Skip to content

Commit ebd93b1

Browse files
committed
refactor(docs): trim inline comments to the traps worth keeping
1 parent 8ded18f commit ebd93b1

6 files changed

Lines changed: 37 additions & 122 deletions

File tree

apps/docs/scripts/check-ui-component-coverage.mjs

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,8 @@
1-
// Asserts two things about the documented component surface:
2-
//
3-
// 1. every entry in packages/ui/vite.config.mts's `componentEntries` (the
4-
// list of publishable subpath bundles) has a matching entry in
5-
// `COMPONENTS` (scripts/ui-components.mjs);
6-
// 2. every `COMPONENTS` entry is actually rendered by an MDX page - a
7-
// generated table nobody shows is the same drift in reverse.
8-
//
9-
// Without this guard, a new component can be added to the package's public
10-
// entry points and shipped to npm without ever getting a docs page - nothing
11-
// else in the build fails, the page just silently never exists. TypeDoc's
12-
// strict mode doesn't catch this either: it only checks that exported types
13-
// have doc comments, not that a docs page renders them.
14-
//
15-
// A vite entry counts as covered if COMPONENTS has a `dir` equal to the entry
16-
// name, or nested under it (`${entry}/...`) - entries like `node` bundle
17-
// several flat docs pages (node-icon, node-description, ...) rather than
18-
// mapping 1:1 by name. `NARRATIVE_ONLY` is an escape hatch for a vite entry
19-
// that is deliberately docs-only-by-prose with no generated props table at
20-
// all (none today - kept for the next one, e.g. a future compound component
21-
// documented like NodePanel).
22-
//
23-
// Wired into apps/docs/package.json's `generate:ui-api` script, right after
24-
// the generator runs, so `dev` / `build` / `typecheck` all catch the drift.
1+
// Cross-checks the documented component surface: every publishable subpath in
2+
// packages/ui/vite.config.mts has a COMPONENTS entry, and every COMPONENTS
3+
// entry is rendered by an MDX page. Without it a component ships to npm with
4+
// no docs page and nothing in the build complains. Runs as part of
5+
// `generate:ui-api`.
256

267
import { globSync, readFileSync } from 'node:fs';
278
import path from 'node:path';
@@ -37,9 +18,7 @@ const viteConfigPath = path.resolve(repoRoot, 'packages/ui/vite.config.mts');
3718

3819
const NARRATIVE_ONLY = new Set([]);
3920

