From 78112fbefdaace678a2d1335409e40533f3819fa Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Tue, 24 Feb 2026 15:34:34 -0600 Subject: [PATCH] feat(site): add util reference pipeline (#537) Co-authored-by: Claude Opus 4.6 --- .claude/skills/api-reference/SKILL.md | 51 +- .../references/builder-conventions.md | 13 +- .../api-reference/references/mdx-structure.md | 145 +- .../references/util-conventions.md | 228 +++ .../skills/docs/references/writing-style.md | 14 +- .gitignore | 2 + CLAUDE.md | 10 +- internal/design/site/api-docs-builder.md | 1264 +++++++++++++++++ packages/core/src/dom/store/selectors.ts | 8 + packages/html/src/player/context.ts | 5 + packages/html/src/player/create-player.ts | 15 + packages/html/src/player/player-controller.ts | 11 + packages/html/src/player/player-mixin.ts | 3 + packages/html/src/store/container-mixin.ts | 5 + packages/html/src/store/provider-mixin.ts | 6 + packages/react/src/player/context.tsx | 14 + packages/react/src/player/create-player.tsx | 18 + packages/react/src/ui/hooks/use-button.ts | 2 + packages/react/src/utils/merge-props.ts | 1 + packages/react/src/utils/use-render.tsx | 1 + packages/store/src/core/selector.ts | 2 + .../html/controllers/snapshot-controller.ts | 11 + .../src/html/controllers/store-controller.ts | 11 + .../controllers/subscription-controller.ts | 5 + .../store/src/react/hooks/use-selector.ts | 11 +- .../store/src/react/hooks/use-snapshot.ts | 16 +- packages/store/src/react/hooks/use-store.ts | 8 + site/CLAUDE.md | 58 +- site/scripts/api-docs-builder/README.md | 232 +-- .../src/data-attrs-handler.ts | 81 +- .../scripts/api-docs-builder/src/formatter.ts | 70 +- site/scripts/api-docs-builder/src/index.ts | 70 +- .../src/tests/data-attrs-handler.test.ts | 159 ++- .../packages/core/src/dom/store/selectors.ts | 36 + .../monorepo/packages/html/src/index.ts | 4 + .../packages/html/src/player/context.ts | 9 + .../packages/html/src/player/create-player.ts | 13 + .../html/src/player/player-controller.ts | 26 + .../html/src/store/container-mixin.ts | 10 + .../monorepo/packages/react/src/index.ts | 4 + .../packages/react/src/player/context.tsx | 16 + .../react/src/player/create-player.ts | 13 + .../packages/react/src/utils/merge-props.ts | 4 + .../packages/react/src/utils/use-format.ts | 9 + .../packages/store/src/core/selector.ts | 10 + .../store/src/html/controllers/index.ts | 1 + .../html/controllers/snapshot-controller.ts | 44 + .../packages/store/src/react/hooks/index.ts | 1 + .../store/src/react/hooks/use-store.ts | 10 + .../fixtures/monorepo/tsconfig.base.json | 13 + .../src/tests/formatter.test.ts | 124 +- .../api-docs-builder/src/tests/test-utils.ts | 14 + .../src/tests/util-handler.test.ts | 127 ++ site/scripts/api-docs-builder/src/types.ts | 14 +- .../api-docs-builder/src/util-handler.ts | 1098 ++++++++++++++ site/src/components/docs/SidebarItem.astro | 38 +- .../components/docs/TableOfContents/utils.ts | 2 +- .../api-reference/ApiDataAttrsTable.astro | 25 +- .../docs/api-reference/ApiPropsTable.astro | 4 +- .../docs/api-reference/ApiStateTable.astro | 4 +- ...ference.astro => ComponentReference.astro} | 14 +- .../docs/api-reference/DataAttrRow.astro | 26 + .../docs/api-reference/DetailRow.astro | 43 +- .../docs/api-reference/PropRow.astro | 9 +- .../docs/api-reference/StateRow.astro | 9 +- .../docs/api-reference/UtilParamsTable.astro | 46 + .../docs/api-reference/UtilReference.astro | 80 ++ .../docs/api-reference/UtilReturnTable.astro | 88 ++ .../create-player/react/css/BasicUsage.css | 24 + .../create-player/react/css/BasicUsage.tsx | 41 + .../html/css/BasicUsage.astro | 10 + .../html/css/BasicUsage.css | 37 + .../html/css/BasicUsage.html | 12 + .../html-create-player/html/css/BasicUsage.ts | 57 + .../html/css/BasicUsage.astro | 10 + .../player-controller/html/css/BasicUsage.css | 36 + .../html/css/BasicUsage.html | 18 + .../player-controller/html/css/BasicUsage.ts | 67 + .../render-element/react/css/BasicUsage.css | 36 + .../render-element/react/css/BasicUsage.tsx | 64 + .../demos/use-button/react/css/BasicUsage.css | 30 + .../demos/use-button/react/css/BasicUsage.tsx | 27 + .../demos/use-media/react/css/BasicUsage.css | 36 + .../demos/use-media/react/css/BasicUsage.tsx | 52 + .../demos/use-player/react/css/Selector.css | 31 + .../demos/use-player/react/css/Selector.tsx | 48 + .../use-player/react/css/StoreAccess.css | 24 + .../use-player/react/css/StoreAccess.tsx | 40 + .../demos/use-store/react/css/Selector.css | 31 + .../demos/use-store/react/css/Selector.tsx | 46 + .../demos/use-store/react/css/StoreAccess.css | 24 + .../demos/use-store/react/css/StoreAccess.tsx | 41 + site/src/content.config.ts | 19 +- .../docs/reference/buffering-indicator.mdx | 4 +- .../docs/reference/container-mixin.mdx | 35 + site/src/content/docs/reference/controls.mdx | 4 +- .../content/docs/reference/create-player.mdx | 35 + .../docs/reference/create-selector.mdx | 32 + .../docs/reference/fullscreen-button.mdx | 4 +- .../docs/reference/html-create-player.mdx | 51 + .../content/docs/reference/mute-button.mdx | 4 +- .../src/content/docs/reference/pip-button.mdx | 4 +- .../content/docs/reference/play-button.mdx | 4 +- .../content/docs/reference/player-context.mdx | 45 + .../docs/reference/player-controller.mdx | 36 + .../content/docs/reference/player-mixin.mdx | 41 + site/src/content/docs/reference/poster.mdx | 4 +- .../content/docs/reference/provider-mixin.mdx | 33 + .../content/docs/reference/render-element.mdx | 71 + .../content/docs/reference/seek-button.mdx | 4 +- .../content/docs/reference/select-buffer.mdx | 49 + .../docs/reference/select-controls.mdx | 51 + .../docs/reference/select-fullscreen.mdx | 51 + .../src/content/docs/reference/select-pip.mdx | 51 + .../docs/reference/select-playback.mdx | 47 + .../content/docs/reference/select-source.mdx | 47 + .../content/docs/reference/select-time.mdx | 51 + .../content/docs/reference/select-volume.mdx | 56 + .../docs/reference/snapshot-controller.mdx | 59 + .../docs/reference/store-controller.mdx | 48 + site/src/content/docs/reference/time.mdx | 4 +- .../src/content/docs/reference/use-button.mdx | 36 + .../docs/reference/use-media-registration.mdx | 47 + site/src/content/docs/reference/use-media.mdx | 34 + .../docs/reference/use-player-context.mdx | 47 + .../src/content/docs/reference/use-player.mdx | 56 + .../content/docs/reference/use-selector.mdx | 46 + .../content/docs/reference/use-snapshot.mdx | 42 + site/src/content/docs/reference/use-store.mdx | 56 + .../docs/reference/write-references.mdx | 88 +- site/src/docs.config.ts | 56 + ...pi-reference.ts => component-reference.ts} | 16 +- site/src/types/docs.ts | 1 + site/src/types/util-reference.ts | 39 + ...nceModel.js => componentReferenceModel.js} | 8 +- site/src/utils/docs/__tests__/routing.test.ts | 48 + site/src/utils/docs/__tests__/sidebar.test.ts | 67 +- site/src/utils/docs/routing.ts | 7 +- site/src/utils/docs/sidebar.ts | 28 +- site/src/utils/remarkConditionalHeadings.js | 59 +- ...est.ts => componentReferenceModel.test.ts} | 14 +- .../utils/tests/utilReferenceModel.test.ts | 247 ++++ site/src/utils/utilReferenceModel.js | 146 ++ 143 files changed, 7031 insertions(+), 481 deletions(-) create mode 100644 .claude/skills/api-reference/references/util-conventions.md create mode 100644 internal/design/site/api-docs-builder.md create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/selectors.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/context.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/create-player.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/player-controller.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/store/container-mixin.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/context.tsx create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/create-player.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/merge-props.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/use-format.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/core/selector.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/snapshot-controller.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/use-store.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/tsconfig.base.json create mode 100644 site/scripts/api-docs-builder/src/tests/util-handler.test.ts create mode 100644 site/scripts/api-docs-builder/src/util-handler.ts rename site/src/components/docs/api-reference/{ApiReference.astro => ComponentReference.astro} (91%) create mode 100644 site/src/components/docs/api-reference/DataAttrRow.astro create mode 100644 site/src/components/docs/api-reference/UtilParamsTable.astro create mode 100644 site/src/components/docs/api-reference/UtilReference.astro create mode 100644 site/src/components/docs/api-reference/UtilReturnTable.astro create mode 100644 site/src/components/docs/demos/create-player/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/create-player/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/html-create-player/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/html-create-player/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/html-create-player/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/html-create-player/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/player-controller/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/player-controller/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/player-controller/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/player-controller/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/render-element/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/render-element/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/use-button/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/use-button/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/use-media/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/use-media/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/use-player/react/css/Selector.css create mode 100644 site/src/components/docs/demos/use-player/react/css/Selector.tsx create mode 100644 site/src/components/docs/demos/use-player/react/css/StoreAccess.css create mode 100644 site/src/components/docs/demos/use-player/react/css/StoreAccess.tsx create mode 100644 site/src/components/docs/demos/use-store/react/css/Selector.css create mode 100644 site/src/components/docs/demos/use-store/react/css/Selector.tsx create mode 100644 site/src/components/docs/demos/use-store/react/css/StoreAccess.css create mode 100644 site/src/components/docs/demos/use-store/react/css/StoreAccess.tsx create mode 100644 site/src/content/docs/reference/container-mixin.mdx create mode 100644 site/src/content/docs/reference/create-player.mdx create mode 100644 site/src/content/docs/reference/create-selector.mdx create mode 100644 site/src/content/docs/reference/html-create-player.mdx create mode 100644 site/src/content/docs/reference/player-context.mdx create mode 100644 site/src/content/docs/reference/player-controller.mdx create mode 100644 site/src/content/docs/reference/player-mixin.mdx create mode 100644 site/src/content/docs/reference/provider-mixin.mdx create mode 100644 site/src/content/docs/reference/render-element.mdx create mode 100644 site/src/content/docs/reference/select-buffer.mdx create mode 100644 site/src/content/docs/reference/select-controls.mdx create mode 100644 site/src/content/docs/reference/select-fullscreen.mdx create mode 100644 site/src/content/docs/reference/select-pip.mdx create mode 100644 site/src/content/docs/reference/select-playback.mdx create mode 100644 site/src/content/docs/reference/select-source.mdx create mode 100644 site/src/content/docs/reference/select-time.mdx create mode 100644 site/src/content/docs/reference/select-volume.mdx create mode 100644 site/src/content/docs/reference/snapshot-controller.mdx create mode 100644 site/src/content/docs/reference/store-controller.mdx create mode 100644 site/src/content/docs/reference/use-button.mdx create mode 100644 site/src/content/docs/reference/use-media-registration.mdx create mode 100644 site/src/content/docs/reference/use-media.mdx create mode 100644 site/src/content/docs/reference/use-player-context.mdx create mode 100644 site/src/content/docs/reference/use-player.mdx create mode 100644 site/src/content/docs/reference/use-selector.mdx create mode 100644 site/src/content/docs/reference/use-snapshot.mdx create mode 100644 site/src/content/docs/reference/use-store.mdx rename site/src/types/{api-reference.ts => component-reference.ts} (70%) create mode 100644 site/src/types/util-reference.ts rename site/src/utils/{apiReferenceModel.js => componentReferenceModel.js} (94%) rename site/src/utils/tests/{apiReferenceModel.test.ts => componentReferenceModel.test.ts} (88%) create mode 100644 site/src/utils/tests/utilReferenceModel.test.ts create mode 100644 site/src/utils/utilReferenceModel.js diff --git a/.claude/skills/api-reference/SKILL.md b/.claude/skills/api-reference/SKILL.md index f4c42557..ebf0a95e 100644 --- a/.claude/skills/api-reference/SKILL.md +++ b/.claude/skills/api-reference/SKILL.md @@ -1,24 +1,25 @@ --- name: api-reference description: >- - Scaffold API reference documentation for Video.js 10 components. Validates - the api-docs-builder output, checks design docs and linked PRs for context, - creates the MDX reference page with anatomy, prose sections, demos, and the - ApiReference component. Triggers: "api reference", "reference page", - "scaffold api docs", "add api docs", "component reference". + Scaffold API reference documentation for Video.js 10 components and utility + APIs. Validates the api-docs-builder output, checks design docs and linked + PRs for context, creates the MDX reference page with anatomy, prose sections, + demos, and the ComponentReference/UtilReference component. Triggers: "api + reference", "reference page", "scaffold api docs", "add api docs", + "component reference", "util reference", "hook reference". --- # API Reference -Scaffold a complete API reference page for a Video.js 10 component. +Scaffold a complete API reference page for a Video.js 10 component or utility API. ## Usage ``` -/api-reference [component-name] +/api-reference [name] ``` -- `component-name` (optional): kebab-case component name (e.g., `play-button`). If omitted, will prompt. +- `name` (optional): kebab-case component name (e.g., `play-button`) or util name (e.g., `use-player`). If omitted, will prompt. ## Arguments @@ -31,6 +32,7 @@ Load these files based on task: | Need | Load | |------|------| | Builder naming conventions | `references/builder-conventions.md` | +| Util conventions | `references/util-conventions.md` | | MDX page structure | `references/mdx-structure.md` | | Demo file patterns | `references/demo-patterns.md` | | Component libraries reference | `docs` skill → `references/component-libraries.md` | @@ -38,7 +40,7 @@ Load these files based on task: | Accessibility | `aria` skill | | Design docs | `internal/design/` | -## Your Tasks +## Component Reference Workflow ### Step 1: Gather context @@ -54,7 +56,7 @@ Accept component name as argument (kebab-case). ### Step 2: Validate api-docs-builder compatibility 1. Run `pnpm -F site api-docs` and check for errors -2. Read the generated JSON at `site/src/content/generated-api-reference/{name}.json` +2. Read the generated JSON at `site/src/content/generated-component-reference/{name}.json` 3. Verify the JSON has expected sections (props, state, dataAttributes, platforms.html.tagName) 4. For multi-part: verify each part appears in `parts` with correct names @@ -88,9 +90,36 @@ Load `references/mdx-structure.md` for the full structure template. 1. Create `site/src/content/docs/reference/{name}.mdx` 2. Add to sidebar in `site/src/docs.config.ts` (alphabetically within Components section) -3. Structure: frontmatter → imports → Anatomy → prose sections → Examples → `` +3. Structure: frontmatter → imports → Anatomy → prose sections → Examples → `` 4. Run `pnpm dev` from root and verify the page renders in both HTML and React framework modes +## Util Reference Workflow + +For hooks, controllers, mixins, factories, and utilities. Load `references/util-conventions.md` for full details. + +### Step 1: Ensure auto-discovery can find the util + +1. The util must be exported from one of the scanned package index files (`packages/react/src/index.ts`, `packages/html/src/index.ts`, or the store subpath indexes) +2. Add JSDoc with a description to the source export +3. If the export doesn't match a naming convention (`use*`, `*Controller`, `create*`), add `@public` to its JSDoc + +Load `references/util-conventions.md` for the full inclusion and classification rules. + +### Step 2: Generate and validate JSON + +1. Run `pnpm -F site api-docs` and check for errors +2. Read the generated JSON at `site/src/content/generated-util-reference/{slug}.json` +3. Verify it has the expected overloads, parameters, and return value + +### Step 3: Create MDX page + +1. Create `site/src/content/docs/reference/{slug}.mdx` +2. Structure: frontmatter → `import UtilReference` → `## Import` → `## Usage` → `` +3. Add to sidebar in `site/src/docs.config.ts`: + - React utils → "Hooks & Utilities" section (`frameworks: ['react']`) + - HTML utils → "Controllers & Mixins" section (`frameworks: ['html']`) +4. Run `pnpm dev` and verify the page renders correctly + ## Related Skills | Need | Use | diff --git a/.claude/skills/api-reference/references/builder-conventions.md b/.claude/skills/api-reference/references/builder-conventions.md index 6e4fa088..1729d7a2 100644 --- a/.claude/skills/api-reference/references/builder-conventions.md +++ b/.claude/skills/api-reference/references/builder-conventions.md @@ -56,6 +56,10 @@ export const Value = ...; - **Part descriptions**: From JSDoc on React component exports in their `.tsx` files - **Prop/state descriptions**: From JSDoc on interface properties in the core file +### Util JSDoc + +Util exports (hooks, controllers, factories, selectors) have their own JSDoc conventions for `@param`, `@label`, and `@public` tags. See `references/util-conventions.md` → "JSDoc Conventions". + ## Common Failures The builder fails silently for many issues — data just won't appear in the JSON: @@ -76,10 +80,13 @@ The builder fails silently for many issues — data just won't appear in the JSO # Generate JSON pnpm -F site api-docs -# Check output -cat site/src/content/generated-api-reference/{name}.json +# Check component output +cat site/src/content/generated-component-reference/{name}.json + +# Check util output +cat site/src/content/generated-util-reference/{slug}.json # Verify schema -# The builder validates against ComponentApiReferenceSchema before writing. +# The builder validates against ComponentReferenceSchema / UtilReferenceSchema before writing. # Schema errors are logged as errors and cause exit code 1. ``` diff --git a/.claude/skills/api-reference/references/mdx-structure.md b/.claude/skills/api-reference/references/mdx-structure.md index bd9ad629..74456f0d 100644 --- a/.claude/skills/api-reference/references/mdx-structure.md +++ b/.claude/skills/api-reference/references/mdx-structure.md @@ -2,7 +2,9 @@ Structure and conventions for API reference MDX pages at `site/src/content/docs/reference/`. -## Frontmatter +## Component Pages + +### Frontmatter ```yaml --- @@ -17,7 +19,7 @@ description: A button component for muting and unmuting audio playback - `frameworkTitle.html`: The `static tagName` from the HTML element file - `description`: One-line description of the component -## Page Structure +### Page Structure ``` frontmatter @@ -29,7 +31,7 @@ imports (React demos, HTML demos) ## Examples ### BasicUsage ### [Additional demos] - + ``` ## Imports Section @@ -185,19 +187,139 @@ Key details: - React source tabs: `App.tsx`, `App.css` - HTML source tabs: `index.html`, `index.css`, `index.ts` -## ApiReference Component +### ComponentReference Component Always the last element in the file: ```mdx - + ``` The component auto-renders Props, State, Data Attributes for single-part and all Parts for multi-part. +### Required Astro Component Imports + +Every component reference MDX needs these at the top of the imports: + +```mdx +import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; +import StyleCase from "@/components/docs/StyleCase.astro"; +import Demo from "@/components/docs/demos/Demo.astro"; +``` + +--- + +## Cross-linking + +Link generously between related reference pages. + +Same-framework or cross-framework link: + +```mdx +Within a player provider, `usePlayer` is usually simpler. +``` + +Selector page linking to framework-specific utils: + +```mdx + +Pass `selectPlayback` to `usePlayer` to subscribe. + + + +Pass `selectPlayback` to `PlayerController` to subscribe. + +``` + +--- + +## Util Pages + +Util pages document React hooks/utilities and HTML controllers/mixins. They are simpler than component pages — no demos, no anatomy. + +### Frontmatter + +```yaml +--- +title: usePlayer +description: Hook to access the player store from within a Player Provider +--- +``` + +- `title`: The exported function/class name (e.g., `usePlayer`, `PlayerController`) +- No `frameworkTitle` — util pages are framework-specific +- `description`: One-line description + +### Page Structure + +``` +frontmatter +import UtilReference +## Import +## Usage + +``` + +### Import Section + +Show the import statement for the util: + +```mdx +## Import + +\`\`\`tsx +import { usePlayer } from '@videojs/react'; +\`\`\` +``` + +### Usage Section + +Explain usage patterns with code examples. For multi-overload utils, document each overload: + +```mdx +## Usage + +`usePlayer` has two overloads: + +**Store access (no subscription)** -- returns the store instance. + +\`\`\`tsx +const store = usePlayer(); +\`\`\` + +**Selector-based subscription** -- returns selected state. + +\`\`\`tsx +const paused = usePlayer((s) => s.paused); +\`\`\` +``` + +### UtilReference Component + +Always the last element in the file: + +```mdx + +``` + +The component auto-renders Parameters and Return Value tables. For multi-overload utils, it renders each overload with its own sections. + +### Required Import + +```mdx +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; +``` + +--- + ## Sidebar Entry -Add to `site/src/docs.config.ts` in the Components section, alphabetically: +Add to `site/src/docs.config.ts` in the appropriate section, alphabetically: + +- **Components** — UI component reference pages +- **Hooks & Utilities** (`frameworks: ['react']`) — React hooks and utilities +- **Controllers & Mixins** (`frameworks: ['html']`) — HTML controllers and mixins ```ts { @@ -212,14 +334,3 @@ Add to `site/src/docs.config.ts` in the Components section, alphabetically: ], }, ``` - -## Required Astro Component Imports - -Every reference MDX needs these at the top of the imports: - -```mdx -import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; -import FrameworkCase from "@/components/docs/FrameworkCase.astro"; -import StyleCase from "@/components/docs/StyleCase.astro"; -import Demo from "@/components/docs/demos/Demo.astro"; -``` diff --git a/.claude/skills/api-reference/references/util-conventions.md b/.claude/skills/api-reference/references/util-conventions.md new file mode 100644 index 00000000..8eb108a0 --- /dev/null +++ b/.claude/skills/api-reference/references/util-conventions.md @@ -0,0 +1,228 @@ +# Util Reference Conventions + +Conventions for the util reference system that documents React hooks/utilities and HTML controllers/mixins. + +## Architecture + +Util references use **convention-based auto-discovery** from package index files. The builder scans entry points, resolves local module paths, and includes exports matching naming conventions or annotated with `@public`. + +## Auto-Discovery Pipeline + +The builder (`site/scripts/api-docs-builder/src/util-handler.ts`) scans entry points: + +```ts +packages/react/src/index.ts → framework: 'react' +packages/store/src/react/hooks/index.ts → framework: 'react' +packages/html/src/index.ts → framework: 'html' +packages/store/src/html/controllers/index.ts → framework: 'html' +packages/core/src/dom/store/selectors.ts → framework: null (agnostic) +packages/store/src/core/selector.ts → framework: null (agnostic) +``` + +Framework-agnostic entries (`framework: null`) produce JSON without a `frameworks` field, meaning they apply to all frameworks. Framework-specific entries get `frameworks: ['react']` or `frameworks: ['html']` in the JSON. + +**Phase 1 — Resolve local modules.** Raw TS AST reads export declarations from each index file, keeping only local paths (`./...`), skipping external packages (`@videojs/...`). + +**Phase 2 — Filter by convention.** Each local module is parsed with TAE (typescript-api-extractor) or raw TS AST. Exports are included if they match naming conventions or have `@public`. + +### Adding a New Util + +1. Export it from the appropriate package index file +2. Add JSDoc with a description +3. If it doesn't match a naming convention (see below), add `@public` to the JSDoc +4. Run `pnpm api-docs` to generate its JSON +4. Create an MDX page with `` +5. Add to the sidebar in `docs.config.ts` + +No code changes needed in the builder itself — convention over configuration. + +## JSDoc Conventions + +The builder extracts JSDoc from source exports to populate reference pages. These rules override the root CLAUDE.md "Minimal JSDoc" guidelines for API reference exports. + +### Summary description (required) + +Every util export needs a JSDoc summary. This becomes the description in the generated JSON: + +```ts +/** Subscribe to the player's volume state. */ +export function useVolume(...): VolumeResult; +``` + +### `@param` descriptions (required for non-obvious params) + +Unlike internal code, API reference exports need `@param` tags so the builder can populate parameter tables. Describe intent and defaults, not types: + +```ts +/** + * Subscribe to derived state with customizable equality check. + * + * @param subscribe - Subscribe function that returns an unsubscribe callback. + * @param selector - Derives a value from the snapshot. + * @param isEqual - Custom equality function. Defaults to `shallowEqual`. + */ +export function useSelector(...): R; +``` + +Format: `@param name - description` (dash after name). + +### No `@returns` + +Return types are inferred from the TypeScript signature. Don't add `@returns`. + +### `@label` for multi-overload functions + +When a function has multiple overload signatures with different return types, each overload gets its own JSDoc block with an `@label` tag. The label becomes a heading in the docs: + +```ts +/** + * Create a player instance with typed store, Provider, and hooks. + * + * @label Video + * @param config - Player configuration with features. + */ +export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; + +/** + * Create a player for audio media. + * + * @label Audio + * @param config - Player configuration with features. + */ +export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; +``` + +Without `@label`, overloads render as "Overload 1", "Overload 2", etc. + +### `@label` for constructor overloads + +Same pattern applies to controller constructors: + +```ts +/** + * @label Without Selector + * @param host - The host element that owns this controller. + * @param state - The State container to subscribe to. + */ +constructor(host: ReactiveControllerHost, state: State); + +/** + * @label With Selector + * @param host - The host element that owns this controller. + * @param state - The State container to subscribe to. + * @param selector - Derives a value from the state. + */ +constructor(host: ReactiveControllerHost, state: State, selector: Selector); +``` + +### `@public` for non-convention exports + +Exports that don't match a naming convention (`use*`, `*Controller`, `create*`, `select*`) need `@public` to be discovered: + +```ts +/** @public The default player context for consuming the player store. */ +export const playerContext = createContext(...); +``` + +## Inclusion Conventions + +Exports are auto-included when they match these patterns: + +| Pattern | Match Rule | Examples | +|---------|-----------|----------| +| Hooks | Name starts with `use`, is a function | `usePlayer`, `useStore` | +| Controllers | Name ends with `Controller` | `PlayerController`, `StoreController` | +| Factories | Name starts with `create`, is a function | `createPlayer` | +| Mixin factories | Name starts with `create` + contains `Mixin` | `createProviderMixin` | +| `@public` | Has `@public` JSDoc tag | `playerContext`, `mergeProps`, `renderElement` | + +Exports that don't match any convention are excluded (UI components, types, internal helpers). + +## Slug Conventions + +- Slugs are kebab-case: `use-player`, `player-controller`, `merge-props` +- All slugs must be unique across both frameworks +- When the same name exists in both React and HTML (e.g., `createPlayer`), prefix the HTML slug: `html-create-player` + +## Frameworks + +| Framework | Sidebar Section | Source Packages | +|-----------|----------------|-----------------| +| `react` | Hooks & Utilities | `@videojs/react`, `@videojs/store/react` | +| `html` | Controllers & Mixins | `@videojs/html`, `@videojs/store/html` | +| `null` (agnostic) | Selectors | `@videojs/core/dom`, `@videojs/store` | + +Agnostic utils omit the `frameworks` field in JSON, meaning they're available to all frameworks. + +## Overloads + +Use multiple overloads when the return type genuinely differs between signatures: + +```ts +// Two overloads — different return types +usePlayer() → PlayerStore +usePlayer(selector) → T (selected value) +``` + +For simple param-count differences with the same return type, use a single overload with optional params instead. + +## Generated JSON + +Output: `site/src/content/generated-util-reference/{slug}.json` + +Schema: `UtilReferenceSchema` from `site/src/types/util-reference.ts` + +```json +{ + "name": "usePlayer", + "description": "...", + "overloads": [ + { + "description": "...", + "parameters": { "selector": { "type": "...", "required": true } }, + "returnValue": { "type": "...", "description": "..." } + } + ] +} +``` + +## Content Collection + +The `utilReference` collection in `site/src/content.config.ts` loads from `generated-util-reference/` and validates against `UtilReferenceSchema`. + +## Astro Components + +| Component | Purpose | +|-----------|---------| +| `UtilReference.astro` | Main component — loads JSON, renders sections | +| `UtilParamsTable.astro` | Parameter table (reuses `PropRow.astro`) | +| `UtilReturnTable.astro` | Return value table (reuses `StateRow.astro` for object returns) | + +## Common Failures + +| Symptom | Cause | +|---------|-------| +| No JSON generated | Export not in a scanned index file, or doesn't match convention / lack `@public` | +| Zod validation error | Schema mismatch — check field names and types | +| Missing from sidebar | Not added to `docs.config.ts` in correct section | +| Page 404 | MDX file missing or slug mismatch | +| Wrong framework section | Export in wrong entry point — check which index file re-exports it | +| TAE crash on index file | Known issue with `UniqueESSymbol` types — raw TS AST fallback handles this | + +## Discovery vs MDX Pages + +Auto-discovery generates JSON files in `site/src/content/generated-util-reference/`. A JSON file without a corresponding MDX page is harmless — it sits unused. + +Only utils with **both** generated JSON **and** a manually-created MDX page appear in the docs. This is intentional: discovery casts a wide net using naming conventions, while MDX pages are curated to document the public API surface. + +For example, `SubscriptionController` is discovered (matches `*Controller`) but has no MDX page because it's an internal building block not intended for direct consumer use. + +When adding a new util to the docs: +1. Ensure the builder discovers it (check with `pnpm api-docs`) +2. Create the MDX page at `site/src/content/docs/reference/{slug}.mdx` +3. Add to the sidebar in `docs.config.ts` + +## Tests + +- `site/scripts/api-docs-builder/src/tests/util-handler.test.ts` — fixture-based tests for discovery, slug uniqueness, frameworks, overloads +- `site/src/utils/tests/utilReferenceModel.test.ts` — validates model structure and TOC headings diff --git a/.claude/skills/docs/references/writing-style.md b/.claude/skills/docs/references/writing-style.md index dc891e5a..394f8da8 100644 --- a/.claude/skills/docs/references/writing-style.md +++ b/.claude/skills/docs/references/writing-style.md @@ -132,12 +132,24 @@ unsubscribe(); ```markdown // ✅ Natural -See [Events](/concepts/events) for the full list. +See Events for the full list. // ❌ Awkward For more information about events, please refer to the Events page. ``` +### Cross-link reference pages generously + +When a reference page mentions another API by name, link to it. Readers exploring one API often need context from related APIs. Link the first prose mention per page — don't link inside code blocks. + +```markdown +// ✅ Linked +Within a player provider, `usePlayer` is usually simpler. + +// ❌ Unlinked +Within a player provider, `usePlayer` is usually simpler. +``` + ## Length guidelines | Content type | Target | diff --git a/.gitignore b/.gitignore index 53e74d92..08933e57 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ coverage/ .jest/ __tests__/coverage/ site/src/content/generated-api-reference/ +site/src/content/generated-component-reference/ +site/src/content/generated-util-reference/ # ------------------------- # Environment diff --git a/CLAUDE.md b/CLAUDE.md index f3424ea4..0b9803d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -437,7 +437,7 @@ const media = node.querySelector('video, audio') as HTMLMediaElement | null; JSDoc should add value, not restate what TypeScript already shows: -**No redundant @param/@returns** — TypeScript signatures are the documentation: +**No redundant @param/@returns** (exception: API reference exports — see below) — TypeScript signatures are the documentation: ```ts // Bad @@ -452,7 +452,7 @@ export function animationFrame(callback: FrameRequestCallback): () => void; export function animationFrame(callback: FrameRequestCallback): () => void; ``` -**Single JSDoc for overloads** — Document the first overload only: +**Single JSDoc for overloads** (exception: API reference exports — see below) — Document the first overload only: ```ts /** Wait for an event to occur on a target. */ @@ -482,6 +482,12 @@ export interface Media extends HTMLMediaElement {} export type FeatureAvailability = 'available' | 'unavailable' | 'unsupported'; ``` +**API reference exports are different** — Exports that feed the api-docs-builder (`use*` hooks, `*Controller` classes, `create*` factories, selectors, and `@public`-annotated exports) need richer JSDoc for the generated reference pages. See the `api-reference` skill → `references/util-conventions.md` for the full rules. Key differences from above: + +- `@param name - description` is required (the builder extracts these into parameter tables). +- Multi-overload functions get per-overload JSDoc with `@label` tags (not a single JSDoc block). +- `@public` opts in exports that don't match naming conventions. + ## Design Documents | Location | Purpose | diff --git a/internal/design/site/api-docs-builder.md b/internal/design/site/api-docs-builder.md new file mode 100644 index 00000000..b348dbe3 --- /dev/null +++ b/internal/design/site/api-docs-builder.md @@ -0,0 +1,1264 @@ +# 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 props and +state. Non-primary parts are documented with a tag name and description only. + +**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 | +| 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. + +### 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) | +| 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` | +| 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: {}` | +| 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. + +**Primary vs. sub-part convention:** + +Every multi-part component has one **primary part** and one or more **sub-parts**. The +convention is file naming: + +- The **root element** file is `{component}-element.ts` (e.g., `time-element.ts`) +- **Sub-part element** files are `{component}-{part}-element.ts` (e.g., `time-group-element.ts`) +- The primary part is whichever part maps to the root element — i.e., the part that does NOT + have a `{component}-{part}-element.ts` file, because its element IS the root element. + +This is a naming convention, not configuration. The root element file always exists for the +primary part; sub-parts always have their own element files. + +**Part-to-element matching:** + +For each 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 → primary part (gets the root element's tag name from `{component}-element.ts`) + +**What the primary part gets:** The shared core Props, State, data attributes, and the root +element's tag name. + +**What sub-parts get:** Their own tag name, a description (from React component JSDoc), and +empty props/state/dataAttributes. + +**What the top-level component gets:** Empty props, state, dataAttributes, and empty platforms. +All meaningful data lives in the `parts` record. + +> **Known limitation:** Our architecture has a single Props/State interface per component at the +> core level, so only the primary part can own them. In Base UI, each part has its own props +> independently. If a sub-part needs its own props in the future, the core architecture would +> need per-part interfaces. + +### 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 +├── platforms +│ └── html? +│ └── tagName: string — e.g., "media-toggle-button" +└── 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 + └── platforms + └── html? + └── tagName: string +``` + +**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." + } + }, + "platforms": { + "html": { + "tagName": "media-toggle-button" + } + } +} +``` + +### 4b. Meter (multi-part component) + +```json +{ + "name": "Meter", + "props": {}, + "state": {}, + "dataAttributes": {}, + "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'" + } + }, + "platforms": { + "html": { + "tagName": "media-meter" + } + } + }, + "track": { + "name": "Track", + "description": "The track area of the meter. Renders a `
` element.", + "props": {}, + "state": {}, + "dataAttributes": {}, + "platforms": { + "html": { + "tagName": "media-meter-track" + } + } + }, + "fill": { + "name": "Fill", + "description": "The filled portion of the meter. Renders a `
` element.", + "props": {}, + "state": {}, + "dataAttributes": {}, + "platforms": { + "html": { + "tagName": "media-meter-fill" + } + } + } + } +} +``` + +### 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) +``` + +**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) + ├─ 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. + +### 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. + +--- + +## 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. 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 +``` + +**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." + +### 6g. 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}"`. + +### 6h. 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/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 600a5ce2..3cb3d1f7 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -9,11 +9,19 @@ import { sourceFeature } from './features/source'; import { timeFeature } from './features/time'; import { volumeFeature } from './features/volume'; +/** Select the buffer state (buffered ranges, percent buffered). */ export const selectBuffer = createSelector(bufferFeature); +/** Select the controls state (controls visible, user-active). */ export const selectControls = createSelector(controlsFeature); +/** Select the fullscreen state (fullscreen active, availability). */ export const selectFullscreen = createSelector(fullscreenFeature); +/** Select the PiP state (picture-in-picture active, availability). */ export const selectPiP = createSelector(pipFeature); +/** Select the playback state (paused, ended, play, pause, toggle). */ export const selectPlayback = createSelector(playbackFeature); +/** Select the source state (src, type). */ export const selectSource = createSelector(sourceFeature); +/** Select the time state (currentTime, duration, seek). */ export const selectTime = createSelector(timeFeature); +/** Select the volume state (volume, muted, setVolume, setMuted). */ export const selectVolume = createSelector(volumeFeature); diff --git a/packages/html/src/player/context.ts b/packages/html/src/player/context.ts index 4560b622..7f03e3de 100644 --- a/packages/html/src/player/context.ts +++ b/packages/html/src/player/context.ts @@ -10,4 +10,9 @@ export type PlayerContext = Context< PlayerContextValue >; +/** + * The default player context instance for consuming the player store in controllers. + * + * @public + */ export const playerContext = createContext(PLAYER_CONTEXT_KEY); diff --git a/packages/html/src/player/create-player.ts b/packages/html/src/player/create-player.ts index b5dc8b27..ab4da3e3 100644 --- a/packages/html/src/player/create-player.ts +++ b/packages/html/src/player/create-player.ts @@ -67,11 +67,26 @@ export interface CreatePlayerResult { * #playback = new PlayerController(this, context, selectPlayback); * } * ``` + * + * @label Video + * @param config - Player configuration with features. */ export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; +/** + * Creates a player factory for audio media. + * + * @label Audio + * @param config - Player configuration with features. + */ export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; +/** + * Creates a player factory with custom features. + * + * @label Generic + * @param config - Player configuration with features. + */ export function createPlayer( config: CreatePlayerConfig ): CreatePlayerResult>; diff --git a/packages/html/src/player/player-controller.ts b/packages/html/src/player/player-controller.ts index e6967ba3..7e9428ac 100644 --- a/packages/html/src/player/player-controller.ts +++ b/packages/html/src/player/player-controller.ts @@ -38,7 +38,18 @@ export class PlayerController impleme #consumer: ContextConsumer, PlayerControllerHost>; #store: StoreController | null = null; + /** + * @label Without Selector + * @param host - The host element that owns this controller. + * @param context - Player context to resolve the store from. + */ constructor(host: PlayerControllerHost, context: PlayerContext); + /** + * @label With Selector + * @param host - The host element that owns this controller. + * @param context - Player context to resolve the store from. + * @param selector - Derives a value from the player store state. + */ constructor( host: PlayerControllerHost, context: PlayerContext, diff --git a/packages/html/src/player/player-mixin.ts b/packages/html/src/player/player-mixin.ts index 1b7025bf..3ab04a3a 100644 --- a/packages/html/src/player/player-mixin.ts +++ b/packages/html/src/player/player-mixin.ts @@ -16,6 +16,9 @@ export type PlayerMixin = ( context: PlayerContext, diff --git a/packages/html/src/store/container-mixin.ts b/packages/html/src/store/container-mixin.ts index 3fdfac97..b12c5ee4 100644 --- a/packages/html/src/store/container-mixin.ts +++ b/packages/html/src/store/container-mixin.ts @@ -9,6 +9,11 @@ export type ContainerMixin = Class & PlayerConsumerConstructor; +/** + * Create a mixin that consumes player context and auto-attaches media elements. + * + * @param context - Player context to consume from an ancestor provider. + */ export function createContainerMixin(context: PlayerContext): ContainerMixin { return (BaseClass: Class) => { class PlayerContainerElement extends BaseClass implements PlayerConsumer, MediaContainer { diff --git a/packages/html/src/store/provider-mixin.ts b/packages/html/src/store/provider-mixin.ts index 658c9a7a..2269a157 100644 --- a/packages/html/src/store/provider-mixin.ts +++ b/packages/html/src/store/provider-mixin.ts @@ -9,6 +9,12 @@ export type ProviderMixin = Class & PlayerProviderConstructor; +/** + * Create a mixin that provides player context to descendant elements. + * + * @param context - Player context to provide to descendants. + * @param factory - Factory function that creates a store instance. + */ export function createProviderMixin( context: PlayerContext, factory: () => Store diff --git a/packages/react/src/player/context.tsx b/packages/react/src/player/context.tsx index bd22f655..b07d3936 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -26,24 +26,38 @@ export function PlayerContextProvider({ return {children}; } +/** Access the full player context value. Throws if used outside a Player Provider. */ export function usePlayerContext(): PlayerContextValue { const ctx = useContext(PlayerContext); if (!ctx) throw new Error('usePlayerContext must be used within a Player Provider'); return ctx; } +/** + * Access the player store from within a Player Provider. + * + * @label Without Selector + */ export function usePlayer(): UnknownStore; +/** + * Select a value from the player store. Re-renders when the selected value changes. + * + * @label With Selector + * @param selector - Derives a value from the player store state. + */ export function usePlayer(selector: (state: UnknownState) => R): R; export function usePlayer(selector?: (state: UnknownState) => R) { const { store } = usePlayerContext(); return useStore(store, selector as any); } +/** Access the media element from within a Player Provider. */ export function useMedia(): Media | null { const { media } = usePlayerContext(); return media; } +/** Access the media registration setter for connecting a media element to the player. */ export function useMediaRegistration(): Dispatch> | undefined { const ctx = useContext(PlayerContext); return ctx?.setMedia; diff --git a/packages/react/src/player/create-player.tsx b/packages/react/src/player/create-player.tsx index b69a68ce..9b0aee41 100644 --- a/packages/react/src/player/create-player.tsx +++ b/packages/react/src/player/create-player.tsx @@ -40,10 +40,28 @@ export type UsePlayerHook = { (selector: (state: InferStoreState) => R): R; }; +/** + * Create a player instance with typed store, Provider component, Container, and hooks. + * + * @label Video + * @param config - Player configuration with features and optional display name. + */ export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; +/** + * Create a player for audio media. + * + * @label Audio + * @param config - Player configuration with features and optional display name. + */ export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult; +/** + * Create a player with custom features. + * + * @label Generic + * @param config - Player configuration with features and optional display name. + */ export function createPlayer( config: CreatePlayerConfig ): CreatePlayerResult>; diff --git a/packages/react/src/ui/hooks/use-button.ts b/packages/react/src/ui/hooks/use-button.ts index 04d04396..55c11c95 100644 --- a/packages/react/src/ui/hooks/use-button.ts +++ b/packages/react/src/ui/hooks/use-button.ts @@ -33,6 +33,8 @@ export interface UseButtonReturnValue { * props: [elementProps, getButtonProps], * }); * ``` + * + * @param params - Button configuration with activation handler and disabled check. */ export function useButton(params: UseButtonParameters): UseButtonReturnValue { const { displayName, onActivate, isDisabled } = params; diff --git a/packages/react/src/utils/merge-props.ts b/packages/react/src/utils/merge-props.ts index a9b7f22b..3e81a9e4 100644 --- a/packages/react/src/utils/merge-props.ts +++ b/packages/react/src/utils/merge-props.ts @@ -93,6 +93,7 @@ function mergeOne( * - style: merged objects (external wins conflicts) * - other: last one wins * + * @public * @example * ```ts * const merged = mergeProps( diff --git a/packages/react/src/utils/use-render.tsx b/packages/react/src/utils/use-render.tsx index 08a7a157..9bc88b85 100644 --- a/packages/react/src/utils/use-render.tsx +++ b/packages/react/src/utils/use-render.tsx @@ -54,6 +54,7 @@ function getElementRef(element: ReactElement): Ref | undefined { * - Ref composition * - className/style as functions of state * + * @public * @example * ```tsx * return renderElement('button', componentProps, { diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts index 9ed53356..87d156f2 100644 --- a/packages/store/src/core/selector.ts +++ b/packages/store/src/core/selector.ts @@ -19,6 +19,8 @@ const stateContext: StateContext = { * const selectPlayback = createSelector(playbackSlice); * selectPlayback(store.state); // { paused, play, pause, ... } | undefined * ``` + * + * @param slice - The feature slice to create a selector for. */ export function createSelector(slice: S): (state: object) => InferSliceState | undefined { const initialState = slice.state(stateContext); diff --git a/packages/store/src/html/controllers/snapshot-controller.ts b/packages/store/src/html/controllers/snapshot-controller.ts index cf3bf4b3..23b029fc 100644 --- a/packages/store/src/html/controllers/snapshot-controller.ts +++ b/packages/store/src/html/controllers/snapshot-controller.ts @@ -25,7 +25,18 @@ export class SnapshotController implements ReactiveCont #cached: R | undefined; #unsubscribe = noop; + /** + * @label Without Selector + * @param host - The host element that owns this controller. + * @param state - The State container to subscribe to. + */ constructor(host: ReactiveControllerHost, state: State); + /** + * @label With Selector + * @param host - The host element that owns this controller. + * @param state - The State container to subscribe to. + * @param selector - Derives a value from the state. + */ constructor(host: ReactiveControllerHost, state: State, selector: Selector); constructor(host: ReactiveControllerHost, state: State, selector?: Selector) { this.#host = host; diff --git a/packages/store/src/html/controllers/store-controller.ts b/packages/store/src/html/controllers/store-controller.ts index 869a8c2d..a8902f98 100644 --- a/packages/store/src/html/controllers/store-controller.ts +++ b/packages/store/src/html/controllers/store-controller.ts @@ -45,7 +45,18 @@ export class StoreController implements #snapshot: SnapshotController | null = null; + /** + * @label Without Selector + * @param host - The host element that owns this controller. + * @param source - Store instance or context to resolve the store from. + */ constructor(host: StoreControllerHost, source: StoreSource); + /** + * @label With Selector + * @param host - The host element that owns this controller. + * @param source - Store instance or context to resolve the store from. + * @param selector - Derives a value from the store state. + */ constructor( host: StoreControllerHost, source: StoreSource, diff --git a/packages/store/src/html/controllers/subscription-controller.ts b/packages/store/src/html/controllers/subscription-controller.ts index 468eacf1..3727512d 100644 --- a/packages/store/src/html/controllers/subscription-controller.ts +++ b/packages/store/src/html/controllers/subscription-controller.ts @@ -42,6 +42,11 @@ export class SubscriptionController implements Re #unsubscribe = noop; + /** + * @param host - The host element that owns this controller. + * @param source - Store instance or context to resolve the store from. + * @param config - Subscription and value extraction configuration. + */ constructor( host: SubscriptionControllerHost, source: StoreSource, diff --git a/packages/store/src/react/hooks/use-selector.ts b/packages/store/src/react/hooks/use-selector.ts index a228a7d3..37c2c6a1 100644 --- a/packages/store/src/react/hooks/use-selector.ts +++ b/packages/store/src/react/hooks/use-selector.ts @@ -3,7 +3,16 @@ import { type Comparator, type Selector, shallowEqual } from '../../core/shallow export type { Comparator, Selector }; -/** Subscribe to derived state with customizable equality check. */ +/** + * Subscribe to derived state with customizable equality check. + * + * Low-level hook used internally by `useStore` and `useSnapshot`. + * + * @param subscribe - Subscribe function that returns an unsubscribe callback. + * @param getSnapshot - Returns the current snapshot value. + * @param selector - Derives a value from the snapshot. + * @param isEqual - Custom equality function. Defaults to `shallowEqual`. + */ export function useSelector( subscribe: (cb: () => void) => () => void, getSnapshot: () => S, diff --git a/packages/store/src/react/hooks/use-snapshot.ts b/packages/store/src/react/hooks/use-snapshot.ts index 49e7a9e0..d1fc8152 100644 --- a/packages/store/src/react/hooks/use-snapshot.ts +++ b/packages/store/src/react/hooks/use-snapshot.ts @@ -2,9 +2,23 @@ import { identity } from '@videojs/utils/function'; import type { State } from '../../core/state'; import { type Comparator, type Selector, useSelector } from './use-selector'; -/** Subscribe to a State container's current value. */ +/** + * Subscribe to a State container's current value. + * + * @param state - The State container to subscribe to. + * @param selector - Derives a value from state. + * @param isEqual - Custom equality function. Defaults to `shallowEqual`. + */ +/** @label Without Selector */ export function useSnapshot(state: State): T; +/** + * Select a value from state. Re-renders when the selected value changes. + * + * @label With Selector + * @param selector - Derives a value from state. + * @param isEqual - Custom equality function. Defaults to `shallowEqual`. + */ export function useSnapshot(state: State, selector: Selector, isEqual?: Comparator): R; export function useSnapshot(state: State, selector?: Selector, isEqual?: Comparator) { diff --git a/packages/store/src/react/hooks/use-store.ts b/packages/store/src/react/hooks/use-store.ts index 831c590b..55bcdba3 100644 --- a/packages/store/src/react/hooks/use-store.ts +++ b/packages/store/src/react/hooks/use-store.ts @@ -24,8 +24,16 @@ const noopSubscribe = () => noop; * } * ``` */ +/** @label Without Selector */ export function useStore(store: S): S; +/** + * Select a value from the store. Re-renders when the selected value changes (shallowEqual). + * + * @label With Selector + * @param selector - Derives a value from the store state. + * @param isEqual - Custom equality function. Defaults to `shallowEqual`. + */ export function useStore( store: S, selector: Selector, R>, diff --git a/site/CLAUDE.md b/site/CLAUDE.md index 3b3ea74c..be79088f 100644 --- a/site/CLAUDE.md +++ b/site/CLAUDE.md @@ -146,7 +146,8 @@ site/ │ ├── content/ # Content collections (blog/, docs/, authors.json) │ ├── layouts/ # Page layouts (Base, Blog, Docs, Markdown) │ ├── pages/ # Route pages (file-based routing) -│ ├── content/generated-api-reference/ # Generated API reference JSON (gitignored) +│ ├── content/generated-component-reference/ # Generated component reference JSON (gitignored) +│ ├── content/generated-util-reference/ # Generated util reference JSON (gitignored) │ ├── stores/ # Nanostores for cross-island state │ ├── styles/ # Global CSS, Tailwind imports │ ├── types/ # TypeScript type definitions @@ -553,58 +554,27 @@ vi.mock('@/types/docs', async () => { ## API Reference Generation -The API docs builder extracts type information from TypeScript sources and generates JSON files used by Astro components. +> **Source of truth:** [`internal/design/site/api-docs-builder.md`](../internal/design/site/api-docs-builder.md) +> +> 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. -### How It Works +The builder (`scripts/api-docs-builder/`) extracts type information from TypeScript sources and generates JSON for two kinds of reference: -``` -packages/core/html/react/ → JSON → → tables -``` +- **Component references** → `src/content/generated-component-reference/{name}.json` +- **Util references** → `src/content/generated-util-reference/{slug}.json` -1. **Builder script** (`scripts/api-docs-builder/`) parses TypeScript using `typescript-api-extractor` -2. **Extracts** from core files: Props interface, State interface, defaultProps -3. **Extracts** from data-attrs files: data attributes with JSDoc descriptions -4. **Extracts** from HTML element files: Lit `tagName` -5. **Detects** multi-part components via `packages/react/src/ui/{name}/index.parts.ts` -6. **Extracts** part descriptions from React component JSDoc -7. **Outputs** JSON to `src/content/generated-api-reference/{component}.json` -8. **``** Astro component renders the JSON as tables - -### Generated Files Are Gitignored - -The `src/content/generated-api-reference/` directory is **gitignored**. JSON files are regenerated: -- Automatically on `pnpm dev` (via `predev` hook) -- Automatically on `pnpm build` (via `prebuild` hook) -- Manually via `pnpm api-docs` +Generated files are **gitignored** and regenerated automatically on `pnpm dev` and `pnpm build`, or manually via `pnpm api-docs`. ### Usage in MDX -Use the unified `` component for both single-part and multi-part components: - ```mdx -import ApiReference from '@/components/docs/api-reference/ApiReference.astro'; +import ComponentReference from '@/components/docs/api-reference/ComponentReference.astro'; +import UtilReference from '@/components/docs/api-reference/UtilReference.astro'; - + + ``` -The component automatically handles: -- **Single-part**: Renders Props, State, and Data Attributes sections with h3 headings -- **Multi-part**: Renders each part with a framework-aware h3 heading, description from JSDoc, and h4 sub-sections - -### Adding a New Component - -When a new component is added to `packages/core/src/core/ui/`: -1. Run `pnpm api-docs` to generate its JSON -2. Add `` to the MDX reference page - -For multi-part components: -1. Ensure `packages/react/src/ui/{name}/index.parts.ts` exports each part -2. Add JSDoc descriptions to each React component export for part descriptions -3. Ensure each part's HTML element is at `packages/html/src/ui/{name}/{name}-{part}-element.ts` -4. The primary part (whose element is just `{name}-element.ts`) gets the shared core props/state/data-attrs - -See `scripts/api-docs-builder/README.md` for full documentation. - ## Custom Astro Integration: Pagefind **Location:** `integrations/pagefind.ts` @@ -677,7 +647,7 @@ OAuth and Mux integration exist to support the **video uploader** on the install Four plugins transform MDX content during build. Registered in `astro.config.mjs`: **`remarkConditionalHeadings`** (`src/utils/remarkConditionalHeadings.js`) -Walks the MDX AST and tracks headings inside `` / `` components, attaching conditional metadata (which frameworks/styles a heading belongs to). Also reads `` component props, loads the generated JSON, and injects heading entries so API reference sections appear in the table of contents. Outputs to `frontmatter.conditionalHeadings`. +Walks the MDX AST and tracks headings inside `` / `` components, attaching conditional metadata (which frameworks/styles a heading belongs to). Also reads `` and `` component props, loads the generated JSON, and injects heading entries so API reference sections appear in the table of contents. Outputs to `frontmatter.conditionalHeadings`. **`remarkReadingTime`** (`src/utils/remarkReadingTime.mjs`) Calculates reading time and injects `frontmatter.minutesRead` (text) and `frontmatter.readingTimeMinutes` (number). diff --git a/site/scripts/api-docs-builder/README.md b/site/scripts/api-docs-builder/README.md index 1b003ec2..4e38388b 100644 --- a/site/scripts/api-docs-builder/README.md +++ b/site/scripts/api-docs-builder/README.md @@ -1,105 +1,27 @@ # API Docs Builder -Generates interactive API documentation from TypeScript sources for Video.js 10 components. +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. ## Architecture ``` -TypeScript Sources (core/html/react packages) +TypeScript Sources (core/html/react/store packages) ↓ api-docs-builder (typescript-api-extractor) ↓ - JSON files (site/src/content/generated-api-reference/) + JSON files (component + util references) ↓ - Astro component + Astro components (ComponentReference / UtilReference) ↓ Interactive tables in MDX pages ``` -## How It Works - -### 1. Source Discovery - -The builder scans `packages/core/src/core/ui/` for component directories. For each component (e.g., `play-button`), it looks for: - -- **Core file**: `play-button-core.ts` → Extracts `PlayButtonProps`, `PlayButtonState`, and `defaultProps` -- **Data attrs file**: `play-button-data-attrs.ts` → Extracts data attributes with JSDoc descriptions -- **HTML element file**: `packages/html/src/ui/play-button/play-button-element.ts` → Extracts `tagName` -- **Parts index**: `packages/react/src/ui/play-button/index.parts.ts` → Detects multi-part components - -### 2. TypeScript Extraction - -Uses `typescript-api-extractor` to parse TypeScript AST and extract: - -- Interface properties with types and JSDoc descriptions -- Default values from `static defaultProps = { ... }` -- Data attributes from `const PlayButtonDataAttrs = { ... } as const` -- Lit element tag names from `static tagName = 'media-play-button'` - -### 3. JSON Output - -Generates one JSON file per component at `site/src/content/generated-api-reference/{kebab-case-name}.json`: - -```json -{ - "name": "PlayButton", - "props": { - "label": { - "type": "string | ((state: PlayButtonState) => string)", - "description": "Custom label for the button.", - "default": "''" - } - }, - "state": { - "paused": { - "type": "boolean", - "description": "Whether playback is paused." - } - }, - "dataAttributes": { - "data-paused": { - "description": "Present when the media is paused." - } - }, - "platforms": { - "html": { - "tagName": "media-play-button" - } - } -} -``` - -### 4. Astro Components - -The `` component: - -1. Loads the JSON via Astro Content Collections (`getEntry('apiReference', 'play-button')`) -2. For single-part components: renders Props, State, and Data Attributes sections with h3 headings -3. For multi-part components: renders each part with a framework-aware h3 heading, part description, and h4 sub-sections -4. Renders interactive tables with expandable prop details - ## Usage -### In MDX - -Use the unified `` component for both single-part and multi-part components: - -```mdx -import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; - - -``` - -For multi-part components, the same pattern applies — the component automatically renders part headings, descriptions, and sub-sections: - -```mdx -import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; - - -``` - -Part descriptions are extracted from JSDoc on the React component exports (e.g., `packages/react/src/ui/time/time-value.tsx`). - ### Building The builder runs automatically before dev/build via npm scripts: @@ -113,129 +35,60 @@ pnpm dev # via predev hook pnpm build # via prebuild hook ``` -## Multi-Part Components +### In MDX -Some components are composed of multiple parts (e.g., Time has Value, Group, Separator). The builder auto-discovers these via convention. +```mdx +import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; -### Detection - -**Trigger**: Presence of `packages/react/src/ui/{name}/index.parts.ts`. - -Single-part components (PlayButton, MuteButton) don't have this file and are unaffected. - -### Discovery Algorithm - -1. **Part name discovery**: Named (non-type-only) exports are parsed from `index.parts.ts`. Each value export becomes a part. -2. **Kebab segment derivation**: Source path `./time-group` → strip `./time-` prefix → `group`. -3. **HTML element matching**: Each part's kebab segment is used to find `{name}-{kebab}-element.ts` in the HTML directory (e.g., `time-group-element.ts`). -4. **Primary part identification**: The part with NO `{name}-{part}-element.ts` match, whose element is just `{name}-element.ts`, is the primary part. -5. **Shared resource attribution**: `{name}-core.ts` and `{name}-data-attrs.ts` are attributed to the primary part only. - -### Naming Conventions Required - -- Core interfaces must be `{Name}Props` and `{Name}State` (not `{Name}CoreProps` etc.) -- Part exports in `index.parts.ts` must be value exports (not type-only) -- HTML element files must follow `{name}-{part}-element.ts` naming -- Element classes must be `{Name}{Part}Element` (e.g., `TimeGroupElement`) - -### JSON Output - -Multi-part components have empty top-level `props`/`state`/`dataAttributes`. All data lives in the `parts` record: - -```json -{ - "name": "Time", - "props": {}, - "state": {}, - "dataAttributes": {}, - "platforms": {}, - "parts": { - "value": { "name": "Value", "description": "Displays a formatted time value.", "props": { ... }, ... }, - "group": { "name": "Group", "description": "Container for composed time displays.", "props": {}, ... }, - "separator": { "name": "Separator", "description": "Divider between time values.", "props": {}, ... } - } -} + + ``` -### Troubleshooting - -- **Part not appearing in JSON?** Check `index.parts.ts` exports the part as a value export (not type-only). -- **Props/state empty for primary part?** Verify core interfaces are named `{Name}Props`/`{Name}State`. -- **HTML tag name missing?** Verify element file follows `{name}-{part}-element.ts` naming and has `static tagName`. -- **No primary part warning?** Ensure the primary part's element file is just `{name}-element.ts` (not `{name}-{part}-element.ts`). -- **Part description missing?** Add a JSDoc comment to the React component export (e.g., `/** Displays a formatted time value. */` above `export const Value`). - ## File Structure ``` site/scripts/api-docs-builder/ -├── README.md # This file +├── README.md # This file └── src/ - ├── index.ts # Main entry point, orchestrates handlers - ├── types.ts # TypeScript interfaces - ├── formatter.ts # Type formatting utilities - ├── utils.ts # Utility functions (naming helpers) - ├── core-handler.ts # Extracts Props/State from core packages + ├── index.ts # Main entry point, orchestrates handlers + ├── 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 - ├── html-handler.ts # Extracts Lit element info - ├── parts-handler.ts # Parses index.parts.ts for multi-part components + ├── 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 site/src/ -├── content/generated-api-reference/ # Generated JSON files (gitignored) -│ ├── play-button.json -│ ├── mute-button.json -│ └── time.json +├── content/generated-component-reference/ # Generated component JSON (gitignored) +├── content/generated-util-reference/ # Generated util JSON (gitignored) └── components/docs/api-reference/ - ├── ApiReference.astro # Unified component — renders full API reference from JSON - ├── ApiPropsTable.astro # Props table - ├── ApiStateTable.astro # State interface table - ├── ApiDataAttrsTable.astro # Data attributes table - └── PropRow.astro # Expandable prop row + ├── ComponentReference.astro # Renders full component API reference + ├── UtilReference.astro # Renders full util API reference + ├── ApiPropsTable.astro # Props table + ├── ApiStateTable.astro # State interface table + ├── ApiDataAttrsTable.astro # Data attributes table + ├── UtilParamsTable.astro # Util parameters table + ├── UtilReturnTable.astro # Util return type table + ├── PropRow.astro # Expandable prop row + ├── StateRow.astro # Expandable state row + ├── DataAttrRow.astro # Data attribute row + ├── DetailRow.astro # Shared disclosure row + └── InlineMarkdown.astro # Renders inline markdown (backticks → ) ``` -## Adding a New Component - -### Single-Part Component - -1. Create the component in `packages/core/src/core/ui/{name}/` -2. Export `{Name}Props` interface and `{Name}State` interface -3. Optionally create `{name}-data-attrs.ts` with data attribute definitions -4. Create the HTML element in `packages/html/src/ui/{name}/` with `static tagName` -5. Run `pnpm api-docs` to generate JSON -6. Use `` in MDX - -### Multi-Part Component - -1. Follow the single-part steps above for the primary part's core/data-attrs/element files -2. Create `packages/react/src/ui/{name}/index.parts.ts` exporting each part -3. Add JSDoc descriptions to each React component export for part descriptions -4. Create HTML element files for each non-primary part at `packages/html/src/ui/{name}/{name}-{part}-element.ts` -5. Run `pnpm api-docs` to generate JSON -6. Use `` in MDX - -## Acknowledgements - -This builder's architecture and approach were inspired by [Base UI](https://github.com/mui/base-ui)'s -`api-docs-builder`, maintained by MUI. Base UI is licensed under the -[MIT License](https://github.com/mui/base-ui/blob/master/LICENSE) (Copyright 2019 Material-UI SAS). -Thank you to the MUI team for the excellent reference implementation. - -### Key differences from Base UI's builder - -1. **Multi-platform**: One JSON per component containing all platform variants (React/HTML) -2. **Core-first**: Props come from core package, not platform-specific components -3. **Data attributes**: Extracted from dedicated `*-data-attrs.ts` files -4. **HTML elements**: Extracts Lit element `static tagName` -5. **No prettier**: Uses biome for formatting (removed prettier dependency) - ## Dependencies - `typescript-api-extractor`: AST parsing for TypeScript types @@ -243,3 +96,10 @@ Thank you to the MUI team for the excellent reference implementation. - `tsx`: TypeScript execution All dependencies are in `site/package.json` devDependencies. + +## Acknowledgements + +This builder's architecture and approach were inspired by [Base UI](https://github.com/mui/base-ui)'s +`api-docs-builder`, maintained by MUI. Base UI is licensed under the +[MIT License](https://github.com/mui/base-ui/blob/master/LICENSE) (Copyright 2019 Material-UI SAS). +Thank you to the MUI team for the excellent reference implementation. diff --git a/site/scripts/api-docs-builder/src/data-attrs-handler.ts b/site/scripts/api-docs-builder/src/data-attrs-handler.ts index 9233cc93..f69bc478 100644 --- a/site/scripts/api-docs-builder/src/data-attrs-handler.ts +++ b/site/scripts/api-docs-builder/src/data-attrs-handler.ts @@ -21,6 +21,45 @@ function unwrapObjectLiteral(node: ts.Expression): ts.ObjectLiteralExpression | return undefined; } +function extractSatisfiesExpression(node: ts.Expression): ts.TypeNode | undefined { + if (ts.isSatisfiesExpression(node)) return node.type; + if (ts.isParenthesizedExpression(node)) return extractSatisfiesExpression(node.expression); + if (ts.isAsExpression(node)) return extractSatisfiesExpression(node.expression); + return undefined; +} + +function inferStateTypes(satisfiesType: ts.TypeNode, program: ts.Program): Map | undefined { + if (!ts.isTypeReferenceNode(satisfiesType) || !satisfiesType.typeArguments?.length) { + return undefined; + } + + const stateTypeArg = satisfiesType.typeArguments[0]!; + const checker = program.getTypeChecker(); + const resolvedType = checker.getTypeAtLocation(stateTypeArg); + const properties = resolvedType.getProperties(); + + if (properties.length === 0) return undefined; + + const result = new Map(); + + for (const prop of properties) { + const propType = checker.getTypeOfSymbol(prop); + + // Expand union types to avoid showing alias names (e.g., VolumeLevel → 'off' | 'low') + let typeStr: string; + if (propType.isUnion()) { + typeStr = propType.types.map((t) => checker.typeToString(t)).join(' | '); + } else { + typeStr = checker.typeToString(propType); + } + + if (typeStr === 'boolean' || typeStr === 'false | true') continue; + result.set(prop.name, typeStr.replace(/"/g, "'")); + } + + return result; +} + /** * Extract data attributes from a data-attrs file. * @@ -60,6 +99,10 @@ export function extractDataAttrs( const objLiteral = unwrapObjectLiteral(decl.initializer); if (!objLiteral) continue; + // Infer types from satisfies StateAttrMap + const satisfiesType = extractSatisfiesExpression(decl.initializer); + const stateTypes = satisfiesType ? inferStateTypes(satisfiesType, program) : undefined; + // Extract properties with their JSDoc comments for (const prop of objLiteral.properties) { if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { @@ -71,13 +114,22 @@ export function extractDataAttrs( dataAttrValue = prop.initializer.text; } - // Get JSDoc comment for this property - const jsDocComment = getJsDocComment(prop, sourceFile); + // Get JSDoc comment and optional @type for this property + const { description: jsDocComment, type: jsDocType } = parseJsDoc(prop, sourceFile); - attrs.push({ + const attrEntry: { name: string; description: string; type?: string } = { name: dataAttrValue || `data-${propName}`, description: jsDocComment || '', - }); + }; + + // JSDoc @type takes priority, then inferred type from satisfies + if (jsDocType) { + attrEntry.type = jsDocType; + } else if (stateTypes?.has(propName)) { + attrEntry.type = stateTypes.get(propName)!; + } + + attrs.push(attrEntry); } } } @@ -95,6 +147,27 @@ export function extractDataAttrs( return { attrs }; } +/** + * Parse JSDoc comment, extracting description and optional `@type` tag. + */ +export function parseJsDoc( + node: ts.PropertyAssignment, + sourceFile: ts.SourceFile +): { description: string; type?: string } { + const raw = getJsDocComment(node, sourceFile); + if (!raw) return { description: '' }; + + // Extract @type {value} tag + const typeMatch = raw.match(/@type\s*\{([^}]+)\}/); + if (!typeMatch) return { description: raw }; + + const type = typeMatch[1]!.trim(); + // Remove the @type line from description + const description = raw.replace(/@type\s*\{[^}]+\}/, '').trim(); + + return { description, type }; +} + /** * Extract JSDoc comment from a property assignment. */ diff --git a/site/scripts/api-docs-builder/src/formatter.ts b/site/scripts/api-docs-builder/src/formatter.ts index 8eb92134..3be3a559 100644 --- a/site/scripts/api-docs-builder/src/formatter.ts +++ b/site/scripts/api-docs-builder/src/formatter.ts @@ -2,12 +2,41 @@ import { uniq } from 'es-toolkit/array'; import * as tae from 'typescript-api-extractor'; import type { PropDef } from './types.js'; +/** + * Detect if a type string is a single function type (vs a top-level union). + * + * Tracks bracket depth to find the matching `)` for the opening `(` of the parameter list, + * then checks if `=>` follows. Returns `false` for top-level unions that happen to contain + * a function member (e.g., `((state: object) => string) | undefined`). + */ +function isFunctionType(type: string): boolean { + if (!type.startsWith('(')) return false; + let depth = 0; + for (let i = 0; i < type.length; i++) { + if (type[i] === '(' || type[i] === '{' || type[i] === '[') depth++; + else if (type[i] === ')' || type[i] === '}' || type[i] === ']') depth--; + if (depth === 0) { + return type + .slice(i + 1) + .trimStart() + .startsWith('=>'); + } + } + return false; +} + /** * Get abbreviated type for display in collapsed rows. * - * Returns `shortType` when abbreviation adds value, `undefined` otherwise. + * Returns an abbreviated string when abbreviation adds value, `undefined` otherwise. */ -export function getShortPropType(name: string, type: string): string | undefined { +export function abbreviateType(name: string, type: string): string | undefined { + // Pure function types (no union) → "function" + // Also matches function types whose return is a union (e.g., `(state: object) => X | undefined`) + if (type.includes('=>') && (!type.includes(' | ') || isFunctionType(type))) { + return 'function'; + } + // Callbacks → "function" if (/^(on|get)[A-Z]/.test(name) && type.includes('=>')) { return 'function'; @@ -29,6 +58,11 @@ export function getShortPropType(name: string, type: string): string | undefined return undefined; } + // Object literal > 40 chars → "object" + if (type.startsWith('{ ') && type.length > 40) { + return 'object'; + } + // Short unions (less than 3 members and under 40 chars) → no abbreviation if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40 && !type.includes('=>'))) { return undefined; @@ -44,6 +78,11 @@ export function getShortPropType(name: string, type: string): string | undefined return 'function'; } + // Any other type > 40 chars → truncated for display, full in detailedType + if (type.length > 40) { + return `${type.slice(0, 37)}...`; + } + // Complex unions → no abbreviation needed (show full type) return undefined; } @@ -60,13 +99,13 @@ export function formatProperties(props: tae.PropertyNode[], allExports?: tae.Exp // Skip props marked with @ignore if (prop.documentation?.hasTag('ignore')) continue; - const formattedType = allExports + const expandedType = allExports ? formatDetailedType(prop.type, allExports, prop.optional) : formatType(prop.type, prop.optional); - const shortType = getShortPropType(prop.name, formattedType); + const abbreviated = abbreviateType(prop.name, expandedType); - const entry: PropDef = { type: formattedType }; - if (shortType !== undefined) entry.shortType = shortType; + const entry: PropDef = { type: abbreviated ?? expandedType }; + if (abbreviated && expandedType !== abbreviated) entry.detailedType = expandedType; if (prop.documentation?.defaultValue !== undefined) entry.default = prop.documentation.defaultValue; if (!prop.optional) entry.required = true; if (prop.documentation?.description !== undefined) entry.description = prop.documentation.description; @@ -114,7 +153,11 @@ export function formatDetailedType( if (t instanceof tae.UnionNode) { return t.typeName ? t : t.types; } - if (t instanceof tae.TypeParameterNode && t.constraint instanceof tae.UnionNode) { + if ( + t instanceof tae.TypeParameterNode && + t.constraint instanceof tae.UnionNode && + t.constraint.types.length <= 5 + ) { return t.constraint.types; } return t; @@ -171,7 +214,11 @@ export function formatType(type: tae.AnyType, removeUndefined: boolean): string if (t instanceof tae.UnionNode) { return t.typeName ? t : t.types; } - if (t instanceof tae.TypeParameterNode && t.constraint instanceof tae.UnionNode) { + if ( + t instanceof tae.TypeParameterNode && + t.constraint instanceof tae.UnionNode && + t.constraint.types.length <= 5 + ) { return t.constraint.types; } return t; @@ -198,7 +245,7 @@ export function formatType(type: tae.AnyType, removeUndefined: boolean): string } if (type.properties.length === 0) { - return '{}'; + return 'object'; } return `{ ${type.properties.map((m) => `${m.name}${m.optional ? '?' : ''}: ${formatType(m.type, m.optional)}`).join('; ')} }`; @@ -239,7 +286,10 @@ export function formatType(type: tae.AnyType, removeUndefined: boolean): string } if (type instanceof tae.TypeParameterNode) { - return type.constraint !== undefined ? formatType(type.constraint, removeUndefined) : type.name; + if (type.constraint === undefined) return type.name; + // Large union constraints (e.g., keyof JSX.IntrinsicElements) — show the parameter name + if (type.constraint instanceof tae.UnionNode && type.constraint.types.length > 5) return type.name; + return formatType(type.constraint, removeUndefined); } return 'unknown'; diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts index a0d39062..d25061d1 100644 --- a/site/scripts/api-docs-builder/src/index.ts +++ b/site/scripts/api-docs-builder/src/index.ts @@ -4,20 +4,22 @@ import * as ts from 'typescript'; import * as tae from 'typescript-api-extractor'; import { extractCore } from './core-handler.js'; import { extractDataAttrs } from './data-attrs-handler.js'; +import { abbreviateType } from './formatter.js'; import { extractHtml } from './html-handler.js'; import { extractPartDescription, extractParts } from './parts-handler.js'; import { - type ComponentApiReference, - ComponentApiReferenceSchema, + type ComponentReference, + ComponentReferenceSchema, type ComponentSource, type CoreExtraction, type DataAttrDef, type DataAttrsExtraction, - type PartApiReference, + type PartReference, type PartSource, type PropDef, type StateDef, } 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. @@ -30,13 +32,13 @@ function buildProps(coreData: CoreExtraction): Record { for (const prop of coreData.props) { props[prop.name] = { type: prop.type, - shortType: prop.shortType, + detailedType: prop.detailedType, description: prop.description, default: coreData.defaultProps[prop.name] ?? prop.default, required: prop.required, }; - if (props[prop.name]!.shortType === undefined) delete props[prop.name]!.shortType; + 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; @@ -49,10 +51,10 @@ function buildState(coreData: CoreExtraction): Record { for (const s of coreData.state) { state[s.name] = { type: s.type, - shortType: s.shortType, + detailedType: s.detailedType, description: s.description, }; - if (state[s.name]!.shortType === undefined) delete state[s.name]!.shortType; + if (state[s.name]!.detailedType === undefined) delete state[s.name]!.detailedType; if (state[s.name]!.description === undefined) delete state[s.name]!.description; } return state; @@ -61,7 +63,17 @@ function buildState(coreData: CoreExtraction): Record { function buildDataAttrs(dataAttrsData: DataAttrsExtraction): Record { const dataAttributes: Record = {}; for (const attr of dataAttrsData.attrs) { - dataAttributes[attr.name] = { description: attr.description }; + 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; } @@ -81,7 +93,8 @@ 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 OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-api-reference'); +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. @@ -191,7 +204,7 @@ function createProgram(sources: ComponentSource[]): ts.Program { /** * Build the API reference for a single-part component. */ -function buildSingleComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null { +function buildSingleComponentReference(source: ComponentSource, program: ts.Program): ComponentReference | null { // Extract from core const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; @@ -207,7 +220,7 @@ function buildSingleComponentApiReference(source: ComponentSource, program: ts.P const htmlData = source.htmlPath ? extractHtml(source.htmlPath, program, source.name) : null; // Build result - const result: ComponentApiReference = { + const result: ComponentReference = { name: source.name, description: coreData.description, props: buildProps(coreData), @@ -312,12 +325,12 @@ function discoverParts(source: ComponentSource, program: ts.Program): PartSource * - Props, state, and data attributes are empty (no dedicated core file) * - HTML tag comes from their sub-part element file (`{name}-{part}-element.ts`) */ -function buildMultiPartApiReference( +function buildMultiPartReference( source: ComponentSource, program: ts.Program, parts: PartSource[] -): ComponentApiReference | null { - const partsRecord: Record = {}; +): ComponentReference | null { + const partsRecord: Record = {}; for (const part of parts) { // Extract JSDoc description from React component file @@ -334,7 +347,7 @@ function buildMultiPartApiReference( const elementName = `${source.name}Element`; const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null; - const partRef: PartApiReference = { + const partRef: PartReference = { name: part.name, description, props: coreData ? sortProps(buildProps(coreData)) : {}, @@ -354,7 +367,7 @@ function buildMultiPartApiReference( const elementName = `${source.name}${part.name}Element`; const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null; - const partRef: PartApiReference = { + const partRef: PartReference = { name: part.name, description, props: {}, @@ -385,15 +398,15 @@ function buildMultiPartApiReference( /** * Build the API reference for a single component. */ -function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null { +function buildComponentReference(source: ComponentSource, program: ts.Program): ComponentReference | null { if (source.partsIndexPath) { const parts = discoverParts(source, program); if (parts.length > 0) { - return buildMultiPartApiReference(source, program, parts); + return buildMultiPartReference(source, program, parts); } } - return buildSingleComponentApiReference(source, program); + return buildSingleComponentReference(source, program); } /** @@ -412,8 +425,8 @@ function main() { }; // Ensure output directory exists - if (!fs.existsSync(OUTPUT_PATH)) { - fs.mkdirSync(OUTPUT_PATH, { recursive: true }); + if (!fs.existsSync(COMPONENT_OUTPUT_PATH)) { + fs.mkdirSync(COMPONENT_OUTPUT_PATH, { recursive: true }); } // Discover components @@ -434,14 +447,14 @@ function main() { for (const source of components) { try { - const apiRef = buildComponentApiReference(source, program); + 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 = ComponentApiReferenceSchema.safeParse(apiRef); + const validated = ComponentReferenceSchema.safeParse(apiRef); if (!validated.success) { log.error(`Schema validation failed for ${source.name}:`); for (const issue of validated.error.issues) { @@ -452,7 +465,7 @@ function main() { } // Write JSON file - const outputFile = path.join(OUTPUT_PATH, `${source.kebab}.json`); + const outputFile = path.join(COMPONENT_OUTPUT_PATH, `${source.kebab}.json`); const json = `${JSON.stringify(validated.data, null, 2)}\n`; fs.writeFileSync(outputFile, json); @@ -465,7 +478,14 @@ function main() { } } - log.info(`Done! Generated ${successCount} files.`); + log.info(`Done! Generated ${successCount} component files.`); + + // Generate util references + const utilResult = generateUtilReferences(UTIL_OUTPUT_PATH, MONOREPO_ROOT); + successCount += utilResult.success; + errorCount += utilResult.errors; + + log.info(`Done! Generated ${utilResult.success} util files.`); console.warn = originalWarn; 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 index c55a6e85..a4243bdc 100644 --- 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 @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { extractDataAttrs } from '../data-attrs-handler.js'; -import { createTestProgram } from './test-utils.js'; +import { createTestProgram, createTypedTestProgram } from './test-utils.js'; describe('extractDataAttrs', () => { it('extracts from {Name}DataAttrs constant', () => { @@ -141,6 +141,55 @@ describe('extractDataAttrs', () => { 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-'; @@ -154,4 +203,112 @@ describe('extractDataAttrs', () => { 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/fixtures/monorepo/packages/core/src/dom/store/selectors.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/selectors.ts new file mode 100644 index 00000000..c0bbac5b --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/selectors.ts @@ -0,0 +1,36 @@ +interface MediaState { + playing: boolean; + volume: number; + currentTime: number; + duration: number; + buffered: number; + fullscreen: boolean; +} + +interface PlaybackState { + playing: boolean; +} + +interface VolumeState { + volume: number; +} + +interface TimeState { + currentTime: number; + duration: number; +} + +/** Select playback state from media state. */ +export function selectPlayback(state: MediaState): PlaybackState { + return { playing: state.playing }; +} + +/** Select volume state from media state. */ +export function selectVolume(state: MediaState): VolumeState { + return { volume: state.volume }; +} + +/** Select time state from media state. */ +export function selectTime(state: MediaState): TimeState { + return { currentTime: state.currentTime, duration: state.duration }; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/index.ts new file mode 100644 index 00000000..e4216848 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/index.ts @@ -0,0 +1,4 @@ +export { playerContext } from './player/context'; +export { createPlayer } from './player/create-player'; +export { PlayerController } from './player/player-controller'; +export { createContainerMixin } from './store/container-mixin'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/context.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/context.ts new file mode 100644 index 00000000..b3383e3f --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/context.ts @@ -0,0 +1,9 @@ +interface PlayerContext { + readonly player: unknown; +} + +/** + * The player context for dependency injection. + * @public + */ +export const playerContext: PlayerContext = { player: null }; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/create-player.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/create-player.ts new file mode 100644 index 00000000..cca094b6 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/create-player.ts @@ -0,0 +1,13 @@ +interface HtmlPlayerInstance { + play(): void; + destroy(): void; +} + +interface HtmlPlayerOptions { + element: HTMLElement; +} + +/** Create an HTML player instance. */ +export function createPlayer(options: HtmlPlayerOptions): HtmlPlayerInstance { + return {} as HtmlPlayerInstance; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/player-controller.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/player-controller.ts new file mode 100644 index 00000000..748ec115 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/player/player-controller.ts @@ -0,0 +1,26 @@ +interface ReactiveControllerHost { + addController(controller: ReactiveController): void; + requestUpdate(): void; +} + +interface ReactiveController { + hostConnected?(): void; + hostDisconnected?(): void; +} + +/** Manages the video player lifecycle. */ +export class PlayerController implements ReactiveController { + #host: ReactiveControllerHost; + + constructor(host: ReactiveControllerHost) { + this.#host = host; + } + + /** Whether the player is ready. */ + get ready(): boolean { + return false; + } + + hostConnected(): void {} + hostDisconnected(): void {} +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/store/container-mixin.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/store/container-mixin.ts new file mode 100644 index 00000000..26a5202d --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/store/container-mixin.ts @@ -0,0 +1,10 @@ +type Constructor = new (...args: any[]) => T; + +interface ContainerHost { + connectedCallback(): void; +} + +/** Create a mixin that provides store container behavior. */ +export function createContainerMixin>(Base: T): T { + return Base; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts new file mode 100644 index 00000000..5f88ace1 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts @@ -0,0 +1,4 @@ +export { usePlayer } from './player/context'; +export { createPlayer } from './player/create-player'; +export { mergeProps } from './utils/merge-props'; +export { useFormat } from './utils/use-format'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/context.tsx b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/context.tsx new file mode 100644 index 00000000..481bafc8 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/context.tsx @@ -0,0 +1,16 @@ +interface PlayerStore { + playing: boolean; + volume: number; +} + +interface StoreState { + playing: boolean; + volume: number; +} + +/** Access the player store or select state from it. */ +export function usePlayer(): PlayerStore; +export function usePlayer(selector: (state: StoreState) => R): R; +export function usePlayer(selector?: (state: StoreState) => R): PlayerStore | R { + return {} as PlayerStore | R; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/create-player.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/create-player.ts new file mode 100644 index 00000000..282dcb07 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/player/create-player.ts @@ -0,0 +1,13 @@ +interface PlayerInstance { + play(): void; + pause(): void; +} + +interface PlayerOptions { + autoplay?: boolean; +} + +/** Create a React player instance. */ +export function createPlayer(options?: PlayerOptions): PlayerInstance { + return {} as PlayerInstance; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/merge-props.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/merge-props.ts new file mode 100644 index 00000000..70284c05 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/merge-props.ts @@ -0,0 +1,4 @@ +/** @public Merge multiple prop objects into one. */ +export function mergeProps>(...args: T[]): T { + return Object.assign({}, ...args) as T; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/use-format.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/use-format.ts new file mode 100644 index 00000000..602e3139 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/use-format.ts @@ -0,0 +1,9 @@ +/** Format a value for display. */ + +/** @label Number */ +export function useFormat(value: number): string; +/** @label String */ +export function useFormat(value: string): string; +export function useFormat(value: number | string): string { + return String(value); +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/core/selector.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/core/selector.ts new file mode 100644 index 00000000..edc602f5 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/core/selector.ts @@ -0,0 +1,10 @@ +interface Store { + getState(): S; +} + +type SelectorFn = (state: S) => R; + +/** Create a memoized selector function. */ +export function createSelector(fn: SelectorFn): SelectorFn { + return fn; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/index.ts new file mode 100644 index 00000000..0b8af097 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/index.ts @@ -0,0 +1 @@ +export { SnapshotController } from './snapshot-controller'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/snapshot-controller.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/snapshot-controller.ts new file mode 100644 index 00000000..b9cd64f6 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/html/controllers/snapshot-controller.ts @@ -0,0 +1,44 @@ +interface ReactiveControllerHost { + addController(controller: ReactiveController): void; + requestUpdate(): void; +} + +interface ReactiveController { + hostConnected?(): void; + hostDisconnected?(): void; +} + +interface Store { + getState(): S; +} + +/** Takes a snapshot of store state. */ +export class SnapshotController implements ReactiveController { + #host: ReactiveControllerHost; + + /** + * @param host - The host element. + * @param state - The store to snapshot. + * @param selector - Derives a value from state. + */ + constructor(host: ReactiveControllerHost, state: Store, selector: (state: S) => R); + /** + * @param host - The host element. + * @param state - The store to snapshot. + */ + constructor(host: ReactiveControllerHost, state: Store); + constructor(host: ReactiveControllerHost, state: Store, selector?: (state: S) => R) { + this.#host = host; + } + + /** The current snapshot value. */ + get value(): R { + return {} as R; + } + + /** Track state changes. */ + track(): void {} + + hostConnected(): void {} + hostDisconnected(): void {} +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/index.ts new file mode 100644 index 00000000..1411677f --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/index.ts @@ -0,0 +1 @@ +export { useStore } from './use-store'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/use-store.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/use-store.ts new file mode 100644 index 00000000..f0e987c3 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/store/src/react/hooks/use-store.ts @@ -0,0 +1,10 @@ +interface Store { + getState(): S; +} + +/** Subscribe to a store. */ +export function useStore(store: Store): S; +export function useStore(store: Store, selector: (state: S) => R): R; +export function useStore(store: Store, selector?: (state: S) => R): S | R { + return {} as S | R; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/tsconfig.base.json b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/tsconfig.base.json new file mode 100644 index 00000000..49e08b8b --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + } +} diff --git a/site/scripts/api-docs-builder/src/tests/formatter.test.ts b/site/scripts/api-docs-builder/src/tests/formatter.test.ts index a39dbbc7..b69e2617 100644 --- a/site/scripts/api-docs-builder/src/tests/formatter.test.ts +++ b/site/scripts/api-docs-builder/src/tests/formatter.test.ts @@ -1,59 +1,96 @@ import * as tae from 'typescript-api-extractor'; import { describe, expect, it } from 'vitest'; -import { formatDetailedType, formatProperties, formatType, getShortPropType } from '../formatter'; +import { abbreviateType, formatDetailedType, formatProperties, formatType } from '../formatter'; + +describe('abbreviateType', () => { + it("returns 'function' for pure function types (no union)", () => { + expect(abbreviateType('selector', '((state: UnknownState) => R)')).toBe('function'); + expect(abbreviateType('subscribe', '((state: State) => void)')).toBe('function'); + expect(abbreviateType('isEqual', '((a: R, b: R) => boolean)')).toBe('function'); + expect(abbreviateType('config', '((options: Options) => Config)')).toBe('function'); + }); + + it("returns 'function' for function types with union return", () => { + expect(abbreviateType('selector', '(state: object) => InferSliceState | undefined')).toBe('function'); + expect(abbreviateType('selector', '(state: object) => string | undefined')).toBe('function'); + }); + + it("returns 'undefined | function' for true top-level union of undefined and function", () => { + expect(abbreviateType('selector', '((state: object) => string) | undefined')).toBe('undefined | function'); + }); -describe('getShortPropType', () => { it("returns 'function' for callback props (onX with =>)", () => { - expect(getShortPropType('onClick', '(event: Event) => void')).toBe('function'); - expect(getShortPropType('onChange', '(value: string) => void')).toBe('function'); + expect(abbreviateType('onClick', '(event: Event) => void')).toBe('function'); + expect(abbreviateType('onChange', '(value: string) => void')).toBe('function'); }); it("returns 'function' for getter props (getX with =>)", () => { - expect(getShortPropType('getValue', '() => string')).toBe('function'); - expect(getShortPropType('getState', '() => State')).toBe('function'); + expect(abbreviateType('getValue', '() => string')).toBe('function'); + expect(abbreviateType('getState', '() => State')).toBe('function'); }); it("returns 'string | function' for className with =>", () => { - expect(getShortPropType('className', 'string | ((state: State) => string)')).toBe('string | function'); + expect(abbreviateType('className', 'string | ((state: State) => string)')).toBe('string | function'); }); it("returns 'CSSProperties | function' for style with =>", () => { - expect(getShortPropType('style', 'CSSProperties | ((state: State) => CSSProperties)')).toBe( + expect(abbreviateType('style', 'CSSProperties | ((state: State) => CSSProperties)')).toBe( 'CSSProperties | function' ); }); it("returns 'ReactElement | function' for render with =>", () => { - expect(getShortPropType('render', 'ReactElement | ((state: State) => ReactElement)')).toBe( - 'ReactElement | function' - ); + expect(abbreviateType('render', 'ReactElement | ((state: State) => ReactElement)')).toBe('ReactElement | function'); }); it('returns undefined for simple types (boolean, string, number)', () => { - expect(getShortPropType('disabled', 'boolean')).toBeUndefined(); - expect(getShortPropType('label', 'string')).toBeUndefined(); - expect(getShortPropType('count', 'number')).toBeUndefined(); + expect(abbreviateType('disabled', 'boolean')).toBeUndefined(); + expect(abbreviateType('label', 'string')).toBeUndefined(); + expect(abbreviateType('count', 'number')).toBeUndefined(); }); it('returns undefined for short unions (< 3 members and < 40 chars)', () => { - expect(getShortPropType('size', "'small' | 'large'")).toBeUndefined(); - expect(getShortPropType('value', 'string | number')).toBeUndefined(); + expect(abbreviateType('size', "'small' | 'large'")).toBeUndefined(); + expect(abbreviateType('value', 'string | number')).toBeUndefined(); }); it("returns 'type | function' for short callback unions (< 40 chars, 2 members)", () => { const type = 'string | ((state: TimeState) => string)'; - expect(getShortPropType('label', type)).toBe('string | function'); + expect(abbreviateType('label', type)).toBe('string | function'); }); it("returns 'type | function' for unions containing functions", () => { const type = "string | ((state: State) => string) | 'auto'"; - expect(getShortPropType('label', type)).toBe("string | 'auto' | function"); + expect(abbreviateType('label', type)).toBe("string | 'auto' | function"); }); it('returns undefined for complex unions (NOT "Union")', () => { // Complex union with 3+ members, no function const complexUnion = "'small' | 'medium' | 'large' | 'xlarge'"; - expect(getShortPropType('size', complexUnion)).toBeUndefined(); + expect(abbreviateType('size', complexUnion)).toBeUndefined(); + }); + + it('returns "object" for object literals > 40 chars', () => { + const longObject = '{ volume: number; muted: boolean; level: string }'; + expect(longObject.length).toBeGreaterThan(40); + expect(abbreviateType('result', longObject)).toBe('object'); + }); + + it('returns undefined for object literals <= 40 chars', () => { + const shortObject = '{ x: number; y: number }'; + expect(abbreviateType('point', shortObject)).toBeUndefined(); + }); + + it('truncates other types > 40 chars', () => { + const longType = "'option-a' | 'option-b' | 'option-c' | 'option-d' | 'option-e'"; + expect(longType.length).toBeGreaterThan(40); + expect(abbreviateType('choice', longType)).toBe(`${longType.slice(0, 37)}...`); + }); + + it('returns undefined for other types <= 40 chars', () => { + const shortType = "'small' | 'medium' | 'large' | 'xlarge'"; + expect(shortType.length).toBeLessThanOrEqual(40); + expect(abbreviateType('size', shortType)).toBeUndefined(); }); }); @@ -100,7 +137,7 @@ describe('formatProperties', () => { const result = formatProperties(props); expect(result.simple).toEqual({ type: 'boolean' }); - expect(Object.keys(result.simple!)).not.toContain('shortType'); + expect(Object.keys(result.simple!)).not.toContain('detailedType'); expect(Object.keys(result.simple!)).not.toContain('default'); expect(Object.keys(result.simple!)).not.toContain('required'); }); @@ -151,7 +188,7 @@ describe('formatProperties', () => { expect(result.type?.type).toBe("'current' | 'duration' | 'remaining'"); }); - it('sets shortType for callback props', () => { + it('sets abbreviated type and detailedType for callback props', () => { const fnType = createFunctionNode([ { parameters: [ @@ -176,7 +213,8 @@ describe('formatProperties', () => { const result = formatProperties([prop]); - expect(result.onClick?.shortType).toBe('function'); + expect(result.onClick?.type).toBe('function'); + expect(result.onClick?.detailedType).toBe('((event: Event) => void)'); }); }); @@ -352,6 +390,27 @@ describe('formatType', () => { expect(formatType(node, false)).toBe('T'); }); + it('returns type name for TypeParameterNode with large union constraint (>5 members)', () => { + const largeUnion = createUnionNode([ + createLiteralNode("'a'"), + createLiteralNode("'b'"), + createLiteralNode("'c'"), + createLiteralNode("'d'"), + createLiteralNode("'e'"), + createLiteralNode("'f'"), + ]); + const node = createTypeParameterNode('TagName', largeUnion); + + expect(formatType(node, false)).toBe('TagName'); + }); + + it('expands TypeParameterNode with small union constraint (<=5 members)', () => { + const smallUnion = createUnionNode([createLiteralNode("'a'"), createLiteralNode("'b'"), createLiteralNode("'c'")]); + const node = createTypeParameterNode('T', smallUnion); + + expect(formatType(node, false)).toBe("'a' | 'b' | 'c'"); + }); + // --- UnionNode with typeName --- it('formats UnionNode with typeName as fully qualified name', () => { @@ -363,10 +422,10 @@ describe('formatType', () => { // --- ObjectNode edge cases --- - it('formats empty ObjectNode as {}', () => { + it('formats empty ObjectNode as object', () => { const node = createObjectNode([]); - expect(formatType(node, false)).toBe('{}'); + expect(formatType(node, false)).toBe('object'); }); // --- Unknown node --- @@ -391,13 +450,28 @@ describe('formatType', () => { // --- TypeParameterNode constraint flattening in union --- - it('flattens TypeParameterNode constraint in union', () => { + it('flattens TypeParameterNode constraint in union when small (<=5 members)', () => { const constraintUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); const typeParam = createTypeParameterNode('T', constraintUnion); const union = createUnionNode([typeParam, createIntrinsicNode('boolean')]); expect(formatType(union, false)).toBe('string | number | boolean'); }); + + it('does not flatten TypeParameterNode constraint in union when large (>5 members)', () => { + const largeConstraint = createUnionNode([ + createLiteralNode("'a'"), + createLiteralNode("'b'"), + createLiteralNode("'c'"), + createLiteralNode("'d'"), + createLiteralNode("'e'"), + createLiteralNode("'f'"), + ]); + const typeParam = createTypeParameterNode('TagName', largeConstraint); + const union = createUnionNode([typeParam, createIntrinsicNode('boolean')]); + + expect(formatType(union, false)).toBe('TagName | boolean'); + }); }); describe('formatDetailedType', () => { diff --git a/site/scripts/api-docs-builder/src/tests/test-utils.ts b/site/scripts/api-docs-builder/src/tests/test-utils.ts index 3f4471f8..7d9e4e4d 100644 --- a/site/scripts/api-docs-builder/src/tests/test-utils.ts +++ b/site/scripts/api-docs-builder/src/tests/test-utils.ts @@ -11,3 +11,17 @@ export function createTestProgram(code: string, fileName = 'test.ts'): ts.Progra 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 new file mode 100644 index 00000000..1d280448 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/util-handler.test.ts @@ -0,0 +1,127 @@ +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/types.ts b/site/scripts/api-docs-builder/src/types.ts index 02c6c004..3fa48690 100644 --- a/site/scripts/api-docs-builder/src/types.ts +++ b/site/scripts/api-docs-builder/src/types.ts @@ -1,16 +1,16 @@ /** * Re-export types from the shared schema. - * The shared schema in src/types/api-reference.ts is the single source of truth. + * The shared schema in src/types/component-reference.ts is the single source of truth. */ export type { - ComponentApiReference, + ComponentReference, DataAttrDef, - PartApiReference, + PartReference, PropDef, StateDef, -} from '../../../src/types/api-reference.js'; +} from '../../../src/types/component-reference.js'; -export { ComponentApiReferenceSchema, PartApiReferenceSchema } from '../../../src/types/api-reference.js'; +export { ComponentReferenceSchema, PartReferenceSchema } from '../../../src/types/component-reference.js'; /** * Discovered part within a multi-part component. @@ -54,7 +54,7 @@ export interface ComponentSource { export interface ExtractedProp { name: string; type: string; - shortType?: string; + detailedType?: string; description?: string; default?: string; required?: boolean; @@ -74,7 +74,7 @@ export interface CoreExtraction { * Extraction result from data attributes file. */ export interface DataAttrsExtraction { - attrs: Array<{ name: string; description: string }>; + attrs: Array<{ name: string; description: string; type?: string }>; } export interface HtmlExtraction { diff --git a/site/scripts/api-docs-builder/src/util-handler.ts b/site/scripts/api-docs-builder/src/util-handler.ts new file mode 100644 index 00000000..840d9c0e --- /dev/null +++ b/site/scripts/api-docs-builder/src/util-handler.ts @@ -0,0 +1,1098 @@ +/** + * Util reference handler — TAE-based auto-discovery. + * + * Generates JSON reference files for hooks, controllers, mixins, factories, + * contexts, selectors, and utilities by scanning package entry points. + * + * Exports are included by naming convention or `@public` JSDoc tag: + * select* (capital 3rd), use* (capital 3rd), *Controller (class), + * create* (function), or any export tagged @public. + * + * Extraction routing is determined by export node type: + * - Class / *Controller non-function → controller extraction (raw TS AST) + * - Non-function → context extraction (type only) + * - Function → function extraction (TAE call signatures) + * + * 4 Discovery Strategies (run per entry point, in order): + * + * Strategy 1 — TAE on local modules (primary path) + * Parses each resolved local module with typescript-api-extractor. + * + * Strategy 2 — TAE on index file (class re-exports) + * Parses the entry index file itself to find controllers that are + * re-exported but whose source module is separate. + * + * Strategy 3 — Raw TS AST fallback (failed modules) + * When TAE fails on a module (e.g., UniqueESSymbol in HTML bundle), + * falls back to walking the raw TypeScript AST for exports. + * + * Strategy 4 — Raw TS AST for missed classes + * Scans local modules for exported classes that TAE parsed but missed. + * + * All overloads are preserved. When a function or constructor has multiple + * overload signatures, each becomes a separate entry in the overloads array. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { kebabCase } from 'es-toolkit/string'; +import * as ts from 'typescript'; +import * as tae from 'typescript-api-extractor'; +import { + type ParamDef, + type ReturnValue, + type UtilOverload, + type UtilReference, + UtilReferenceSchema, +} from '../../../src/types/util-reference.js'; +import { abbreviateType, formatDetailedType, formatType } from './formatter.js'; + +const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; + +const log = { + info: (...args: unknown[]) => console.log(PREFIX, ...args), + warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args), + error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args), + success: (...args: unknown[]) => console.log(PREFIX, ...args), +}; + +// ─── Types ───────────────────────────────────────────────────────── + +export interface UtilEntry { + slug: string; + data: UtilReference; + framework: 'react' | 'html' | null; +} + +interface EntryPoint { + index: string; + framework: 'react' | 'html' | null; +} + +// ─── Entry Points ────────────────────────────────────────────────── + +// IMPORTANT: React entries must come before HTML entries. On slug collision, +// the first framework keeps the bare slug; later frameworks get prefixed +// (e.g., "create-player" for React, "html-create-player" for HTML). +const UTIL_ENTRY_POINTS: EntryPoint[] = [ + { index: 'packages/react/src/index.ts', framework: 'react' }, + { index: 'packages/store/src/react/hooks/index.ts', framework: 'react' }, + { index: 'packages/html/src/index.ts', framework: 'html' }, + { index: 'packages/store/src/html/controllers/index.ts', framework: 'html' }, + { index: 'packages/core/src/dom/store/selectors.ts', framework: null }, + { index: 'packages/store/src/core/selector.ts', framework: null }, +]; + +// ─── Phase 1: Resolve Local Modules ─────────────────────────────── + +function resolveModulePath(fromFile: string, specifier: string): string { + const dir = path.dirname(fromFile); + const resolved = path.resolve(dir, specifier); + + // Try exact match, then with extensions + const extensions = ['', '.ts', '.tsx']; + for (const ext of extensions) { + const full = resolved + ext; + if (fs.existsSync(full)) return full; + } + + // Try index files + for (const ext of ['.ts', '.tsx']) { + const indexFile = path.join(resolved, `index${ext}`); + if (fs.existsSync(indexFile)) return indexFile; + } + + return resolved; +} + +function resolveLocalModules(indexPath: string): string[] { + const sourceFile = ts.createSourceFile(indexPath, fs.readFileSync(indexPath, 'utf-8'), ts.ScriptTarget.Latest, true); + + const localPaths: string[] = []; + + ts.forEachChild(sourceFile, (node) => { + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const specifier = node.moduleSpecifier.text; + if (specifier.startsWith('.')) { + localPaths.push(resolveModulePath(indexPath, specifier)); + } + } + }); + + return localPaths; +} + +// ─── Phase 2: Convention Matching ────────────────────────────────── + +function isUtilExport(exportNode: tae.ExportNode): boolean { + const name = exportNode.name; + const type = exportNode.type; + + // Skip type-only exports (interfaces, type aliases without runtime value) + if (type instanceof tae.ObjectNode && !type.typeName) return false; + + // Naming conventions (auto-included) + if (name.startsWith('select') && name.charAt(6) >= 'A' && name.charAt(6) <= 'Z' && type instanceof tae.FunctionNode) { + return true; + } + if (name.startsWith('use') && name.charAt(3) >= 'A' && name.charAt(3) <= 'Z' && type instanceof tae.FunctionNode) { + return true; + } + if (name.endsWith('Controller') && !(type instanceof tae.FunctionNode)) return true; + if (name.startsWith('create') && type instanceof tae.FunctionNode) return true; + + // @public tag (for anything else — utilities, contexts, etc.) + if (exportNode.isPublic(true)) return true; + + return false; +} + +// ─── Display Name ────────────────────────────────────────────────── + +function getDisplayName(name: string): string { + if (name.startsWith('create') && name.includes('Mixin')) { + // createProviderMixin → ProviderMixin + return name.replace(/^create/, ''); + } + return name; +} + +// ─── Extraction: Functions ───────────────────────────────────────── + +function extractFunctionOverloads( + exportNode: tae.ExportNode, + filePath: string, + program: ts.Program, + allExports?: tae.ExportNode[] +): UtilOverload[] { + const funcType = exportNode.type; + if (!(funcType instanceof tae.FunctionNode)) return []; + + const signatures = funcType.callSignatures; + if (signatures.length === 0) return []; + + // Get per-overload JSDoc from raw TS AST + const overloadDocs = getOverloadDocs(filePath, program, exportNode.name); + + const overloads = signatures.map((sig, i) => + buildOverload(sig, overloadDocs[i]?.description, overloadDocs[i]?.label, allExports) + ); + + fixDegradedTypes(overloads, filePath, program, exportNode.name); + + return overloads; +} + +function buildOverload( + sig: tae.CallSignature, + doc?: string, + label?: string, + allExports?: tae.ExportNode[] +): UtilOverload { + const parameters: Record = {}; + + for (const param of sig.parameters) { + const typeStr = allExports + ? formatDetailedType(param.type, allExports, param.optional) + : formatType(param.type, param.optional); + const abbreviated = abbreviateType(param.name, typeStr); + + const entry: ParamDef = { type: abbreviated ?? typeStr }; + if (abbreviated && typeStr !== abbreviated) entry.detailedType = typeStr; + if (param.documentation?.description) entry.description = param.documentation.description; + if (!param.optional) entry.required = true; + + // Clean undefined fields + if (entry.detailedType === undefined) delete entry.detailedType; + if (entry.description === undefined) delete entry.description; + if (!entry.required) delete entry.required; + + parameters[param.name] = entry; + } + + const returnValue = buildReturnValue(sig.returnValueType, allExports); + const overload: UtilOverload = { parameters, returnValue }; + + if (label) overload.label = label; + if (doc) overload.description = doc; + + return overload; +} + +function buildReturnValue(type: tae.AnyType, allExports?: tae.ExportNode[]): ReturnValue { + const typeStr = allExports ? formatDetailedType(type, allExports, false) : formatType(type, false); + const abbreviated = abbreviateType('return', typeStr); + + const result: ReturnValue = { type: abbreviated ?? typeStr }; + if (abbreviated && typeStr !== abbreviated) result.detailedType = typeStr; + + // Resolve ExternalTypeNode via allExports before checking for ObjectNode fields + let resolvedType = type; + if (allExports && type instanceof tae.ExternalTypeNode) { + const resolved = allExports.find((e) => e.name === type.typeName.name && e.reexportedFrom === undefined); + if (resolved) resolvedType = resolved.type; + } + + // Expand object properties as fields + if (resolvedType instanceof tae.ObjectNode && resolvedType.properties.length > 0) { + const fields: Record = {}; + for (const prop of resolvedType.properties) { + const propType = allExports + ? formatDetailedType(prop.type, allExports, prop.optional) + : formatType(prop.type, prop.optional); + const propAbbrev = abbreviateType(prop.name, propType); + const field: { type: string; detailedType?: string; description?: string } = { type: propAbbrev ?? propType }; + if (propAbbrev && propType !== propAbbrev) field.detailedType = propType; + if (prop.documentation?.description) field.description = prop.documentation.description; + fields[prop.name] = field; + } + result.fields = fields; + } + + return result; +} + +// ─── Degraded Type Repair ─────────────────────────────────────────── + +function isDegradedType(type: string): boolean { + return /\bany\b/.test(type) || type.includes('__type'); +} + +function fixDegradedTypes(overloads: UtilOverload[], filePath: string, program: ts.Program, funcName: string): void { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return; + + // Collect overload declarations (no body) and implementation fallback + const overloadDecls: ts.FunctionDeclaration[] = []; + let implDecl: ts.FunctionDeclaration | undefined; + + function visit(node: ts.Node) { + if (ts.isFunctionDeclaration(node) && node.name?.text === funcName) { + if (!node.body) { + overloadDecls.push(node); + } else { + implDecl = node; + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + + const decls = overloadDecls.length > 0 ? overloadDecls : implDecl ? [implDecl] : []; + if (decls.length === 0) return; + + for (let i = 0; i < overloads.length; i++) { + const overload = overloads[i]!; + const decl = decls[i]; + if (!decl) continue; + + // Fix degraded param types + for (const [paramName, paramDef] of Object.entries(overload.parameters)) { + const effectiveType = paramDef.detailedType ?? paramDef.type; + if (!isDegradedType(effectiveType)) continue; + + const astParam = decl.parameters.find((p) => ts.isIdentifier(p.name) && p.name.text === paramName); + if (!astParam?.type) continue; + + const rawType = astParam.type.getText(sourceFile); + const abbreviated = abbreviateType(paramName, rawType); + paramDef.type = abbreviated ?? rawType; + if (abbreviated && rawType !== abbreviated) { + paramDef.detailedType = rawType; + } else { + delete paramDef.detailedType; + } + } + + // Fix degraded return type + const effectiveReturn = overload.returnValue.detailedType ?? overload.returnValue.type; + if (isDegradedType(effectiveReturn) && decl.type) { + const rawReturn = decl.type.getText(sourceFile); + const abbreviated = abbreviateType('return', rawReturn); + overload.returnValue.type = abbreviated ?? rawReturn; + if (abbreviated && rawReturn !== abbreviated) { + overload.returnValue.detailedType = rawReturn; + } else { + delete overload.returnValue.detailedType; + } + } + } +} + +// ─── Extraction: Controllers (Classes via raw TS AST) ────────────── + +function extractControllerOverloads(filePath: string, program: ts.Program, className: string): UtilOverload[] { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return []; + + let classDecl: ts.ClassDeclaration | undefined; + + function findClass(node: ts.Node) { + if (ts.isClassDeclaration(node) && node.name?.text === className) { + classDecl = node; + } + ts.forEachChild(node, findClass); + } + findClass(sourceFile); + if (!classDecl) return []; + + // Get constructor overloads (declarations without body), falling back to + // the implementation constructor when there are no overload declarations. + const overloadDecls: ts.ConstructorDeclaration[] = []; + let implDecl: ts.ConstructorDeclaration | undefined; + + for (const member of classDecl.members) { + if (ts.isConstructorDeclaration(member)) { + if (!member.body) { + overloadDecls.push(member); + } else { + implDecl = member; + } + } + } + + const constructorDecls = overloadDecls.length > 0 ? overloadDecls : implDecl ? [implDecl] : []; + if (constructorDecls.length === 0) return []; + + // Get public instance members for returnValue.fields + const fields = extractPublicMembers(classDecl, sourceFile); + + return constructorDecls.map((decl) => { + const parameters: Record = {}; + + for (const param of decl.parameters) { + const result = buildParamEntry(param, decl, sourceFile); + if (result) parameters[result.name] = result.entry; + } + + // Build return value with class type and public members + const typeParams = getClassTypeParams(classDecl!); + const returnValue: ReturnValue = { + type: typeParams ? `${className}<${typeParams}>` : className, + }; + + if (Object.keys(fields).length > 0) { + returnValue.fields = fields; + } + + const overload: UtilOverload = { parameters, returnValue }; + + // Get overload-specific JSDoc + const label = getJSDocTagValue(decl, 'label'); + if (label) overload.label = label; + const jsDoc = getNodeJSDoc(decl); + if (jsDoc) overload.description = jsDoc; + + return overload; + }); +} + +function extractPublicMembers( + classDecl: ts.ClassDeclaration, + sourceFile: ts.SourceFile +): Record { + const fields: Record = {}; + + for (const member of classDecl.members) { + // Skip private, protected, static, constructor + if ( + member.modifiers?.some( + (m) => + m.kind === ts.SyntaxKind.PrivateKeyword || + m.kind === ts.SyntaxKind.ProtectedKeyword || + m.kind === ts.SyntaxKind.StaticKeyword + ) + ) + continue; + + // Skip # private fields + if (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name)) continue; + + // Skip lifecycle methods + const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined; + if (!name) continue; + if (['hostConnected', 'hostDisconnected', 'hostUpdate', 'hostUpdated'].includes(name)) continue; + + if (ts.isGetAccessorDeclaration(member)) { + const typeStr = member.type ? member.type.getText(sourceFile) : 'unknown'; + const abbreviated = abbreviateType(name, typeStr); + const description = getNodeJSDoc(member); + + const field: { type: string; detailedType?: string; description?: string } = { type: abbreviated ?? typeStr }; + if (abbreviated && typeStr !== abbreviated) field.detailedType = typeStr; + if (description) field.description = description; + + fields[name] = field; + } else if (ts.isMethodDeclaration(member) && !member.body) { + // Public method declaration (without body = overload, but we skip those) + } else if (ts.isMethodDeclaration(member)) { + const params = member.parameters + .map((p) => { + const pName = ts.isIdentifier(p.name) ? p.name.text : '...'; + const pType = p.type ? p.type.getText(sourceFile) : 'unknown'; + return `${pName}: ${pType}`; + }) + .join(', '); + const retType = member.type ? member.type.getText(sourceFile) : 'void'; + const typeStr = `(${params}) => ${retType}`; + const abbreviated = abbreviateType(name, typeStr); + const description = getNodeJSDoc(member); + + const field: { type: string; detailedType?: string; description?: string } = { type: abbreviated ?? typeStr }; + if (abbreviated && typeStr !== abbreviated) field.detailedType = typeStr; + if (description) field.description = description; + + fields[name] = field; + } + } + + return fields; +} + +function getClassTypeParams(classDecl: ts.ClassDeclaration): string { + if (!classDecl.typeParameters || classDecl.typeParameters.length === 0) return ''; + return classDecl.typeParameters.map((tp) => tp.name.text).join(', '); +} + +// ─── Extraction: Context (non-function @public exports) ──────────── + +function extractContextOverload(exportNode: tae.ExportNode): UtilOverload { + const typeStr = formatType(exportNode.type, false); + + return { + parameters: {}, + returnValue: { type: typeStr }, + }; +} + +// ─── JSDoc Helpers ───────────────────────────────────────────────── + +interface OverloadDoc { + description?: string; + label?: string; +} + +function getOverloadDocs(filePath: string, program: ts.Program, funcName: string): OverloadDoc[] { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return []; + + const docs: OverloadDoc[] = []; + + function visit(node: ts.Node) { + if (ts.isFunctionDeclaration(node) && node.name?.text === funcName && !node.body) { + // This is an overload declaration + docs.push({ + description: getNodeJSDoc(node), + label: getJSDocTagValue(node, 'label'), + }); + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + + return docs; +} + +function getNodeJSDoc(node: ts.Node): string | undefined { + const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; + if (!jsDocNodes?.length) return undefined; + + const doc = jsDocNodes[0]!; + if (!doc.comment) return undefined; + + if (typeof doc.comment === 'string') return doc.comment; + + // Handle JSDocComment array + return doc.comment.map((c: ts.JSDocText | ts.JSDocLink) => ('text' in c ? c.text : '')).join(''); +} + +function getJSDocParamDescription(node: ts.Node, paramName: string): string | undefined { + const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; + if (!jsDocNodes?.length) return undefined; + + for (const doc of jsDocNodes) { + if (!doc.tags) continue; + for (const tag of doc.tags) { + if (ts.isJSDocParameterTag(tag) && ts.isIdentifier(tag.name) && tag.name.text === paramName) { + if (!tag.comment) return undefined; + const raw = + typeof tag.comment === 'string' + ? tag.comment + : tag.comment.map((c: ts.JSDocText | ts.JSDocLink) => ('text' in c ? c.text : '')).join(''); + return raw.replace(/^\s*-\s+/, ''); + } + } + } + + return undefined; +} + +function hasJSDocTag(node: ts.Node, tagName: string): boolean { + const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; + if (!jsDocNodes?.length) return false; + + for (const doc of jsDocNodes) { + if (!doc.tags) continue; + for (const tag of doc.tags) { + if (tag.tagName.text === tagName) return true; + } + } + return false; +} + +function getJSDocTagValue(node: ts.Node, tagName: string): string | undefined { + const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; + if (!jsDocNodes?.length) return undefined; + + for (const doc of jsDocNodes) { + if (!doc.tags) continue; + for (const tag of doc.tags) { + if (tag.tagName.text === tagName) { + if (!tag.comment) return undefined; + if (typeof tag.comment === 'string') return tag.comment.trim(); + return tag.comment + .map((c: ts.JSDocText | ts.JSDocLink) => ('text' in c ? c.text : '')) + .join('') + .trim(); + } + } + } + return undefined; +} + +// ─── Shared AST Helpers ───────────────────────────────────────────── + +function buildParamEntry( + param: ts.ParameterDeclaration, + decl: ts.FunctionLikeDeclaration, + sourceFile: ts.SourceFile +): { name: string; entry: ParamDef } | undefined { + if (!ts.isIdentifier(param.name)) return undefined; + const name = param.name.text; + const isOptional = !!param.questionToken || !!param.initializer; + + let typeStr = 'unknown'; + if (param.type) { + typeStr = param.type.getText(sourceFile); + } + + const abbreviated = abbreviateType(name, typeStr); + const description = getJSDocParamDescription(decl, name); + + const entry: ParamDef = { type: abbreviated ?? typeStr }; + if (abbreviated && typeStr !== abbreviated) entry.detailedType = typeStr; + if (description) entry.description = description; + if (!isOptional) entry.required = true; + if (!entry.required) delete entry.required; + + return { name, entry }; +} + +// ─── Raw TS AST: Fallback Discovery ──────────────────────────────── + +interface RawExportInfo { + name: string; + isFunction: boolean; + isClass: boolean; + hasPublicTag: boolean; + description?: string; + sourceFile: string; +} + +function discoverExportsFromRawAST(modulePath: string, program: ts.Program): RawExportInfo[] { + const sourceFile = program.getSourceFile(modulePath); + if (!sourceFile) return []; + + const results: RawExportInfo[] = []; + + function visit(node: ts.Node) { + // Exported function declarations + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && + !node.body // overload declaration + ) { + const name = node.name.text; + const jsDoc = getNodeJSDoc(node); + const hasPublicTag = hasJSDocTag(node, 'public'); + + // Only add if not already in results (first overload wins for the name) + if (!results.some((r) => r.name === name)) { + results.push({ + name, + isFunction: true, + isClass: false, + hasPublicTag, + description: jsDoc, + sourceFile: modulePath, + }); + } + } + + // Exported function with body (single signature) + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.body && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && + !results.some((r) => r.name === node.name!.text) + ) { + const name = node.name.text; + const jsDoc = getNodeJSDoc(node); + const hasPublicTag = hasJSDocTag(node, 'public'); + + results.push({ + name, + isFunction: true, + isClass: false, + hasPublicTag, + description: jsDoc, + sourceFile: modulePath, + }); + } + + // Exported class declarations + if ( + ts.isClassDeclaration(node) && + node.name && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + const name = node.name.text; + const jsDoc = getNodeJSDoc(node); + + results.push({ + name, + isFunction: false, + isClass: true, + hasPublicTag: false, + description: jsDoc, + sourceFile: modulePath, + }); + } + + // Exported const/variable declarations + if (ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) { + const jsDoc = getNodeJSDoc(node); + const hasPublicTag = hasJSDocTag(node, 'public'); + + results.push({ + name: decl.name.text, + isFunction: false, + isClass: false, + hasPublicTag, + description: jsDoc, + sourceFile: modulePath, + }); + } + } + } + + ts.forEachChild(node, visit); + } + visit(sourceFile); + + return results; +} + +function isRawUtilExport(info: RawExportInfo): boolean { + const { name, isFunction, isClass, hasPublicTag } = info; + + if (name.startsWith('select') && name.charAt(6) >= 'A' && name.charAt(6) <= 'Z') return true; + if (name.startsWith('use') && name.charAt(3) >= 'A' && name.charAt(3) <= 'Z' && isFunction) return true; + if (name.endsWith('Controller') && isClass) return true; + if (name.startsWith('create') && isFunction) return true; + if (hasPublicTag) return true; + + return false; +} + +// ─── Raw TS AST: Function Extraction ─────────────────────────────── + +function extractFunctionOverloadsFromAST(filePath: string, program: ts.Program, funcName: string): UtilOverload[] { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return []; + + // Collect overload declarations (no body) and implementation (has body) + const overloadDecls: ts.FunctionDeclaration[] = []; + let implDecl: ts.FunctionDeclaration | undefined; + + function visit(node: ts.Node) { + if (ts.isFunctionDeclaration(node) && node.name?.text === funcName) { + if (!node.body) { + overloadDecls.push(node); + } else { + implDecl = node; + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + + const decls = overloadDecls.length > 0 ? overloadDecls : implDecl ? [implDecl] : []; + if (decls.length === 0) return []; + + return decls.map((d) => buildOverloadFromAST(d, sourceFile)); +} + +function buildOverloadFromAST(decl: ts.FunctionDeclaration, sourceFile: ts.SourceFile): UtilOverload { + const parameters: Record = {}; + + for (const param of decl.parameters) { + const result = buildParamEntry(param, decl, sourceFile); + if (result) parameters[result.name] = result.entry; + } + + let returnType = 'unknown'; + if (decl.type) { + returnType = decl.type.getText(sourceFile); + } + + const returnValue: ReturnValue = { type: returnType }; + + // Try to expand return type fields from source if it's an interface/type in the same file + const fields = extractReturnTypeFields(returnType, sourceFile); + if (fields && Object.keys(fields).length > 0) { + returnValue.fields = fields; + } + + const overload: UtilOverload = { parameters, returnValue }; + + const label = getJSDocTagValue(decl, 'label'); + if (label) overload.label = label; + const doc = getNodeJSDoc(decl); + if (doc) overload.description = doc; + + return overload; +} + +function extractReturnTypeFields( + returnType: string, + sourceFile: ts.SourceFile +): Record | undefined { + // Extract the base type name (strip generic parameters) + const match = returnType.match(/^(\w+)/); + if (!match) return undefined; + const typeName = match[1]!; + + // Find the interface/type in the same file + let interfaceDecl: ts.InterfaceDeclaration | undefined; + + function visit(node: ts.Node) { + if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) { + interfaceDecl = node; + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + if (!interfaceDecl) return undefined; + + const fields: Record = {}; + for (const member of interfaceDecl.members) { + if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; + + const name = member.name.text; + const typeStr = member.type ? member.type.getText(sourceFile) : 'unknown'; + const abbreviated = abbreviateType(name, typeStr); + const description = getNodeJSDoc(member); + + const field: { type: string; detailedType?: string; description?: string } = { type: abbreviated ?? typeStr }; + if (abbreviated && typeStr !== abbreviated) field.detailedType = typeStr; + if (description) field.description = description; + + fields[name] = field; + } + + return Object.keys(fields).length > 0 ? fields : undefined; +} + +// ─── Slug Resolution ─────────────────────────────────────────────── + +function resolveSlugCollision(slug: string, framework: EntryPoint['framework'], seenSlugs: Set): string { + if (seenSlugs.has(slug)) { + if (!framework) { + log.error(`Framework-agnostic slug collision: ${slug}`); + } + if (framework === 'react') { + log.error(`Unexpected: React slug "${slug}" collided — check UTIL_ENTRY_POINTS order`); + } + slug = `${framework}-${slug}`; + } + seenSlugs.add(slug); + return slug; +} + +// ─── Discovery Pipeline ──────────────────────────────────────────── + +function processExport( + exportNode: tae.ExportNode, + modulePath: string, + entryPoint: EntryPoint, + program: ts.Program, + seenKeys: Set, + seenSlugs: Set, + entries: UtilEntry[], + allExports?: tae.ExportNode[] +): void { + const key = `${entryPoint.framework}:${exportNode.name}`; + if (seenKeys.has(key)) return; + if (!isUtilExport(exportNode)) return; + + const displayName = getDisplayName(exportNode.name); + const slug = resolveSlugCollision(kebabCase(displayName), entryPoint.framework, seenSlugs); + + let overloads: UtilOverload[]; + + if (exportNode.name.endsWith('Controller') && !(exportNode.type instanceof tae.FunctionNode)) { + // Controllers use raw TS AST because TAE represents them as ObjectNode + overloads = extractControllerOverloads(modulePath, program, exportNode.name); + } else if (!(exportNode.type instanceof tae.FunctionNode)) { + overloads = [extractContextOverload(exportNode)]; + } else { + overloads = extractFunctionOverloads(exportNode, modulePath, program, allExports); + } + + if (overloads.length === 0) { + log.warn(`No overloads extracted for ${exportNode.name}, skipping`); + return; + } + + const description = exportNode.documentation?.description; + const data: UtilReference = { + name: displayName, + overloads, + }; + + if (description) data.description = description; + + entries.push({ + slug, + data, + framework: entryPoint.framework, + }); + + seenKeys.add(key); +} + +function processRawExport( + info: RawExportInfo, + entryPoint: EntryPoint, + program: ts.Program, + seenKeys: Set, + seenSlugs: Set, + entries: UtilEntry[] +): void { + const key = `${entryPoint.framework}:${info.name}`; + if (seenKeys.has(key)) return; + + if (!isRawUtilExport(info)) return; + + const displayName = getDisplayName(info.name); + const slug = resolveSlugCollision(kebabCase(displayName), entryPoint.framework, seenSlugs); + + let overloads: UtilOverload[]; + + if (info.isClass) { + overloads = extractControllerOverloads(info.sourceFile, program, info.name); + } else if (!info.isFunction && !info.isClass) { + overloads = [{ parameters: {}, returnValue: { type: 'unknown' } }]; + } else { + overloads = extractFunctionOverloadsFromAST(info.sourceFile, program, info.name); + } + + if (overloads.length === 0) { + log.warn(`No overloads extracted for ${info.name} (AST fallback), skipping`); + return; + } + + const data: UtilReference = { + name: displayName, + overloads, + }; + + if (info.description) data.description = info.description; + + entries.push({ + slug, + data, + framework: entryPoint.framework, + }); + + seenKeys.add(key); +} + +function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEntry[] { + const entries: UtilEntry[] = []; + const seenKeys = new Set(); + const seenSlugs = new Set(); + + for (const entryPoint of UTIL_ENTRY_POINTS) { + const indexPath = path.join(monorepoRoot, entryPoint.index); + if (!fs.existsSync(indexPath)) { + log.warn(`Entry point not found: ${indexPath}`); + continue; + } + + const localModules = resolveLocalModules(indexPath); + // When the entry point is a leaf module (no re-exports), scan it directly + const modulesToScan = localModules.length > 0 ? localModules : [indexPath]; + const failedModules: string[] = []; + + // Collect all TAE exports for type resolution (formatDetailedType) + const allExports: tae.ExportNode[] = []; + + // Strategy 1: TAE on local modules — primary path for hooks, factories, mixins, + // utilities, contexts, and selectors (e.g., usePlayer, createPlayer, selectPlayback) + for (const modulePath of modulesToScan) { + if (!fs.existsSync(modulePath)) continue; + + let ast: tae.Module; + try { + ast = tae.parseFromProgram(modulePath, program); + } catch { + failedModules.push(modulePath); + continue; + } + + allExports.push(...ast.exports); + + for (const exportNode of ast.exports) { + processExport(exportNode, modulePath, entryPoint, program, seenKeys, seenSlugs, entries, allExports); + } + } + + // Strategy 2: TAE on index file — finds controllers re-exported from the entry + // (e.g., PlayerController re-exported from packages/html/src/index.ts) + try { + const indexAst = tae.parseFromProgram(indexPath, program); + allExports.push(...indexAst.exports); + + for (const exportNode of indexAst.exports) { + // For controllers from the index, find the source module file for extraction + if (exportNode.name.endsWith('Controller') && !(exportNode.type instanceof tae.FunctionNode)) { + const sourceModule = findClassSourceModule(exportNode.name, localModules, program); + if (sourceModule) { + processExport(exportNode, sourceModule, entryPoint, program, seenKeys, seenSlugs, entries, allExports); + } + } + } + } catch { + // Index parsing failed (e.g., HTML index with UniqueESSymbol) + } + + // Strategy 3: Raw TS AST fallback — when TAE fails on a module (e.g., UniqueESSymbol + // in HTML bundle), walks the raw TypeScript AST for exports + for (const modulePath of failedModules) { + const rawExports = discoverExportsFromRawAST(modulePath, program); + for (const info of rawExports) { + processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries); + } + } + + // Strategy 4: Raw TS AST for missed classes — catches exported classes that TAE + // parsed but skipped (e.g., SnapshotController) + for (const modulePath of localModules) { + if (!fs.existsSync(modulePath)) continue; + + const rawExports = discoverExportsFromRawAST(modulePath, program); + for (const info of rawExports) { + if (!info.isClass) continue; + processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries); + } + } + } + + return entries; +} + +function findClassSourceModule(className: string, localModules: string[], program: ts.Program): string | undefined { + for (const modulePath of localModules) { + const sourceFile = program.getSourceFile(modulePath); + if (!sourceFile) continue; + + let found = false; + function visit(node: ts.Node) { + if (ts.isClassDeclaration(node) && node.name?.text === className) { + found = true; + } + if (!found) ts.forEachChild(node, visit); + } + visit(sourceFile); + + if (found) return modulePath; + } + return undefined; +} + +// ─── Program Creation ────────────────────────────────────────────── + +function createUtilProgram(monorepoRoot: string): ts.Program { + const files: string[] = []; + + for (const entryPoint of UTIL_ENTRY_POINTS) { + const indexPath = path.join(monorepoRoot, entryPoint.index); + if (!fs.existsSync(indexPath)) continue; + + files.push(indexPath); + + const localModules = resolveLocalModules(indexPath); + for (const mod of localModules) { + if (fs.existsSync(mod) && !files.includes(mod)) { + files.push(mod); + } + } + } + + const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json'); + const config = tae.loadConfig(tsconfigPath); + config.options.rootDir = monorepoRoot; + + return ts.createProgram(files, config.options); +} + +// ─── Public API ──────────────────────────────────────────────────── + +export function getUtilEntries(monorepoRoot: string): UtilEntry[] { + const program = createUtilProgram(monorepoRoot); + return discoverUtilExports(monorepoRoot, program); +} + +export function generateUtilReferences(outputPath: string, monorepoRoot: string): { success: number; errors: number } { + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }); + } + + const entries = getUtilEntries(monorepoRoot); + let success = 0; + let errors = 0; + + log.info(`Found ${entries.length} util APIs. Processing...`); + + for (const entry of entries) { + const dataToValidate: Record = { ...entry.data }; + if (entry.framework !== null) { + dataToValidate.frameworks = [entry.framework]; + } + + const validated = UtilReferenceSchema.safeParse(dataToValidate); + + if (!validated.success) { + log.error(`Schema validation failed for ${entry.data.name} (${entry.slug}):`); + for (const issue of validated.error.issues) { + log.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + errors++; + continue; + } + + const outputFile = path.join(outputPath, `${entry.slug}.json`); + const json = `${JSON.stringify(validated.data, null, 2)}\n`; + fs.writeFileSync(outputFile, json); + + log.success(`\u2705 Generated ${path.basename(outputFile)} (${entry.framework ?? 'all'})`); + success++; + } + + return { success, errors }; +} diff --git a/site/src/components/docs/SidebarItem.astro b/site/src/components/docs/SidebarItem.astro index 10a1a16e..9fb2f445 100644 --- a/site/src/components/docs/SidebarItem.astro +++ b/site/src/components/docs/SidebarItem.astro @@ -26,27 +26,49 @@ function containsActivePath(item: Guide | Section): boolean { } const isActive = containsActivePath(item); + +function getGroup(depth: number) { + if (depth === 0) return 'group'; + if (depth === 1) return 'group/1'; + if (depth === 2) return 'group/2'; + return ''; +} +function getGroupMargin(depth: number) { + if (depth === 1) return 'group-open/1:mb-1'; + if (depth === 2) return 'group-open/2:mb-1'; + return ''; +} +function getGroupRotate(depth: number) { + if (depth === 0) return 'group-open:rotate-180'; + if (depth === 1) return 'group-open/1:rotate-180'; + if (depth === 2) return 'group-open/2:rotate-180'; + return ''; +} --- { isSection(item) ? (
0 && 'border-l', + (depth === 0 || depth === 1) && 'font-medium', + depth > 0 && 'border-l pl-4', isActive ? 'text-dark-100 dark:text-light-100 border-dark-100' : 'border-light-40 dark:border-dark-40', + getGroupMargin(depth) ]} + style={depth ? `margin-left: calc(var(--spacing) * ${(depth - 1) * 4})` : ``} > {item.sidebarLabel} - +
{item.contents.map((contentItem) => ( @@ -61,10 +83,10 @@ const isActive = containsActivePath(item); 'py-2 flex items-center gap-4 ', 'intent:text-dark-100 dark:intent:text-light-100 cursor-pointer', depth === 0 && 'font-medium', - depth > 0 && 'border-l', + depth > 0 && 'border-l pl-4', isActive ? 'text-dark-100 dark:text-light-100 border-current ' : 'border-light-40 dark:border-dark-40', ]} - style={`padding-left: calc(var(--spacing) * ${depth * 4})`} + style={`margin-left: calc(var(--spacing) * ${(depth - 1) * 4})`} href={`/docs/framework/${framework}/${item.slug}`} > {item.devOnly ? '[Dev only] ' : ''} diff --git a/site/src/components/docs/TableOfContents/utils.ts b/site/src/components/docs/TableOfContents/utils.ts index 8800ee66..73e99309 100644 --- a/site/src/components/docs/TableOfContents/utils.ts +++ b/site/src/components/docs/TableOfContents/utils.ts @@ -3,7 +3,7 @@ import debounce from 'just-debounce-it'; import throttle from 'just-throttle'; import type { RefObject } from 'react'; import { useEffect, useState } from 'react'; -import { API_REFERENCE_SUBSECTION_TITLES } from '@/utils/apiReferenceModel'; +import { API_REFERENCE_SUBSECTION_TITLES } from '@/utils/componentReferenceModel'; /** * Find the first scrollable ancestor of an element diff --git a/site/src/components/docs/api-reference/ApiDataAttrsTable.astro b/site/src/components/docs/api-reference/ApiDataAttrsTable.astro index 7f2d7a0f..cd56c4bf 100644 --- a/site/src/components/docs/api-reference/ApiDataAttrsTable.astro +++ b/site/src/components/docs/api-reference/ApiDataAttrsTable.astro @@ -2,21 +2,20 @@ /** * Renders the data attributes table for API reference. */ -import MarkdownCode from '@/components/typography/MarkdownCode.astro'; import Table from '@/components/typography/Table.astro'; import Tbody from '@/components/typography/Tbody.astro'; -import Td from '@/components/typography/Td.astro'; import Th from '@/components/typography/Th.astro'; import Thead from '@/components/typography/Thead.astro'; import Tr from '@/components/typography/Tr.astro'; -import type { DataAttrDef } from '@/types/api-reference'; -import InlineMarkdown from './InlineMarkdown.astro'; +import type { DataAttrDef } from '@/types/component-reference'; +import DataAttrRow from './DataAttrRow.astro'; interface Props { dataAttributes: Record; + componentName: string; } -const { dataAttributes } = Astro.props; +const { dataAttributes, componentName } = Astro.props; const attrs = Object.entries(dataAttributes); --- @@ -25,18 +24,20 @@ const attrs = Object.entries(dataAttributes); Attribute - Description + Type + { attrs.map(([name, def]) => ( - - - {name} - - - + )) } diff --git a/site/src/components/docs/api-reference/ApiPropsTable.astro b/site/src/components/docs/api-reference/ApiPropsTable.astro index 447ac77a..f08efb77 100644 --- a/site/src/components/docs/api-reference/ApiPropsTable.astro +++ b/site/src/components/docs/api-reference/ApiPropsTable.astro @@ -9,7 +9,7 @@ import Tbody from '@/components/typography/Tbody.astro'; import Th from '@/components/typography/Th.astro'; import Thead from '@/components/typography/Thead.astro'; import Tr from '@/components/typography/Tr.astro'; -import type { PropDef } from '@/types/api-reference'; +import type { PropDef } from '@/types/component-reference'; import PropRow from './PropRow.astro'; interface Props { @@ -35,7 +35,7 @@ const { props, componentName } = Astro.props; diff --git a/site/src/components/docs/api-reference/ApiReference.astro b/site/src/components/docs/api-reference/ComponentReference.astro similarity index 91% rename from site/src/components/docs/api-reference/ApiReference.astro rename to site/src/components/docs/api-reference/ComponentReference.astro index 2f48c46e..a7102c4a 100644 --- a/site/src/components/docs/api-reference/ApiReference.astro +++ b/site/src/components/docs/api-reference/ComponentReference.astro @@ -7,9 +7,9 @@ import H3 from '@/components/typography/H3Markdown.astro'; import H4 from '@/components/typography/H4Markdown.astro'; import MarkdownCode from '@/components/typography/MarkdownCode.astro'; import P from '@/components/typography/P.astro'; -import type { ComponentApiReference } from '@/types/api-reference'; +import type { ComponentReference } from '@/types/component-reference'; import { isValidFramework } from '@/types/docs'; -import { createApiReferenceModel } from '@/utils/apiReferenceModel'; +import { createComponentReferenceModel } from '@/utils/componentReferenceModel'; import FrameworkCase from '../FrameworkCase.astro'; import ApiDataAttrsTable from './ApiDataAttrsTable.astro'; import ApiPropsTable from './ApiPropsTable.astro'; @@ -26,11 +26,11 @@ if (!framework || !isValidFramework(framework)) { throw new Error(`Invalid or missing framework param.`); } -const entry = await getEntry('apiReference', kebabCase(component)); -const apiRef: ComponentApiReference | null = entry?.data ?? null; +const entry = await getEntry('componentReference', kebabCase(component)); +const apiRef: ComponentReference | null = entry?.data ?? null; if (!apiRef) return; -const apiReferenceModel = createApiReferenceModel(component, apiRef); +const apiReferenceModel = createComponentReferenceModel(component, apiRef); if (!apiReferenceModel) return; const singlePropsSection = !apiReferenceModel.hasParts @@ -92,7 +92,7 @@ const singleDataAttributesSection = !apiReferenceModel.hasParts {partDataAttributesSection && ( <>

{partDataAttributesSection.title}

- + )} @@ -128,7 +128,7 @@ const singleDataAttributesSection = !apiReferenceModel.hasParts {singleDataAttributesSection && ( <>

{singleDataAttributesSection.title}

- + )} diff --git a/site/src/components/docs/api-reference/DataAttrRow.astro b/site/src/components/docs/api-reference/DataAttrRow.astro new file mode 100644 index 00000000..f3842144 --- /dev/null +++ b/site/src/components/docs/api-reference/DataAttrRow.astro @@ -0,0 +1,26 @@ +--- +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Td from '@/components/typography/Td.astro'; +import DetailRow from './DetailRow.astro'; + +interface Props { + name: string; + type?: string; + detailedType?: string; + description?: string; + componentName: string; +} + +const { name, type, detailedType, description, componentName } = Astro.props; + +const id = `${componentName}-attr-${name}`; +--- + + + + {name} + + + {type ? {type} : null} + + diff --git a/site/src/components/docs/api-reference/DetailRow.astro b/site/src/components/docs/api-reference/DetailRow.astro index 7cd58cd1..210400a5 100644 --- a/site/src/components/docs/api-reference/DetailRow.astro +++ b/site/src/components/docs/api-reference/DetailRow.astro @@ -19,14 +19,14 @@ interface Props { id: string; name: string; type: string; - shortType?: string; + detailedType?: string; description?: string; colspan: number; } -const { id, name, type, shortType, description, colspan } = Astro.props; +const { id, name, type, detailedType, description, colspan } = Astro.props; -const hasDetail = Boolean(shortType || description); +const hasDetail = Boolean(detailedType || description); --- - { - hasDetail && ( - - - - ) - } + ▸ + + )} + { @@ -74,14 +71,14 @@ const hasDetail = Boolean(shortType || description); )} - {(type || shortType) && ( + {detailedType && ( <>
Type
- {type || shortType} + {detailedType}
diff --git a/site/src/components/docs/api-reference/PropRow.astro b/site/src/components/docs/api-reference/PropRow.astro index cd143175..5e465fc7 100644 --- a/site/src/components/docs/api-reference/PropRow.astro +++ b/site/src/components/docs/api-reference/PropRow.astro @@ -6,20 +6,19 @@ import DetailRow from './DetailRow.astro'; interface Props { name: string; type: string; - shortType?: string; + detailedType?: string; description?: string; defaultValue?: string; required?: boolean; componentName: string; } -const { name, type, shortType, description, defaultValue, required, componentName } = Astro.props; +const { name, type, detailedType, description, defaultValue, required, componentName } = Astro.props; -const displayType = shortType ?? type; const id = `${componentName}-${name}`; --- - + {name} @@ -27,7 +26,7 @@ const id = `${componentName}-${name}`; - {displayType} + {type} diff --git a/site/src/components/docs/api-reference/StateRow.astro b/site/src/components/docs/api-reference/StateRow.astro index 51ef8f40..d3edaeaf 100644 --- a/site/src/components/docs/api-reference/StateRow.astro +++ b/site/src/components/docs/api-reference/StateRow.astro @@ -6,22 +6,21 @@ import DetailRow from './DetailRow.astro'; interface Props { name: string; type: string; - shortType?: string; + detailedType?: string; description?: string; componentName: string; } -const { name, type, shortType, description, componentName } = Astro.props; +const { name, type, detailedType, description, componentName } = Astro.props; -const displayType = shortType ?? type; const id = `${componentName}-state-${name}`; --- - + {name} - {displayType} + {type} diff --git a/site/src/components/docs/api-reference/UtilParamsTable.astro b/site/src/components/docs/api-reference/UtilParamsTable.astro new file mode 100644 index 00000000..707bbd60 --- /dev/null +++ b/site/src/components/docs/api-reference/UtilParamsTable.astro @@ -0,0 +1,46 @@ +--- +/** + * Renders the parameters table for util reference. + * Reuses PropRow.astro since ParamDef has the same shape as PropDef. + */ +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { ParamDef } from '@/types/util-reference'; +import PropRow from './PropRow.astro'; + +interface Props { + params: Record; + utilName: string; +} + +const { params, utilName } = Astro.props; +--- + + + + + + + + + + + { + Object.entries(params).map(([name, param]) => ( + + )) + } + +
ParameterTypeDefault +
diff --git a/site/src/components/docs/api-reference/UtilReference.astro b/site/src/components/docs/api-reference/UtilReference.astro new file mode 100644 index 00000000..6b2daec4 --- /dev/null +++ b/site/src/components/docs/api-reference/UtilReference.astro @@ -0,0 +1,80 @@ +--- +import { getEntry } from 'astro:content'; +import { kebabCase } from 'es-toolkit/string'; +import ContentWidth from '@/components/frames/ContentWidth.astro'; +import H2 from '@/components/typography/H2Markdown.astro'; +import H3 from '@/components/typography/H3Markdown.astro'; +import H4 from '@/components/typography/H4Markdown.astro'; +import P from '@/components/typography/P.astro'; +import type { UtilReference } from '@/types/util-reference'; +import { createUtilReferenceModel } from '@/utils/utilReferenceModel'; +import InlineMarkdown from './InlineMarkdown.astro'; +import UtilParamsTable from './UtilParamsTable.astro'; +import UtilReturnTable from './UtilReturnTable.astro'; + +interface Props { + util: string; + slug?: string; +} + +const { util, slug } = Astro.props; + +const entry = await getEntry('utilReference', slug ?? kebabCase(util)); +const ref: UtilReference | null = entry?.data ?? null; +if (!ref) return; + +const model = createUtilReferenceModel(util, ref); +if (!model) return; +--- + + +

{model.heading.text}

+ + {model.isMultiOverload ? ( + model.overloads.map((overload) => { + const paramsSection = overload.sections.find((s) => s.key === 'parameters'); + const returnSection = overload.sections.find((s) => s.key === 'returnValue'); + const hasParams = Object.keys(overload.data.parameters).length > 0; + + return ( + <> +

{overload.label ?? `Overload ${overload.index}`}

+ + {overload.description && ( +

+ )} + + {paramsSection && hasParams && ( + <> +

{paramsSection.title}

+ + + )} + + {returnSection && ( + <> +

{returnSection.title}

+ + + )} + + ); + }) + ) : ( + <> + {model.sections.find((s) => s.key === 'parameters') && ( + <> +

Parameters

+ + + )} + + {model.sections.find((s) => s.key === 'returnValue') && ( + <> +

Return Value

+ + + )} + + )} +
diff --git a/site/src/components/docs/api-reference/UtilReturnTable.astro b/site/src/components/docs/api-reference/UtilReturnTable.astro new file mode 100644 index 00000000..665619e1 --- /dev/null +++ b/site/src/components/docs/api-reference/UtilReturnTable.astro @@ -0,0 +1,88 @@ +--- +/** + * Renders the return value section for util reference. + * + * Three rendering modes: + * - Mode 1: Object return with `fields` → table using StateRow + * - Mode 2: Simple return with detailedType/description → single-row disclosure table + * - Mode 3: Simple return without detail → inline type text + */ +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import P from '@/components/typography/P.astro'; +import Table from '@/components/typography/Table.astro'; +import Tbody from '@/components/typography/Tbody.astro'; +import Td from '@/components/typography/Td.astro'; +import Th from '@/components/typography/Th.astro'; +import Thead from '@/components/typography/Thead.astro'; +import Tr from '@/components/typography/Tr.astro'; +import type { ReturnValue } from '@/types/util-reference'; +import DetailRow from './DetailRow.astro'; +import InlineMarkdown from './InlineMarkdown.astro'; +import StateRow from './StateRow.astro'; + +interface Props { + returnValue: ReturnValue; + utilName: string; +} + +const { returnValue, utilName } = Astro.props; + +const hasFields = returnValue.fields && Object.keys(returnValue.fields).length > 0; +const hasDetail = !hasFields && Boolean(returnValue.detailedType || returnValue.description); +--- + +{hasFields ? ( + + + + + + + + + { + Object.entries(returnValue.fields!).map(([name, field]) => ( + + )) + } + +
PropertyType +
+) : hasDetail ? ( + + + + + + + + + + + +
Type +
{returnValue.type}
+) : ( +

+ {returnValue.type} + {returnValue.description && ( + <> + {" — "} + + + )} +

+)} diff --git a/site/src/components/docs/demos/create-player/react/css/BasicUsage.css b/site/src/components/docs/demos/create-player/react/css/BasicUsage.css new file mode 100644 index 00000000..f050ebb0 --- /dev/null +++ b/site/src/components/docs/demos/create-player/react/css/BasicUsage.css @@ -0,0 +1,24 @@ +.react-create-player-basic { + position: relative; +} + +.react-create-player-basic video { + width: 100%; +} + +.react-create-player-basic__controls { + position: absolute; + bottom: 10px; + left: 10px; +} + +.react-create-player-basic__button { + padding-block: 8px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-inline: 20px; + cursor: pointer; +} diff --git a/site/src/components/docs/demos/create-player/react/css/BasicUsage.tsx b/site/src/components/docs/demos/create-player/react/css/BasicUsage.tsx new file mode 100644 index 00000000..46c0c769 --- /dev/null +++ b/site/src/components/docs/demos/create-player/react/css/BasicUsage.tsx @@ -0,0 +1,41 @@ +import { createPlayer, features } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './BasicUsage.css'; + +const { Provider, Container, usePlayer } = createPlayer({ + features: features.video, +}); + +function Controls() { + const store = usePlayer(); + const paused = usePlayer((s) => s.paused); + + return ( +
+ +
+ ); +} + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.astro b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.astro new file mode 100644 index 00000000..e7e95f49 --- /dev/null +++ b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +import './BasicUsage.css'; +--- + + + diff --git a/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.css b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.css new file mode 100644 index 00000000..1f0e0a89 --- /dev/null +++ b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.css @@ -0,0 +1,37 @@ +.html-create-player-basic { + position: relative; +} + +.html-create-player-basic video { + width: 100%; +} + +.html-create-player-basic__button { + padding-block: 8px; + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-inline: 20px; + cursor: pointer; +} + +.html-create-player-basic__button .show-when-paused { + display: none; +} + +.html-create-player-basic__button .show-when-playing { + display: none; +} + +.html-create-player-basic__button[data-paused] .show-when-paused { + display: inline; +} + +.html-create-player-basic__button:not([data-paused]) .show-when-playing { + display: inline; +} diff --git a/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.html b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.html new file mode 100644 index 00000000..49156b89 --- /dev/null +++ b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.html @@ -0,0 +1,12 @@ + + + + Play + Pause + + diff --git a/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.ts b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.ts new file mode 100644 index 00000000..71a83c8c --- /dev/null +++ b/site/src/components/docs/demos/html-create-player/html/css/BasicUsage.ts @@ -0,0 +1,57 @@ +import { + applyElementProps, + applyStateDataAttrs, + createButton, + createPlayer, + features, + MediaElement, + selectPlayback, +} from '@videojs/html'; + +const { PlayerElement, PlayerController, context } = createPlayer({ + features: [...features.video], +}); + +class VideoPlayer extends PlayerElement { + static readonly tagName = 'demo-video-player'; +} + +class PlayToggle extends MediaElement { + static readonly tagName = 'demo-play-toggle'; + + readonly #player = new PlayerController(this, context, selectPlayback); + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + + const buttonProps = createButton({ + onActivate: () => { + const state = this.#player.value; + if (!state) return; + state.paused ? state.play() : state.pause(); + }, + isDisabled: () => !this.#player.value, + }); + + applyElementProps(this, buttonProps, this.#disconnect.signal); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + protected override update(): void { + super.update(); + const state = this.#player.value; + if (!state) return; + applyStateDataAttrs(this, state, { paused: 'data-paused', ended: 'data-ended' }); + } +} + +customElements.define(VideoPlayer.tagName, VideoPlayer); +customElements.define(PlayToggle.tagName, PlayToggle); diff --git a/site/src/components/docs/demos/player-controller/html/css/BasicUsage.astro b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.astro new file mode 100644 index 00000000..e7e95f49 --- /dev/null +++ b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +import './BasicUsage.css'; +--- + + + diff --git a/site/src/components/docs/demos/player-controller/html/css/BasicUsage.css b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.css new file mode 100644 index 00000000..20a6e12d --- /dev/null +++ b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.css @@ -0,0 +1,36 @@ +.html-player-controller-basic { + position: relative; +} + +.html-player-controller-basic video { + width: 100%; +} + +.html-player-controller-basic__panel { + display: flex; + gap: 16px; + padding: 12px; + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid rgba(0, 0, 0, 0.1); + align-items: center; +} + +.html-player-controller-basic__actions { + display: flex; + gap: 6px; +} + +.html-player-controller-basic__actions button { + padding: 4px 12px; + border-radius: 6px; + border: 1px solid #ccc; + background: white; + cursor: pointer; + font-size: 0.8125rem; +} + +.html-player-controller-basic__state { + font-size: 0.8125rem; + color: #374151; + font-variant-numeric: tabular-nums; +} diff --git a/site/src/components/docs/demos/player-controller/html/css/BasicUsage.html b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.html new file mode 100644 index 00000000..3da148dc --- /dev/null +++ b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.html @@ -0,0 +1,18 @@ + + +
+ + + + + + + Paused: Yes | Time: 0.0s | Volume: 100% + +
+
diff --git a/site/src/components/docs/demos/player-controller/html/css/BasicUsage.ts b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.ts new file mode 100644 index 00000000..09f18635 --- /dev/null +++ b/site/src/components/docs/demos/player-controller/html/css/BasicUsage.ts @@ -0,0 +1,67 @@ +import { applyElementProps, createButton, createPlayer, features, MediaElement } from '@videojs/html'; + +const { PlayerElement, PlayerController, context } = createPlayer({ + features: [...features.video], +}); + +class DemoPlayer extends PlayerElement { + static readonly tagName = 'demo-ctrl-player'; +} + +class PlayerActions extends MediaElement { + static readonly tagName = 'demo-ctrl-actions'; + + readonly #player = new PlayerController(this, context); + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + const signal = this.#disconnect.signal; + + const playBtn = this.querySelector('.action-play')!; + const pauseBtn = this.querySelector('.action-pause')!; + const volumeBtn = this.querySelector('.action-volume')!; + + const bind = (el: HTMLElement, action: () => void) => { + const props = createButton({ onActivate: action, isDisabled: () => !this.#player.value }); + applyElementProps(el, props, signal); + }; + + bind(playBtn, () => this.#player.value?.play()); + bind(pauseBtn, () => this.#player.value?.pause()); + bind(volumeBtn, () => this.#player.value?.changeVolume(0.5)); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } +} + +class PlayerState extends MediaElement { + static readonly tagName = 'demo-ctrl-state'; + + readonly #state = new PlayerController(this, context, (s) => ({ + paused: s.paused, + currentTime: s.currentTime, + volume: s.volume, + })); + + protected override update(): void { + super.update(); + const state = this.#state.value; + if (!state) return; + + const el = this.querySelector('.state-text'); + if (el) { + el.textContent = `Paused: ${state.paused ? 'Yes' : 'No'} | Time: ${state.currentTime.toFixed(1)}s | Volume: ${Math.round(state.volume * 100)}%`; + } + } +} + +customElements.define(DemoPlayer.tagName, DemoPlayer); +customElements.define(PlayerActions.tagName, PlayerActions); +customElements.define(PlayerState.tagName, PlayerState); diff --git a/site/src/components/docs/demos/render-element/react/css/BasicUsage.css b/site/src/components/docs/demos/render-element/react/css/BasicUsage.css new file mode 100644 index 00000000..4e5f5c4f --- /dev/null +++ b/site/src/components/docs/demos/render-element/react/css/BasicUsage.css @@ -0,0 +1,36 @@ +.react-render-element-basic { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; +} + +.react-render-element-basic__toggle { + align-self: flex-start; + padding: 6px 16px; + border-radius: 6px; + border: 1px solid #ccc; + background: #f5f5f5; + cursor: pointer; +} + +.react-render-element-basic__tags { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.react-render-element-basic__tag { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 9999px; + background: #e5e7eb; + color: #374151; + transition: all 0.2s ease; +} + +.react-render-element-basic__tag--active { + background: #3b82f6; + color: white; +} diff --git a/site/src/components/docs/demos/render-element/react/css/BasicUsage.tsx b/site/src/components/docs/demos/render-element/react/css/BasicUsage.tsx new file mode 100644 index 00000000..8a8f1cee --- /dev/null +++ b/site/src/components/docs/demos/render-element/react/css/BasicUsage.tsx @@ -0,0 +1,64 @@ +import { renderElement } from '@videojs/react'; +import { type ReactNode, useState } from 'react'; + +import './BasicUsage.css'; + +interface TagState { + active: boolean; +} + +function Tag({ + className, + style, + render, + active, + children, +}: renderElement.ComponentProps & { active: boolean; children?: ReactNode }) { + const state: TagState = { active }; + + return renderElement( + 'span', + { className, style, render }, + { + state, + props: { children }, + stateAttrMap: { active: 'data-active' }, + } + ); +} + +export default function BasicUsage() { + const [active, setActive] = useState(false); + + const className = (state: TagState) => + `react-render-element-basic__tag${state.active ? ' react-render-element-basic__tag--active' : ''}`; + + const style = (state: TagState) => ({ + fontSize: state.active ? '1.125rem' : '0.875rem', + }); + + return ( +
+ + +
+ + Default <span> + + + }> + Element <strong> + + + {state.active ? 'Active!' : 'Inactive'}} + /> +
+
+ ); +} diff --git a/site/src/components/docs/demos/use-button/react/css/BasicUsage.css b/site/src/components/docs/demos/use-button/react/css/BasicUsage.css new file mode 100644 index 00000000..d7d98c30 --- /dev/null +++ b/site/src/components/docs/demos/use-button/react/css/BasicUsage.css @@ -0,0 +1,30 @@ +.react-use-button-basic { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} + +.react-use-button-basic__button { + align-self: flex-start; + padding: 8px 20px; + border-radius: 6px; + border: 1px solid #ccc; + background: #f5f5f5; + cursor: pointer; + font-variant-numeric: tabular-nums; + transition: opacity 0.2s; +} + +.react-use-button-basic__button[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.react-use-button-basic__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.875rem; + color: #6b7280; +} diff --git a/site/src/components/docs/demos/use-button/react/css/BasicUsage.tsx b/site/src/components/docs/demos/use-button/react/css/BasicUsage.tsx new file mode 100644 index 00000000..49d18cc0 --- /dev/null +++ b/site/src/components/docs/demos/use-button/react/css/BasicUsage.tsx @@ -0,0 +1,27 @@ +import { useButton } from '@videojs/react'; +import { useState } from 'react'; + +import './BasicUsage.css'; + +export default function BasicUsage() { + const [count, setCount] = useState(0); + const [disabled, setDisabled] = useState(false); + + const { getButtonProps, buttonRef } = useButton({ + displayName: 'ActivateButton', + onActivate: () => setCount((c) => c + 1), + isDisabled: () => disabled, + }); + + return ( +
+ + +
+ ); +} diff --git a/site/src/components/docs/demos/use-media/react/css/BasicUsage.css b/site/src/components/docs/demos/use-media/react/css/BasicUsage.css new file mode 100644 index 00000000..59fedac3 --- /dev/null +++ b/site/src/components/docs/demos/use-media/react/css/BasicUsage.css @@ -0,0 +1,36 @@ +.react-use-media-basic { + position: relative; +} + +.react-use-media-basic video { + width: 100%; +} + +.react-use-media-basic__info { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 12px; + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid rgba(0, 0, 0, 0.1); + font-size: 0.8125rem; +} + +.react-use-media-basic__info div { + display: flex; + gap: 8px; +} + +.react-use-media-basic__info dt { + color: #6b7280; + min-width: 80px; +} + +.react-use-media-basic__info dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/site/src/components/docs/demos/use-media/react/css/BasicUsage.tsx b/site/src/components/docs/demos/use-media/react/css/BasicUsage.tsx new file mode 100644 index 00000000..442d3300 --- /dev/null +++ b/site/src/components/docs/demos/use-media/react/css/BasicUsage.tsx @@ -0,0 +1,52 @@ +import { createPlayer, features, useMedia } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './BasicUsage.css'; + +const { Provider, Container } = createPlayer({ + features: features.video, +}); + +function MediaInfo() { + const media = useMedia(); + + if (!media) return null; + + return ( +
+
+
tagName
+
{media.tagName.toLowerCase()}
+
+
+
src
+
{media.currentSrc || '—'}
+
+
+
videoWidth
+
{media.videoWidth}px
+
+
+
videoHeight
+
{media.videoHeight}px
+
+
+ ); +} + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/use-player/react/css/Selector.css b/site/src/components/docs/demos/use-player/react/css/Selector.css new file mode 100644 index 00000000..788c6185 --- /dev/null +++ b/site/src/components/docs/demos/use-player/react/css/Selector.css @@ -0,0 +1,31 @@ +.react-use-player-selector { + position: relative; +} + +.react-use-player-selector video { + width: 100%; +} + +.react-use-player-selector__state { + display: flex; + gap: 16px; + padding: 12px; + margin: 0; + font-size: 0.8125rem; + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.react-use-player-selector__state div { + display: flex; + gap: 8px; +} + +.react-use-player-selector__state dt { + color: #6b7280; +} + +.react-use-player-selector__state dd { + margin: 0; + font-variant-numeric: tabular-nums; +} diff --git a/site/src/components/docs/demos/use-player/react/css/Selector.tsx b/site/src/components/docs/demos/use-player/react/css/Selector.tsx new file mode 100644 index 00000000..aed8bea5 --- /dev/null +++ b/site/src/components/docs/demos/use-player/react/css/Selector.tsx @@ -0,0 +1,48 @@ +import { createPlayer, features, usePlayer } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './Selector.css'; + +const { Provider, Container } = createPlayer({ + features: features.video, +}); + +function StateDisplay() { + const state = usePlayer((s) => ({ + paused: s.paused, + currentTime: s.currentTime, + duration: s.duration, + })); + + return ( +
+
+
Paused
+
{String(state.paused)}
+
+
+
Time
+
+ {state.currentTime.toFixed(1)}s / {state.duration.toFixed(1)}s +
+
+
+ ); +} + +export default function Selector() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/use-player/react/css/StoreAccess.css b/site/src/components/docs/demos/use-player/react/css/StoreAccess.css new file mode 100644 index 00000000..c5a2f610 --- /dev/null +++ b/site/src/components/docs/demos/use-player/react/css/StoreAccess.css @@ -0,0 +1,24 @@ +.react-use-player-store { + position: relative; +} + +.react-use-player-store video { + width: 100%; +} + +.react-use-player-store__controls { + display: flex; + gap: 6px; + padding: 12px; + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.react-use-player-store__controls button { + padding: 4px 12px; + border-radius: 6px; + border: 1px solid #ccc; + background: white; + cursor: pointer; + font-size: 0.8125rem; +} diff --git a/site/src/components/docs/demos/use-player/react/css/StoreAccess.tsx b/site/src/components/docs/demos/use-player/react/css/StoreAccess.tsx new file mode 100644 index 00000000..abcb8c46 --- /dev/null +++ b/site/src/components/docs/demos/use-player/react/css/StoreAccess.tsx @@ -0,0 +1,40 @@ +import { createPlayer, features, usePlayer } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './StoreAccess.css'; + +const { Provider, Container } = createPlayer({ + features: features.video, +}); + +function Controls() { + const store = usePlayer(); + + return ( +
+ + +
+ ); +} + +export default function StoreAccess() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/use-store/react/css/Selector.css b/site/src/components/docs/demos/use-store/react/css/Selector.css new file mode 100644 index 00000000..40dbde51 --- /dev/null +++ b/site/src/components/docs/demos/use-store/react/css/Selector.css @@ -0,0 +1,31 @@ +.react-use-store-selector { + position: relative; +} + +.react-use-store-selector video { + width: 100%; +} + +.react-use-store-selector__state { + display: flex; + gap: 16px; + padding: 12px; + margin: 0; + font-size: 0.8125rem; + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.react-use-store-selector__state div { + display: flex; + gap: 8px; +} + +.react-use-store-selector__state dt { + color: #6b7280; +} + +.react-use-store-selector__state dd { + margin: 0; + font-variant-numeric: tabular-nums; +} diff --git a/site/src/components/docs/demos/use-store/react/css/Selector.tsx b/site/src/components/docs/demos/use-store/react/css/Selector.tsx new file mode 100644 index 00000000..1c14ab4e --- /dev/null +++ b/site/src/components/docs/demos/use-store/react/css/Selector.tsx @@ -0,0 +1,46 @@ +import { createPlayer, features, usePlayer, useStore } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './Selector.css'; + +const { Provider, Container } = createPlayer({ + features: features.video, +}); + +function DerivedState() { + const store = usePlayer(); + const derived = useStore(store, (s) => ({ + remaining: s.duration - s.currentTime, + progress: s.duration > 0 ? (s.currentTime / s.duration) * 100 : 0, + })); + + return ( +
+
+
Remaining
+
{derived.remaining.toFixed(1)}s
+
+
+
Progress
+
{derived.progress.toFixed(1)}%
+
+
+ ); +} + +export default function Selector() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/use-store/react/css/StoreAccess.css b/site/src/components/docs/demos/use-store/react/css/StoreAccess.css new file mode 100644 index 00000000..88e3f4e4 --- /dev/null +++ b/site/src/components/docs/demos/use-store/react/css/StoreAccess.css @@ -0,0 +1,24 @@ +.react-use-store-access { + position: relative; +} + +.react-use-store-access video { + width: 100%; +} + +.react-use-store-access__controls { + display: flex; + gap: 6px; + padding: 12px; + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.react-use-store-access__controls button { + padding: 4px 12px; + border-radius: 6px; + border: 1px solid #ccc; + background: white; + cursor: pointer; + font-size: 0.8125rem; +} diff --git a/site/src/components/docs/demos/use-store/react/css/StoreAccess.tsx b/site/src/components/docs/demos/use-store/react/css/StoreAccess.tsx new file mode 100644 index 00000000..8b4ec328 --- /dev/null +++ b/site/src/components/docs/demos/use-store/react/css/StoreAccess.tsx @@ -0,0 +1,41 @@ +import { createPlayer, features, usePlayer, useStore } from '@videojs/react'; +import { Video } from '@videojs/react/video'; + +import './StoreAccess.css'; + +const { Provider, Container } = createPlayer({ + features: features.video, +}); + +function SeekControls() { + const store = usePlayer(); + const s = useStore(store); + + return ( +
+ + +
+ ); +} + +export default function StoreAccess() { + return ( + + + + + ); +} diff --git a/site/src/content.config.ts b/site/src/content.config.ts index 357d855e..411e49a3 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -1,7 +1,8 @@ import { defineCollection, reference, z } from 'astro:content'; import { file, glob } from 'astro/loaders'; -import { ComponentApiReferenceSchema } from './types/api-reference'; +import { ComponentReferenceSchema } from './types/component-reference'; import { SUPPORTED_FRAMEWORKS } from './types/docs'; +import { UtilReferenceSchema } from './types/util-reference'; import { defaultGitService } from './utils/gitService'; import { globWithParser } from './utils/globWithParser'; @@ -107,12 +108,20 @@ const authors = defineCollection({ }), }); -const apiReference = defineCollection({ +const componentReference = defineCollection({ loader: glob({ pattern: '*.json', - base: './src/content/generated-api-reference', + base: './src/content/generated-component-reference', }), - schema: ComponentApiReferenceSchema, + schema: ComponentReferenceSchema, }); -export const collections = { blog, docs, authors, apiReference }; +const utilReference = defineCollection({ + loader: glob({ + pattern: '*.json', + base: './src/content/generated-util-reference', + }), + schema: UtilReferenceSchema, +}); + +export const collections = { blog, docs, authors, componentReference, utilReference }; diff --git a/site/src/content/docs/reference/buffering-indicator.mdx b/site/src/content/docs/reference/buffering-indicator.mdx index 720ae84f..857671b2 100644 --- a/site/src/content/docs/reference/buffering-indicator.mdx +++ b/site/src/content/docs/reference/buffering-indicator.mdx @@ -5,7 +5,7 @@ frameworkTitle: description: Loading indicator that displays when the video player is buffering or waiting for data --- -import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; +import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; import FrameworkCase from "@/components/docs/FrameworkCase.astro"; import StyleCase from "@/components/docs/StyleCase.astro"; import Demo from "@/components/docs/demos/Demo.astro"; @@ -70,4 +70,4 @@ Hide and show the indicator based on the `data-visible` attribute. - + diff --git a/site/src/content/docs/reference/container-mixin.mdx b/site/src/content/docs/reference/container-mixin.mdx new file mode 100644 index 00000000..96f609a3 --- /dev/null +++ b/site/src/content/docs/reference/container-mixin.mdx @@ -0,0 +1,35 @@ +--- +title: ContainerMixin +description: Mixin that consumes player context and auto-attaches media elements +--- + +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; + +`ContainerMixin` creates a class that consumes the player store from context and automatically attaches `