From 64f5b5d5dad24ad7f1c8660e7363fdc6ebe019bf Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Mon, 6 Apr 2026 17:36:37 -0500 Subject: [PATCH] test(site): replace api-docs-builder design doc with E2E spec tests (#1225) Co-authored-by: Claude --- internal/design/site/api-docs-builder.md | 1368 ----------------- site/CLAUDE.md | 6 +- site/scripts/api-docs-builder/README.md | 22 +- site/scripts/api-docs-builder/src/index.ts | 633 +------- site/scripts/api-docs-builder/src/pipeline.ts | 511 ++++++ .../src/tests/core-handler.test.ts | 378 ----- .../src/tests/css-vars-handler.test.ts | 121 -- .../src/tests/data-attrs-handler.test.ts | 314 ---- .../api-docs-builder/src/tests/e2e.test.ts | 631 ++++++++ .../core/src/core/ui/gauge/gauge-core.ts | 32 + .../core/src/core/ui/gauge/gauge-css-vars.ts | 10 + .../src/core/ui/gauge/gauge-data-attrs.ts | 23 + .../src/core/ui/pip-button/pip-button-core.ts | 22 + .../core/src/core/ui/slider/slider-core.ts | 27 + .../src/core/ui/slider/slider-data-attrs.ts | 19 + .../ui/toggle-button/toggle-button-core.ts | 34 + .../toggle-button/toggle-button-css-vars.ts | 12 + .../toggle-button/toggle-button-data-attrs.ts | 19 + .../ui/volume-slider/volume-slider-core.ts | 23 + .../html/src/ui/gauge/gauge-element.ts | 9 + .../html/src/ui/gauge/gauge-fill-element.ts | 7 + .../html/src/ui/gauge/gauge-track-element.ts | 9 + .../html/src/ui/slider/slider-element.ts | 3 + .../src/ui/slider/slider-thumb-element.ts | 3 + .../src/ui/slider/slider-track-element.ts | 3 + .../ui/toggle-button/toggle-button-element.ts | 9 + .../ui/volume-slider/volume-slider-element.ts | 3 + .../react/src/ui/gauge/gauge-fill.tsx | 28 + .../react/src/ui/gauge/gauge-indicator.tsx | 16 + .../react/src/ui/gauge/gauge-label.tsx | 14 + .../react/src/ui/gauge/gauge-track.tsx | 13 + .../react/src/ui/gauge/index.parts.ts | 14 + .../react/src/ui/slider/index.parts.ts | 8 + .../react/src/ui/slider/slider-root.tsx | 13 + .../react/src/ui/slider/slider-thumb.tsx | 15 + .../react/src/ui/slider/slider-track.tsx | 6 + .../react/src/ui/volume-slider/index.parts.ts | 17 + .../ui/volume-slider/volume-slider-root.tsx | 13 + .../src/tests/html-handler.test.ts | 105 -- .../src/tests/parts-handler.test.ts | 187 --- .../api-docs-builder/src/tests/test-utils.ts | 27 - .../src/tests/util-handler.test.ts | 127 -- .../api-docs-builder/src/tests/utils.test.ts | 88 -- 43 files changed, 1602 insertions(+), 3340 deletions(-) delete mode 100644 internal/design/site/api-docs-builder.md create mode 100644 site/scripts/api-docs-builder/src/pipeline.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/core-handler.test.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/css-vars-handler.test.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts create mode 100644 site/scripts/api-docs-builder/src/tests/e2e.test.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-core.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-css-vars.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-data-attrs.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/pip-button/pip-button-core.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-core.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-data-attrs.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-core.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-css-vars.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-data-attrs.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/volume-slider-core.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-fill-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-track-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-thumb-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-track-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/toggle-button/toggle-button-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/volume-slider/volume-slider-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-fill.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-indicator.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-label.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-track.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/index.parts.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/index.parts.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-root.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-thumb.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-track.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/index.parts.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/volume-slider-root.tsx delete mode 100644 site/scripts/api-docs-builder/src/tests/html-handler.test.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/parts-handler.test.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/test-utils.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/util-handler.test.ts delete mode 100644 site/scripts/api-docs-builder/src/tests/utils.test.ts diff --git a/internal/design/site/api-docs-builder.md b/internal/design/site/api-docs-builder.md deleted file mode 100644 index c27bd907..00000000 --- a/internal/design/site/api-docs-builder.md +++ /dev/null @@ -1,1368 +0,0 @@ -# API Docs Builder Spec - -Ground-truth specification for the API docs builder pipeline: from TypeScript source code to -rendered documentation tables. When implementation diverges from this spec, this spec wins. - -Inspired by [Base UI](https://github.com/mui/base-ui)'s API reference system. Base UI generates one JSON -file per component part, and each part gets its own props and data-attributes tables. Our system -aspires to the same philosophy but has a known architectural limitation: a single Props/State -interface per component at the core level means only one part (the "primary") can own core-level props -and state. Non-primary parts get shared data attributes, custom React-specific props, and a description. - -**Principles:** - -- **Convention over configuration.** The builder infers structure from file naming and placement. - No config files, no explicit annotations for standard cases. Follow the conventions and things - just work. -- **Spec wins.** When implementation diverges from this spec, the spec is the source of truth. - -**Scope:** This spec covers the builder (extraction + JSON generation), the reference model -layer (JSON → heading/section structure), TOC integration, and rendered output (tables + disclosure -panels). It does not cover CSS styling, demo scaffolding, or MDX page authoring. - ---- - -## 1. Sample Inputs - -These fictional examples are the source-of-truth for expected behavior throughout this spec. - -### 1a. Single-part component: `ToggleButton` - -**Core file** — `packages/core/src/core/ui/toggle-button/toggle-button-core.ts`: - -```ts -interface ToggleButtonProps { - /** Whether the button is disabled. */ - disabled: boolean; - /** Custom label for the button. */ - label: string | ((state: ToggleButtonState) => string); -} - -interface ToggleButtonState { - /** Whether the toggle is pressed. */ - pressed: boolean; - /** Whether the button is disabled. */ - disabled: boolean; -} - -class ToggleButtonCore { - static readonly defaultProps = { - disabled: false, - label: '', - } as const; -} -``` - -**Data attributes file** — `packages/core/src/core/ui/toggle-button/toggle-button-data-attrs.ts`: - -```ts -import type { StateAttrMap } from '../types'; - -export const ToggleButtonDataAttrs = { - /** Present when the toggle is pressed. */ - pressed: 'data-pressed', - /** Present when the button is disabled. */ - disabled: 'data-disabled', -} as const satisfies StateAttrMap; -``` - -**HTML element file** — `packages/html/src/ui/toggle-button/toggle-button-element.ts`: - -```ts -export class ToggleButtonElement extends ... { - static readonly tagName = 'media-toggle-button'; -} -``` - -### 1b. Multi-part component: `Meter` - -**Core file** — `packages/core/src/core/ui/meter/meter-core.ts`: - -```ts -interface MeterProps { - /** Minimum value. */ - min: number; - /** Maximum value. */ - max: number; - /** Custom label for accessibility. */ - label: string | ((state: MeterState) => string); -} - -interface MeterState { - /** Current value as a percentage (0–1). */ - percentage: number; - /** The fill level. */ - fillState: 'empty' | 'partial' | 'full'; -} - -class MeterCore { - static readonly defaultProps = { - min: 0, - max: 100, - label: '', - } as const; -} -``` - -**Data attributes file** — `packages/core/src/core/ui/meter/meter-data-attrs.ts`: - -```ts -import type { StateAttrMap } from '../types'; - -export const MeterDataAttrs = { - /** Current percentage as a string. */ - percentage: 'data-percentage', - /** The fill level. */ - fillState: 'data-fill-state', -} as const satisfies StateAttrMap; -``` - -**HTML element files:** - -- `packages/html/src/ui/meter/meter-element.ts` → `static tagName = 'media-meter'` -- `packages/html/src/ui/meter/meter-track-element.ts` → `static tagName = 'media-meter-track'` -- `packages/html/src/ui/meter/meter-fill-element.ts` → `static tagName = 'media-meter-fill'` - -**React parts index** — `packages/react/src/ui/meter/index.parts.ts`: - -```ts -export { default as Track } from './Track'; -export { default as Fill } from './Fill'; -export { default as Indicator } from './Indicator'; -``` - -**React component JSDoc:** - -```tsx -// Track.tsx -/** The track area of the meter. Renders a `
` element. */ -export default function Track(...) { ... } - -// Fill.tsx -/** The filled portion of the meter. Renders a `
` element. */ -export default function Fill(...) { ... } - -// Indicator.tsx — no matching HTML element file -/** A visual indicator for the current value. Renders a `` element. */ -export default function Indicator(...) { ... } -``` - -### 1c. Single-overload util: `useVolume` - -```ts -/** - * Subscribe to the player's volume state. - */ -export function useVolume(options?: { muted?: boolean }): { - /** Current volume level (0–1). */ - volume: number; - /** Whether audio is muted. */ - muted: boolean; - /** Set the volume level. */ - setVolume: (level: number) => void; -}; -``` - -Exported from `packages/react/src/index.ts` → framework: `react`. - -### 1d. Multi-overload util: `createPlayer` - -```ts -/** - * Create a player instance with typed store, Provider component, Container, and hooks. - */ - -/** @label Video */ -export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; -/** @label Audio */ -export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; -``` - -Exported from `packages/react/src/index.ts` → framework: `react`. - -Return types differ (`VideoPlayerStore` vs `AudioPlayerStore`). Each overload uses the optional -`@label` JSDoc tag to give it a descriptive heading in the docs (see §2d). - -### 1e. Context: `playerContext` - -**Source** — `packages/html/src/player/context.ts`: - -```ts -/** @public The default player context instance for consuming the player store in controllers. */ -export const playerContext = createContext(PLAYER_CONTEXT_KEY); -``` - -Exported from `packages/html/src/index.ts` → framework: `html`. Discovered via `@public` JSDoc. -This is a non-function, non-controller export — a context value. - ---- - -## 2. Builder Pipeline - -### 2a. Component discovery - -The builder scans `packages/core/src/core/ui/` for directories. For each directory with -kebab-name `{name}`: - -| File | Location | Required | -|------|----------|----------| -| Core | `packages/core/src/core/ui/{name}/{name}-core.ts` | Yes | -| Data attrs | `packages/core/src/core/ui/{name}/{name}-data-attrs.ts` | No | -| CSS vars | `packages/core/src/core/ui/{name}/{name}-css-vars.ts` | No | -| HTML element | `packages/html/src/ui/{name}/{name}-element.ts` | No | -| React parts index | `packages/react/src/ui/{name}/index.parts.ts` | No | - -**Kebab-to-PascalCase conversion:** `toggle-button` → `ToggleButton`. For cases where standard -conversion fails (e.g., `pip-button` → `PiPButton`), a `NAME_OVERRIDES` map provides the correct -PascalCase name. - -**Multi-part detection:** A component is multi-part if and only if `index.parts.ts` exists. - -**Domain variant components:** Components like TimeSlider and VolumeSlider that share base -logic (e.g., from `slider/`) must still have their own directories under `core/ui/`. The -builder discovers components by directory — files nested inside a shared directory (like -`slider/time-slider-core.ts`) won't be found. - -### 2b. Component extraction - -#### Single-part components - -Extract from the three source files and merge into one reference object. - -| Source | Extracts | -|--------|----------| -| Core file | Props interface members (if present), State interface members (if present), `defaultProps` values (if present) | -| Data attrs file | Data attribute names, JSDoc descriptions, and inferred types (if file exists) | -| CSS vars file | CSS custom property names and JSDoc descriptions (if file exists) | -| HTML element file | `static tagName` value (if file exists) | - -**Naming conventions the builder depends on:** - -| Symbol | Expected name | -|--------|---------------| -| Props interface | `{PascalCase}Props` | -| State interface | `{PascalCase}State` | -| Core class | `{PascalCase}Core` | -| Data attrs export | `{PascalCase}DataAttrs` | -| CSS vars export | `{PascalCase}CSSVars` | -| HTML element class | `{PascalCase}Element` | - -**All symbols are optional.** Only the Core class is required. If a component has no Props -interface, `props` is `{}`. If it has no State interface, `state` is `{}`. If the Core class -has no `defaultProps`, no defaults are populated. If both Props and State are missing, the -component is skipped with a warning. - -**Extraction conventions** (apply to both Props and State members): - -- Members named `ref` are auto-skipped (React internal). -- Members with `@ignore` JSDoc tag are skipped. - -**Missing-symbol behavior:** - -| Missing symbol | Behavior | Output | -|---|---|---| -| Props interface only | Silent | `props: {}` | -| State interface only | Silent | `state: {}` | -| Both Props and State | Warn, skip component | Component omitted | -| `defaultProps` static | Silent | Props have no `default` field | -| Data-attrs file/export | Silent | `dataAttributes: {}` | -| CSS-vars file/export | Silent | `cssCustomProperties: {}` | -| JSDoc on a data attribute | Silent | `description: ""` (empty string) | -| HTML element file | Silent | No `platforms.html` section | - -This applies uniformly to single-part and multi-part component extraction. - -**Data attribute type inference:** - -The builder infers data attribute types from the `StateAttrMap` constraint on the -data-attrs export. Every data-attrs file follows the pattern: - -```ts -export const FooDataAttrs = { ... } as const satisfies StateAttrMap; -``` - -The builder uses the TypeScript type checker to: - -1. Extract the `State` type argument from the `satisfies StateAttrMap` expression -2. Resolve each property key's type on the state interface -3. Format the resolved type as a display string - -| State property type | Inferred display type | Example | -|---|---|---| -| `boolean` | _(omitted)_ | `data-paused` — presence/absence, no type shown | -| String literal union | The union | `'off' \| 'low' \| 'medium' \| 'high'` | -| Named type alias (resolves to union) | Expanded literals | `VolumeLevel` → `'off' \| 'low' \| 'medium' \| 'high'` | -| `string` | `string` | Rare — freeform string value | -| `number` | `number` | Rare — numeric attribute | - -Boolean attributes are presence/absence by convention (the runtime uses `setAttribute`/ -`removeAttribute`), so their type is omitted from the output to avoid noise. Non-boolean -types are included to show the enumerated values the attribute can take. - -**Fallback:** If the `satisfies` expression is absent or the type checker cannot resolve the -state type, the builder falls back to the existing `@type` JSDoc tag extraction. Manual -`@type` tags are no longer needed when the `satisfies StateAttrMap` pattern is used. - -#### Multi-part components - -The builder discovers parts from `index.parts.ts` and matches them to HTML element files. - -**Re-exported parts:** When `index.parts.ts` re-exports parts from another component (source -path doesn't start with `./`), the builder resolves the re-export back to its origin. It -parses the origin's `index.parts.ts`, matches each re-exported name to the original local -export, then derives the kebab segment and HTML element file from the **origin component** — -not the current one. Re-exported parts are never primary. For example, TimeSlider re-exports -Buffer, Fill, Thumb, Track, and Value from `../slider/index.parts`; each resolves to the -Slider component's HTML element files (`slider-buffer-element.ts`, etc.). - -**Single-part fallback:** When all exports are local and filtering leaves only one part, the -component uses single-part mode. The remaining part (typically Root) becomes the top-level -component — its props/state/data-attrs/CSS-vars are promoted to the component level, not -nested under `parts`. Components with re-exported parts (like TimeSlider and VolumeSlider) -always produce multi-part output since the re-exports are resolved rather than filtered. - -**Primary vs. sub-part convention:** - -Every multi-part component has one **primary part** and one or more **sub-parts**. - -**Primary part:** The part whose React source file instantiates the component's own Core class -(matches `new {ComponentName}Core\b`). This captures the architectural relationship — the primary -part owns the Core — and is immune to import ordering and framework-divergent element structures. - -Sub-part element files use the naming convention `{component}-{part}-element.ts` (e.g., -`time-group-element.ts`) for HTML tag resolution. - -**Part-to-element matching:** - -For each local named export in `index.parts.ts`: -1. Derive kebab segment from the export's source path (e.g., `./time-value` → `value`) -2. Look for `{component}-{part}-element.ts` in the HTML directory -3. If found → sub-part (gets its own tag name) -4. If not found AND `{component}-element.ts` exists → check via Core-instantiation for primary - -For cases where the element file doesn't follow standard naming (e.g., Tooltip's Provider maps -to `tooltip-group-element.ts`, not `tooltip-provider-element.ts`), a `PART_ELEMENT_OVERRIDES` -map provides the correct filename. - -For re-exported parts: use the origin component's kebab and HTML directory for element file -lookup. The element class name is derived from the filename convention (`kebabToPascal` of the -basename, e.g., `slider-buffer-element.ts` → `SliderBufferElement`), not from the current -component's PascalCase name. This same convention-based derivation is also used for local -non-primary parts. - -**What the primary part gets:** The shared core Props, State, data attributes, CSS custom -properties, and the root element's tag name. - -**What sub-parts get:** Their own tag name, a description (from React JSDoc), shared data -attributes from the component's `*-data-attrs.ts` file (when the sub-part's React source -references `stateAttrMap`), and custom props from the `{LocalName}Props` interface — own -members plus members inherited from project-local interfaces, excluding `children` and -React DOM attributes. State and CSS custom properties remain empty. - -For re-exported sub-parts, data attributes come from the **origin** component's data-attrs file -(e.g., TimeSlider.Fill uses Slider's data-attrs, not TimeSlider's), because the builder can't -resolve spread entries and the origin file has the complete set that sub-parts inherit. - -**What the top-level component gets:** Empty props, state, dataAttributes, cssCustomProperties, -and empty platforms. All meaningful data lives in the `parts` record. - -**Framework-divergent parts:** Parts discovered from `index.parts.ts` always get -`platforms.react`. Parts with a matching HTML element file also get `platforms.html`. The -renderer filters parts by framework — only parts with the current framework's platform -entry are shown. This handles cases like Popover where Arrow, Popup, and Trigger are -React-only compound parts with no HTML element counterparts. - -> **Known limitation:** Our architecture has a single Props/State interface per component at the -> core level, so only the primary part can own core-level props and state. Sub-parts can declare -> custom React-specific props (e.g., `SliderValueProps.type`), which the builder extracts from -> the React source. In Base UI, each part has its own props independently. - -### 2c. Util discovery - -The builder scans a fixed set of entry points: - -| Entry point | Framework | -|-------------|-----------| -| `packages/react/src/index.ts` | `react` | -| `packages/store/src/react/hooks/index.ts` | `react` | -| `packages/html/src/index.ts` | `html` | -| `packages/store/src/html/controllers/index.ts` | `html` | -| `packages/core/src/dom/store/selectors.ts` | _(none — framework-agnostic)_ | -| `packages/store/src/core/selector.ts` | _(none — framework-agnostic)_ | - -**Re-export scoping:** For each entry point, the builder resolves local re-exports (relative -import paths starting with `./`) and scans the resolved modules. External package re-exports -(e.g., `export * from '@videojs/core/dom'`) are not followed — cross-package exports are -discovered via their own dedicated entry points instead. This prevents duplication and -incorrect framework tagging. - -**Inclusion rules** — An export is included if it matches any naming convention OR has -`@public` JSDoc: - -| Pattern | Rule | Category | -|---------|------|----------| -| `use*` (capital 4th char) + function | Hook | `react` | -| `select*` (capital 7th char) + function | Selector | varies | -| `*Controller` (non-function) | Controller | `html` | -| `create*` + function | Factory | varies | -| `@public` JSDoc tag | Explicit inclusion | varies | - -**Note:** The raw TS AST fallback (used when TAE fails on a module) relaxes the function -check for `select*` — it includes by name alone, since type information isn't reliably -available in that path. - -**Display name:** The export name is used as-is, except `create*Mixin` factories strip the -`create` prefix (e.g., `createProviderMixin` → `ProviderMixin`). - -**Leaf module scanning:** When an entry point has no relative re-exports (i.e., `resolveLocalModules` -returns an empty list), the file itself is scanned directly. - -**Slug generation:** kebab-case of the display name. If two frameworks produce the same slug, -React keeps the bare slug and HTML gets prefixed with `html-` (e.g., `create-player` vs -`html-create-player`). React entries must come before HTML entries in `UTIL_ENTRY_POINTS` -to ensure this ordering. - -### 2d. Util extraction - -**Functions (hooks, factories):** Extract call signatures via TypeScript API. Each signature -becomes an overload with parameters and return value. - -**Controllers:** Extract constructor signatures. Each constructor becomes an overload. The -return value type uses `ClassName` when the class has type parameters, or just -`ClassName` when it has none. The return value describes the controller's public interface -(e.g., `value` property). - -**Contexts (non-function, non-controller):** These are non-function, non-controller exports -included via `@public` JSDoc. They produce a single overload with empty parameters and a -`returnValue` containing the type string. - -**All overloads are preserved.** When a function or constructor has multiple overload -signatures, each becomes a separate entry in the `overloads` array. This applies uniformly -to function call signatures and class constructor signatures. - -**Overload labels (`@label`):** If an overload signature has a `@label` JSDoc tag, the -builder extracts its value as the overload's `label`. This is optional — overloads without -`@label` get no label and fall back to "Overload {N}" headings in the rendered output. - -**Degraded type repair:** After TAE extraction, the builder cross-references param and return -types against the raw TS AST. When a formatted type contains `any` (word boundary) or `__type` -— indicators that TAE couldn't resolve the type — the builder replaces it with the source -annotation text from the raw AST declaration. This handles types TAE can't resolve (e.g., -cross-package interfaces like `Media`, ESSymbol-based types like `PlayerContext`). - -### 2e. Type formatting - -Every type string goes through two stages: - -**Stage 1: Format** — Convert the TypeScript type node to a human-readable string. - -| TS construct | Formatted as | Example | -|--------------|-------------|---------| -| Primitives | Lowercase name | `boolean`, `string`, `number` | -| String literals | Single-quoted | `'current'` | -| Unions | Pipe-separated | `'current' \| 'duration' \| 'remaining'` | -| Objects | Inline notation | `{ volume: number; muted: boolean }` | -| Arrays | Bracket suffix | `string[]` | -| Functions | Arrow notation | `((level: number) => void)` | -| Tuples | Bracket notation | `[string, number]` | -| Empty object type | `object` | `object` (not `{}`) | -| Type parameter (small constraint) | Constraint expansion | `string` for `T extends string` | -| Type parameter (large constraint, >5 union members) | Parameter name | `TagName` for `TagName extends keyof JSX.IntrinsicElements` | - -**Stage 2: Abbreviate** — Shorten complex types for display. The abbreviated form goes in `type`; -the full form goes in `detailedType` (shown in the disclosure panel). - -Abbreviation checks rules in the following order. The first match wins: - -| # | Rule | Condition | Abbreviated to | `detailedType` | -|---|------|-----------|---------------|----------------| -| 1 | Pure function | Type contains `=>`, and either no `\|` or is a single function type (paren-depth matching detects `(params) => return \| union` vs `(fn) \| undefined`) | `function` | Full signature | -| 2 | `on*` / `get*` callback | Name matches, type contains `=>` | `function` | Full signature | -| 3 | `className` | Name matches, type contains `=>` | `string \| function` | Full union | -| 4 | `style` | Name matches, type contains `=>` | `CSSProperties \| function` | Full union | -| 5 | `render` | Name matches, type contains `=>` | `ReactElement \| function` | Full union | -| 6 | Simple primitive | `boolean`, `string`, `number` | _(no abbreviation)_ | _(omitted)_ | -| 7 | Object literal (> 40 chars) | Starts with `{ `, length > 40 | `object` | Full inline notation | -| 8 | Short union (< 3 members AND < 40 chars, no fn) | — | _(no abbreviation)_ | _(omitted)_ | -| 9 | Union with a function member | Type contains `=>` and `\|` | Non-fn members `\| function` | Full union | -| 10 | Long type (> 40 chars) | — | _(as-is, truncated)_ | Full type | -| 11 | Fallback | Everything else | _(no abbreviation)_ | _(omitted)_ | - -`detailedType` is only present when abbreviation occurred. If the type is short enough to -display as-is, `detailedType` is omitted. - -**Union member ordering:** `null`, `undefined`, and `any` sort to the end. - -**Default values:** Stored as string representations of the literal value. Examples: `'false'`, -`"''"` (empty string), `'0'`, `'null'`, `'[]'`. - ---- - -## 3. JSON Schemas - -These are the intermediate format between builder and front-end. Defined as Zod schemas in -`site/src/types/`. - -### 3a. Component reference - -Output: `site/src/content/generated-component-reference/{kebab-name}.json` - -``` -ComponentReference -├── name: string — PascalCase (e.g., "ToggleButton") -├── description?: string — JSDoc description of the component -├── props: Record — Empty {} for multi-part top-level -├── state: Record — Empty {} for multi-part top-level -├── dataAttributes: Record -├── cssCustomProperties: Record -├── platforms -│ ├── html? -│ │ └── tagName: string — e.g., "media-toggle-button" -│ └── react? — Present for React-discovered parts (object, no fields) -└── parts?: Record — Only for multi-part components - └── [partId] - ├── name: string — PascalCase part name (e.g., "Track") - ├── description?: string — From React component JSDoc - ├── props: Record - ├── state: Record - ├── dataAttributes: Record - ├── cssCustomProperties: Record - └── platforms - ├── html? - │ └── tagName: string - └── react? — Always present (parts come from index.parts.ts) -``` - -**CSSVarDef:** - -``` -└── description: string — JSDoc description of the CSS custom property -``` - -**PropDef:** - -``` -├── type: string — Abbreviated type for display -├── detailedType?: string — Full type (only if abbreviated) -├── description?: string — JSDoc description -├── default?: string — String representation of default value -└── required?: boolean — Only present when true -``` - -**StateDef:** - -``` -├── type: string -├── detailedType?: string -└── description?: string -``` - -**DataAttrDef:** - -``` -├── description: string -├── type?: string — Inferred from state type. Omitted for boolean (presence/absence). -│ Shows enumerated values for non-boolean types (e.g., "'empty' | 'partial' | 'full'"). -│ Abbreviated via the same rules as PropDef/StateDef (§2e Stage 2). -└── detailedType?: string — Full type when abbreviation occurred. Same semantics as PropDef.detailedType. -``` - -### 3b. Util reference - -Output: `site/src/content/generated-util-reference/{slug}.json` - -``` -UtilReference -├── name: string — Display name (e.g., "useVolume") -├── description?: string — JSDoc description -├── frameworks?: string[] — e.g., ["react"] or ["html"]; omitted if agnostic -└── overloads: UtilOverload[] — At least one - └── [n] - ├── label?: string — From @label JSDoc tag (e.g., "Video") - ├── description?: string — Overload-specific description - ├── parameters: Record - └── returnValue: ReturnValue -``` - -**ParamDef** — Same shape as PropDef: - -``` -├── type: string -├── detailedType?: string -├── description?: string -├── default?: string — Default parameter value (e.g., "0", "'muted'") -└── required?: boolean -``` - -**ReturnValue:** - -``` -├── type: string — e.g., "object", "void", "boolean" -├── detailedType?: string -├── description?: string — Used when return is a simple type (no fields) -└── fields?: Record — Used when return is an object - └── [fieldName] - ├── type: string - ├── detailedType?: string - └── description?: string -``` - -**Cleanup rules:** Optional fields are omitted from JSON when undefined. `required: false` is -omitted (absence means not required). This keeps JSON files small. - ---- - -## 4. Expected JSON Output for Sample Inputs - -### 4a. ToggleButton (single-part component) - -```json -{ - "name": "ToggleButton", - "props": { - "disabled": { - "type": "boolean", - "description": "Whether the button is disabled.", - "default": "false" - }, - "label": { - "type": "string | function", - "detailedType": "string | ((state: ToggleButtonState) => string)", - "description": "Custom label for the button.", - "default": "''" - } - }, - "state": { - "pressed": { - "type": "boolean", - "description": "Whether the toggle is pressed." - }, - "disabled": { - "type": "boolean", - "description": "Whether the button is disabled." - } - }, - "dataAttributes": { - "data-pressed": { - "description": "Present when the toggle is pressed." - }, - "data-disabled": { - "description": "Present when the button is disabled." - } - }, - "cssCustomProperties": {}, - "platforms": { - "html": { - "tagName": "media-toggle-button" - } - } -} -``` - -### 4b. Meter (multi-part component) - -```json -{ - "name": "Meter", - "props": {}, - "state": {}, - "dataAttributes": {}, - "cssCustomProperties": {}, - "platforms": {}, - "parts": { - "indicator": { - "name": "Indicator", - "description": "A visual indicator for the current value. Renders a `` element.", - "props": { - "min": { - "type": "number", - "description": "Minimum value.", - "default": "0" - }, - "max": { - "type": "number", - "description": "Maximum value.", - "default": "100" - }, - "label": { - "type": "string | function", - "detailedType": "string | ((state: MeterState) => string)", - "description": "Custom label for accessibility.", - "default": "''" - } - }, - "state": { - "percentage": { - "type": "number", - "description": "Current value as a percentage (0–1)." - } - }, - "dataAttributes": { - "data-percentage": { - "description": "Current percentage as a string.", - "type": "number" - }, - "data-fill-state": { - "description": "The fill level.", - "type": "'empty' | 'partial' | 'full'" - } - }, - "cssCustomProperties": {}, - "platforms": { - "html": { - "tagName": "media-meter" - }, - "react": {} - } - }, - "track": { - "name": "Track", - "description": "The track area of the meter. Renders a `
` element.", - "props": {}, - "state": {}, - "dataAttributes": {}, - "cssCustomProperties": {}, - "platforms": { - "html": { - "tagName": "media-meter-track" - }, - "react": {} - } - }, - "fill": { - "name": "Fill", - "description": "The filled portion of the meter. Renders a `
` element.", - "props": {}, - "state": {}, - "dataAttributes": {}, - "cssCustomProperties": {}, - "platforms": { - "html": { - "tagName": "media-meter-fill" - }, - "react": {} - } - } - } -} -``` - -### 4c. useVolume (single-overload hook) - -```json -{ - "name": "useVolume", - "description": "Subscribe to the player's volume state.", - "overloads": [ - { - "parameters": { - "options": { - "type": "object", - "detailedType": "{ muted?: boolean }", - "default": "{}" - } - }, - "returnValue": { - "type": "object", - "detailedType": "{ volume: number; muted: boolean; setVolume: (level: number) => void }", - "fields": { - "volume": { - "type": "number", - "description": "Current volume level (0–1)." - }, - "muted": { - "type": "boolean", - "description": "Whether audio is muted." - }, - "setVolume": { - "type": "function", - "detailedType": "((level: number) => void)", - "description": "Set the volume level." - } - } - } - } - ], - "frameworks": ["react"] -} -``` - -### 4d. createPlayer (multi-overload) - -Both overloads are preserved. Return types differ (`VideoPlayerStore` vs `AudioPlayerStore`). - -```json -{ - "name": "createPlayer", - "description": "Create a player instance with typed store, Provider component, Container, and hooks.", - "overloads": [ - { - "label": "Video", - "description": "Create a player instance with typed store, Provider component, Container, and hooks.", - "parameters": { - "config": { - "type": "CreatePlayerConfig", - "required": true - } - }, - "returnValue": { - "type": "CreatePlayerResult", - "fields": { - "Provider": { - "type": "React.FC" - }, - "Container": { - "type": "function", - "detailedType": "React.ForwardRefExoticComponent>" - }, - "usePlayer": { - "type": "UsePlayerHook" - } - } - } - }, - { - "label": "Audio", - "parameters": { - "config": { - "type": "CreatePlayerConfig", - "required": true - } - }, - "returnValue": { - "type": "CreatePlayerResult", - "fields": { - "Provider": { - "type": "React.FC" - }, - "Container": { - "type": "function", - "detailedType": "React.ForwardRefExoticComponent>" - }, - "usePlayer": { - "type": "UsePlayerHook" - } - } - } - } - ], - "frameworks": ["react"] -} -``` - -### 4e. playerContext (context) - -```json -{ - "name": "playerContext", - "description": "The default player context instance for consuming the player store in controllers.", - "overloads": [ - { - "parameters": {}, - "returnValue": { - "type": "Context" - } - } - ], - "frameworks": ["html"] -} -``` - ---- - -## 5. Reference Model Layer - -The reference model transforms flat JSON into a structured heading/section model. This model is -consumed by two places: - -1. **Astro components** — to render headings and tables -2. **remarkConditionalHeadings** — to inject heading entries into the table of contents - -Both consume the same model, which prevents anchor drift (TOC links matching rendered heading IDs). - -### 5a. Component reference model - -**Single-part** heading structure: - -``` -H2 "API Reference" id="api-reference" - ├─ H3 "Props" id="props" (if props non-empty) - ├─ H3 "State" id="state" (if state non-empty) - ├─ H3 "Data attributes" id="data-attributes" (if dataAttributes non-empty) - └─ H3 "CSS custom properties" id="css-custom-properties" (if cssCustomProperties non-empty) -``` - -**Multi-part** heading structure: - -``` -H2 "API Reference" id="api-reference" - ├─ H3 "{Part.name}" (React) id="{partId}" - │ or "{part.tagName}" (HTML) - │ ├─ H4 "Props" id="{partId}-props" (if props non-empty) - │ ├─ H4 "State" id="{partId}-state" (if state non-empty) - │ ├─ H4 "Data attributes" id="{partId}-data-attributes" (if dataAttributes non-empty) - │ └─ H4 "CSS custom properties" id="{partId}-css-custom-properties" (if cssCustomProperties non-empty) - ├─ H3 next part... - └─ ... -``` - -Multi-part H3 headings are framework-aware: React sees the PascalCase part name (e.g., "Track"), -HTML sees the tag name (e.g., "media-meter-track"). The TOC emits both variants with -`frameworks` metadata so the correct one displays per framework. - -**Framework filtering:** The TOC and rendered output only emit headings for parts the current -framework supports (derived from `platforms` keys). React-only parts (those with -`platforms.react` but no `platforms.html`) are hidden when viewing HTML docs. - -**Part ordering:** By default, parts render in JSON key order (primary first, then discovery -order). The `` component accepts an optional `partOrder` prop — an array -of part IDs (e.g., `["provider", "root", "trigger", "popup", "arrow"]`) that overrides -the default order. This lets MDX authors match the anatomy. The reordering is applied inside -`createComponentReferenceModel`, so both the rendered output and the TOC consume the same -order. Parts not listed in `partOrder` appear after the listed ones in their original order. - -### 5b. Util reference model - -**Single-overload** heading structure: - -``` -H2 "API Reference" id="api-reference" - ├─ H3 "Parameters" id="parameters" (if params non-empty) - └─ H3 "Return Value" id="return-value" -``` - -**Multi-overload** heading structure: - -``` -H2 "API Reference" id="api-reference" - ├─ H3 "{label}" or "Overload 1" id="{slug}" or "overload-1" - │ ├─ H4 "Parameters" id="{slug}-parameters" (if params non-empty) - │ └─ H4 "Return Value" id="{slug}-return-value" - ├─ H3 "{label}" or "Overload 2" id="{slug}" or "overload-2" - │ ├─ H4 "Parameters" id="{slug}-parameters" - │ └─ H4 "Return Value" id="{slug}-return-value" - └─ ... -``` - -### 5c. TOC integration - -The `remarkConditionalHeadings` remark plugin detects `` and -`` components in MDX, loads the generated JSON, builds the reference model, and -injects synthetic heading entries into `frontmatter.conditionalHeadings`. These entries carry the -same `id`/`slug` values as the rendered headings, so TOC links always match. For -``, the plugin also reads the optional `partOrder` attribute and forwards -it to `createComponentReferenceModel`, ensuring the TOC reflects the same part ordering as -the rendered page. - ---- - -## 6. Rendered Output - -### 6a. Props table (components) - -Rendered by `ApiPropsTable` → `PropRow` → `DetailRow`. - -**Columns:** - -| Prop | Type | Default | | -|------|------|---------|-| - -- **Prop** — Property name in monospace. Required props have an orange `*` suffix. -- **Type** — Abbreviated type in monospace. -- **Default** — Default value in monospace, or `—` if none. -- **(toggle)** — Disclosure triangle. Only present if the row has a description or detailedType. - -**Disclosure panel** (when expanded): - -Contains a description list (`
`): -- **Description** — Markdown-rendered description. Only shown if `description` is present. -- **Type** — Full `detailedType` in monospace. Only shown if `detailedType` is present - (i.e., the type was abbreviated). - -**Sort order:** Required props first, then alphabetical. (Defined at the builder level via -`sortProps`.) - -**ToggleButton example:** - -| Prop | Type | Default | | -|------|------|---------|-| -| `disabled` | `boolean` | `false` | | -| `label` | `string \| function` | `''` | ▸ | - -Expanding `label`: -> **Description:** Custom label for the button. -> **Type:** `string | ((state: ToggleButtonState) => string)` - -### 6b. State table (components) - -Rendered by `ApiStateTable` → `StateRow` → `DetailRow`. - -**Columns:** - -| Property | Type | | -|----------|------|-| - -- **Property** — State property name in monospace. -- **Type** — Type in monospace (abbreviated if needed). -- **(toggle)** — Disclosure triangle, same rules as props. - -**Disclosure panel:** Same as props (description + detailedType). - -**State preamble** (framework-specific, shown above the table): - -- **React:** "State is accessible via the `render`, `className`, and `style` props." -- **HTML:** "State is reflected as data attributes for CSS styling." - -**ToggleButton example:** - -| Property | Type | | -|----------|------|-| -| `pressed` | `boolean` | ▸ | -| `disabled` | `boolean` | ▸ | - -Expanding `pressed`: -> **Description:** Whether the toggle is pressed. - -### 6c. Data attributes table (components) - -Rendered by `ApiDataAttrsTable` → `DataAttrRow` → `DetailRow`. Uses the same disclosure -pattern as props and state tables. - -**Columns:** - -| Attribute | Type | | -|-----------|------|-| - -- **Attribute** — Data attribute name in monospace (e.g., `data-pressed`). -- **Type** — Inferred from `StateAttrMap`. Shows enumerated values in monospace for - non-boolean types. Empty cell for boolean (present/absent) attributes. Abbreviated via §2e - Stage 2 when the type is long. -- **(toggle)** — Disclosure triangle. Only present if the row has a description or detailedType. - -**Disclosure panel** (when expanded): - -Contains a description list (`
`): -- **Description** — Markdown-rendered description. Only shown if `description` is present. -- **Type** — Full `detailedType` in monospace. Only shown if `detailedType` is present - (i.e., the type was abbreviated). - -**ToggleButton example** (boolean attributes — type column empty): - -| Attribute | Type | | -|-----------|------|-| -| `data-pressed` | | ▸ | -| `data-disabled` | | ▸ | - -Expanding `data-pressed`: -> **Description:** Present when the toggle is pressed. - -**Meter example** (mix of boolean and non-boolean — types inferred from state): - -| Attribute | Type | | -|-----------|------|-| -| `data-percentage` | `number` | ▸ | -| `data-fill-state` | `'empty' \| 'partial' \| 'full'` | ▸ | - -Expanding `data-percentage`: -> **Description:** Current percentage as a string. - -### 6d. Parameters table (utils) - -Rendered by `UtilParamsTable` → `PropRow` → `DetailRow`. Reuses the same row component as -component props. - -**Columns:** - -| Parameter | Type | Default | | -|-----------|------|---------|-| - -- **Parameter** — Parameter name in monospace. Required params have an orange `*` suffix. -- **Type** — Abbreviated type in monospace. -- **Default** — Default value in monospace, or `—` if none. -- **(toggle)** — Disclosure triangle. - -**Disclosure panel:** Same as props (description + detailedType). - -**useVolume example:** - -| Parameter | Type | Default | | -|-----------|------|---------|-| -| `options` | `object` | `{}` | ▸ | - -Expanding `options`: -> **Type:** `{ muted?: boolean }` - -### 6e. Return value (utils) - -Rendered by `UtilReturnTable`. Two rendering modes: - -**Mode 1: Object return with fields** — Renders a table using `StateRow`: - -| Property | Type | | -|----------|------|-| - -Same columns and disclosure behavior as the state table. - -**useVolume example:** - -| Property | Type | | -|----------|------|-| -| `volume` | `number` | ▸ | -| `muted` | `boolean` | ▸ | -| `setVolume` | `function` | ▸ | - -Expanding `setVolume`: -> **Description:** Set the volume level. -> **Type:** `((level: number) => void)` - -**Mode 2: Simple return with detail** — When no fields but `detailedType` or `description` is -present, renders a single-row disclosure table using `DetailRow`: - -| Type | | -|------|-| -| `function` | ▸ | - -Expanding the row reveals the detailed type and/or description, same as prop/state disclosure. - -**Mode 3: Simple return (no detail)** — When no fields, no `detailedType`, and no `description`, -renders inline: - -> `ReturnType` — Description text here. - -### 6f. CSS custom properties table (components) - -Rendered by `ApiCSSVarsTable` → `CSSVarRow` → `DetailRow`. Uses the same disclosure -pattern as data attributes tables but with no type column. - -**Columns:** - -| Variable | | -|----------|-| - -- **Variable** — CSS custom property name in monospace (e.g., `--media-slider-fill`). -- **(toggle)** — Disclosure triangle. Present if the row has a description. - -**Disclosure panel** (when expanded): - -Contains a description list (`
`): -- **Description** — Markdown-rendered description. Only shown if `description` is present. - -### 6g. Multi-part component rendering - -For multi-part components, the top-level has no tables (all empty). Each part renders as: - -``` -H3: Part name (framework-specific label) - Part description (if present) - H4: Props (if non-empty) → Props table - H4: State (if non-empty) → State table - H4: Data attributes (if non-empty) → Data attributes table - H4: CSS custom properties (if non-empty) → CSS custom properties table -``` - -**Part ordering:** Parts render in JSON key order by default (primary part first). To match -the component anatomy, pass `partOrder` on the `` component: - -```mdx - -``` - -**State section preamble** (framework-specific): - -- **React:** "State is accessible via the `render`, `className`, and `style` props." -- **HTML:** "State is reflected as data attributes for CSS styling." - -### 6h. Multi-overload util rendering - -Each overload renders as: - -``` -H3: "{label}" or "Overload {N}" - Overload description (if present) - H4: Parameters (if non-empty) → Parameters table - H4: Return Value → Return value table or inline -``` - -**Heading text:** If the overload has a `label` (from `@label` JSDoc), use it as the H3 -heading text. Otherwise fall back to "Overload {N}". - -**Heading ID:** Labeled overloads use the kebab-case slug of the label (e.g., `"Video"` → -`id="video"`). Unlabeled overloads use `id="overload-{n}"`. - -### 6i. Disclosure panel interaction - -The `DetailRow` component implements an expandable disclosure pattern: - -- **Toggle button** renders a disclosure triangle (`▸`) that rotates 90° when expanded. -- Clicking anywhere on the summary row toggles the detail panel (unless the click target is - a link or button). -- Uses `aria-expanded` and `aria-controls` for accessibility. -- The detail panel is initially `hidden` and toggled via JavaScript. - ---- - -## 7. Full Rendered Example: ToggleButton - -Given the ToggleButton source code from Section 1a, the user sees: - -``` -## API Reference - -### Props - -| Prop | Type | Default | | -|------------|---------------------|---------|-| -| disabled | boolean | false | | -| label | string | function | '' | ▸ | - - └─ [expanded] Description: Custom label for the button. - Type: string | ((state: ToggleButtonState) => string) - -### State - -State is accessible via the render, className, and style props. ← React -State is reflected as data attributes for CSS styling. ← HTML - -| Property | Type | | -|------------|---------|--| -| pressed | boolean | ▸ | -| disabled | boolean | ▸ | - - └─ [expanded] Description: Whether the toggle is pressed. - └─ [expanded] Description: Whether the button is disabled. - -### Data attributes - -| Attribute | Type | | -|-----------------|------|-| -| data-pressed | | ▸ | -| data-disabled | | ▸ | - - └─ [expanded] Description: Present when the toggle is pressed. - └─ [expanded] Description: Present when the button is disabled. -``` - -## 8. Full Rendered Example: Meter (multi-part) - -Given the Meter source code from Section 1b, the user sees: - -``` -## API Reference - -### Indicator ← React framework -### media-meter ← HTML framework - -A visual indicator for the current value. Renders a `` element. - -#### Props - -| Prop | Type | Default | | -|-------|---------------------|---------|-| -| label | string | function | '' | ▸ | -| max | number | 100 | | -| min | number | 0 | | - -#### State - -State is accessible via the render, className, and style props. ← React -State is reflected as data attributes for CSS styling. ← HTML - -| Property | Type | | -|------------|--------|-| -| percentage | number | ▸ | - -#### Data attributes - -| Attribute | Type | | -|------------------|-------------------------------|-| -| data-percentage | number | ▸ | -| data-fill-state | 'empty' | 'partial' | 'full' | ▸ | - - └─ [expanded] Description: Current percentage as a string. - └─ [expanded] Description: The fill level. - - -### Track ← React framework -### media-meter-track ← HTML framework - -The track area of the meter. Renders a `
` element. - -(no Props, State, or Data attributes sections — all empty) - - -### Fill ← React framework -### media-meter-fill ← HTML framework - -The filled portion of the meter. Renders a `
` element. - -(no Props, State, or Data attributes sections — all empty) -``` - -## 9. Full Rendered Example: useVolume (single-overload) - -``` -## API Reference - -### Parameters - -| Parameter | Type | Default | | -|-----------|--------|---------|-| -| options | object | {} | ▸ | - - └─ [expanded] Type: { muted?: boolean } - -### Return Value - -| Property | Type | | -|-----------|----------|-| -| volume | number | ▸ | -| muted | boolean | ▸ | -| setVolume | function | ▸ | - - └─ [expanded] Description: Set the volume level. - Type: ((level: number) => void) -``` - -## 10. Full Rendered Example: createPlayer (multi-overload, labeled) - -``` -## API Reference - -### Video ← from @label "Video" - -Create a player instance with typed store, Provider component, Container, and hooks. - -#### Parameters - -| Parameter | Type | Default | | -|-----------|-----------------------------------|---------|-| -| config* | CreatePlayerConfig | — | | - -#### Return Value - -| Property | Type | | -|-----------|---------------------------------|-| -| Provider | React.FC | | -| Container | function | ▸ | -| usePlayer | UsePlayerHook | | - - └─ [expanded] Type: React.ForwardRefExoticComponent> - -### Audio ← from @label "Audio" - -#### Parameters - -| Parameter | Type | Default | | -|-----------|-----------------------------------|---------|-| -| config* | CreatePlayerConfig | — | | - -#### Return Value - -| Property | Type | | -|-----------|---------------------------------|-| -| Provider | React.FC | | -| Container | function | ▸ | -| usePlayer | UsePlayerHook | | - - └─ [expanded] Type: React.ForwardRefExoticComponent> -``` diff --git a/site/CLAUDE.md b/site/CLAUDE.md index 344de9cc..2d0f3868 100644 --- a/site/CLAUDE.md +++ b/site/CLAUDE.md @@ -560,9 +560,11 @@ vi.mock('@/types/docs', async () => { ## API Reference Generation -> **Source of truth:** [`internal/design/site/api-docs-builder.md`](../internal/design/site/api-docs-builder.md) +> **Source of truth:** [`scripts/api-docs-builder/src/tests/e2e.test.ts`](scripts/api-docs-builder/src/tests/e2e.test.ts) > -> The design spec is the ground-truth for the entire pipeline — discovery, extraction, JSON schemas, reference model, and rendered output. **Any changes to the api-docs-builder must be reflected in the spec.** When implementation diverges from the spec, the spec wins. +> The E2E test suite is the living specification for the builder pipeline. It exercises every input +> pattern against a mock monorepo and asserts the expected JSON output. Read the test to understand +> how the builder works. **Any changes to the api-docs-builder must keep the E2E tests passing.** The builder (`scripts/api-docs-builder/`) extracts type information from TypeScript sources and generates JSON for two kinds of reference: diff --git a/site/scripts/api-docs-builder/README.md b/site/scripts/api-docs-builder/README.md index 4e38388b..127ff39f 100644 --- a/site/scripts/api-docs-builder/README.md +++ b/site/scripts/api-docs-builder/README.md @@ -2,9 +2,9 @@ Generates API reference JSON from TypeScript sources for Video.js 10 components and utilities. -> **Spec:** [`internal/design/site/api-docs-builder.md`](../../../internal/design/site/api-docs-builder.md) -> is the ground-truth for discovery conventions, extraction rules, JSON schemas, the reference model, -> and rendered output. When implementation diverges from the spec, the spec wins. +> **Spec:** The E2E test suite at [`src/tests/e2e.test.ts`](src/tests/e2e.test.ts) is the living +> specification for the builder pipeline. It exercises every input pattern against a mock monorepo +> and asserts the expected JSON output. Read the test to understand how the builder works. ## Architecture @@ -51,25 +51,21 @@ import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; site/scripts/api-docs-builder/ ├── README.md # This file └── src/ - ├── index.ts # Main entry point, orchestrates handlers + ├── index.ts # CLI entry point + ├── pipeline.ts # Testable pipeline functions (discovery, extraction, building) ├── types.ts # TypeScript interfaces ├── formatter.ts # Type formatting utilities ├── utils.ts # Utility functions (naming helpers) ├── core-handler.ts # Extracts Props/State from core packages ├── data-attrs-handler.ts # Extracts data attributes + ├── css-vars-handler.ts # Extracts CSS custom properties ├── html-handler.ts # Extracts Lit element info ├── parts-handler.ts # Parses index.parts.ts for multi-part components ├── util-handler.ts # Extracts util params/return from store/react packages └── tests/ - ├── test-utils.ts - ├── fixtures/ # Monorepo fixtures for integration tests - ├── core-handler.test.ts - ├── data-attrs-handler.test.ts - ├── formatter.test.ts - ├── html-handler.test.ts - ├── parts-handler.test.ts - ├── util-handler.test.ts - └── utils.test.ts + ├── e2e.test.ts # ★ E2E spec — the living specification + ├── formatter.test.ts # Type abbreviation/formatting edge cases + └── fixtures/ # Mock monorepo for E2E tests site/src/ ├── content/generated-component-reference/ # Generated component JSON (gitignored) diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts index b45696d3..757bc085 100644 --- a/site/scripts/api-docs-builder/src/index.ts +++ b/site/scripts/api-docs-builder/src/index.ts @@ -1,99 +1,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as ts from 'typescript'; -import * as tae from 'typescript-api-extractor'; -import { extractCore } from './core-handler.js'; -import { extractCSSVars } from './css-vars-handler.js'; -import { extractDataAttrs } from './data-attrs-handler.js'; -import { abbreviateType } from './formatter.js'; -import { extractHtml } from './html-handler.js'; -import { extractPartDescription, extractParts, extractSubPartProps } from './parts-handler.js'; -import { - type ComponentReference, - ComponentReferenceSchema, - type ComponentSource, - type CoreExtraction, - type CSSVarDef, - type CSSVarsExtraction, - type DataAttrDef, - type DataAttrsExtraction, - type PartReference, - type PartSource, - type PropDef, - type StateDef, -} from './types.js'; +import { generateComponentReferences } from './pipeline.js'; +import { ComponentReferenceSchema } from './types.js'; import { generateUtilReferences } from './util-handler.js'; -import { kebabToPascal, partKebabFromSource, sortProps } from './utils.js'; - -// Components whose PascalCase name doesn't match simple kebab-to-pascal conversion. -const NAME_OVERRIDES: Record = { - 'pip-button': 'PiPButton', -}; - -// Parts whose HTML element file doesn't follow the `{component}-{part}-element.ts` convention. -// Key: `{component}/{part-kebab}`, Value: element file basename (without `.ts`). -const PART_ELEMENT_OVERRIDES: Record = { - 'tooltip/provider': 'tooltip-group-element', -}; - -function buildProps(coreData: CoreExtraction): Record { - const props: Record = {}; - for (const prop of coreData.props) { - props[prop.name] = { - type: prop.type, - detailedType: prop.detailedType, - description: prop.description, - default: coreData.defaultProps[prop.name] ?? prop.default, - required: prop.required, - }; - - if (props[prop.name]!.detailedType === undefined) delete props[prop.name]!.detailedType; - if (props[prop.name]!.description === undefined) delete props[prop.name]!.description; - if (props[prop.name]!.default === undefined) delete props[prop.name]!.default; - if (!props[prop.name]!.required) delete props[prop.name]!.required; - } - return props; -} - -function buildState(coreData: CoreExtraction): Record { - const state: Record = {}; - for (const s of coreData.state) { - state[s.name] = { - type: s.type, - detailedType: s.detailedType, - description: s.description, - }; - if (state[s.name]!.detailedType === undefined) delete state[s.name]!.detailedType; - if (state[s.name]!.description === undefined) delete state[s.name]!.description; - } - return state; -} - -function buildDataAttrs(dataAttrsData: DataAttrsExtraction): Record { - const dataAttributes: Record = {}; - for (const attr of dataAttrsData.attrs) { - const def: DataAttrDef = { description: attr.description }; - if (attr.type) { - const abbreviated = abbreviateType(attr.name, attr.type); - if (abbreviated) { - def.type = abbreviated; - def.detailedType = attr.type; - } else { - def.type = attr.type; - } - } - dataAttributes[attr.name] = def; - } - return dataAttributes; -} - -function buildCSSVars(cssVarsData: CSSVarsExtraction): Record { - const cssCustomProperties: Record = {}; - for (const v of cssVarsData.vars) { - cssCustomProperties[v.name] = { description: v.description }; - } - return cssCustomProperties; -} // Magenta prefix - visible on both light and dark terminals const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; @@ -107,488 +16,9 @@ const log = { // Paths relative to the monorepo root const MONOREPO_ROOT = path.resolve(import.meta.dirname, '../../../../'); -const CORE_UI_PATH = path.join(MONOREPO_ROOT, 'packages/core/src/core/ui'); -const HTML_UI_PATH = path.join(MONOREPO_ROOT, 'packages/html/src/ui'); -const REACT_UI_PATH = path.join(MONOREPO_ROOT, 'packages/react/src/ui'); const COMPONENT_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-component-reference'); const UTIL_OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-util-reference'); -/** - * Discover all components by scanning the core/ui directory. - */ -function discoverComponents(): ComponentSource[] { - const components: ComponentSource[] = []; - - if (!fs.existsSync(CORE_UI_PATH)) { - log.error(`Core UI path not found: ${CORE_UI_PATH}`); - return components; - } - - const dirs = fs.readdirSync(CORE_UI_PATH, { withFileTypes: true }); - - for (const dir of dirs) { - if (!dir.isDirectory()) continue; - - const componentName = NAME_OVERRIDES[dir.name] ?? kebabToPascal(dir.name); - const componentDir = path.join(CORE_UI_PATH, dir.name); - - // Look for core file - const coreFile = path.join(componentDir, `${dir.name}-core.ts`); - const dataAttrsFile = path.join(componentDir, `${dir.name}-data-attrs.ts`); - const cssVarsFile = path.join(componentDir, `${dir.name}-css-vars.ts`); - - // Look for HTML element file - const htmlFile = path.join(HTML_UI_PATH, dir.name, `${dir.name}-element.ts`); - - const source: ComponentSource = { - name: componentName, - kebab: dir.name, - }; - - if (fs.existsSync(coreFile)) { - source.corePath = coreFile; - } - - if (fs.existsSync(dataAttrsFile)) { - source.dataAttrsPath = dataAttrsFile; - } - - if (fs.existsSync(cssVarsFile)) { - source.cssVarsPath = cssVarsFile; - } - - if (fs.existsSync(htmlFile)) { - source.htmlPath = htmlFile; - } - - // Check for multi-part component (index.parts.ts in React package) - const partsIndexFile = path.join(REACT_UI_PATH, dir.name, 'index.parts.ts'); - if (fs.existsSync(partsIndexFile)) { - source.partsIndexPath = partsIndexFile; - } - - // Only include if we have at least a core file - if (source.corePath) { - components.push(source); - } - } - - return components; -} - -/** - * Create a TypeScript program for all relevant files. - */ -function createProgram(sources: ComponentSource[]): ts.Program { - const files: string[] = []; - - for (const source of sources) { - if (source.corePath) files.push(source.corePath); - if (source.dataAttrsPath) files.push(source.dataAttrsPath); - if (source.cssVarsPath) files.push(source.cssVarsPath); - if (source.htmlPath) files.push(source.htmlPath); - if (source.partsIndexPath) files.push(source.partsIndexPath); - - // For multi-part components, include all element files from the HTML directory - // and React source files for JSDoc description extraction - if (source.partsIndexPath) { - const htmlDir = path.join(HTML_UI_PATH, source.kebab); - if (fs.existsSync(htmlDir)) { - const elementFiles = fs.readdirSync(htmlDir).filter((f) => f.endsWith('-element.ts')); - for (const file of elementFiles) { - const fullPath = path.join(htmlDir, file); - if (!files.includes(fullPath)) { - files.push(fullPath); - } - } - } - - // Include React component .tsx files for JSDoc description extraction - const reactDir = path.dirname(source.partsIndexPath); - const reactFiles = fs.readdirSync(reactDir).filter((f) => f.endsWith('.tsx')); - for (const file of reactFiles) { - const fullPath = path.join(reactDir, file); - if (!files.includes(fullPath)) { - files.push(fullPath); - } - } - - // Include origin component files for re-exported parts - const partsSource = fs.readFileSync(source.partsIndexPath, 'utf-8'); - const nonLocalImports = partsSource.match(/from\s+['"]([^.][^'"]*)['"]/g); - if (nonLocalImports) { - for (const match of nonLocalImports) { - const importPath = match.replace(/from\s+['"]/, '').replace(/['"]$/, ''); - const originDir = path.resolve(path.dirname(source.partsIndexPath), importPath, '..'); - const originKebab = path.basename(originDir); - - // Include origin HTML element files - const originHtmlDir = path.join(HTML_UI_PATH, originKebab); - if (fs.existsSync(originHtmlDir)) { - const originElementFiles = fs.readdirSync(originHtmlDir).filter((f) => f.endsWith('-element.ts')); - for (const file of originElementFiles) { - const fullPath = path.join(originHtmlDir, file); - if (!files.includes(fullPath)) { - files.push(fullPath); - } - } - } - - // Include origin React .tsx files for JSDoc description extraction - if (fs.existsSync(originDir)) { - const originReactFiles = fs.readdirSync(originDir).filter((f) => f.endsWith('.tsx')); - for (const file of originReactFiles) { - const fullPath = path.join(originDir, file); - if (!files.includes(fullPath)) { - files.push(fullPath); - } - } - } - } - } - } - } - - // Load base tsconfig - works for all packages since we only need type resolution - const tsconfigPath = path.join(MONOREPO_ROOT, 'tsconfig.base.json'); - const config = tae.loadConfig(tsconfigPath); - - config.options.rootDir = MONOREPO_ROOT; - - return ts.createProgram(files, config.options); -} - -/** - * Build the API reference for a single-part component. - */ -function buildSingleComponentReference(source: ComponentSource, program: ts.Program): ComponentReference | null { - // Extract from core - const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; - - if (!coreData) { - log.warn(`No core data found for ${source.name}`); - return null; - } - - // Extract data attributes - const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null; - - // Extract CSS custom properties - const cssVarsData = source.cssVarsPath ? extractCSSVars(source.cssVarsPath, program, source.name) : null; - - // Extract HTML element info - const htmlData = source.htmlPath ? extractHtml(source.htmlPath, program, source.name) : null; - - // Build result - const result: ComponentReference = { - name: source.name, - description: coreData.description, - props: buildProps(coreData), - state: buildState(coreData), - dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, - cssCustomProperties: cssVarsData ? buildCSSVars(cssVarsData) : {}, - platforms: {}, - }; - - // Add HTML platform info if available - if (htmlData) { - result.platforms.html = { - tagName: htmlData.tagName, - }; - } - - // Clean up undefined description - if (result.description === undefined) delete result.description; - - return result; -} - -/** - * Check if a React source file instantiates the component's own Core class - * (matches `new {ComponentName}Core(`). This prevents auxiliary classes like - * `TooltipGroupCore` from being mistaken for the primary Core. - */ -function instantiatesCore(filePath: string, componentName: string): boolean { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - return new RegExp(`new ${componentName}Core\\b`).test(content); - } catch { - return false; - } -} - -/** String search heuristic — a comment containing `stateAttrMap` would also match. */ -function usesDataAttrs(filePath: string): boolean { - try { - return fs.readFileSync(filePath, 'utf-8').includes('stateAttrMap'); - } catch { - return false; - } -} - -/** - * Discover parts and match them to HTML element files. - * - * Matching algorithm: - * 1. Parse `index.parts.ts` for named exports -> part names and source paths - * 2. Filter out non-local re-exports (sources not starting with './') - * 3. Derive kebab segment from source: `./time-value` -> strip `./time-` prefix -> `value` - * 4. For each part, look for `{name}-{kebab}-element.ts` in HTML dir - * 5. Primary part: the part whose React source file instantiates the Core class - * 6. Primary part gets: shared core file, shared data-attrs/css-vars, main element - */ -function discoverParts(source: ComponentSource, program: ts.Program): PartSource[] { - if (!source.partsIndexPath) return []; - - const partExports = extractParts(source.partsIndexPath, program); - if (partExports.length === 0) return []; - - const localExports = partExports.filter((p) => p.source.startsWith('./')); - const nonLocalExports = partExports.filter((p) => !p.source.startsWith('./')); - - if (localExports.length === 0 && nonLocalExports.length === 0) return []; - - const componentKebab = source.kebab; - const htmlDir = path.join(HTML_UI_PATH, componentKebab); - - const parts: PartSource[] = []; - - // Process local exports - for (const partExport of localExports) { - const kebab = partKebabFromSource(partExport.source, componentKebab); - - // Look for sub-part element file: {component}-{part}-element.ts (or override) - const overrideKey = `${componentKebab}/${kebab}`; - const elementBasename = PART_ELEMENT_OVERRIDES[overrideKey] ?? `${componentKebab}-${kebab}-element`; - const subPartElementFile = path.join(htmlDir, `${elementBasename}.ts`); - const hasSubPartElement = fs.existsSync(subPartElementFile); - - // Resolve React source path for JSDoc description extraction - const reactFile = path.join(path.dirname(source.partsIndexPath!), `${partExport.source.replace('./', '')}.tsx`); - const reactPath = fs.existsSync(reactFile) ? reactFile : undefined; - - // Primary detection: the part whose React source instantiates the Core class - const isPrimary = !!reactPath && instantiatesCore(reactPath, source.name); - - const subPartUsesDataAttrs = !isPrimary && !!reactPath && usesDataAttrs(reactPath); - - const part: PartSource = { - name: partExport.name, - localName: partExport.localName, - kebab, - isPrimary, - htmlPath: hasSubPartElement ? subPartElementFile : isPrimary ? source.htmlPath : undefined, - reactPath, - dataAttrsPath: subPartUsesDataAttrs ? source.dataAttrsPath : undefined, - dataAttrsComponentName: subPartUsesDataAttrs ? source.name : undefined, - }; - - parts.push(part); - } - - // Resolve re-exported parts from other components - if (nonLocalExports.length > 0) { - // Group by source module path - const bySource = new Map(); - for (const exp of nonLocalExports) { - const list = bySource.get(exp.source) ?? []; - list.push(exp); - bySource.set(exp.source, list); - } - - const partsDir = path.dirname(source.partsIndexPath!); - - for (const [sourcePath, exports] of bySource) { - // Resolve the origin index.parts.ts file - const originPartsFile = path.resolve(partsDir, `${sourcePath}.ts`); - if (!fs.existsSync(originPartsFile)) { - log.warn(`${source.name}: Re-export source not found: ${originPartsFile}`); - continue; - } - - // Derive origin component kebab from directory name - const originKebab = path.basename(path.dirname(originPartsFile)); - const originHtmlDir = path.join(HTML_UI_PATH, originKebab); - const originReactDir = path.dirname(originPartsFile); - - // Parse origin's exports to match re-exported names to original local exports - const originExports = extractParts(originPartsFile, program); - - for (const reExport of exports) { - // Find the matching export in the origin file - const originExport = originExports.find((o) => o.name === reExport.name); - if (!originExport) { - log.warn(`${source.name}: Re-exported part "${reExport.name}" not found in ${originPartsFile}`); - continue; - } - - // Derive kebab from the origin export's source path - const kebab = partKebabFromSource(originExport.source, originKebab); - - // Look for element file in origin's HTML directory - const subPartElementFile = path.join(originHtmlDir, `${originKebab}-${kebab}-element.ts`); - const hasSubPartElement = fs.existsSync(subPartElementFile); - - // Resolve React source from origin component for JSDoc description - const reactFile = path.join(originReactDir, `${originExport.source.replace('./', '')}.tsx`); - const reactPath = fs.existsSync(reactFile) ? reactFile : undefined; - - const reExportUsesDataAttrs = !!reactPath && usesDataAttrs(reactPath); - const originDataAttrsFile = path.join(CORE_UI_PATH, originKebab, `${originKebab}-data-attrs.ts`); - const originDataAttrsPath = - reExportUsesDataAttrs && fs.existsSync(originDataAttrsFile) ? originDataAttrsFile : undefined; - const originComponentName = originDataAttrsPath ? kebabToPascal(originKebab) : undefined; - - const part: PartSource = { - name: reExport.name, - localName: originExport.localName, - kebab, - isPrimary: false, // Re-exported parts are never primary - htmlPath: hasSubPartElement ? subPartElementFile : undefined, - reactPath, - dataAttrsPath: originDataAttrsPath, - dataAttrsComponentName: originComponentName, - }; - - parts.push(part); - } - } - } - - const primaryCount = parts.filter((p) => p.isPrimary).length; - if (primaryCount === 0) { - log.warn(`${source.name}: No primary part identified (no part instantiates a Core class)`); - } else if (primaryCount > 1) { - log.warn(`${source.name}: Multiple parts instantiate Core — only the first will be treated as primary`); - let foundFirst = false; - for (const p of parts) { - if (p.isPrimary) { - if (foundFirst) p.isPrimary = false; - foundFirst = true; - } - } - } - - // Primary part first so it appears first in the docs. - return parts.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)); -} - -/** - * Build the API reference for a multi-part component. - * - * Multi-part components have empty top-level props/state/dataAttributes/cssCustomProperties. - * All data is in the `parts` record. - * - * For the primary part: - * - Props and state come from the shared core file (`{name}-core.ts`) - * - Data attributes come from the shared data-attrs file (`{name}-data-attrs.ts`) - * - CSS custom properties come from the shared css-vars file (`{name}-css-vars.ts`) - * - HTML tag comes from the main element file (`{name}-element.ts`) - * - * For non-primary parts: - * - Props, state, data attributes, and CSS vars are empty (no dedicated core file) - * - HTML tag comes from their sub-part element file (`{name}-{part}-element.ts`) - * - * All parts get `platforms.react` (they come from `index.parts.ts`). - * Parts with matching HTML element files also get `platforms.html`. - */ -function buildMultiPartReference( - source: ComponentSource, - program: ts.Program, - parts: PartSource[] -): ComponentReference | null { - const partsRecord: Record = {}; - - for (const part of parts) { - // Extract JSDoc description from React component file - const description = part.reactPath - ? (extractPartDescription(part.reactPath, program, part.localName) ?? - extractPartDescription(part.reactPath, program, part.name)) - : undefined; - - if (part.isPrimary) { - // Primary part: extract from shared core, data-attrs, and css-vars - const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; - const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null; - const cssVarsData = source.cssVarsPath ? extractCSSVars(source.cssVarsPath, program, source.name) : null; - - const elementName = `${source.name}Element`; - const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null; - - const partRef: PartReference = { - name: part.name, - description, - props: coreData ? sortProps(buildProps(coreData)) : {}, - state: coreData ? buildState(coreData) : {}, - dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, - cssCustomProperties: cssVarsData ? buildCSSVars(cssVarsData) : {}, - platforms: { react: {} }, - }; - - if (!partRef.description) delete partRef.description; - if (htmlData) { - partRef.platforms.html = { tagName: htmlData.tagName }; - } - - partsRecord[part.kebab] = partRef; - } else { - // Non-primary part: extract HTML tag, shared data attributes, and custom React props - const elementName = part.htmlPath ? kebabToPascal(path.basename(part.htmlPath, '.ts')) : undefined; - const htmlData = - part.htmlPath && elementName ? extractHtml(part.htmlPath, program, source.name, elementName) : null; - - const dataAttrsData = - part.dataAttrsPath && part.dataAttrsComponentName - ? extractDataAttrs(part.dataAttrsPath, program, part.dataAttrsComponentName) - : null; - - const subPartProps = part.reactPath ? extractSubPartProps(part.reactPath, program, part.localName) : {}; - - const partRef: PartReference = { - name: part.name, - description, - props: sortProps(subPartProps), - state: {}, - dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, - cssCustomProperties: {}, - platforms: { react: {} }, - }; - - if (!partRef.description) delete partRef.description; - if (htmlData) { - partRef.platforms.html = { tagName: htmlData.tagName }; - } - - partsRecord[part.kebab] = partRef; - } - } - - return { - name: source.name, - props: {}, - state: {}, - dataAttributes: {}, - cssCustomProperties: {}, - platforms: {}, - parts: partsRecord, - }; -} - -/** - * Build the API reference for a single component. - */ -function buildComponentReference(source: ComponentSource, program: ts.Program): ComponentReference | null { - if (source.partsIndexPath) { - const parts = discoverParts(source, program); - // Single-part fallback: when filtering leaves only 1 part, use single-part mode - if (parts.length > 1) { - return buildMultiPartReference(source, program, parts); - } - } - - return buildSingleComponentReference(source, program); -} - /** * Main entry point. */ @@ -597,7 +27,6 @@ function main() { // (or a few others like ESSymbol, TemplateLiteral). It falls back to `any` // and logs a warning for each occurrence. This is a known gap in the alpha // library — not a bug in our types. Suppress the noise here. - // https://github.com/michaldudak/typescript-api-extractor/blob/main/src/parsers/typeResolver.ts const originalWarn = console.warn; console.warn = (...args: unknown[]) => { if (typeof args[0] === 'string' && args[0].startsWith('Unable to handle a type with flag')) return; @@ -609,53 +38,37 @@ function main() { fs.mkdirSync(COMPONENT_OUTPUT_PATH, { recursive: true }); } - // Discover components - const components = discoverComponents(); + // Generate component references via pipeline + const componentResults = generateComponentReferences(MONOREPO_ROOT); - if (components.length === 0) { + if (componentResults.length === 0) { log.info('No components found.'); - return; + } else { + log.info(`Found ${componentResults.length} components. Processing...`); } - log.info(`Found ${components.length} components. Processing...`); - // Create TypeScript program - const program = createProgram(components); - - // Process each component let successCount = 0; let errorCount = 0; - for (const source of components) { - try { - const apiRef = buildComponentReference(source, program); - - if (apiRef) { - // Sort props (top-level only for single-part) - apiRef.props = sortProps(apiRef.props); - - // Validate against schema before writing - const validated = ComponentReferenceSchema.safeParse(apiRef); - if (!validated.success) { - log.error(`Schema validation failed for ${source.name}:`); - for (const issue of validated.error.issues) { - log.error(` - ${issue.path.join('.')}: ${issue.message}`); - } - errorCount++; - continue; - } - - // Write JSON file - const outputFile = path.join(COMPONENT_OUTPUT_PATH, `${source.kebab}.json`); - const json = `${JSON.stringify(validated.data, null, 2)}\n`; - fs.writeFileSync(outputFile, json); - - log.success(`✅ Generated ${path.basename(outputFile)}`); - successCount++; + for (const result of componentResults) { + // Validate against schema before writing + const validated = ComponentReferenceSchema.safeParse(result.reference); + if (!validated.success) { + log.error(`Schema validation failed for ${result.name}:`); + for (const issue of validated.error.issues) { + log.error(` - ${issue.path.join('.')}: ${issue.message}`); } - } catch (error) { - log.error(`⚠️ Error processing ${source.name}:`, (error as Error).message); errorCount++; + continue; } + + // Write JSON file + const outputFile = path.join(COMPONENT_OUTPUT_PATH, `${result.kebab}.json`); + const json = `${JSON.stringify(validated.data, null, 2)}\n`; + fs.writeFileSync(outputFile, json); + + log.success(`✅ Generated ${path.basename(outputFile)}`); + successCount++; } log.info(`Done! Generated ${successCount} component files.`); diff --git a/site/scripts/api-docs-builder/src/pipeline.ts b/site/scripts/api-docs-builder/src/pipeline.ts new file mode 100644 index 00000000..4062ca9c --- /dev/null +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -0,0 +1,511 @@ +/** + * Testable pipeline functions for the API docs builder. + * + * Extracted from index.ts so that E2E tests can run the full pipeline + * against a fixture monorepo by passing a custom root path. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as ts from 'typescript'; +import * as tae from 'typescript-api-extractor'; +import { extractCore } from './core-handler.js'; +import { extractCSSVars } from './css-vars-handler.js'; +import { extractDataAttrs } from './data-attrs-handler.js'; +import { abbreviateType } from './formatter.js'; +import { extractHtml } from './html-handler.js'; +import { extractPartDescription, extractParts, extractSubPartProps } from './parts-handler.js'; +import type { + ComponentReference, + ComponentSource, + CoreExtraction, + CSSVarDef, + CSSVarsExtraction, + DataAttrDef, + DataAttrsExtraction, + PartReference, + PartSource, + PropDef, + StateDef, +} from './types.js'; +import { kebabToPascal, partKebabFromSource, sortProps } from './utils.js'; + +// ─── Overrides ───────────────────────────────────────────────────── + +// Components whose PascalCase name doesn't match simple kebab-to-pascal conversion. +export const NAME_OVERRIDES: Record = { + 'pip-button': 'PiPButton', +}; + +// Parts whose HTML element file doesn't follow the `{component}-{part}-element.ts` convention. +// Key: `{component}/{part-kebab}`, Value: element file basename (without `.ts`). +export const PART_ELEMENT_OVERRIDES: Record = { + 'tooltip/provider': 'tooltip-group-element', +}; + +// ─── Build Helpers ───────────────────────────────────────────────── + +export function buildProps(coreData: CoreExtraction): Record { + const props: Record = {}; + for (const prop of coreData.props) { + props[prop.name] = { + type: prop.type, + detailedType: prop.detailedType, + description: prop.description, + default: coreData.defaultProps[prop.name] ?? prop.default, + required: prop.required, + }; + + if (props[prop.name]!.detailedType === undefined) delete props[prop.name]!.detailedType; + if (props[prop.name]!.description === undefined) delete props[prop.name]!.description; + if (props[prop.name]!.default === undefined) delete props[prop.name]!.default; + if (!props[prop.name]!.required) delete props[prop.name]!.required; + } + return props; +} + +export function buildState(coreData: CoreExtraction): Record { + const state: Record = {}; + for (const s of coreData.state) { + state[s.name] = { + type: s.type, + detailedType: s.detailedType, + description: s.description, + }; + if (state[s.name]!.detailedType === undefined) delete state[s.name]!.detailedType; + if (state[s.name]!.description === undefined) delete state[s.name]!.description; + } + return state; +} + +export function buildDataAttrs(dataAttrsData: DataAttrsExtraction): Record { + const dataAttributes: Record = {}; + for (const attr of dataAttrsData.attrs) { + const def: DataAttrDef = { description: attr.description }; + if (attr.type) { + const abbreviated = abbreviateType(attr.name, attr.type); + if (abbreviated) { + def.type = abbreviated; + def.detailedType = attr.type; + } else { + def.type = attr.type; + } + } + dataAttributes[attr.name] = def; + } + return dataAttributes; +} + +export function buildCSSVars(cssVarsData: CSSVarsExtraction): Record { + const cssCustomProperties: Record = {}; + for (const v of cssVarsData.vars) { + cssCustomProperties[v.name] = { description: v.description }; + } + return cssCustomProperties; +} + +// ─── Discovery ───────────────────────────────────────────────────── + +export function discoverComponents(monorepoRoot: string): ComponentSource[] { + const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui'); + const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui'); + const reactUiPath = path.join(monorepoRoot, 'packages/react/src/ui'); + + const components: ComponentSource[] = []; + + if (!fs.existsSync(coreUiPath)) { + return components; + } + + const dirs = fs.readdirSync(coreUiPath, { withFileTypes: true }); + + for (const dir of dirs) { + if (!dir.isDirectory()) continue; + + const componentName = NAME_OVERRIDES[dir.name] ?? kebabToPascal(dir.name); + const componentDir = path.join(coreUiPath, dir.name); + + const coreFile = path.join(componentDir, `${dir.name}-core.ts`); + const dataAttrsFile = path.join(componentDir, `${dir.name}-data-attrs.ts`); + const cssVarsFile = path.join(componentDir, `${dir.name}-css-vars.ts`); + const htmlFile = path.join(htmlUiPath, dir.name, `${dir.name}-element.ts`); + + const source: ComponentSource = { + name: componentName, + kebab: dir.name, + }; + + if (fs.existsSync(coreFile)) source.corePath = coreFile; + if (fs.existsSync(dataAttrsFile)) source.dataAttrsPath = dataAttrsFile; + if (fs.existsSync(cssVarsFile)) source.cssVarsPath = cssVarsFile; + if (fs.existsSync(htmlFile)) source.htmlPath = htmlFile; + + const partsIndexFile = path.join(reactUiPath, dir.name, 'index.parts.ts'); + if (fs.existsSync(partsIndexFile)) source.partsIndexPath = partsIndexFile; + + if (source.corePath) { + components.push(source); + } + } + + return components; +} + +// ─── Program Creation ────────────────────────────────────────────── + +export function createComponentProgram(sources: ComponentSource[], monorepoRoot: string): ts.Program { + const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui'); + const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui'); + const files: string[] = []; + + for (const source of sources) { + if (source.corePath) files.push(source.corePath); + if (source.dataAttrsPath) files.push(source.dataAttrsPath); + if (source.cssVarsPath) files.push(source.cssVarsPath); + if (source.htmlPath) files.push(source.htmlPath); + if (source.partsIndexPath) files.push(source.partsIndexPath); + + if (source.partsIndexPath) { + const htmlDir = path.join(htmlUiPath, source.kebab); + if (fs.existsSync(htmlDir)) { + const elementFiles = fs.readdirSync(htmlDir).filter((f) => f.endsWith('-element.ts')); + for (const file of elementFiles) { + const fullPath = path.join(htmlDir, file); + if (!files.includes(fullPath)) { + files.push(fullPath); + } + } + } + + const reactDir = path.dirname(source.partsIndexPath); + const reactFiles = fs.readdirSync(reactDir).filter((f) => f.endsWith('.tsx')); + for (const file of reactFiles) { + const fullPath = path.join(reactDir, file); + if (!files.includes(fullPath)) { + files.push(fullPath); + } + } + + const partsSource = fs.readFileSync(source.partsIndexPath, 'utf-8'); + const nonLocalImports = partsSource.match(/from\s+['"]([^.][^'"]*)['"]/g); + if (nonLocalImports) { + for (const match of nonLocalImports) { + const importPath = match.replace(/from\s+['"]/, '').replace(/['"]$/, ''); + const originDir = path.resolve(path.dirname(source.partsIndexPath), importPath, '..'); + const originKebab = path.basename(originDir); + + const originHtmlDir = path.join(htmlUiPath, originKebab); + if (fs.existsSync(originHtmlDir)) { + const originElementFiles = fs.readdirSync(originHtmlDir).filter((f) => f.endsWith('-element.ts')); + for (const file of originElementFiles) { + const fullPath = path.join(originHtmlDir, file); + if (!files.includes(fullPath)) { + files.push(fullPath); + } + } + } + + if (fs.existsSync(originDir)) { + const originReactFiles = fs.readdirSync(originDir).filter((f) => f.endsWith('.tsx')); + for (const file of originReactFiles) { + const fullPath = path.join(originDir, file); + if (!files.includes(fullPath)) { + files.push(fullPath); + } + } + } + } + } + } + } + + const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json'); + const config = tae.loadConfig(tsconfigPath); + config.options.rootDir = monorepoRoot; + + return ts.createProgram(files, config.options); +} + +// ─── Part Discovery ──────────────────────────────────────────────── + +function instantiatesCore(filePath: string, componentName: string): boolean { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return new RegExp(`new ${componentName}Core\\b`).test(content); + } catch { + return false; + } +} + +function usesDataAttrs(filePath: string): boolean { + try { + return fs.readFileSync(filePath, 'utf-8').includes('stateAttrMap'); + } catch { + return false; + } +} + +export function discoverParts(source: ComponentSource, program: ts.Program, monorepoRoot: string): PartSource[] { + if (!source.partsIndexPath) return []; + + const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui'); + const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui'); + + const partExports = extractParts(source.partsIndexPath, program); + if (partExports.length === 0) return []; + + const localExports = partExports.filter((p) => p.source.startsWith('./')); + const nonLocalExports = partExports.filter((p) => !p.source.startsWith('./')); + + if (localExports.length === 0 && nonLocalExports.length === 0) return []; + + const componentKebab = source.kebab; + const htmlDir = path.join(htmlUiPath, componentKebab); + + const parts: PartSource[] = []; + + for (const partExport of localExports) { + const kebab = partKebabFromSource(partExport.source, componentKebab); + + const overrideKey = `${componentKebab}/${kebab}`; + const elementBasename = PART_ELEMENT_OVERRIDES[overrideKey] ?? `${componentKebab}-${kebab}-element`; + const subPartElementFile = path.join(htmlDir, `${elementBasename}.ts`); + const hasSubPartElement = fs.existsSync(subPartElementFile); + + const reactFile = path.join(path.dirname(source.partsIndexPath!), `${partExport.source.replace('./', '')}.tsx`); + const reactPath = fs.existsSync(reactFile) ? reactFile : undefined; + + const isPrimary = !!reactPath && instantiatesCore(reactPath, source.name); + + const subPartUsesDataAttrs = !isPrimary && !!reactPath && usesDataAttrs(reactPath); + + const part: PartSource = { + name: partExport.name, + localName: partExport.localName, + kebab, + isPrimary, + htmlPath: hasSubPartElement ? subPartElementFile : isPrimary ? source.htmlPath : undefined, + reactPath, + dataAttrsPath: subPartUsesDataAttrs ? source.dataAttrsPath : undefined, + dataAttrsComponentName: subPartUsesDataAttrs ? source.name : undefined, + }; + + parts.push(part); + } + + if (nonLocalExports.length > 0) { + const bySource = new Map(); + for (const exp of nonLocalExports) { + const list = bySource.get(exp.source) ?? []; + list.push(exp); + bySource.set(exp.source, list); + } + + const partsDir = path.dirname(source.partsIndexPath!); + + for (const [sourcePath, exports] of bySource) { + const originPartsFile = path.resolve(partsDir, `${sourcePath}.ts`); + if (!fs.existsSync(originPartsFile)) continue; + + const originKebab = path.basename(path.dirname(originPartsFile)); + const originHtmlDir = path.join(htmlUiPath, originKebab); + const originReactDir = path.dirname(originPartsFile); + + const originExports = extractParts(originPartsFile, program); + + for (const reExport of exports) { + const originExport = originExports.find((o) => o.name === reExport.name); + if (!originExport) continue; + + const kebab = partKebabFromSource(originExport.source, originKebab); + + const subPartElementFile = path.join(originHtmlDir, `${originKebab}-${kebab}-element.ts`); + const hasSubPartElement = fs.existsSync(subPartElementFile); + + const reactFile = path.join(originReactDir, `${originExport.source.replace('./', '')}.tsx`); + const reactPath = fs.existsSync(reactFile) ? reactFile : undefined; + + const reExportUsesDataAttrs = !!reactPath && usesDataAttrs(reactPath); + const originDataAttrsFile = path.join(coreUiPath, originKebab, `${originKebab}-data-attrs.ts`); + const originDataAttrsPath = + reExportUsesDataAttrs && fs.existsSync(originDataAttrsFile) ? originDataAttrsFile : undefined; + const originComponentName = originDataAttrsPath ? kebabToPascal(originKebab) : undefined; + + const part: PartSource = { + name: reExport.name, + localName: originExport.localName, + kebab, + isPrimary: false, + htmlPath: hasSubPartElement ? subPartElementFile : undefined, + reactPath, + dataAttrsPath: originDataAttrsPath, + dataAttrsComponentName: originComponentName, + }; + + parts.push(part); + } + } + } + + const primaryCount = parts.filter((p) => p.isPrimary).length; + if (primaryCount > 1) { + let foundFirst = false; + for (const p of parts) { + if (p.isPrimary) { + if (foundFirst) p.isPrimary = false; + foundFirst = true; + } + } + } + + return parts.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)); +} + +// ─── Component Reference Building ────────────────────────────────── + +function buildSingleComponentReference(source: ComponentSource, program: ts.Program): ComponentReference | null { + const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; + + if (!coreData) return null; + + const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null; + const cssVarsData = source.cssVarsPath ? extractCSSVars(source.cssVarsPath, program, source.name) : null; + const htmlData = source.htmlPath ? extractHtml(source.htmlPath, program, source.name) : null; + + const result: ComponentReference = { + name: source.name, + description: coreData.description, + props: buildProps(coreData), + state: buildState(coreData), + dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, + cssCustomProperties: cssVarsData ? buildCSSVars(cssVarsData) : {}, + platforms: {}, + }; + + if (htmlData) { + result.platforms.html = { tagName: htmlData.tagName }; + } + + if (result.description === undefined) delete result.description; + + return result; +} + +function buildMultiPartReference( + source: ComponentSource, + program: ts.Program, + parts: PartSource[] +): ComponentReference | null { + const partsRecord: Record = {}; + + for (const part of parts) { + const description = part.reactPath + ? (extractPartDescription(part.reactPath, program, part.localName) ?? + extractPartDescription(part.reactPath, program, part.name)) + : undefined; + + if (part.isPrimary) { + const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; + const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null; + const cssVarsData = source.cssVarsPath ? extractCSSVars(source.cssVarsPath, program, source.name) : null; + + const elementName = `${source.name}Element`; + const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null; + + const partRef: PartReference = { + name: part.name, + description, + props: coreData ? sortProps(buildProps(coreData)) : {}, + state: coreData ? buildState(coreData) : {}, + dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, + cssCustomProperties: cssVarsData ? buildCSSVars(cssVarsData) : {}, + platforms: { react: {} }, + }; + + if (!partRef.description) delete partRef.description; + if (htmlData) { + partRef.platforms.html = { tagName: htmlData.tagName }; + } + + partsRecord[part.kebab] = partRef; + } else { + const elementName = part.htmlPath ? kebabToPascal(path.basename(part.htmlPath, '.ts')) : undefined; + const htmlData = + part.htmlPath && elementName ? extractHtml(part.htmlPath, program, source.name, elementName) : null; + + const dataAttrsData = + part.dataAttrsPath && part.dataAttrsComponentName + ? extractDataAttrs(part.dataAttrsPath, program, part.dataAttrsComponentName) + : null; + + const subPartProps = part.reactPath ? extractSubPartProps(part.reactPath, program, part.localName) : {}; + + const partRef: PartReference = { + name: part.name, + description, + props: sortProps(subPartProps), + state: {}, + dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, + cssCustomProperties: {}, + platforms: { react: {} }, + }; + + if (!partRef.description) delete partRef.description; + if (htmlData) { + partRef.platforms.html = { tagName: htmlData.tagName }; + } + + partsRecord[part.kebab] = partRef; + } + } + + return { + name: source.name, + props: {}, + state: {}, + dataAttributes: {}, + cssCustomProperties: {}, + platforms: {}, + parts: partsRecord, + }; +} + +export function buildComponentReference( + source: ComponentSource, + program: ts.Program, + monorepoRoot: string +): ComponentReference | null { + if (source.partsIndexPath) { + const parts = discoverParts(source, program, monorepoRoot); + if (parts.length > 1) { + return buildMultiPartReference(source, program, parts); + } + } + + return buildSingleComponentReference(source, program); +} + +// ─── Full Pipeline ───────────────────────────────────────────────── + +export interface ComponentResult { + name: string; + kebab: string; + reference: ComponentReference; +} + +export function generateComponentReferences(monorepoRoot: string): ComponentResult[] { + const sources = discoverComponents(monorepoRoot); + if (sources.length === 0) return []; + + const program = createComponentProgram(sources, monorepoRoot); + const results: ComponentResult[] = []; + + for (const source of sources) { + const apiRef = buildComponentReference(source, program, monorepoRoot); + if (apiRef) { + apiRef.props = sortProps(apiRef.props); + results.push({ name: source.name, kebab: source.kebab, reference: apiRef }); + } + } + + return results; +} diff --git a/site/scripts/api-docs-builder/src/tests/core-handler.test.ts b/site/scripts/api-docs-builder/src/tests/core-handler.test.ts deleted file mode 100644 index 963d6db2..00000000 --- a/site/scripts/api-docs-builder/src/tests/core-handler.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import * as tae from 'typescript-api-extractor'; -import { describe, expect, it, type MockInstance, vi } from 'vitest'; -import { extractCore, extractDefaultProps } from '../core-handler.js'; -import { createTestProgram } from './test-utils.js'; - -vi.mock('typescript-api-extractor', async () => { - const actual = await vi.importActual('typescript-api-extractor'); - return { - ...actual, - parseFromProgram: vi.fn(), - }; -}); - -const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance; - -describe('extractDefaultProps', () => { - it("extracts string literals with quotes ('label' → \"''\")", () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - label: '', - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.label).toBe("''"); - }); - - it("extracts non-empty string literals ('Play' → \"'Play'\")", () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - label: 'Play', - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.label).toBe("'Play'"); - }); - - it('extracts booleans (false → "false")', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - disabled: false, - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.disabled).toBe('false'); - }); - - it('extracts booleans (true → "true")', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - enabled: true, - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.enabled).toBe('true'); - }); - - it('extracts null values (null → "null")', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - value: null, - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.value).toBe('null'); - }); - - it('extracts empty arrays ([] → "[]")', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - items: [], - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.items).toBe('[]'); - }); - - it('extracts empty objects ({} → "{}")', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - config: {}, - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.config).toBe('{}'); - }); - - it('extracts numeric literals', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - count: 42, - ratio: 1.5, - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result.count).toBe('42'); - expect(result.ratio).toBe('1.5'); - }); - - it('returns empty object when class not found', () => { - const code = ` - export class OtherClass { - static readonly defaultProps = { - label: 'test', - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result).toEqual({}); - }); - - it('returns empty object when no defaultProps property', () => { - const code = ` - export class MockComponentCore { - static readonly otherProperty = { - label: 'test', - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result).toEqual({}); - }); - - it('ignores non-static defaultProps', () => { - const code = ` - export class MockComponentCore { - readonly defaultProps = { - label: 'test', - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - expect(result).toEqual({}); - }); -}); - -describe('getPropertyValue', () => { - it('falls back to getText for complex expressions', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - label: \`hello \${world}\`, - }; - } - `; - const program = createTestProgram(code); - const result = extractDefaultProps('test.ts', program, 'MockComponent'); - - // biome-ignore lint/suspicious/noTemplateCurlyInString: testing template literal extraction - expect(result.label).toBe('`hello ${world}`'); - }); -}); - -describe('extractCore', () => { - function createMockAst(exports: Array<{ name: string; type: unknown; documentation?: unknown }>) { - return { exports }; - } - - function createMockObjectNode(properties: tae.PropertyNode[]): tae.ObjectNode { - const node = Object.create(tae.ObjectNode.prototype); - node.properties = properties; - return node; - } - - function createMockIntrinsicNode(intrinsic: string): tae.IntrinsicNode { - const node = Object.create(tae.IntrinsicNode.prototype); - node.intrinsic = intrinsic; - node.typeName = undefined; - return node; - } - - function createMockPropertyNode( - name: string, - typeName: string, - options: { optional?: boolean; description?: string; defaultValue?: string } = {} - ): tae.PropertyNode { - const type = createMockIntrinsicNode(typeName); - const documentation = - options.description !== undefined || options.defaultValue !== undefined - ? ({ - description: options.description, - defaultValue: options.defaultValue, - hasTag: () => false, - } as unknown as tae.Documentation) - : undefined; - - return { name, type, optional: options.optional ?? false, documentation } as tae.PropertyNode; - } - - it('returns null when neither Props nor State export is found', () => { - const code = 'export const x = 1;'; - const program = createTestProgram(code); - - mockParseFromProgram.mockReturnValueOnce( - createMockAst([{ name: 'SomethingElse', type: createMockIntrinsicNode('string') }]) - ); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('extracts props when propsExport.type is an ObjectNode', () => { - const code = 'export const x = 1;'; - const program = createTestProgram(code); - - const propsType = createMockObjectNode([ - createMockPropertyNode('label', 'string', { optional: true }), - createMockPropertyNode('disabled', 'boolean', { optional: true }), - ]); - - mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }])); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.props).toHaveLength(2); - expect(result!.props[0]!.name).toBe('label'); - expect(result!.props[0]!.type).toBe('string'); - expect(result!.props[1]!.name).toBe('disabled'); - expect(result!.props[1]!.type).toBe('boolean'); - }); - - it('extracts state when stateExport.type is an ObjectNode', () => { - const code = 'export const x = 1;'; - const program = createTestProgram(code); - - const stateType = createMockObjectNode([createMockPropertyNode('paused', 'boolean', { optional: false })]); - - mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentState', type: stateType }])); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.state).toHaveLength(1); - expect(result!.state[0]!.name).toBe('paused'); - expect(result!.state[0]!.type).toBe('boolean'); - }); - - it('extracts description from propsExport documentation', () => { - const code = 'export const x = 1;'; - const program = createTestProgram(code); - - const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]); - - mockParseFromProgram.mockReturnValueOnce( - createMockAst([ - { - name: 'MockComponentProps', - type: propsType, - documentation: { description: 'Props for the play button.' }, - }, - ]) - ); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.description).toBe('Props for the play button.'); - }); - - it('skips props when propsExport.type is not an ObjectNode', () => { - const code = 'export const x = 1;'; - const program = createTestProgram(code); - - mockParseFromProgram.mockReturnValueOnce( - createMockAst([ - { name: 'MockComponentProps', type: createMockIntrinsicNode('string') }, - { name: 'MockComponentState', type: createMockObjectNode([createMockPropertyNode('paused', 'boolean')]) }, - ]) - ); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.props).toHaveLength(0); - expect(result!.state).toHaveLength(1); - }); - - it('expands type aliases via allExports', () => { - const code = 'export const x = 1;'; - const program = createTestProgram(code); - - // Create an ExternalTypeNode referencing 'TimeType' - const externalTypeNode = Object.create(tae.ExternalTypeNode.prototype); - externalTypeNode.typeName = new tae.TypeName('TimeType'); - - const propsType = createMockObjectNode([ - { - name: 'type', - type: externalTypeNode, - optional: true, - documentation: undefined, - } as tae.PropertyNode, - ]); - - // TimeType is also in the exports list with its resolved union type - const timeTypeLiteral1 = Object.create(tae.LiteralNode.prototype); - timeTypeLiteral1.value = "'current'"; - const timeTypeLiteral2 = Object.create(tae.LiteralNode.prototype); - timeTypeLiteral2.value = "'duration'"; - const timeTypeLiteral3 = Object.create(tae.LiteralNode.prototype); - timeTypeLiteral3.value = "'remaining'"; - const timeTypeUnion = Object.create(tae.UnionNode.prototype); - timeTypeUnion.types = [timeTypeLiteral1, timeTypeLiteral2, timeTypeLiteral3]; - timeTypeUnion.typeName = undefined; - - mockParseFromProgram.mockReturnValueOnce( - createMockAst([ - { name: 'MockComponentProps', type: propsType }, - { name: 'TimeType', type: timeTypeUnion }, - ]) - ); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.props[0]!.name).toBe('type'); - expect(result!.props[0]!.type).toBe("'current' | 'duration' | 'remaining'"); - }); - - it('merges defaultProps from extractDefaultProps into result', () => { - const code = ` - export class MockComponentCore { - static readonly defaultProps = { - label: 'Play', - }; - } - `; - const program = createTestProgram(code); - - const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]); - - mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }])); - - const result = extractCore('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.defaultProps).toEqual({ label: "'Play'" }); - }); -}); diff --git a/site/scripts/api-docs-builder/src/tests/css-vars-handler.test.ts b/site/scripts/api-docs-builder/src/tests/css-vars-handler.test.ts deleted file mode 100644 index e1cf4c39..00000000 --- a/site/scripts/api-docs-builder/src/tests/css-vars-handler.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractCSSVars } from '../css-vars-handler.js'; -import { createTestProgram } from './test-utils.js'; - -describe('extractCSSVars', () => { - it('extracts from {Name}CSSVars constant', () => { - const code = ` - export const MockComponentCSSVars = { - fill: '--media-slider-fill', - pointer: '--media-slider-pointer', - } as const; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars).toHaveLength(2); - expect(result!.vars[0]!.name).toBe('--media-slider-fill'); - expect(result!.vars[1]!.name).toBe('--media-slider-pointer'); - }); - - it('extracts JSDoc comments as descriptions', () => { - const code = ` - export const MockComponentCSSVars = { - /** Fill level percentage (0-100). */ - fill: '--media-slider-fill', - /** Pointer position percentage (0-100). */ - pointer: '--media-slider-pointer', - } as const; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars[0]!.description).toBe('Fill level percentage (0-100).'); - expect(result!.vars[1]!.description).toBe('Pointer position percentage (0-100).'); - }); - - it('handles object without as const', () => { - const code = ` - export const MockComponentCSSVars = { - fill: '--media-slider-fill', - }; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars).toHaveLength(1); - expect(result!.vars[0]!.name).toBe('--media-slider-fill'); - }); - - it('handles as const satisfies expression', () => { - const code = ` - export const MockComponentCSSVars = { - fill: '--media-slider-fill', - } as const satisfies Record; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars).toHaveLength(1); - expect(result!.vars[0]!.name).toBe('--media-slider-fill'); - }); - - it('returns null when constant not found', () => { - const code = ` - export const OtherConstant = { - fill: '--media-slider-fill', - }; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('returns empty description when no JSDoc present', () => { - const code = ` - export const MockComponentCSSVars = { - fill: '--media-slider-fill', - } as const; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars[0]!.description).toBe(''); - }); - - it('skips properties with non-string-literal values', () => { - const code = ` - const PREFIX = '--media-'; - export const MockComponentCSSVars = { - fill: PREFIX + 'fill', - pointer: '--media-slider-pointer', - } as const; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars).toHaveLength(1); - expect(result!.vars[0]!.name).toBe('--media-slider-pointer'); - }); - - it('extracts single-line // comments as descriptions', () => { - const code = ` - export const MockComponentCSSVars = { - // Fill level percentage (0-100). - fill: '--media-slider-fill', - } as const; - `; - const program = createTestProgram(code); - const result = extractCSSVars('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.vars[0]!.description).toBe('Fill level percentage (0-100).'); - }); -}); diff --git a/site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts b/site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts deleted file mode 100644 index a4243bdc..00000000 --- a/site/scripts/api-docs-builder/src/tests/data-attrs-handler.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractDataAttrs } from '../data-attrs-handler.js'; -import { createTestProgram, createTypedTestProgram } from './test-utils.js'; - -describe('extractDataAttrs', () => { - it('extracts from {Name}DataAttrs constant', () => { - const code = ` - export const MockComponentDataAttrs = { - active: 'data-active', - disabled: 'data-disabled', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs).toHaveLength(2); - expect(result!.attrs[0]!.name).toBe('data-active'); - expect(result!.attrs[1]!.name).toBe('data-disabled'); - }); - - it('extracts from {Name}DataAttributes constant (alternate naming)', () => { - const code = ` - export const MockComponentDataAttributes = { - paused: 'data-paused', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs).toHaveLength(1); - expect(result!.attrs[0]!.name).toBe('data-paused'); - }); - - it('extracts JSDoc comments for each property', () => { - const code = ` - export const MockComponentDataAttrs = { - /** Present when the component is active. */ - active: 'data-active', - /** Present when the component is disabled. */ - disabled: 'data-disabled', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.description).toBe('Present when the component is active.'); - expect(result!.attrs[1]!.description).toBe('Present when the component is disabled.'); - }); - - it('handles object without as const', () => { - const code = ` - export const MockComponentDataAttrs = { - value: 'data-value', - }; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs).toHaveLength(1); - }); - - it('extracts from {Name}DataAttrs with as const satisfies', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - interface MockComponentState { - active: boolean; - } - - export const MockComponentDataAttrs = { - active: 'data-active', - } as const satisfies StateAttrMap; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs).toHaveLength(1); - expect(result!.attrs[0]!.name).toBe('data-active'); - }); - - it('extracts from {Name}DataAttrs with satisfies expression', () => { - const code = ` - export const MockComponentDataAttrs = ({ - active: 'data-active', - }) satisfies Record; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs).toHaveLength(1); - expect(result!.attrs[0]!.name).toBe('data-active'); - }); - - it('extracts JSDoc comments for properties wrapped with satisfies', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - interface MockComponentState { - active: boolean; - } - - export const MockComponentDataAttrs = { - /** Present when the component is active. */ - active: 'data-active', - } as const satisfies StateAttrMap; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.description).toBe('Present when the component is active.'); - }); - - it('returns null when constant not found', () => { - const code = ` - export const OtherConstant = { - value: 'data-value', - }; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('extracts single-line // comments for properties', () => { - const code = ` - export const MockComponentDataAttrs = { - // Present when the component is focused. - focused: 'data-focused', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.description).toBe('Present when the component is focused.'); - }); - - it('extracts @type JSDoc tag as type field', () => { - const code = ` - export const MockComponentDataAttrs = { - /** - * The fill level. - * @type {'empty' | 'partial' | 'full'} - */ - fillState: 'data-fill-state', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBe("'empty' | 'partial' | 'full'"); - }); - - it('separates description from @type line', () => { - const code = ` - export const MockComponentDataAttrs = { - /** - * The fill level. - * @type {'empty' | 'partial' | 'full'} - */ - fillState: 'data-fill-state', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.description).toBe('The fill level.'); - expect(result!.attrs[0]!.description).not.toContain('@type'); - }); - - it('omits type when no @type tag present', () => { - const code = ` - export const MockComponentDataAttrs = { - /** Present when the component is active. */ - active: 'data-active', - } as const; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBeUndefined(); - }); - - it('falls back to data-{key} when value is not a string literal', () => { - const code = ` - const PREFIX = 'data-'; - export const MockComponentDataAttrs = { - active: PREFIX + 'active', - }; - `; - const program = createTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.name).toBe('data-active'); - }); - - it('infers boolean as omitted type', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - interface MockComponentState { - active: boolean; - } - - export const MockComponentDataAttrs = { - active: 'data-active', - } as const satisfies StateAttrMap; - `; - const program = createTypedTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBeUndefined(); - }); - - it('infers string literal union from state type', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - interface MockComponentState { - level: 'low' | 'medium' | 'high'; - } - - export const MockComponentDataAttrs = { - level: 'data-level', - } as const satisfies StateAttrMap; - `; - const program = createTypedTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBe("'low' | 'medium' | 'high'"); - }); - - it('infers number type from state', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - interface MockComponentState { - count: number; - } - - export const MockComponentDataAttrs = { - count: 'data-count', - } as const satisfies StateAttrMap; - `; - const program = createTypedTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBe('number'); - }); - - it('infers through type alias to expanded literals', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - type VolumeLevel = 'off' | 'low'; - interface MockComponentState { - level: VolumeLevel; - } - - export const MockComponentDataAttrs = { - level: 'data-level', - } as const satisfies StateAttrMap; - `; - const program = createTypedTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBe("'off' | 'low'"); - }); - - it('JSDoc @type overrides inferred type', () => { - const code = ` - type StateAttrMap = { [Key in keyof State]?: string }; - interface MockComponentState { - level: 'low' | 'medium' | 'high'; - } - - export const MockComponentDataAttrs = { - /** - * The volume level. - * @type {'quiet' | 'loud'} - */ - level: 'data-level', - } as const satisfies StateAttrMap; - `; - const program = createTypedTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBe("'quiet' | 'loud'"); - }); - - it('no satisfies expression produces no inferred type', () => { - const code = ` - export const MockComponentDataAttrs = { - active: 'data-active', - } as const; - `; - const program = createTypedTestProgram(code); - const result = extractDataAttrs('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.attrs[0]!.type).toBeUndefined(); - }); -}); diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts new file mode 100644 index 00000000..9e177c66 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -0,0 +1,631 @@ +/** + * ┌─────────────────────────────────────────────────────────────────────────────┐ + * │ API DOCS BUILDER — END-TO-END SPEC │ + * │ │ + * │ This file IS the specification for the API docs builder pipeline. │ + * │ It exercises every pattern the builder must handle, using a mock │ + * │ monorepo under fixtures/monorepo/. If you're an agent trying to │ + * │ understand how the builder works: read this file. The fixtures are │ + * │ the inputs, the expected JSON objects are the outputs. │ + * │ │ + * │ The builder is a black box: given TypeScript source files following │ + * │ specific conventions, it produces JSON reference objects. These tests │ + * │ verify the contract between input conventions and output shape. │ + * └─────────────────────────────────────────────────────────────────────────────┘ + * + * FIXTURE LAYOUT (under fixtures/monorepo/): + * + * Components (packages/core/src/core/ui/): + * toggle-button/ — Single-part component. Exercises: props, state, data-attrs, + * CSS vars, defaultProps, HTML element, type abbreviation, + * @ignore skipping, ref auto-skip, function-typed props. + * gauge/ — Multi-part component. Exercises: primary part detection via + * Core instantiation, sub-parts with/without HTML elements, + * React-only parts (no platforms.html), sub-part data-attr + * inheritance (stateAttrMap heuristic), non-boolean type + * inference (number, string literal union via type alias). + * slider/ — Base multi-part component. Exercises: base component whose + * parts are re-exported by domain variants. + * volume-slider/ — Domain variant. Exercises: re-exported parts from slider, + * origin-based element + data-attr resolution, re-exported + * parts are never primary, always multi-part (no fallback). + * + * Utils (already existing fixtures for hooks, controllers, selectors, etc.): + * Exercises: hook discovery, controller discovery, @public context, + * create* factory, mixin display name stripping, selector discovery, + * @label overloads, slug collision (react vs html create-player), + * framework assignment. + */ +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { generateComponentReferences } from '../pipeline'; +import { getUtilEntries, type UtilEntry } from '../util-handler'; + +const FIXTURE_ROOT = path.resolve(import.meta.dirname, 'fixtures/monorepo'); + +// ═══════════════════════════════════════════════════════════════════════ +// COMPONENT PIPELINE +// ═══════════════════════════════════════════════════════════════════════ + +describe('Component pipeline (end-to-end)', () => { + // Run the full pipeline once and reuse results across tests. + const results = generateComponentReferences(FIXTURE_ROOT); + + function findComponent(name: string) { + return results.find((r) => r.name === name); + } + + // ───────────────────────────────────────────────────────────────── + // SINGLE-PART COMPONENT: ToggleButton + // ───────────────────────────────────────────────────────────────── + // + // A single-part component is the simplest case. The builder merges + // data from three source files into one flat reference object: + // - Core file → Props interface, State interface, defaultProps + // - Data-attrs file → data attribute names + JSDoc descriptions + // - CSS-vars file → CSS custom property names + descriptions + // - HTML element file → tagName for platforms.html + // + // Key behaviors tested: + // - Props with `@ignore` JSDoc are excluded from output + // - Props named `ref` are auto-excluded (React internal) + // - Function-typed props get abbreviated ("function") with detailedType + // - Union props with function members get "type | function" abbreviation + // - defaultProps values are merged as string representations + // - Boolean data-attrs have NO type field (presence/absence convention) + // - CSS custom properties appear in cssCustomProperties + // - platforms.html is present when an HTML element file exists + // - No `parts` field on single-part components + + describe('ToggleButton (single-part)', () => { + it('produces the expected JSON reference', () => { + const toggle = findComponent('ToggleButton'); + expect(toggle).toBeDefined(); + + const ref = toggle!.reference; + + // Top-level shape + expect(ref.name).toBe('ToggleButton'); + expect(ref.parts).toBeUndefined(); + + // ── Props ── + // `ref` prop is auto-skipped. `_internalFlag` has @ignore and is skipped. + // What remains: disabled, label, onPressedChange. + expect(Object.keys(ref.props)).toEqual(expect.arrayContaining(['disabled', 'label', 'onPressedChange'])); + expect(ref.props['ref' as keyof typeof ref.props]).toBeUndefined(); + expect(ref.props['_internalFlag' as keyof typeof ref.props]).toBeUndefined(); + + // disabled: simple boolean, has defaultProps value. + // Props that are non-optional in the interface have required: true, + // even when they have a runtime default (defaultProps is separate from optionality). + expect(ref.props.disabled).toEqual({ + type: 'boolean', + description: 'Whether the button is disabled.', + default: 'false', + required: true, + }); + + // label: union with function → abbreviated to "string | function" + // defaultProps '' → "''" + expect(ref.props.label).toMatchObject({ + type: 'string | function', + description: 'Custom label for the button.', + default: "''", + }); + // detailedType shows the full function signature + expect(ref.props.label!.detailedType).toBeDefined(); + expect(ref.props.label!.detailedType).toContain('=>'); + + // onPressedChange: pure function → abbreviated to "function" + expect(ref.props.onPressedChange).toMatchObject({ + type: 'function', + description: 'Callback when pressed state changes.', + }); + expect(ref.props.onPressedChange!.detailedType).toBeDefined(); + + // ── State ── + expect(ref.state.pressed).toEqual({ + type: 'boolean', + description: 'Whether the toggle is pressed.', + }); + expect(ref.state.disabled).toEqual({ + type: 'boolean', + description: 'Whether the button is disabled.', + }); + + // ── Data attributes ── + // Boolean state types → type field is OMITTED (presence/absence convention). + expect(ref.dataAttributes['data-pressed']).toEqual({ + description: 'Present when the toggle is pressed.', + }); + expect(ref.dataAttributes['data-disabled']).toEqual({ + description: 'Present when the button is disabled.', + }); + + // ── CSS custom properties ── + expect(ref.cssCustomProperties['--media-toggle-pressed-bg']).toEqual({ + description: 'Background color when pressed.', + }); + expect(ref.cssCustomProperties['--media-toggle-transition']).toEqual({ + description: 'Transition duration for the toggle animation.', + }); + + // ── Platforms ── + // HTML element exists → platforms.html with tagName + expect(ref.platforms.html).toEqual({ tagName: 'media-toggle-button' }); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // MULTI-PART COMPONENT: Gauge + // ───────────────────────────────────────────────────────────────── + // + // A multi-part component is detected when `index.parts.ts` exists + // in the React package. The top-level reference has EMPTY props, + // state, dataAttributes, cssCustomProperties, and platforms. All + // meaningful data lives in the `parts` record. + // + // Parts are discovered from index.parts.ts exports: + // - PRIMARY PART: The part whose React source instantiates the + // component's own Core class (matches `new {Name}Core\b`). + // Gets: shared core Props/State, data-attrs, CSS vars, root tagName. + // - SUB-PARTS: All other parts. Get: their own tagName (if element + // file exists), description from React JSDoc, shared data-attrs + // (only if React source references `stateAttrMap`), and custom + // React props from `{LocalName}Props` interface. + // - REACT-ONLY PARTS: Sub-parts with no matching HTML element file. + // Get platforms.react but NOT platforms.html. + // + // Non-boolean data-attr types are inferred from StateAttrMap: + // - number → type: "number" + // - string literal union → type: "'empty' | 'partial' | 'full'" + // - type alias → expanded to literals (FillLevel → 'empty' | ...) + // - boolean → type field OMITTED + + describe('Gauge (multi-part)', () => { + it('has empty top-level and parts record', () => { + const gauge = findComponent('Gauge'); + expect(gauge).toBeDefined(); + + const ref = gauge!.reference; + + // Top-level is empty for multi-part components + expect(ref.props).toEqual({}); + expect(ref.state).toEqual({}); + expect(ref.dataAttributes).toEqual({}); + expect(ref.cssCustomProperties).toEqual({}); + expect(ref.platforms).toEqual({}); + + // Parts record exists + expect(ref.parts).toBeDefined(); + expect(Object.keys(ref.parts!)).toEqual(expect.arrayContaining(['indicator', 'track', 'fill', 'label'])); + }); + + it('primary part (Indicator) gets core props, state, data-attrs, CSS vars', () => { + const parts = findComponent('Gauge')!.reference.parts!; + const indicator = parts.indicator!; + + expect(indicator.name).toBe('Indicator'); + expect(indicator.description).toBe('A visual indicator for the current value. Renders a `` element.'); + + // Props from shared core (GaugeProps), with defaultProps merged + expect(indicator.props.min).toMatchObject({ type: 'number', default: '0' }); + expect(indicator.props.max).toMatchObject({ type: 'number', default: '100' }); + expect(indicator.props.label).toMatchObject({ + type: 'string | function', + default: "''", + }); + + // State from shared core (GaugeState) + expect(indicator.state.percentage).toMatchObject({ + type: 'number', + description: 'Current value as a percentage (0\u20131).', + }); + + // Data attributes with non-boolean type inference + expect(indicator.dataAttributes['data-percentage']).toMatchObject({ + description: 'Current percentage as a string.', + type: 'number', + }); + expect(indicator.dataAttributes['data-fill-level']).toMatchObject({ + description: 'The fill level.', + }); + // FillLevel type alias → expanded to string literal union + const fillType = indicator.dataAttributes['data-fill-level']!.type; + expect(fillType).toBeDefined(); + expect(fillType).toContain("'empty'"); + expect(fillType).toContain("'partial'"); + expect(fillType).toContain("'full'"); + + // CSS vars from shared css-vars file + expect(indicator.cssCustomProperties['--media-gauge-fill']).toEqual({ + description: 'The fill color of the gauge.', + }); + + // Platforms: both html and react + expect(indicator.platforms.html).toEqual({ tagName: 'media-gauge' }); + expect(indicator.platforms.react).toEqual({}); + }); + + it('sub-part (Track) gets its own tagName, empty props/state', () => { + const track = findComponent('Gauge')!.reference.parts!.track!; + + expect(track.name).toBe('Track'); + expect(track.description).toBe('The track area of the gauge. Renders a `
` element.'); + expect(track.props).toEqual({}); + expect(track.state).toEqual({}); + expect(track.dataAttributes).toEqual({}); + expect(track.cssCustomProperties).toEqual({}); + + // Has both HTML and React platforms + expect(track.platforms.html).toEqual({ tagName: 'media-gauge-track' }); + expect(track.platforms.react).toEqual({}); + }); + + it('sub-part (Fill) inherits shared data-attrs via stateAttrMap heuristic', () => { + const fill = findComponent('Gauge')!.reference.parts!.fill!; + + expect(fill.name).toBe('Fill'); + // Sub-part custom React props: extracted from `FillProps` interface. + // `children` is auto-excluded by the builder. + expect(fill.props.color).toMatchObject({ type: 'string' }); + expect(fill.props.children).toBeUndefined(); + expect(fill.state).toEqual({}); + + // Fill's React source references `stateAttrMap`, so it gets the + // component's shared data-attrs from gauge-data-attrs.ts + expect(Object.keys(fill.dataAttributes).length).toBeGreaterThan(0); + expect(fill.dataAttributes['data-percentage']).toBeDefined(); + expect(fill.dataAttributes['data-fill-level']).toBeDefined(); + + expect(fill.platforms.html).toEqual({ tagName: 'media-gauge-fill' }); + expect(fill.platforms.react).toEqual({}); + }); + + it('React-only sub-part (Label) has no platforms.html', () => { + const label = findComponent('Gauge')!.reference.parts!.label!; + + expect(label.name).toBe('Label'); + expect(label.description).toBe('An accessible label for the gauge value. Renders a `` element.'); + + // React-only: has platforms.react but NOT platforms.html + expect(label.platforms.react).toEqual({}); + expect(label.platforms.html).toBeUndefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // MULTI-PART WITH RE-EXPORTS: VolumeSlider + // ───────────────────────────────────────────────────────────────── + // + // Domain variant components like VolumeSlider re-export parts from + // a base component (Slider). The builder resolves re-exports: + // - Parses the origin's index.parts.ts to find the original export + // - Derives element file paths from the ORIGIN component + // - Data-attrs come from the ORIGIN component's data-attrs file + // - Re-exported parts are NEVER primary + // - Components with re-exported parts always produce multi-part + // output (no single-part fallback, even if only 1 local export) + + describe('VolumeSlider (multi-part with re-exports)', () => { + it('has empty top-level and parts from both local and re-exported sources', () => { + const vs = findComponent('VolumeSlider'); + expect(vs).toBeDefined(); + + const ref = vs!.reference; + expect(ref.props).toEqual({}); + expect(ref.state).toEqual({}); + expect(ref.parts).toBeDefined(); + + // Root is local, Thumb and Track are re-exported from slider + expect(ref.parts!.root).toBeDefined(); + expect(ref.parts!.thumb).toBeDefined(); + expect(ref.parts!.track).toBeDefined(); + }); + + it('local primary part (Root) gets VolumeSlider core data', () => { + const root = findComponent('VolumeSlider')!.reference.parts!.root!; + + expect(root.name).toBe('Root'); + // Props come from VolumeSliderProps + expect(root.props.orientation).toBeDefined(); + // State comes from VolumeSliderState + expect(root.state.volume).toBeDefined(); + // HTML tag comes from volume-slider-element.ts + expect(root.platforms.html).toEqual({ tagName: 'media-volume-slider' }); + expect(root.platforms.react).toEqual({}); + }); + + it('re-exported sub-part (Thumb) resolves from slider origin', () => { + const thumb = findComponent('VolumeSlider')!.reference.parts!.thumb!; + + expect(thumb.name).toBe('Thumb'); + // HTML tag comes from SLIDER's element file (slider-thumb-element.ts), + // not volume-slider's directory + expect(thumb.platforms.html).toEqual({ tagName: 'media-slider-thumb' }); + expect(thumb.platforms.react).toEqual({}); + + // Data-attrs come from SLIDER's data-attrs file because the origin + // React source (slider-thumb.tsx) references stateAttrMap + expect(Object.keys(thumb.dataAttributes).length).toBeGreaterThan(0); + expect(thumb.dataAttributes['data-value']).toBeDefined(); + expect(thumb.dataAttributes['data-dragging']).toBeDefined(); + }); + + it('re-exported sub-part (Track) with no stateAttrMap gets empty data-attrs', () => { + const track = findComponent('VolumeSlider')!.reference.parts!.track!; + + expect(track.name).toBe('Track'); + // slider-track.tsx does NOT reference stateAttrMap, so no data-attrs + expect(track.dataAttributes).toEqual({}); + // HTML tag from slider's track element + expect(track.platforms.html).toEqual({ tagName: 'media-slider-track' }); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // BASE COMPONENT: Slider + // ───────────────────────────────────────────────────────────────── + // + // The slider base is also discovered as its own component. + // It has index.parts.ts with 3 local parts (Root, Thumb, Track). + // This tests that the base component is independently valid. + + describe('Slider (base multi-part)', () => { + it('is discovered and has parts', () => { + const slider = findComponent('Slider'); + expect(slider).toBeDefined(); + + const ref = slider!.reference; + expect(ref.parts).toBeDefined(); + + // Root is primary (instantiates SliderCore) + expect(ref.parts!.root).toBeDefined(); + expect(ref.parts!.root!.props.min).toBeDefined(); + expect(ref.parts!.root!.props.max).toBeDefined(); + expect(ref.parts!.root!.state.value).toBeDefined(); + expect(ref.parts!.root!.state.dragging).toBeDefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // CROSS-CUTTING CONVENTIONS + // ───────────────────────────────────────────────────────────────── + + describe('Cross-cutting conventions', () => { + it('all components are discovered from core/ui directories', () => { + const names = results.map((r) => r.name).sort(); + expect(names).toEqual(expect.arrayContaining(['Gauge', 'PiPButton', 'Slider', 'ToggleButton', 'VolumeSlider'])); + }); + + it('kebab name matches directory name', () => { + expect(findComponent('ToggleButton')!.kebab).toBe('toggle-button'); + expect(findComponent('Gauge')!.kebab).toBe('gauge'); + expect(findComponent('Slider')!.kebab).toBe('slider'); + expect(findComponent('VolumeSlider')!.kebab).toBe('volume-slider'); + }); + + it('NAME_OVERRIDES: pip-button → PiPButton (not PipButton)', () => { + // The NAME_OVERRIDES map handles cases where standard kebab-to-PascalCase + // conversion is wrong. "pip-button" would normally become "PipButton", + // but the override maps it to "PiPButton". + const pip = findComponent('PiPButton'); + expect(pip).toBeDefined(); + expect(pip!.kebab).toBe('pip-button'); + expect(pip!.reference.name).toBe('PiPButton'); + // Props use the overridden name for interface lookup (PiPButtonProps) + expect(pip!.reference.props.disabled).toBeDefined(); + expect(pip!.reference.state.active).toBeDefined(); + }); + + it('primary part appears first in parts record (sorted by isPrimary)', () => { + const gaugeParts = Object.keys(findComponent('Gauge')!.reference.parts!); + expect(gaugeParts[0]).toBe('indicator'); + + const vsParts = Object.keys(findComponent('VolumeSlider')!.reference.parts!); + expect(vsParts[0]).toBe('root'); + }); + + it('props are sorted: required first, then alphabetical', () => { + // All ToggleButton props are required (non-optional in the interface), + // so they should be purely alphabetical within the required group. + const toggleProps = Object.keys(findComponent('ToggleButton')!.reference.props); + const sorted = [...toggleProps].sort((a, b) => a.localeCompare(b)); + expect(toggleProps).toEqual(sorted); + }); + + it('optional fields are omitted from JSON when undefined', () => { + const ref = findComponent('ToggleButton')!.reference; + + // disabled has no detailedType (simple boolean, no abbreviation) + expect('detailedType' in ref.props.disabled!).toBe(false); + + // Boolean data-attrs have no type field + expect('type' in ref.dataAttributes['data-pressed']!).toBe(false); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// UTIL PIPELINE +// ═══════════════════════════════════════════════════════════════════════ +// +// The util pipeline scans fixed entry points for exports matching +// naming conventions (use*, select*, create*, *Controller) or @public +// JSDoc. Each export produces a UtilReference JSON with overloads. +// +// Key behaviors: +// - Hooks (use*): discovered from React entry points, framework: "react" +// - Controllers (*Controller): discovered from HTML entry points, framework: "html" +// - Selectors (select*): framework-agnostic (null) +// - Factories (create*): framework depends on entry point +// - @public exports: explicit inclusion regardless of naming +// - create*Mixin: display name strips "create" prefix +// - Slug collisions: React keeps bare slug, HTML gets prefixed with "html-" +// - Multi-overload functions: each overload is preserved in the overloads array +// - @label JSDoc: becomes the overload's label field +// - Controller params: "- " prefix stripped from @param descriptions + +describe('Util pipeline (end-to-end)', () => { + const entries = getUtilEntries(FIXTURE_ROOT); + + function findByName(name: string, framework?: 'react' | 'html' | null): UtilEntry | undefined { + return entries.find((e) => e.data.name === name && (framework === undefined || e.framework === framework)); + } + + // ───────────────────────────────────────────────────────────────── + // DISCOVERY & FRAMEWORK ASSIGNMENT + // ───────────────────────────────────────────────────────────────── + // + // Exports are discovered by scanning entry point files and their + // local re-exports. The framework is determined by which entry + // point the export was found in. + + describe('Discovery', () => { + it('discovers hooks from React entry points', () => { + expect(findByName('usePlayer', 'react')).toBeDefined(); + expect(findByName('useStore', 'react')).toBeDefined(); + expect(findByName('useFormat', 'react')).toBeDefined(); + }); + + it('discovers controllers from HTML entry points', () => { + expect(findByName('PlayerController', 'html')).toBeDefined(); + expect(findByName('SnapshotController', 'html')).toBeDefined(); + }); + + it('discovers selectors as framework-agnostic (null)', () => { + for (const name of ['selectPlayback', 'selectVolume', 'selectTime']) { + const entry = findByName(name, null); + expect(entry, `expected ${name} to be framework-agnostic`).toBeDefined(); + expect(entry!.framework).toBeNull(); + } + }); + + it('discovers @public exports (mergeProps, playerContext)', () => { + expect(findByName('mergeProps', 'react')).toBeDefined(); + expect(findByName('playerContext', 'html')).toBeDefined(); + }); + + it('discovers factories from both React and HTML', () => { + expect(findByName('createPlayer', 'react')).toBeDefined(); + expect(findByName('createPlayer', 'html')).toBeDefined(); + expect(findByName('createSelector', null)).toBeDefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // DISPLAY NAME & SLUG + // ───────────────────────────────────────────────────────────────── + // + // Display names are the export name as-is, EXCEPT create*Mixin + // factories which strip the "create" prefix. + // Slugs are kebab-case of the display name. On collision, React + // keeps the bare slug and HTML gets "html-" prefixed. + + describe('Display name & slug', () => { + it('strips "create" prefix from mixin display names', () => { + const mixin = findByName('ContainerMixin', 'html'); + expect(mixin).toBeDefined(); + expect(mixin!.slug).toBe('container-mixin'); + }); + + it('resolves slug collisions: React bare, HTML prefixed', () => { + const reactCreate = entries.find((e) => e.slug === 'create-player' && e.framework === 'react'); + const htmlCreate = entries.find((e) => e.slug === 'html-create-player' && e.framework === 'html'); + + expect(reactCreate).toBeDefined(); + expect(htmlCreate).toBeDefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // OVERLOADS + // ───────────────────────────────────────────────────────────────── + // + // When a function or constructor has multiple signatures, each + // becomes a separate entry in the overloads array. + // @label JSDoc tags on overload signatures become the label field. + + describe('Overloads', () => { + it('preserves multiple overload signatures', () => { + const usePlayer = findByName('usePlayer', 'react'); + expect(usePlayer!.data.overloads.length).toBe(2); + + const useStore = findByName('useStore', 'react'); + expect(useStore!.data.overloads.length).toBe(2); + }); + + it('extracts @label from overload JSDoc', () => { + const useFormat = findByName('useFormat', 'react'); + expect(useFormat!.data.overloads[0]!.label).toBe('Number'); + expect(useFormat!.data.overloads[1]!.label).toBe('String'); + }); + + it('omits label when @label is absent', () => { + const useStore = findByName('useStore', 'react'); + expect(useStore!.data.overloads[0]!.label).toBeUndefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // EXTRACTION SHAPE + // ───────────────────────────────────────────────────────────────── + // + // Each util reference has: name, description?, overloads[]. + // Each overload has: label?, description?, parameters, returnValue. + // Parameters and returnValue follow the same PropDef/StateDef shape + // used by component references. + + describe('Extraction shape', () => { + it('hooks have description and overloads with parameters + returnValue', () => { + const usePlayer = findByName('usePlayer', 'react'); + expect(usePlayer!.data.description).toBeDefined(); + + const overload = usePlayer!.data.overloads[0]!; + expect(overload.returnValue).toBeDefined(); + expect(overload.returnValue.type).toBeDefined(); + }); + + it('controllers have constructor params and public members as returnValue.fields', () => { + const snapshot = findByName('SnapshotController', 'html'); + expect(snapshot!.data.description).toBeDefined(); + + const overload = snapshot!.data.overloads[0]!; + // Constructor parameters + expect(overload.parameters.host).toBeDefined(); + + // Return value type includes class name with type params + expect(overload.returnValue.type).toContain('SnapshotController'); + + // Public members as fields + expect(overload.returnValue.fields).toBeDefined(); + expect(overload.returnValue.fields!.value).toBeDefined(); + expect(overload.returnValue.fields!.track).toBeDefined(); + }); + + it('controller param descriptions have "- " prefix stripped', () => { + const snapshot = findByName('SnapshotController', 'html'); + const hostParam = snapshot!.data.overloads[0]!.parameters.host; + expect(hostParam!.description).toBe('The host element.'); + expect(hostParam!.description).not.toMatch(/^-\s/); + }); + + it('contexts (@public non-function) have empty parameters and type as returnValue', () => { + const ctx = findByName('playerContext', 'html'); + expect(ctx!.data.description).toBeDefined(); + + const overload = ctx!.data.overloads[0]!; + expect(overload.parameters).toEqual({}); + expect(overload.returnValue.type).toBeDefined(); + }); + + it('selectors have parameters and returnValue', () => { + const sel = findByName('selectPlayback', null); + expect(sel!.data.description).toBeDefined(); + + const overload = sel!.data.overloads[0]!; + expect(Object.keys(overload.parameters).length).toBeGreaterThan(0); + expect(overload.returnValue.type).toBeDefined(); + }); + }); +}); diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-core.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-core.ts new file mode 100644 index 00000000..97f35603 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-core.ts @@ -0,0 +1,32 @@ +/** + * Multi-part component fixture (core). + * + * Exercises: multi-part Props/State extraction, defaultProps merging, + * non-boolean data-attr type inference (string union, number). + */ + +export type FillLevel = 'empty' | 'partial' | 'full'; + +export interface GaugeProps { + /** Minimum value. */ + min: number; + /** Maximum value. */ + max: number; + /** Custom label for accessibility. */ + label: string | ((state: GaugeState) => string); +} + +export interface GaugeState { + /** Current value as a percentage (0–1). */ + percentage: number; + /** The fill level. */ + fillLevel: FillLevel; +} + +export class GaugeCore { + static readonly defaultProps = { + min: 0, + max: 100, + label: '', + }; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-css-vars.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-css-vars.ts new file mode 100644 index 00000000..e8a20941 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-css-vars.ts @@ -0,0 +1,10 @@ +/** + * CSS vars fixture for multi-part component. + * + * Exercises: CSS custom properties on a multi-part component (assigned to primary part). + */ + +export const GaugeCSSVars = { + /** The fill color of the gauge. */ + fill: '--media-gauge-fill', +} as const; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-data-attrs.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-data-attrs.ts new file mode 100644 index 00000000..73838702 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-data-attrs.ts @@ -0,0 +1,23 @@ +/** + * Data attributes fixture for multi-part component. + * + * Exercises: non-boolean type inference through satisfies StateAttrMap. + * - percentage → number type (shown in output) + * - fillLevel → string literal union (shown in output, expanded from FillLevel alias) + */ + +type StateAttrMap = { [Key in keyof State]?: string }; + +type FillLevel = 'empty' | 'partial' | 'full'; + +interface GaugeState { + percentage: number; + fillLevel: FillLevel; +} + +export const GaugeDataAttrs = { + /** Current percentage as a string. */ + percentage: 'data-percentage', + /** The fill level. */ + fillLevel: 'data-fill-level', +} as const satisfies StateAttrMap; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/pip-button/pip-button-core.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/pip-button/pip-button-core.ts new file mode 100644 index 00000000..a0eb01bd --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/pip-button/pip-button-core.ts @@ -0,0 +1,22 @@ +/** + * NAME_OVERRIDES fixture. + * + * Exercises: The NAME_OVERRIDES map in pipeline.ts. The directory name is + * "pip-button", which kebabToPascal would convert to "PipButton". But the + * override maps it to "PiPButton" (capital P at position 2). + * + * This covers cases where standard kebab-to-PascalCase conversion produces + * the wrong name. The builder uses NAME_OVERRIDES[dirName] ?? kebabToPascal(dirName). + */ + +export interface PiPButtonProps { + /** Whether the button is disabled. */ + disabled: boolean; +} + +export interface PiPButtonState { + /** Whether picture-in-picture is active. */ + active: boolean; +} + +export class PiPButtonCore {} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-core.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-core.ts new file mode 100644 index 00000000..b885bcbe --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-core.ts @@ -0,0 +1,27 @@ +/** + * Base slider component (core). + * + * Exercises: base component whose parts get re-exported by domain variants + * (volume-slider). This component is also discovered on its own. + */ + +export interface SliderProps { + /** Minimum slider value. */ + min: number; + /** Maximum slider value. */ + max: number; +} + +export interface SliderState { + /** Current slider value (0–1). */ + value: number; + /** Whether the user is dragging. */ + dragging: boolean; +} + +export class SliderCore { + static readonly defaultProps = { + min: 0, + max: 100, + }; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-data-attrs.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-data-attrs.ts new file mode 100644 index 00000000..e86571f4 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/slider/slider-data-attrs.ts @@ -0,0 +1,19 @@ +/** + * Data attributes for slider base (used by re-exported sub-parts). + * + * Exercises: boolean type (omitted) + non-boolean type for re-exported parts. + */ + +type StateAttrMap = { [Key in keyof State]?: string }; + +interface SliderState { + value: number; + dragging: boolean; +} + +export const SliderDataAttrs = { + /** The current slider value. */ + value: 'data-value', + /** Present when the user is dragging the slider. */ + dragging: 'data-dragging', +} as const satisfies StateAttrMap; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-core.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-core.ts new file mode 100644 index 00000000..35a2e2f4 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-core.ts @@ -0,0 +1,34 @@ +/** + * Single-part component fixture. + * + * Exercises: Props interface, State interface, defaultProps, function-typed prop + * (triggers type abbreviation), @ignore JSDoc (skipped prop), ref prop (auto-skipped), + * required prop (no default, not optional). + */ + +export interface ToggleButtonProps { + /** Whether the button is disabled. */ + disabled: boolean; + /** Custom label for the button. */ + label: string | ((state: ToggleButtonState) => string); + /** @ignore Internal ref — should be excluded from output. */ + _internalFlag: boolean; + /** React ref — auto-skipped by the builder. */ + ref: unknown; + /** Callback when pressed state changes. */ + onPressedChange: (pressed: boolean) => void; +} + +export interface ToggleButtonState { + /** Whether the toggle is pressed. */ + pressed: boolean; + /** Whether the button is disabled. */ + disabled: boolean; +} + +export class ToggleButtonCore { + static readonly defaultProps = { + disabled: false, + label: '', + }; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-css-vars.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-css-vars.ts new file mode 100644 index 00000000..91811fac --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-css-vars.ts @@ -0,0 +1,12 @@ +/** + * CSS vars fixture for single-part component. + * + * Exercises: CSS custom property extraction with JSDoc descriptions. + */ + +export const ToggleButtonCSSVars = { + /** Background color when pressed. */ + pressed: '--media-toggle-pressed-bg', + /** Transition duration for the toggle animation. */ + transition: '--media-toggle-transition', +} as const; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-data-attrs.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-data-attrs.ts new file mode 100644 index 00000000..4987990a --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/toggle-button/toggle-button-data-attrs.ts @@ -0,0 +1,19 @@ +/** + * Data attributes fixture for single-part component. + * + * Exercises: boolean type inference (omitted), satisfies StateAttrMap pattern. + */ + +type StateAttrMap = { [Key in keyof State]?: string }; + +interface ToggleButtonState { + pressed: boolean; + disabled: boolean; +} + +export const ToggleButtonDataAttrs = { + /** Present when the toggle is pressed. */ + pressed: 'data-pressed', + /** Present when the button is disabled. */ + disabled: 'data-disabled', +} as const satisfies StateAttrMap; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/volume-slider-core.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/volume-slider-core.ts new file mode 100644 index 00000000..88f146c0 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -0,0 +1,23 @@ +/** + * Domain variant component (core). + * + * Exercises: domain variant components that share base logic (slider/) + * but have their own directory under core/ui/. The builder discovers + * components by directory — this file must exist for volume-slider to be found. + */ + +export interface VolumeSliderProps { + /** The orientation of the slider. */ + orientation: 'horizontal' | 'vertical'; +} + +export interface VolumeSliderState { + /** Current volume (0–1). */ + volume: number; +} + +export class VolumeSliderCore { + static readonly defaultProps = { + orientation: 'horizontal', + }; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-element.ts new file mode 100644 index 00000000..dab1bb99 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-element.ts @@ -0,0 +1,9 @@ +/** + * HTML element fixture for multi-part primary part. + * + * Exercises: primary part gets the root element's tagName. + */ + +export class GaugeElement { + static readonly tagName = 'media-gauge'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-fill-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-fill-element.ts new file mode 100644 index 00000000..0bd180fe --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-fill-element.ts @@ -0,0 +1,7 @@ +/** + * HTML element fixture for multi-part sub-part. + */ + +export class GaugeFillElement { + static readonly tagName = 'media-gauge-fill'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-track-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-track-element.ts new file mode 100644 index 00000000..5f8965e6 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/gauge/gauge-track-element.ts @@ -0,0 +1,9 @@ +/** + * HTML element fixture for multi-part sub-part. + * + * Exercises: sub-part element file naming convention ({component}-{part}-element.ts). + */ + +export class GaugeTrackElement { + static readonly tagName = 'media-gauge-track'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-element.ts new file mode 100644 index 00000000..b493137a --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-element.ts @@ -0,0 +1,3 @@ +export class SliderElement { + static readonly tagName = 'media-slider'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-thumb-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-thumb-element.ts new file mode 100644 index 00000000..de489948 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-thumb-element.ts @@ -0,0 +1,3 @@ +export class SliderThumbElement { + static readonly tagName = 'media-slider-thumb'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-track-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-track-element.ts new file mode 100644 index 00000000..f9e1cebf --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/slider/slider-track-element.ts @@ -0,0 +1,3 @@ +export class SliderTrackElement { + static readonly tagName = 'media-slider-track'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/toggle-button/toggle-button-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/toggle-button/toggle-button-element.ts new file mode 100644 index 00000000..82deb557 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/toggle-button/toggle-button-element.ts @@ -0,0 +1,9 @@ +/** + * HTML element fixture for single-part component. + * + * Exercises: static tagName extraction for platforms.html. + */ + +export class ToggleButtonElement { + static readonly tagName = 'media-toggle-button'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/volume-slider/volume-slider-element.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/volume-slider/volume-slider-element.ts new file mode 100644 index 00000000..e9ad61ca --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/ui/volume-slider/volume-slider-element.ts @@ -0,0 +1,3 @@ +export class VolumeSliderElement { + static readonly tagName = 'media-volume-slider'; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-fill.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-fill.tsx new file mode 100644 index 00000000..67de6c12 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-fill.tsx @@ -0,0 +1,28 @@ +/** + * Sub-part React component that references stateAttrMap. + * + * Exercises: + * 1. Sub-part inheriting shared data-attrs from the component's data-attrs + * file. The builder uses a string search heuristic — if the React source + * contains "stateAttrMap", the sub-part gets shared data attributes. + * 2. Sub-part custom React props. The builder extracts own members from the + * `{LocalName}Props` interface (must be `interface`, not `type`). + * `children` and React DOM attributes are excluded. + */ + +import type { GaugeDataAttrs } from '../../../../core/src/core/ui/gauge/gauge-data-attrs'; + +const stateAttrMap = {} as typeof GaugeDataAttrs; + +/** The filled portion of the gauge. Renders a `
` element. */ +export function Fill() { + return null; +} + +// Must be `interface` (not `type`) for extractSubPartProps to detect it. +// `children` is auto-excluded by the builder. +export interface FillProps { + /** The color of the fill bar. */ + color: string; + children: unknown; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-indicator.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-indicator.tsx new file mode 100644 index 00000000..26ef4caf --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-indicator.tsx @@ -0,0 +1,16 @@ +/** + * Primary part React component. + * + * Exercises: primary part detection via `new GaugeCore` instantiation. + * The builder checks React source files for `new {ComponentName}Core\b`. + */ + +class GaugeCore {} + +/** A visual indicator for the current value. Renders a `` element. */ +export function Indicator() { + const core = new GaugeCore(); + return null; +} + +export type IndicatorProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-label.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-label.tsx new file mode 100644 index 00000000..2a8c3449 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-label.tsx @@ -0,0 +1,14 @@ +/** + * React-only sub-part (no HTML element counterpart). + * + * Exercises: framework-divergent parts. Parts discovered from index.parts.ts + * always get platforms.react. Parts WITHOUT a matching HTML element file do NOT + * get platforms.html. This part has no gauge-label-element.ts in the HTML dir. + */ + +/** An accessible label for the gauge value. Renders a `` element. */ +export function Label() { + return null; +} + +export type LabelProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-track.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-track.tsx new file mode 100644 index 00000000..473fe170 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-track.tsx @@ -0,0 +1,13 @@ +/** + * Sub-part React component with no special behavior. + * + * Exercises: sub-part that has an HTML element counterpart but no data-attrs reference. + * Gets empty props, state, dataAttributes, cssCustomProperties. + */ + +/** The track area of the gauge. Renders a `
` element. */ +export function Track() { + return null; +} + +export type TrackProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/index.parts.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/index.parts.ts new file mode 100644 index 00000000..cb99b035 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/index.parts.ts @@ -0,0 +1,14 @@ +/** + * React parts index for multi-part component. + * + * Exercises: multi-part detection, local exports for part discovery. + * - Indicator: primary part (instantiates GaugeCore) + * - Track: sub-part with HTML element + * - Fill: sub-part with HTML element and stateAttrMap reference (gets shared data-attrs) + * - Label: React-only part (no HTML element file) + */ + +export { Fill, type FillProps } from './gauge-fill'; +export { Indicator, type IndicatorProps } from './gauge-indicator'; +export { Label, type LabelProps } from './gauge-label'; +export { Track, type TrackProps } from './gauge-track'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/index.parts.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/index.parts.ts new file mode 100644 index 00000000..9aabc6f8 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/index.parts.ts @@ -0,0 +1,8 @@ +/** + * Slider base parts index. + * + * All local exports. volume-slider re-exports Thumb and Track from here. + */ +export { Root, type RootProps } from './slider-root'; +export { Thumb, type ThumbProps } from './slider-thumb'; +export { Track, type TrackProps } from './slider-track'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-root.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-root.tsx new file mode 100644 index 00000000..16fa2327 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-root.tsx @@ -0,0 +1,13 @@ +/** + * Primary part of slider — instantiates SliderCore. + */ + +class SliderCore {} + +/** The root slider container. Renders a `
` element. */ +export function Root() { + const core = new SliderCore(); + return null; +} + +export type RootProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-thumb.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-thumb.tsx new file mode 100644 index 00000000..cda61753 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-thumb.tsx @@ -0,0 +1,15 @@ +/** + * Slider sub-part that references stateAttrMap. + * + * When volume-slider re-exports this part, data-attrs come from + * the ORIGIN component (slider), not the consuming component (volume-slider). + */ + +const stateAttrMap = {}; + +/** The draggable thumb of the slider. Renders a `
` element. */ +export function Thumb() { + return null; +} + +export type ThumbProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-track.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-track.tsx new file mode 100644 index 00000000..0625d9e3 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/slider/slider-track.tsx @@ -0,0 +1,6 @@ +/** The track area of the slider. Renders a `
` element. */ +export function Track() { + return null; +} + +export type TrackProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/index.parts.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/index.parts.ts new file mode 100644 index 00000000..73b09aaf --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/index.parts.ts @@ -0,0 +1,17 @@ +/** + * Volume slider parts index — re-exports from slider base. + * + * Exercises: re-exported parts from another component. + * - Root: local export (primary part, instantiates VolumeSliderCore) + * - Thumb: re-exported from slider (gets slider's HTML elements + data-attrs) + * - Track: re-exported from slider (gets slider's HTML elements) + * + * Re-exported parts are NEVER primary. Their element files and data-attrs + * are resolved from the ORIGIN component (slider), not the consumer (volume-slider). + * + * Because there are re-exported parts, this always produces multi-part output + * (no single-part fallback). + */ + +export { Thumb, type ThumbProps, Track, type TrackProps } from '../slider/index.parts'; +export { Root, type RootProps } from './volume-slider-root'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/volume-slider-root.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/volume-slider-root.tsx new file mode 100644 index 00000000..b6cae4a7 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/volume-slider/volume-slider-root.tsx @@ -0,0 +1,13 @@ +/** + * Primary part of volume-slider — instantiates VolumeSliderCore. + */ + +class VolumeSliderCore {} + +/** The root volume slider container. Renders a `
` element. */ +export function Root() { + const core = new VolumeSliderCore(); + return null; +} + +export type RootProps = {}; diff --git a/site/scripts/api-docs-builder/src/tests/html-handler.test.ts b/site/scripts/api-docs-builder/src/tests/html-handler.test.ts deleted file mode 100644 index d2f163ae..00000000 --- a/site/scripts/api-docs-builder/src/tests/html-handler.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractHtml } from '../html-handler.js'; -import { createTestProgram } from './test-utils.js'; - -describe('extractHtml', () => { - it('extracts tagName from {Name}Element class', () => { - const code = ` - export class MockComponentElement { - static readonly tagName = 'media-mock-component'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.tagName).toBe('media-mock-component'); - }); - - it('extracts tagName without readonly modifier', () => { - const code = ` - export class MockComponentElement { - static tagName = 'media-mock-component'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'MockComponent'); - - expect(result).not.toBeNull(); - expect(result!.tagName).toBe('media-mock-component'); - }); - - it('returns null when Element class not found', () => { - const code = ` - export class OtherClass { - static readonly tagName = 'media-other'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('returns null when tagName not static', () => { - const code = ` - export class MockComponentElement { - readonly tagName = 'media-mock-component'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('returns null when tagName is not a string literal', () => { - const code = ` - const TAG = 'media-mock-component'; - export class MockComponentElement { - static readonly tagName = TAG; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('returns null when no tagName property exists', () => { - const code = ` - export class MockComponentElement { - static readonly otherProperty = 'value'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'MockComponent'); - - expect(result).toBeNull(); - }); - - it('extracts tagName using custom elementName override', () => { - const code = ` - export class TimeGroupElement { - static readonly tagName = 'media-time-group'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'Time', 'TimeGroupElement'); - - expect(result).not.toBeNull(); - expect(result!.tagName).toBe('media-time-group'); - }); - - it('returns null when elementName override does not match', () => { - const code = ` - export class TimeGroupElement { - static readonly tagName = 'media-time-group'; - } - `; - const program = createTestProgram(code); - const result = extractHtml('test.ts', program, 'Time', 'TimeSeparatorElement'); - - expect(result).toBeNull(); - }); -}); diff --git a/site/scripts/api-docs-builder/src/tests/parts-handler.test.ts b/site/scripts/api-docs-builder/src/tests/parts-handler.test.ts deleted file mode 100644 index d0897d90..00000000 --- a/site/scripts/api-docs-builder/src/tests/parts-handler.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import * as tae from 'typescript-api-extractor'; -import { describe, expect, it, type MockInstance, vi } from 'vitest'; -import { extractPartDescription, extractParts } from '../parts-handler.js'; -import { createTestProgram } from './test-utils.js'; - -vi.mock('typescript-api-extractor', async () => { - const actual = await vi.importActual('typescript-api-extractor'); - return { - ...actual, - parseFromProgram: vi.fn(), - }; -}); - -const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance; - -describe('extractParts', () => { - it('extracts value exports from index.parts.ts', () => { - const code = ` - export { Group, type GroupProps } from './time-group'; - export { Separator, type SeparatorProps } from './time-separator'; - export { Value, type ValueProps } from './time-value'; - `; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([ - { name: 'Group', localName: 'Group', source: './time-group' }, - { name: 'Separator', localName: 'Separator', source: './time-separator' }, - { name: 'Value', localName: 'Value', source: './time-value' }, - ]); - }); - - it('filters out type-only exports', () => { - const code = ` - export { Group, type GroupProps } from './time-group'; - export type { SomeType } from './types'; - `; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([{ name: 'Group', localName: 'Group', source: './time-group' }]); - }); - - it('returns empty array for file with no exports', () => { - const code = `const x = 1;`; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([]); - }); - - it('handles multiple value exports from same source', () => { - const code = ` - export { Foo, Bar } from './source'; - `; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([ - { name: 'Foo', localName: 'Foo', source: './source' }, - { name: 'Bar', localName: 'Bar', source: './source' }, - ]); - }); - - it('skips type-only specifiers within a value export declaration', () => { - const code = ` - export { Value, type ValueProps, type ValueState } from './time-value'; - `; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([{ name: 'Value', localName: 'Value', source: './time-value' }]); - }); - - it('captures local symbol name for aliased exports', () => { - const code = ` - export { ControlsRoot as Root, type ControlsRootProps as RootProps } from './controls-root'; - `; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([{ name: 'Root', localName: 'ControlsRoot', source: './controls-root' }]); - }); - - it('includes non-local re-exports (caller is responsible for filtering)', () => { - const code = ` - export { Root, type RootProps } from './slider-root'; - export { Thumb, type ThumbProps } from '../slider/index.parts'; - `; - const program = createTestProgram(code); - const result = extractParts('test.ts', program); - - expect(result).toEqual([ - { name: 'Root', localName: 'Root', source: './slider-root' }, - { name: 'Thumb', localName: 'Thumb', source: '../slider/index.parts' }, - ]); - }); -}); - -describe('extractPartDescription', () => { - it('extracts JSDoc description from a named export', () => { - const program = createTestProgram(''); - mockParseFromProgram.mockReturnValue({ - exports: [ - { - name: 'Value', - documentation: { - description: 'Displays a formatted time value (current, duration, or remaining).', - }, - }, - ], - }); - - const result = extractPartDescription('test.tsx', program, 'Value'); - - expect(result).toBe('Displays a formatted time value (current, duration, or remaining).'); - }); - - it('strips @example blocks from description', () => { - const program = createTestProgram(''); - mockParseFromProgram.mockReturnValue({ - exports: [ - { - name: 'Group', - documentation: { - description: 'Container for composed time displays.\n\n@example\n```tsx\n\n```', - }, - }, - ], - }); - - const result = extractPartDescription('test.tsx', program, 'Group'); - - expect(result).toBe('Container for composed time displays.'); - }); - - it('extracts JSDoc description from a local (non-aliased) symbol name', () => { - const program = createTestProgram(''); - mockParseFromProgram.mockReturnValue({ - exports: [ - { - name: 'ControlsRoot', - documentation: { - description: 'Root container for player controls.', - }, - }, - ], - }); - - const result = extractPartDescription('test.tsx', program, 'ControlsRoot'); - - expect(result).toBe('Root container for player controls.'); - }); - - it('returns undefined when export is not found', () => { - const program = createTestProgram(''); - mockParseFromProgram.mockReturnValue({ - exports: [{ name: 'OtherComponent', documentation: { description: 'Some desc.' } }], - }); - - const result = extractPartDescription('test.tsx', program, 'Value'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when export has no documentation', () => { - const program = createTestProgram(''); - mockParseFromProgram.mockReturnValue({ - exports: [{ name: 'Value' }], - }); - - const result = extractPartDescription('test.tsx', program, 'Value'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined for empty description', () => { - const program = createTestProgram(''); - mockParseFromProgram.mockReturnValue({ - exports: [{ name: 'Value', documentation: { description: '' } }], - }); - - const result = extractPartDescription('test.tsx', program, 'Value'); - - expect(result).toBeUndefined(); - }); -}); diff --git a/site/scripts/api-docs-builder/src/tests/test-utils.ts b/site/scripts/api-docs-builder/src/tests/test-utils.ts deleted file mode 100644 index 7d9e4e4d..00000000 --- a/site/scripts/api-docs-builder/src/tests/test-utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as ts from 'typescript'; - -/** Only suitable for AST-walking tests — no type resolution. */ -export function createTestProgram(code: string, fileName = 'test.ts'): ts.Program { - const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); - const compilerHost = ts.createCompilerHost({}); - const originalGetSourceFile = compilerHost.getSourceFile; - compilerHost.getSourceFile = (name, ...args) => { - return name === fileName ? sourceFile : originalGetSourceFile.call(compilerHost, name, ...args); - }; - compilerHost.fileExists = (name) => name === fileName; - return ts.createProgram([fileName], {}, compilerHost); -} - -/** Suitable for tests that need type resolution via `getTypeChecker()`. */ -export function createTypedTestProgram(code: string, fileName = 'test.ts'): ts.Program { - const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); - const options: ts.CompilerOptions = { strict: true, target: ts.ScriptTarget.ESNext }; - const compilerHost = ts.createCompilerHost(options); - const originalGetSourceFile = compilerHost.getSourceFile; - const originalFileExists = compilerHost.fileExists; - compilerHost.getSourceFile = (name, ...args) => { - return name === fileName ? sourceFile : originalGetSourceFile.call(compilerHost, name, ...args); - }; - compilerHost.fileExists = (name) => name === fileName || originalFileExists.call(compilerHost, name); - return ts.createProgram([fileName], options, compilerHost); -} diff --git a/site/scripts/api-docs-builder/src/tests/util-handler.test.ts b/site/scripts/api-docs-builder/src/tests/util-handler.test.ts deleted file mode 100644 index 1d280448..00000000 --- a/site/scripts/api-docs-builder/src/tests/util-handler.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { getUtilEntries, type UtilEntry } from '../util-handler'; - -const FIXTURE_ROOT = path.resolve(import.meta.dirname, 'fixtures/monorepo'); - -describe('getUtilEntries', () => { - const entries = getUtilEntries(FIXTURE_ROOT); - - function findByName(name: string, framework?: 'react' | 'html' | null): UtilEntry | undefined { - return entries.find((e) => e.data.name === name && (framework === undefined || e.framework === framework)); - } - - it('discovers hooks', () => { - expect(findByName('usePlayer', 'react')).toBeDefined(); - expect(findByName('useStore', 'react')).toBeDefined(); - }); - - it('discovers controllers', () => { - expect(findByName('PlayerController', 'html')).toBeDefined(); - expect(findByName('SnapshotController', 'html')).toBeDefined(); - }); - - it('discovers mixin with stripped display name', () => { - const mixin = findByName('ContainerMixin', 'html'); - expect(mixin).toBeDefined(); - expect(mixin!.slug).toBe('container-mixin'); - }); - - it('discovers factories including createSelector', () => { - const reactCreate = findByName('createPlayer', 'react'); - const htmlCreate = findByName('createPlayer', 'html'); - const createSelector = findByName('createSelector', null); - - expect(reactCreate).toBeDefined(); - expect(htmlCreate).toBeDefined(); - expect(createSelector).toBeDefined(); - }); - - it('discovers @public utility and context', () => { - expect(findByName('mergeProps', 'react')).toBeDefined(); - expect(findByName('playerContext', 'html')).toBeDefined(); - }); - - it('discovers selectors as framework-agnostic', () => { - const selectorNames = ['selectPlayback', 'selectVolume', 'selectTime']; - for (const name of selectorNames) { - const entry = findByName(name, null); - expect(entry, `expected to find ${name}`).toBeDefined(); - expect(entry!.framework).toBeNull(); - } - }); - - it('assigns correct frameworks', () => { - // React - expect(findByName('usePlayer')!.framework).toBe('react'); - expect(findByName('useStore')!.framework).toBe('react'); - expect(findByName('mergeProps')!.framework).toBe('react'); - - // HTML - expect(findByName('PlayerController')!.framework).toBe('html'); - expect(findByName('SnapshotController')!.framework).toBe('html'); - expect(findByName('playerContext')!.framework).toBe('html'); - - // Framework-agnostic - expect(findByName('selectPlayback')!.framework).toBeNull(); - expect(findByName('createSelector')!.framework).toBeNull(); - }); - - it('handles slug collision', () => { - const reactCreate = entries.find((e) => e.slug === 'create-player'); - const htmlCreate = entries.find((e) => e.slug === 'html-create-player'); - - expect(reactCreate).toBeDefined(); - expect(reactCreate!.framework).toBe('react'); - expect(htmlCreate).toBeDefined(); - expect(htmlCreate!.framework).toBe('html'); - }); - - it('extracts multi-overload signatures', () => { - const usePlayer = findByName('usePlayer', 'react'); - expect(usePlayer!.data.overloads).toHaveLength(2); - - const useStore = findByName('useStore', 'react'); - expect(useStore!.data.overloads).toHaveLength(2); - }); - - it('preserves overloads with identical return types', () => { - const useFormat = findByName('useFormat', 'react'); - expect(useFormat).toBeDefined(); - expect(useFormat!.data.overloads).toHaveLength(2); - }); - - it('extracts @label from overload JSDoc', () => { - const useFormat = findByName('useFormat', 'react'); - expect(useFormat!.data.overloads[0]!.label).toBe('Number'); - expect(useFormat!.data.overloads[1]!.label).toBe('String'); - }); - - it('omits label when @label is not present', () => { - const useStore = findByName('useStore', 'react'); - expect(useStore!.data.overloads[0]!.label).toBeUndefined(); - expect(useStore!.data.overloads[1]!.label).toBeUndefined(); - }); - - it('strips "- " prefix from controller param descriptions', () => { - const snapshot = findByName('SnapshotController', 'html'); - expect(snapshot).toBeDefined(); - - const firstOverload = snapshot!.data.overloads[0]!; - const hostParam = firstOverload.parameters.host; - expect(hostParam).toBeDefined(); - expect(hostParam!.description).toBe('The host element.'); - expect(hostParam!.description).not.toMatch(/^-\s/); - }); - - it('extracts JSDoc descriptions', () => { - const usePlayer = findByName('usePlayer', 'react'); - expect(usePlayer!.data.description).toBeDefined(); - - const playerController = findByName('PlayerController', 'html'); - expect(playerController!.data.description).toBeDefined(); - - const playerContext = findByName('playerContext', 'html'); - expect(playerContext!.data.description).toBeDefined(); - }); -}); diff --git a/site/scripts/api-docs-builder/src/tests/utils.test.ts b/site/scripts/api-docs-builder/src/tests/utils.test.ts deleted file mode 100644 index e6f82049..00000000 --- a/site/scripts/api-docs-builder/src/tests/utils.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { kebabToPascal, partKebabFromSource, sortProps } from '../utils.js'; - -describe('kebabToPascal', () => { - it("converts 'play-button' to 'PlayButton'", () => { - expect(kebabToPascal('play-button')).toBe('PlayButton'); - }); - - it("converts 'slider' to 'Slider'", () => { - expect(kebabToPascal('slider')).toBe('Slider'); - }); - - it("converts 'time-display-current' to 'TimeDisplayCurrent'", () => { - expect(kebabToPascal('time-display-current')).toBe('TimeDisplayCurrent'); - }); -}); - -describe('partKebabFromSource', () => { - it("derives 'value' from './time-value' with component 'time'", () => { - expect(partKebabFromSource('./time-value', 'time')).toBe('value'); - }); - - it("derives 'group' from './time-group' with component 'time'", () => { - expect(partKebabFromSource('./time-group', 'time')).toBe('group'); - }); - - it("derives 'separator' from './time-separator' with component 'time'", () => { - expect(partKebabFromSource('./time-separator', 'time')).toBe('separator'); - }); - - it("handles multi-segment component names like 'play-button'", () => { - expect(partKebabFromSource('./play-button-icon', 'play-button')).toBe('icon'); - }); -}); - -describe('sortProps', () => { - it('sorts required props before optional props', () => { - const props = { - optional: { type: 'string' }, - required: { type: 'string', required: true as const }, - }; - - const result = sortProps(props); - const keys = Object.keys(result); - - expect(keys).toEqual(['required', 'optional']); - }); - - it('sorts alphabetically within each group', () => { - const props = { - zebra: { type: 'string', required: true as const }, - apple: { type: 'string', required: true as const }, - mango: { type: 'string' }, - banana: { type: 'string' }, - }; - - const result = sortProps(props); - const keys = Object.keys(result); - - expect(keys).toEqual(['apple', 'zebra', 'banana', 'mango']); - }); - - it('keeps all-optional props alphabetical', () => { - const props = { - charlie: { type: 'string' }, - alpha: { type: 'string' }, - bravo: { type: 'string' }, - }; - - const result = sortProps(props); - const keys = Object.keys(result); - - expect(keys).toEqual(['alpha', 'bravo', 'charlie']); - }); - - it('keeps all-required props alphabetical', () => { - const props = { - charlie: { type: 'string', required: true as const }, - alpha: { type: 'string', required: true as const }, - bravo: { type: 'string', required: true as const }, - }; - - const result = sortProps(props); - const keys = Object.keys(result); - - expect(keys).toEqual(['alpha', 'bravo', 'charlie']); - }); -});