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,84 @@
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';
import { ProfileCard } from '@/app/_components/contributors/ProfileCard';
import { ProfileInfo } from '@/app/_components/contributors/ProfileInfo';
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';
export function generateMetadata({
params,
}: {
params: { slug: string };
}): Metadata {
return {
metadataBase: new URL(`https://twenty.com`),
title: 'Twenty - ' + params.slug,
description:
'Explore the impactful contributions of ' +
params.slug +
' on the Twenty Github Repo. Discover their merged pull requests, ongoing work, and top ranking. Join and contribute to the #1 Open-Source CRM thriving community!',
openGraph: {
images: [`https://twenty.com/api/contributors/${params.slug}/og.png`],
},
};
}
export default async function ({ params }: { params: { slug: string } }) {
try {
const contributorActivity = await getContributorActivity(params.slug);
if (contributorActivity) {
const {
firstContributionAt,
mergedPRsCount,
rank,
activeDays,
pullRequestActivityArray,
contributorPullRequests,
contributor,
} = contributorActivity;
return (
<Background>
<ContentContainer>
<Breadcrumb active={contributor.id} />
<ProfileCard
username={contributor.id}
avatarUrl={contributor.avatarUrl}
firstContributionAt={firstContributionAt}
/>
<ProfileInfo
mergedPRsCount={mergedPRsCount}
rank={rank}
activeDays={activeDays}
/>
<ProfileSharing username={contributor.id} />
<ActivityLog data={pullRequestActivityArray} />
<PullRequests
list={
contributorPullRequests.slice(0, 9) as {
id: string;
title: string;
url: string;
createdAt: string;
mergedAt: string | null;
authorId: string;
}[]
}
/>
<ThankYou username={contributor.id} />
</ContentContainer>
</Background>
);
}
} catch (error) {
console.error('error: ', error);
}
}

View File

@ -0,0 +1,53 @@
export const dynamic = 'force-dynamic';
import AvatarGrid from '@/app/_components/contributors/AvatarGrid';
import { Header } from '@/app/_components/contributors/Header';
import { Background } from '@/app/_components/oss-friends/Background';
import { ContentContainer } from '@/app/_components/oss-friends/ContentContainer';
import { findAll } from '@/database/database';
import { pullRequestModel, userModel } from '@/database/model';
import { TWENTY_TEAM_MEMBERS } from '@/shared-utils/listTeamMembers';
export const metadata = {
title: 'Twenty - Contributors',
description:
'Discover the brilliant minds behind Twenty.com. Meet our contributors and explore how their expertise contributes to making Twenty the leading open-source CRM. Join our community today.',
icons: '/images/core/logo.svg',
};
interface Contributor {
id: string;
avatarUrl: string;
}
const Contributors = async () => {
const contributors = await findAll(userModel);
const pullRequests = await findAll(pullRequestModel);
const pullRequestByAuthor = pullRequests.reduce((acc, pr) => {
acc[pr.authorId] = acc[pr.authorId] ? acc[pr.authorId] + 1 : 1;
return acc;
}, {});
const fitlerContributors = contributors
.filter((contributor) => contributor.isEmployee === '0')
.filter((contributor) => !TWENTY_TEAM_MEMBERS.includes(contributor.id))
.map((contributor) => {
contributor.pullRequestCount = pullRequestByAuthor[contributor.id] || 0;
return contributor;
})
.sort((a, b) => b.pullRequestCount - a.pullRequestCount)
.filter((contributor) => contributor.pullRequestCount > 0);
return (
<Background>
<ContentContainer>
<Header />
<AvatarGrid users={fitlerContributors as Contributor[]} />
</ContentContainer>
</Background>
);
};
export default Contributors;

View File

@ -0,0 +1,22 @@
const TOTAL_DAYS_TO_FULL_WIDTH = 232;
export const getActivityEndDate = (
activityDates: { value: number; day: string }[],
) => {
const startDate = new Date(activityDates[0].day);
const endDate = new Date(activityDates[activityDates.length - 1].day);
const differenceInMilliseconds = endDate.getTime() - startDate.getTime();
const numberOfDays = Math.floor(
differenceInMilliseconds / (1000 * 60 * 60 * 24),
);
const daysToAdd = TOTAL_DAYS_TO_FULL_WIDTH - numberOfDays;
if (daysToAdd > 0) {
endDate.setDate(endDate.getDate() + daysToAdd);
}
return endDate;
};

View File

