Integrate Keystatic to edit twenty.com content (#10709)

This PR introduces Keystatic to let us edit twenty.com's content with a
CMS. For now, we'll focus on creating release notes through Keystatic as
it uses quite simple Markdown. Other types of content will need some
refactoring to work with Keystatic.


https://github.com/user-attachments/assets/e9f85bbf-daff-4b41-bc97-d1baf63758b2

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Baptiste Devessier
2025-03-07 07:59:06 +01:00
committed by GitHub
parent 6b4d3ed025
commit 2c465bd42e
52 changed files with 3059 additions and 63 deletions

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { getReleases } from '@/app/(public)/releases/utils/get-releases';
export interface ReleaseNote {
slug: string;
date: string;
release: string;
content: string;
}
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const host = request.nextUrl.hostname;
const protocol = request.nextUrl.protocol;
const baseUrl = `${protocol}//${host}`;
console.log(baseUrl);
return NextResponse.json(await getReleases(baseUrl), { status: 200 });
}

View File

@ -0,0 +1,52 @@
import { desc } from 'drizzle-orm';
import { Metadata } from 'next';
import {
getMdxReleasesContent,
getReleases,
} from '@/app/(public)/releases/utils/get-releases';
import { getVisibleReleases } from '@/app/(public)/releases/utils/get-visible-releases';
import { ReleaseContainer } from '@/app/_components/releases/ReleaseContainer';
import { Title } from '@/app/_components/releases/StyledTitle';
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
import { findAll } from '@/database/database';
import { GithubReleases, githubReleasesModel } from '@/database/model';
import { pgGithubReleasesModel } from '@/database/schema-postgres';
export const metadata: Metadata = {
title: 'Twenty - Releases',
description:
'Discover the newest features and improvements in Twenty, the #1 open-source CRM.',
};
export const dynamic = 'force-dynamic';
const Home = async () => {
const githubReleases = (await findAll(
githubReleasesModel,
desc(pgGithubReleasesModel.publishedAt),
)) as GithubReleases[];
const latestGithubRelease = githubReleases[0];
const releaseNotes = await getReleases();
const visibleReleasesNotes = getVisibleReleases(
releaseNotes,
latestGithubRelease.tagName,
);
const mdxReleasesContent = await getMdxReleasesContent(releaseNotes);
return (
<ContentContainer>
<Title />
<ReleaseContainer
visibleReleasesNotes={visibleReleasesNotes}
githubReleases={githubReleases}
mdxReleasesContent={mdxReleasesContent}
/>
</ContentContainer>
);
};
export default Home;

View File

@ -0,0 +1,19 @@
export const getFormattedReleaseNumber = (versionNumber: string) => {
const formattedVersion = versionNumber.replace('v', '');
const parts = formattedVersion.split('.').map(Number);
if (parts.length !== 3) {
throw new Error('Version must be in the format major.minor.patch');
}
// Assign weights. Adjust these based on your needs.
const majorWeight = 10000;
const minorWeight = 100;
const patchWeight = 1;
const numericVersion =
parts[0] * majorWeight + parts[1] * minorWeight + parts[2] * patchWeight;
return numericVersion;
};

View File

@ -0,0 +1,43 @@
import { GithubReleases } from '@/database/model';
function formatDate(dateString: string) {
const date = new Date(dateString);
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
});
return formatter.format(date) + getOrdinal(date.getDate());
}
function getOrdinal(day: number) {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}
export const getGithubReleaseDateFromReleaseNote = (
githubReleases: GithubReleases[],
noteTagName: string,
noteDate: string,
) => {
const formattedNoteTagName = `v${noteTagName}`;
const date = githubReleases?.find?.(
(githubRelease) => githubRelease?.tagName === formattedNoteTagName,
)?.publishedAt;
if (date) {
return formatDate(date);
}
return noteDate;
};

View File

@ -0,0 +1,70 @@
import fs from 'fs';
import matter from 'gray-matter';
import { compileMDX } from 'next-mdx-remote/rsc';
import { JSXElementConstructor, ReactElement } from 'react';
import gfm from 'remark-gfm';
import { ReleaseNote } from '@/app/(public)/releases/api/route';
import { compareSemanticVersions } from '@/shared-utils/compareSemanticVersions';
// WARNING: This API is used by twenty-front, not just by twenty-website
// Make sure you don't change it without updating twenty-front at the same time
export async function getReleases(baseUrl?: string): Promise<ReleaseNote[]> {
const files = fs.readdirSync('src/content/releases');
const releasenotes: ReleaseNote[] = [];
for (const fileName of files) {
if (!fileName.endsWith('.md') && !fileName.endsWith('.mdx')) {
continue;
}
const file = fs.readFileSync(`src/content/releases/${fileName}`, 'utf-8');
const { data: frontmatter, content } = matter(file);
let updatedContent;
if (baseUrl) {
updatedContent = content.replace(
/!\[(.*?)\]\((?!http)(.*?)\)/g,
(match: string, alt: string, src: string) => {
// Check if src is a relative path (not starting with http:// or https://)
if (!src.startsWith('/')) {
src = `${baseUrl}/${src}`;
} else {
src = `${baseUrl}${src}`;
}
return `![${alt}](${src})`;
},
);
}
releasenotes.push({
slug: fileName.slice(0, -4),
date: frontmatter.Date,
release: frontmatter.release,
content: updatedContent ?? content,
});
}
releasenotes.sort((a, b) => compareSemanticVersions(b.release, a.release));
return releasenotes;
}
export async function getMdxReleasesContent(
releases: ReleaseNote[],
): Promise<ReactElement<any, string | JSXElementConstructor<any>>[]> {
const mdxSourcesPromises = releases.map(async (release) => {
const mdxSource = await compileMDX<{ title: string; position?: number }>({
source: release.content,
options: {
parseFrontmatter: true,
mdxOptions: {
development: process.env.NODE_ENV === 'development',
remarkPlugins: [gfm],
},
},
});
return mdxSource.content;
});
return await Promise.all(mdxSourcesPromises);
}

View File

@ -0,0 +1,18 @@
import { ReleaseNote } from '@/app/(public)/releases/api/route';
import { getFormattedReleaseNumber } from '@/app/(public)/releases/utils/get-formatted-release-number';
export const getVisibleReleases = (
releaseNotes: ReleaseNote[],
publishedReleaseVersion: string,
) => {
if (process.env.NODE_ENV !== 'production') return releaseNotes;
const publishedVersionNumber = getFormattedReleaseNumber(
publishedReleaseVersion,
);
return releaseNotes.filter(
(releaseNote) =>
getFormattedReleaseNumber(releaseNote.release) <= publishedVersionNumber,
);
};