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,133 @@
import { globSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { extractDocumentationExamples } from '../docs/extractDocumentationExamples';
import { getDocumentationImportDiagnostics } from '../docs/getDocumentationImportDiagnostics';
const toModuleName = (subpath: string): string =>
subpath === '.' ? 'twenty-ui' : `twenty-ui/${subpath.slice(2)}`;
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const documentationRoot = resolve(packageRoot, '../twenty-docs/ui');
const packageManifest: {
exports: Record<string, unknown>;
peerDependencies: Record<string, string>;
} = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8'));
const exportedSubpaths = Object.entries(packageManifest.exports)
.filter(([, target]) => target !== null)
.map(([subpath]) => subpath);
const allowedModules = new Set([
...exportedSubpaths.map(toModuleName),
...Object.keys(packageManifest.peerDependencies),
]);
const configuration = ts.readConfigFile(
resolve(packageRoot, 'tsconfig.json'),
ts.sys.readFile,
);
const { options } = ts.parseJsonConfigFileContent(
configuration.config,
ts.sys,
packageRoot,
);
const examples = new Map<string, string>();
for (const page of globSync('**/*.mdx', { cwd: documentationRoot })) {
const contents = readFileSync(resolve(documentationRoot, page), 'utf8');
for (const [index, example] of extractDocumentationExamples(
contents,
).entries()) {
examples.set(
resolve(
documentationRoot,
`${page}.example-${index + 1}.${example.language}`,
),
example.code,
);
}
}
if (examples.size === 0) {
throw new Error('No UI documentation examples found');
}
const compilerOptions: ts.CompilerOptions = {
...options,
preserveSymlinks: true,
noEmit: true,
types: [],
paths: {
...options.paths,
...Object.fromEntries(
exportedSubpaths
.filter((subpath) => !subpath.endsWith('.css'))
.map((subpath) => [
toModuleName(subpath),
[resolve(packageRoot, 'src', subpath, 'index.ts')],
]),
),
},
};
const compilerHost = ts.createCompilerHost(compilerOptions);
const getSourceFile = compilerHost.getSourceFile;
compilerHost.getSourceFile = (
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
) => {
const example = examples.get(fileName);
return example === undefined
? getSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
)
: ts.createSourceFile(
fileName,
example,
languageVersion,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
};
const program = ts.createProgram(
[...examples.keys()],
compilerOptions,
compilerHost,
);
const diagnostics = [
...program.getOptionsDiagnostics(),
...[...examples.keys()].flatMap((fileName) => {
const source = program.getSourceFile(fileName);
if (!source) {
throw new Error(`Could not load documentation example ${fileName}`);
}
return [
...getDocumentationImportDiagnostics({ source, allowedModules }),
...program.getSyntacticDiagnostics(source),
...program.getSemanticDiagnostics(source),
];
}),
];
if (diagnostics.length > 0) {
process.stderr.write(
ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCurrentDirectory: () => documentationRoot,
getCanonicalFileName: (fileName) => fileName,
getNewLine: () => '\n',
}),
);
process.exitCode = 1;
} else {
process.stdout.write(
`Checked ${examples.size} UI documentation examples against public entry points.\n`,
);
}

View File

@@ -0,0 +1,47 @@
import { execSync } from 'child_process';
import * as path from 'path';
import { fileURLToPath } from 'url';
const MAX_UNPACKED_BYTES = 30 * 1024 * 1024;
const PACKAGE_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const formatMegabytes = (bytes: number) =>
`${(bytes / (1024 * 1024)).toFixed(2)} MB`;
type NpmPackResult = {
filename: string;
size: number;
unpackedSize: number;
entryCount: number;
};
const output = execSync('npm pack --dry-run --json', {
cwd: PACKAGE_PATH,
encoding: 'utf-8',
maxBuffer: 64 * 1024 * 1024,
});
const [packResult] = JSON.parse(output) as NpmPackResult[];
if (packResult === undefined) {
throw new Error('npm pack did not return any package information');
}
const { size, unpackedSize, entryCount } = packResult;
if (unpackedSize > MAX_UNPACKED_BYTES) {
process.stderr.write(
`twenty-ui unpacked size ${formatMegabytes(unpackedSize)} exceeds budget ${formatMegabytes(
MAX_UNPACKED_BYTES,
)} (tarball ${formatMegabytes(size)}, ${entryCount} files)\n`,
);
process.exit(1);
}
process.stdout.write(
`twenty-ui pack OK: tarball ${formatMegabytes(size)}, unpacked ${formatMegabytes(
unpackedSize,
)}, ${entryCount} files\n`,
);