@ -0,0 +1,76 @@
import { findAll } from '@/database/database';
import { pullRequestModel, userModel } from '@/database/model';
import { TWENTY_TEAM_MEMBERS } from '@/shared-utils/listTeamMembers';
export const getContributorActivity = async (username: string) => {
const contributors = await findAll(userModel);
const contributor = contributors.find((contributor) => {
return contributor.id === username;
});
if (!contributor) {
return;
}
const pullRequests = await findAll(pullRequestModel);
const mergedPullRequests = pullRequests
.filter((pr) => pr.mergedAt !== null)
.filter((pr) => !TWENTY_TEAM_MEMBERS.includes(pr.authorId));
const contributorPullRequests = pullRequests.filter(
(pr) => pr.authorId === contributor.id,
);
const mergedContributorPullRequests = contributorPullRequests.filter(
(pr) => pr.mergedAt !== null,
);
const mergedContributorPullRequestsByContributor = mergedPullRequests.reduce(
(acc, pr) => {
acc[pr.authorId] = (acc[pr.authorId] || 0) + 1;
return acc;
},
{},
);
const mergedContributorPullRequestsByContributorArray = Object.entries(
mergedContributorPullRequestsByContributor,
)
.map(([authorId, value]) => ({ authorId, value }))
.sort((a, b) => b.value - a.value);
const contributorRank =
((mergedContributorPullRequestsByContributorArray.findIndex(
(contributor) => contributor.authorId === username,
) +
1) /
contributors.length) *
100;
const pullRequestActivity = contributorPullRequests.reduce((acc, pr) => {
const date = new Date(pr.createdAt).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, []);
const pullRequestActivityArray = Object.entries(pullRequestActivity)
.map(([day, value]) => ({ day, value }))
.sort((a, b) => new Date(a.day).getTime() - new Date(b.day).getTime());
const firstContributionAt = pullRequestActivityArray[0]?.day;
const mergedPRsCount = mergedContributorPullRequests.length;
const rank = Math.ceil(Number(contributorRank)).toFixed(0);
const activeDays = pullRequestActivityArray.length;
const contributorAvatar = contributor.avatarUrl;
return {
firstContributionAt,
mergedPRsCount,
rank,
activeDays,
pullRequestActivityArray,
contributorPullRequests,
contributorAvatar,
contributor,
};
};

View File

@ -0,0 +1,33 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsContent from '@/app/_components/docs/DocsContent';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const formattedSlug = formatSlug(params.slug);
const basePath = '/src/content/developers';
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function DocsSlug({
params,
}: {
params: { slug: string };
}) {
const basePath = '/src/content/developers';
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
if (!mainPost) {
notFound();
}
return <DocsContent item={mainPost} />;
}

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
export default function DocsLayout({ children }: { children: ReactNode }) {
const filePath = 'src/content/developers/';
const getAllArticles = true;
const docsIndex = getDocsArticles(filePath, getAllArticles);
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
}

View File

@ -0,0 +1,15 @@
import DocsMain from '@/app/_components/docs/DocsMain';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
export const metadata = {
title: 'Twenty - Docs',
description: 'Twenty is a CRM designed to fit your unique business needs.',
icons: '/images/core/logo.svg',
};
export default async function DocsHome() {
const filePath = 'src/content/developers/';
const docsArticleCards = getDocsArticles(filePath);
return <DocsMain docsArticleCards={docsArticleCards} />;
}

View File

@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import Playground from '@/app/_components/playground/playground';
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
const RestApi = () => {
const [openApiJson, setOpenApiJson] = useState({});
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
const children = <RestApiWrapper openApiJson={openApiJson} />;
return (
<div style={{ width: '100vw' }}>
<Playground
children={children}
setOpenApiJson={setOpenApiJson}
subDoc="core"
/>
</div>
);
};
export default RestApi;

View File

@ -0,0 +1,30 @@
'use client';
import React, { useEffect, useState } from 'react';
import Playground from '@/app/_components/playground/playground';
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
const restApi = () => {
const [openApiJson, setOpenApiJson] = useState({});
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
const children = <RestApiWrapper openApiJson={openApiJson} />;
return (
<Playground
children={children}
setOpenApiJson={setOpenApiJson}
subDoc="metadata"
/>
);
};
export default restApi;

View File

@ -0,0 +1,35 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsContent from '@/app/_components/docs/DocsContent';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: { folder: string; documentation: string };
}): Promise<Metadata> {
const basePath = `/src/content/developers/${params.folder}`;
const formattedSlug = formatSlug(params.documentation);
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function DocsSlug({
params,
}: {
params: { documentation: string; folder: string };
}) {
const basePath = `/src/content/developers/${params.folder}`;
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
if (!mainPost) {
notFound();
}
return <DocsContent item={mainPost} />;
}

View File

@ -0,0 +1,38 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsMain from '@/app/_components/docs/DocsMain';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export async function generateMetadata({
params,
}: {
params: { folder: string };
}): Promise<Metadata> {
const formattedSlug = formatSlug(params.folder);
const basePath = '/src/content/developers';
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function DocsSlug({
params,
}: {
params: { folder: string };
}) {
const filePath = `src/content/developers/${params.folder}/`;
const docsArticleCards = getDocsArticles(filePath);
const isSection = true;
const hasOnlyEmptySections = docsArticleCards.every(
(article) => article.topic === 'Empty Section',
);
if (!docsArticleCards || hasOnlyEmptySections) {
notFound();
}
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
}

View File

@ -0,0 +1,126 @@
*,
*::before,
*::after {
box-sizing: border-box;
font-smooth: antialiased;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
box-sizing: border-box;
padding: 0;
margin: 0;
word-wrap: break-word;
font-family: var(--font-gabarito);
}
@media (max-width: 810px) {
body {
-ms-overflow-style: none;
scrollbar-width: none;
}
body::-webkit-scrollbar {
display: none;
}
}
.container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 53px;
}
.warning {
color: rgb(94, 30, 4);
}
a {
color: rgb(129, 129, 129);
&:hover {
color: #000;
}
}
@media (max-width: 1199px) {
nav {
display: none;
}
}
html {
scroll-behavior: smooth;
}
nav.toc {
width: 20%;
position: fixed;
top: 135px;
right: 0;
font-family: var(--font-gabarito);
font-size: 12px;
}
nav.toc ol {
list-style-type: none;
margin-top: 0px;
}
nav.toc li {
margin-top: 0px;
margin-bottom: 0px;
}
.toc-link {
padding: 5px;
border-radius: 4px;
}
.toc-link.toc-link-h2 {
color: rgb(129, 129, 129);
margin-left: -16px;
}
.toc-link.toc-link-h2:hover,.toc-link.toc-link-h3:hover, .toc-link.toc-link-h4:hover, .toc-link.toc-link-h5:hover {
background: #1414140a;
}
.toc-link.toc-link-h2:active,.toc-link.toc-link-h3:active, .toc-link.toc-link-h4:active, .toc-link.toc-link-h5:active {
background: #1414140f;
}
.toc-link.toc-link-h3 {
color: rgb(129, 129, 129);
margin-left: -40px;
}
.toc-link.toc-link-h4 {
color: rgb(129, 129, 129);
margin-left: -64px;
}
.toc-link.toc-link-h5 {
color: rgb(129, 129, 129);
margin-left: -88px;
}
nav.toc a {
color: #141414;
font-family: var(--font-inter);
font-size: 12px;
font-weight: 500;
text-decoration: none;
margin-top: 12px;
}
strong,
b {
color: #141414;
-webkit-font-smoothing: auto;
font-family: var(--font-inter);
font-weight: 500;
text-decoration: none;
}

View File

@ -0,0 +1,53 @@
import { Metadata } from 'next';
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 './layout.css';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Twenty.com',
description: 'Open Source CRM',
icons: '/images/core/logo.svg',
};
const gabarito = Gabarito({
weight: ['400', '500', '600', '700'],
subsets: ['latin'],
display: 'swap',
adjustFontFallback: false,
variable: '--font-gabarito',
});
const inter = Inter({
weight: ['400', '500', '600', '700'],
subsets: ['latin'],
display: 'swap',
adjustFontFallback: false,
variable: '--font-inter',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${gabarito.variable} ${inter.variable}`}>
<body>
<PublicEnvScript />
<EmotionRootStyleRegistry>
<AppHeader />
<div className="container">{children}</div>
<FooterDesktop />
</EmotionRootStyleRegistry>
</body>
</html>
);
}

View File

@ -0,0 +1,33 @@
import { Background } from '@/app/_components/oss-friends/Background';
import { Card, OssData } from '@/app/_components/oss-friends/Card';
import { CardContainer } from '@/app/_components/oss-friends/CardContainer';
import { ContentContainer } from '@/app/_components/oss-friends/ContentContainer';
import { Header } from '@/app/_components/oss-friends/Header';
export const metadata = {
title: 'Twenty - OSS friends',
description:
'At Twenty, we are proud to be part of a global open-source movement. Here are some of our fellow open source friends.',
icons: '/images/core/logo.svg',
};
export const dynamic = 'force-dynamic';
export default async function OssFriends() {
const ossList = await fetch('https://formbricks.com/api/oss-friends');
const listJson = await ossList.json();
return (
<Background>
<ContentContainer>
<Header />
<CardContainer>
{listJson.data.map((data: OssData, index: number) => (
<Card key={index} data={data} />
))}
</CardContainer>
</ContentContainer>
</Background>
);
}

View File

@ -0,0 +1,15 @@
import { ContentContainer } from '../_components/ui/layout/ContentContainer';
export const dynamic = 'force-dynamic';
export default function Home() {
return (
<ContentContainer>
<div style={{ minHeight: '60vh', marginTop: '50px' }}>
Part of the website is built directly with Framer, including the
homepage. <br />
We use Cloudflare to split the traffic between the two sites.
</div>
</ContentContainer>
);
}

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,
);
};

View File

@ -0,0 +1,35 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsContent from '@/app/_components/docs/DocsContent';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const formattedSlug = formatSlug(params.slug);
const basePath = '/src/content/twenty-ui';
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function TwentyUISlug({
params,
}: {
params: { slug: string };
}) {
const basePath = '/src/content/twenty-ui';
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
if (!mainPost) {
notFound();
}
return mainPost && <DocsContent item={mainPost} />;
}

View File

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
export default function TwentyUILayout({ children }: { children: ReactNode }) {
const filePath = 'src/content/twenty-ui/';
const getAllArticles = true;
const docsIndex = getDocsArticles(filePath, getAllArticles);
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
}

View File

@ -0,0 +1,17 @@
import DocsMain from '@/app/_components/docs/DocsMain';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
export const metadata = {
title: 'Twenty - Twenty UI',
description: 'Twenty is a CRM designed to fit your unique business needs.',
icons: '/images/core/logo.svg',
};
export const dynamic = 'force-dynamic';
export default async function TwentyUIHome() {
const filePath = 'src/content/twenty-ui/';
const docsArticleCards = getDocsArticles(filePath);
return <DocsMain docsArticleCards={docsArticleCards} />;
}

View File

@ -0,0 +1,35 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsContent from '@/app/_components/docs/DocsContent';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: { folder: string; documentation: string };
}): Promise<Metadata> {
const basePath = `/src/content/twenty-ui/${params.folder}`;
const formattedSlug = formatSlug(params.documentation);
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function TwentyUISlug({
params,
}: {
params: { documentation: string; folder: string };
}) {
const basePath = `/src/content/twenty-ui/${params.folder}`;
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
if (!mainPost) {
notFound();
}
return mainPost && <DocsContent item={mainPost} />;
}

View File

@ -0,0 +1,40 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsMain from '@/app/_components/docs/DocsMain';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: { folder: string };
}): Promise<Metadata> {
const formattedSlug = formatSlug(params.folder);
const basePath = '/src/content/twenty-ui';
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function TwentyUISlug({
params,
}: {
params: { folder: string };
}) {
const filePath = `src/content/twenty-ui/${params.folder}/`;
const docsArticleCards = getDocsArticles(filePath);
const isSection = true;
const hasOnlyEmptySections = docsArticleCards.every(
(article) => article.topic === 'Empty Section',
);
if (!docsArticleCards || hasOnlyEmptySections) {
notFound();
}
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
}

View File

@ -0,0 +1,33 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsContent from '@/app/_components/docs/DocsContent';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const formattedSlug = formatSlug(params.slug);
const basePath = '/src/content/user-guide';
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function UserGuideSlug({
params,
}: {
params: { slug: string };
}) {
const basePath = '/src/content/user-guide';
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
if (!mainPost) {
notFound();
}
return <DocsContent item={mainPost} />;
}

View File

@ -0,0 +1,122 @@
.DocSearch-Hit-source{
color: #1c1e21;
}
.DocSearch-Search-Icon {
color: #1c1e21;
}
.DocSearch-Logo {
display: none;
}
.DocSearch-Footer{
flex-direction: row;
box-shadow: none;
border: 1px solid #14141414;
}
.DocSearch-Form {
box-shadow: none;
border: 1px solid #141414;
border-radius: 8px;
}
.DocSearch-Modal {
background-color: white;
}
.DocSearch-Hits {
width: 100%;
margin-bottom: 2px !important;
}
.DocSearch-Hit[aria-selected=true] mark {
color: #1961ED !important;
text-decoration: none;
}
.DocSearch-Hit a {
box-shadow: none;
}
.DocSearch-Hits mark {
background-color: #E8EFFD;
color: #1961ED;
}
.DocSearch-Hit-action {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
margin: 0 8px;
}
.DocSearch-Hit-action h2{
font-size: .9em;
margin: 0 0 4px;
font-weight: 500;
}
.DocSearch-Hit-action p{
font-size: 12px;
margin: 0;
width: 90%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: var(--font-inter);
font-weight: 400;
}
.DocSearch-Button {
margin: 0px;
min-height: 36px;
background-color: white;
border-radius: 8px;
border: 1px solid #14141414;
}
.DocSearch-Button:hover {
color: #B3B3B3;
box-shadow: none;
}
.DocSearch-Button-Placeholder{
color: #B3B3B3;
}
.DocSearch-Search-Icon {
height: 12px;
width: 12px;
color: #B3B3B3 !important;
}
.DocSearch-Hit-source {
background: none;
font-weight: 600;
font-size: 12px;
font-family: var(--font-inter);
}
.DocSearch-Button-Placeholder {
font-weight: 500;
font-family: var(--font-gabarito);
}
.DocSearch-Button-Keys {
display: none
}
:root {
--docsearch-primary-color: #1c1e21;
--docsearch-highlight-color: #1414140F;
--docsearch-hit-active-color: var(--docsearch-muted-color);
}
.anchor {
scroll-margin-top: calc(80px);
}

View File

@ -0,0 +1,25 @@
import { useEffect, useRef, useState } from 'react';
export function useHeadsObserver(location: string) {
const [activeText, setActiveText] = useState('');
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const handleObsever = (entries: any[]) => {
entries.forEach((entry) => {
if (entry?.isIntersecting) {
setActiveText(entry.target.innerText);
}
});
};
observer.current = new IntersectionObserver(handleObsever, {
rootMargin: '0% 0% -85% 0px',
});
const elements = document.querySelectorAll('h2, h3, h4, h5');
elements.forEach((elem) => observer.current?.observe(elem));
return () => observer.current?.disconnect();
}, [location]);
return { activeText };
}

View File

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
export default function UserGuideLayout({ children }: { children: ReactNode }) {
const filePath = 'src/content/user-guide/';
const getAllArticles = true;
const docsIndex = getDocsArticles(filePath, getAllArticles);
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
}

View File

@ -0,0 +1,16 @@
import DocsMain from '@/app/_components/docs/DocsMain';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
export const metadata = {
title: 'Twenty - User Guide',
description:
'Discover how to use Twenty CRM effectively with our detailed user guide. Explore ways to customize features, manage tasks, integrate emails, and navigate the system with ease.',
icons: '/images/core/logo.svg',
};
export default async function UserGuideHome() {
const filePath = 'src/content/user-guide/';
const docsArticleCards = getDocsArticles(filePath);
return <DocsMain docsArticleCards={docsArticleCards} />;
}

View File

@ -0,0 +1,33 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsContent from '@/app/_components/docs/DocsContent';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export async function generateMetadata({
params,
}: {
params: { folder: string; documentation: string };
}): Promise<Metadata> {
const basePath = `/src/content/user-guide/${params.folder}`;
const formattedSlug = formatSlug(params.documentation);
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function UserGuideSlug({
params,
}: {
params: { documentation: string; folder: string };
}) {
const basePath = `/src/content/user-guide/${params.folder}`;
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
if (!mainPost) {
notFound();
}
return mainPost && <DocsContent item={mainPost} />;
}

View File

@ -0,0 +1,40 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DocsMain from '@/app/_components/docs/DocsMain';
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
import { formatSlug } from '@/shared-utils/formatSlug';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: { folder: string };
}): Promise<Metadata> {
const formattedSlug = formatSlug(params.folder);
const basePath = '/src/content/user-guide';
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
return {
title: 'Twenty - ' + formattedSlug,
description: mainPost?.itemInfo?.info,
};
}
export default async function UserGuideSlug({
params,
}: {
params: { folder: string };
}) {
const filePath = `src/content/user-guide/${params.folder}/`;
const docsArticleCards = getDocsArticles(filePath);
const isSection = true;
const hasOnlyEmptySections = docsArticleCards.every(
(article) => article.topic === 'Empty Section',
);
if (!docsArticleCards || hasOnlyEmptySections) {
notFound();
}
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
}