From 552016a4d079c24a3fd606e62f8c35c8f7939636 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Thu, 7 May 2026 12:54:28 +0500 Subject: [PATCH] [Website] Add articles section with index and article pages, matching customers page design (#20315) Bare-bone structure for the blog/articles on website. --- .../src/app/[locale]/articles/[slug]/page.tsx | 86 +++ .../src/app/[locale]/articles/page.tsx | 124 +++++ .../twenty-website-new/src/app/sitemap.ts | 30 +- .../src/content/articles/.gitkeep | 1 + .../components/MarkdownProse.tsx | 164 ++++++ .../src/design-system/components/index.ts | 1 + .../__tests__/load-local-articles.test.ts | 142 +++++ .../src/lib/articles/format-article-date.ts | 13 + .../src/lib/articles/index.ts | 8 + .../src/lib/articles/load-local-articles.ts | 177 +++++++ .../src/lib/articles/types.ts | 11 + packages/twenty-website-new/src/lib/pages.ts | 1 + .../src/lib/seo/__tests__/json-ld.test.tsx | 61 +++ .../src/lib/seo/build-page-metadata.ts | 7 +- .../src/lib/seo/build-route-metadata.ts | 2 + .../twenty-website-new/src/lib/seo/index.ts | 2 + .../src/lib/seo/json-ld.tsx | 55 ++ .../src/lib/website-routing/article-routes.ts | 39 ++ .../src/lib/website-routing/index.ts | 1 + .../website-routing/static-website-routes.ts | 10 + .../src/lib/website-routing/types.ts | 3 + .../lib/website-routing/website-route-list.ts | 2 + .../sections/Articles/components/Article.tsx | 121 +++++ .../Articles/components/ArticleIndex.tsx | 488 ++++++++++++++++++ .../src/sections/Articles/components/Root.tsx | 33 ++ .../src/sections/Articles/components/index.ts | 7 + .../src/sections/Footer/data.ts | 1 + .../src/sections/Hero/components/Body.tsx | 4 + .../src/sections/Menu/data.ts | 13 + .../components/ReleaseMarkdown.tsx | 160 +----- 30 files changed, 1599 insertions(+), 168 deletions(-) create mode 100644 packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx create mode 100644 packages/twenty-website-new/src/app/[locale]/articles/page.tsx create mode 100644 packages/twenty-website-new/src/content/articles/.gitkeep create mode 100644 packages/twenty-website-new/src/design-system/components/MarkdownProse.tsx create mode 100644 packages/twenty-website-new/src/lib/articles/__tests__/load-local-articles.test.ts create mode 100644 packages/twenty-website-new/src/lib/articles/format-article-date.ts create mode 100644 packages/twenty-website-new/src/lib/articles/index.ts create mode 100644 packages/twenty-website-new/src/lib/articles/load-local-articles.ts create mode 100644 packages/twenty-website-new/src/lib/articles/types.ts create mode 100644 packages/twenty-website-new/src/lib/website-routing/article-routes.ts create mode 100644 packages/twenty-website-new/src/sections/Articles/components/Article.tsx create mode 100644 packages/twenty-website-new/src/sections/Articles/components/ArticleIndex.tsx create mode 100644 packages/twenty-website-new/src/sections/Articles/components/Root.tsx create mode 100644 packages/twenty-website-new/src/sections/Articles/components/index.ts diff --git a/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx b/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx new file mode 100644 index 0000000000..da2de3b534 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/articles/[slug]/page.tsx @@ -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; +}; + +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 { + 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 ( + <> + + + + + + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/[locale]/articles/page.tsx b/packages/twenty-website-new/src/app/[locale]/articles/page.tsx new file mode 100644 index 0000000000..3064a3de78 --- /dev/null +++ b/packages/twenty-website-new/src/app/[locale]/articles/page.tsx @@ -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; +}; + +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 ? ( + + ) : null} + + + + + + + +
+ + + Ideas on +
+ open-source{' '} + CRM +
+ +
+ + + + + +
+ + + + ); +} diff --git a/packages/twenty-website-new/src/app/sitemap.ts b/packages/twenty-website-new/src/app/sitemap.ts index 82f54bd863..77ccac9ca1 100644 --- a/packages/twenty-website-new/src/app/sitemap.ts +++ b/packages/twenty-website-new/src/app/sitemap.ts @@ -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 => { +const getRouteLocales = (route: WebsiteRoute): readonly AppLocale[] => + route.localeMode === 'source' ? [SOURCE_LOCALE] : PUBLIC_APP_LOCALE_LIST; + +const buildLanguageAlternates = ( + path: string, + locales: readonly AppLocale[], +): Record => { const alternates: Record = {}; - 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), + ), ); } diff --git a/packages/twenty-website-new/src/content/articles/.gitkeep b/packages/twenty-website-new/src/content/articles/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/packages/twenty-website-new/src/content/articles/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/twenty-website-new/src/design-system/components/MarkdownProse.tsx b/packages/twenty-website-new/src/design-system/components/MarkdownProse.tsx new file mode 100644 index 0000000000..4f0df17662 --- /dev/null +++ b/packages/twenty-website-new/src/design-system/components/MarkdownProse.tsx @@ -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 ( + + ( + + {children} + + ), + img: ({ alt, src, ...props }) => ( + {alt + ), + }} + remarkPlugins={[remarkGfm]} + > + {markdown} + + + ); +} diff --git a/packages/twenty-website-new/src/design-system/components/index.ts b/packages/twenty-website-new/src/design-system/components/index.ts index 714301d18e..09cf1255c4 100644 --- a/packages/twenty-website-new/src/design-system/components/index.ts +++ b/packages/twenty-website-new/src/design-system/components/index.ts @@ -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'; diff --git a/packages/twenty-website-new/src/lib/articles/__tests__/load-local-articles.test.ts b/packages/twenty-website-new/src/lib/articles/__tests__/load-local-articles.test.ts new file mode 100644 index 0000000000..d0b48a999b --- /dev/null +++ b/packages/twenty-website-new/src/lib/articles/__tests__/load-local-articles.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-website-new/src/lib/articles/format-article-date.ts b/packages/twenty-website-new/src/lib/articles/format-article-date.ts new file mode 100644 index 0000000000..98be14be41 --- /dev/null +++ b/packages/twenty-website-new/src/lib/articles/format-article-date.ts @@ -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); +} diff --git a/packages/twenty-website-new/src/lib/articles/index.ts b/packages/twenty-website-new/src/lib/articles/index.ts new file mode 100644 index 0000000000..28d4055988 --- /dev/null +++ b/packages/twenty-website-new/src/lib/articles/index.ts @@ -0,0 +1,8 @@ +export { formatArticleDate } from './format-article-date'; +export { + getPublishedArticle, + getPublishedArticles, + loadLocalArticles, + loadLocalArticlesFromDirectory, +} from './load-local-articles'; +export type { Article } from './types'; diff --git a/packages/twenty-website-new/src/lib/articles/load-local-articles.ts b/packages/twenty-website-new/src/lib/articles/load-local-articles.ts new file mode 100644 index 0000000000..c681ec061d --- /dev/null +++ b/packages/twenty-website-new/src/lib/articles/load-local-articles.ts @@ -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, + 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 + ); +} diff --git a/packages/twenty-website-new/src/lib/articles/types.ts b/packages/twenty-website-new/src/lib/articles/types.ts new file mode 100644 index 0000000000..84f973023f --- /dev/null +++ b/packages/twenty-website-new/src/lib/articles/types.ts @@ -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; +}; diff --git a/packages/twenty-website-new/src/lib/pages.ts b/packages/twenty-website-new/src/lib/pages.ts index 97da0f1cc2..2a2605cfc3 100644 --- a/packages/twenty-website-new/src/lib/pages.ts +++ b/packages/twenty-website-new/src/lib/pages.ts @@ -1,4 +1,5 @@ export const Pages = { + Articles: 'articles', CaseStudies: 'caseStudies', ReleaseNotes: 'releaseNotes', Home: 'home', diff --git a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx b/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx index 018af62ba6..f89dcf4509 100644 --- a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx +++ b/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx @@ -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>; + }; + + 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', + }, + }); + }); +}); diff --git a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts index 2a4f142b69..acc00e3aca 100644 --- a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts +++ b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts @@ -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 => { const languages: Record = {}; - 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, diff --git a/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts index 8c54fa813b..7e8e7e02a9 100644 --- a/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts +++ b/packages/twenty-website-new/src/lib/seo/build-route-metadata.ts @@ -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, }); }; diff --git a/packages/twenty-website-new/src/lib/seo/index.ts b/packages/twenty-website-new/src/lib/seo/index.ts index d88747d635..e3c863a8a5 100644 --- a/packages/twenty-website-new/src/lib/seo/index.ts +++ b/packages/twenty-website-new/src/lib/seo/index.ts @@ -5,6 +5,8 @@ export { } from './build-page-metadata'; export { buildRouteMetadata } from './build-route-metadata'; export { + buildArticleListJsonLd, + buildArticleJsonLd, buildFaqPageJsonLd, buildOrganizationJsonLd, buildReleaseListJsonLd, diff --git a/packages/twenty-website-new/src/lib/seo/json-ld.tsx b/packages/twenty-website-new/src/lib/seo/json-ld.tsx index 3e61cd2c5e..2c01a89341 100644 --- a/packages/twenty-website-new/src/lib/seo/json-ld.tsx +++ b/packages/twenty-website-new/src/lib/seo/json-ld.tsx @@ -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, + }, + }; +}; diff --git a/packages/twenty-website-new/src/lib/website-routing/article-routes.ts b/packages/twenty-website-new/src/lib/website-routing/article-routes.ts new file mode 100644 index 0000000000..ba1f43f8fa --- /dev/null +++ b/packages/twenty-website-new/src/lib/website-routing/article-routes.ts @@ -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, + }), + ), + ]; diff --git a/packages/twenty-website-new/src/lib/website-routing/index.ts b/packages/twenty-website-new/src/lib/website-routing/index.ts index 42a5551f3c..d002cb538a 100644 --- a/packages/twenty-website-new/src/lib/website-routing/index.ts +++ b/packages/twenty-website-new/src/lib/website-routing/index.ts @@ -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'; diff --git a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts index b8fd9b73d3..be73fe438c 100644 --- a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts +++ b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts @@ -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', diff --git a/packages/twenty-website-new/src/lib/website-routing/types.ts b/packages/twenty-website-new/src/lib/website-routing/types.ts index 7e9e22fcd1..ed77a2a1cd 100644 --- a/packages/twenty-website-new/src/lib/website-routing/types.ts +++ b/packages/twenty-website-new/src/lib/website-routing/types.ts @@ -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; diff --git a/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts b/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts index 85025c3b71..1611baecb6 100644 --- a/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts +++ b/packages/twenty-website-new/src/lib/website-routing/website-route-list.ts @@ -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, ]; diff --git a/packages/twenty-website-new/src/sections/Articles/components/Article.tsx b/packages/twenty-website-new/src/sections/Articles/components/Article.tsx new file mode 100644 index 0000000000..588639b30f --- /dev/null +++ b/packages/twenty-website-new/src/sections/Articles/components/Article.tsx @@ -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 ( + + + + ← Back to articles +
+ + {formatArticleDate(post.date)} · {post.readingTimeMinutes} min + read · {post.author} + + {post.title} + {post.description} + {post.tags.length > 0 ? ( + + {post.tags.map((tag) => ( + {tag} + ))} + + ) : null} +
+ +
+
+
+ ); +} diff --git a/packages/twenty-website-new/src/sections/Articles/components/ArticleIndex.tsx b/packages/twenty-website-new/src/sections/Articles/components/ArticleIndex.tsx new file mode 100644 index 0000000000..315db89966 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Articles/components/ArticleIndex.tsx @@ -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 ( + + + + Article · {post.readingTimeMinutes} min read + + + + + + {formatArticleDate(post.date)} + {post.title} + {post.description} + + + {post.tags.length > 0 ? ( + + {post.tags.map((tag) => ( + {tag} + ))} + + ) : null} + + + + {initials} + {post.author} + + + + + + + + ); +} + +type ArticleIndexProps = { + posts: readonly ArticleData[]; +}; + +export function ArticleIndex({ posts }: ArticleIndexProps) { + const lastIndex = posts.length - 1; + + return ( +
+ + + + + + + + + + + + {posts.length === 0 ? ( + + No posts published yet. + + ) : ( + + {posts.map((post, index) => ( + + ))} + + )} + + +
+ ); +} diff --git a/packages/twenty-website-new/src/sections/Articles/components/Root.tsx b/packages/twenty-website-new/src/sections/Articles/components/Root.tsx new file mode 100644 index 0000000000..275e270b60 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Articles/components/Root.tsx @@ -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 {children}; +} diff --git a/packages/twenty-website-new/src/sections/Articles/components/index.ts b/packages/twenty-website-new/src/sections/Articles/components/index.ts new file mode 100644 index 0000000000..1b263f8eca --- /dev/null +++ b/packages/twenty-website-new/src/sections/Articles/components/index.ts @@ -0,0 +1,7 @@ +import { Article } from './Article'; +import { ArticleIndex } from './ArticleIndex'; + +export const Articles = { + Article, + Index: ArticleIndex, +}; diff --git a/packages/twenty-website-new/src/sections/Footer/data.ts b/packages/twenty-website-new/src/sections/Footer/data.ts index d0a998cdbf..4532d2b48c 100644 --- a/packages/twenty-website-new/src/sections/Footer/data.ts +++ b/packages/twenty-website-new/src/sections/Footer/data.ts @@ -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 }, ], }, { diff --git a/packages/twenty-website-new/src/sections/Hero/components/Body.tsx b/packages/twenty-website-new/src/sections/Hero/components/Body.tsx index 60baa5aef8..2294d1eed4 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/Body.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/Body.tsx @@ -56,6 +56,10 @@ const StyledBody = styled.div` white-space: pre-line; } + &[data-page='articles'] { + max-width: 550px; + } + &[data-page='caseStudies'] { max-width: 550px; } diff --git a/packages/twenty-website-new/src/sections/Menu/data.ts b/packages/twenty-website-new/src/sections/Menu/data.ts index 276efa84c8..f42764e184 100644 --- a/packages/twenty-website-new/src/sections/Menu/data.ts +++ b/packages/twenty-website-new/src/sections/Menu/data.ts @@ -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' }, diff --git a/packages/twenty-website-new/src/sections/ReleaseNotes/components/ReleaseMarkdown.tsx b/packages/twenty-website-new/src/sections/ReleaseNotes/components/ReleaseMarkdown.tsx index 2284840f37..2edd12f000 100644 --- a/packages/twenty-website-new/src/sections/ReleaseNotes/components/ReleaseMarkdown.tsx +++ b/packages/twenty-website-new/src/sections/ReleaseNotes/components/ReleaseMarkdown.tsx @@ -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 ( - - ( - - {children} - - ), - img: ({ alt, src, ...props }) => ( - {alt - ), - }} - remarkPlugins={[remarkGfm]} - urlTransform={(value) => value} - > - {markdown} - - - ); + return ; }