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:
committed by
GitHub
parent
6b4d3ed025
commit
2c465bd42e
4
.github/workflows/ci-website.yaml
vendored
4
.github/workflows/ci-website.yaml
vendored
@ -20,7 +20,7 @@ jobs:
|
|||||||
website-build:
|
website-build:
|
||||||
needs: changed-files-check
|
needs: changed-files-check
|
||||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||||
timeout-minutes: 3
|
timeout-minutes: 10
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@ -58,7 +58,7 @@ jobs:
|
|||||||
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
||||||
ci-website-status-check:
|
ci-website-status-check:
|
||||||
if: always() && !cancelled()
|
if: always() && !cancelled()
|
||||||
timeout-minutes: 1
|
timeout-minutes: 10
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [changed-files-check, website-build]
|
needs: [changed-files-check, website-build]
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
55
packages/twenty-website/keystatic.config.ts
Normal file
55
packages/twenty-website/keystatic.config.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -15,6 +15,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docsearch/react": "^3.6.2",
|
"@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",
|
"@nivo/calendar": "^0.87.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next-runtime-env": "^3.2.2",
|
"next-runtime-env": "^3.2.2",
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { makePage } from '@keystatic/next/ui/app';
|
||||||
|
import config from '../../../../keystatic.config';
|
||||||
|
|
||||||
|
export default makePage(config);
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import KeystaticApp from './keystatic';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return <KeystaticApp />;
|
||||||
|
}
|
||||||
21
packages/twenty-website/src/app/(cms)/layout.tsx
Normal file
21
packages/twenty-website/src/app/(cms)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { getContributorActivity } from '@/app/(public)/contributors/utils/get-contributor-activity';
|
||||||
import { ActivityLog } from '@/app/_components/contributors/ActivityLog';
|
import { ActivityLog } from '@/app/_components/contributors/ActivityLog';
|
||||||
import { Breadcrumb } from '@/app/_components/contributors/Breadcrumb';
|
import { Breadcrumb } from '@/app/_components/contributors/Breadcrumb';
|
||||||
import { ContentContainer } from '@/app/_components/contributors/ContentContainer';
|
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 { PullRequests } from '@/app/_components/contributors/PullRequests';
|
||||||
import { ThankYou } from '@/app/_components/contributors/ThankYou';
|
import { ThankYou } from '@/app/_components/contributors/ThankYou';
|
||||||
import { Background } from '@/app/_components/oss-friends/Background';
|
import { Background } from '@/app/_components/oss-friends/Background';
|
||||||
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
|
|
||||||
|
|
||||||
export function generateMetadata({
|
export function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const GraphQlPlayground = dynamic(
|
const GraphQlPlayground = dynamic(
|
||||||
() => import('../../../_components/playground/graphql-playground'),
|
() => import('@/app/_components/playground/graphql-playground'),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1,14 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const GraphQlPlayground = dynamic(
|
const GraphQlPlayground = dynamic(
|
||||||
() => import('../../../_components/playground/graphql-playground'),
|
() => import('@/app/_components/playground/graphql-playground'),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const CoreGraphql = () => {
|
const MetadataGraphql = () => {
|
||||||
return <GraphQlPlayground subDoc={'metadata'} />;
|
return <GraphQlPlayground subDoc={'metadata'} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CoreGraphql;
|
export default MetadataGraphql;
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { Gabarito, Inter } from 'next/font/google';
|
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
import { PublicEnvScript } from 'next-runtime-env';
|
||||||
|
import { Gabarito, Inter } from 'next/font/google';
|
||||||
|
|
||||||
import { AppHeader } from '@/app/_components/ui/layout/header';
|
import { AppHeader } from '@/app/_components/ui/layout/header';
|
||||||
|
|
||||||
import { FooterDesktop } from './_components/ui/layout/FooterDesktop';
|
import { FooterDesktop } from '../_components/ui/layout/FooterDesktop';
|
||||||
import EmotionRootStyleRegistry from './emotion-root-style-registry';
|
import EmotionRootStyleRegistry from '../emotion-root-style-registry';
|
||||||
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ContentContainer } from './_components/ui/layout/ContentContainer';
|
import { ContentContainer } from '../_components/ui/layout/ContentContainer';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 {
|
export interface ReleaseNote {
|
||||||
slug: string;
|
slug: string;
|
||||||
@ -1,15 +1,14 @@
|
|||||||
import React from 'react';
|
|
||||||
import { desc } from 'drizzle-orm';
|
import { desc } from 'drizzle-orm';
|
||||||
import { Metadata } from 'next';
|
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 {
|
import {
|
||||||
getMdxReleasesContent,
|
getMdxReleasesContent,
|
||||||
getReleases,
|
getReleases,
|
||||||
} from '@/app/releases/utils/get-releases';
|
} from '@/app/(public)/releases/utils/get-releases';
|
||||||
import { getVisibleReleases } from '@/app/releases/utils/get-visible-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 { findAll } from '@/database/database';
|
||||||
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
||||||
import { pgGithubReleasesModel } from '@/database/schema-postgres';
|
import { pgGithubReleasesModel } from '@/database/schema-postgres';
|
||||||
@ -4,7 +4,7 @@ import { compileMDX } from 'next-mdx-remote/rsc';
|
|||||||
import { JSXElementConstructor, ReactElement } from 'react';
|
import { JSXElementConstructor, ReactElement } from 'react';
|
||||||
import gfm from 'remark-gfm';
|
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';
|
import { compareSemanticVersions } from '@/shared-utils/compareSemanticVersions';
|
||||||
|
|
||||||
// WARNING: This API is used by twenty-front, not just by twenty-website
|
// WARNING: This API is used by twenty-front, not just by twenty-website
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { ReleaseNote } from '@/app/releases/api/route';
|
import { ReleaseNote } from '@/app/(public)/releases/api/route';
|
||||||
import { getFormattedReleaseNumber } from '@/app/releases/utils/get-formatted-release-number';
|
import { getFormattedReleaseNumber } from '@/app/(public)/releases/utils/get-formatted-release-number';
|
||||||
|
|
||||||
export const getVisibleReleases = (
|
export const getVisibleReleases = (
|
||||||
releaseNotes: ReleaseNote[],
|
releaseNotes: ReleaseNote[],
|
||||||
@ -3,9 +3,9 @@
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { TimeRange } from '@nivo/calendar';
|
import { TimeRange } from '@nivo/calendar';
|
||||||
|
|
||||||
|
import { getActivityEndDate } from '@/app/(public)/contributors/utils/get-activity-end-date';
|
||||||
import { CardContainer } from '@/app/_components/contributors/CardContainer';
|
import { CardContainer } from '@/app/_components/contributors/CardContainer';
|
||||||
import { Title } from '@/app/_components/contributors/Title';
|
import { Title } from '@/app/_components/contributors/Title';
|
||||||
import { getActivityEndDate } from '@/app/contributors/utils/get-activity-end-date';
|
|
||||||
|
|
||||||
const CalendarContentContainer = styled.div`
|
const CalendarContentContainer = styled.div`
|
||||||
@media (max-width: 890px) {
|
@media (max-width: 890px) {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticle
|
|||||||
import { getSectionIcon } from '@/shared-utils/getSectionIcons';
|
import { getSectionIcon } from '@/shared-utils/getSectionIcons';
|
||||||
|
|
||||||
import '@docsearch/css';
|
import '@docsearch/css';
|
||||||
import '../../user-guide/algolia.css';
|
import '../../(public)/user-guide/algolia.css';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
${mq({
|
${mq({
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { usePathname } from 'next/navigation';
|
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 ClientOnly from '@/app/_components/docs/ClientOnly';
|
||||||
import mq from '@/app/_components/ui/theme/mq';
|
import mq from '@/app/_components/ui/theme/mq';
|
||||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||||
import { useHeadsObserver } from '@/app/user-guide/hooks/useHeadsObserver';
|
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
${mq({
|
${mq({
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { JSXElementConstructor, ReactElement } from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { Gabarito } from 'next/font/google';
|
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 { ArticleContent } from '@/app/_components/ui/layout/articles/ArticleContent';
|
||||||
import MotionContainer from '@/app/_components/ui/layout/LoaderAnimation';
|
import MotionContainer from '@/app/_components/ui/layout/LoaderAnimation';
|
||||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||||
import { ReleaseNote } from '@/app/releases/api/route';
|
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
width: 810px;
|
width: 810px;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 { Line } from '@/app/_components/releases/Line';
|
||||||
import { Release } from '@/app/_components/releases/Release';
|
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';
|
import { GithubReleases } from '@/database/model';
|
||||||
|
|
||||||
interface ReleaseProps {
|
interface ReleaseProps {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
|
|
||||||
|
import { getContributorActivity } from '@/app/(public)/contributors/utils/get-contributor-activity';
|
||||||
import {
|
import {
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
container,
|
container,
|
||||||
@ -16,7 +17,6 @@ import {
|
|||||||
profileUsernameHeader,
|
profileUsernameHeader,
|
||||||
styledContributorAvatar,
|
styledContributorAvatar,
|
||||||
} from '@/app/api/contributors/[slug]/og.png/style';
|
} from '@/app/api/contributors/[slug]/og.png/style';
|
||||||
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
|
|
||||||
|
|
||||||
const GABARITO_FONT_CDN_URL =
|
const GABARITO_FONT_CDN_URL =
|
||||||
'https://fonts.cdnfonts.com/s/105143/Gabarito-Medium-BF651cdf1f3f18e.woff';
|
'https://fonts.cdnfonts.com/s/105143/Gabarito-Medium-BF651cdf1f3f18e.woff';
|
||||||
|
|||||||
@ -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';
|
import { executePartialSync } from '@/github/execute-partial-sync';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { makeRouteHandler } from '@keystatic/next/route-handler';
|
||||||
|
import config from '../../../../../keystatic.config';
|
||||||
|
|
||||||
|
export const { POST, GET } = makeRouteHandler({
|
||||||
|
config,
|
||||||
|
});
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { desc } from 'drizzle-orm';
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
import { getGithubReleaseDateFromReleaseNote } from '@/app/releases/utils/get-github-release-date-from-release-note';
|
import { getGithubReleaseDateFromReleaseNote } from '@/app/(public)/releases/utils/get-github-release-date-from-release-note';
|
||||||
import { getReleases } from '@/app/releases/utils/get-releases';
|
import { getReleases } from '@/app/(public)/releases/utils/get-releases';
|
||||||
import { getVisibleReleases } from '@/app/releases/utils/get-visible-releases';
|
import { getVisibleReleases } from '@/app/(public)/releases/utils/get-visible-releases';
|
||||||
import { findAll } from '@/database/database';
|
import { findAll } from '@/database/database';
|
||||||
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user