Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,8 @@ CLAUDE.local.md
# and is copied into this dir by a tiny Astro integration after typedoc
# generation (see astro.config.mjs).
apps/docs/src/content/docs/api/

# UI Library props + CSS-variable data, generated from @workflowbuilder/ui by
# apps/docs/scripts/generate-ui-api.mjs (TypeDoc + CSS extraction) on every
# docs build / dev. Source of truth is the library, so keep it out of git.
apps/docs/src/generated/
18 changes: 17 additions & 1 deletion apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { copyFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';

import react from '@astrojs/react';
import starlight from '@astrojs/starlight';
import umami from '@yeskunall/astro-umami';
import { defineConfig, passthroughImageService } from 'astro/config';
Expand Down Expand Up @@ -70,6 +71,7 @@ export default defineConfig({
},
integrations: [
icon(),
react(),
umami({ id: UMAMI_WEBSITE_ID }),
starlight({
plugins: [
Expand Down Expand Up @@ -113,7 +115,12 @@ export default defineConfig({
},
}),
],
customCss: ['./src/styles/custom.css'],
// `@workflowbuilder/ui` styles are safe to load globally: styles.css is
// just the @layer order + one :root var + opt-in `.ax-public-*` typography
// classes (no global reset), and tokens.css only defines `--ax-*` custom
// properties keyed on `html[data-theme]` — which Starlight already toggles,
// so the live component showcases follow the docs light/dark theme.
customCss: ['./src/styles/custom.css', '@workflowbuilder/ui/styles.css', '@workflowbuilder/ui/tokens.css'],
components: {
Head: './src/components/head.astro',
Search: './src/components/search.astro',
Expand Down Expand Up @@ -159,6 +166,15 @@ export default defineConfig({
{ label: 'Node Schemas', autogenerate: { directory: 'node-schemas' } },
{ label: 'Built-in Nodes', autogenerate: { directory: 'nodes' } },
{ label: 'Plugins', autogenerate: { directory: 'plugins' } },
{
label: 'UI Library',
items: [
{ label: 'Overview', link: '/ui-library/overview/' },
{ label: 'Design tokens', link: '/ui-library/design-tokens/' },
{ label: 'UI Components', autogenerate: { directory: 'ui-library/ui-components' } },
{ label: 'Diagram Components', autogenerate: { directory: 'ui-library/diagram-components' } },
],
},
// API Reference — pages auto-generated by `starlight-typedoc` from
// packages/sdk's barrel into `src/content/docs/api/<Category>/`.
// Folder names match the `@category` tag in source TSDoc verbatim.
Expand Down
12 changes: 10 additions & 2 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm clean:typedoc && astro dev",
"build": "pnpm clean:typedoc && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs",
"dev": "pnpm clean:typedoc && pnpm generate:ui-api && astro dev",
"build": "pnpm clean:typedoc && pnpm generate:ui-api && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs",
"generate:ui-api": "node scripts/generate-ui-api.mjs",
"clean:typedoc": "node -e \"import('node:fs').then(fs => fs.rmSync('src/content/docs/api', { recursive: true, force: true }))\"",
"preview": "astro preview",
"typecheck": "astro check",
Expand All @@ -14,18 +15,25 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@astrojs/react": "^4.4.0",
"@astrojs/starlight": "^0.37.6",
"@base-ui/react": "catalog:",
"@iconify-json/ph": "^1.2.2",
"@workflowbuilder/sdk": "workspace:*",
"@workflowbuilder/ui": "workspace:*",
"@yeskunall/astro-umami": "^0.0.7",
"astro": "^5.18.0",
"astro-icon": "^1.1.5",
"marked": "^15.0.0",
"react": "catalog:",
"react-dom": "catalog:",
"rehype-external-links": "^3.0.0",
"starlight-image-zoom": "^0.13.2"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@types/react": "catalog:",
"@types/react-dom": "^19.1.0",
"eslint-plugin-astro": "^1.3.1",
"starlight-typedoc": "^0.21.3",
"typedoc": "^0.28.9",
Expand Down
223 changes: 223 additions & 0 deletions apps/docs/scripts/generate-ui-api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*
* Generates `src/generated/ui-api.json` for the UI Library docs.
*
* Props are extracted with TypeDoc (source of truth: the component prop types
* in `@workflowbuilder/ui`); CSS variables are extracted from each component's
* stylesheets. The per-component docs pages render this JSON, so the Props and
* CSS variables tables never drift from source. Run by `pnpm generate:ui-api`
* and as a prebuild step in `dev` / `build`.
*/
import { execFile } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { globSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

const here = path.dirname(fileURLToPath(import.meta.url));
const docsRoot = path.resolve(here, '..');
const repoRoot = path.resolve(docsRoot, '../..');
const uiSrc = path.resolve(repoRoot, 'packages/ui/src');
const outFile = path.resolve(docsRoot, 'src/generated/ui-api.json');
const tdJson = path.resolve(docsRoot, 'node_modules/.cache/ui-typedoc.json');

// slug -> { name, propsType, dir }. `propsType` is the exported prop type the
// component accepts; `dir` is the component folder under packages/ui/src/components.
const COMPONENTS = [
{ slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' },
{ slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' },
{ slug: 'button', name: 'Button', propsType: 'BaseRegularButtonProps', dir: 'button' },
{ slug: 'checkbox', name: 'Checkbox', propsType: 'CheckboxProps', dir: 'checkbox' },
{ slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' },
{ slug: 'input', name: 'Input', propsType: 'InputProps', dir: 'input' },
{ slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' },
{ slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' },
{ slug: 'nav-button', name: 'NavButton', propsType: 'NavBaseButtonProps', dir: 'button/nav-button' },
{ slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' },
{ slug: 'segment-picker', name: 'SegmentPicker', propsType: 'SegmentPickerProps', dir: 'segment-picker' },
{ slug: 'select', name: 'Select', propsType: 'SelectBaseProps', dir: 'select' },
{ slug: 'separator', name: 'Separator', propsType: null, dir: 'separator' },
{ slug: 'snackbar', name: 'Snackbar', propsType: 'SnackbarProps', dir: 'snackbar' },
{ slug: 'status', name: 'Status', propsType: 'StatusProps', dir: 'status' },
{ slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' },
{ slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' },
{ slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' },
// Diagram components (props extracted the same way; NodePanel is a compound
// component documented narratively, so it has no flat props entry here).
{ slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' },
{ slug: 'node-description', name: 'NodeDescription', propsType: 'NodeDescriptionProps', dir: 'node/node-description' },
{ slug: 'node-as-port-wrapper', name: 'NodeAsPortWrapper', propsType: 'NodeAsPortWrapperProps', dir: 'node/node-as-port-wrapper' },
{ slug: 'edge', name: 'EdgeLabel', propsType: 'EdgeLabelProps', dir: 'edge' },
];

async function runTypedoc() {
await mkdir(path.dirname(tdJson), { recursive: true });
const bin = path.resolve(docsRoot, 'node_modules/.bin/typedoc');
await promisify(execFile)(
bin,
[
'--json', tdJson,
'--entryPoints', path.resolve(uiSrc, 'index.ts'),
'--tsconfig', path.resolve(repoRoot, 'packages/ui/tsconfig.json'),
'--excludeExternals', '--excludePrivate', '--skipErrorChecking', '--logLevel', 'Error',
],
{ cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 },
);
return JSON.parse(await readFile(tdJson, 'utf8'));
}

function indexById(root) {
const byId = new Map();
(function walk(node) {
if (node && typeof node.id === 'number') byId.set(node.id, node);
for (const child of node.children ?? []) walk(child);
})(root);
return byId;
}

function findTypeByName(root, name) {
let found = null;
(function walk(node) {
if (found) return;
// 2097152 = TypeAlias, 256 = Interface
if (node.name === name && (node.kind === 2097152 || node.kind === 256)) found = node;
for (const child of node.children ?? []) walk(child);
})(root);
return found;
}

function typeToString(t, byId, depth = 0) {
if (!t || depth > 6) return 'unknown';
switch (t.type) {
case 'intrinsic': return t.name;
case 'literal': return typeof t.value === 'string' ? `'${t.value}'` : String(t.value);
case 'reference': {
const args = t.typeArguments?.length ? `<${t.typeArguments.map((a) => typeToString(a, byId, depth + 1)).join(', ')}>` : '';
return `${t.name}${args}`;
}
case 'union': return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' | ');
case 'intersection': return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' & ');
case 'array': return `${typeToString(t.elementType, byId, depth + 1)}[]`;
case 'tuple': return `[${(t.elements ?? []).map((x) => typeToString(x, byId, depth + 1)).join(', ')}]`;
case 'reflection': {
const sig = t.declaration?.signatures?.[0];
if (sig) {
const params = (sig.parameters ?? []).map((p) => `${p.name}: ${typeToString(p.type, byId, depth + 1)}`).join(', ');
return `(${params}) => ${typeToString(sig.type, byId, depth + 1)}`;
}
return '{ … }';
}
case 'indexedAccess': return `${typeToString(t.objectType, byId, depth + 1)}[${typeToString(t.indexType, byId, depth + 1)}]`;
case 'templateLiteral': return 'string';
case 'query': return typeToString(t.queryType, byId, depth + 1);
case 'predicate': return 'boolean';
case 'typeOperator': return `${t.operator} ${typeToString(t.target, byId, depth + 1)}`;
default: return t.name ?? 'unknown';
}
}

function summaryText(comment) {
if (!comment?.summary) return '';
return comment.summary.map((s) => s.text).join('').trim();
}

function defaultTag(comment) {
const tag = (comment?.blockTags ?? []).find((b) => b.tag === '@default' || b.tag === '@defaultValue');
if (!tag) return null;
let value = tag.content.map((c) => c.text).join('').trim();
value = value.replace(/^```[a-z]*\s*/i, '').replace(/\s*```$/, '').trim(); // strip ```ts … ``` fences
value = value.replace(/^`+|`+$/g, '').trim(); // strip inline backticks
return value || null;
}

// Collect own properties from a prop type alias / interface, walking
// intersections and skipping referenced (extended / native HTML) members.
function collectProps(typeNode, byId, acc = new Map()) {
if (!typeNode) return acc;
// TypeAlias / Interface: plain object members land directly on `.children`;
// computed types (intersections etc.) land on `.type`.
if (typeNode.kind === 2097152 || typeNode.kind === 256) {
if (typeNode.children?.length) {
for (const child of typeNode.children) addProp(child, byId, acc);
return acc;
}
return collectProps(typeNode.type, byId, acc);
}
if (typeNode.type === 'intersection' || typeNode.type === 'union') {
for (const member of typeNode.types) collectProps(member, byId, acc);
return acc;
}
if (typeNode.type === 'reflection' && typeNode.declaration?.children) {
for (const child of typeNode.declaration.children) addProp(child, byId, acc);
return acc;
}
if (typeNode.type === 'reference' && typeof typeNode.target === 'number') {
const target = byId.get(typeNode.target);
// Only follow references into our own package's prop types, not native ones.
if (target && target.kind === 2097152) collectProps(target, byId, acc);
return acc;
}
return acc;
}

function addProp(child, byId, acc) {
if (child.kind !== 1024 || acc.has(child.name)) return; // 1024 = Property
acc.set(child.name, {
name: child.name,
type: typeToString(child.type, byId),
required: !child.flags?.isOptional,
default: defaultTag(child.comment),
description: summaryText(child.comment),
});
}

function extractCssVariables(dir) {
const abs = path.resolve(uiSrc, 'components', dir);
const files = globSync('**/*.css', { cwd: abs }).sort();
const seen = new Set();
const vars = [];
for (const file of files) {
const css = readFileSync(path.resolve(abs, file), 'utf8');
// Match `--ax-public-xxx:` declarations, capturing an optional same-line comment.
const re = /(--ax-public-[\w-]+)\s*:[^;]*?(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g;
let m;
while ((m = re.exec(css))) {
if (seen.has(m[1])) continue;
seen.add(m[1]);
vars.push({ name: m[1], comment: (m[2] ?? '').trim() });
}
}
return vars;
}

async function main() {
const project = await runTypedoc();
const byId = indexById(project);
const out = {};
const warnings = [];

for (const c of COMPONENTS) {
let props = [];
if (c.propsType) {
const typeNode = findTypeByName(project, c.propsType);
if (typeNode) {
props = [...collectProps(typeNode, byId).values()].sort((a, b) => a.name.localeCompare(b.name));
} else {
warnings.push(`props type "${c.propsType}" not found for "${c.slug}"`);
}
}
out[c.slug] = { name: c.name, props, cssVariables: extractCssVariables(c.dir) };
}

await mkdir(path.dirname(outFile), { recursive: true });
await writeFile(outFile, JSON.stringify(out, null, 2) + '\n');

const summary = Object.entries(out).map(([s, v]) => `${s}: ${v.props.length} props, ${v.cssVariables.length} vars`);
console.log('✔ ui-api.json generated\n ' + summary.join('\n '));
if (warnings.length) console.warn('⚠ ' + warnings.join('\n⚠ '));
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
43 changes: 43 additions & 0 deletions apps/docs/src/components/api/css-variables-table.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
// Renders a component's CSS custom properties from the generated ui-api.json
// (extracted from the component stylesheets - never hand-maintained).
import data from '../../generated/ui-api.json';

interface Props {
slug: string;
}

const { slug } = Astro.props;
const entry = (data as Record<string, { cssVariables: CssVar[] }>)[slug];
const cssVariables = entry?.cssVariables ?? [];

interface CssVar {
name: string;
comment: string;
}
---

{
cssVariables.length === 0 ? (
<p>This component does not expose its own CSS variables.</p>
) : (
<table>
<thead>
<tr>
<th>Variable</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{cssVariables.map((cssVar) => (
<tr>
<td>
<code>{cssVar.name}</code>
</td>
<td>{cssVar.comment}</td>
</tr>
))}
</tbody>
</table>
)
}
Loading