40-
// The vite entry list is TypeScript, so it is read as text rather than
41-
// imported. An empty result means the shape changed and every check below
42-
// would pass over nothing - fail instead.
21+
// Read as text, not imported - it is TypeScript. Empty means the shape changed.
4322
function extractComponentEntries(source) {
4423
const match = /const componentEntries = \[([\s\S]*?)] as const;/.exec(source);
4524
if (!match) throw new Error('Could not find `componentEntries` in packages/ui/vite.config.mts');
@@ -59,9 +38,6 @@ const missing = componentEntries.filter((entry) => {
5938
return !componentDirectories.some((directory) => directory === entry || directory.startsWith(`${entry}/`));
6039
});
6140

62-
// A COMPONENTS entry only produces data - nothing renders it unless an MDX
63-
// page passes the slug to PropsTable / CssVariablesTable. Without this leg,
64-
// adding a generator entry with no page passes the whole build silently.
6541
const contentRoot = path.resolve(documentsRoot, 'src/content/docs');
6642
const pageSources = globSync('**/*.mdx', { cwd: contentRoot }).map((file) =>
6743
readFileSync(path.resolve(contentRoot, file), 'utf8'),

apps/docs/scripts/generate-ui-api.mjs

Lines changed: 18 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@ const uiSource = path.resolve(repoRoot, 'packages/ui/src');
2424
const outFile = path.resolve(documentsRoot, 'src/generated/ui-api.json');
2525
const tdJson = path.resolve(documentsRoot, 'node_modules/.cache/ui-typedoc.json');
2626

27-
// Comment patterns that are engineering notes on the token pipeline, not
28-
// public documentation - stripped so they never render as CSS variable
29-
// descriptions in the docs (see e.g. status.module.css, icon-size.module.css).
27+
// Engineering notes in the CSS, never public documentation.
3028
const INTERNAL_NOTE_RE = /missing token/i;
3129

3230
async function runTypedoc() {
@@ -37,18 +35,10 @@ async function runTypedoc() {
3735
[
3836
'--json',
3937
tdJson,
40-
// `expand` over the whole components tree (rather than resolving just
41-
// `index.ts`'s re-exports) so that per-variant prop types like
42-
// LabelButtonProps/IconButtonProps/IconLabelButtonProps - not
43-
// individually re-exported from the package barrel, only reachable
44-
// through the Button component's overloaded signature - still get a
45-
// full type reflection collectVariantProps can look up by name.
38+
// Whole tree, not the barrel: variant prop types are not re-exported.
4639
'--entryPoints',
4740
path.resolve(uiSource, 'components'),
48-
// `shared` must be an entry too: helper prop types like WithIcon live
49-
// there, and a type outside the entry tree gets no reflection - its
50-
// intersection members (e.g. Accordion/Modal's `icon`) silently vanish
51-
// from the generated tables.
41+
// A type outside the entry tree gets no reflection and vanishes from the tables.
5242
'--entryPoints',
5343
path.resolve(uiSource, 'shared'),
5444
'--entryPointStrategy',
@@ -78,7 +68,6 @@ function indexById(root) {
7868
function findTypeByName(root, name, warnings) {
7969
const matches = [];
8070
(function walk(node) {
81-
// 2097152 = TypeAlias, 256 = Interface
8271
if (node.name === name && (node.kind === 2_097_152 || node.kind === 256)) matches.push(node);
8372
for (const child of node.children ?? []) walk(child);
8473
})(root);
@@ -169,10 +158,8 @@ function defaultTag(comment) {
169158
return value || null;
170159
}
171160

172-
// A component whose props extend a native element's attributes accepts far
173-
// more than the table lists (`placeholder`, `value`, `onChange`, aria-*, ...).
174-
// Enumerating ~280 DOM attributes would drown the table, so record which
175-
// element it forwards to and let the page say so in one line.
161+
// Which native element a component forwards its remaining props to; listing
162+
// ~280 DOM attributes in the table would drown the props that are ours.
176163
const NATIVE_ATTRIBUTE_TYPES = new Map([
177164
['InputHTMLAttributes', 'input'],
178165
['ButtonHTMLAttributes', 'button'],
@@ -185,34 +172,27 @@ const NATIVE_ATTRIBUTE_TYPES = new Map([
185172
function findNativeElement(typeNode, byId, depth = 0) {
186173
if (!typeNode || depth > 8) return null;
187174
if (typeNode.type === 'reference') {
188-
// TypeDoc keeps the qualifier when the import is namespaced (`React.HTMLAttributes`).
189175
const element = NATIVE_ATTRIBUTE_TYPES.get(typeNode.name.replace(/^React\./, ''));
190176
if (element === 'element') {
191-
// Plain HTMLAttributes<T> - name the element from its type argument.
192177
const tag = /^HTML(\w*?)Element$/.exec(typeNode.typeArguments?.[0]?.name ?? '')?.[1];
193178
return tag ? tag.toLowerCase() || 'element' : 'element';
194179
}
195180
if (element) return element;
196-
// A first-party alias can carry it indirectly (BaseButtonProps -> ButtonHTMLAttributes).
197181
if (typeof typeNode.target === 'number') {
198182
const found = findNativeElement(byId.get(typeNode.target)?.type, byId, depth + 1);
199183
if (found) return found;
200184
}
201185
}
202-
// The reference is usually wrapped: `Omit<InputHTMLAttributes<…>, 'size'>`.
203186
for (const nested of [...(typeNode.types ?? []), ...(typeNode.typeArguments ?? [])]) {
204187
const found = findNativeElement(nested, byId, depth + 1);
205188
if (found) return found;
206189
}
207190
return null;
208191
}
209192

210-
// Collect own properties from a prop type alias / interface, walking
211-
// intersections and skipping referenced (extended / native HTML) members.
193+
// Own properties of a prop type, walking intersections and skipping native members.
212194
function collectProps(typeNode, byId, accumulator = new Map(), context = null) {
213195
if (!typeNode) return accumulator;
214-
// TypeAlias / Interface: plain object members land directly on `.children`;
215-
// computed types (intersections etc.) land on `.type`.
216196
if (typeNode.kind === 2_097_152 || typeNode.kind === 256) {
217197
if (typeNode.children?.length) {
218198
for (const child of typeNode.children) addProperty(child, byId, accumulator);
@@ -230,16 +210,13 @@ function collectProps(typeNode, byId, accumulator = new Map(), context = null) {
230210
}
231211
if (typeNode.type === 'reference' && typeof typeNode.target === 'number') {
232212
const target = byId.get(typeNode.target);
233-
// Only follow references into our own package's prop types, not native ones.
234-
// Both declaration forms count - a prop type written as an interface is as
235-
// valid a target as a type alias.
213+
// Follow first-party prop types only; both declaration forms count.
236214
if (target && (target.kind === 2_097_152 || target.kind === 256)) {
237215
collectProps(target, byId, accumulator, context);
238216
}
239217
return accumulator;
240218
}
241-
// An unfollowed generic reference (Partial<X>, Omit<X, ...>) silently drops
242-
// every prop of a first-party X - warn instead of shipping a slimmer table.
219+
// Partial<X> / Omit<X, …> would silently drop every prop of X.
243220
if (typeNode.type === 'reference' && typeNode.typeArguments?.length && context) {
244221
const firstParty = typeNode.typeArguments.find(
245222
(argument) => argument.type === 'reference' && typeof argument.target === 'number' && byId.get(argument.target),
@@ -264,12 +241,8 @@ function addProperty(child, byId, accumulator) {
264241
});
265242
}
266243

267-
// Merge the prop sets of a discriminated-union component's variants (e.g.
268-
// Button's Label/Icon/IconLabel components). Props shared by every variant
269-
// with the same type are documented once, unqualified; props that are
270-
// variant-specific (missing from, or typed differently in, some variants)
271-
// get a note appended to their description so the table stays a single flat
272-
// list without a separate "variants" column.
244+
// Merges the variants of a union/overload component into one flat table,
245+
// noting in the description where a prop applies to some variants only.
273246
function collectVariantProps(propsTypeNames, project, byId, warnings, slug, context) {
274247
const perVariant = [];
275248
for (const typeName of propsTypeNames) {
@@ -290,15 +263,13 @@ function collectVariantProps(propsTypeNames, project, byId, warnings, slug, cont
290263
const occurrences = perVariant
291264
.filter((variant) => variant.props.has(propertyName))
292265
.map((variant) => ({ typeName: variant.typeName, prop: variant.props.get(propertyName) }))
293-
// `foo?: never` marks a prop as forbidden in that variant - treat it as absent.
266+
// `foo?: never` marks a prop forbidden in that variant.
294267
.filter((occurrence) => occurrence.prop.type !== 'never');
295268
if (occurrences.length === 0) continue;
296269
const distinctTypes = new Set(occurrences.map((o) => o.prop.type));
297270
const sharedByAll = occurrences.length === perVariant.length && distinctTypes.size === 1;
298271

299-
// Required only when required in EVERY variant: `value` (controlled) and
300-
// `defaultValue` (uncontrolled) marked required side by side would
301-
// document a call that cannot exist. The variant note carries the detail.
272+
// Required in every variant, else the table documents an impossible call.
302273
const requiredEverywhere =
303274
occurrences.length === perVariant.length && occurrences.every((o) => o.prop.required);
304275
const requiredInItsVariants = !requiredEverywhere && occurrences.every((o) => o.prop.required);
@@ -329,20 +300,16 @@ function collectVariantProps(propsTypeNames, project, byId, warnings, slug, cont
329300
}
330301

331302
function extractCssVariables(directory, warnings, slug) {
332-
// An entry that documents an API rather than a styled component (a hook, say)
333-
// carries no directory - its page renders the owning component's variables.
303+
// No directory - the entry documents an API, not a styled component.
334304
if (!directory) return [];
335305

336306
const abs = path.resolve(uiSource, 'components', directory);
337307
if (!existsSync(abs)) {
338-
// globSync on a missing directory returns [] - the page would then claim
339-
// the component exposes no CSS variables.
340308
warnings.push(`"${slug}": component directory ${directory} does not exist`);
341309
return [];
342310
}
343-
// Subcomponents with their own COMPONENTS entry document their own
344-
// variables - without this a parent page repeats them and offers overrides
345-
// that do nothing there (Button listing NavButton's, Switch IconSwitch's).
311+
// Subcomponents with their own page document their own variables; an
312+
// override offered on the parent page would do nothing.
346313
const nestedPrefixes = COMPONENTS.map((component) => component.dir)
347314
.filter((nested) => nested?.startsWith(`${directory}/`))
348315
.map((nested) => `${nested.slice(directory.length + 1)}/`);
@@ -354,8 +321,6 @@ function extractCssVariables(directory, warnings, slug) {
354321
const variables = [];
355322
for (const file of files) {
356323
const css = readFileSync(path.resolve(abs, file), 'utf8');
357-
// Match `--ax-public-xxx: value` declarations, capturing the value and an
358-
// optional same-line comment.
359324
const re = /(--ax-public-[\w-]+)\s*:\s*([^;]*?)(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g;
360325
let m;
361326
while ((m = re.exec(css))) {
@@ -372,10 +337,8 @@ function extractCssVariables(directory, warnings, slug) {
372337
return variables;
373338
}
374339

375-
// Groups the docs table by what the variable resolves to, not by what its name
376-
// suggests: `edge-stroke-width` is a length and `snackbar-success-border` is a
377-
// color, and neither reads that way from the name alone. Values usually point
378-
// at a design token, so the token stylesheets are followed to the literal.
340+
// Groups by what the value resolves to - the name misleads (`edge-stroke-width`
341+
// is a length, `snackbar-success-border` is a color), so follow it to the literal.
379342
const LITERAL_COLOR_RE = /^(#|rgb|hsl|oklch|color-mix|linear-gradient|radial-gradient|transparent\b|currentColor\b)/i;
380343

381344
const tokenValues = readTokenValues();
@@ -442,8 +405,7 @@ async function main() {
442405
console.log('✔ ui-api.json generated\n ' + summary.join('\n '));
443406

444407
if (warnings.length > 0) {
445-
// An unresolved props type means a rename/typo silently shipped an empty
446-
// "no configurable props" page - fail the build instead of warning.
408+
// An unresolved type would silently ship a "no configurable props" page.
447409
console.error('✗ ' + warnings.join('\n✗ '));
448410
process.exitCode = 1;
449411
}

apps/docs/scripts/ui-components.mjs

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,13 @@
66
* Imported by both the generator and the coverage guard, so the guard can
77
* never disagree with the generator about what is documented.
88
*/
9-
// slug -> { name, propsType, dir }. `propsType` is the exported prop type the
10-
// component accepts (or the list of variant prop types for a component whose
11-
// public surface is a discriminated union - see collectVariantProps), or null
12-
// when the parts are described in prose instead. `dir` is the component folder
13-
// under packages/ui/src/components, or null for an entry that documents an API
14-
// but owns no stylesheet of its own.
9+
// `propsType`: the exported prop type, a list of variant types, or null when
10+
// the parts are described in prose. `dir`: the folder under
11+
// packages/ui/src/components, or null for an entry that owns no stylesheet.
1512
export const COMPONENTS = [
1613
{ slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' },
1714
{ slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' },
18-
// Button has no single public props type - it renders one of three variant
19-
// components depending on `children` (label / icon / icon+label). Merge
20-
// their prop sets instead of documenting only the shared base.
15+
// No single props type - one of three variants depending on `children`.
2116
{
2217
slug: 'button',
2318
name: 'Button',
@@ -51,8 +46,7 @@ export const COMPONENTS = [
5146
{ slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' },
5247
{ slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' },
5348
{ slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' },
54-
// Diagram components (props extracted the same way; NodePanel is a compound
55-
// component documented narratively, so it has no flat props entry here).
49+
// Diagram components.
5650
{ slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' },
5751
{
5852
slug: 'node-description',
@@ -67,12 +61,8 @@ export const COMPONENTS = [
6761
dir: 'node/node-as-port-wrapper',
6862
},
6963
{ slug: 'edge', name: 'EdgeLabel', propsType: 'EdgeLabelProps', dir: 'edge' },
70-
// Documented on the Edge page, which already renders the edge variables -
71-
// `dir: null` keeps them there instead of splitting them off to a hook that
72-
// has no variables section of its own.
64+
// Documented on the Edge page, which already renders the edge variables.
7365
{ slug: 'use-edge-style', name: 'useEdgeStyle', propsType: 'UseEdgeStyleParams', dir: null },
74-
// Compound component: the parts and their props are described in prose on the
75-
// page (like Separator, there is no single props type to generate from), but
76-
// its CSS variables are generated.
66+
// Compound component - parts described in prose, variables generated.
7767
{ slug: 'node-panel', name: 'NodePanel', propsType: null, dir: 'node/node-panel' },
7868
];

apps/docs/src/components/api/css-variables-table.astro

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
---
2-
// Renders a component's CSS custom properties (from the component stylesheets,
3-
// generated into ui-api.json) as a list grouped into Size / Color, mirroring the
4-
// Overflow UI docs.
2+
// CSS custom properties of a UI component, generated from source into ui-api.json.
53
import data from '../../generated/ui-api.json';
64
75
interface CssVariable {

apps/docs/src/components/api/props-table.astro

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
---
2-
// Renders a component's props as a card list (name + optional "required" chip +
3-
// Type / Default + description), generated from `@workflowbuilder/ui` source via
4-
// TypeDoc. Mirrors the Overflow UI docs - required props first, no `?` marker.
2+
// Props of a UI component, generated from source into ui-api.json.
53
import data from '../../generated/ui-api.json';
64
75
interface PropertyRow {
@@ -27,8 +25,6 @@ if (!entry) {
2725
const props = [...(entry?.props ?? [])].sort(
2826
(a, b) => Number(b.required) - Number(a.required) || a.name.localeCompare(b.name),
2927
);
30-
// Components built on a native element forward everything else to it; listing
31-
// ~280 DOM attributes would bury the props that are actually ours.
3228
const nativeElement = entry?.nativeElement ?? null;
3329
---
3430

apps/docs/src/components/ui-examples/component-preview.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,10 @@ import { createPortal } from 'react-dom';
55

66
import styles from './component-preview.module.css';
77

8-
// Render examples inside a shadow root so Starlight's stylesheet RULES cannot
9-
// reach the components (and the library's cannot leak out) - the same
10-
// isolation the original Overflow UI docs use. Inherited properties
11-
// (typography, color) and custom properties still cross the shadow boundary
12-
// by design: that is how the docs theme (`--ax-*` per data-theme) reaches the
13-
// examples, and why a preview is close to - not pixel-identical with - a
14-
// consumer app that inherits different page styles.
15-
//
16-
// Inside a shadow root `:root` matches nothing, so retarget the library's
17-
// root-scoped custom-property defaults to `:host`. The stage has a fixed
18-
// max width, so oversized examples (Snackbar) shrink instead of clipping.
8+
// Examples render in a shadow root so Starlight's rules cannot reach them and
9+
// the library's cannot leak out. Inherited and custom properties still cross
10+
// the boundary - that is how the docs theme reaches the examples. Inside a
11+
// shadow root `:root` matches nothing, hence the retarget to `:host`.
1912
const shadowCss = `${`${globalCss}\n${componentCss}`.replaceAll(':root', ':host')}
2013
:host > :not(style) { max-width: 100%; }`;
2114

0 commit comments

Comments
 (0)