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

@ -20,7 +20,7 @@ jobs:
website-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 3
timeout-minutes: 10
runs-on: ubuntu-latest
services:
postgres:
@ -58,7 +58,7 @@ jobs:
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 1
timeout-minutes: 10
runs-on: ubuntu-latest
needs: [changed-files-check, website-build]
steps:

View File

@ -0,0 +1,55 @@
import { collection, config, fields } from '@keystatic/core';
import { wrapper } from '@keystatic/core/content-components';
export default config({
storage: {
kind:
process.env.KEYSTATIC_STORAGE_KIND === ''
? 'local'
: ((process.env.KEYSTATIC_STORAGE_KIND || 'local') as
| 'local'
| 'github'
| 'cloud'),
repo: {
owner: 'twentyhq',
name: 'twenty',
},
},
collections: {
developers: collection({
label: 'Technical documentation',
slugField: 'title',
path: 'src/content/developers/**',
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
icon: fields.text({ label: 'Icon' }),
image: fields.text({ label: 'Image' }),
info: fields.text({ label: 'Info' }),
content: fields.mdx({
label: 'Content',
components: {
ArticleEditContent: wrapper({
label: 'ArticleEditContent',
schema: {},
}),
},
}),
},
}),
releases: collection({
label: 'Releases',
slugField: 'release',
path: 'src/content/releases/*',
format: { contentField: 'content' },
schema: {
release: fields.slug({ name: { label: 'Release' } }),
// TODO: Define the date with a normalized format
Date: fields.text({ label: 'Date' }),
content: fields.mdx({
label: 'Content',
}),
},
}),
},
});

View File

@ -15,6 +15,9 @@
},
"dependencies": {
"@docsearch/react": "^3.6.2",
"@keystatic/core": "^0.5.45",
"@keystatic/next": "^5.0.3",
"@markdoc/markdoc": "^0.5.1",
"@nivo/calendar": "^0.87.0",
"gray-matter": "^4.0.3",
"next-runtime-env": "^3.2.2",

View File

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View File

@ -0,0 +1,6 @@
'use client';
import { makePage } from '@keystatic/next/ui/app';
import config from '../../../../keystatic.config';
export default makePage(config);

View File

@ -0,0 +1,5 @@
import KeystaticApp from './keystatic';
export default function Layout() {
return <KeystaticApp />;
}

View File

@ -0,0 +1,21 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Twenty.com',
description: 'Open Source CRM',
icons: '/images/core/logo.svg',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<div className="container">{children}</div>
</body>
</html>
);
}

View File

