mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(site): add util reference pipeline (#537)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c11395ece1
commit
78112fbefd
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Centralized API subsection definitions.
|
||||
* Centralized component API subsection definitions.
|
||||
*
|
||||
* Why this exists:
|
||||
* API reference headings are produced in two different places:
|
||||
* 1) rendered markup in ApiReference.astro
|
||||
* 1) rendered markup in ComponentReference.astro
|
||||
* 2) synthetic TOC metadata in remarkConditionalHeadings
|
||||
*
|
||||
* Historically each side computed ids/slugs independently, which caused drift
|
||||
@@ -76,7 +76,7 @@ function createSections(source, options) {
|
||||
* The shared model is what prevents anchor drift: ids are computed once and
|
||||
* reused verbatim by the renderer and the remark plugin.
|
||||
*/
|
||||
export function createApiReferenceModel(componentName, apiReference) {
|
||||
export function createComponentReferenceModel(componentName, apiReference) {
|
||||
if (!apiReference) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export function createApiReferenceModel(componentName, apiReference) {
|
||||
* Important: this function does not slugify heading text. It uses model ids
|
||||
* directly, so TOC slugs are guaranteed to match rendered heading ids.
|
||||
*/
|
||||
export function buildApiReferenceTocHeadings(apiReferenceModel) {
|
||||
export function buildComponentReferenceTocHeadings(apiReferenceModel) {
|
||||
if (!apiReferenceModel) {
|
||||
return [];
|
||||
}
|
||||
@@ -58,6 +58,16 @@ describe('routing utilities', () => {
|
||||
frameworks: ['html'] satisfies MockFramework[],
|
||||
};
|
||||
|
||||
// Guide with no own restrictions, but lives inside a react-only section
|
||||
const guideInReactSection: Guide = {
|
||||
slug: 'reference/react-hook',
|
||||
};
|
||||
|
||||
// Guide with no own restrictions, but lives inside an html-only section
|
||||
const guideInHtmlSection: Guide = {
|
||||
slug: 'reference/html-controller',
|
||||
};
|
||||
|
||||
const mockSidebar: Sidebar = [
|
||||
{
|
||||
sidebarLabel: 'Getting started',
|
||||
@@ -67,6 +77,16 @@ describe('routing utilities', () => {
|
||||
sidebarLabel: 'Concepts',
|
||||
contents: [guideReactOnly],
|
||||
},
|
||||
{
|
||||
sidebarLabel: 'Hooks',
|
||||
frameworks: ['react'] satisfies MockFramework[],
|
||||
contents: [guideInReactSection],
|
||||
},
|
||||
{
|
||||
sidebarLabel: 'Controllers',
|
||||
frameworks: ['html'] satisfies MockFramework[],
|
||||
contents: [guideInHtmlSection],
|
||||
},
|
||||
guideHtmlOnly,
|
||||
];
|
||||
|
||||
@@ -187,6 +207,21 @@ describe('routing utilities', () => {
|
||||
expect(result.shouldReplace).toBe(false);
|
||||
expect(result.reason).toContain('changed slug');
|
||||
});
|
||||
|
||||
it('should change slug when guide inherits framework restriction from section', () => {
|
||||
const result = resolveFrameworkChange(
|
||||
{
|
||||
currentFramework: 'html',
|
||||
currentSlug: 'reference/html-controller', // in html-only section
|
||||
newFramework: 'react',
|
||||
},
|
||||
mockSidebar
|
||||
);
|
||||
|
||||
expect(result.selectedSlug).not.toBe('reference/html-controller');
|
||||
expect(result.slugChanged).toBe(true);
|
||||
expect(result.shouldReplace).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
@@ -264,6 +299,19 @@ describe('routing utilities', () => {
|
||||
expect(result.priorityLevel).toBe(2);
|
||||
expect(result.reason).toContain('Priority 2');
|
||||
});
|
||||
|
||||
it('should fall back when guide inherits framework restriction from section', () => {
|
||||
const result = resolveDocsLinkUrl(
|
||||
{
|
||||
targetSlug: 'reference/react-hook', // in react-only section, no own restriction
|
||||
contextFramework: 'html',
|
||||
},
|
||||
mockSidebar
|
||||
);
|
||||
|
||||
expect(result.selectedFramework).toBe('react');
|
||||
expect(result.priorityLevel).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slug pinning', () => {
|
||||
|
||||
@@ -331,16 +331,77 @@ describe('sidebar utilities', () => {
|
||||
|
||||
describe('getValidFrameworksForGuide', () => {
|
||||
it('should return all frameworks when guide has no restrictions', () => {
|
||||
const result = getValidFrameworksForGuide(mockGuide3);
|
||||
const result = getValidFrameworksForGuide(mockGuide3, mockSidebar);
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(['html', 'react']));
|
||||
});
|
||||
|
||||
it('should return only restricted frameworks', () => {
|
||||
const result = getValidFrameworksForGuide(mockGuide2);
|
||||
it('should return only restricted frameworks from guide itself', () => {
|
||||
const result = getValidFrameworksForGuide(mockGuide2, mockSidebar);
|
||||
|
||||
expect(result).toEqual(['react']);
|
||||
});
|
||||
|
||||
it('should inherit framework restrictions from parent section', () => {
|
||||
const unrestricted: Guide = { slug: 'child-guide' };
|
||||
const sidebar: Sidebar = [
|
||||
{
|
||||
sidebarLabel: 'React Only Section',
|
||||
frameworks: ['react'] satisfies MockFramework[],
|
||||
contents: [unrestricted],
|
||||
},
|
||||
];
|
||||
|
||||
const result = getValidFrameworksForGuide(unrestricted, sidebar);
|
||||
|
||||
expect(result).toEqual(['react']);
|
||||
});
|
||||
|
||||
it('should intersect guide and ancestor section restrictions', () => {
|
||||
const guideWithOwn: Guide = {
|
||||
slug: 'both-restricted',
|
||||
frameworks: ['html', 'react'] satisfies MockFramework[],
|
||||
};
|
||||
const sidebar: Sidebar = [
|
||||
{
|
||||
sidebarLabel: 'HTML Only Section',
|
||||
frameworks: ['html'] satisfies MockFramework[],
|
||||
contents: [guideWithOwn],
|
||||
},
|
||||
];
|
||||
|
||||
const result = getValidFrameworksForGuide(guideWithOwn, sidebar);
|
||||
|
||||
expect(result).toEqual(['html']);
|
||||
});
|
||||
|
||||
it('should inherit restrictions through deeply nested sections', () => {
|
||||
const deepGuide: Guide = { slug: 'deep-guide' };
|
||||
const sidebar: Sidebar = [
|
||||
{
|
||||
sidebarLabel: 'Level 1',
|
||||
frameworks: ['react'] satisfies MockFramework[],
|
||||
contents: [
|
||||
{
|
||||
sidebarLabel: 'Level 2',
|
||||
contents: [deepGuide],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = getValidFrameworksForGuide(deepGuide, sidebar);
|
||||
|
||||
expect(result).toEqual(['react']);
|
||||
});
|
||||
|
||||
it('should return all frameworks when guide is not found in sidebar', () => {
|
||||
const orphan: Guide = { slug: 'not-in-sidebar' };
|
||||
|
||||
const result = getValidFrameworksForGuide(orphan, mockSidebar);
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(['html', 'react']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFirstGuide with real sidebar config', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sidebar as defaultSidebar } from '@/docs.config';
|
||||
import type { Sidebar, SupportedFramework } from '@/types/docs';
|
||||
import { DEFAULT_FRAMEWORK, isValidFramework } from '@/types/docs';
|
||||
import { findFirstGuide, findGuideBySlug, getValidFrameworksForGuide, isItemVisible } from './sidebar';
|
||||
import { findFirstGuide, findGuideBySlug, getValidFrameworksForGuide } from './sidebar';
|
||||
|
||||
/**
|
||||
* Build a docs URL from framework and guide slug components.
|
||||
@@ -135,7 +135,8 @@ export function resolveFrameworkChange(
|
||||
let reason: string;
|
||||
|
||||
const guide = findGuideBySlug(currentSlug, sidebar);
|
||||
if (guide && isItemVisible(guide, selectedFramework)) {
|
||||
const validFrameworks = guide ? getValidFrameworksForGuide(guide, sidebar) : [];
|
||||
if (guide && validFrameworks.includes(selectedFramework)) {
|
||||
// Current slug is visible in the new framework
|
||||
selectedSlug = currentSlug;
|
||||
shouldReplace = true;
|
||||
@@ -211,7 +212,7 @@ export function resolveDocsLinkUrl(input: DocsLinkInput, sidebar: Sidebar = defa
|
||||
let reason: string;
|
||||
|
||||
// Priority 1: Try current framework
|
||||
const validFrameworks = getValidFrameworksForGuide(guide);
|
||||
const validFrameworks = getValidFrameworksForGuide(guide, sidebar);
|
||||
if (validFrameworks.includes(contextFramework)) {
|
||||
selectedFramework = contextFramework;
|
||||
priorityLevel = 1;
|
||||
|
||||
@@ -178,19 +178,27 @@ export function getSectionsForGuide(slug: string, sidebarToSearch: Sidebar = sid
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid frameworks for a guide.
|
||||
* Returns the frameworks the guide is restricted to, or all frameworks if it has no restrictions.
|
||||
*
|
||||
* @param guide - The guide to check
|
||||
* @returns Array of valid frameworks for this guide
|
||||
* Get all valid frameworks for a guide, accounting for ancestor section restrictions.
|
||||
* Walks the sidebar tree to find the guide and intersects `frameworks` from every
|
||||
* ancestor section along the path, then applies the guide's own restriction on top.
|
||||
*/
|
||||
export function getValidFrameworksForGuide(guide: Guide): SupportedFramework[] {
|
||||
// If guide has no framework restrictions, all frameworks are valid
|
||||
if (!guide.frameworks) {
|
||||
return Object.keys(FRAMEWORK_STYLES) as SupportedFramework[];
|
||||
export function getValidFrameworksForGuide(guide: Guide, sidebarToSearch: Sidebar = sidebar): SupportedFramework[] {
|
||||
const allFrameworks = Object.keys(FRAMEWORK_STYLES) as SupportedFramework[];
|
||||
|
||||
function findWithRestrictions(items: Sidebar, inherited: SupportedFramework[]): SupportedFramework[] | null {
|
||||
for (const item of items) {
|
||||
if (isSection(item)) {
|
||||
const sectionFrameworks = item.frameworks ? inherited.filter((f) => item.frameworks!.includes(f)) : inherited;
|
||||
const result = findWithRestrictions(item.contents, sectionFrameworks);
|
||||
if (result) return result;
|
||||
} else if (item.slug === guide.slug) {
|
||||
return item.frameworks ? inherited.filter((f) => item.frameworks!.includes(f)) : inherited;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return guide.frameworks;
|
||||
return findWithRestrictions(sidebarToSearch, allFrameworks) ?? allFrameworks;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,14 +3,16 @@ import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { kebabCase } from 'es-toolkit/string';
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { buildApiReferenceTocHeadings, createApiReferenceModel } from './apiReferenceModel';
|
||||
import { buildComponentReferenceTocHeadings, createComponentReferenceModel } from './componentReferenceModel';
|
||||
import { buildUtilReferenceTocHeadings, createUtilReferenceModel } from './utilReferenceModel';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const API_REF_DIR = path.resolve(__dirname, '../content/generated-api-reference');
|
||||
const COMPONENT_REF_DIR = path.resolve(__dirname, '../content/generated-component-reference');
|
||||
const UTIL_REF_DIR = path.resolve(__dirname, '../content/generated-util-reference');
|
||||
|
||||
function readApiRefJson(componentName) {
|
||||
function readComponentRefJson(componentName) {
|
||||
const kebab = kebabCase(componentName);
|
||||
const filePath = path.join(API_REF_DIR, `${kebab}.json`);
|
||||
const filePath = path.join(COMPONENT_REF_DIR, `${kebab}.json`);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
@@ -22,7 +24,7 @@ function readApiRefJson(componentName) {
|
||||
* Remark plugin that tracks headings wrapped in FrameworkCase or StyleCase components
|
||||
* and adds conditional metadata to them.
|
||||
*
|
||||
* Also detects `<ApiReference>` components and injects heading metadata from
|
||||
* Also detects `<ComponentReference>` components and injects heading metadata from
|
||||
* generated JSON, so component-rendered headings appear in the table of contents.
|
||||
*/
|
||||
export default function remarkConditionalHeadings() {
|
||||
@@ -61,8 +63,10 @@ export default function remarkConditionalHeadings() {
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (node.name === 'ApiReference') {
|
||||
injectApiReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
|
||||
} else if (node.name === 'ComponentReference') {
|
||||
injectComponentReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
|
||||
} else if (node.name === 'UtilReference') {
|
||||
injectUtilReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -124,19 +128,48 @@ export default function remarkConditionalHeadings() {
|
||||
* For single-part components, injects "API reference".
|
||||
* For each, injects Props/State/Data attributes headings
|
||||
*/
|
||||
function injectApiReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
|
||||
function injectComponentReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
|
||||
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);
|
||||
const json = readComponentRefJson(componentName);
|
||||
if (!json) return;
|
||||
|
||||
const apiReferenceModel = createApiReferenceModel(componentName, json);
|
||||
const apiReferenceHeadings = buildApiReferenceTocHeadings(apiReferenceModel);
|
||||
const componentModel = createComponentReferenceModel(componentName, json);
|
||||
const componentHeadings = buildComponentReferenceTocHeadings(componentModel);
|
||||
|
||||
headingsWithMetadata.push(...apiReferenceHeadings);
|
||||
for (const heading of apiReferenceHeadings) {
|
||||
headingsWithMetadata.push(...componentHeadings);
|
||||
for (const heading of componentHeadings) {
|
||||
reservedSlugs.add(heading.slug);
|
||||
}
|
||||
}
|
||||
|
||||
function readUtilRefJson(slug) {
|
||||
const filePath = path.join(UTIL_REF_DIR, `${slug}.json`);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function injectUtilReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
|
||||
const utilAttr = node.attributes?.find((a) => a.name === 'util');
|
||||
const utilName = typeof utilAttr?.value === 'string' ? utilAttr.value : null;
|
||||
if (!utilName) return;
|
||||
|
||||
const slugAttr = node.attributes?.find((a) => a.name === 'slug');
|
||||
const slugValue = typeof slugAttr?.value === 'string' ? slugAttr.value : null;
|
||||
|
||||
const json = readUtilRefJson(slugValue ?? kebabCase(utilName));
|
||||
if (!json) return;
|
||||
|
||||
const utilModel = createUtilReferenceModel(utilName, json);
|
||||
const utilHeadings = buildUtilReferenceTocHeadings(utilModel);
|
||||
|
||||
headingsWithMetadata.push(...utilHeadings);
|
||||
for (const heading of utilHeadings) {
|
||||
reservedSlugs.add(heading.slug);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildApiReferenceTocHeadings, createApiReferenceModel } from '../apiReferenceModel';
|
||||
import { buildComponentReferenceTocHeadings, createComponentReferenceModel } from '../componentReferenceModel';
|
||||
|
||||
describe('createApiReferenceModel', () => {
|
||||
describe('createComponentReferenceModel', () => {
|
||||
it('builds a single-part model with H3 sections for present data only', () => {
|
||||
const apiReference = {
|
||||
name: 'PlayButton',
|
||||
@@ -19,7 +19,7 @@ describe('createApiReferenceModel', () => {
|
||||
platforms: {},
|
||||
};
|
||||
|
||||
const model = createApiReferenceModel('PlayButton', apiReference);
|
||||
const model = createComponentReferenceModel('PlayButton', apiReference);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
hasParts: false,
|
||||
@@ -83,7 +83,7 @@ describe('createApiReferenceModel', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const model = createApiReferenceModel('Controls', apiReference);
|
||||
const model = createComponentReferenceModel('Controls', apiReference);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
hasParts: true,
|
||||
@@ -131,7 +131,7 @@ describe('createApiReferenceModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildApiReferenceTocHeadings', () => {
|
||||
describe('buildComponentReferenceTocHeadings', () => {
|
||||
it('creates TOC headings with API H4 metadata for multi-part sections', () => {
|
||||
const apiReference = {
|
||||
name: 'Controls',
|
||||
@@ -162,8 +162,8 @@ describe('buildApiReferenceTocHeadings', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const model = createApiReferenceModel('Controls', apiReference);
|
||||
const headings = buildApiReferenceTocHeadings(model);
|
||||
const model = createComponentReferenceModel('Controls', apiReference);
|
||||
const headings = buildComponentReferenceTocHeadings(model);
|
||||
|
||||
expect(headings).toEqual([
|
||||
{
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildUtilReferenceTocHeadings, createUtilReferenceModel } from '../utilReferenceModel';
|
||||
|
||||
describe('createUtilReferenceModel', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(createUtilReferenceModel('foo', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a single-overload model with Parameters and Return Value H3s', () => {
|
||||
const ref = {
|
||||
name: 'useMedia',
|
||||
overloads: [
|
||||
{
|
||||
parameters: {},
|
||||
returnValue: { type: 'Media | null' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('useMedia', ref);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
isMultiOverload: false,
|
||||
heading: { id: 'api-reference', depth: 2, text: 'API Reference' },
|
||||
sections: [{ key: 'returnValue', title: 'Return Value', id: 'return-value', depth: 3 }],
|
||||
});
|
||||
// No parameters section since parameters is empty
|
||||
expect(model.sections.find((s) => s.key === 'parameters')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes parameters section when parameters are present', () => {
|
||||
const ref = {
|
||||
name: 'useButton',
|
||||
overloads: [
|
||||
{
|
||||
parameters: {
|
||||
params: { type: 'UseButtonParameters', required: true },
|
||||
},
|
||||
returnValue: { type: 'UseButtonReturnValue' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('useButton', ref);
|
||||
|
||||
expect(model.isMultiOverload).toBe(false);
|
||||
expect(model.sections).toEqual([
|
||||
{ key: 'parameters', title: 'Parameters', id: 'parameters', depth: 3 },
|
||||
{ key: 'returnValue', title: 'Return Value', id: 'return-value', depth: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds a multi-overload model with overload H3s and H4 subsections', () => {
|
||||
const ref = {
|
||||
name: 'usePlayer',
|
||||
overloads: [
|
||||
{
|
||||
description: 'Returns the store. No subscription.',
|
||||
parameters: {},
|
||||
returnValue: { type: 'PlayerStore' },
|
||||
},
|
||||
{
|
||||
description: 'Returns selected state.',
|
||||
parameters: {
|
||||
selector: { type: '(state: StoreState) => R', required: true },
|
||||
},
|
||||
returnValue: { type: 'R' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('usePlayer', ref);
|
||||
|
||||
expect(model.isMultiOverload).toBe(true);
|
||||
expect(model.overloads).toHaveLength(2);
|
||||
|
||||
// Overload 1: no parameters, only return value
|
||||
expect(model.overloads[0]).toMatchObject({
|
||||
id: 'overload-1',
|
||||
index: 1,
|
||||
sections: [{ key: 'returnValue', id: 'overload-1-return-value', depth: 4 }],
|
||||
});
|
||||
|
||||
// Overload 2: has parameters and return value
|
||||
expect(model.overloads[1]).toMatchObject({
|
||||
id: 'overload-2',
|
||||
index: 2,
|
||||
sections: [
|
||||
{ key: 'parameters', id: 'overload-2-parameters', depth: 4 },
|
||||
{ key: 'returnValue', id: 'overload-2-return-value', depth: 4 },
|
||||
],
|
||||
});
|
||||
});
|
||||
it('uses label for overload id and heading when present', () => {
|
||||
const ref = {
|
||||
name: 'createPlayer',
|
||||
overloads: [
|
||||
{
|
||||
label: 'Video',
|
||||
parameters: { config: { type: 'VideoConfig', required: true } },
|
||||
returnValue: { type: 'VideoPlayer' },
|
||||
},
|
||||
{
|
||||
label: 'Audio',
|
||||
parameters: { config: { type: 'AudioConfig', required: true } },
|
||||
returnValue: { type: 'AudioPlayer' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('createPlayer', ref);
|
||||
|
||||
expect(model.isMultiOverload).toBe(true);
|
||||
expect(model.overloads[0]).toMatchObject({
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
index: 1,
|
||||
sections: [
|
||||
{ key: 'parameters', id: 'video-parameters', depth: 4 },
|
||||
{ key: 'returnValue', id: 'video-return-value', depth: 4 },
|
||||
],
|
||||
});
|
||||
expect(model.overloads[1]).toMatchObject({
|
||||
id: 'audio',
|
||||
label: 'Audio',
|
||||
index: 2,
|
||||
sections: [
|
||||
{ key: 'parameters', id: 'audio-parameters', depth: 4 },
|
||||
{ key: 'returnValue', id: 'audio-return-value', depth: 4 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to overload-N when label is absent', () => {
|
||||
const ref = {
|
||||
name: 'useStore',
|
||||
overloads: [
|
||||
{
|
||||
parameters: {},
|
||||
returnValue: { type: 'S' },
|
||||
},
|
||||
{
|
||||
label: 'Selector',
|
||||
parameters: { selector: { type: 'function', required: true } },
|
||||
returnValue: { type: 'R' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('useStore', ref);
|
||||
|
||||
expect(model.overloads[0]).toMatchObject({ id: 'overload-1', label: undefined });
|
||||
expect(model.overloads[1]).toMatchObject({ id: 'selector', label: 'Selector' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUtilReferenceTocHeadings', () => {
|
||||
it('returns empty array for null model', () => {
|
||||
expect(buildUtilReferenceTocHeadings(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates TOC headings for single-overload model', () => {
|
||||
const ref = {
|
||||
name: 'useButton',
|
||||
overloads: [
|
||||
{
|
||||
parameters: { params: { type: 'UseButtonParameters', required: true } },
|
||||
returnValue: { type: 'UseButtonReturnValue' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('useButton', ref);
|
||||
const headings = buildUtilReferenceTocHeadings(model);
|
||||
|
||||
expect(headings).toEqual([
|
||||
{ depth: 2, text: 'API Reference', slug: 'api-reference' },
|
||||
{ depth: 3, text: 'Parameters', slug: 'parameters' },
|
||||
{ depth: 3, text: 'Return Value', slug: 'return-value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates TOC headings for multi-overload model', () => {
|
||||
const ref = {
|
||||
name: 'useStore',
|
||||
overloads: [
|
||||
{
|
||||
description: 'Store access',
|
||||
parameters: { store: { type: 'Store', required: true } },
|
||||
returnValue: { type: 'S' },
|
||||
},
|
||||
{
|
||||
description: 'Selector',
|
||||
parameters: {
|
||||
store: { type: 'Store', required: true },
|
||||
selector: { type: 'function', required: true },
|
||||
},
|
||||
returnValue: { type: 'R' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('useStore', ref);
|
||||
const headings = buildUtilReferenceTocHeadings(model);
|
||||
|
||||
expect(headings).toEqual([
|
||||
{ depth: 2, text: 'API Reference', slug: 'api-reference' },
|
||||
{ depth: 3, text: 'Overload 1', slug: 'overload-1' },
|
||||
{ depth: 4, text: 'Parameters', slug: 'overload-1-parameters' },
|
||||
{ depth: 4, text: 'Return Value', slug: 'overload-1-return-value' },
|
||||
{ depth: 3, text: 'Overload 2', slug: 'overload-2' },
|
||||
{ depth: 4, text: 'Parameters', slug: 'overload-2-parameters' },
|
||||
{ depth: 4, text: 'Return Value', slug: 'overload-2-return-value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses label text and slug in TOC headings when present', () => {
|
||||
const ref = {
|
||||
name: 'createPlayer',
|
||||
overloads: [
|
||||
{
|
||||
label: 'Video',
|
||||
parameters: { config: { type: 'VideoConfig', required: true } },
|
||||
returnValue: { type: 'VideoPlayer' },
|
||||
},
|
||||
{
|
||||
label: 'Audio',
|
||||
parameters: { config: { type: 'AudioConfig', required: true } },
|
||||
returnValue: { type: 'AudioPlayer' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const model = createUtilReferenceModel('createPlayer', ref);
|
||||
const headings = buildUtilReferenceTocHeadings(model);
|
||||
|
||||
expect(headings).toEqual([
|
||||
{ depth: 2, text: 'API Reference', slug: 'api-reference' },
|
||||
{ depth: 3, text: 'Video', slug: 'video' },
|
||||
{ depth: 4, text: 'Parameters', slug: 'video-parameters' },
|
||||
{ depth: 4, text: 'Return Value', slug: 'video-return-value' },
|
||||
{ depth: 3, text: 'Audio', slug: 'audio' },
|
||||
{ depth: 4, text: 'Parameters', slug: 'audio-parameters' },
|
||||
{ depth: 4, text: 'Return Value', slug: 'audio-return-value' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Centralized util API subsection definitions.
|
||||
*
|
||||
* Mirrors componentReferenceModel.js for utility APIs (hooks, controllers,
|
||||
* mixins, factories, contexts, utilities). Produces heading/id data consumed
|
||||
* by both UtilReference.astro and remarkConditionalHeadings.
|
||||
*/
|
||||
import { kebabCase } from 'es-toolkit/string';
|
||||
|
||||
/**
|
||||
* Create a single source-of-truth model for util reference headings and sections.
|
||||
*
|
||||
* Single-overload: Parameters (H3) + Return Value (H3)
|
||||
* Multi-overload: Overload N (H3) → Parameters (H4) + Return Value (H4)
|
||||
*/
|
||||
export function createUtilReferenceModel(name, ref) {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMultiOverload = ref.overloads.length > 1;
|
||||
|
||||
if (isMultiOverload) {
|
||||
const overloads = ref.overloads.map((overload, index) => {
|
||||
const label = overload.label;
|
||||
const overloadId = label ? kebabCase(label) : `overload-${index + 1}`;
|
||||
const sections = [];
|
||||
|
||||
if (Object.keys(overload.parameters).length > 0) {
|
||||
sections.push({
|
||||
key: 'parameters',
|
||||
title: 'Parameters',
|
||||
id: `${overloadId}-parameters`,
|
||||
depth: 4,
|
||||
});
|
||||
}
|
||||
|
||||
sections.push({
|
||||
key: 'returnValue',
|
||||
title: 'Return Value',
|
||||
id: `${overloadId}-return-value`,
|
||||
depth: 4,
|
||||
});
|
||||
|
||||
return {
|
||||
id: overloadId,
|
||||
label,
|
||||
index: index + 1,
|
||||
description: overload.description,
|
||||
sections,
|
||||
data: overload,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
description: ref.description,
|
||||
isMultiOverload: true,
|
||||
heading: {
|
||||
id: 'api-reference',
|
||||
depth: 2,
|
||||
text: 'API Reference',
|
||||
},
|
||||
overloads,
|
||||
};
|
||||
}
|
||||
|
||||
const overload = ref.overloads[0];
|
||||
const sections = [];
|
||||
|
||||
if (Object.keys(overload.parameters).length > 0) {
|
||||
sections.push({
|
||||
key: 'parameters',
|
||||
title: 'Parameters',
|
||||
id: 'parameters',
|
||||
depth: 3,
|
||||
});
|
||||
}
|
||||
|
||||
sections.push({
|
||||
key: 'returnValue',
|
||||
title: 'Return Value',
|
||||
id: 'return-value',
|
||||
depth: 3,
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
description: ref.description,
|
||||
isMultiOverload: false,
|
||||
heading: {
|
||||
id: 'api-reference',
|
||||
depth: 2,
|
||||
text: 'API Reference',
|
||||
},
|
||||
sections,
|
||||
overload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build TOC heading metadata from the shared util reference model.
|
||||
*/
|
||||
export function buildUtilReferenceTocHeadings(model) {
|
||||
if (!model) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headings = [
|
||||
{
|
||||
depth: model.heading.depth,
|
||||
text: model.heading.text,
|
||||
slug: model.heading.id,
|
||||
},
|
||||
];
|
||||
|
||||
if (model.isMultiOverload) {
|
||||
for (const overload of model.overloads) {
|
||||
headings.push({
|
||||
depth: 3,
|
||||
text: overload.label ?? `Overload ${overload.index}`,
|
||||
slug: overload.id,
|
||||
});
|
||||
|
||||
for (const section of overload.sections) {
|
||||
headings.push({
|
||||
depth: section.depth,
|
||||
text: section.title,
|
||||
slug: section.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
for (const section of model.sections) {
|
||||
headings.push({
|
||||
depth: section.depth,
|
||||
text: section.title,
|
||||
slug: section.id,
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
Reference in New Issue
Block a user