feat(site): clean up api reference header hierarchy

I'm ok with H4s now
This commit is contained in:
Darius Cepulis
2026-02-12 12:00:35 -06:00
parent a04bedccba
commit 1c916efed4
6 changed files with 496 additions and 115 deletions
+184
View File
@@ -0,0 +1,184 @@
/**
* Centralized API subsection definitions.
*
* Why this exists:
* API reference headings are produced in two different places:
* 1) rendered markup in ApiReference.astro
* 2) synthetic TOC metadata in remarkConditionalHeadings
*
* Historically each side computed ids/slugs independently, which caused drift
* (TOC links diverging from rendered heading ids). Keeping subsection shape and
* id pieces here makes both sides consume the same contract.
*/
const API_REFERENCE_SUBSECTIONS = Object.freeze([
{
key: 'props',
title: 'Props',
singleId: 'props',
suffix: 'props',
},
{
key: 'state',
title: 'State',
singleId: 'state',
suffix: 'state',
},
{
key: 'dataAttributes',
title: 'Data attributes',
singleId: 'data-attributes',
suffix: 'data-attributes',
},
]);
export const API_REFERENCE_SUBSECTION_TITLES = Object.freeze(API_REFERENCE_SUBSECTIONS.map((section) => section.title));
function hasEntries(value) {
return Object.keys(value ?? {}).length > 0;
}
function createSections(source, options) {
return API_REFERENCE_SUBSECTIONS.flatMap((definition) => {
if (!hasEntries(source[definition.key])) {
return [];
}
if (options.forPart) {
return [
{
key: definition.key,
title: definition.title,
id: `${options.partId}-${definition.suffix}`,
depth: 4,
tocKind: 'api-reference-subsection',
},
];
}
return [
{
key: definition.key,
title: definition.title,
id: definition.singleId,
depth: 3,
},
];
});
}
/**
* Create a single source-of-truth model for API reference headings and sections.
*
* This model intentionally carries both:
* - display concerns (heading text/depth/framework label)
* - identity concerns (exact ids used by anchors + TOC entries)
*
* 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) {
if (!apiReference) {
return null;
}
const hasParts = Boolean(apiReference.parts && Object.keys(apiReference.parts).length > 0);
if (hasParts) {
const parts = Object.entries(apiReference.parts).map(([partId, part]) => ({
id: partId,
name: part.name,
description: part.description,
componentName: `${componentName}.${part.name}`,
labelByFramework: {
react: part.name,
html: part.platforms?.html?.tagName ?? part.name,
},
sections: createSections(part, { forPart: true, partId }),
data: part,
}));
return {
componentName,
hasParts: true,
heading: {
id: 'api-reference',
depth: 2,
text: 'API Reference',
},
sections: [],
parts,
data: apiReference,
};
}
return {
componentName,
hasParts: false,
heading: {
id: 'api-reference',
depth: 2,
text: 'API Reference',
},
sections: createSections(apiReference, { forPart: false }),
parts: [],
data: apiReference,
};
}
/**
* Build TOC heading metadata from the shared API reference model.
*
* 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) {
if (!apiReferenceModel) {
return [];
}
const headings = [
{
depth: apiReferenceModel.heading.depth,
text: apiReferenceModel.heading.text,
slug: apiReferenceModel.heading.id,
},
];
if (apiReferenceModel.hasParts) {
for (const part of apiReferenceModel.parts) {
headings.push({
depth: 3,
text: part.labelByFramework.react,
slug: part.id,
frameworks: ['react'],
});
headings.push({
depth: 3,
text: part.labelByFramework.html,
slug: part.id,
frameworks: ['html'],
});
for (const section of part.sections) {
headings.push({
depth: section.depth,
text: section.title,
slug: section.id,
tocKind: section.tocKind,
});
}
}
return headings;
}
for (const section of apiReferenceModel.sections) {
headings.push({
depth: section.depth,
text: section.title,
slug: section.id,
});
}
return headings;
}
+19 -40
View File
@@ -3,6 +3,7 @@ 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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_REF_DIR = path.resolve(__dirname, '../content/generated-api-reference');
@@ -28,6 +29,7 @@ export default function remarkConditionalHeadings() {
return (tree, file) => {
const headingsWithMetadata = [];
const slugger = new GithubSlugger();
const reservedSlugs = new Set();
// Process the tree with a stateful visitor
function visitWithContext(node, context = { frameworks: null, styles: null }) {
@@ -60,7 +62,7 @@ export default function remarkConditionalHeadings() {
return;
} else if (node.name === 'ApiReference') {
injectApiReferenceHeadings(node, slugger, headingsWithMetadata);
injectApiReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
return;
}
}
@@ -68,10 +70,18 @@ export default function remarkConditionalHeadings() {
// Handle headings
if (node.type === 'heading') {
const text = extractText(node);
let slug = slugger.slug(text);
// Avoid collisions with explicit API reference ids.
while (reservedSlugs.has(slug)) {
slug = slugger.slug(text);
}
reservedSlugs.add(slug);
const metadata = {
depth: node.depth,
text,
slug: slugger.slug(text),
slug,
};
// Add conditional context if present
@@ -114,7 +124,7 @@ export default function remarkConditionalHeadings() {
* For single-part components, injects "API reference".
* For each, injects Props/State/Data attributes headings
*/
function injectApiReferenceHeadings(node, slugger, headingsWithMetadata) {
function injectApiReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
const componentAttr = node.attributes?.find((a) => a.name === 'component');
const componentName = typeof componentAttr?.value === 'string' ? componentAttr.value : null;
if (!componentName) return;
@@ -122,43 +132,12 @@ function injectApiReferenceHeadings(node, slugger, headingsWithMetadata) {
const json = readApiRefJson(componentName);
if (!json) return;
if (json.parts && Object.keys(json.parts).length > 0) {
for (const [partKebab, part] of Object.entries(json.parts)) {
const tagName = part.platforms?.html?.tagName;
const partKebabSlug = slugger.slug(partKebab);
headingsWithMetadata.push({
depth: 2,
text: `<${componentName}.${part.name} /> reference`,
slug: partKebabSlug,
frameworks: ['react'],
});
headingsWithMetadata.push({
depth: 2,
text: `${tagName ? `<${tagName}>` : part.name} reference`,
slug: partKebabSlug,
frameworks: ['html'],
});
if (part.props && Object.keys(part.props).length > 0) {
headingsWithMetadata.push({ depth: 3, text: 'Props', slug: slugger.slug('Props') });
}
if (part.state && Object.keys(part.state).length > 0) {
headingsWithMetadata.push({ depth: 3, text: 'State', slug: slugger.slug('State') });
}
if (part.dataAttributes && Object.keys(part.dataAttributes).length > 0) {
headingsWithMetadata.push({ depth: 3, text: 'Data attributes', slug: slugger.slug('Data attributes') });
}
}
} else {
headingsWithMetadata.push({ depth: 2, text: 'API reference', slug: slugger.slug('API reference') });
if (json.props && Object.keys(json.props).length > 0) {
headingsWithMetadata.push({ depth: 3, text: 'Props', slug: slugger.slug('Props') });
}
if (json.state && Object.keys(json.state).length > 0) {
headingsWithMetadata.push({ depth: 3, text: 'State', slug: slugger.slug('State') });
}
if (json.dataAttributes && Object.keys(json.dataAttributes).length > 0) {
headingsWithMetadata.push({ depth: 3, text: 'Data attributes', slug: slugger.slug('Data attributes') });
}
const apiReferenceModel = createApiReferenceModel(componentName, json);
const apiReferenceHeadings = buildApiReferenceTocHeadings(apiReferenceModel);
headingsWithMetadata.push(...apiReferenceHeadings);
for (const heading of apiReferenceHeadings) {
reservedSlugs.add(heading.slug);
}
}
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';
import { buildApiReferenceTocHeadings, createApiReferenceModel } from '../apiReferenceModel';
describe('createApiReferenceModel', () => {
it('builds a single-part model with H3 sections for present data only', () => {
const apiReference = {
name: 'PlayButton',
props: {
size: {
type: 'string',
},
},
state: {
pressed: {
type: 'boolean',
},
},
dataAttributes: {},
platforms: {},
};
const model = createApiReferenceModel('PlayButton', apiReference);
expect(model).toMatchObject({
hasParts: false,
heading: {
id: 'api-reference',
depth: 2,
text: 'API Reference',
},
sections: [
{
key: 'props',
title: 'Props',
id: 'props',
depth: 3,
},
{
key: 'state',
title: 'State',
id: 'state',
depth: 3,
},
],
});
});
it('builds a multi-part model with framework-specific labels and H4 section ids', () => {
const apiReference = {
name: 'Controls',
props: {},
state: {},
dataAttributes: {},
platforms: {},
parts: {
root: {
name: 'Root',
description: 'Root part',
props: {},
state: {
visible: {
type: 'boolean',
},
},
dataAttributes: {
'data-visible': {
description: 'Visible',
},
},
platforms: {
html: {
tagName: 'media-controls',
},
},
},
group: {
name: 'Group',
props: {},
state: {},
dataAttributes: {},
platforms: {},
},
},
};
const model = createApiReferenceModel('Controls', apiReference);
expect(model).toMatchObject({
hasParts: true,
heading: {
id: 'api-reference',
depth: 2,
text: 'API Reference',
},
parts: [
{
id: 'root',
labelByFramework: {
react: 'Root',
html: 'media-controls',
},
componentName: 'Controls.Root',
sections: [
{
key: 'state',
title: 'State',
id: 'root-state',
depth: 4,
tocKind: 'api-reference-subsection',
},
{
key: 'dataAttributes',
title: 'Data attributes',
id: 'root-data-attributes',
depth: 4,
tocKind: 'api-reference-subsection',
},
],
},
{
id: 'group',
labelByFramework: {
react: 'Group',
html: 'Group',
},
componentName: 'Controls.Group',
sections: [],
},
],
});
});
});
describe('buildApiReferenceTocHeadings', () => {
it('creates TOC headings with API H4 metadata for multi-part sections', () => {
const apiReference = {
name: 'Controls',
props: {},
state: {},
dataAttributes: {},
platforms: {},
parts: {
root: {
name: 'Root',
props: {},
state: {
visible: {
type: 'boolean',
},
},
dataAttributes: {
'data-visible': {
description: 'Visible',
},
},
platforms: {
html: {
tagName: 'media-controls',
},
},
},
},
};
const model = createApiReferenceModel('Controls', apiReference);
const headings = buildApiReferenceTocHeadings(model);
expect(headings).toEqual([
{
depth: 2,
text: 'API Reference',
slug: 'api-reference',
},
{
depth: 3,
text: 'Root',
slug: 'root',
frameworks: ['react'],
},
{
depth: 3,
text: 'media-controls',
slug: 'root',
frameworks: ['html'],
},
{
depth: 4,
text: 'State',
slug: 'root-state',
tocKind: 'api-reference-subsection',
},
{
depth: 4,
text: 'Data attributes',
slug: 'root-data-attributes',
tocKind: 'api-reference-subsection',
},
]);
});
});