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
265 lines
8.0 KiB
TypeScript
265 lines
8.0 KiB
TypeScript
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);
|
|
}
|
|
}
|