[Website] Add articles section with index and article pages, matching customers page design (#20315)

Bare-bone structure for the blog/articles on website.
This commit is contained in:
Abdullah.
2026-05-07 12:54:28 +05:00
committed by GitHub
parent 10876138d2
commit 552016a4d0
30 changed files with 1599 additions and 168 deletions
@@ -0,0 +1,86 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { getPublishedArticle, getPublishedArticles } from '@/lib/articles';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { type LocaleRouteParams } from '@/lib/i18n/get-route-i18n';
import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments';
import { resolveLocaleParam } from '@/lib/i18n';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { buildArticleJsonLd, buildPageMetadata, JsonLd } from '@/lib/seo';
import { Articles } from '@/sections/Articles/components';
import { Menu } from '@/sections/Menu/components';
import { MENU_DATA } from '@/sections/Menu/data';
import { theme } from '@/theme';
type ArticleSlugParams = LocaleRouteParams & { slug: string };
type ArticlePageProps = {
params: Promise<ArticleSlugParams>;
};
export const dynamicParams = false;
export function generateStaticParams(): ArticleSlugParams[] {
return getPublishedArticles().map((post) => ({
locale: localeToUrlSegment(SOURCE_LOCALE),
slug: post.slug,
}));
}
export async function generateMetadata({
params,
}: ArticlePageProps): Promise<Metadata> {
const { locale: rawLocale, slug } = await params;
const locale = resolveLocaleParam(rawLocale);
const post = getPublishedArticle(slug);
if (locale !== SOURCE_LOCALE || !post) {
return { robots: { index: false, follow: false } };
}
return buildPageMetadata({
locale,
path: `/articles/${post.slug}`,
title: {
id: `${post.title} | Twenty Articles`,
message: `${post.title} | Twenty Articles`,
},
description: { id: post.description, message: post.description },
locales: [SOURCE_LOCALE],
type: 'article',
extend: { authors: [{ name: post.author }] },
});
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { locale: rawLocale, slug } = await params;
const locale = resolveLocaleParam(rawLocale);
const post = getPublishedArticle(slug);
if (locale !== SOURCE_LOCALE || !post) {
notFound();
}
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<>
<JsonLd data={buildArticleJsonLd(post)} />
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Articles.Article post={post} />
</>
);
}
@@ -0,0 +1,124 @@
import { notFound } from 'next/navigation';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { HeadingPart } from '@/design-system/components';
import { getPublishedArticles } from '@/lib/articles';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/get-route-i18n';
import { resolveLocaleParam } from '@/lib/i18n';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Pages } from '@/lib/pages';
import { buildArticleListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
import { Articles } from '@/sections/Articles/components';
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { MENU_DATA } from '@/sections/Menu/data';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { css } from '@linaria/core';
export const generateMetadata = buildRouteMetadata('articles');
const ARTICLES_HERO_BODY = {
text: 'Ideas from the team building Twenty on open source CRM, GTM systems, and building software that lasts.',
};
const ARTICLES_TOP_BACKGROUND_COLOR = '#F4F4F4';
const pageRevealClassName = css`
@keyframes articlesPageReveal {
from {
opacity: 0;
transform: translate3d(0, 20px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
background-color: ${ARTICLES_TOP_BACKGROUND_COLOR};
& > * {
animation: articlesPageReveal 720ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: 80ms;
}
@media (prefers-reduced-motion: reduce) {
& > * {
animation: none;
}
}
`;
type ArticlesPageProps = {
params: Promise<LocaleRouteParams>;
};
export default async function ArticlesPage({ params }: ArticlesPageProps) {
const { locale: rawLocale } = await params;
const locale = resolveLocaleParam(rawLocale);
if (locale !== SOURCE_LOCALE) {
notFound();
}
const [i18n, stats] = await Promise.all([
getRouteI18n(params),
fetchCommunityStats(),
]);
const renderText = createMessageDescriptorRenderer(i18n);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
const posts = getPublishedArticles();
return (
<>
{posts.length > 0 ? (
<JsonLd data={buildArticleListJsonLd(posts)} />
) : null}
<Menu.Root
backgroundColor={ARTICLES_TOP_BACKGROUND_COLOR}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<div className={pageRevealClassName}>
<Hero.Root backgroundColor={ARTICLES_TOP_BACKGROUND_COLOR}>
<Hero.Heading page={Pages.Articles}>
<HeadingPart fontFamily="serif">Ideas on</HeadingPart>
<br />
<HeadingPart fontFamily="serif">open-source</HeadingPart>{' '}
<HeadingPart fontFamily="sans">CRM</HeadingPart>
</Hero.Heading>
<Hero.Body body={ARTICLES_HERO_BODY} page={Pages.Articles} />
</Hero.Root>
<TrustedBy.Root
cardBackgroundColor={ARTICLES_TOP_BACKGROUND_COLOR}
compactBottom
>
<TrustedBy.Separator
renderText={renderText}
separator={TRUSTED_BY_DATA.separator}
/>
<TrustedBy.Logos logos={TRUSTED_BY_DATA.logos} />
<TrustedBy.ClientCount
label={TRUSTED_BY_DATA.clientCountLabel.text}
renderText={renderText}
/>
</TrustedBy.Root>
</div>
<Articles.Index posts={posts} />
</>
);
}
+22 -8
View File
@@ -3,7 +3,10 @@ import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { PUBLIC_APP_LOCALE_LIST, localeToUrlSegment } from '@/lib/i18n';
import { getSiteUrl } from '@/lib/seo';
import { getIndexedWebsiteRoutes } from '@/lib/website-routing';
import {
getIndexedWebsiteRoutes,
type WebsiteRoute,
} from '@/lib/website-routing';
const SITE_URL = getSiteUrl();
@@ -14,9 +17,15 @@ const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
return `${SITE_URL}${prefix}${tail}`;
};
const buildLanguageAlternates = (path: string): Record<string, string> => {
const getRouteLocales = (route: WebsiteRoute): readonly AppLocale[] =>
route.localeMode === 'source' ? [SOURCE_LOCALE] : PUBLIC_APP_LOCALE_LIST;
const buildLanguageAlternates = (
path: string,
locales: readonly AppLocale[],
): Record<string, string> => {
const alternates: Record<string, string> = {};
for (const locale of PUBLIC_APP_LOCALE_LIST) {
for (const locale of locales) {
alternates[locale] = buildLocalizedUrl(locale, path);
}
alternates['x-default'] = buildLocalizedUrl(SOURCE_LOCALE, path);
@@ -27,17 +36,22 @@ const localize = (
path: string,
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'],
priority: number,
locales: readonly AppLocale[],
): MetadataRoute.Sitemap =>
PUBLIC_APP_LOCALE_LIST.map((locale) => ({
locales.map((locale) => ({
url: buildLocalizedUrl(locale, path),
changeFrequency,
priority,
alternates: { languages: buildLanguageAlternates(path) },
alternates: { languages: buildLanguageAlternates(path, locales) },
}));
export default function sitemap(): MetadataRoute.Sitemap {
return getIndexedWebsiteRoutes().flatMap(
({ path, changeFrequency, priority }) =>
localize(path, changeFrequency, priority),
return getIndexedWebsiteRoutes().flatMap((route) =>
localize(
route.path,
route.changeFrequency,
route.priority,
getRouteLocales(route),
),
);
}
@@ -0,0 +1 @@
@@ -0,0 +1,164 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function resolveAssetUrl(url: string | undefined): string {
if (!url) {
return '';
}
if (url.startsWith('/')) {
return url;
}
return url;
}
const Prose = styled.div`
max-width: 100%;
min-width: 0;
h1 {
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(8)};
font-weight: ${theme.font.weight.medium};
line-height: 1.25;
margin: 0;
margin-top: ${theme.spacing(8)};
&:first-child {
margin-top: 0;
}
}
h2 {
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(6)};
font-weight: ${theme.font.weight.medium};
line-height: 1.3;
margin: 0;
margin-top: ${theme.spacing(8)};
&:first-child {
margin-top: 0;
}
}
h3 {
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(5)};
font-weight: ${theme.font.weight.medium};
line-height: 1.35;
margin: 0;
margin-top: ${theme.spacing(6)};
}
p {
color: ${theme.colors.primary.text[80]};
font-size: ${theme.font.size(4)};
line-height: 1.65;
margin: 0;
margin-top: ${theme.spacing(4)};
}
ul,
ol {
color: ${theme.colors.primary.text[80]};
font-size: ${theme.font.size(4)};
line-height: 1.65;
margin: ${theme.spacing(4)} 0 0;
padding-left: ${theme.spacing(6)};
}
li {
margin-top: ${theme.spacing(2)};
}
a {
color: ${theme.colors.highlight[100]};
text-decoration: underline;
text-underline-offset: 2px;
}
code {
background-color: ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(1)};
font-family: ${theme.font.family.mono};
font-size: 0.9em;
padding: 0.1em 0.35em;
}
pre {
background-color: ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
margin-top: ${theme.spacing(4)};
overflow: auto;
padding: ${theme.spacing(4)};
}
pre code {
background: none;
padding: 0;
}
img {
border-radius: ${theme.radius(2)};
display: block;
height: auto;
margin-top: ${theme.spacing(6)};
max-width: min(100%, 720px);
width: auto;
}
hr {
border: none;
border-top: 1px solid ${theme.colors.primary.border[20]};
margin: ${theme.spacing(8)} 0 0;
}
blockquote {
border-left: 2px solid ${theme.colors.primary.border[40]};
color: ${theme.colors.primary.text[60]};
margin: ${theme.spacing(4)} 0 0;
padding-left: ${theme.spacing(4)};
}
`;
type MarkdownProseProps = {
markdown: string;
};
export function MarkdownProse({ markdown }: MarkdownProseProps) {
return (
<Prose>
<ReactMarkdown
components={{
a: ({ children, href, ...props }) => (
<a
href={resolveAssetUrl(href)}
rel="noopener noreferrer"
// oxlint-disable-next-line react/jsx-props-no-spreading -- ReactMarkdown component overrides
{...props}
>
{children}
</a>
),
img: ({ alt, src, ...props }) => (
<img
alt={alt ?? ''}
decoding="async"
loading="lazy"
src={resolveAssetUrl(src)}
// oxlint-disable-next-line react/jsx-props-no-spreading -- ReactMarkdown component overrides
{...props}
/>
),
}}
remarkPlugins={[remarkGfm]}
>
{markdown}
</ReactMarkdown>
</Prose>
);
}
@@ -18,6 +18,7 @@ export type {
StackAlign,
StackProps,
} from './Layout';
export { MarkdownProse } from './MarkdownProse';
export { Modal } from './Modal';
export type { ModalRootProps } from './Modal';
export { StepperProgressRail } from './StepperProgressRail';
@@ -0,0 +1,142 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { loadLocalArticlesFromDirectory } from '../load-local-articles';
function createTempDirectory(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'twenty-articles-'));
}
function writePost(directoryPath: string, fileName: string, content: string) {
fs.writeFileSync(path.join(directoryPath, fileName), content);
}
describe('loadLocalArticlesFromDirectory', () => {
it('loads valid posts sorted by date descending', () => {
const directoryPath = createTempDirectory();
writePost(
directoryPath,
'older-post.mdx',
`---
title: Older post
description: Older description
date: 2026-01-01
tags:
- CRM
---
Older body.`,
);
writePost(
directoryPath,
'newer-post.mdx',
`---
title: Newer post
description: Newer description
date: 2026-02-01
author: Twenty Team
---
Newer body.`,
);
const posts = loadLocalArticlesFromDirectory(directoryPath);
expect(posts.map((post) => post.slug)).toEqual([
'newer-post',
'older-post',
]);
expect(posts[0]).toMatchObject({
author: 'Twenty Team',
date: '2026-02-01',
description: 'Newer description',
draft: false,
title: 'Newer post',
});
expect(posts[1].tags).toEqual(['CRM']);
});
it('trims tag strings after validating', () => {
const directoryPath = createTempDirectory();
writePost(
directoryPath,
'tagged-post.mdx',
`---
title: Tagged post
description: Tagged description
date: 2026-03-01
tags:
- " CRM "
- product
---
Body.`,
);
const posts = loadLocalArticlesFromDirectory(directoryPath);
expect(posts[0].tags).toEqual(['CRM', 'product']);
});
it('fails loudly when required frontmatter is missing', () => {
const directoryPath = createTempDirectory();
writePost(
directoryPath,
'broken-post.mdx',
`---
title: Broken post
date: 2026-01-01
---
Body.`,
);
expect(() => loadLocalArticlesFromDirectory(directoryPath)).toThrow(
'missing "description"',
);
});
it('rejects non-kebab-case slugs', () => {
const directoryPath = createTempDirectory();
writePost(
directoryPath,
'Invalid Slug.mdx',
`---
title: Invalid post
description: Invalid description
date: 2026-01-01
---
Body.`,
);
expect(() => loadLocalArticlesFromDirectory(directoryPath)).toThrow(
'Use lowercase kebab-case',
);
});
it('rejects invalid dates', () => {
const directoryPath = createTempDirectory();
writePost(
directoryPath,
'invalid-date.mdx',
`---
title: Invalid date
description: Invalid date description
date: not-a-date
---
Body.`,
);
expect(() => loadLocalArticlesFromDirectory(directoryPath)).toThrow(
'has invalid date',
);
});
});
@@ -0,0 +1,13 @@
export function formatArticleDate(dateString: string): string {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid article date: ${dateString}`);
}
return new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(date);
}
@@ -0,0 +1,8 @@
export { formatArticleDate } from './format-article-date';
export {
getPublishedArticle,
getPublishedArticles,
loadLocalArticles,
loadLocalArticlesFromDirectory,
} from './load-local-articles';
export type { Article } from './types';
@@ -0,0 +1,177 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import type { Article } from './types';
const WORDS_PER_MINUTE = 220;
type ArticleFrontmatter = {
author?: unknown;
date?: unknown;
description?: unknown;
draft?: unknown;
slug?: unknown;
tags?: unknown;
title?: unknown;
};
const ARTICLES_DIRECTORY_CANDIDATES = [
path.join(process.cwd(), 'src', 'content', 'articles'),
path.join(
process.cwd(),
'packages',
'twenty-website-new',
'src',
'content',
'articles',
),
];
function resolveArticlesDirectory(): string | null {
for (const directoryPath of ARTICLES_DIRECTORY_CANDIDATES) {
if (fs.existsSync(directoryPath)) {
return directoryPath;
}
}
return null;
}
function normalizeDate(value: unknown, fileName: string): string {
if (typeof value === 'string' && value.trim()) {
if (Number.isNaN(new Date(value).getTime())) {
throw new Error(`Article "${fileName}" has invalid date "${value}".`);
}
return value;
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().slice(0, 10);
}
throw new Error(`Article "${fileName}" is missing a valid date.`);
}
function requireString(
value: unknown,
fieldName: keyof Pick<Article, 'description' | 'title'>,
fileName: string,
): string {
if (typeof value === 'string' && value.trim()) {
return value;
}
throw new Error(`Article "${fileName}" is missing "${fieldName}".`);
}
function normalizeTags(value: unknown, fileName: string): readonly string[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new Error(`Article "${fileName}" has invalid "tags".`);
}
if (!value.every((tag) => typeof tag === 'string' && tag.trim())) {
throw new Error(`Article "${fileName}" has invalid "tags".`);
}
return value.map((tag) => {
if (typeof tag !== 'string') {
throw new Error(`Article "${fileName}" has invalid "tags".`);
}
return tag.trim();
});
}
function normalizeSlug(value: unknown, fileName: string): string {
const slug =
typeof value === 'string' && value.trim()
? value
: fileName.replace(/\.mdx?$/i, '');
if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
return slug;
}
throw new Error(
`Article "${fileName}" has invalid slug "${slug}". Use lowercase kebab-case.`,
);
}
function calculateReadingTimeMinutes(content: string): number {
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
}
function parseArticle(fileName: string, raw: string): Article {
const { content, data } = matter(raw);
const frontmatter = data as ArticleFrontmatter;
return {
author:
typeof frontmatter.author === 'string' && frontmatter.author.trim()
? frontmatter.author
: 'Twenty',
content,
date: normalizeDate(frontmatter.date, fileName),
description: requireString(
frontmatter.description,
'description',
fileName,
),
draft: frontmatter.draft === true,
readingTimeMinutes: calculateReadingTimeMinutes(content),
slug: normalizeSlug(frontmatter.slug, fileName),
tags: normalizeTags(frontmatter.tags, fileName),
title: requireString(frontmatter.title, 'title', fileName),
};
}
export function loadLocalArticlesFromDirectory(
directoryPath: string,
): Article[] {
const articles = fs
.readdirSync(directoryPath)
.filter((fileName) => fileName.endsWith('.md') || fileName.endsWith('.mdx'))
.map((fileName) => {
const raw = fs.readFileSync(path.join(directoryPath, fileName), 'utf-8');
return parseArticle(fileName, raw);
});
articles.sort((article, other) => other.date.localeCompare(article.date));
return articles;
}
let cachedArticles: Article[] | null = null;
export function loadLocalArticles(): Article[] {
if (cachedArticles !== null) {
return cachedArticles;
}
const directoryPath = resolveArticlesDirectory();
cachedArticles = directoryPath
? loadLocalArticlesFromDirectory(directoryPath)
: [];
return cachedArticles;
}
export function getPublishedArticles(): Article[] {
return loadLocalArticles().filter((article) => !article.draft);
}
export function getPublishedArticle(slug: string): Article | null {
return (
getPublishedArticles().find((article) => article.slug === slug) ?? null
);
}
@@ -0,0 +1,11 @@
export type Article = {
author: string;
content: string;
date: string;
description: string;
draft: boolean;
readingTimeMinutes: number;
slug: string;
tags: readonly string[];
title: string;
};
@@ -1,4 +1,5 @@
export const Pages = {
Articles: 'articles',
CaseStudies: 'caseStudies',
ReleaseNotes: 'releaseNotes',
Home: 'home',
@@ -1,4 +1,6 @@
import {
buildArticleListJsonLd,
buildArticleJsonLd,
buildFaqPageJsonLd,
buildOrganizationJsonLd,
buildReleaseListJsonLd,
@@ -166,3 +168,62 @@ describe('buildReleaseListJsonLd', () => {
expect(data.itemListElement[0].item).not.toHaveProperty('datePublished');
});
});
describe('articles JSON-LD', () => {
const post = {
author: 'Twenty',
content: 'Body',
date: '2026-05-06',
description: 'A practical CRM article.',
draft: false,
readingTimeMinutes: 3,
slug: 'practical-crm-article',
tags: ['CRM'],
title: 'Practical CRM Article',
};
it('builds a Blog schema for the articles index', () => {
const data = buildArticleListJsonLd([post]) as {
'@type': string;
blogPost: Array<Record<string, unknown>>;
};
expect(data).toMatchObject({
'@type': 'Blog',
name: 'Twenty Articles',
url: 'https://example.test/articles',
});
expect(data.blogPost[0]).toMatchObject({
'@type': 'BlogPosting',
headline: 'Practical CRM Article',
url: 'https://example.test/articles/practical-crm-article',
datePublished: '2026-05-06',
author: {
'@type': 'Organization',
name: 'Twenty',
},
});
});
it('builds a BlogPosting schema for an article page', () => {
const data = buildArticleJsonLd(post);
expect(data).toMatchObject({
'@type': 'BlogPosting',
headline: 'Practical CRM Article',
description: 'A practical CRM article.',
url: 'https://example.test/articles/practical-crm-article',
mainEntityOfPage: 'https://example.test/articles/practical-crm-article',
datePublished: '2026-05-06',
author: {
'@type': 'Organization',
name: 'Twenty',
},
publisher: {
'@type': 'Organization',
name: 'Twenty',
url: 'https://example.test',
},
});
});
});
@@ -20,6 +20,7 @@ export type BuildPageMetadataInput = {
path: string;
title: MessageDescriptor;
description: MessageDescriptor;
locales?: readonly AppLocale[];
ogImage?: string;
type?: 'website' | 'article';
extend?: Metadata;
@@ -40,9 +41,10 @@ const localizePath = (locale: AppLocale, normalizedPath: string): string => {
const buildLanguageAlternates = (
normalizedPath: string,
locales: readonly AppLocale[],
): Record<string, string> => {
const languages: Record<string, string> = {};
for (const locale of PUBLIC_APP_LOCALE_LIST) {
for (const locale of locales) {
languages[locale] = localizePath(locale, normalizedPath);
}
languages['x-default'] = localizePath(SOURCE_LOCALE, normalizedPath);
@@ -54,6 +56,7 @@ export function buildPageMetadata({
path,
title,
description,
locales = PUBLIC_APP_LOCALE_LIST,
ogImage,
type = 'website',
extend,
@@ -83,7 +86,7 @@ export function buildPageMetadata({
description: resolvedDescription,
alternates: {
canonical,
languages: buildLanguageAlternates(normalizedPath),
languages: buildLanguageAlternates(normalizedPath, locales),
},
openGraph: {
title: resolvedTitle,
@@ -1,4 +1,5 @@
import { WEBSITE_ROUTES, type WebsiteRouteId } from '@/lib/website-routing';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { buildLocalizedMetadata } from './build-localized-metadata';
import type { BuildPageMetadataInput } from './build-page-metadata';
@@ -21,6 +22,7 @@ export const buildRouteMetadata = (
path: route.path,
title: route.title,
description: route.description,
locales: route.localeMode === 'source' ? [SOURCE_LOCALE] : undefined,
...options,
});
};
@@ -5,6 +5,8 @@ export {
} from './build-page-metadata';
export { buildRouteMetadata } from './build-route-metadata';
export {
buildArticleListJsonLd,
buildArticleJsonLd,
buildFaqPageJsonLd,
buildOrganizationJsonLd,
buildReleaseListJsonLd,
@@ -1,5 +1,6 @@
import type { MessageDescriptor } from '@lingui/core';
import type { Article } from '@/lib/articles';
import type { LocalReleaseNote } from '@/lib/releases/types';
import { getSiteUrl } from './site-url';
@@ -135,3 +136,57 @@ export const buildReleaseListJsonLd = (
})),
};
};
export const buildArticleListJsonLd = (
posts: readonly Article[],
): JsonLdValue => {
const siteUrl = getSiteUrl();
const articlesUrl = `${siteUrl}/articles`;
return {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Twenty Articles',
url: articlesUrl,
blogPost: posts.map((post) => ({
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
url: `${articlesUrl}/${post.slug}`,
datePublished: post.date,
author: {
'@type': 'Organization',
name: post.author,
},
publisher: {
'@type': 'Organization',
name: 'Twenty',
url: siteUrl,
},
})),
};
};
export const buildArticleJsonLd = (post: Article): JsonLdValue => {
const siteUrl = getSiteUrl();
const postUrl = `${siteUrl}/articles/${post.slug}`;
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
url: postUrl,
mainEntityOfPage: postUrl,
datePublished: post.date,
author: {
'@type': 'Organization',
name: post.author,
},
publisher: {
'@type': 'Organization',
name: 'Twenty',
url: siteUrl,
},
};
};
@@ -0,0 +1,39 @@
import { getPublishedArticles } from '@/lib/articles';
import type { WebsiteRoute } from './types';
const descriptorFromText = (message: string) => ({ id: message, message });
const publishedArticles = getPublishedArticles();
export const ARTICLE_ROUTES: readonly WebsiteRoute[] =
publishedArticles.length === 0
? []
: [
{
id: 'articles',
path: '/articles',
title: descriptorFromText(
'Twenty Articles | Open Source CRM Insights',
),
description: descriptorFromText(
'Ideas from the team building Twenty on open source CRM, customer data, GTM systems, and building software that lasts.',
),
changeFrequency: 'weekly',
priority: 0.8,
indexed: true,
localeMode: 'source',
},
...publishedArticles.map(
(article): WebsiteRoute => ({
id: `articles:${article.slug}`,
path: `/articles/${article.slug}`,
title: descriptorFromText(`${article.title} | Twenty Articles`),
description: descriptorFromText(article.description),
changeFrequency: 'monthly' as const,
priority: 0.6,
indexed: true,
localeMode: 'source' as const,
}),
),
];
@@ -1,3 +1,4 @@
export { ARTICLE_ROUTES } from './article-routes';
export { CUSTOMER_STORY_ROUTES } from './customer-story-routes';
export { STATIC_WEBSITE_ROUTES } from './static-website-routes';
export type { WebsiteRoute, WebsiteRouteId } from './types';
@@ -66,6 +66,16 @@ export const STATIC_WEBSITE_ROUTES = [
priority: 0.7,
indexed: true,
},
{
id: 'articles',
path: '/articles',
title: msg`Twenty Articles — Open Source CRM Insights`,
description: msg`Ideas from the team building Twenty on open source CRM, customer data, GTM systems, and building software that lasts.`,
changeFrequency: 'daily',
priority: 0.8,
indexed: true,
localeMode: 'source',
},
{
id: 'privacyPolicy',
path: '/privacy-policy',
@@ -10,10 +10,12 @@ export type WebsiteRouteId =
| 'partners'
| 'releases'
| 'customers'
| 'articles'
| 'privacyPolicy'
| 'terms'
| 'halftone'
| 'enterpriseActivate'
| `articles:${string}`
| `customer:${string}`;
export type WebsiteRoute = {
@@ -21,6 +23,7 @@ export type WebsiteRoute = {
description: MessageDescriptor;
id: WebsiteRouteId;
indexed: boolean;
localeMode?: 'all' | 'source';
path: string;
priority: number;
robotsDisallow?: boolean;
@@ -1,3 +1,4 @@
import { ARTICLE_ROUTES } from './article-routes';
import { CUSTOMER_STORY_ROUTES } from './customer-story-routes';
import { STATIC_WEBSITE_ROUTES } from './static-website-routes';
import type { WebsiteRoute } from './types';
@@ -5,4 +6,5 @@ import type { WebsiteRoute } from './types';
export const WEBSITE_ROUTE_LIST: readonly WebsiteRoute[] = [
...STATIC_WEBSITE_ROUTES,
...CUSTOMER_STORY_ROUTES,
...ARTICLE_ROUTES,
];
@@ -0,0 +1,121 @@
import Link from 'next/link';
import { styled } from '@linaria/react';
import { Container, MarkdownProse } from '@/design-system/components';
import { formatArticleDate, type Article as ArticleData } from '@/lib/articles';
import { theme } from '@/theme';
import { Root } from './Root';
const ArticleEl = styled.article``;
const StyledContainer = styled(Container)`
max-width: 860px;
padding: ${theme.spacing(20)} ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
padding: ${theme.spacing(28)} ${theme.spacing(10)};
}
`;
const BackLink = styled(Link)`
color: ${theme.colors.primary.text[60]};
display: inline-flex;
font-size: ${theme.font.size(3)};
margin-bottom: ${theme.spacing(8)};
text-decoration: none;
&:hover {
color: ${theme.colors.primary.text[100]};
}
`;
const Header = styled.header`
display: grid;
margin-bottom: ${theme.spacing(10)};
row-gap: ${theme.spacing(4)};
`;
const Meta = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.04em;
margin: 0;
text-transform: uppercase;
`;
const Title = styled.h1`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.serif};
font-size: ${theme.font.size(10)};
font-weight: ${theme.font.weight.light};
line-height: ${theme.lineHeight(11.5)};
margin: 0;
@media (min-width: ${theme.breakpoints.md}px) {
font-size: ${theme.font.size(15)};
line-height: ${theme.lineHeight(16.5)};
}
`;
const Description = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4.5)};
line-height: ${theme.lineHeight(6.5)};
margin: 0;
`;
const Tags = styled.ul`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(2)};
list-style: none;
margin: 0;
padding: 0;
`;
const Tag = styled.li`
border: 1px solid ${theme.colors.primary.border[20]};
border-radius: 999px;
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(2.5)};
letter-spacing: 0.06em;
padding: ${theme.spacing(1)} ${theme.spacing(2.5)};
text-transform: uppercase;
`;
type ArticleProps = {
post: ArticleData;
};
export function Article({ post }: ArticleProps) {
return (
<Root variant="article">
<ArticleEl>
<StyledContainer>
<BackLink href="/articles"> Back to articles</BackLink>
<Header>
<Meta>
{formatArticleDate(post.date)} · {post.readingTimeMinutes} min
read · {post.author}
</Meta>
<Title>{post.title}</Title>
<Description>{post.description}</Description>
{post.tags.length > 0 ? (
<Tags>
{post.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Tags>
) : null}
</Header>
<MarkdownProse markdown={post.content} />
</StyledContainer>
</ArticleEl>
</Root>
);
}
@@ -0,0 +1,488 @@
import Link from 'next/link';
import { styled } from '@linaria/react';
import type { CSSProperties } from 'react';
import { ArrowRightIcon, PlusIcon } from '@/icons';
import { formatArticleDate, type Article as ArticleData } from '@/lib/articles';
import { theme } from '@/theme';
import { Container } from '@/design-system/components';
type ArticleCardStyle = CSSProperties & { '--article-card-index': number };
const CORNER_SIZE = 14;
const CORNER_OFFSET = '-7px';
const Section = styled.section`
background-color: #f4f4f4;
min-width: 0;
width: 100%;
`;
const StyledContainer = styled(Container)`
display: flex;
flex-direction: column;
gap: ${theme.spacing(10)};
padding-bottom: ${theme.spacing(36)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(20)};
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(14)};
padding-bottom: ${theme.spacing(44)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(24)};
}
`;
const FramedGrid = styled.div`
position: relative;
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(12)};
padding-left: ${theme.spacing(12)};
padding-right: ${theme.spacing(12)};
}
@media (min-width: ${theme.breakpoints.lg}px) {
padding-bottom: ${theme.spacing(20)};
padding-left: ${theme.spacing(20)};
padding-right: ${theme.spacing(20)};
}
`;
const FrameRail = styled.span`
background-color: ${theme.colors.primary.border[10]};
bottom: 0;
display: none;
position: absolute;
top: ${`calc(6px - ${theme.spacing(24)})`};
width: 1px;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
}
`;
const FrameRailLeft = styled(FrameRail)`
left: 0;
`;
const FrameRailRight = styled(FrameRail)`
right: 0;
`;
const FrameRailBottom = styled.span`
background-color: ${theme.colors.primary.border[10]};
bottom: 0;
display: none;
height: 1px;
left: 0;
position: absolute;
right: 0;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
}
`;
const FrameCorner = styled.span`
align-items: center;
display: none;
height: ${CORNER_SIZE}px;
justify-content: center;
line-height: 0;
pointer-events: none;
position: absolute;
width: ${CORNER_SIZE}px;
@media (min-width: ${theme.breakpoints.md}px) {
display: flex;
}
`;
const FrameCornerBottomLeft = styled(FrameCorner)`
bottom: ${CORNER_OFFSET};
left: ${CORNER_OFFSET};
`;
const FrameCornerBottomRight = styled(FrameCorner)`
bottom: ${CORNER_OFFSET};
right: ${CORNER_OFFSET};
`;
const CardGrid = styled.div`
display: grid;
gap: ${theme.spacing(8)};
grid-template-columns: 1fr;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(10)};
grid-template-columns: repeat(2, 1fr);
}
`;
const CardLink = styled(Link)<{ $isLarge: boolean }>`
@keyframes articleCardEnter {
from {
opacity: 0;
transform: translate3d(0, 18px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
animation: articleCardEnter 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--article-card-index) * 90ms + 180ms);
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
color: inherit;
display: flex;
flex-direction: column;
grid-column: ${({ $isLarge }) => ($isLarge ? '1 / -1' : 'auto')};
overflow: hidden;
text-decoration: none;
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
transform 0.25s ease;
will-change: transform;
@media (min-width: ${theme.breakpoints.md}px) {
flex-direction: ${({ $isLarge }) => ($isLarge ? 'row' : 'column')};
}
&:hover {
border-color: ${theme.colors.primary.border[20]};
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
transform: translateY(-2px);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
&:hover {
transform: none;
}
}
`;
const Thumbnail = styled.div<{ $isLarge: boolean }>`
background: linear-gradient(135deg, #f4f2ec 0%, #e8e3d8 100%);
flex-shrink: 0;
height: 200px;
overflow: hidden;
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
height: ${({ $isLarge }) => ($isLarge ? 'auto' : '240px')};
min-height: ${({ $isLarge }) => ($isLarge ? '360px' : '0')};
width: ${({ $isLarge }) => ($isLarge ? '50%' : '100%')};
}
`;
const ThumbnailBadge = styled.span`
bottom: ${theme.spacing(4)};
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
left: ${theme.spacing(6)};
letter-spacing: 0.08em;
position: absolute;
text-transform: uppercase;
z-index: 2;
@media (min-width: ${theme.breakpoints.md}px) {
bottom: ${theme.spacing(6)};
left: ${theme.spacing(8)};
}
`;
const ContentWrapper = styled.div<{ $isLarge: boolean }>`
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
padding: ${({ $isLarge }) =>
$isLarge
? `${theme.spacing(10)} ${theme.spacing(6)} ${theme.spacing(5)}`
: '0'};
}
`;
const CardBody = styled.div<{ $isLarge: boolean }>`
display: flex;
flex: 1;
flex-direction: column;
gap: ${theme.spacing(3)};
padding: ${theme.spacing(5)} ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${({ $isLarge }) => ($isLarge ? theme.spacing(5) : theme.spacing(3))};
padding: ${({ $isLarge }) =>
$isLarge ? '0' : `${theme.spacing(5)} ${theme.spacing(6)}`};
}
`;
const DateLabel = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0.08em;
text-transform: uppercase;
`;
const Title = styled.h2<{ $isLarge: boolean }>`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.serif};
font-size: ${theme.font.size(6)};
font-weight: ${theme.font.weight.light};
line-height: ${theme.lineHeight(7.5)};
margin: 0;
@media (min-width: ${theme.breakpoints.md}px) {
font-size: ${({ $isLarge }) =>
$isLarge ? theme.font.size(8) : theme.font.size(6.5)};
line-height: ${({ $isLarge }) =>
$isLarge ? theme.lineHeight(9.5) : theme.lineHeight(8)};
}
`;
const Description = styled.p`
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: ${theme.colors.primary.text[60]};
display: -webkit-box;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
font-weight: ${theme.font.weight.regular};
line-height: ${theme.lineHeight(5.5)};
margin: 0;
overflow: hidden;
`;
const TagRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(2)};
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(6)};
padding-right: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(5)};
}
`;
const Tag = styled.span`
align-items: center;
background-color: ${theme.colors.primary.text[5]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: 999px;
color: ${theme.colors.primary.text[60]};
display: inline-flex;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(2.5)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0.08em;
padding: ${theme.spacing(1.5)} ${theme.spacing(3.5)};
text-transform: uppercase;
white-space: nowrap;
`;
const CardFooter = styled.div<{ $isLarge: boolean }>`
align-items: center;
border-top: 1px solid ${theme.colors.primary.border[10]};
display: flex;
gap: ${theme.spacing(3)};
justify-content: space-between;
margin-left: ${theme.spacing(6)};
margin-right: ${theme.spacing(6)};
padding-bottom: ${theme.spacing(5)};
padding-top: ${theme.spacing(5)};
@media (min-width: ${theme.breakpoints.md}px) {
margin-left: ${({ $isLarge }) => ($isLarge ? '0' : theme.spacing(6))};
margin-right: ${({ $isLarge }) => ($isLarge ? '0' : theme.spacing(6))};
padding-top: ${({ $isLarge }) =>
$isLarge ? theme.spacing(6) : theme.spacing(5)};
}
`;
const AuthorGroup = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${theme.spacing(3)};
min-width: 0;
`;
const AuthorAvatar = styled.div`
align-items: center;
background-color: ${theme.colors.highlight[100]};
border-radius: 50%;
color: ${theme.colors.secondary.text[100]};
display: flex;
flex-shrink: 0;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
height: 32px;
justify-content: center;
width: 32px;
`;
const AuthorText = styled.div`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3.5)};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ReadIconButton = styled.span`
align-items: center;
border: 1px solid ${theme.colors.primary.border[20]};
border-radius: ${theme.radius(2)};
color: ${theme.colors.primary.text[80]};
display: inline-flex;
flex-shrink: 0;
height: 40px;
justify-content: center;
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
width: 40px;
a:hover & {
transform: scale(1.08);
}
`;
const EmptyState = styled.div`
background: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
max-width: 720px;
padding: ${theme.spacing(6)};
`;
const EmptyStateTitle = styled.p`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4.5)};
font-weight: ${theme.font.weight.medium};
margin: 0;
`;
type ArticleCardProps = {
index: number;
isLarge: boolean;
post: ArticleData;
};
function ArticleCard({ index, isLarge, post }: ArticleCardProps) {
const initials = post.author
.split(' ')
.map((word) => word[0])
.join('')
.slice(0, 2);
const cardStyle: ArticleCardStyle = { '--article-card-index': index };
return (
<CardLink
$isLarge={isLarge}
href={`/articles/${post.slug}`}
style={cardStyle}
>
<Thumbnail $isLarge={isLarge}>
<ThumbnailBadge>
Article · {post.readingTimeMinutes} min read
</ThumbnailBadge>
</Thumbnail>
<ContentWrapper $isLarge={isLarge}>
<CardBody $isLarge={isLarge}>
<DateLabel>{formatArticleDate(post.date)}</DateLabel>
<Title $isLarge={isLarge}>{post.title}</Title>
<Description>{post.description}</Description>
</CardBody>
{post.tags.length > 0 ? (
<TagRow>
{post.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</TagRow>
) : null}
<CardFooter $isLarge={isLarge}>
<AuthorGroup>
<AuthorAvatar>{initials}</AuthorAvatar>
<AuthorText>{post.author}</AuthorText>
</AuthorGroup>
<ReadIconButton aria-hidden>
<ArrowRightIcon size={14} strokeColor="currentColor" />
</ReadIconButton>
</CardFooter>
</ContentWrapper>
</CardLink>
);
}
type ArticleIndexProps = {
posts: readonly ArticleData[];
};
export function ArticleIndex({ posts }: ArticleIndexProps) {
const lastIndex = posts.length - 1;
return (
<Section>
<StyledContainer>
<FramedGrid>
<FrameRailLeft aria-hidden />
<FrameRailRight aria-hidden />
<FrameRailBottom aria-hidden />
<FrameCornerBottomLeft aria-hidden>
<PlusIcon
size={CORNER_SIZE}
strokeColor={theme.colors.highlight[100]}
/>
</FrameCornerBottomLeft>
<FrameCornerBottomRight aria-hidden>
<PlusIcon
size={CORNER_SIZE}
strokeColor={theme.colors.highlight[100]}
/>
</FrameCornerBottomRight>
{posts.length === 0 ? (
<EmptyState>
<EmptyStateTitle>No posts published yet.</EmptyStateTitle>
</EmptyState>
) : (
<CardGrid>
{posts.map((post, index) => (
<ArticleCard
index={index}
isLarge={index === 0 || index === lastIndex}
key={post.slug}
post={post}
/>
))}
</CardGrid>
)}
</FramedGrid>
</StyledContainer>
</Section>
);
}
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { styled } from '@linaria/react';
import { theme } from '@/theme';
type RootVariant = 'article' | 'index';
type RootProps = {
children: ReactNode;
variant?: RootVariant;
};
const RootSection = styled.section`
color: ${theme.colors.primary.text[100]};
&[data-variant='index'] {
background: radial-gradient(
circle at 14% 8%,
rgba(255, 255, 255, 0.76),
transparent 30%
),
linear-gradient(180deg, #f4f2ec 0%, #ffffff 44%, #f3f3f3 100%);
}
&[data-variant='article'] {
background: linear-gradient(180deg, #f4f2ec 0%, #ffffff 360px);
}
`;
export function Root({ children, variant = 'index' }: RootProps) {
return <RootSection data-variant={variant}>{children}</RootSection>;
}
@@ -0,0 +1,7 @@
import { Article } from './Article';
import { ArticleIndex } from './ArticleIndex';
export const Articles = {
Article,
Index: ArticleIndex,
};
@@ -15,6 +15,7 @@ export const FOOTER_DATA: FooterDataType = {
{ label: msg`Pricing`, href: '/pricing', external: false },
{ label: msg`Partners`, href: '/partners', external: false },
{ label: msg`Why Twenty`, href: '/why-twenty', external: false },
{ label: msg`Articles`, href: '/articles', external: false },
],
},
{
@@ -56,6 +56,10 @@ const StyledBody = styled.div`
white-space: pre-line;
}
&[data-page='articles'] {
max-width: 550px;
}
&[data-page='caseStudies'] {
max-width: 550px;
}
@@ -1,4 +1,5 @@
import { msg } from '@lingui/core/macro';
import { getPublishedArticles } from '@/lib/articles';
import { getLatestReleasePreview } from '@/lib/releases/get-latest-release-preview';
import type {
MenuDataType,
@@ -15,6 +16,8 @@ const FALLBACK_RELEASES_PREVIEW: MenuNavChildPreview = {
description: msg`Track every release with changelogs, highlights and demos of the newest features.`,
};
const HAS_PUBLISHED_ARTICLES = getPublishedArticles().length > 0;
function buildNavItems(): MenuNavItemType[] {
const releasesPreview =
getLatestReleasePreview() ?? FALLBACK_RELEASES_PREVIEW;
@@ -72,6 +75,16 @@ function buildNavItems(): MenuNavItemType[] {
icon: 'tag',
preview: releasesPreview,
},
...(HAS_PUBLISHED_ARTICLES
? [
{
label: msg`Articles`,
description: msg`Read Twenty insights`,
href: '/articles',
icon: 'book' as const,
},
]
: []),
],
},
{ label: msg`Customers`, href: '/customers' },
@@ -1,165 +1,9 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function resolveAssetUrl(url: string | undefined): string {
if (!url) {
return '';
}
if (url.startsWith('/')) {
return url;
}
return url;
}
const Prose = styled.div`
max-width: 100%;
min-width: 0;
h1 {
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(8)};
font-weight: ${theme.font.weight.medium};
line-height: 1.25;
margin: 0;
margin-top: ${theme.spacing(8)};
&:first-child {
margin-top: 0;
}
}
h2 {
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(6)};
font-weight: ${theme.font.weight.medium};
line-height: 1.3;
margin: 0;
margin-top: ${theme.spacing(8)};
&:first-child {
margin-top: 0;
}
}
h3 {
color: ${theme.colors.primary.text[100]};
font-size: ${theme.font.size(5)};
font-weight: ${theme.font.weight.medium};
line-height: 1.35;
margin: 0;
margin-top: ${theme.spacing(6)};
}
p {
color: ${theme.colors.primary.text[80]};
font-size: ${theme.font.size(4)};
line-height: 1.65;
margin: 0;
margin-top: ${theme.spacing(4)};
}
ul,
ol {
color: ${theme.colors.primary.text[80]};
font-size: ${theme.font.size(4)};
line-height: 1.65;
margin: ${theme.spacing(4)} 0 0;
padding-left: ${theme.spacing(6)};
}
li {
margin-top: ${theme.spacing(2)};
}
a {
color: ${theme.colors.highlight[100]};
text-decoration: underline;
text-underline-offset: 2px;
}
code {
background-color: ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(1)};
font-family: ${theme.font.family.mono};
font-size: 0.9em;
padding: 0.1em 0.35em;
}
pre {
background-color: ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
margin-top: ${theme.spacing(4)};
overflow: auto;
padding: ${theme.spacing(4)};
}
pre code {
background: none;
padding: 0;
}
img {
border-radius: ${theme.radius(2)};
display: block;
height: auto;
margin-top: ${theme.spacing(6)};
max-width: min(100%, 720px);
width: auto;
}
hr {
border: none;
border-top: 1px solid ${theme.colors.primary.border[20]};
margin: ${theme.spacing(8)} 0 0;
}
blockquote {
border-left: 2px solid ${theme.colors.primary.border[40]};
color: ${theme.colors.primary.text[60]};
margin: ${theme.spacing(4)} 0 0;
padding-left: ${theme.spacing(4)};
}
`;
import { MarkdownProse } from '@/design-system/components/MarkdownProse';
type ReleaseMarkdownProps = {
markdown: string;
};
export function ReleaseMarkdown({ markdown }: ReleaseMarkdownProps) {
return (
<Prose>
<ReactMarkdown
components={{
a: ({ children, href, ...props }) => (
<a
href={resolveAssetUrl(href)}
rel="noopener noreferrer"
// oxlint-disable-next-line react/jsx-props-no-spreading -- ReactMarkdown component overrides
{...props}
>
{children}
</a>
),
img: ({ alt, src, ...props }) => (
<img
alt={alt ?? ''}
decoding="async"
loading="lazy"
src={resolveAssetUrl(src)}
// oxlint-disable-next-line react/jsx-props-no-spreading -- ReactMarkdown component overrides
{...props}
/>
),
}}
remarkPlugins={[remarkGfm]}
urlTransform={(value) => value}
>
{markdown}
</ReactMarkdown>
</Prose>
);
return <MarkdownProse markdown={markdown} />;
}