mirror of
https://github.com/zoriya/v10.git
synced 2026-08-10 08:08:10 +00:00
docs(site): complete menu radio group references with demos and options hooks (#1807)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
82b9e43bb5
commit
a9a09a7e52
@@ -23,12 +23,13 @@ import type {
|
||||
CSSVarsExtraction,
|
||||
DataAttrDef,
|
||||
DataAttrsExtraction,
|
||||
ExtraDataAttrsSource,
|
||||
PartReference,
|
||||
PartSource,
|
||||
PropDef,
|
||||
StateDef,
|
||||
} from './types.js';
|
||||
import { kebabToPascal, partKebabFromSource, sortProps } from './utils.js';
|
||||
import { getJSDocTagValue, kebabToPascal, log, partKebabFromSource, sortProps } from './utils.js';
|
||||
|
||||
// ─── Overrides ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,6 +106,48 @@ export function buildCSSVars(cssVarsData: CSSVarsExtraction): Record<string, CSS
|
||||
|
||||
// ─── Discovery ─────────────────────────────────────────────────────
|
||||
|
||||
// Extra data-attrs files in a component dir ({kebab}-{x}-data-attrs.ts)
|
||||
// declare their target parts with a `@parts item, radio-item` JSDoc tag on
|
||||
// the exported const. They cover attrs a DOM layer applies to part elements
|
||||
// directly, which the per-part stateAttrMap heuristic can't see (e.g.
|
||||
// menu-item-data-attrs applied by create-menu).
|
||||
function dataAttrsComponentName(fileBasename: string): string {
|
||||
return kebabToPascal(fileBasename.replace(/-data-attrs\.ts$/, ''));
|
||||
}
|
||||
|
||||
function discoverExtraDataAttrs(componentDir: string, componentKebab: string): ExtraDataAttrsSource[] {
|
||||
const extras: ExtraDataAttrsSource[] = [];
|
||||
const mainFile = `${componentKebab}-data-attrs.ts`;
|
||||
|
||||
for (const file of fs.readdirSync(componentDir)) {
|
||||
if (!file.endsWith('-data-attrs.ts') || file === mainFile) continue;
|
||||
|
||||
const filePath = path.join(componentDir, file);
|
||||
const exportName = `${dataAttrsComponentName(file)}DataAttrs`;
|
||||
const sourceFile = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf-8'), ts.ScriptTarget.Latest, true);
|
||||
|
||||
let tagValue: string | undefined;
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isVariableStatement(node)) return;
|
||||
const declaresExport = node.declarationList.declarations.some(
|
||||
(decl) => ts.isIdentifier(decl.name) && decl.name.text === exportName
|
||||
);
|
||||
if (declaresExport) tagValue = getJSDocTagValue(node, 'parts');
|
||||
});
|
||||
if (!tagValue) continue;
|
||||
|
||||
const parts = tagValue
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
extras.push({ path: filePath, parts });
|
||||
}
|
||||
|
||||
return extras;
|
||||
}
|
||||
|
||||
export function discoverComponents(monorepoRoot: string): ComponentSource[] {
|
||||
const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui');
|
||||
const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui');
|
||||
@@ -142,6 +185,9 @@ export function discoverComponents(monorepoRoot: string): ComponentSource[] {
|
||||
const partsIndexFile = path.join(reactUiPath, dir.name, 'index.parts.ts');
|
||||
if (fs.existsSync(partsIndexFile)) source.partsIndexPath = partsIndexFile;
|
||||
|
||||
const extraDataAttrs = discoverExtraDataAttrs(componentDir, dir.name);
|
||||
if (extraDataAttrs.length > 0) source.extraDataAttrs = extraDataAttrs;
|
||||
|
||||
if (source.corePath) {
|
||||
components.push(source);
|
||||
}
|
||||
@@ -154,7 +200,6 @@ export function discoverComponents(monorepoRoot: string): ComponentSource[] {
|
||||
|
||||
export function createComponentProgram(sources: ComponentSource[], monorepoRoot: string): ts.Program {
|
||||
const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui');
|
||||
const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui');
|
||||
const files: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
@@ -163,6 +208,7 @@ export function createComponentProgram(sources: ComponentSource[], monorepoRoot:
|
||||
if (source.cssVarsPath) files.push(source.cssVarsPath);
|
||||
if (source.htmlPath) files.push(source.htmlPath);
|
||||
if (source.partsIndexPath) files.push(source.partsIndexPath);
|
||||
if (source.extraDataAttrs) files.push(...source.extraDataAttrs.map((extra) => extra.path));
|
||||
|
||||
if (source.partsIndexPath) {
|
||||
const htmlDir = path.join(htmlUiPath, source.kebab);
|
||||
@@ -458,6 +504,25 @@ function buildMultiPartReference(
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of source.extraDataAttrs ?? []) {
|
||||
const componentName = dataAttrsComponentName(path.basename(extra.path));
|
||||
const extraData = extractDataAttrs(extra.path, program, componentName);
|
||||
if (!extraData) {
|
||||
log.warn(`No ${componentName}DataAttrs export found in ${extra.path}; skipping @parts merge`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const extraAttrs = buildDataAttrs(extraData);
|
||||
for (const partKebab of extra.parts) {
|
||||
const partRef = partsRecord[partKebab];
|
||||
if (!partRef) {
|
||||
log.warn(`@parts in ${extra.path} references unknown part "${partKebab}" on ${source.name}`);
|
||||
continue;
|
||||
}
|
||||
partRef.dataAttributes = { ...partRef.dataAttributes, ...extraAttrs };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
props: {},
|
||||
@@ -481,6 +546,10 @@ export function buildComponentReference(
|
||||
}
|
||||
}
|
||||
|
||||
if (source.extraDataAttrs?.length) {
|
||||
log.warn(`Ignoring @parts data-attrs in ${source.kebab}: ${source.name} is not a multi-part component`);
|
||||
}
|
||||
|
||||
return buildSingleComponentReference(source, program);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
* Core instantiation, sub-parts with/without HTML elements,
|
||||
* React-only parts (no platforms.html), sub-part data-attr
|
||||
* inheritance (stateAttrMap heuristic), non-boolean type
|
||||
* inference (number, string literal union via type alias).
|
||||
* inference (number, string literal union via type alias),
|
||||
* extra @parts-tagged data-attrs files attaching to the
|
||||
* listed parts (gauge-label-data-attrs.ts).
|
||||
* slider/ — Base multi-part component. Exercises: base component whose
|
||||
* parts are re-exported by domain variants.
|
||||
* volume-slider/ — Domain variant. Exercises: re-exported parts from slider,
|
||||
@@ -35,6 +37,13 @@
|
||||
* create* factory, mixin display name stripping, selector discovery,
|
||||
* @label overloads, slug collision (react vs html create-player),
|
||||
* framework assignment.
|
||||
* ui/rate-options/ — Hook re-exported through a directory index
|
||||
* (entry index → ./ui/rate-options → ./use-rate-options), with a
|
||||
* namespace merged onto the function (Props/Result pattern).
|
||||
* Exercises: recursive re-export resolution in util discovery,
|
||||
* entry-visibility filtering (useRateInternals is scanned but never
|
||||
* re-exported to the entry), and skipping re-exports that resolve to
|
||||
* a directory with no index.ts (./legacy holds only compiled JS).
|
||||
*
|
||||
* Features (packages/core/src/dom/store/features/):
|
||||
* playback.ts — Simple feature. Exercises: boolean state properties,
|
||||
@@ -343,6 +352,28 @@ describe('Component pipeline (end-to-end)', () => {
|
||||
expect(label.platforms.react).toEqual({});
|
||||
expect(label.platforms.html).toBeUndefined();
|
||||
});
|
||||
|
||||
// Extra data-attrs files ({component}-{x}-data-attrs.ts, next to the
|
||||
// main {component}-data-attrs.ts) declare their target parts with a
|
||||
// @parts JSDoc tag. This covers attrs that a DOM layer applies to
|
||||
// parts directly, invisible to the per-part stateAttrMap heuristic
|
||||
// (e.g. menu-item-data-attrs.ts applied by create-menu.ts).
|
||||
it('extra @parts-tagged data-attrs file attaches to listed parts', () => {
|
||||
const parts = findComponent('Gauge')!.reference.parts!;
|
||||
|
||||
// label: no other attrs — gets the extra file's attrs
|
||||
expect(parts.label!.dataAttributes['data-emphasized']).toMatchObject({
|
||||
description: 'Present when the value is emphasized.',
|
||||
});
|
||||
|
||||
// fill: extra attrs merge with attrs inherited via stateAttrMap
|
||||
expect(parts.fill!.dataAttributes['data-emphasized']).toBeDefined();
|
||||
expect(parts.fill!.dataAttributes['data-percentage']).toBeDefined();
|
||||
|
||||
// parts not listed in @parts are untouched
|
||||
expect(parts.track!.dataAttributes).toEqual({});
|
||||
expect(parts.indicator!.dataAttributes['data-emphasized']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -539,6 +570,39 @@ describe('Util pipeline (end-to-end)', () => {
|
||||
expect(findByName('useFormat', 'react')).toBeDefined();
|
||||
});
|
||||
|
||||
// Entry indexes often re-export hooks through a directory index
|
||||
// (entry index → ./ui/rate-options → ./use-rate-options). Discovery
|
||||
// must follow re-export hops to the declaring module so JSDoc and
|
||||
// overload extraction read the real source file. The fixture also
|
||||
// merges a namespace onto the hook (the repo's Props/Result pattern),
|
||||
// which must not break FunctionNode detection.
|
||||
it('discovers hooks re-exported through a directory index', () => {
|
||||
const entry = findByName('useRateOptions', 'react');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.slug).toBe('use-rate-options');
|
||||
expect(entry!.data.description).toContain('Create rate menu options');
|
||||
|
||||
const overload = entry!.data.overloads[0]!;
|
||||
expect(overload.parameters.props).toBeDefined();
|
||||
expect(overload.parameters.props!.description).toContain('formatRate');
|
||||
});
|
||||
|
||||
// Whole modules are scanned, but only names visible from the entry
|
||||
// point (through named re-exports and local `export *` chains) are
|
||||
// public API. useRateInternals matches the use* convention and lives
|
||||
// in a scanned file, but is never re-exported up to the entry.
|
||||
it('excludes exports that are not visible from the entry point', () => {
|
||||
expect(findByName('useRateInternals', 'react')).toBeUndefined();
|
||||
});
|
||||
|
||||
// rate-options/index.ts re-exports './legacy', which resolves to a
|
||||
// directory with no index.ts (only compiled index.js). Discovery must
|
||||
// skip it — not crash reading a directory — and the unreachable export
|
||||
// stays undocumented.
|
||||
it('skips re-exports that resolve to a directory without index.ts', () => {
|
||||
expect(findByName('useLegacyRate', 'react')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discovers controllers from HTML entry points', () => {
|
||||
expect(findByName('PlayerController', 'html')).toBeDefined();
|
||||
expect(findByName('SnapshotController', 'html')).toBeDefined();
|
||||
@@ -562,6 +626,13 @@ describe('Util pipeline (end-to-end)', () => {
|
||||
expect(findByName('createPlayer', 'html')).toBeDefined();
|
||||
expect(findByName('createSelector', null)).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves external re-exports with their canonical entry point', () => {
|
||||
const matches = entries.filter((entry) => entry.data.name === 'createSelector');
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]).toMatchObject({ slug: 'create-selector', framework: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Data attributes fixture for part-scoped attrs files.
|
||||
*
|
||||
* Exercises: extra data-attrs files ({component}-{x}-data-attrs.ts) declare
|
||||
* their target parts with a @parts JSDoc tag on the exported const. Listed
|
||||
* parts get the attrs merged into whatever they already have — plain attach
|
||||
* (label) and merge with attrs inherited via the stateAttrMap heuristic
|
||||
* (fill). This header's own raw "@parts" mention is a deliberate hazard:
|
||||
* the builder must bind to the tag in the JSDoc block closest to the export,
|
||||
* not the first match anywhere in the file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data attributes set on caption-like parts.
|
||||
*
|
||||
* @parts label, fill
|
||||
*/
|
||||
export const GaugeLabelDataAttrs = {
|
||||
/** Present when the value is emphasized. */
|
||||
emphasized: 'data-emphasized',
|
||||
} as const;
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
export { usePlayer } from './player/context';
|
||||
export { createPlayer } from './player/create-player';
|
||||
export { useRateOptions } from './ui/rate-options';
|
||||
export { createSelector } from './utils/external';
|
||||
export { mergeProps } from './utils/merge-props';
|
||||
export { useFormat } from './utils/use-format';
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// Resolves to a directory with no index.ts — discovery must skip it, not crash.
|
||||
export { useLegacyRate } from './legacy';
|
||||
export {
|
||||
type RateOption,
|
||||
type RateOptionsProps,
|
||||
type RateOptionsResult,
|
||||
useRateOptions,
|
||||
} from './use-rate-options';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// Compiled-only module (no TypeScript source): exercises discovery skipping
|
||||
// a re-export specifier that resolves to a directory without index.ts/.tsx.
|
||||
export function useLegacyRate() {
|
||||
return 1;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
export interface RateOptionsProps {
|
||||
/** Custom formatter for visible rate labels. */
|
||||
formatRate?: ((rate: number) => string) | undefined;
|
||||
/** Whether rate selection is disabled. */
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface RateOption {
|
||||
rate: number;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface RateOptionsResult {
|
||||
rate: number;
|
||||
options: RateOption[];
|
||||
setRate: (rate: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate menu options from the player rate state. Returns `null` when
|
||||
* the rate feature is not configured.
|
||||
*
|
||||
* @param props - Optional `formatRate` and `disabled` overrides.
|
||||
*/
|
||||
export function useRateOptions(props?: RateOptionsProps): RateOptionsResult | null {
|
||||
return props?.disabled ? null : { rate: 1, options: [], setRate: () => {} };
|
||||
}
|
||||
|
||||
export namespace useRateOptions {
|
||||
export type Props = RateOptionsProps;
|
||||
export type Result = RateOptionsResult;
|
||||
export type Option = RateOption;
|
||||
}
|
||||
|
||||
/** Internal helper — matches the use* convention but is never re-exported to the entry point. */
|
||||
export function useRateInternals(): number {
|
||||
return 1;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { createContext as createSelector } from 'react';
|
||||
@@ -59,6 +59,14 @@ export interface ComponentSource {
|
||||
htmlPath?: string;
|
||||
/** Path to index.parts.ts (if multi-part) */
|
||||
partsIndexPath?: string;
|
||||
/** Extra part-scoped data-attrs files ({kebab}-{x}-data-attrs.ts with a `@parts` tag) */
|
||||
extraDataAttrs?: ExtraDataAttrsSource[];
|
||||
}
|
||||
|
||||
export interface ExtraDataAttrsSource {
|
||||
path: string;
|
||||
/** Part kebabs listed in the `@parts` JSDoc tag on the file's export */
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from '../../../src/types/util-reference.js';
|
||||
import { utilReferenceSlug } from '../../../src/utils/utilReferenceSlug.js';
|
||||
import { abbreviateType, formatDetailedType, formatType } from './formatter.js';
|
||||
import { getJSDocTagValue, hasJSDocTag } from './utils.js';
|
||||
|
||||
const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m';
|
||||
|
||||
@@ -90,11 +91,12 @@ function resolveModulePath(fromFile: string, specifier: string): string {
|
||||
const dir = path.dirname(fromFile);
|
||||
const resolved = path.resolve(dir, specifier);
|
||||
|
||||
// Try exact match, then with extensions
|
||||
// Try exact match, then with extensions. Require a file — a bare
|
||||
// directory specifier must fall through to index resolution below.
|
||||
const extensions = ['', '.ts', '.tsx'];
|
||||
for (const ext of extensions) {
|
||||
const full = resolved + ext;
|
||||
if (fs.existsSync(full)) return full;
|
||||
if (fs.existsSync(full) && fs.statSync(full).isFile()) return full;
|
||||
}
|
||||
|
||||
// Try index files
|
||||
@@ -106,21 +108,138 @@ function resolveModulePath(fromFile: string, specifier: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveLocalModules(indexPath: string): string[] {
|
||||
const sourceFile = ts.createSourceFile(indexPath, fs.readFileSync(indexPath, 'utf-8'), ts.ScriptTarget.Latest, true);
|
||||
function isFile(filePath: string): boolean {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
}
|
||||
|
||||
// Memoized: getUtilEntries resolves the same entry point twice (program
|
||||
// creation + discovery), and each pass re-reads every module in the graph.
|
||||
const localModulesCache = new Map<string, string[]>();
|
||||
|
||||
function resolveLocalModules(indexPath: string): string[] {
|
||||
const cached = localModulesCache.get(indexPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const visited = new Set<string>([indexPath]);
|
||||
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));
|
||||
// Post-order: a module's own re-exports are pushed before the module
|
||||
// itself, so declaring files are scanned (and win seenKeys dedup) before
|
||||
// the directory indexes that re-export them.
|
||||
function collect(filePath: string): void {
|
||||
const sourceFile = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf-8'), ts.ScriptTarget.Latest, true);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
if (!specifier.startsWith('.')) return;
|
||||
|
||||
const resolved = resolveModulePath(filePath, specifier);
|
||||
if (visited.has(resolved)) return;
|
||||
visited.add(resolved);
|
||||
|
||||
// resolveModulePath falls back to the raw path when nothing matches;
|
||||
// skip anything that isn't a readable file (e.g. a directory with no
|
||||
// index.ts) instead of crashing on the read.
|
||||
if (!isFile(resolved)) return;
|
||||
|
||||
collect(resolved);
|
||||
localPaths.push(resolved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
collect(indexPath);
|
||||
localModulesCache.set(indexPath, localPaths);
|
||||
|
||||
return localPaths;
|
||||
}
|
||||
|
||||
// Names actually exported from an entry point, resolving local `export *`
|
||||
// chains. External star re-exports (`@videojs/*`) are skipped — they can't
|
||||
// make a locally-declared symbol visible. Discovery scans whole modules, so
|
||||
// without this filter a deep-scanned file's internal exports (never
|
||||
// re-exported up to the entry) would be documented as public API.
|
||||
function collectVisibleExportNames(indexPath: string, visited = new Set<string>()): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (visited.has(indexPath) || !isFile(indexPath)) return names;
|
||||
visited.add(indexPath);
|
||||
|
||||
const sourceFile = ts.createSourceFile(indexPath, fs.readFileSync(indexPath, 'utf-8'), ts.ScriptTarget.Latest, true);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (ts.isExportDeclaration(node)) {
|
||||
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
||||
for (const spec of node.exportClause.elements) {
|
||||
names.add(spec.name.text);
|
||||
if (spec.propertyName) names.add(spec.propertyName.text);
|
||||
}
|
||||
} else if (node.exportClause && ts.isNamespaceExport(node.exportClause)) {
|
||||
names.add(node.exportClause.name.text);
|
||||
} else if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
if (!specifier.startsWith('.')) return;
|
||||
for (const name of collectVisibleExportNames(resolveModulePath(indexPath, specifier), visited)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
||||
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
if (!isExported) return;
|
||||
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name)) names.add(decl.name.text);
|
||||
}
|
||||
} else if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
ts.isClassDeclaration(node) ||
|
||||
ts.isInterfaceDeclaration(node) ||
|
||||
ts.isTypeAliasDeclaration(node) ||
|
||||
ts.isEnumDeclaration(node)) &&
|
||||
node.name
|
||||
) {
|
||||
names.add(node.name.text);
|
||||
}
|
||||
});
|
||||
|
||||
return localPaths;
|
||||
return names;
|
||||
}
|
||||
|
||||
function collectDeclaredExportNames(modulePath: string): Set<string> {
|
||||
const names = new Set<string>();
|
||||
const sourceFile = ts.createSourceFile(
|
||||
modulePath,
|
||||
fs.readFileSync(modulePath, 'utf-8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
||||
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
if (!isExported) return;
|
||||
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text);
|
||||
}
|
||||
} else if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
ts.isClassDeclaration(node) ||
|
||||
ts.isInterfaceDeclaration(node) ||
|
||||
ts.isTypeAliasDeclaration(node) ||
|
||||
ts.isEnumDeclaration(node)) &&
|
||||
node.name
|
||||
) {
|
||||
names.add(node.name.text);
|
||||
}
|
||||
});
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
// ─── Phase 2: Convention Matching ──────────────────────────────────
|
||||
@@ -550,39 +669,6 @@ function getJSDocParamDescription(node: ts.Node, paramName: string): string | un
|
||||
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.JSDocComment) => ('text' in c ? c.text : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Shared AST Helpers ─────────────────────────────────────────────
|
||||
|
||||
function buildParamEntry(
|
||||
@@ -961,6 +1047,7 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt
|
||||
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 visibleNames = collectVisibleExportNames(indexPath);
|
||||
const failedModules: string[] = [];
|
||||
|
||||
// Collect all TAE exports for type resolution (formatDetailedType)
|
||||
@@ -970,6 +1057,8 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt
|
||||
// utilities, contexts, and selectors (e.g., usePlayer, createPlayer, selectPlayback)
|
||||
for (const modulePath of modulesToScan) {
|
||||
if (!fs.existsSync(modulePath)) continue;
|
||||
const declaredNames = collectDeclaredExportNames(modulePath);
|
||||
if (declaredNames.size === 0) continue;
|
||||
|
||||
let ast: tae.ModuleNode;
|
||||
try {
|
||||
@@ -982,6 +1071,13 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt
|
||||
allExports.push(...ast.exports);
|
||||
|
||||
for (const exportNode of ast.exports) {
|
||||
// Re-exported APIs are owned by their declaring module. Local
|
||||
// declarations are scanned post-order, while external package
|
||||
// re-exports are documented by that package's canonical entry point.
|
||||
if (!declaredNames.has(exportNode.name)) continue;
|
||||
// Whole modules are scanned, but only exports that are actually
|
||||
// visible from the entry point are public API.
|
||||
if (!visibleNames.has(exportNode.name)) continue;
|
||||
processExport(exportNode, modulePath, entryPoint, program, seenKeys, seenSlugs, entries, allExports);
|
||||
}
|
||||
}
|
||||
@@ -1010,6 +1106,7 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt
|
||||
for (const modulePath of failedModules) {
|
||||
const rawExports = discoverExportsFromRawAST(modulePath, program);
|
||||
for (const info of rawExports) {
|
||||
if (!visibleNames.has(info.name)) continue;
|
||||
processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries);
|
||||
}
|
||||
}
|
||||
@@ -1021,7 +1118,7 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt
|
||||
|
||||
const rawExports = discoverExportsFromRawAST(modulePath, program);
|
||||
for (const info of rawExports) {
|
||||
if (!info.isClass) continue;
|
||||
if (!info.isClass || !visibleNames.has(info.name)) continue;
|
||||
processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
import type * as ts from 'typescript';
|
||||
import type { PropDef } from './types.js';
|
||||
|
||||
const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m';
|
||||
|
||||
export 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),
|
||||
};
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function getJSDocTagValue(node: ts.Node, tagName: string): string | undefined {
|
||||
const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined;
|
||||
if (!jsDocNodes?.length) return undefined;
|
||||
|
||||
// TS attaches every leading JSDoc block to the node (file headers included)
|
||||
// and parses @tags even mid-sentence — the block closest to the declaration
|
||||
// is the binding one, so scan in reverse.
|
||||
for (const doc of [...jsDocNodes].reverse()) {
|
||||
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.JSDocComment) => ('text' in c ? c.text : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function kebabToPascal(str: string): string {
|
||||
return str
|
||||
.split('-')
|
||||
|
||||
Reference in New Issue
Block a user