[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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
+2
-158
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user