feat(site): add util reference pipeline (#537)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-02-24 15:34:34 -06:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c11395ece1
commit 78112fbefd
143 changed files with 7031 additions and 481 deletions
@@ -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', () => {
+64 -3
View File
@@ -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', () => {
+4 -3
View File
@@ -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;
+18 -10
View File
@@ -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;
}
/**
+46 -13
View File
@@ -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);
}
}
@@ -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' },
]);
});
});
+146
View File
@@ -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;
}