View File

@@ -0,0 +1,560 @@
import prettier from '@prettier/sync';
import * as fs from 'fs';
import { globSync } from 'glob';
import path from 'path';
import { type Options } from 'prettier';
import slash from 'slash';
import ts from 'typescript';
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
const INDEX_FILENAME = 'index';
const PACKAGE_JSON_FILENAME = 'package.json';
const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json';
const PACKAGE_PATH = path.resolve('packages/twenty-ui');
const SRC_PATH = path.resolve(`${PACKAGE_PATH}/src`);
const PACKAGE_JSON_PATH = path.join(PACKAGE_PATH, PACKAGE_JSON_FILENAME);
const NX_PROJECT_CONFIGURATION_PATH = path.join(
PACKAGE_PATH,
NX_PROJECT_CONFIGURATION_FILENAME,
);
const prettierConfigFile = prettier.resolveConfigFile();
if (prettierConfigFile == null) {
throw new Error('Prettier config file not found');
}
const prettierConfiguration = prettier.resolveConfig(prettierConfigFile);
const prettierFormat = (str: string, parser: Options['parser']) =>
prettier.format(str, {
...prettierConfiguration,
parser,
});
type createTypeScriptFileArgs = {
path: string;
content: string;
filename: string;
};
const createTypeScriptFile = ({
content,
path: filePath,
filename,
}: createTypeScriptFileArgs) => {
const header = `
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
* | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, |
* |___/
*/
`;
const formattedContent = prettierFormat(
`${header}\n${content}\n`,
'typescript',
);
fs.writeFileSync(
path.join(filePath, `${filename}.ts`),
formattedContent,
'utf-8',
);
};
const getModuleName = (moduleDirectory: string) =>
slash(path.relative(SRC_PATH, moduleDirectory));
const getSubDirectoryPaths = (directoryPath: string): string[] => {
return globSync('*/', {
ignore: [...EXCLUDED_DIRECTORIES],
cwd: directoryPath,
nodir: false,
maxDepth: 1,
})
.map((directory) => path.resolve(directoryPath, directory))
.sort((a, b) => a.localeCompare(b));
};
const partitionFileExportsByType = (declarations: DeclarationOccurrence[]) => {
return declarations.reduce<{
typeAndInterfaceDeclarations: DeclarationOccurrence[];
otherDeclarations: DeclarationOccurrence[];
}>(
(acc, { kind, name }) => {
if (kind === 'type' || kind === 'interface') {
return {
...acc,
typeAndInterfaceDeclarations: [
...acc.typeAndInterfaceDeclarations,
{ kind, name },
],
};
}
return {
...acc,
otherDeclarations: [...acc.otherDeclarations, { kind, name }],
};
},
{
typeAndInterfaceDeclarations: [],
otherDeclarations: [],
},
);
};
const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => {
return exportByBarrel.map<createTypeScriptFileArgs>(
({ barrel: { moduleDirectory }, allFileExports }) => {
const childModuleDirectories = exportByBarrel
.map(({ barrel }) => barrel.moduleDirectory)
.filter((directory) => path.dirname(directory) === moduleDirectory);
const fileExports = allFileExports
.filter(
({ file }) =>
!childModuleDirectories.some((directory) =>
file.startsWith(`${directory}${path.sep}`),
),
)
.sort((a, b) => a.file.localeCompare(b.file))
.map(({ exports, file }) => {
const { otherDeclarations, typeAndInterfaceDeclarations } =
partitionFileExportsByType(exports);
const fileWithoutExtension = path.parse(file).name;
const pathToImport = slash(
path.relative(
moduleDirectory,
path.join(path.dirname(file), fileWithoutExtension),
),
);
const mapDeclarationNameAndJoin = (
declarations: DeclarationOccurrence[],
) => declarations.map(({ name }) => name).join(', ');
const typeExport =
typeAndInterfaceDeclarations.length > 0
? `export type { ${mapDeclarationNameAndJoin(typeAndInterfaceDeclarations)} } from "./${pathToImport}"`
: '';
const othersExport =
otherDeclarations.length > 0
? `export { ${mapDeclarationNameAndJoin(otherDeclarations)} } from "./${pathToImport}"`
: '';
return [typeExport, othersExport]
.filter((el) => el !== '')
.join('\n');
})
.join('\n');
const childModuleExports = childModuleDirectories.map(
(directory) => `export * from './${path.basename(directory)}';`,
);
const content = [fileExports, ...childModuleExports]
.filter((entry) => entry !== '')
.join('\n');
return {
// A placeholder barrel for an empty module must still be a valid module
// so `export * from './x'` re-exports (e.g. in individual-entry.ts) resolve.
content: content === '' ? 'export {};' : content,
path: moduleDirectory,
filename: INDEX_FILENAME,
};
},
);
};
type JsonUpdate = Record<string, any>;
type WriteInJsonFileArgs = {
content: JsonUpdate;
file: string;
};
const updateJsonFile = ({ content, file }: WriteInJsonFileArgs) => {
const updatedJsonFile = JSON.stringify(content);
const formattedContent = prettier.format(updatedJsonFile, {
...prettierConfiguration,
filepath: file,
});
fs.writeFileSync(file, formattedContent, 'utf-8');
};
const writeInPackageJson = (update: JsonUpdate) => {
const rawJsonFile = fs.readFileSync(PACKAGE_JSON_PATH, 'utf-8');
const initialJsonFile = JSON.parse(rawJsonFile);
updateJsonFile({
file: PACKAGE_JSON_PATH,
content: {
...initialJsonFile,
...update,
},
});
};
const updateNxProjectConfigurationBuildOutputs = (outputs: JsonUpdate) => {
const rawJsonFile = fs.readFileSync(NX_PROJECT_CONFIGURATION_PATH, 'utf-8');
const initialJsonFile = JSON.parse(rawJsonFile);
updateJsonFile({
file: NX_PROJECT_CONFIGURATION_PATH,
content: {
...initialJsonFile,
targets: {
...initialJsonFile.targets,
build: {
...initialJsonFile.targets.build,
outputs,
},
},
},
});
};
type ExportOccurrence = {
types: string;
import: string;
require: string;
};
type ExportsConfig = Record<string, ExportOccurrence | string>;
const generateModulePackageExports = (moduleDirectories: string[]) => {
return moduleDirectories.reduce<ExportsConfig>(
(acc, moduleDirectory) => {
const moduleName = getModuleName(moduleDirectory);
if (moduleName === undefined) {
throw new Error(
`Should never occur, moduleName is undefined ${moduleDirectory}`,
);
}
return {
...acc,
[`./${moduleName}`]: {
types: `./dist/${moduleName}/index.d.ts`,
import: `./dist/${moduleName}.mjs`,
require: `./dist/${moduleName}.cjs`,
},
};
},
{
'./style.css': './dist/style.css',
'./theme-light.css': './dist/theme-light.css',
'./theme-dark.css': './dist/theme-dark.css',
},
);
};
const computePackageJsonFilesAndExportsConfig = (
moduleDirectories: string[],
) => {
const entrypoints = moduleDirectories.map(getModuleName);
const exports = {
'.': {
types: './dist/index.d.ts',
import: './dist/index.mjs',
require: './dist/index.cjs',
},
...generateModulePackageExports(moduleDirectories),
} satisfies ExportsConfig;
const typesVersionsEntries = entrypoints.reduce<Record<string, string[]>>(
(acc, moduleName) => ({
...acc,
[`${moduleName}`]: [`dist/${moduleName}/index.d.ts`],
}),
{},
);
return {
exports,
typesVersions: { '*': typesVersionsEntries },
files: [
'dist',
'LICENSE',
'!dist/individual',
'!dist/individual/**',
'!dist/**/*.map',
],
};
};
const computeProjectNxBuildOutputsPath = () => {
return ['{projectRoot}/dist'];
};
const EXCLUDED_EXTENSIONS = [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.stories.ts',
'**/*.stories.tsx',
] as const;
const EXCLUDED_DIRECTORIES = [
'**/__tests__/**',
'**/__mocks__/**',
'**/__stories__/**',
'**/internal/**',
'**/internals/**',
'**/parts/**',
] as const;
function getTypeScriptFiles(
directoryPath: string,
includeIndex: boolean = false,
): string[] {
const pattern = slash(path.join(directoryPath, '**', '*.{ts,tsx}'));
const files = globSync(pattern, {
cwd: SRC_PATH,
nodir: true,
ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES],
});
return files.filter(
(file) =>
!file.endsWith('.d.ts') &&
(includeIndex ? true : !file.endsWith('index.ts')),
);
}
const getKind = (
node: ts.VariableStatement,
): Extract<ExportKind, 'const' | 'let' | 'var'> => {
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
if (isConst) {
return 'const';
}
const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0;
if (isLet) {
return 'let';
}
return 'var';
};
function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
const exports: DeclarationOccurrence[] = [];
function visit(node: ts.Node) {
if (!ts.canHaveModifiers(node)) {
return ts.forEachChild(node, visit);
}
const modifiers = ts.getModifiers(node);
const isExport = modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExport && !ts.isExportDeclaration(node)) {
return ts.forEachChild(node, visit);
}
switch (true) {
case ts.isTypeAliasDeclaration(node):
exports.push({
kind: 'type',
name: node.name.text,
});
break;
case ts.isInterfaceDeclaration(node):
exports.push({
kind: 'interface',
name: node.name.text,
});
break;
case ts.isEnumDeclaration(node):
exports.push({
kind: 'enum',
name: node.name.text,
});
break;
case ts.isFunctionDeclaration(node) && node.name !== undefined:
exports.push({
kind: 'function',
name: node.name.text,
});
break;
case ts.isVariableStatement(node):
node.declarationList.declarations.forEach((decl) => {
const kind = getKind(node);
if (ts.isIdentifier(decl.name)) {
exports.push({
kind,
name: decl.name.text,
});
} else if (ts.isObjectBindingPattern(decl.name)) {
decl.name.elements.forEach((element) => {
if (
!ts.isBindingElement(element) ||
!ts.isIdentifier(element.name)
) {
return;
}
exports.push({
kind,
name: element.name.text,
});
});
}
});
break;
case ts.isClassDeclaration(node) && node.name !== undefined:
exports.push({
kind: 'class',
name: node.name.text,
});
break;
case ts.isExportDeclaration(node):
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
node.exportClause.elements.forEach((element) => {
const exportName = element.name.text;
// Check both the declaration and the individual specifier for type-only exports
const isTypeExport =
node.isTypeOnly || ts.isTypeOnlyExportDeclaration(node);
if (isTypeExport) {
exports.push({
kind: 'type',
name: exportName,
});
return;
}
exports.push({
kind: 'const',
name: exportName,
});
});
}
break;
}
return ts.forEachChild(node, visit);
}
visit(sourceFile);
return exports;
}
type ExportKind =
| 'type'
| 'interface'
| 'enum'
| 'function'
| 'const'
| 'let'
| 'var'
| 'class';
type DeclarationOccurrence = { kind: ExportKind; name: string };
type FileExports = Array<{
file: string;
exports: DeclarationOccurrence[];
}>;
function findAllExports(directoryPath: string): FileExports {
const results: FileExports = [];
const files = getTypeScriptFiles(directoryPath);
for (const file of files) {
const sourceFile = ts.createSourceFile(
file,
fs.readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
);
const exports = extractExportsFromSourceFile(sourceFile);
if (exports.length > 0) {
results.push({
file,
exports,
});
}
}
return results;
}
type ExportByBarrel = {
barrel: {
moduleName: string;
moduleDirectory: string;
};
allFileExports: FileExports;
};
const retrieveExportsByBarrel = (barrelDirectories: string[]) => {
return barrelDirectories.map<ExportByBarrel>((moduleDirectory) => {
const moduleExportsPerFile = findAllExports(moduleDirectory);
const moduleName = getModuleName(moduleDirectory);
if (!moduleName) {
throw new Error(
`Should never occur moduleName not found ${moduleDirectory}`,
);
}
return {
barrel: {
moduleName,
moduleDirectory,
},
allFileExports: moduleExportsPerFile,
};
});
};
const ROOT_BARREL_EXCLUDED_MODULES = ['assets', 'styles', 'testing'];
const INDIVIDUAL_ENTRY_FILENAME = 'individual-entry';
const getRootBarrelModuleNames = (moduleDirectories: string[]) =>
moduleDirectories
.map(getModuleName)
.filter((moduleName) => !ROOT_BARREL_EXCLUDED_MODULES.includes(moduleName));
const generateRootBarrel = (
moduleDirectories: string[],
): createTypeScriptFileArgs => {
const content = [
"import './styles/base/reset.scss';",
'',
...getRootBarrelModuleNames(moduleDirectories).map(
(moduleName) => `export * from './${moduleName}';`,
),
].join('\n');
return { path: SRC_PATH, content, filename: INDEX_FILENAME };
};
const generateIndividualEntry = (
moduleDirectories: string[],
): createTypeScriptFileArgs => {
const content = getRootBarrelModuleNames(moduleDirectories)
.map((moduleName) => `export * from './${moduleName}';`)
.join('\n');
return { path: SRC_PATH, content, filename: INDIVIDUAL_ENTRY_FILENAME };
};
const main = () => {
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
const barrelDirectories = [
...moduleDirectories,
...getSubDirectoryPaths(path.join(SRC_PATH, 'primitives')),
].sort((first, second) => first.localeCompare(second));
const exportsByBarrel = retrieveExportsByBarrel(barrelDirectories);
const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel);
const packageJsonConfig =
computePackageJsonFilesAndExportsConfig(barrelDirectories);
const nxBuildOutputsPath = computeProjectNxBuildOutputsPath();
updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath);
writeInPackageJson(packageJsonConfig);
moduleIndexFiles.forEach(createTypeScriptFile);
createTypeScriptFile(generateRootBarrel(moduleDirectories));
createTypeScriptFile(generateIndividualEntry(moduleDirectories));
};
main();

