Skip to content

Commit f7cd3ed

Browse files
feat(docs): generate UI Library props and CSS tables from source via TypeDoc
1 parent b8fda94 commit f7cd3ed

29 files changed

Lines changed: 507 additions & 816 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,8 @@ CLAUDE.local.md
7171
# and is copied into this dir by a tiny Astro integration after typedoc
7272
# generation (see astro.config.mjs).
7373
apps/docs/src/content/docs/api/
74+
75+
# UI Library props + CSS-variable data, generated from @workflowbuilder/ui by
76+
# apps/docs/scripts/generate-ui-api.mjs (TypeDoc + CSS extraction) on every
77+
# docs build / dev. Source of truth is the library, so keep it out of git.
78+
apps/docs/src/generated/

apps/docs/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export default defineConfig({
170170
label: 'UI Library',
171171
items: [
172172
{ label: 'Overview', link: '/ui-library/overview/' },
173+
{ label: 'Design tokens', link: '/ui-library/design-tokens/' },
173174
{ label: 'UI Components', autogenerate: { directory: 'ui-library/ui-components' } },
174175
{ label: 'Diagram Components', autogenerate: { directory: 'ui-library/diagram-components' } },
175176
],

apps/docs/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"dev": "pnpm clean:typedoc && astro dev",
8-
"build": "pnpm clean:typedoc && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs",
7+
"dev": "pnpm clean:typedoc && pnpm generate:ui-api && astro dev",
8+
"build": "pnpm clean:typedoc && pnpm generate:ui-api && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs",
9+
"generate:ui-api": "node scripts/generate-ui-api.mjs",
910
"clean:typedoc": "node -e \"import('node:fs').then(fs => fs.rmSync('src/content/docs/api', { recursive: true, force: true }))\"",
1011
"preview": "astro preview",
1112
"typecheck": "astro check",
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/*
2+
* Generates `src/generated/ui-api.json` for the UI Library docs.
3+
*
4+
* Props are extracted with TypeDoc (source of truth: the component prop types
5+
* in `@workflowbuilder/ui`); CSS variables are extracted from each component's
6+
* stylesheets. The per-component docs pages render this JSON, so the Props and
7+
* CSS variables tables never drift from source. Run by `pnpm generate:ui-api`
8+
* and as a prebuild step in `dev` / `build`.
9+
*/
10+
import { execFile } from 'node:child_process';
11+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
12+
import { globSync, readFileSync } from 'node:fs';
13+
import path from 'node:path';
14+
import { fileURLToPath } from 'node:url';
15+
import { promisify } from 'node:util';
16+
17+
const here = path.dirname(fileURLToPath(import.meta.url));
18+
const docsRoot = path.resolve(here, '..');
19+
const repoRoot = path.resolve(docsRoot, '../..');
20+
const uiSrc = path.resolve(repoRoot, 'packages/ui/src');
21+
const outFile = path.resolve(docsRoot, 'src/generated/ui-api.json');
22+
const tdJson = path.resolve(docsRoot, 'node_modules/.cache/ui-typedoc.json');
23+
24+
// slug -> { name, propsType, dir }. `propsType` is the exported prop type the
25+
// component accepts; `dir` is the component folder under packages/ui/src/components.
26+
const COMPONENTS = [
27+
{ slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' },
28+
{ slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' },
29+
{ slug: 'button', name: 'Button', propsType: 'BaseRegularButtonProps', dir: 'button' },
30+
{ slug: 'checkbox', name: 'Checkbox', propsType: 'CheckboxProps', dir: 'checkbox' },
31+
{ slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' },
32+
{ slug: 'input', name: 'Input', propsType: 'InputProps', dir: 'input' },
33+
{ slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' },
34+
{ slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' },
35+
{ slug: 'nav-button', name: 'NavButton', propsType: 'NavBaseButtonProps', dir: 'button/nav-button' },
36+
{ slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' },
37+
{ slug: 'segment-picker', name: 'SegmentPicker', propsType: 'SegmentPickerProps', dir: 'segment-picker' },
38+
{ slug: 'select', name: 'Select', propsType: 'SelectBaseProps', dir: 'select' },
39+
{ slug: 'separator', name: 'Separator', propsType: null, dir: 'separator' },
40+
{ slug: 'snackbar', name: 'Snackbar', propsType: 'SnackbarProps', dir: 'snackbar' },
41+
{ slug: 'status', name: 'Status', propsType: 'StatusProps', dir: 'status' },
42+
{ slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' },
43+
{ slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' },
44+
{ slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' },
45+
// Diagram components (props extracted the same way; NodePanel is a compound
46+
// component documented narratively, so it has no flat props entry here).
47+
{ slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' },
48+
{ slug: 'node-description', name: 'NodeDescription', propsType: 'NodeDescriptionProps', dir: 'node/node-description' },
49+
{ slug: 'node-as-port-wrapper', name: 'NodeAsPortWrapper', propsType: 'NodeAsPortWrapperProps', dir: 'node/node-as-port-wrapper' },
50+
{ slug: 'edge', name: 'EdgeLabel', propsType: 'EdgeLabelProps', dir: 'edge' },
51+
];
52+
53+
async function runTypedoc() {
54+
await mkdir(path.dirname(tdJson), { recursive: true });
55+
const bin = path.resolve(docsRoot, 'node_modules/.bin/typedoc');
56+
await promisify(execFile)(
57+
bin,
58+
[
59+
'--json', tdJson,
60+
'--entryPoints', path.resolve(uiSrc, 'index.ts'),
61+
'--tsconfig', path.resolve(repoRoot, 'packages/ui/tsconfig.json'),
62+
'--excludeExternals', '--excludePrivate', '--skipErrorChecking', '--logLevel', 'Error',
63+
],
64+
{ cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 },
65+
);
66+
return JSON.parse(await readFile(tdJson, 'utf8'));
67+
}
68+
69+
function indexById(root) {
70+
const byId = new Map();
71+
(function walk(node) {
72+
if (node && typeof node.id === 'number') byId.set(node.id, node);
73+
for (const child of node.children ?? []) walk(child);
74+
})(root);
75+
return byId;
76+
}
77+
78+
function findTypeByName(root, name) {
79+
let found = null;
80+
(function walk(node) {
81+
if (found) return;
82+
// 2097152 = TypeAlias, 256 = Interface
83+
if (node.name === name && (node.kind === 2097152 || node.kind === 256)) found = node;
84+
for (const child of node.children ?? []) walk(child);
85+
})(root);
86+
return found;
87+
}
88+
89+
function typeToString(t, byId, depth = 0) {
90+
if (!t || depth > 6) return 'unknown';
91+
switch (t.type) {
92+
case 'intrinsic': return t.name;
93+
case 'literal': return typeof t.value === 'string' ? `'${t.value}'` : String(t.value);
94+
case 'reference': {
95+
const args = t.typeArguments?.length ? `<${t.typeArguments.map((a) => typeToString(a, byId, depth + 1)).join(', ')}>` : '';
96+
return `${t.name}${args}`;
97+
}
98+
case 'union': return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' | ');
99+
case 'intersection': return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' & ');
100+
case 'array': return `${typeToString(t.elementType, byId, depth + 1)}[]`;
101+
case 'tuple': return `[${(t.elements ?? []).map((x) => typeToString(x, byId, depth + 1)).join(', ')}]`;
102+
case 'reflection': {
103+
const sig = t.declaration?.signatures?.[0];
104+
if (sig) {
105+
const params = (sig.parameters ?? []).map((p) => `${p.name}: ${typeToString(p.type, byId, depth + 1)}`).join(', ');
106+
return `(${params}) => ${typeToString(sig.type, byId, depth + 1)}`;
107+
}
108+
return '{ … }';
109+
}
110+
case 'indexedAccess': return `${typeToString(t.objectType, byId, depth + 1)}[${typeToString(t.indexType, byId, depth + 1)}]`;
111+
case 'templateLiteral': return 'string';
112+
case 'query': return typeToString(t.queryType, byId, depth + 1);
113+
case 'predicate': return 'boolean';
114+
case 'typeOperator': return `${t.operator} ${typeToString(t.target, byId, depth + 1)}`;
115+
default: return t.name ?? 'unknown';
116+
}
117+
}
118+
119+
function summaryText(comment) {
120+
if (!comment?.summary) return '';
121+
return comment.summary.map((s) => s.text).join('').trim();
122+
}
123+
124+
function defaultTag(comment) {
125+
const tag = (comment?.blockTags ?? []).find((b) => b.tag === '@default' || b.tag === '@defaultValue');
126+
if (!tag) return null;
127+
let value = tag.content.map((c) => c.text).join('').trim();
128+
value = value.replace(/^```[a-z]*\s*/i, '').replace(/\s*```$/, '').trim(); // strip ```ts … ``` fences
129+
value = value.replace(/^`+|`+$/g, '').trim(); // strip inline backticks
130+
return value || null;
131+
}
132+
133+
// Collect own properties from a prop type alias / interface, walking
134+
// intersections and skipping referenced (extended / native HTML) members.
135+
function collectProps(typeNode, byId, acc = new Map()) {
136+
if (!typeNode) return acc;
137+
// TypeAlias / Interface: plain object members land directly on `.children`;
138+
// computed types (intersections etc.) land on `.type`.
139+
if (typeNode.kind === 2097152 || typeNode.kind === 256) {
140+
if (typeNode.children?.length) {
141+
for (const child of typeNode.children) addProp(child, byId, acc);
142+
return acc;
143+
}
144+
return collectProps(typeNode.type, byId, acc);
145+
}
146+
if (typeNode.type === 'intersection' || typeNode.type === 'union') {
147+
for (const member of typeNode.types) collectProps(member, byId, acc);
148+
return acc;
149+
}
150+
if (typeNode.type === 'reflection' && typeNode.declaration?.children) {
151+
for (const child of typeNode.declaration.children) addProp(child, byId, acc);
152+
return acc;
153+
}
154+
if (typeNode.type === 'reference' && typeof typeNode.target === 'number') {
155+
const target = byId.get(typeNode.target);
156+
// Only follow references into our own package's prop types, not native ones.
157+
if (target && target.kind === 2097152) collectProps(target, byId, acc);
158+
return acc;
159+
}
160+
return acc;
161+
}
162+
163+
function addProp(child, byId, acc) {
164+
if (child.kind !== 1024 || acc.has(child.name)) return; // 1024 = Property
165+
acc.set(child.name, {
166+
name: child.name,
167+
type: typeToString(child.type, byId),
168+
required: !child.flags?.isOptional,
169+
default: defaultTag(child.comment),
170+
description: summaryText(child.comment),
171+
});
172+
}
173+
174+
function extractCssVariables(dir) {
175+
const abs = path.resolve(uiSrc, 'components', dir);
176+
const files = globSync('**/*.css', { cwd: abs }).sort();
177+
const seen = new Set();
178+
const vars = [];
179+
for (const file of files) {
180+
const css = readFileSync(path.resolve(abs, file), 'utf8');
181+
// Match `--ax-public-xxx:` declarations, capturing an optional same-line comment.
182+
const re = /(--ax-public-[\w-]+)\s*:[^;]*?(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g;
183+
let m;
184+
while ((m = re.exec(css))) {
185+
if (seen.has(m[1])) continue;
186+
seen.add(m[1]);
187+
vars.push({ name: m[1], comment: (m[2] ?? '').trim() });
188+
}
189+
}
190+
return vars;
191+
}
192+
193+
async function main() {
194+
const project = await runTypedoc();
195+
const byId = indexById(project);
196+
const out = {};
197+
const warnings = [];
198+
199+
for (const c of COMPONENTS) {
200+
let props = [];
201+
if (c.propsType) {
202+
const typeNode = findTypeByName(project, c.propsType);
203+
if (typeNode) {
204+
props = [...collectProps(typeNode, byId).values()].sort((a, b) => a.name.localeCompare(b.name));
205+
} else {
206+
warnings.push(`props type "${c.propsType}" not found for "${c.slug}"`);
207+
}
208+
}
209+
out[c.slug] = { name: c.name, props, cssVariables: extractCssVariables(c.dir) };
210+
}
211+
212+
await mkdir(path.dirname(outFile), { recursive: true });
213+
await writeFile(outFile, JSON.stringify(out, null, 2) + '\n');
214+
215+
const summary = Object.entries(out).map(([s, v]) => `${s}: ${v.props.length} props, ${v.cssVariables.length} vars`);
216+
console.log('✔ ui-api.json generated\n ' + summary.join('\n '));
217+
if (warnings.length) console.warn('⚠ ' + warnings.join('\n⚠ '));
218+
}
219+
220+
main().catch((error) => {
221+
console.error(error);
222+
process.exit(1);
223+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
// Renders a component's CSS custom properties from the generated ui-api.json
3+
// (extracted from the component stylesheets - never hand-maintained).
4+
import data from '../../generated/ui-api.json';
5+
6+
interface Props {
7+
slug: string;
8+
}
9+
10+
const { slug } = Astro.props;
11+
const entry = (data as Record<string, { cssVariables: CssVar[] }>)[slug];
12+
const cssVariables = entry?.cssVariables ?? [];
13+
14+
interface CssVar {
15+
name: string;
16+
comment: string;
17+
}
18+
---
19+
20+
{
21+
cssVariables.length === 0 ? (
22+
<p>This component does not expose its own CSS variables.</p>
23+
) : (
24+
<table>
25+
<thead>
26+
<tr>
27+
<th>Variable</th>
28+
<th>Notes</th>
29+
</tr>
30+
</thead>
31+
<tbody>
32+
{cssVariables.map((cssVar) => (
33+
<tr>
34+
<td>
35+
<code>{cssVar.name}</code>
36+
</td>
37+
<td>{cssVar.comment}</td>
38+
</tr>
39+
))}
40+
</tbody>
41+
</table>
42+
)
43+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
// Renders a component's props table from the generated ui-api.json (extracted
3+
// from `@workflowbuilder/ui` source with TypeDoc - never hand-maintained).
4+
import data from '../../generated/ui-api.json';
5+
6+
interface Props {
7+
slug: string;
8+
}
9+
10+
const { slug } = Astro.props;
11+
const entry = (data as Record<string, { props: PropRow[] }>)[slug];
12+
const props = entry?.props ?? [];
13+
14+
interface PropRow {
15+
name: string;
16+
type: string;
17+
required: boolean;
18+
default: string | null;
19+
description: string;
20+
}
21+
---
22+
23+
{
24+
props.length === 0 ? (
25+
<p>This component has no configurable props.</p>
26+
) : (
27+
<table>
28+
<thead>
29+
<tr>
30+
<th>Prop</th>
31+
<th>Type</th>
32+
<th>Default</th>
33+
<th>Description</th>
34+
</tr>
35+
</thead>
36+
<tbody>
37+
{props.map((prop) => (
38+
<tr>
39+
<td>
40+
<code>{prop.name}</code>
41+
{!prop.required && <span title="optional"> ?</span>}
42+
</td>
43+
<td>
44+
<code>{prop.type}</code>
45+
</td>
46+
<td>{prop.default ? <code>{prop.default}</code> : '-'}</td>
47+
<td>{prop.description.replace(/\n/g, ' ')}</td>
48+
</tr>
49+
))}
50+
</tbody>
51+
</table>
52+
)
53+
}

0 commit comments

Comments
 (0)