mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(site): generated multipart component api reference (#468)
This commit is contained in:
@@ -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<string, PropDef> {
|
||||
const props: Record<string, PropDef> = {};
|
||||
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<string, StateDef> {
|
||||
const state: Record<string, StateDef> = {};
|
||||
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<string, DataAttrDef> {
|
||||
const dataAttributes: Record<string, DataAttrDef> = {};
|
||||
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<string, PropDef> = {};
|
||||
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<string, StateDef> = {};
|
||||
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<string, DataAttrDef> = {};
|
||||
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<string, PartApiReference> = {};
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user