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
@@ -1,14 +1,14 @@
import type { MarkdownHeading } from 'astro';
import { TableOfContentsDesktop } from './TableOfContents.desktop';
import { TableOfContentsMobile } from './TableOfContents.mobile';
import { filterHeadingsByDepth, navigateToHeading, useActiveHeading } from './utils';
import { filterHeadingsForToc, navigateToHeading, useActiveHeading } from './utils';
interface TableOfContentsProps {
headings: MarkdownHeading[];
}
export function TableOfContents({ headings }: TableOfContentsProps) {
const filteredHeadings = filterHeadingsByDepth(headings, 2, 3);
const filteredHeadings = filterHeadingsForToc(headings);
const activeId = useActiveHeading(filteredHeadings);
if (filteredHeadings.length === 0) {
@@ -3,6 +3,7 @@ import debounce from 'just-debounce-it';
import throttle from 'just-throttle';
import type { RefObject } from 'react';
import { useEffect, useState } from 'react';
import { API_REFERENCE_SUBSECTION_TITLES } from '@/utils/apiReferenceModel';
/**
* Find the first scrollable ancestor of an element
@@ -39,14 +40,28 @@ export function isElementOffscreen(element: HTMLElement, container: HTMLElement)
}
/**
* Filter headings by depth range
* Include headings for docs TOC, including API-reference subsection H4s only.
*/
export function filterHeadingsByDepth(
headings: MarkdownHeading[],
minDepth: number,
maxDepth: number
): MarkdownHeading[] {
return headings.filter((h) => h.depth >= minDepth && h.depth <= maxDepth);
export function filterHeadingsForToc(headings: MarkdownHeading[]): MarkdownHeading[] {
const apiReferenceSubsectionTitles = new Set(API_REFERENCE_SUBSECTION_TITLES);
const isTocHeadingDepth = (depth: number): boolean => depth === 2 || depth === 3;
const isApiReferenceSubsectionHeading = (heading: MarkdownHeading): boolean => {
const tocKind = (heading as MarkdownHeading & { tocKind?: string }).tocKind;
return tocKind === 'api-reference-subsection' && apiReferenceSubsectionTitles.has(heading.text);
};
return headings.filter((heading) => {
if (isTocHeadingDepth(heading.depth)) {
return true;
}
if (heading.depth === 4) {
return isApiReferenceSubsectionHeading(heading);
}
return false;
});
}
/**
@@ -4,10 +4,12 @@ import { kebabCase } from 'es-toolkit/string';
import ContentWidth from '@/components/frames/ContentWidth.astro';
import H2 from '@/components/typography/H2Markdown.astro';
import H3 from '@/components/typography/H3Markdown.astro';
import H4 from '@/components/typography/H4Markdown.astro';
import MarkdownCode from '@/components/typography/MarkdownCode.astro';
import P from '@/components/typography/P.astro';
import type { ComponentApiReference } from '@/types/api-reference';
import { isValidFramework } from '@/types/docs';
import { createApiReferenceModel } from '@/utils/apiReferenceModel';
import FrameworkCase from '../FrameworkCase.astro';
import ApiDataAttrsTable from './ApiDataAttrsTable.astro';
import ApiPropsTable from './ApiPropsTable.astro';
@@ -28,47 +30,50 @@ const entry = await getEntry('apiReference', kebabCase(component));
const apiRef: ComponentApiReference | null = entry?.data ?? null;
if (!apiRef) return;
const hasParts = apiRef.parts && Object.keys(apiRef.parts).length > 0;
const apiReferenceModel = createApiReferenceModel(component, apiRef);
if (!apiReferenceModel) return;
const hasProps = Object.keys(apiRef.props).length > 0;
const hasState = Object.keys(apiRef.state).length > 0;
const hasDataAttrs = Object.keys(apiRef.dataAttributes).length > 0;
const singlePropsSection = !apiReferenceModel.hasParts
? apiReferenceModel.sections.find((section) => section.key === 'props')
: null;
const singleStateSection = !apiReferenceModel.hasParts
? apiReferenceModel.sections.find((section) => section.key === 'state')
: null;
const singleDataAttributesSection = !apiReferenceModel.hasParts
? apiReferenceModel.sections.find((section) => section.key === 'dataAttributes')
: null;
---
{hasParts ? (
<ContentWidth>
{Object.entries(apiRef.parts!).map(([partKebab, part]) => {
const tagName = part.platforms?.html?.tagName;
const partHasProps = Object.keys(part.props).length > 0;
const partHasState = Object.keys(part.state).length > 0;
const partHasDataAttrs = Object.keys(part.dataAttributes).length > 0;
const componentName = `${component}.${part.name}`;
<ContentWidth>
<H2 id={apiReferenceModel.heading.id}>{apiReferenceModel.heading.text}</H2>
{apiReferenceModel.hasParts ? (
apiReferenceModel.parts.map((part) => {
const partPropsSection = part.sections.find((section) => section.key === 'props');
const partStateSection = part.sections.find((section) => section.key === 'state');
const partDataAttributesSection = part.sections.find((section) => section.key === 'dataAttributes');
return (
<>
<H2 id={partKebab}>
<FrameworkCase frameworks={["react"]}>
<MarkdownCode>{`<${component}.${part.name} />`}</MarkdownCode> reference
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<MarkdownCode>{tagName ? `<${tagName}>` : part.name}</MarkdownCode> reference
</FrameworkCase>
</H2>
<H3 id={part.id}>
<FrameworkCase frameworks={["react"]}>{part.labelByFramework.react}</FrameworkCase>
<FrameworkCase frameworks={["html"]}>{part.labelByFramework.html}</FrameworkCase>
</H3>
{part.description && (
<P><InlineMarkdown content={part.description} /></P>
)}
{partHasProps && (
{partPropsSection && (
<>
<H3 id={`${partKebab}-props`}>Props</H3>
<ApiPropsTable props={part.props} componentName={componentName} />
<H4 id={partPropsSection.id}>{partPropsSection.title}</H4>
<ApiPropsTable props={part.data.props} componentName={part.componentName} />
</>
)}
{partHasState && (
{partStateSection && (
<>
<H3 id={`${partKebab}-state`}>State</H3>
<H4 id={partStateSection.id}>{partStateSection.title}</H4>
<P>
<FrameworkCase frameworks={["react"]}>
State is accessible via the{" "}
@@ -80,54 +85,52 @@ const hasDataAttrs = Object.keys(apiRef.dataAttributes).length > 0;
State is reflected as data attributes for CSS styling.
</FrameworkCase>
</P>
<ApiStateTable state={part.state} componentName={componentName} />
<ApiStateTable state={part.data.state} componentName={part.componentName} />
</>
)}
{partHasDataAttrs && (
{partDataAttributesSection && (
<>
<H3 id={`${partKebab}-data-attributes`}>Data attributes</H3>
<ApiDataAttrsTable dataAttributes={part.dataAttributes} />
<H4 id={partDataAttributesSection.id}>{partDataAttributesSection.title}</H4>
<ApiDataAttrsTable dataAttributes={part.data.dataAttributes} />
</>
)}
</>
);
})}
</ContentWidth>
) : (
<ContentWidth>
<H2 id="api-reference">API reference</H2>
})
) : (
<>
{singlePropsSection && (
<>
<H3 id={singlePropsSection.id}>{singlePropsSection.title}</H3>
<ApiPropsTable props={apiReferenceModel.data.props} componentName={component} />
</>
)}
{hasProps && (
<>
<H3 id="props">Props</H3>
<ApiPropsTable props={apiRef.props} componentName={component} />
</>
)}
{singleStateSection && (
<>
<H3 id={singleStateSection.id}>{singleStateSection.title}</H3>
<P>
<FrameworkCase frameworks={["react"]}>
State is accessible via the{" "}
<MarkdownCode>render</MarkdownCode>,{" "}
<MarkdownCode>className</MarkdownCode>, and{" "}
<MarkdownCode>style</MarkdownCode> props.
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
State is reflected as data attributes for CSS styling.
</FrameworkCase>
</P>
<ApiStateTable state={apiReferenceModel.data.state} componentName={component} />
</>
)}
{hasState && (
<>
<H3 id="state">State</H3>
<P>
<FrameworkCase frameworks={["react"]}>
State is accessible via the{" "}
<MarkdownCode>render</MarkdownCode>,{" "}
<MarkdownCode>className</MarkdownCode>, and{" "}
<MarkdownCode>style</MarkdownCode> props.
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
State is reflected as data attributes for CSS styling.
</FrameworkCase>
</P>
<ApiStateTable state={apiRef.state} componentName={component} />
</>
)}
{hasDataAttrs && (
<>
<H3 id="data-attributes">Data attributes</H3>
<ApiDataAttrsTable dataAttributes={apiRef.dataAttributes} />
</>
)}
</ContentWidth>
)}
{singleDataAttributesSection && (
<>
<H3 id={singleDataAttributesSection.id}>{singleDataAttributesSection.title}</H3>
<ApiDataAttrsTable dataAttributes={apiReferenceModel.data.dataAttributes} />
</>
)}
</>
)}
</ContentWidth>
+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',
},
]);
});
});