snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest';
import { findAngleBracketPlaceholders } from '../lint-mdx';
describe('findAngleBracketPlaceholders', () => {
it('flags a placeholder in prose', () => {
const violations = findAngleBracketPlaceholders(
'Set the header to <your-api-key> before calling.',
);
expect(violations).toEqual([
{ line: 1, column: 19, text: '<your-api-key>', name: 'your-api-key' },
]);
});
it('reports the line and column of a placeholder further down the page', () => {
const violations = findAngleBracketPlaceholders(
'intro\n\nuse <workspace-id> here',
);
expect(violations).toEqual([
{ line: 3, column: 5, text: '<workspace-id>', name: 'workspace-id' },
]);
});
it('allows HTML elements', () => {
expect(
findAngleBracketPlaceholders('press <kbd>K</kbd> to search'),
).toEqual([]);
});
it('allows closing tags', () => {
expect(findAngleBracketPlaceholders('<details>x</details>')).toEqual([]);
});
it('ignores placeholders inside a fenced code block', () => {
expect(
findAngleBracketPlaceholders(
'before\n\n```bash\ncurl -H "key: <token>"\n```\n\nafter',
),
).toEqual([]);
});
it('ignores placeholders inside inline code', () => {
expect(findAngleBracketPlaceholders('run `deploy <env>` first')).toEqual(
[],
);
});
it('still flags a placeholder after a closed inline code span', () => {
const violations = findAngleBracketPlaceholders(
'run `deploy` against <env> now',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('env');
});
it('flags an HTML element name used as a host placeholder', () => {
const violations = findAngleBracketPlaceholders(
'open https://<code>.twenty.com',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('code');
});
it('flags a placeholder inside a path segment', () => {
const violations = findAngleBracketPlaceholders(
'edit packages/<package-name>/README.md',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('package-name');
});
it('does not flag single letter or capitalised tags', () => {
expect(
findAngleBracketPlaceholders('<a>link</a> and <Card>body</Card>'),
).toEqual([]);
});
it('reports every placeholder on a line', () => {
const violations = findAngleBracketPlaceholders(
'<first-id> and <second-id>',
);
expect(violations.map((violation) => violation.name)).toEqual([
'first-id',
'second-id',
]);
});
it('is not blinded by an unpaired backtick earlier in the file', () => {
const violations = findAngleBracketPlaceholders(
'a stray ` backtick\n\nthen use <workspace-id> here',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('workspace-id');
});
it('pairs backtick runs by length', () => {
expect(
findAngleBracketPlaceholders('``code with ` tick and <env>``'),
).toEqual([]);
});
it('does not treat a backtick on a previous line as opening a span', () => {
const violations = findAngleBracketPlaceholders(
'ends with a tick `\nnext line has <api-key>',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('api-key');
});
it('keeps a four-backtick block that shows a triple-backtick example as code', () => {
expect(
findAngleBracketPlaceholders('````md\n```\n<token>\n```\n````'),
).toEqual([]);
});
it('closes a block on a longer fence than the one that opened it', () => {
expect(findAngleBracketPlaceholders('```ini\nKEY=<value>\n````')).toEqual(
[],
);
});
});

View File

@@ -0,0 +1,41 @@
import { globSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isNonEmptyString } from '@sniptt/guards';
import { checkStoryEmbeds } from './ui/check-story-embeds.mjs';
const indexPath = process.argv[2];
if (!isNonEmptyString(indexPath)) {
throw new Error('Pass the path to the generated Storybook index.json.');
}
const index: { entries: Record<string, { id: string; type: string }> } =
JSON.parse(readFileSync(resolve(indexPath), 'utf8'));
const storyIds = new Set(
Object.values(index.entries)
.filter((entry) => entry.type === 'story')
.map((entry) => entry.id),
);
const documentationRoot = fileURLToPath(new URL('..', import.meta.url));
const pages = globSync(
['ui/**/*.mdx', 'l/*/ui/**/*.mdx', 'snippets/ui/**/*.mdx'],
{ cwd: documentationRoot },
).sort();
const errors = pages.flatMap((page) =>
checkStoryEmbeds({
content: readFileSync(resolve(documentationRoot, page), 'utf8'),
storyIds,
}).map((error) => `${page}: ${error}`),
);
if (errors.length > 0) {
process.stderr.write(`${errors.join('\n')}\n`);
process.exitCode = 1;
} else {
process.stdout.write(
`Checked UI documentation embeds against ${storyIds.size} Storybook stories.\n`,
);
}

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Post-processing script to fix internal links in translated documentation
# This script adds the locale prefix to all internal documentation links
# Works for any language by automatically detecting language directories
set -e
if [ -d "packages/twenty-docs" ]; then
DOCS_DIR="packages/twenty-docs/l"
elif [ -d "l" ]; then
DOCS_DIR="l"
else
echo "❌ Error: Cannot find locales directory (l/)"
exit 1
fi
echo "🔧 Fixing internal links in translated documentation..."
for lang_dir in "$DOCS_DIR"/*/ ; do
lang_code=$(basename "$lang_dir")
if [ ! -d "$lang_dir" ] || [ -z "$(ls -A "$lang_dir")" ]; then
continue
fi
echo "📝 Processing $lang_code documentation..."
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|href=\"/getting-started/|href=\"/l/$lang_code/getting-started/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|href=\"/user-guide/|href=\"/l/$lang_code/user-guide/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|href=\"/developers/|href=\"/l/$lang_code/developers/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|href=\"/twenty-ui/|href=\"/l/$lang_code/twenty-ui/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|href=\"/ui/|href=\"/l/$lang_code/ui/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|](/getting-started/|](/l/$lang_code/getting-started/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|](/user-guide/|](/l/$lang_code/user-guide/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|](/developers/|](/l/$lang_code/developers/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|](/twenty-ui/|](/l/$lang_code/twenty-ui/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|](/ui/|](/l/$lang_code/ui/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|https://docs\.twenty\.com/getting-started/|https://docs.twenty.com/l/$lang_code/getting-started/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|https://docs\.twenty\.com/user-guide/|https://docs.twenty.com/l/$lang_code/user-guide/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|https://docs\.twenty\.com/developers/|https://docs.twenty.com/l/$lang_code/developers/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|https://docs\.twenty\.com/twenty-ui/|https://docs.twenty.com/l/$lang_code/twenty-ui/|g" {} \;
find "$lang_dir" -name "*.mdx" -type f -exec sed -i.bak \
"s|https://docs\.twenty\.com/ui/|https://docs.twenty.com/l/$lang_code/ui/|g" {} \;
find "$lang_dir" -name "*.bak" -type f -delete
echo "✅ $lang_code documentation links fixed"
done
echo "🎉 All translated links have been fixed!"

View File

@@ -0,0 +1,169 @@
import fs from 'fs';
import path from 'path';
import {
type BaseGroup,
type BaseStructure,
} from '../navigation/base-structure-types';
import {
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES,
} from '../navigation/supported-languages';
type TranslationGroupEntry = {
label: string;
groups?: Record<string, TranslationGroupEntry>;
};
type TranslationTabEntry = {
label: string;
groups: Record<string, TranslationGroupEntry>;
};
type TranslationFile = {
tabs: Record<string, TranslationTabEntry>;
};
type TranslationMaps = {
tabLabels: Map<string, string>;
groupLabels: Map<string, string>;
};
type GeneratedLanguage = {
language: string;
tabs: Array<{
tab: string;
groups: GeneratedGroup[];
}>;
};
type GeneratedGroup = {
group: string;
icon?: string;
pages: Array<string | GeneratedGroup>;
};
const baseStructurePath = path.resolve(
__dirname,
'../navigation/base-structure.json',
);
const docsPath = path.resolve(__dirname, '../docs.json');
const localesRoot = path.resolve(__dirname, '../l');
const baseStructure: BaseStructure = JSON.parse(
fs.readFileSync(baseStructurePath, 'utf8'),
);
const docsConfig = JSON.parse(fs.readFileSync(docsPath, 'utf8'));
const collectTranslations = (file: TranslationFile | null): TranslationMaps => {
const tabLabels = new Map<string, string>();
const groupLabels = new Map<string, string>();
const collectGroups = (groups?: Record<string, TranslationGroupEntry>) => {
if (!groups) {
return;
}
Object.entries(groups).forEach(([key, group]) => {
groupLabels.set(key, group.label);
collectGroups(group.groups);
});
};
Object.entries(file?.tabs ?? {}).forEach(([key, tab]) => {
tabLabels.set(key, tab.label);
collectGroups(tab.groups);
});
return { tabLabels, groupLabels };
};
const loadTranslationFile = (language: string): TranslationFile | null => {
const translationPath = path.join(localesRoot, language, 'navigation.json');
if (!fs.existsSync(translationPath)) {
return null;
}
return JSON.parse(
fs.readFileSync(translationPath, 'utf8'),
) as TranslationFile;
};
const buildLanguageEntry = (language: string): GeneratedLanguage => {
const translationFile =
language === DEFAULT_LANGUAGE ? null : loadTranslationFile(language);
const translationMaps = collectTranslations(translationFile);
return {
language,
tabs: baseStructure.tabs
.map((tab) => ({
tab: translationMaps.tabLabels.get(tab.key) ?? tab.label,
groups: tab.groups
.map((group) => buildGroup(group, translationMaps, language))
.filter((group): group is GeneratedGroup => group !== null),
}))
.filter((tab) => tab.groups.length > 0),
};
};
// Mintlify requires each page path to appear in only one language's navigation.
// Duplicating a path across languages breaks the language switcher (it can no
// longer resolve the equivalent page and falls back to the first page). So a
// page is only included in a non-default language when its translation exists,
// and empty groups/tabs are dropped entirely.
const buildGroup = (
group: BaseGroup,
translations: TranslationMaps,
language: string,
): GeneratedGroup | null => {
const pages = group.pages
.map((page) =>
typeof page === 'string'
? formatPageSlug(page, language)
: buildGroup(page, translations, language),
)
.filter((page): page is string | GeneratedGroup => page !== null);
if (pages.length === 0) {
return null;
}
return {
group: translations.groupLabels.get(group.key) ?? group.label,
...(group.icon ? { icon: group.icon } : {}),
pages,
};
};
const formatPageSlug = (slug: string, language: string): string | null => {
if (language === DEFAULT_LANGUAGE) {
return slug;
}
const localizedPagePath = path.join(localesRoot, language, `${slug}.mdx`);
return fs.existsSync(localizedPagePath) ? `l/${language}/${slug}` : null;
};
const hasLocaleContent = (language: string): boolean => {
if (language === DEFAULT_LANGUAGE) {
return true;
}
const localeDir = path.join(localesRoot, language);
return fs.existsSync(localeDir);
};
const languages =
SUPPORTED_LANGUAGES.filter(hasLocaleContent).map(buildLanguageEntry);
if (!docsConfig.navigation) {
docsConfig.navigation = {};
}
docsConfig.navigation.languages = languages;
fs.writeFileSync(docsPath, `${JSON.stringify(docsConfig, null, 2)}\n`);

View File

@@ -0,0 +1,101 @@
import fs from 'fs';
import path from 'path';
type BasePage = string | BaseGroup;
type BaseGroup = {
key: string;
label: string;
icon?: string;
pages: BasePage[];
};
type BaseTab = {
key: string;
label: string;
groups: BaseGroup[];
};
type BaseStructure = {
tabs: BaseTab[];
};
const baseStructurePath = path.resolve(
__dirname,
'../navigation/base-structure.json',
);
const outputDir = path.resolve(__dirname, '../../twenty-shared/src/constants');
const baseStructure: BaseStructure = JSON.parse(
fs.readFileSync(baseStructurePath, 'utf8'),
);
const extractPaths = (pages: BasePage[]): string[] => {
const paths: string[] = [];
for (const page of pages) {
if (typeof page === 'string') {
paths.push(page);
} else {
paths.push(...extractPaths(page.pages));
}
}
return paths;
};
const pathToConstantName = (docPath: string): string => {
return docPath.replace(/^\//, '').replace(/[/-]/g, '_').toUpperCase();
};
const allPaths: string[] = [];
for (const tab of baseStructure.tabs) {
for (const group of tab.groups) {
allPaths.push(...extractPaths(group.pages));
}
}
const sortedPaths = [...allPaths].sort();
const AUTO_GENERATED_HEADER = `/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
* | | \\ V V / __/ | | | |_| |_| | DO NOT EDIT - changes will be overwritten
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | Generated by: yarn docs:generate-paths
* |___/
*
* This file is generated from packages/twenty-docs/navigation/base-structure.json
* To add new documentation pages, add them to base-structure.json and run:
* yarn docs:generate-paths
*/
`;
// Generate DocumentationPaths.ts
const pathEntries = sortedPaths
.map((docPath) => {
const constName = pathToConstantName(docPath);
const value = `'/${docPath}'`;
// Check if line would be too long (80 char limit)
if (` ${constName}: ${value},`.length > 80) {
return ` ${constName}:\n ${value},`;
}
return ` ${constName}: ${value},`;
})
.join('\n');
const pathsContent = `${AUTO_GENERATED_HEADER}export const DOCUMENTATION_PATHS = {
${pathEntries}
} as const;
export type DocumentationPath =
(typeof DOCUMENTATION_PATHS)[keyof typeof DOCUMENTATION_PATHS];
`;
fs.writeFileSync(path.join(outputDir, 'DocumentationPaths.ts'), pathsContent);
console.log(`Generated documentation constants to ${outputDir}`);
console.log(`Total paths: ${sortedPaths.length}`);

View File

@@ -0,0 +1,144 @@
/**
* Converts existing User Guide illustrations into halftone style.
* Uses Playwright to run a canvas-based halftone filter in the browser.
* No dev server needed.
*
* Usage:
* node packages/twenty-docs/scripts/generate-halftone-illustrations.mjs
*/
import { chromium } from 'playwright';
import { writeFileSync, readFileSync, mkdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DOCS_IMAGES = join(__dirname, '..', 'images');
const OUTPUT_DIR = join(DOCS_IMAGES, 'user-guide', 'halftone');
const ILLUSTRATIONS = [
{ name: 'data-model', source: 'user-guide/fields/custom_data_model.png' },
{ name: 'data-migration', source: 'user-guide/import-export-data/cloud.png' },
{ name: 'calendar-emails', source: 'user-guide/emails/emails_header.png' },
{ name: 'workflows', source: 'user-guide/workflows/workflow.png' },
{ name: 'ai', source: 'user-guide/workflows/robot.png' },
{ name: 'layout', source: 'user-guide/table-views/table_pink.png' },
{ name: 'dashboards', source: 'user-guide/reporting/pie-chart.png' },
{ name: 'permissions', source: 'user-guide/permissions/permissions.png' },
{ name: 'billing', source: 'user-guide/setup/pricing.png' },
{ name: 'settings', source: 'user-guide/setup/settings.png' },
// Developers section
{ name: 'dev-apps', source: 'user-guide/integrations/plug.png' },
{ name: 'dev-api', source: 'user-guide/api/api-overview.png' },
{ name: 'dev-self-host', source: 'user-guide/what-is-twenty/20.png' },
{ name: 'dev-contribute', source: 'user-guide/github/github-header.png' },
];
async function main() {
mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setContent('<html><body></body></html>');
console.log(
`Converting ${ILLUSTRATIONS.length} illustrations to halftone...\n`,
);
for (const illust of ILLUSTRATIONS) {
const sourcePath = resolve(DOCS_IMAGES, illust.source);
console.log(` ${illust.name} ← ${illust.source}`);
const imageBytes = readFileSync(sourcePath);
const base64 = imageBytes.toString('base64');
const mime = sourcePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
const resultBase64 = await page.evaluate(
async ({ b64, m }) => {
const LINE_SPACING = 8;
const MIN_LINE_WIDTH = 0.5;
const MAX_LINE_WIDTH = 6;
const COLOR = { r: 74, g: 56, b: 245 }; // #4A38F5
const TRANSPARENT_BG = true;
const img = new Image();
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = `data:${m};base64,${b64}`;
});
const { width, height } = img;
const scale = 2;
const outW = width * scale;
const outH = height * scale;
// Read source pixels
const tmp = document.createElement('canvas');
tmp.width = width;
tmp.height = height;
const tmpCtx = tmp.getContext('2d');
tmpCtx.drawImage(img, 0, 0);
const px = tmpCtx.getImageData(0, 0, width, height).data;
// Output canvas
const out = document.createElement('canvas');
out.width = outW;
out.height = outH;
const ctx = out.getContext('2d');
// Transparent background — adapts to both light and dark mode
const sp = LINE_SPACING * scale;
for (let y = sp / 2; y < outH; y += sp) {
const srcY = Math.min(Math.floor(y / scale), height - 1);
let x = 0;
while (x < outW) {
const srcX = Math.min(Math.floor(x / scale), width - 1);
const i = (srcY * width + srcX) * 4;
const lum =
(0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]) / 255;
const a = px[i + 3] / 255;
const dark = (1 - lum) * a;
if (dark > 0.2) {
const w =
(MIN_LINE_WIDTH + (MAX_LINE_WIDTH - MIN_LINE_WIDTH) * dark) *
scale;
const dashLen = sp * (0.3 + 0.7 * dark);
const gap = sp * (0.1 + 0.4 * (1 - dark));
const alpha = 0.3 + 0.7 * dark;
ctx.strokeStyle = `rgba(${COLOR.r},${COLOR.g},${COLOR.b},${alpha})`;
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(Math.min(x + dashLen, outW), y);
ctx.stroke();
x += dashLen + gap;
} else {
x += sp * 0.5;
}
}
}
return out.toDataURL('image/png').split(',')[1];
},
{ b64: base64, m: mime },
);
const outputPath = join(OUTPUT_DIR, `${illust.name}.png`);
writeFileSync(outputPath, Buffer.from(resultBase64, 'base64'));
console.log(` → ${outputPath}`);
}
await browser.close();
console.log('\nDone!');
}
main().catch((err) => {
console.error('Failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,65 @@
import fs from 'fs';
import path from 'path';
import {
type BaseGroup,
type BaseStructure,
} from '../navigation/base-structure-types';
type TemplateGroup = {
label: string;
groups?: Record<string, TemplateGroup>;
};
type TemplateTab = {
label: string;
groups: Record<string, TemplateGroup>;
};
type TemplateFile = {
tabs: Record<string, TemplateTab>;
};
const baseStructurePath = path.resolve(
__dirname,
'../navigation/base-structure.json',
);
const templatePath = path.resolve(
__dirname,
'../navigation/navigation.template.json',
);
const baseStructure: BaseStructure = JSON.parse(
fs.readFileSync(baseStructurePath, 'utf8'),
);
const buildGroupMap = (groups: BaseGroup[]): Record<string, TemplateGroup> =>
groups.reduce<Record<string, TemplateGroup>>((acc, group) => {
const nestedGroups = group.pages.filter(
(page): page is BaseGroup => typeof page !== 'string',
);
acc[group.key] = {
label: group.label,
...(nestedGroups.length > 0
? { groups: buildGroupMap(nestedGroups) }
: {}),
};
return acc;
}, {});
const template: TemplateFile = {
tabs: baseStructure.tabs.reduce<Record<string, TemplateTab>>(
(acc, tab) => ({
...acc,
[tab.key]: {
label: tab.label,
groups: buildGroupMap(tab.groups),
},
}),
{},
),
};
fs.writeFileSync(templatePath, `${JSON.stringify(template, null, 2)}\n`);

View File

@@ -0,0 +1,69 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
type ComponentDocumentation,
type TokenDocumentation,
} from '../../twenty-ui/docs/types';
import { checkUiReferenceImports } from './ui/check-ui-reference-imports';
import { renderComponentReference } from './ui/render-component-reference';
import { renderTokenReference } from './ui/render-ui-reference';
import { syncUiReferenceFiles } from './ui/sync-ui-reference-files';
const GENERATED_HEADER = `{/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
* | | \\ V V / __/ | | | |_| |_| | DO NOT EDIT - changes will be overwritten
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | Generated by: npx nx generate:ui twenty-docs
* |___/
*
* This file is generated from the documentation data in packages/twenty-ui/generated.
* To update it, edit the component prop types or design tokens in twenty-ui and run:
* npx nx generate:ui twenty-docs
*/}`;
const dataRoot = resolve(__dirname, '../../twenty-ui/generated');
const snippetsRoot = resolve(__dirname, '../snippets/ui/generated');
const pagesRoot = resolve(__dirname, '../ui');
const components: ComponentDocumentation[] = JSON.parse(
readFileSync(resolve(dataRoot, 'components.docs.json'), 'utf8'),
);
const tokens: TokenDocumentation[] = JSON.parse(
readFileSync(resolve(dataRoot, 'tokens.docs.json'), 'utf8'),
);
const outputs = [
...components.map((component) => ({
name: `${component.slug}.mdx`,
content: renderComponentReference(component),
})),
{ name: 'tokens.mdx', content: renderTokenReference(tokens) },
];
const syncErrors = syncUiReferenceFiles({
directory: snippetsRoot,
outputs: outputs.map((output) => ({
...output,
content: `${GENERATED_HEADER}\n\n${output.content}`,
})),
isCheckMode: process.argv.includes('--check'),
});
if (syncErrors.length > 0) {
process.stderr.write(
`${syncErrors.join('\n')}\nRun npx nx generate:ui twenty-docs.\n`,
);
process.exitCode = 1;
}
const importErrors = checkUiReferenceImports({
pagesDirectory: pagesRoot,
outputs,
});
if (importErrors.length > 0) {
process.stderr.write(
`${importErrors.join('\n')}\nKeep the pages under ui/ and DOCUMENTED_COMPONENTS in twenty-ui/docs/components.ts in sync.\n`,
);
process.exitCode = 1;
}

View File

@@ -0,0 +1,266 @@
import fs from 'fs';
import path from 'path';
// Crowdin parses `<foo>` in prose as a tag rather than literal text, so an angle
// bracket placeholder is either dropped or mangled in every translated page.
// Curly braces `{foo}` survive the round trip.
const DOCS_ROOT = path.resolve(__dirname, '..');
const IGNORED_DIRECTORIES = ['node_modules', 'l', 'images', 'scripts'];
const HTML_ELEMENTS = [
'abbr',
'article',
'aside',
'blockquote',
'br',
'button',
'cite',
'code',
'dd',
'del',
'details',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'iframe',
'img',
'input',
'ins',
'kbd',
'label',
'li',
'main',
'mark',
'nav',
'ol',
'option',
'picture',
'pre',
'samp',
'section',
'select',
'small',
'source',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'textarea',
'tfoot',
'th',
'thead',
'tr',
'ul',
'var',
'video',
];
const PLACEHOLDER_PATTERN = /<([a-z][a-z0-9_-]+)>/g;
const URL_PREFIX_PATTERN = /https?:\/\/$/;
export type MdxViolation = {
line: number;
column: number;
text: string;
name: string;
};
type Range = {
start: number;
end: number;
};
const FENCE_LINE_PATTERN = /^\s*(`{3,})/;
// A fence closes only on a run at least as long as the one that opened it, so a
// block opened with ``` is not closed by the first ``` inside a ```` example.
const getFencedCodeRanges = (text: string): Range[] => {
const ranges: Range[] = [];
const lines = text.split('\n');
let lineStart = 0;
let openStart: number | null = null;
let openLength = 0;
for (const line of lines) {
const fence = FENCE_LINE_PATTERN.exec(line);
if (openStart === null) {
if (fence) {
openStart = lineStart;
openLength = fence[1].length;
}
} else if (
fence &&
fence[1].length >= openLength &&
line.slice(fence[0].length).trim() === ''
) {
ranges.push({ start: openStart, end: lineStart + line.length });
openStart = null;
}
lineStart += line.length + 1;
}
return ranges;
};
const isInsideRange = (position: number, ranges: Range[]) =>
ranges.some((range) => position >= range.start && position < range.end);
// Backtick runs are paired within a line, never across one. A running parity
// counter would let a single unpaired backtick silently suppress every finding
// in the rest of the file.
const getInlineCodeRanges = (text: string, fencedRanges: Range[]): Range[] => {
const ranges: Range[] = [];
let lineStart = 0;
for (const line of text.split('\n')) {
const runs: { start: number; length: number }[] = [];
for (const match of line.matchAll(/`+/g)) {
const start = lineStart + match.index;
if (!isInsideRange(start, fencedRanges)) {
runs.push({ start, length: match[0].length });
}
}
const unclosed: typeof runs = [];
for (const run of runs) {
const openerIndex = unclosed.findIndex(
(candidate) => candidate.length === run.length,
);
if (openerIndex === -1) {
unclosed.push(run);
continue;
}
ranges.push({
start: unclosed[openerIndex].start,
end: run.start + run.length,
});
unclosed.splice(0, openerIndex + 1);
}
lineStart += line.length + 1;
}
return ranges;
};
const getLineAndColumn = (text: string, position: number) => {
const precedingText = text.slice(0, position);
const lines = precedingText.split('\n');
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
};
};
export const findAngleBracketPlaceholders = (text: string): MdxViolation[] => {
const fencedRanges = getFencedCodeRanges(text);
const inlineCodeRanges = getInlineCodeRanges(text, fencedRanges);
const violations: MdxViolation[] = [];
for (const match of text.matchAll(PLACEHOLDER_PATTERN)) {
const name = match[1];
const position = match.index;
const isUrlPlaceholder = URL_PREFIX_PATTERN.test(text.slice(0, position));
if (HTML_ELEMENTS.includes(name) && !isUrlPlaceholder) {
continue;
}
if (
isInsideRange(position, fencedRanges) ||
isInsideRange(position, inlineCodeRanges)
) {
continue;
}
violations.push({
...getLineAndColumn(text, position),
text: match[0],
name,
});
}
return violations;
};
const collectMdxFiles = (directory: string, collected: string[] = []) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (IGNORED_DIRECTORIES.includes(entry.name)) {
continue;
}
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectMdxFiles(entryPath, collected);
} else if (entry.name.endsWith('.mdx')) {
collected.push(entryPath);
}
}
return collected;
};
const main = () => {
const files = collectMdxFiles(DOCS_ROOT);
let violationCount = 0;
for (const file of files) {
const violations = findAngleBracketPlaceholders(
fs.readFileSync(file, 'utf8'),
);
for (const violation of violations) {
const relativePath = path.relative(DOCS_ROOT, file);
console.error(
`${relativePath}:${violation.line}:${violation.column} ${violation.text} reads as a tag in Crowdin, use {${violation.name}} instead`,
);
}
violationCount += violations.length;
}
if (violationCount > 0) {
console.error(
`\n${violationCount} angle bracket placeholder(s) found in ${files.length} MDX files.`,
);
process.exit(1);
}
console.log(`No angle bracket placeholders in ${files.length} MDX files.`);
};
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,116 @@
import fs from 'fs';
import path from 'path';
// Removes orphan localized docs: files under packages/twenty-docs/l/<lang>/**
// whose corresponding English source page no longer exists. The Crowdin i18n
// pull only ever adds/updates files (never deletes), so when an English page is
// renamed, moved, or removed, its localized copies linger and keep serving
// dead/stale URLs. This keeps the localized tree in sync with the source tree.
//
// DRY-RUN by default (prints what it would remove); pass --apply to delete.
//
// Usage:
// tsx packages/twenty-docs/scripts/prune-orphan-translations.ts
// tsx packages/twenty-docs/scripts/prune-orphan-translations.ts --apply
const DOCS_ROOT = path.resolve(__dirname, '..');
const LOCALIZED_DIR = path.join(DOCS_ROOT, 'l');
// Safety: never delete a language's localized files wholesale. If more than this
// fraction of a language's files look orphaned, something is wrong (e.g. the
// English source tree wasn't checked out) — abort instead of pruning.
const MAX_ORPHAN_RATIO_PER_LANGUAGE = 0.25;
const apply = process.argv.slice(2).includes('--apply');
const walkMdxFiles = (dir: string): string[] => {
const result: string[] = [];
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (fs.statSync(fullPath).isDirectory()) {
result.push(...walkMdxFiles(fullPath));
} else if (entry.endsWith('.mdx')) {
result.push(fullPath);
}
}
return result;
};
// English source path a localized file mirrors: strip the leading `l/<lang>/`.
const sourcePathOf = (localizedFile: string): string => {
const relativeToL = path.relative(LOCALIZED_DIR, localizedFile);
const segments = relativeToL.split(path.sep);
// segments[0] is the language code; the rest is the source-relative path.
return path.join(DOCS_ROOT, ...segments.slice(1));
};
const languageOf = (localizedFile: string): string =>
path.relative(LOCALIZED_DIR, localizedFile).split(path.sep)[0];
const main = (): void => {
if (!fs.existsSync(LOCALIZED_DIR)) {
console.log('No localized docs directory (packages/twenty-docs/l) — nothing to prune.');
return;
}
// Sanity guard: if the English source tree is empty, the checkout is broken;
// refuse to prune so we never mass-delete translations by mistake.
const englishFileCount = walkMdxFiles(DOCS_ROOT).filter(
(file) => !file.startsWith(`${LOCALIZED_DIR}${path.sep}`),
).length;
if (englishFileCount === 0) {
throw new Error(
'Refusing to prune: found 0 English source .mdx files (source tree missing?).',
);
}
const localizedFiles = walkMdxFiles(LOCALIZED_DIR);
const orphans = localizedFiles.filter(
(file) => !fs.existsSync(sourcePathOf(file)),
);
if (orphans.length === 0) {
console.log(`No orphan localized files (${localizedFiles.length} scanned).`);
return;
}
// Per-language blast-radius check.
const filesByLanguage = new Map<string, number>();
const orphansByLanguage = new Map<string, number>();
for (const file of localizedFiles) {
const language = languageOf(file);
filesByLanguage.set(language, (filesByLanguage.get(language) ?? 0) + 1);
}
for (const file of orphans) {
const language = languageOf(file);
orphansByLanguage.set(language, (orphansByLanguage.get(language) ?? 0) + 1);
}
for (const [language, orphanCount] of orphansByLanguage) {
const total = filesByLanguage.get(language) ?? 0;
const ratio = total === 0 ? 1 : orphanCount / total;
if (ratio > MAX_ORPHAN_RATIO_PER_LANGUAGE) {
throw new Error(
`Refusing to prune: ${orphanCount}/${total} (${Math.round(ratio * 100)}%) of "${language}" files look orphaned, ` +
`over the ${Math.round(MAX_ORPHAN_RATIO_PER_LANGUAGE * 100)}% safety cap. Check the source checkout before pruning.`,
);
}
}
console.log(
`${apply ? 'Removing' : 'Would remove'} ${orphans.length} orphan localized file(s) of ${localizedFiles.length} scanned:`,
);
for (const file of orphans.sort()) {
console.log(` ${apply ? 'DELETE' : 'orphan'} ${path.relative(DOCS_ROOT, file)}`);
if (apply) {
fs.rmSync(file);
}
}
console.log(
`${apply ? 'Removed' : 'Would remove'}: ${orphans.length} file(s) across ${orphansByLanguage.size} language(s).`,
);
};
main();

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { checkStoryEmbeds } from '../check-story-embeds.mjs';
const storyIds = new Set(['ui-input-input--default']);
describe('checkStoryEmbeds', () => {
it('accepts an existing story inside a nested preview', () => {
expect(
checkStoryEmbeds({
content: `<Tabs>
<Tab title="Preview">
<StoryEmbed storyId="ui-input-input--default" />
</Tab>
</Tabs>`,
storyIds,
}),
).toEqual([]);
});
it('reports a renamed or deleted story with its line number', () => {
expect(
checkStoryEmbeds({
content: '\n<StoryEmbed storyId="ui-input-input--removed" />',
storyIds,
}),
).toEqual([
'Line 2: Storybook story "ui-input-input--removed" does not exist.',
]);
});
it.each([
'<StoryEmbed />',
'<StoryEmbed storyId={selectedStory} />',
'<StoryEmbed storyId="" />',
'<StoryEmbed storyId="ui-input-input--default" {...props} />',
])('rejects an unverifiable story ID in %s', (content) => {
expect(checkStoryEmbeds({ content, storyIds })).toEqual([
'Line 1: StoryEmbed needs a literal storyId.',
]);
});
it('checks a renamed snippet import', () => {
expect(
checkStoryEmbeds({
content: `import { StoryEmbed as Preview } from '/snippets/ui/StoryEmbed.mdx';
<Preview storyId="removed-story" />`,
storyIds,
}),
).toEqual(['Line 3: Storybook story "removed-story" does not exist.']);
});
it('ignores examples and comments while parsing page metadata', () => {
expect(
checkStoryEmbeds({
content: `---
title: Preview
description: "A {component} preview"
---
\`\`\`mdx
<StoryEmbed storyId="not-a-live-embed" />
\`\`\`
\`<StoryEmbed storyId="inline-code" />\`
{/* <StoryEmbed storyId="commented-out" /> */}`,
storyIds,
}),
).toEqual([]);
});
});

View File

@@ -0,0 +1,48 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { checkUiReferenceImports } from '../check-ui-reference-imports';
describe('UI reference imports', () => {
let pagesDirectory: string;
beforeEach(() => {
pagesDirectory = mkdtempSync(join(tmpdir(), 'twenty-ui-pages-test-'));
mkdirSync(join(pagesDirectory, 'components/input'), { recursive: true });
writeFileSync(
join(pagesDirectory, 'components/input/checkbox.mdx'),
"---\ntitle: Checkbox\n---\n\nimport CheckboxReference from '/snippets/ui/generated/input/checkbox.mdx';\n\n<CheckboxReference />\n",
);
writeFileSync(
join(pagesDirectory, 'tokens.mdx'),
"import TokenReference from '/snippets/ui/generated/tokens.mdx'\n",
);
});
afterEach(() => {
rmSync(pagesDirectory, { recursive: true, force: true });
});
it('passes when every generated reference is imported by a page', () => {
expect(
checkUiReferenceImports({
pagesDirectory,
outputs: [{ name: 'input/checkbox.mdx' }, { name: 'tokens.mdx' }],
}),
).toEqual([]);
});
it('reports pages importing missing references and references no page imports', () => {
expect(
checkUiReferenceImports({
pagesDirectory,
outputs: [{ name: 'input/switch.mdx' }, { name: 'tokens.mdx' }],
}),
).toEqual([
'Missing UI reference: components/input/checkbox.mdx imports input/checkbox.mdx, which is not generated.',
'Unused UI reference: input/switch.mdx is not imported by any page.',
]);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { normalizeDocumentationDefaultValue } from '../../../../twenty-ui/docs/normalizeDocumentationDefaultValue';
import { renderComponentReference } from '../render-component-reference';
describe('documentation defaults', () => {
it.each([
["'onSubmit'", 'onSubmit'],
['"horizontal"', 'horizontal'],
["'it\\'s ready'", "it's ready"],
['"undefined"', 'undefined'],
["''", ''],
['accent', 'accent'],
[false, 'false'],
[0, '0'],
[null, 'null'],
[undefined, null],
['undefined', null],
])('normalizes %j to %j', (value, expected) => {
expect(normalizeDocumentationDefaultValue({ value })).toBe(expected);
});
it('omits absent defaults and renders string defaults without display quotes', () => {
const reference = renderComponentReference({
name: 'Example',
entryPoint: 'twenty-ui/primitives/input',
slug: 'input/example',
props: [
{
name: 'mode',
type: 'string',
required: false,
defaultValue: normalizeDocumentationDefaultValue({
value: "'onSubmit'",
}),
description: '',
},
{
name: 'checked',
type: 'boolean',
required: false,
defaultValue: normalizeDocumentationDefaultValue({
value: 'undefined',
}),
description: '',
},
],
});
expect(normalizeDocumentationDefaultValue(null)).toBeNull();
expect(reference).toContain('default="onSubmit"');
expect(reference.match(/default=/g)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { extractDocumentationExamples } from '../../../../twenty-ui/docs/extractDocumentationExamples';
describe('documentation examples', () => {
it('extracts fences with info strings, indentation, and other fence markers', () => {
const examples = extractDocumentationExamples(
[
'```tsx App.tsx',
'const app = 1;',
'```',
'<Tabs>',
' ```TSX title="Indented"',
' const indented = 2;',
' ```',
'</Tabs>',
'~~~ts',
'const tilde = 3;',
'~~~',
'````tsx',
'```',
'const nested = 4;',
'````',
'```bash',
'yarn add twenty-ui',
'```',
'```css',
'.summary {}',
'```',
].join('\n'),
);
expect(examples).toEqual([
{ language: 'tsx', code: 'const app = 1;\n' },
{ language: 'tsx', code: 'const indented = 2;\n' },
{ language: 'ts', code: 'const tilde = 3;\n' },
{ language: 'tsx', code: '```\nconst nested = 4;\n' },
]);
});
it('handles CRLF line endings and ignores an unterminated fence', () => {
expect(
extractDocumentationExamples(
'```tsx\r\nconst app = 1;\r\n```\r\n```tsx\r\nconst open = 2;\r\n',
),
).toEqual([{ language: 'tsx', code: 'const app = 1;\n' }]);
});
});

View File

@@ -0,0 +1,66 @@
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import { getDocumentationImportDiagnostics } from '../../../../twenty-ui/docs/getDocumentationImportDiagnostics';
const allowedModules = new Set([
'twenty-ui',
'twenty-ui/primitives/input',
'twenty-ui/style.css',
'react',
]);
const checkImports = (content: string) =>
getDocumentationImportDiagnostics({
source: ts.createSourceFile(
'example.tsx',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
),
allowedModules,
});
describe('documentation imports', () => {
it('allows public entry points, exported CSS, and peer dependencies', () => {
expect(
checkImports(`
import { Input } from 'twenty-ui/primitives/input';
import 'twenty-ui/style.css';
export { Text } from 'twenty-ui';
import { useState } from 'react';
`),
).toEqual([]);
});
it.each([
"import { Internal } from 'twenty-ui/internal';",
"import 'twenty-ui/private.css';",
"export { Internal } from 'twenty-ui/internal';",
"const component = import('twenty-ui/internal');",
"type Internal = import('twenty-ui/internal').Internal;",
"const component = require('twenty-ui/internal');",
])('rejects an unpublished subpath in %s', (content) => {
const diagnostics = checkImports(content);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].category).toBe(ts.DiagnosticCategory.Error);
expect(diagnostics[0].messageText).toContain(
'is not exported by twenty-ui/package.json',
);
});
it.each([
"import { Checkbox } from '@ui/primitives/input/Checkbox/Checkbox';",
"import { Input } from '../../twenty-ui/src/input';",
"import { vi } from 'vitest';",
])('rejects a module outside the public surface in %s', (content) => {
const diagnostics = checkImports(content);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].messageText).toContain(
'is not a twenty-ui entry point or peer dependency',
);
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { normalizeDocumentationPropType } from '../../../../twenty-ui/docs/normalizeDocumentationPropType';
describe('documentation prop types', () => {
it.each([
[
{
type: {
name: 'string | ((state: State) => string | undefined) | undefined',
},
required: false,
},
'string | ((state: State) => string | undefined)',
],
[
{
type: {
name: '((details: { trigger: Element | undefined; }) => void) | undefined',
},
required: false,
},
'((details: { trigger: Element | undefined; }) => void)',
],
[
{ type: { name: 'Element | undefined' }, required: true },
'Element | undefined',
],
[{ type: { name: 'boolean' }, required: false }, 'boolean'],
[
{
type: {
name: 'enum',
value: [{ value: '"sm"' }, { value: '"md"' }, { value: 'undefined' }],
},
required: false,
},
'"sm" | "md"',
],
[
{
type: {
name: 'enum',
value: [{ value: '"sm"' }, { value: 'undefined' }],
},
required: true,
},
'"sm" | undefined',
],
])('normalizes %j to %j', (input, expected) => {
expect(normalizeDocumentationPropType(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { formatDocumentationTokenValue } from '../../../../twenty-ui/docs/formatDocumentationTokenValue';
describe('documentation token values', () => {
it.each([
[
'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIYAAABQCA), url(data:image/jpeg;base64,/9j/4AAQ)',
'url(data:image/png;base64,...), url(data:image/jpeg;base64,...)',
],
[
'color(display-p3 0.992 0.992 0.996)',
'color(display-p3 0.992 0.992 0.996)',
],
['url(/noise.png)', 'url(/noise.png)'],
['16', '16'],
])('formats %s as %s', (value, expected) => {
expect(formatDocumentationTokenValue(value)).toBe(expected);
});
});

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import { renderComponentReference } from '../render-component-reference';
import { renderTokenReference } from '../render-ui-reference';
describe('renderComponentReference', () => {
it('escapes attributes and prose while keeping markdown and code intact', () => {
const reference = renderComponentReference({
name: 'Example',
entryPoint: 'twenty-ui/primitives/input',
slug: 'input/example',
props: [
{
name: 'render',
type: 'Ref<custom> | "quoted"',
required: true,
defaultValue: null,
description:
'Render `<custom>` with {value} & more.\n\n- `unmount`: Manually unmounts.\n\n```tsx\n<Example render={<span />} />\n```',
},
],
});
expect(reference).toBe(
[
'<ParamField body="render" type="Ref&lt;custom&gt; | &quot;quoted&quot;" required>',
' Render `<custom>` with &#123;value&#125; &amp; more.',
'',
' - `unmount`: Manually unmounts.',
'',
' ```tsx',
' <Example render={<span />} />',
' ```',
'</ParamField>',
'',
].join('\n'),
);
});
});
describe('compound component references', () => {
it('includes convenience props before compound parts', () => {
const reference = renderComponentReference({
name: 'Tooltip',
entryPoint: 'twenty-ui/primitives/surfaces',
slug: 'surfaces/tooltip',
props: [
{
name: 'content',
type: 'ReactNode',
required: true,
defaultValue: null,
description: 'Tooltip content.',
},
],
parts: [
{
name: 'Popup',
props: [
{
name: 'arrow',
type: 'boolean',
required: false,
defaultValue: 'false',
description: 'Show an arrow.',
},
],
},
],
});
expect(reference).toContain('<ParamField body="content"');
expect(reference).toContain('### Tooltip.Popup');
expect(reference).toContain('<ParamField body="Popup.arrow"');
expect(reference.indexOf('body="content"')).toBeLessThan(
reference.indexOf('### Tooltip.Popup'),
);
});
it('distinguishes identically named props on different parts and retains defaults', () => {
const reference = renderComponentReference({
name: 'Menu',
entryPoint: 'twenty-ui/primitives/surfaces',
slug: 'surfaces/menu',
props: [],
parts: [
{
name: 'Item',
props: [
{
name: 'closeOnClick',
type: 'boolean',
required: false,
defaultValue: 'true',
description: 'Close after selection.',
},
],
},
{
name: 'CheckboxItem',
props: [
{
name: 'closeOnClick',
type: 'boolean',
required: false,
defaultValue: 'false',
description: 'Keep open for multiple choices.',
},
],
},
],
});
expect(reference).toContain('### Menu.Item');
expect(reference).toContain('### Menu.CheckboxItem');
expect(reference).toContain(
'<ParamField body="Item.closeOnClick" type="boolean" default="true">',
);
expect(reference).toContain(
'<ParamField body="CheckboxItem.closeOnClick" type="boolean" default="false">',
);
});
});
describe('renderTokenReference', () => {
it('keeps token expressions inside one table cell and distinguishes numeric values', () => {
const reference = renderTokenReference([
{
path: 'spacing.1',
cssVariable: '--t-spacing-1',
light: 'calc(1px | <custom> & {value})\nnext',
dark: '4px',
isNumber: false,
},
{
path: 'icon.size.md',
cssVariable: '--t-icon-size-md',
light: '16',
dark: '16',
isNumber: true,
},
]);
const rows = reference
.split('\n')
.filter(
(line) => line.startsWith('| spacing.') || line.startsWith('| icon.'),
);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.split('|').length === 7)).toBe(true);
expect(reference).not.toContain('<custom>');
expect(rows[1]).toContain('| Yes |');
expect(reference).toContain('## spacing');
expect(reference).toContain('## icon');
});
});

View File

@@ -0,0 +1,71 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { syncUiReferenceFiles } from '../sync-ui-reference-files';
describe('generated UI reference files', () => {
let directory: string;
const outputs = [
{ name: 'input/current.mdx', content: 'Current reference\n' },
];
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twenty-ui-reference-test-'));
mkdirSync(join(directory, 'input'));
writeFileSync(join(directory, 'input/obsolete.mdx'), 'Old reference\n');
});
afterEach(() => {
rmSync(directory, { recursive: true, force: true });
});
it('rejects obsolete and missing references without changing files in check mode', () => {
expect(
syncUiReferenceFiles({ directory, outputs, isCheckMode: true }),
).toEqual([
'Obsolete UI reference: input/obsolete.mdx',
'Stale UI reference: input/current.mdx',
]);
expect(readFileSync(join(directory, 'input/obsolete.mdx'), 'utf8')).toBe(
'Old reference\n',
);
expect(existsSync(join(directory, 'input/current.mdx'))).toBe(false);
});
it('removes obsolete snippets, writes current outputs, and passes a subsequent check', () => {
writeFileSync(join(directory, 'metadata.json'), '{}');
expect(
syncUiReferenceFiles({ directory, outputs, isCheckMode: false }),
).toEqual([]);
expect(existsSync(join(directory, 'input/obsolete.mdx'))).toBe(false);
expect(readFileSync(join(directory, 'input/current.mdx'), 'utf8')).toBe(
outputs[0].content,
);
expect(existsSync(join(directory, 'metadata.json'))).toBe(true);
expect(
syncUiReferenceFiles({ directory, outputs, isCheckMode: true }),
).toEqual([]);
});
it('reports changed content without overwriting it in check mode', () => {
rmSync(join(directory, 'input/obsolete.mdx'));
writeFileSync(join(directory, 'input/current.mdx'), 'Outdated reference\n');
expect(
syncUiReferenceFiles({ directory, outputs, isCheckMode: true }),
).toEqual(['Stale UI reference: input/current.mdx']);
expect(readFileSync(join(directory, 'input/current.mdx'), 'utf8')).toBe(
'Outdated reference\n',
);
});
});

View File

@@ -0,0 +1,73 @@
import { createProcessor } from '@mdx-js/mdx';
import remarkFrontmatter from 'remark-frontmatter';
import { isNonEmptyString } from '@sniptt/guards';
import { visit } from 'unist-util-visit';
export const checkStoryEmbeds = ({
content,
storyIds,
}: {
content: string;
storyIds: Set<string>;
}): string[] => {
const errors: string[] = [];
const tree = createProcessor({
remarkPlugins: [remarkFrontmatter],
}).parse(content);
const embedNames = new Set(['StoryEmbed']);
visit(tree, 'mdxjsEsm', (node) => {
for (const statement of node.data?.estree?.body ?? []) {
if (
statement.type !== 'ImportDeclaration' ||
statement.source.value !== '/snippets/ui/StoryEmbed.mdx'
) {
continue;
}
for (const specifier of statement.specifiers) {
const isNamedEmbed =
specifier.type === 'ImportSpecifier' &&
specifier.imported.type === 'Identifier' &&
specifier.imported.name === 'StoryEmbed';
if (specifier.type === 'ImportDefaultSpecifier' || isNamedEmbed) {
embedNames.add(specifier.local.name);
}
}
}
});
visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
if (
(node.type !== 'mdxJsxFlowElement' &&
node.type !== 'mdxJsxTextElement') ||
!embedNames.has(node.name ?? '')
) {
return;
}
const storyId = node.attributes.find(
(attribute) =>
attribute.type === 'mdxJsxAttribute' && attribute.name === 'storyId',
);
const value =
storyId?.type === 'mdxJsxAttribute' ? storyId.value : undefined;
const line = node.position?.start.line;
const hasSpreadAttributes = node.attributes.some(
(attribute) => attribute.type === 'mdxJsxExpressionAttribute',
);
if (!isNonEmptyString(value) || hasSpreadAttributes) {
errors.push(`Line ${line}: StoryEmbed needs a literal storyId.`);
return;
}
if (!storyIds.has(value)) {
errors.push(`Line ${line}: Storybook story "${value}" does not exist.`);
}
});
return errors;
};

View File

@@ -0,0 +1,39 @@
import { globSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const REFERENCE_IMPORT_PATTERN =
/^import\s+\w+\s+from\s+'\/snippets\/ui\/generated\/([^']+)';?$/gm;
export const checkUiReferenceImports = ({
pagesDirectory,
outputs,
}: {
pagesDirectory: string;
outputs: { name: string }[];
}): string[] => {
const errors: string[] = [];
const generatedNames = new Set(outputs.map((output) => output.name));
const importedNames = new Set<string>();
for (const page of globSync('**/*.mdx', { cwd: pagesDirectory }).sort()) {
const content = readFileSync(resolve(pagesDirectory, page), 'utf8');
for (const [, name] of content.matchAll(REFERENCE_IMPORT_PATTERN)) {
importedNames.add(name);
if (!generatedNames.has(name)) {
errors.push(
`Missing UI reference: ${page} imports ${name}, which is not generated.`,
);
}
}
}
for (const name of generatedNames) {
if (!importedNames.has(name)) {
errors.push(`Unused UI reference: ${name} is not imported by any page.`);
}
}
return errors;
};

View File

@@ -0,0 +1,72 @@
import { type ComponentDocumentation } from '../../../twenty-ui/docs/types';
const CODE_SEGMENT_PATTERN = /(`{3,})[\s\S]*?\1|`[^`\n]+`/g;
const escapeAttributeValue = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const escapeProseText = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;');
const escapeMarkdown = (markdown: string): string => {
let escaped = '';
let proseStart = 0;
for (const codeSegment of markdown.matchAll(CODE_SEGMENT_PATTERN)) {
escaped +=
escapeProseText(markdown.slice(proseStart, codeSegment.index)) +
codeSegment[0];
proseStart = codeSegment.index + codeSegment[0].length;
}
return escaped + escapeProseText(markdown.slice(proseStart));
};
const indent = (text: string): string =>
text
.split('\n')
.map((line) => (line.length > 0 ? ` ${line}` : line))
.join('\n');
const renderProps = ({
props,
partName,
}: {
props: ComponentDocumentation['props'];
partName?: string;
}): string =>
props
.map((prop) => {
const attributes = [
`body="${escapeAttributeValue(partName ? `${partName}.${prop.name}` : prop.name)}"`,
`type="${escapeAttributeValue(prop.type)}"`,
...(prop.required ? ['required'] : []),
...(prop.defaultValue === null
? []
: [`default="${escapeAttributeValue(prop.defaultValue)}"`]),
];
return `<ParamField ${attributes.join(' ')}>\n${indent(escapeMarkdown(prop.description))}\n</ParamField>`;
})
.join('\n\n') + '\n';
export const renderComponentReference = (
component: ComponentDocumentation,
): string =>
[
...(component.props.length > 0
? [renderProps({ props: component.props })]
: []),
...(component.parts ?? []).map(
(part) =>
`### ${component.name}.${part.name}\n\n${renderProps({ props: part.props, partName: part.name })}`,
),
].join('\n');

View File

@@ -0,0 +1,43 @@
import { type TokenDocumentation } from '../../../twenty-ui/docs/types';
const escapeTableCell = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;')
.replace(/\|/g, '&#124;')
.replace(/\r?\n/g, ' ');
export const renderTokenReference = (tokens: TokenDocumentation[]): string => {
const groups = new Map<string, TokenDocumentation[]>();
for (const token of tokens) {
const group = token.path.split('.')[0];
const entries = groups.get(group);
if (entries) {
entries.push(token);
} else {
groups.set(group, [token]);
}
}
return (
[...groups.entries()]
.map(([group, entries]) =>
[
`## ${group}`,
'',
'| Token path | CSS variable | Light | Dark | Numeric |',
'| --- | --- | --- | --- | --- |',
...entries.map(
(token) =>
`| ${[token.path, token.cssVariable, token.light, token.dark, token.isNumber ? 'Yes' : ''].map(escapeTableCell).join(' | ')} |`,
),
].join('\n'),
)
.join('\n\n') + '\n'
);
};

View File

@@ -0,0 +1,53 @@
import {
existsSync,
globSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
export const syncUiReferenceFiles = ({
directory,
outputs,
isCheckMode,
}: {
directory: string;
outputs: { name: string; content: string }[];
isCheckMode: boolean;
}): string[] => {
const errors: string[] = [];
const expectedPaths = new Set(
outputs.map((output) => resolve(directory, output.name)),
);
for (const name of globSync('**/*.mdx', { cwd: directory })) {
const filePath = resolve(directory, name);
if (expectedPaths.has(filePath)) {
continue;
}
if (isCheckMode) {
errors.push(`Obsolete UI reference: ${name}`);
} else {
unlinkSync(filePath);
}
}
for (const { name, content } of outputs) {
const filePath = resolve(directory, name);
if (isCheckMode) {
if (!existsSync(filePath) || readFileSync(filePath, 'utf8') !== content) {
errors.push(`Stale UI reference: ${name}`);
}
} else {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
}
return errors;
};