View File

@@ -0,0 +1,264 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
type ComponentDoc,
type ParserOptions,
type PropItem,
} from 'react-docgen-typescript';
import ts from 'typescript';
import { DESIGN_TOKENS } from '../design-tokens/designTokens';
import { collectLeaves } from '../design-tokens/pipeline/collectLeaves';
import { DOCUMENTED_COMPONENTS } from '../docs/components';
import { DocumentationParser } from '../docs/DocumentationParser';
import { formatDocumentationTokenValue } from '../docs/formatDocumentationTokenValue';
import { normalizeDocumentationDefaultValue } from '../docs/normalizeDocumentationDefaultValue';
import { normalizeDocumentationPropType } from '../docs/normalizeDocumentationPropType';
import {
type ComponentDocumentation,
type TokenDocumentation,
} from '../docs/types';
const HIDDEN_PROP_TAGS = ['ignore', 'internal'];
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const sourceRoot = resolve(packageRoot, 'src');
const configurationPath = resolve(packageRoot, 'tsconfig.json');
const configuration = ts.readConfigFile(configurationPath, ts.sys.readFile);
if (configuration.error) {
throw new Error(
ts.flattenDiagnosticMessageText(configuration.error.messageText, '\n'),
);
}
const { options, errors } = ts.parseJsonConfigFileContent(
configuration.config,
ts.sys,
packageRoot,
);
if (errors.length > 0) {
throw new Error(
errors
.map((error) => ts.flattenDiagnosticMessageText(error.messageText, '\n'))
.join('\n'),
);
}
const sourcePaths = DOCUMENTED_COMPONENTS.map((component) =>
resolve(sourceRoot, component.source),
);
const program = ts.createProgram(sourcePaths, {
...options,
preserveSymlinks: true,
});
const checker = program.getTypeChecker();
const isReactNativeAttribute = (prop: PropItem): boolean =>
prop.declarations !== undefined &&
prop.declarations.length > 0 &&
prop.declarations.every((declaration) =>
declaration.fileName.includes('@types/react/'),
);
const isHiddenProp = (prop: PropItem): boolean =>
HIDDEN_PROP_TAGS.some((tag) => tag in (prop.tags ?? {}));
const isDeclaredInTwentyUi = (prop: PropItem): boolean =>
prop.declarations?.some((declaration) =>
declaration.fileName.startsWith(sourceRoot),
) ?? false;
const parserOptions: ParserOptions = {
shouldExtractLiteralValuesFromEnum: true,
shouldIncludePropTagMap: true,
propFilter: (prop) => !isReactNativeAttribute(prop) && !isHiddenProp(prop),
};
const parser = new DocumentationParser(program, parserOptions);
const documentedChildrenParser = new DocumentationParser(program, {
...parserOptions,
skipChildrenPropWithoutDoc: false,
});
const extractProps = ({
symbol,
name,
propDescriptions = {},
propDefaults = {},
}: {
symbol: ts.Symbol;
name: string;
propDescriptions?: Partial<Record<string, string>>;
propDefaults?: Partial<Record<string, string>>;
}): ComponentDocumentation['props'] => {
const declaration = symbol.valueDeclaration;
if (!declaration) {
throw new Error(`Could not find the declaration for ${name}`);
}
const componentParser =
'children' in propDescriptions ? documentedChildrenParser : parser;
const parsed: ComponentDoc | null = componentParser.getComponentInfo(
symbol,
declaration.getSourceFile(),
() => name,
);
if (!parsed || Object.keys(parsed.props).length === 0) {
throw new Error(`Could not extract props for ${name}`);
}
return Object.values(parsed.props)
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
.map((prop) => {
const description = (
propDescriptions[prop.name] ?? prop.description
).trim();
if (description.length === 0 && isDeclaredInTwentyUi(prop)) {
throw new Error(
`Missing description for ${name}.${prop.name}. Document the prop in its props type or documentation metadata.`,
);
}
return {
name: prop.name,
type: normalizeDocumentationPropType({
type: prop.type,
required: prop.required,
}),
required: prop.required,
defaultValue:
propDefaults[prop.name] ??
normalizeDocumentationDefaultValue(prop.defaultValue),
description,
};
});
};
const components: ComponentDocumentation[] = DOCUMENTED_COMPONENTS.map(
(component) => {
const source = program.getSourceFile(resolve(sourceRoot, component.source));
const moduleSymbol = source && checker.getSymbolAtLocation(source);
const symbol =
moduleSymbol &&
checker
.getExportsOfModule(moduleSymbol)
.find((candidate) => candidate.name === component.name);
if (!symbol?.valueDeclaration) {
throw new Error(`Could not find the export for ${component.name}`);
}
const type = checker.getTypeOfSymbolAtLocation(
symbol,
symbol.valueDeclaration,
);
const metadata = {
name: component.name,
entryPoint: component.entryPoint,
slug: component.slug,
};
const props =
type.getCallSignatures().length > 0
? extractProps({
symbol,
name: component.name,
propDescriptions:
'propDescriptions' in component
? component.propDescriptions
: undefined,
propDefaults:
'propDefaults' in component ? component.propDefaults : undefined,
})
: [];
const partNames: readonly string[] | undefined =
'parts' in component ? component.parts : undefined;
const partPropDescriptions: Partial<
Record<string, Partial<Record<string, string>>>
> =
'partPropDescriptions' in component ? component.partPropDescriptions : {};
const componentParts = type
.getProperties()
.filter((part) => partNames?.includes(part.name) ?? props.length === 0);
const parts = componentParts.map((part) => {
const declaration = part.valueDeclaration;
if (!declaration || !ts.isPropertyAssignment(declaration)) {
throw new Error(`Could not resolve ${component.name}.${part.name}`);
}
let partSymbol = checker.getSymbolAtLocation(declaration.initializer);
if (!partSymbol) {
throw new Error(`Could not find ${component.name}.${part.name}`);
}
if (partSymbol.flags & ts.SymbolFlags.Alias) {
partSymbol = checker.getAliasedSymbol(partSymbol);
}
return {
name: part.name,
props: extractProps({
symbol: partSymbol,
name: `${component.name}.${part.name}`,
propDescriptions: partPropDescriptions[part.name],
}),
};
});
if (props.length === 0 && parts.length === 0) {
throw new Error(`Could not extract parts for ${component.name}`);
}
return {
...metadata,
props,
...(parts.length > 0 ? { parts } : {}),
};
},
);
const tokens: TokenDocumentation[] = collectLeaves(DESIGN_TOKENS)
.map((leaf) => ({
path: leaf.path.join('.'),
cssVariable: leaf.varName,
light: formatDocumentationTokenValue(leaf.light),
dark: formatDocumentationTokenValue(leaf.dark),
isNumber: leaf.unit === 'number',
}))
.sort((left, right) =>
left.path.localeCompare(right.path, 'en', { numeric: true }),
);
const outputs = [
{ name: 'components.docs.json', data: components },
{ name: 'tokens.docs.json', data: tokens },
];
const isCheckMode = process.argv.includes('--check');
for (const output of outputs) {
const outputPath = resolve(packageRoot, 'generated', output.name);
const content = `${JSON.stringify(output.data, null, 2)}\n`;
if (isCheckMode) {
if (
!existsSync(outputPath) ||
readFileSync(outputPath, 'utf8') !== content
) {
process.stderr.write(
`Stale documentation data: ${relative(packageRoot, outputPath)}. Run npx nx generate:ui twenty-docs.\n`,
);
process.exitCode = 1;
}
} else {
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, content);
}
}