@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
import { getContributorActivity } from '@/app/(public)/contributors/utils/get-contributor-activity';
import { ActivityLog } from '@/app/_components/contributors/ActivityLog';
import { Breadcrumb } from '@/app/_components/contributors/Breadcrumb';
import { ContentContainer } from '@/app/_components/contributors/ContentContainer';
@ -11,7 +12,6 @@ import { ProfileSharing } from '@/app/_components/contributors/ProfileSharing';
import { PullRequests } from '@/app/_components/contributors/PullRequests';
import { ThankYou } from '@/app/_components/contributors/ThankYou';
import { Background } from '@/app/_components/oss-friends/Background';
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
export function generateMetadata({
params,

View File

@ -1,9 +1,8 @@
'use client';
import React from 'react';
import dynamic from 'next/dynamic';
const GraphQlPlayground = dynamic(
() => import('../../../_components/playground/graphql-playground'),
() => import('@/app/_components/playground/graphql-playground'),
{ ssr: false },
);

View File

@ -1,14 +1,13 @@
'use client';
import React from 'react';
import dynamic from 'next/dynamic';
const GraphQlPlayground = dynamic(
() => import('../../../_components/playground/graphql-playground'),
() => import('@/app/_components/playground/graphql-playground'),
{ ssr: false },
);
const CoreGraphql = () => {
const MetadataGraphql = () => {
return <GraphQlPlayground subDoc={'metadata'} />;
};
export default CoreGraphql;
export default MetadataGraphql;

View File

@ -1,11 +1,11 @@
import { Metadata } from 'next';
import { Gabarito, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { Gabarito, Inter } from 'next/font/google';
import { AppHeader } from '@/app/_components/ui/layout/header';
import { FooterDesktop } from './_components/ui/layout/FooterDesktop';
import EmotionRootStyleRegistry from './emotion-root-style-registry';
import { FooterDesktop } from '../_components/ui/layout/FooterDesktop';
import EmotionRootStyleRegistry from '../emotion-root-style-registry';
import './layout.css';

View File

@ -1,4 +1,4 @@
import { ContentContainer } from './_components/ui/layout/ContentContainer';
import { ContentContainer } from '../_components/ui/layout/ContentContainer';
export const dynamic = 'force-dynamic';

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getReleases } from '@/app/releases/utils/get-releases';
import { getReleases } from '@/app/(public)/releases/utils/get-releases';
export interface ReleaseNote {
slug: string;

View File

@ -1,15 +1,14 @@
import React from 'react';
import { desc } from 'drizzle-orm';
import { Metadata } from 'next';
import { ReleaseContainer } from '@/app/_components/releases/ReleaseContainer';
import { Title } from '@/app/_components/releases/StyledTitle';
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
import {
getMdxReleasesContent,
getReleases,
} from '@/app/releases/utils/get-releases';
import { getVisibleReleases } from '@/app/releases/utils/get-visible-releases';
} 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';

View File

@ -4,7 +4,7 @@ import { compileMDX } from 'next-mdx-remote/rsc';
import { JSXElementConstructor, ReactElement } from 'react';
import gfm from 'remark-gfm';
import { ReleaseNote } from '@/app/releases/api/route';
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

View File

@ -1,5 +1,5 @@
import { ReleaseNote } from '@/app/releases/api/route';
import { getFormattedReleaseNumber } from '@/app/releases/utils/get-formatted-release-number';
import { ReleaseNote } from '@/app/(public)/releases/api/route';
import { getFormattedReleaseNumber } from '@/app/(public)/releases/utils/get-formatted-release-number';
export const getVisibleReleases = (
releaseNotes: ReleaseNote[],

View File

@ -3,9 +3,9 @@
import styled from '@emotion/styled';
import { TimeRange } from '@nivo/calendar';
import { getActivityEndDate } from '@/app/(public)/contributors/utils/get-activity-end-date';
import { CardContainer } from '@/app/_components/contributors/CardContainer';
import { Title } from '@/app/_components/contributors/Title';
import { getActivityEndDate } from '@/app/contributors/utils/get-activity-end-date';
const CalendarContentContainer = styled.div`
@media (max-width: 890px) {

View File

@ -11,7 +11,7 @@ import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticle
import { getSectionIcon } from '@/shared-utils/getSectionIcons';
import '@docsearch/css';
import '../../user-guide/algolia.css';
import '../../(public)/user-guide/algolia.css';
const StyledContainer = styled.div`
${mq({

View File

@ -1,13 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useHeadsObserver } from '@/app/(public)/user-guide/hooks/useHeadsObserver';
import ClientOnly from '@/app/_components/docs/ClientOnly';
import mq from '@/app/_components/ui/theme/mq';
import { Theme } from '@/app/_components/ui/theme/theme';
import { useHeadsObserver } from '@/app/user-guide/hooks/useHeadsObserver';
const StyledContainer = styled.div`
${mq({

View File

@ -1,13 +1,13 @@
'use client';
import { JSXElementConstructor, ReactElement } from 'react';
import styled from '@emotion/styled';
import { Gabarito } from 'next/font/google';
import { JSXElementConstructor, ReactElement } from 'react';
import { ReleaseNote } from '@/app/(public)/releases/api/route';
import { ArticleContent } from '@/app/_components/ui/layout/articles/ArticleContent';
import MotionContainer from '@/app/_components/ui/layout/LoaderAnimation';
import { Theme } from '@/app/_components/ui/theme/theme';
import { ReleaseNote } from '@/app/releases/api/route';
const StyledContainer = styled.div`
width: 810px;

View File

@ -1,10 +1,10 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { ReleaseNote } from '@/app/(public)/releases/api/route';
import { getGithubReleaseDateFromReleaseNote } from '@/app/(public)/releases/utils/get-github-release-date-from-release-note';
import { Line } from '@/app/_components/releases/Line';
import { Release } from '@/app/_components/releases/Release';
import { ReleaseNote } from '@/app/releases/api/route';
import { getGithubReleaseDateFromReleaseNote } from '@/app/releases/utils/get-github-release-date-from-release-note';
import { GithubReleases } from '@/database/model';
interface ReleaseProps {

View File

@ -1,6 +1,7 @@
import { format } from 'date-fns';
import { ImageResponse } from 'next/og';
import { getContributorActivity } from '@/app/(public)/contributors/utils/get-contributor-activity';
import {
backgroundImage,
container,
@ -16,7 +17,6 @@ import {
profileUsernameHeader,
styledContributorAvatar,
} from '@/app/api/contributors/[slug]/og.png/style';
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
const GABARITO_FONT_CDN_URL =
'https://fonts.cdnfonts.com/s/105143/Gabarito-Medium-BF651cdf1f3f18e.woff';

View File

@ -1,4 +1,4 @@
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
import { getContributorActivity } from '@/app/(public)/contributors/utils/get-contributor-activity';
import { executePartialSync } from '@/github/execute-partial-sync';
export const dynamic = 'force-dynamic';

View File

@ -0,0 +1,6 @@
import { makeRouteHandler } from '@keystatic/next/route-handler';
import config from '../../../../../keystatic.config';
export const { POST, GET } = makeRouteHandler({
config,
});

View File

@ -1,8 +1,8 @@
import { desc } from 'drizzle-orm';
import { getGithubReleaseDateFromReleaseNote } from '@/app/releases/utils/get-github-release-date-from-release-note';
import { getReleases } from '@/app/releases/utils/get-releases';
import { getVisibleReleases } from '@/app/releases/utils/get-visible-releases';
import { getGithubReleaseDateFromReleaseNote } from '@/app/(public)/releases/utils/get-github-release-date-from-release-note';
import { getReleases } from '@/app/(public)/releases/utils/get-releases';
import { getVisibleReleases } from '@/app/(public)/releases/utils/get-visible-releases';
import { findAll } from '@/database/database';
import { GithubReleases, githubReleasesModel } from '@/database/model';

2954
yarn.lock

File diff suppressed because it is too large Load Diff