diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts index 99343eda..b916589c 100644 --- a/packages/core/src/core/ui/time/time-core.ts +++ b/packages/core/src/core/ui/time/time-core.ts @@ -3,21 +3,21 @@ import { isFunction } from '@videojs/utils/predicate'; import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '@videojs/utils/time'; import type { NonNullableObject } from '@videojs/utils/types'; -import type { TimeState } from '../../media/state'; +import type { TimeState as MediaTimeState } from '../../media/state'; /** Time display type. */ export type TimeType = 'current' | 'duration' | 'remaining'; -export interface TimeCoreProps { +export interface TimeProps { /** Which time value to display. */ type?: TimeType | undefined; /** Symbol prepended to remaining time. */ negativeSign?: string | undefined; /** Custom label for accessibility. */ - label?: string | ((state: TimeValueState) => string) | undefined; + label?: string | ((state: TimeState) => string) | undefined; } -export interface TimeValueState { +export interface TimeState { /** Time display type. */ type: TimeType; /** Raw value in seconds. */ @@ -37,7 +37,7 @@ const DEFAULT_LABELS: Record = { }; export class TimeCore { - static readonly defaultProps: NonNullableObject = { + static readonly defaultProps: NonNullableObject = { type: 'current', negativeSign: '-', label: '', @@ -45,15 +45,15 @@ export class TimeCore { #props = { ...TimeCore.defaultProps }; - constructor(props?: TimeCoreProps) { + constructor(props?: TimeProps) { if (props) this.setProps(props); } - setProps(props: TimeCoreProps): void { + setProps(props: TimeProps): void { this.#props = defaults(props, TimeCore.defaultProps); } - #getSeconds(time: TimeState): number { + #getSeconds(time: MediaTimeState): number { const { type } = this.#props; switch (type) { case 'current': @@ -67,7 +67,7 @@ export class TimeCore { } } - #getText(time: TimeState): string { + #getText(time: MediaTimeState): string { const { type, negativeSign } = this.#props; const seconds = this.#getSeconds(time); @@ -79,7 +79,7 @@ export class TimeCore { return formatTime(seconds, time.duration); } - #getPhrase(time: TimeState): string { + #getPhrase(time: MediaTimeState): string { const { type } = this.#props; const seconds = this.#getSeconds(time); @@ -91,12 +91,12 @@ export class TimeCore { return formatTimeAsPhrase(seconds); } - #getDatetime(time: TimeState): string { + #getDatetime(time: MediaTimeState): string { const seconds = this.#getSeconds(time); return secondsToIsoDuration(Math.abs(seconds)); } - getLabel(time: TimeState): string { + getLabel(time: MediaTimeState): string { const state = this.getState(time); const { label } = this.#props; @@ -110,14 +110,14 @@ export class TimeCore { return DEFAULT_LABELS[this.#props.type]; } - getAttrs(time: TimeState): Record { + getAttrs(time: MediaTimeState): Record { return { 'aria-label': this.getLabel(time), 'aria-valuetext': this.#getPhrase(time), }; } - getState(time: TimeState): TimeValueState { + getState(time: MediaTimeState): TimeState { const seconds = this.#getSeconds(time); return { type: this.#props.type, @@ -130,6 +130,6 @@ export class TimeCore { } export namespace TimeCore { - export type Props = TimeCoreProps; - export type State = TimeValueState; + export type Props = TimeProps; + export type State = TimeState; } diff --git a/site/CLAUDE.md b/site/CLAUDE.md index e64cf88c..f55d7aaf 100644 --- a/site/CLAUDE.md +++ b/site/CLAUDE.md @@ -442,15 +442,17 @@ The API docs builder extracts type information from TypeScript sources and gener ### How It Works ``` -packages/core/src/core/ui/{component}/ → JSON → → tables +packages/core/html/react/ → JSON → → tables ``` 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. **Outputs** JSON to `src/content/generated-api-reference/{component}.json` -6. **Astro components** (`src/components/docs/api-reference/`) render the JSON as tables +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 @@ -461,29 +463,29 @@ The `src/content/generated-api-reference/` directory is **gitignored**. JSON fil ### Usage in MDX +Use the unified `` component for both single-part and multi-part components: + ```mdx -import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; +import ApiReference from '@/components/docs/api-reference/ApiReference.astro'; -## API Reference - -### Props - - - -### State - - - -### Data Attributes - - + ``` +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 as described above +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. diff --git a/site/scripts/api-docs-builder/README.md b/site/scripts/api-docs-builder/README.md index 756bfef8..1b003ec2 100644 --- a/site/scripts/api-docs-builder/README.md +++ b/site/scripts/api-docs-builder/README.md @@ -5,13 +5,13 @@ Generates interactive API documentation from TypeScript sources for Video.js 10 ## Architecture ``` -TypeScript Sources (core/html packages) +TypeScript Sources (core/html/react packages) ↓ api-docs-builder (typescript-api-extractor) ↓ JSON files (site/src/content/generated-api-reference/) ↓ - Astro component + Astro component ↓ Interactive tables in MDX pages ``` @@ -25,6 +25,7 @@ The builder scans `packages/core/src/core/ui/` for component directories. For ea - **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 @@ -70,34 +71,35 @@ Generates one JSON file per component at `site/src/content/generated-api-referen ### 4. Astro Components -The `` component: +The `` component: 1. Loads the JSON via Astro Content Collections (`getEntry('apiReference', 'play-button')`) -2. Filters props based on current framework (hides React-only props on HTML pages) -3. Renders interactive tables with expandable prop details +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 ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; +import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; -## API Reference - -### Props - - - -### State - - - -### Data Attributes - - + ``` +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: @@ -111,6 +113,58 @@ pnpm dev # via predev hook pnpm build # via prebuild hook ``` +## Multi-Part Components + +Some components are composed of multiple parts (e.g., Time has Value, Group, Separator). The builder auto-discovers these via convention. + +### 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 ``` @@ -120,22 +174,27 @@ site/scripts/api-docs-builder/ ├── 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 └── tests/ ├── test-utils.ts ├── core-handler.test.ts ├── data-attrs-handler.test.ts ├── formatter.test.ts - └── html-handler.test.ts + ├── html-handler.test.ts + ├── parts-handler.test.ts + └── utils.test.ts site/src/ ├── content/generated-api-reference/ # Generated JSON files (gitignored) │ ├── play-button.json -│ └── mute-button.json +│ ├── mute-button.json +│ └── time.json └── components/docs/api-reference/ - ├── ApiRefSection.astro # Main wrapper, loads JSON + ├── ApiReference.astro # Unified component — renders full API reference from JSON ├── ApiPropsTable.astro # Props table ├── ApiStateTable.astro # State interface table ├── ApiDataAttrsTable.astro # Data attributes table @@ -144,16 +203,32 @@ site/src/ ## 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 as described above +6. Use `` in MDX -## Differences from base-ui +### Multi-Part Component -This implementation is adapted from MUI base-ui's api-docs-builder with key differences: +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 diff --git a/site/scripts/api-docs-builder/src/core-handler.ts b/site/scripts/api-docs-builder/src/core-handler.ts index b9f2727b..7bdfa126 100644 --- a/site/scripts/api-docs-builder/src/core-handler.ts +++ b/site/scripts/api-docs-builder/src/core-handler.ts @@ -29,7 +29,7 @@ export function extractCore(filePath: string, program: ts.Program, componentName let description: string | undefined; if (propsExport?.type instanceof tae.ObjectNode) { - const formatted = formatProperties(propsExport.type.properties); + const formatted = formatProperties(propsExport.type.properties, ast.exports); props = Object.entries(formatted).map(([name, def]) => ({ name, ...def, @@ -40,7 +40,7 @@ export function extractCore(filePath: string, program: ts.Program, componentName // Extract state let state: ExtractedProp[] = []; if (stateExport?.type instanceof tae.ObjectNode) { - const formatted = formatProperties(stateExport.type.properties); + const formatted = formatProperties(stateExport.type.properties, ast.exports); state = Object.entries(formatted).map(([name, def]) => ({ name, ...def, diff --git a/site/scripts/api-docs-builder/src/formatter.ts b/site/scripts/api-docs-builder/src/formatter.ts index 0e196958..8eb92134 100644 --- a/site/scripts/api-docs-builder/src/formatter.ts +++ b/site/scripts/api-docs-builder/src/formatter.ts @@ -30,7 +30,7 @@ export function getShortPropType(name: string, type: string): string | undefined } // Short unions (less than 3 members and under 40 chars) → no abbreviation - if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40)) { + if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40 && !type.includes('=>'))) { return undefined; } @@ -51,7 +51,7 @@ export function getShortPropType(name: string, type: string): string | undefined /** * Format a list of properties into API reference format. */ -export function formatProperties(props: tae.PropertyNode[]): Record { +export function formatProperties(props: tae.PropertyNode[], allExports?: tae.ExportNode[]): Record { const result: Record = {}; for (const prop of props) { @@ -60,7 +60,9 @@ export function formatProperties(props: tae.PropertyNode[]): Record = new Set() +): string { + if (type instanceof tae.ExternalTypeNode) { + const name = type.typeName.name; + + if (!visited.has(name)) { + const resolved = allExports.find((exp) => exp.name === name && exp.reexportedFrom === undefined); + if (resolved) { + visited.add(name); + return formatDetailedType(resolved.type, allExports, removeUndefined, visited); + } + } + + return formatType(type, removeUndefined); + } + + if (type instanceof tae.UnionNode) { + let memberTypes = type.types; + + if (removeUndefined) { + memberTypes = memberTypes.filter((t) => !(t instanceof tae.IntrinsicNode && t.intrinsic === 'undefined')); + } + + const flattenedMemberTypes = memberTypes.flatMap((t) => { + if (t instanceof tae.UnionNode) { + return t.typeName ? t : t.types; + } + if (t instanceof tae.TypeParameterNode && t.constraint instanceof tae.UnionNode) { + return t.constraint.types; + } + return t; + }); + + const formattedMemberTypes = uniq( + orderMembers(flattenedMemberTypes).map((t) => formatDetailedType(t, allExports, removeUndefined, visited)) + ); + + return formattedMemberTypes.join(' | '); + } + + if (type instanceof tae.IntersectionNode) { + return orderMembers(type.types) + .map((t) => formatDetailedType(t, allExports, false, visited)) + .join(' & '); + } + + return formatType(type, removeUndefined); +} + /** * Format a type into a human-readable string. */ diff --git a/site/scripts/api-docs-builder/src/html-handler.ts b/site/scripts/api-docs-builder/src/html-handler.ts index 4cec4421..26e6525c 100644 --- a/site/scripts/api-docs-builder/src/html-handler.ts +++ b/site/scripts/api-docs-builder/src/html-handler.ts @@ -2,14 +2,20 @@ import * as ts from 'typescript'; import type { HtmlExtraction } from './types.js'; /** Extract tagName from a Lit element file. */ -export function extractHtml(filePath: string, program: ts.Program, componentName: string): HtmlExtraction | null { +export function extractHtml( + filePath: string, + program: ts.Program, + componentName: string, + elementName?: string +): HtmlExtraction | null { const sourceFile = program.getSourceFile(filePath); if (!sourceFile) return null; + const className = elementName ?? `${componentName}Element`; let tagName = ''; function visit(node: ts.Node) { - if (ts.isClassDeclaration(node) && node.name?.text === `${componentName}Element`) { + if (ts.isClassDeclaration(node) && node.name?.text === className) { for (const member of node.members) { if ( ts.isPropertyDeclaration(member) && diff --git a/site/scripts/api-docs-builder/src/index.ts b/site/scripts/api-docs-builder/src/index.ts index 006c2894..0c038a61 100644 --- a/site/scripts/api-docs-builder/src/index.ts +++ b/site/scripts/api-docs-builder/src/index.ts @@ -6,15 +6,61 @@ import * as tae from 'typescript-api-extractor'; import { extractCore } from './core-handler.js'; import { extractDataAttrs } from './data-attrs-handler.js'; import { extractHtml } from './html-handler.js'; +import { extractPartDescription, extractParts } from './parts-handler.js'; import { type ComponentApiReference, ComponentApiReferenceSchema, type ComponentSource, + type CoreExtraction, type DataAttrDef, + type DataAttrsExtraction, + type PartApiReference, + type PartSource, type PropDef, type StateDef, } from './types.js'; -import { kebabToPascal, sortProps } from './utils.js'; +import { kebabToPascal, partKebabFromSource, sortProps } from './utils.js'; + +function buildProps(coreData: CoreExtraction): Record { + const props: Record = {}; + for (const prop of coreData.props) { + props[prop.name] = { + type: prop.type, + shortType: prop.shortType, + 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]!.description === undefined) delete props[prop.name]!.description; + if (props[prop.name]!.default === undefined) delete props[prop.name]!.default; + if (!props[prop.name]!.required) delete props[prop.name]!.required; + } + return props; +} + +function buildState(coreData: CoreExtraction): Record { + const state: Record = {}; + for (const s of coreData.state) { + state[s.name] = { + type: s.type, + shortType: s.shortType, + description: s.description, + }; + if (state[s.name]!.shortType === undefined) delete state[s.name]!.shortType; + if (state[s.name]!.description === undefined) delete state[s.name]!.description; + } + return state; +} + +function buildDataAttrs(dataAttrsData: DataAttrsExtraction): Record { + const dataAttributes: Record = {}; + for (const attr of dataAttrsData.attrs) { + dataAttributes[attr.name] = { description: attr.description }; + } + return dataAttributes; +} // Magenta prefix - visible on both light and dark terminals const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; @@ -30,6 +76,7 @@ const log = { 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'); /** @@ -74,6 +121,12 @@ function discoverComponents(): ComponentSource[] { source.htmlPath = htmlFile; } + // Check for multi-part component (index.parts.ts in React package) + const partsIndexFile = path.join(REACT_UI_PATH, dir.name, 'index.parts.ts'); + if (fs.existsSync(partsIndexFile)) { + source.partsIndexPath = partsIndexFile; + } + // Only include if we have at least a core file if (source.corePath) { components.push(source); @@ -93,6 +146,33 @@ function createProgram(sources: ComponentSource[]): ts.Program { if (source.corePath) files.push(source.corePath); if (source.dataAttrsPath) files.push(source.dataAttrsPath); if (source.htmlPath) files.push(source.htmlPath); + if (source.partsIndexPath) files.push(source.partsIndexPath); + + // For multi-part components, include all element files from the HTML directory + // and React source files for JSDoc description extraction + if (source.partsIndexPath) { + const componentKebab = kebabCase(source.name); + const htmlDir = path.join(HTML_UI_PATH, componentKebab); + if (fs.existsSync(htmlDir)) { + const elementFiles = fs.readdirSync(htmlDir).filter((f) => f.endsWith('-element.ts')); + for (const file of elementFiles) { + const fullPath = path.join(htmlDir, file); + if (!files.includes(fullPath)) { + files.push(fullPath); + } + } + } + + // Include React component .tsx files for JSDoc description extraction + const reactDir = path.dirname(source.partsIndexPath); + const reactFiles = fs.readdirSync(reactDir).filter((f) => f.endsWith('.tsx')); + for (const file of reactFiles) { + const fullPath = path.join(reactDir, file); + if (!files.includes(fullPath)) { + files.push(fullPath); + } + } + } } // Load base tsconfig - works for all packages since we only need type resolution @@ -105,9 +185,9 @@ function createProgram(sources: ComponentSource[]): ts.Program { } /** - * Build the API reference for a single component. + * Build the API reference for a single-part component. */ -function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null { +function buildSingleComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null { // Extract from core const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; @@ -122,51 +202,13 @@ function buildComponentApiReference(source: ComponentSource, program: ts.Program // Extract HTML element info const htmlData = source.htmlPath ? extractHtml(source.htmlPath, program, source.name) : null; - // Build props record - const props: Record = {}; - for (const prop of coreData.props) { - props[prop.name] = { - type: prop.type, - shortType: prop.shortType, - description: prop.description, - default: coreData.defaultProps[prop.name] ?? prop.default, - required: prop.required, - }; - - // Clean up undefined values - if (props[prop.name]!.shortType === undefined) delete props[prop.name]!.shortType; - 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; - } - - // Build state record - const state: Record = {}; - for (const s of coreData.state) { - state[s.name] = { - type: s.type, - description: s.description, - }; - if (state[s.name]!.description === undefined) delete state[s.name]!.description; - } - - // Build data attributes record - const dataAttributes: Record = {}; - if (dataAttrsData) { - for (const attr of dataAttrsData.attrs) { - dataAttributes[attr.name] = { - description: attr.description, - }; - } - } - // Build result const result: ComponentApiReference = { name: source.name, description: coreData.description, - props, - state, - dataAttributes, + props: buildProps(coreData), + state: buildState(coreData), + dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, platforms: {}, }; @@ -183,10 +225,178 @@ function buildComponentApiReference(source: ComponentSource, program: ts.Program return result; } +/** + * Discover parts and match them to HTML element files. + * + * Matching algorithm: + * 1. Parse `index.parts.ts` for named exports -> part names and source paths + * 2. Derive kebab segment from source: `./time-value` -> strip `./time-` prefix -> `value` + * 3. For each part, look for `{name}-{kebab}-element.ts` in HTML dir (e.g., `time-group-element.ts`) + * 4. The part with NO matching `{name}-{kebab}-element.ts` but where `{name}-element.ts` exists -> primary part + * 5. Primary part gets: shared core file, shared data-attrs, main element (`{name}-element.ts`) + */ +function discoverParts(source: ComponentSource, program: ts.Program): PartSource[] { + if (!source.partsIndexPath) return []; + + const partExports = extractParts(source.partsIndexPath, program); + if (partExports.length === 0) return []; + + const componentKebab = kebabCase(source.name); + const htmlDir = path.join(HTML_UI_PATH, componentKebab); + + const parts: PartSource[] = []; + let hasPrimary = false; + + for (const partExport of partExports) { + const kebab = partKebabFromSource(partExport.source, componentKebab); + + // Look for sub-part element file: {component}-{part}-element.ts + const subPartElementFile = path.join(htmlDir, `${componentKebab}-${kebab}-element.ts`); + const hasSubPartElement = fs.existsSync(subPartElementFile); + + // Primary part: no matching sub-part element, but main element exists + const isPrimary = !hasSubPartElement && !!source.htmlPath; + + if (isPrimary) hasPrimary = true; + + // Resolve React source path for JSDoc description extraction + const reactFile = path.join(path.dirname(source.partsIndexPath), `${partExport.source.replace('./', '')}.tsx`); + const reactPath = fs.existsSync(reactFile) ? reactFile : undefined; + + const part: PartSource = { + name: partExport.name, + kebab, + isPrimary, + htmlPath: hasSubPartElement ? subPartElementFile : isPrimary ? source.htmlPath : undefined, + reactPath, + }; + + if (!part.htmlPath) { + log.warn(`${source.name}: Part "${partExport.name}" has no matching HTML element file`); + } + + parts.push(part); + } + + if (!hasPrimary) { + log.warn(`${source.name}: No primary part identified (expected one part to use ${componentKebab}-element.ts)`); + } + + // Primary part first so it appears first in the docs. + return parts.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)); +} + +/** + * Build the API reference for a multi-part component. + * + * Multi-part components have empty top-level props/state/dataAttributes. + * All data is in the `parts` record. + * + * For the primary part: + * - Props and state come from the shared core file (`{name}-core.ts`) + * - Data attributes come from the shared data-attrs file (`{name}-data-attrs.ts`) + * - HTML tag comes from the main element file (`{name}-element.ts`) + * + * For non-primary parts: + * - 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( + source: ComponentSource, + program: ts.Program, + parts: PartSource[] +): ComponentApiReference | null { + const partsRecord: Record = {}; + + for (const part of parts) { + // Extract JSDoc description from React component file + const description = part.reactPath ? extractPartDescription(part.reactPath, program, part.name) : undefined; + + if (part.isPrimary) { + // Primary part: extract from shared core and data-attrs + const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null; + const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null; + + const elementName = `${source.name}Element`; + const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null; + + const partRef: PartApiReference = { + name: part.name, + description, + props: coreData ? sortProps(buildProps(coreData)) : {}, + state: coreData ? buildState(coreData) : {}, + dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {}, + platforms: {}, + }; + + if (!partRef.description) delete partRef.description; + if (htmlData) { + partRef.platforms.html = { tagName: htmlData.tagName }; + } + + partsRecord[part.kebab] = partRef; + } else { + // Non-primary part: extract only HTML tag + const elementName = `${source.name}${part.name}Element`; + const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null; + + const partRef: PartApiReference = { + name: part.name, + description, + props: {}, + state: {}, + dataAttributes: {}, + platforms: {}, + }; + + if (!partRef.description) delete partRef.description; + if (htmlData) { + partRef.platforms.html = { tagName: htmlData.tagName }; + } + + partsRecord[part.kebab] = partRef; + } + } + + return { + name: source.name, + props: {}, + state: {}, + dataAttributes: {}, + platforms: {}, + parts: partsRecord, + }; +} + +/** + * Build the API reference for a single component. + */ +function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null { + if (source.partsIndexPath) { + const parts = discoverParts(source, program); + if (parts.length > 0) { + return buildMultiPartApiReference(source, program, parts); + } + } + + return buildSingleComponentApiReference(source, program); +} + /** * Main entry point. */ function main() { + // typescript-api-extractor doesn't handle the `never` TypeScript type flag + // (or a few others like ESSymbol, TemplateLiteral). It falls back to `any` + // and logs a warning for each occurrence. This is a known gap in the alpha + // library — not a bug in our types. Suppress the noise here. + // https://github.com/michaldudak/typescript-api-extractor/blob/main/src/parsers/typeResolver.ts + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith('Unable to handle a type with flag')) return; + originalWarn.apply(console, args); + }; + // Ensure output directory exists if (!fs.existsSync(OUTPUT_PATH)) { fs.mkdirSync(OUTPUT_PATH, { recursive: true }); @@ -213,7 +423,7 @@ function main() { const apiRef = buildComponentApiReference(source, program); if (apiRef) { - // Sort props + // Sort props (top-level only for single-part) apiRef.props = sortProps(apiRef.props); // Validate against schema before writing @@ -243,6 +453,8 @@ function main() { log.info(`Done! Generated ${successCount} files.`); + console.warn = originalWarn; + if (errorCount > 0) { log.error(`${errorCount} errors occurred.`); process.exit(1); diff --git a/site/scripts/api-docs-builder/src/parts-handler.ts b/site/scripts/api-docs-builder/src/parts-handler.ts new file mode 100644 index 00000000..34926929 --- /dev/null +++ b/site/scripts/api-docs-builder/src/parts-handler.ts @@ -0,0 +1,70 @@ +import * as ts from 'typescript'; +import * as tae from 'typescript-api-extractor'; + +export interface PartExport { + /** PascalCase export name (e.g., "Value", "Group", "Separator"). */ + name: string; + /** Source path (e.g., "./time-value", "./time-group"). */ + source: string; +} + +/** + * Extract part definitions from a React `index.parts.ts` file. + * + * Discovery algorithm: + * 1. Parses named exports from `index.parts.ts` (filters out type-only exports) + * 2. Each value export becomes a part: `export { Group } from './time-group'` -> part "Group" + * 3. Source path is preserved for HTML element matching in the main builder + * + * If a part isn't appearing in the output: + * - Ensure it's exported as a value export (not `type`-only) in `index.parts.ts` + * - Ensure the source path follows `'./{component}-{part}'` naming + */ +export function extractParts(filePath: string, program: ts.Program): PartExport[] { + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) return []; + + const parts: PartExport[] = []; + + function visit(node: ts.Node) { + // Match: export { Name } from './source' or export { Name, type NameProps } from './source' + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const source = node.moduleSpecifier.text; + + // Skip type-only export declarations (export type { ... } from '...') + if (node.isTypeOnly) return; + + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + // Skip type-only specifiers (e.g., `type GroupProps`) + if (element.isTypeOnly) continue; + + parts.push({ + name: element.name.text, + source, + }); + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + return parts; +} + +/** + * Extract the JSDoc description from a React component export. + * + * Parses the file with `typescript-api-extractor`, finds the export matching + * `partName`, and returns its description (stripping `@example` blocks). + */ +export function extractPartDescription(filePath: string, program: ts.Program, partName: string): string | undefined { + const ast = tae.parseFromProgram(filePath, program); + const component = ast.exports.find((exp) => exp.name === partName); + let desc = component?.documentation?.description; + if (desc) desc = desc.replace(/\n*@example[\s\S]*$/, '').trim(); + return desc || undefined; +} diff --git a/site/scripts/api-docs-builder/src/tests/core-handler.test.ts b/site/scripts/api-docs-builder/src/tests/core-handler.test.ts index 090f17cc..963d6db2 100644 --- a/site/scripts/api-docs-builder/src/tests/core-handler.test.ts +++ b/site/scripts/api-docs-builder/src/tests/core-handler.test.ts @@ -314,6 +314,48 @@ describe('extractCore', () => { expect(result!.state).toHaveLength(1); }); + it('expands type aliases via allExports', () => { + const code = 'export const x = 1;'; + const program = createTestProgram(code); + + // Create an ExternalTypeNode referencing 'TimeType' + const externalTypeNode = Object.create(tae.ExternalTypeNode.prototype); + externalTypeNode.typeName = new tae.TypeName('TimeType'); + + const propsType = createMockObjectNode([ + { + name: 'type', + type: externalTypeNode, + optional: true, + documentation: undefined, + } as tae.PropertyNode, + ]); + + // TimeType is also in the exports list with its resolved union type + const timeTypeLiteral1 = Object.create(tae.LiteralNode.prototype); + timeTypeLiteral1.value = "'current'"; + const timeTypeLiteral2 = Object.create(tae.LiteralNode.prototype); + timeTypeLiteral2.value = "'duration'"; + const timeTypeLiteral3 = Object.create(tae.LiteralNode.prototype); + timeTypeLiteral3.value = "'remaining'"; + const timeTypeUnion = Object.create(tae.UnionNode.prototype); + timeTypeUnion.types = [timeTypeLiteral1, timeTypeLiteral2, timeTypeLiteral3]; + timeTypeUnion.typeName = undefined; + + mockParseFromProgram.mockReturnValueOnce( + createMockAst([ + { name: 'MockComponentProps', type: propsType }, + { name: 'TimeType', type: timeTypeUnion }, + ]) + ); + + const result = extractCore('test.ts', program, 'MockComponent'); + + expect(result).not.toBeNull(); + expect(result!.props[0]!.name).toBe('type'); + expect(result!.props[0]!.type).toBe("'current' | 'duration' | 'remaining'"); + }); + it('merges defaultProps from extractDefaultProps into result', () => { const code = ` export class MockComponentCore { 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 c83963af..a39dbbc7 100644 --- a/site/scripts/api-docs-builder/src/tests/formatter.test.ts +++ b/site/scripts/api-docs-builder/src/tests/formatter.test.ts @@ -1,6 +1,6 @@ import * as tae from 'typescript-api-extractor'; import { describe, expect, it } from 'vitest'; -import { formatProperties, formatType, getShortPropType } from '../formatter'; +import { formatDetailedType, formatProperties, formatType, getShortPropType } from '../formatter'; describe('getShortPropType', () => { it("returns 'function' for callback props (onX with =>)", () => { @@ -40,6 +40,11 @@ describe('getShortPropType', () => { expect(getShortPropType('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'); + }); + it("returns 'type | function' for unions containing functions", () => { const type = "string | ((state: State) => string) | 'auto'"; expect(getShortPropType('label', type)).toBe("string | 'auto' | function"); @@ -120,6 +125,32 @@ describe('formatProperties', () => { expect(result.disabled?.default).toBe('false'); }); + it('expands type aliases when allExports is provided', () => { + // Create a property with an ExternalTypeNode referencing 'TimeType' + const externalType = createExternalTypeNode('TimeType'); + const prop = { + name: 'type', + type: externalType, + optional: true, + documentation: undefined, + } as tae.PropertyNode; + + // Create allExports with TimeType resolved to a union + const timeTypeExport = { + name: 'TimeType', + type: createUnionNode([ + createLiteralNode("'current'"), + createLiteralNode("'duration'"), + createLiteralNode("'remaining'"), + ]), + documentation: undefined, + } as tae.ExportNode; + + const result = formatProperties([prop], [timeTypeExport]); + + expect(result.type?.type).toBe("'current' | 'duration' | 'remaining'"); + }); + it('sets shortType for callback props', () => { const fnType = createFunctionNode([ { @@ -369,6 +400,93 @@ describe('formatType', () => { }); }); +describe('formatDetailedType', () => { + it('expands ExternalTypeNode when found in allExports', () => { + const externalType = createExternalTypeNode('TimeType'); + const resolvedUnion = createUnionNode([ + createLiteralNode("'current'"), + createLiteralNode("'duration'"), + createLiteralNode("'remaining'"), + ]); + const allExports = [{ name: 'TimeType', type: resolvedUnion, documentation: undefined }] as tae.ExportNode[]; + + expect(formatDetailedType(externalType, allExports, false)).toBe("'current' | 'duration' | 'remaining'"); + }); + + it('returns qualified name when not found in allExports', () => { + const externalType = createExternalTypeNode('UnknownType'); + + expect(formatDetailedType(externalType, [], false)).toBe('UnknownType'); + }); + + it('skips re-exported types (reexportedFrom is set)', () => { + const externalType = createExternalTypeNode('TimeType'); + const resolvedUnion = createUnionNode([createLiteralNode("'current'"), createLiteralNode("'duration'")]); + const reexport = { + name: 'TimeType', + type: resolvedUnion, + documentation: undefined, + reexportedFrom: 'OriginalTimeType', + } as unknown as tae.ExportNode; + + expect(formatDetailedType(externalType, [reexport], false)).toBe('TimeType'); + }); + + it('expands UnionNode with typeName (ignores alias, expands members)', () => { + const typeName = createTypeName('VolumeLevel'); + const union = createUnionNode( + [ + createLiteralNode("'off'"), + createLiteralNode("'low'"), + createLiteralNode("'medium'"), + createLiteralNode("'high'"), + ], + typeName + ); + const allExports: tae.ExportNode[] = []; + + expect(formatDetailedType(union, allExports, false)).toBe("'off' | 'low' | 'medium' | 'high'"); + }); + + it('handles removeUndefined for optional props', () => { + const union = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('undefined')]); + + expect(formatDetailedType(union, [], true)).toBe('string'); + expect(formatDetailedType(union, [], false)).toBe('string | undefined'); + }); + + it('prevents infinite recursion via visited set', () => { + const externalType = createExternalTypeNode('SelfRef'); + // SelfRef resolves to itself + const selfRefExport = { + name: 'SelfRef', + type: createExternalTypeNode('SelfRef'), + documentation: undefined, + } as tae.ExportNode; + + // Should not stack overflow; falls back to formatType + expect(formatDetailedType(externalType, [selfRefExport], false)).toBe('SelfRef'); + }); + + it('expands IntersectionNode members', () => { + const externalA = createExternalTypeNode('BaseProps'); + const basePropsExport = { + name: 'BaseProps', + type: createObjectNode([{ name: 'id', type: createIntrinsicNode('string'), optional: false }]), + documentation: undefined, + } as tae.ExportNode; + const intersection = createIntersectionNode([externalA, createIntrinsicNode('number')]); + + expect(formatDetailedType(intersection, [basePropsExport], false)).toBe('{ id: string } & number'); + }); + + it('delegates non-expandable nodes to formatType', () => { + const intrinsic = createIntrinsicNode('boolean'); + + expect(formatDetailedType(intrinsic, [], false)).toBe('boolean'); + }); +}); + // --- Helper factories --- function createPropertyNode( diff --git a/site/scripts/api-docs-builder/src/tests/html-handler.test.ts b/site/scripts/api-docs-builder/src/tests/html-handler.test.ts index 14688487..d2f163ae 100644 --- a/site/scripts/api-docs-builder/src/tests/html-handler.test.ts +++ b/site/scripts/api-docs-builder/src/tests/html-handler.test.ts @@ -77,4 +77,29 @@ describe('extractHtml', () => { expect(result).toBeNull(); }); + + it('extracts tagName using custom elementName override', () => { + const code = ` + export class TimeGroupElement { + static readonly tagName = 'media-time-group'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'Time', 'TimeGroupElement'); + + expect(result).not.toBeNull(); + expect(result!.tagName).toBe('media-time-group'); + }); + + it('returns null when elementName override does not match', () => { + const code = ` + export class TimeGroupElement { + static readonly tagName = 'media-time-group'; + } + `; + const program = createTestProgram(code); + const result = extractHtml('test.ts', program, 'Time', 'TimeSeparatorElement'); + + expect(result).toBeNull(); + }); }); diff --git a/site/scripts/api-docs-builder/src/tests/parts-handler.test.ts b/site/scripts/api-docs-builder/src/tests/parts-handler.test.ts new file mode 100644 index 00000000..39a2fde2 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/parts-handler.test.ts @@ -0,0 +1,145 @@ +import * as tae from 'typescript-api-extractor'; +import { describe, expect, it, type MockInstance, vi } from 'vitest'; +import { extractPartDescription, extractParts } from '../parts-handler.js'; +import { createTestProgram } from './test-utils.js'; + +vi.mock('typescript-api-extractor', async () => { + const actual = await vi.importActual('typescript-api-extractor'); + return { + ...actual, + parseFromProgram: vi.fn(), + }; +}); + +const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance; + +describe('extractParts', () => { + it('extracts value exports from index.parts.ts', () => { + const code = ` + export { Group, type GroupProps } from './time-group'; + export { Separator, type SeparatorProps } from './time-separator'; + export { Value, type ValueProps } from './time-value'; + `; + const program = createTestProgram(code); + const result = extractParts('test.ts', program); + + expect(result).toEqual([ + { name: 'Group', source: './time-group' }, + { name: 'Separator', source: './time-separator' }, + { name: 'Value', source: './time-value' }, + ]); + }); + + it('filters out type-only exports', () => { + const code = ` + export { Group, type GroupProps } from './time-group'; + export type { SomeType } from './types'; + `; + const program = createTestProgram(code); + const result = extractParts('test.ts', program); + + expect(result).toEqual([{ name: 'Group', source: './time-group' }]); + }); + + it('returns empty array for file with no exports', () => { + const code = `const x = 1;`; + const program = createTestProgram(code); + const result = extractParts('test.ts', program); + + expect(result).toEqual([]); + }); + + it('handles multiple value exports from same source', () => { + const code = ` + export { Foo, Bar } from './source'; + `; + const program = createTestProgram(code); + const result = extractParts('test.ts', program); + + expect(result).toEqual([ + { name: 'Foo', source: './source' }, + { name: 'Bar', source: './source' }, + ]); + }); + + it('skips type-only specifiers within a value export declaration', () => { + const code = ` + export { Value, type ValueProps, type ValueState } from './time-value'; + `; + const program = createTestProgram(code); + const result = extractParts('test.ts', program); + + expect(result).toEqual([{ name: 'Value', source: './time-value' }]); + }); +}); + +describe('extractPartDescription', () => { + it('extracts JSDoc description from a named export', () => { + const program = createTestProgram(''); + mockParseFromProgram.mockReturnValue({ + exports: [ + { + name: 'Value', + documentation: { + description: 'Displays a formatted time value (current, duration, or remaining).', + }, + }, + ], + }); + + const result = extractPartDescription('test.tsx', program, 'Value'); + + expect(result).toBe('Displays a formatted time value (current, duration, or remaining).'); + }); + + it('strips @example blocks from description', () => { + const program = createTestProgram(''); + mockParseFromProgram.mockReturnValue({ + exports: [ + { + name: 'Group', + documentation: { + description: 'Container for composed time displays.\n\n@example\n```tsx\n\n```', + }, + }, + ], + }); + + const result = extractPartDescription('test.tsx', program, 'Group'); + + expect(result).toBe('Container for composed time displays.'); + }); + + it('returns undefined when export is not found', () => { + const program = createTestProgram(''); + mockParseFromProgram.mockReturnValue({ + exports: [{ name: 'OtherComponent', documentation: { description: 'Some desc.' } }], + }); + + const result = extractPartDescription('test.tsx', program, 'Value'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when export has no documentation', () => { + const program = createTestProgram(''); + mockParseFromProgram.mockReturnValue({ + exports: [{ name: 'Value' }], + }); + + const result = extractPartDescription('test.tsx', program, 'Value'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty description', () => { + const program = createTestProgram(''); + mockParseFromProgram.mockReturnValue({ + exports: [{ name: 'Value', documentation: { description: '' } }], + }); + + const result = extractPartDescription('test.tsx', program, 'Value'); + + expect(result).toBeUndefined(); + }); +}); diff --git a/site/scripts/api-docs-builder/src/tests/utils.test.ts b/site/scripts/api-docs-builder/src/tests/utils.test.ts index 81e3e395..e6f82049 100644 --- a/site/scripts/api-docs-builder/src/tests/utils.test.ts +++ b/site/scripts/api-docs-builder/src/tests/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { kebabToPascal, sortProps } from '../utils.js'; +import { kebabToPascal, partKebabFromSource, sortProps } from '../utils.js'; describe('kebabToPascal', () => { it("converts 'play-button' to 'PlayButton'", () => { @@ -15,6 +15,24 @@ describe('kebabToPascal', () => { }); }); +describe('partKebabFromSource', () => { + it("derives 'value' from './time-value' with component 'time'", () => { + expect(partKebabFromSource('./time-value', 'time')).toBe('value'); + }); + + it("derives 'group' from './time-group' with component 'time'", () => { + expect(partKebabFromSource('./time-group', 'time')).toBe('group'); + }); + + it("derives 'separator' from './time-separator' with component 'time'", () => { + expect(partKebabFromSource('./time-separator', 'time')).toBe('separator'); + }); + + it("handles multi-segment component names like 'play-button'", () => { + expect(partKebabFromSource('./play-button-icon', 'play-button')).toBe('icon'); + }); +}); + describe('sortProps', () => { it('sorts required props before optional props', () => { const props = { diff --git a/site/scripts/api-docs-builder/src/types.ts b/site/scripts/api-docs-builder/src/types.ts index 793331ac..dfaba5e9 100644 --- a/site/scripts/api-docs-builder/src/types.ts +++ b/site/scripts/api-docs-builder/src/types.ts @@ -5,11 +5,28 @@ export type { ComponentApiReference, DataAttrDef, + PartApiReference, PropDef, StateDef, } from '../../../src/types/api-reference.js'; -export { ComponentApiReferenceSchema } from '../../../src/types/api-reference.js'; +export { ComponentApiReferenceSchema, PartApiReferenceSchema } from '../../../src/types/api-reference.js'; + +/** + * Discovered part within a multi-part component. + */ +export interface PartSource { + /** PascalCase name (e.g., "Value", "Group", "Separator"). */ + name: string; + /** Kebab-case segment (e.g., "value", "group", "separator"). */ + kebab: string; + /** True if this part gets the shared core/data-attrs. */ + isPrimary: boolean; + /** Path to HTML element file. */ + htmlPath?: string; + /** Path to React component file (for JSDoc description extraction). */ + reactPath?: string; +} /** * Source file locations for a component across packages. @@ -23,6 +40,8 @@ export interface ComponentSource { dataAttrsPath?: string; /** Path to HTML element file */ htmlPath?: string; + /** Path to index.parts.ts (if multi-part) */ + partsIndexPath?: string; } /** diff --git a/site/scripts/api-docs-builder/src/utils.ts b/site/scripts/api-docs-builder/src/utils.ts index 2e036940..c08c447d 100644 --- a/site/scripts/api-docs-builder/src/utils.ts +++ b/site/scripts/api-docs-builder/src/utils.ts @@ -7,6 +7,21 @@ export function kebabToPascal(str: string): string { .join(''); } +/** + * Derive the kebab-case part segment from an `index.parts.ts` source path. + * + * Strips the leading `'./{componentKebab}-'` prefix to get the part segment. + * Example: `partKebabFromSource('./time-value', 'time')` -> `'value'` + */ +export function partKebabFromSource(source: string, componentKebab: string): string { + const prefix = `./${componentKebab}-`; + if (source.startsWith(prefix)) { + return source.slice(prefix.length); + } + // Fallback: strip leading './' and the component prefix + return source.replace(/^\.\//, '').replace(new RegExp(`^${componentKebab}-`), ''); +} + export function sortProps(props: Record): Record { const entries = Object.entries(props); diff --git a/site/src/components/docs/api-reference/ApiRefSection.astro b/site/src/components/docs/api-reference/ApiRefSection.astro deleted file mode 100644 index fbb9ff11..00000000 --- a/site/src/components/docs/api-reference/ApiRefSection.astro +++ /dev/null @@ -1,63 +0,0 @@ ---- -import { getEntry } from 'astro:content'; -import { kebabCase } from 'es-toolkit/string'; -import ContentWidth from '@/components/frames/ContentWidth.astro'; -import MarkdownCode from '@/components/typography/MarkdownCode.astro'; -import P from '@/components/typography/P.astro'; -import type { ComponentApiReference } from '@/types/api-reference'; -import { isValidFramework } from '@/types/docs'; -import FrameworkCase from '../FrameworkCase.astro'; -import ApiDataAttrsTable from './ApiDataAttrsTable.astro'; -import ApiPropsTable from './ApiPropsTable.astro'; -import ApiStateTable from './ApiStateTable.astro'; - -interface Props { - component: string; - section: 'props' | 'state' | 'dataAttributes'; -} - -const { component, section } = Astro.props; - -const { framework } = Astro.params; -if (!framework || !isValidFramework(framework)) { - throw new Error(`Invalid or missing framework param "${framework ?? 'undefined'}".`); -} - -const entry = await getEntry('apiReference', kebabCase(component)); -const apiRef: ComponentApiReference | null = entry?.data ?? null; - -const hasData = apiRef && Object.keys(apiRef[section]).length > 0; ---- - - - { - hasData && section === "props" && ( - - ) - } - - { - hasData && section === "state" && ( - <> -

- - State is accessible via the{" "} - render,{" "} - className, and{" "} - style props. - - - State is reflected as data attributes for CSS styling. - -

- - - ) - } - - { - hasData && section === "dataAttributes" && ( - - ) - } -
diff --git a/site/src/components/docs/api-reference/ApiReference.astro b/site/src/components/docs/api-reference/ApiReference.astro new file mode 100644 index 00000000..675fc83a --- /dev/null +++ b/site/src/components/docs/api-reference/ApiReference.astro @@ -0,0 +1,133 @@ +--- +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 MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import P from '@/components/typography/P.astro'; +import type { ComponentApiReference } from '@/types/api-reference'; +import { isValidFramework } from '@/types/docs'; +import FrameworkCase from '../FrameworkCase.astro'; +import ApiDataAttrsTable from './ApiDataAttrsTable.astro'; +import ApiPropsTable from './ApiPropsTable.astro'; +import ApiStateTable from './ApiStateTable.astro'; +import InlineMarkdown from './InlineMarkdown.astro'; + +interface Props { + component: string; +} + +const { component } = Astro.props; +const { framework } = Astro.params; +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; +if (!apiRef) return; + +const hasParts = apiRef.parts && Object.keys(apiRef.parts).length > 0; + +const hasProps = Object.keys(apiRef.props).length > 0; +const hasState = Object.keys(apiRef.state).length > 0; +const hasDataAttrs = Object.keys(apiRef.dataAttributes).length > 0; +--- + +{hasParts ? ( + + {Object.entries(apiRef.parts!).map(([partKebab, part]) => { + const tagName = part.platforms?.html?.tagName; + const partHasProps = Object.keys(part.props).length > 0; + const partHasState = Object.keys(part.state).length > 0; + const partHasDataAttrs = Object.keys(part.dataAttributes).length > 0; + const componentName = `${component}.${part.name}`; + + return ( + <> +

+ + {`<${component}.${part.name} />`} reference + + + {tagName ? `<${tagName}>` : part.name} reference + +

+ + {part.description && ( +

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

Props

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

State

+

+ + State is accessible via the{" "} + render,{" "} + className, and{" "} + style props. + + + State is reflected as data attributes for CSS styling. + +

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

Data attributes

+ + + )} + + ); + })} +
+) : ( + +

API reference

+ + {hasProps && ( + <> +

Props

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

State

+

+ + State is accessible via the{" "} + render,{" "} + className, and{" "} + style props. + + + State is reflected as data attributes for CSS styling. + +

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

Data attributes

+ + + )} +
+)} diff --git a/site/src/components/docs/api-reference/ApiStateTable.astro b/site/src/components/docs/api-reference/ApiStateTable.astro index 8122b93d..06280bec 100644 --- a/site/src/components/docs/api-reference/ApiStateTable.astro +++ b/site/src/components/docs/api-reference/ApiStateTable.astro @@ -2,21 +2,20 @@ /** * Renders the state interface 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 { StateDef } from '@/types/api-reference'; -import InlineMarkdown from './InlineMarkdown.astro'; +import StateRow from './StateRow.astro'; interface Props { state: Record; + componentName: string; } -const { state } = Astro.props; +const { state, componentName } = Astro.props; const stateEntries = Object.entries(state); --- @@ -26,21 +25,19 @@ const stateEntries = Object.entries(state); Property Type - Description + { stateEntries.map(([name, def]) => ( - - - {name} - - - {def.type} - - - + )) } diff --git a/site/src/components/docs/api-reference/DetailRow.astro b/site/src/components/docs/api-reference/DetailRow.astro new file mode 100644 index 00000000..7cd58cd1 --- /dev/null +++ b/site/src/components/docs/api-reference/DetailRow.astro @@ -0,0 +1,126 @@ +--- +/** + * Expandable table row used by PropRow and StateRow. + * + * Uses button disclosure pattern: the summary row has real `` cells, + * the detail row is a separate `` toggled via `aria-controls` + `hidden`. + * The entire summary row is clickable, delegating to the toggle button. + * + * The default slot provides the summary cells. This component appends the + * toggle-button cell and renders the expandable detail panel beneath. + */ +import clsx from 'clsx'; +import MarkdownCode from '@/components/typography/MarkdownCode.astro'; +import Td from '@/components/typography/Td.astro'; +import Tr from '@/components/typography/Tr.astro'; +import InlineMarkdown from './InlineMarkdown.astro'; + +interface Props { + id: string; + name: string; + type: string; + shortType?: string; + description?: string; + colspan: number; +} + +const { id, name, type, shortType, description, colspan } = Astro.props; + +const hasDetail = Boolean(shortType || description); +--- + + + + { + hasDetail && ( + + + + ) + } + + +{ + hasDetail && ( + + +
+
+ {description && ( + <> +
+ Description +
+
+ + )} + + {(type || shortType) && ( + <> +
+ Type +
+
+ + {type || shortType} + +
+ + )} +
+
+ + + ) +} + + diff --git a/site/src/components/docs/api-reference/PropRow.astro b/site/src/components/docs/api-reference/PropRow.astro index 19c9d361..cd143175 100644 --- a/site/src/components/docs/api-reference/PropRow.astro +++ b/site/src/components/docs/api-reference/PropRow.astro @@ -1,16 +1,7 @@ --- -/** - * Single row in the props table with expandable details. - * - * Uses button disclosure pattern: the summary row has real `` cells, - * the detail row is a separate `` toggled via `aria-controls` + `hidden`. - * The entire summary row is clickable, delegating to the toggle button. - */ -import clsx from 'clsx'; import MarkdownCode from '@/components/typography/MarkdownCode.astro'; import Td from '@/components/typography/Td.astro'; -import Tr from '@/components/typography/Tr.astro'; -import InlineMarkdown from './InlineMarkdown.astro'; +import DetailRow from './DetailRow.astro'; interface Props { name: string; @@ -25,18 +16,10 @@ interface Props { const { name, type, shortType, description, defaultValue, required, componentName } = Astro.props; const displayType = shortType ?? type; -const hasDetail = Boolean(shortType || description); const id = `${componentName}-${name}`; --- - + {name} @@ -51,90 +34,4 @@ const id = `${componentName}-${name}`; {defaultValue ?? "—"} - { - hasDetail && ( - - - - ) - } - - -{ - hasDetail && ( - - -
-
- {description && ( - <> -
- Description -
-
- - )} - - {(type || shortType) && ( - <> -
- Type -
-
- - {type || shortType} - -
- - )} -
-
- - - ) -} - -{/* Astro deduplicates script tags, so this runs once per page. */} - +
diff --git a/site/src/components/docs/api-reference/StateRow.astro b/site/src/components/docs/api-reference/StateRow.astro new file mode 100644 index 00000000..51ef8f40 --- /dev/null +++ b/site/src/components/docs/api-reference/StateRow.astro @@ -0,0 +1,27 @@ +--- +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; + shortType?: string; + description?: string; + componentName: string; +} + +const { name, type, shortType, description, componentName } = Astro.props; + +const displayType = shortType ?? type; +const id = `${componentName}-state-${name}`; +--- + + + + {name} + + + {displayType} + + diff --git a/site/src/content/docs/reference/fullscreen-button.mdx b/site/src/content/docs/reference/fullscreen-button.mdx new file mode 100644 index 00000000..1455f8e4 --- /dev/null +++ b/site/src/content/docs/reference/fullscreen-button.mdx @@ -0,0 +1,25 @@ +--- +title: FullscreenButton +frameworkTitle: + html: media-fullscreen-button +description: A button component for entering and exiting fullscreen mode +--- + +import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; + +## Anatomy + + +```tsx + +``` + + + +```html + +``` + + + diff --git a/site/src/content/docs/reference/mute-button.mdx b/site/src/content/docs/reference/mute-button.mdx index d491e450..82b41062 100644 --- a/site/src/content/docs/reference/mute-button.mdx +++ b/site/src/content/docs/reference/mute-button.mdx @@ -1,22 +1,25 @@ --- title: MuteButton frameworkTitle: - html: mute-button + html: media-mute-button description: A button component for muting and unmuting audio playback --- -import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; +import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; -## API Reference +## Anatomy -### Props + +```tsx + +``` + - + +```html + +``` + -### State - - - -### Data Attributes - - + diff --git a/site/src/content/docs/reference/play-button.mdx b/site/src/content/docs/reference/play-button.mdx index 5835317e..e39f4b8c 100644 --- a/site/src/content/docs/reference/play-button.mdx +++ b/site/src/content/docs/reference/play-button.mdx @@ -1,22 +1,25 @@ --- title: PlayButton frameworkTitle: - html: play-button + html: media-play-button description: A button component for playing and pausing media playback --- -import ApiRefSection from '@/components/docs/api-reference/ApiRefSection.astro'; +import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; -## API Reference +## Anatomy -### Props + +```tsx + +``` + - + +```html + +``` + -### State - - - -### Data Attributes - - + diff --git a/site/src/content/docs/reference/time.mdx b/site/src/content/docs/reference/time.mdx new file mode 100644 index 00000000..03b1b170 --- /dev/null +++ b/site/src/content/docs/reference/time.mdx @@ -0,0 +1,33 @@ +--- +title: Time +frameworkTitle: + html: media-time +description: Components for displaying and composing media time information +--- + +import ApiReference from "@/components/docs/api-reference/ApiReference.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; + +## Anatomy + + +```tsx + + + + + +``` + + + +```html + + + + + +``` + + + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 48ddf452..cce9f891 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -23,6 +23,11 @@ export const sidebar: Sidebar = [ }, { sidebarLabel: 'Components', - contents: [{ slug: 'reference/play-button' }, { slug: 'reference/mute-button' }], + contents: [ + { slug: 'reference/play-button' }, + { slug: 'reference/mute-button' }, + { slug: 'reference/fullscreen-button' }, + { slug: 'reference/time' }, + ], }, ]; diff --git a/site/src/styles/globals.css b/site/src/styles/globals.css index c6ddc3bd..a1f4ff62 100644 --- a/site/src/styles/globals.css +++ b/site/src/styles/globals.css @@ -139,7 +139,7 @@ mark { --text-sm--font-weight: var(--font-weight-normal); /* Notice the use of em, so that code scales with, say, headers */ - --text-code: 0.9375em; + --text-code: clamp(0.875em, calc(0.8125em + 0.125rem), 0.9375em); --text-code--line-height: inherit; --text-code--letter-spacing: -0.02em; --text-code--font-weight: var(--font-weight-code-normal); diff --git a/site/src/types/api-reference.ts b/site/src/types/api-reference.ts index 797620c0..9b91ba73 100644 --- a/site/src/types/api-reference.ts +++ b/site/src/types/api-reference.ts @@ -16,6 +16,7 @@ export const PropDefSchema = z.object({ export const StateDefSchema = z.object({ type: z.string(), + shortType: z.string().optional(), description: z.string().optional(), }); @@ -23,7 +24,7 @@ export const DataAttrDefSchema = z.object({ description: z.string(), }); -export const ComponentApiReferenceSchema = z.object({ +export const PartApiReferenceSchema = z.object({ name: z.string(), description: z.string().optional(), props: z.record(z.string(), PropDefSchema), @@ -38,7 +39,12 @@ export const ComponentApiReferenceSchema = z.object({ }), }); +export const ComponentApiReferenceSchema = PartApiReferenceSchema.extend({ + parts: z.record(z.string(), PartApiReferenceSchema).optional(), +}); + export type PropDef = z.infer; export type StateDef = z.infer; export type DataAttrDef = z.infer; +export type PartApiReference = z.infer; export type ComponentApiReference = z.infer; diff --git a/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts b/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts index 0d65e81e..9786a4b5 100644 --- a/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts +++ b/site/src/utils/docs/__tests__/renderInlineMarkdown.test.ts @@ -85,4 +85,10 @@ describe('renderInlineMarkdown', () => { expect(result).toContain(' { + const result = renderInlineMarkdown('Renders a `` element.'); + expect(result).toContain('<span>'); + expect(result).not.toContain(''); + }); }); diff --git a/site/src/utils/docs/renderInlineMarkdown.ts b/site/src/utils/docs/renderInlineMarkdown.ts index 0a1fa9ae..e1fb1b3f 100644 --- a/site/src/utils/docs/renderInlineMarkdown.ts +++ b/site/src/utils/docs/renderInlineMarkdown.ts @@ -13,6 +13,10 @@ const classes = { a: shared.a, } as const; +function escapeHtmlCarets(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + const renderer: MarkedExtension['renderer'] = { // --- Supported elements --- @@ -36,11 +40,11 @@ const renderer: MarkedExtension['renderer'] = { }, code({ text }) { - return `${text}`; + return `${escapeHtmlCarets(text)}`; }, codespan({ text }) { - return `${text}`; + return `${escapeHtmlCarets(text)}`; }, strong({ tokens }) { diff --git a/site/src/utils/remarkConditionalHeadings.js b/site/src/utils/remarkConditionalHeadings.js index a77424fb..d0c576e9 100644 --- a/site/src/utils/remarkConditionalHeadings.js +++ b/site/src/utils/remarkConditionalHeadings.js @@ -1,10 +1,28 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { kebabCase } from 'es-toolkit/string'; import GithubSlugger from 'github-slugger'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const API_REF_DIR = path.resolve(__dirname, '../content/generated-api-reference'); + +function readApiRefJson(componentName) { + const kebab = kebabCase(componentName); + const filePath = path.join(API_REF_DIR, `${kebab}.json`); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + /** * Remark plugin that tracks headings wrapped in FrameworkCase or StyleCase components * and adds conditional metadata to them. * - * This allows filtering headings in the TOC based on the current framework/style combination. + * Also detects `` components and injects heading metadata from + * generated JSON, so component-rendered headings appear in the table of contents. */ export default function remarkConditionalHeadings() { return (tree, file) => { @@ -40,6 +58,9 @@ export default function remarkConditionalHeadings() { node.children.forEach((child) => visitWithContext(child, newContext)); } + return; + } else if (node.name === 'ApiReference') { + injectApiReferenceHeadings(node, slugger, headingsWithMetadata); return; } } @@ -86,6 +107,61 @@ export default function remarkConditionalHeadings() { }; } +/** + * Inject heading metadata from generated API reference JSON. + * + * For multi-part components, injects framework-conditional part headings. + * For single-part components, injects "API reference". + * For each, injects Props/State/Data attributes headings + */ +function injectApiReferenceHeadings(node, slugger, headingsWithMetadata) { + const componentAttr = node.attributes?.find((a) => a.name === 'component'); + const componentName = typeof componentAttr?.value === 'string' ? componentAttr.value : null; + if (!componentName) return; + + const json = readApiRefJson(componentName); + if (!json) return; + + if (json.parts && Object.keys(json.parts).length > 0) { + for (const [partKebab, part] of Object.entries(json.parts)) { + const tagName = part.platforms?.html?.tagName; + const partKebabSlug = slugger.slug(partKebab); + headingsWithMetadata.push({ + depth: 2, + text: `<${componentName}.${part.name} /> reference`, + slug: partKebabSlug, + frameworks: ['react'], + }); + headingsWithMetadata.push({ + depth: 2, + text: `${tagName ? `<${tagName}>` : part.name} reference`, + slug: partKebabSlug, + frameworks: ['html'], + }); + if (part.props && Object.keys(part.props).length > 0) { + headingsWithMetadata.push({ depth: 3, text: 'Props', slug: slugger.slug('Props') }); + } + if (part.state && Object.keys(part.state).length > 0) { + headingsWithMetadata.push({ depth: 3, text: 'State', slug: slugger.slug('State') }); + } + if (part.dataAttributes && Object.keys(part.dataAttributes).length > 0) { + headingsWithMetadata.push({ depth: 3, text: 'Data attributes', slug: slugger.slug('Data attributes') }); + } + } + } else { + headingsWithMetadata.push({ depth: 2, text: 'API reference', slug: slugger.slug('API reference') }); + if (json.props && Object.keys(json.props).length > 0) { + headingsWithMetadata.push({ depth: 3, text: 'Props', slug: slugger.slug('Props') }); + } + if (json.state && Object.keys(json.state).length > 0) { + headingsWithMetadata.push({ depth: 3, text: 'State', slug: slugger.slug('State') }); + } + if (json.dataAttributes && Object.keys(json.dataAttributes).length > 0) { + headingsWithMetadata.push({ depth: 3, text: 'Data attributes', slug: slugger.slug('Data attributes') }); + } + } +} + /** * Extract array value from JSX attribute like frameworks={["react", "html"]} */