View File

@@ -0,0 +1,24 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateTwentyIconDictionaryMarkdown } from '../src/icon/internal/generateTwentyIconDictionaryMarkdown';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const outputPath = resolve(scriptDirectory, '../src/icon/icon-dictionary.md');
const generatedMarkdown = generateTwentyIconDictionaryMarkdown();
const isCheckOnly = process.argv.includes('--check');
if (isCheckOnly) {
const currentMarkdown = existsSync(outputPath)
? readFileSync(outputPath, 'utf8')
: undefined;
if (currentMarkdown !== generatedMarkdown) {
throw new Error(
'The icon dictionary Markdown is stale. Run `npx nx generateIconDictionary twenty-ui`.',
);
}
} else {
writeFileSync(outputPath, generatedMarkdown, 'utf8');
}

View File

@@ -0,0 +1,154 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MAIN_COLOR_TOKENS } from '../design-tokens/color/mainColors';
import { DESIGN_TOKENS } from '../design-tokens/designTokens';
import { buildMainColorNames } from '../design-tokens/pipeline/buildMainColorNames';
import { buildThemeConstants } from '../design-tokens/pipeline/buildThemeConstants';
import { buildThemeCss } from '../design-tokens/pipeline/buildThemeCss';
import { buildThemeCommon } from '../design-tokens/pipeline/buildThemeCommon';
import { buildThemeCssVariables } from '../design-tokens/pipeline/buildThemeCssVariables';
import { buildThemeSpacing } from '../design-tokens/pipeline/buildThemeSpacing';
import { buildThemeSubtreeConstant } from '../design-tokens/pipeline/buildThemeSubtreeConstant';
import { buildThemeTypes } from '../design-tokens/pipeline/buildThemeTypes';
import { collectLeaves } from '../design-tokens/pipeline/collectLeaves';
import { THEME_COMMON_ROOT_KEYS } from '../design-tokens/themeCommonRootKeys';
import { THEME_CSS_FILE_NAME_BY_SCHEME } from '../design-tokens/themeCssFileNameByScheme';
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const themeConstantsDirectory = resolve(packageRoot, 'src/theme-constants');
const themeDirectory = resolve(packageRoot, 'src/theme');
const leaves = collectLeaves(DESIGN_TOKENS);
const sourceOutputs = [
{
path: resolve(themeConstantsDirectory, THEME_CSS_FILE_NAME_BY_SCHEME.light),
content: buildThemeCss({ leaves, scheme: 'light' }),
},
{
path: resolve(themeConstantsDirectory, THEME_CSS_FILE_NAME_BY_SCHEME.dark),
content: buildThemeCss({ leaves, scheme: 'dark' }),
},
{
path: resolve(themeConstantsDirectory, 'themeCssVariables.ts'),
content: buildThemeCssVariables(leaves),
},
{
path: resolve(themeConstantsDirectory, 'themeTypes.ts'),
content: buildThemeTypes(leaves),
},
{
path: resolve(themeDirectory, 'internal/themeSpacing.ts'),
content: buildThemeSpacing(leaves),
},
{
path: resolve(themeDirectory, 'constants/ThemeLight.ts'),
content: buildThemeConstants({ leaves, scheme: 'light' }),
},
{
path: resolve(themeDirectory, 'constants/ThemeDark.ts'),
content: buildThemeConstants({ leaves, scheme: 'dark' }),
},
{
path: resolve(themeDirectory, 'constants/ThemeCommon.ts'),
content: buildThemeCommon({ leaves, rootKeys: THEME_COMMON_ROOT_KEYS }),
},
{
path: resolve(themeDirectory, 'constants/MainColorNames.ts'),
content: buildMainColorNames(Object.keys(MAIN_COLOR_TOKENS)),
},
{
path: resolve(themeDirectory, 'constants/Animation.ts'),
content: buildThemeSubtreeConstant({
leaves,
rootKey: 'animation',
scheme: 'light',
exportName: 'ANIMATION',
}),
},
{
path: resolve(themeDirectory, 'constants/GrayScaleLight.ts'),
content: buildThemeSubtreeConstant({
leaves,
rootKey: 'grayScale',
scheme: 'light',
exportName: 'GRAY_SCALE_LIGHT',
}),
},
{
path: resolve(themeDirectory, 'constants/GrayScaleDark.ts'),
content: buildThemeSubtreeConstant({
leaves,
rootKey: 'grayScale',
scheme: 'dark',
exportName: 'GRAY_SCALE_DARK',
}),
},
];
const isCheckMode = process.argv.includes('--check');
const outputPaths = sourceOutputs.map(({ path }) => path);
const readIfPresent = (path: string) =>
existsSync(path) ? readFileSync(path, 'utf-8') : undefined;
const writeAndFormatOutputs = () => {
for (const { path, content } of sourceOutputs) {
writeFileSync(path, content, 'utf-8');
}
// oxfmt reformats CSS as well as TypeScript, so the committed artifacts are
// its output rather than the builders'.
const formatResult = spawnSync('npx', ['oxfmt', ...outputPaths], {
cwd: packageRoot,
stdio: 'inherit',
});
if (formatResult.status !== 0) {
throw new Error('oxfmt failed on the generated theme files');
}
};
// Formatting has to happen on the real paths, because oxfmt resolves the
// repository .oxfmtrc.jsonc from the file location, so check mode restores
// what was there rather than leaving the working tree edited.
const committedContents = isCheckMode ? outputPaths.map(readIfPresent) : [];
const restoreCommittedOutputs = () => {
for (const [index, path] of outputPaths.entries()) {
const committedContent = committedContents[index];
if (committedContent === undefined) {
rmSync(path, { force: true });
continue;
}
writeFileSync(path, committedContent, 'utf-8');
}
};
let staleOutputPaths: string[] = [];
if (isCheckMode) {
try {
writeAndFormatOutputs();
staleOutputPaths = outputPaths.filter(
(path, index) => readIfPresent(path) !== committedContents[index],
);
} finally {
restoreCommittedOutputs();
}
} else {
writeAndFormatOutputs();
}
if (staleOutputPaths.length > 0) {
process.stderr.write(
`::error::Generated theme artifacts are stale: ${staleOutputPaths
.map((path) => relative(packageRoot, path))
.join(', ')}. Run: npx nx generateTokens twenty-ui\n`,
);
process.exit(1);
}
console.log(`Generated theme artifacts from ${leaves.length} design tokens.`);

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
source "$SCRIPT_DIR/.env"
set +a
fi
ARGOS_API_BASE_URL="${ARGOS_API_BASE_URL:-http://127.0.0.1:4002/v2/}"
ARGOS_TOKEN="${ARGOS_TOKEN:?ARGOS_TOKEN is required – set it in packages/twenty-ui/.env}"
USERNAME=$(whoami)
GIT_BRANCH="${ARGOS_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")}"
COMMIT="${ARGOS_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")}"
REFERENCE_COMMIT="${ARGOS_REFERENCE_COMMIT:-$(git merge-base HEAD main 2>/dev/null || echo "")}"
export ARGOS_API_BASE_URL
export ARGOS_TOKEN
export ARGOS_BUILD_NAME="${USERNAME}/twenty-ui"
export ARGOS_BRANCH="${USERNAME}/${GIT_BRANCH}"
export ARGOS_COMMIT="$COMMIT"
export ARGOS_REFERENCE_COMMIT="${REFERENCE_COMMIT}"
echo "Argos visual diff"
echo " API: $ARGOS_API_BASE_URL"
echo " Build name: $ARGOS_BUILD_NAME"
echo " Branch: $ARGOS_BRANCH"
echo " Commit: ${ARGOS_COMMIT:0:12}"
echo " Ref commit: ${ARGOS_REFERENCE_COMMIT:0:12}"
echo ""
npx http-server storybook-static --port 6007 --silent &
HTTP_PID=$!
trap "kill $HTTP_PID 2>/dev/null || true" EXIT
SERVER_UP=false
for i in $(seq 1 30); do
if curl -sf http://localhost:6007 > /dev/null 2>&1; then SERVER_UP=true; break; fi
sleep 1
done
if [[ "$SERVER_UP" != "true" ]]; then
echo "Storybook static server did not start on http://localhost:6007 after 30s" >&2
exit 1
fi
export STORYBOOK_URL="http://localhost:6007"
npx vitest run