From 1c916efed456c8a6df240776520fea1d892164c1 Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Thu, 12 Feb 2026 12:00:35 -0600 Subject: [PATCH] feat(site): clean up api reference header hierarchy I'm ok with H4s now --- .../components/docs/TableOfContents/index.tsx | 4 +- .../components/docs/TableOfContents/utils.ts | 29 ++- .../docs/api-reference/ApiReference.astro | 135 ++++++------ site/src/utils/apiReferenceModel.js | 184 ++++++++++++++++ site/src/utils/remarkConditionalHeadings.js | 59 ++---- .../src/utils/tests/apiReferenceModel.test.ts | 200 ++++++++++++++++++ 6 files changed, 496 insertions(+), 115 deletions(-) create mode 100644 site/src/utils/apiReferenceModel.js create mode 100644 site/src/utils/tests/apiReferenceModel.test.ts diff --git a/site/src/components/docs/TableOfContents/index.tsx b/site/src/components/docs/TableOfContents/index.tsx index e8004e03..ea60d43f 100644 --- a/site/src/components/docs/TableOfContents/index.tsx +++ b/site/src/components/docs/TableOfContents/index.tsx @@ -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) { diff --git a/site/src/components/docs/TableOfContents/utils.ts b/site/src/components/docs/TableOfContents/utils.ts index 7d21e822..8800ee66 100644 --- a/site/src/components/docs/TableOfContents/utils.ts +++ b/site/src/components/docs/TableOfContents/utils.ts @@ -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; + }); } /** diff --git a/site/src/components/docs/api-reference/ApiReference.astro b/site/src/components/docs/api-reference/ApiReference.astro index 675fc83a..2f48c46e 100644 --- a/site/src/components/docs/api-reference/ApiReference.astro +++ b/site/src/components/docs/api-reference/ApiReference.astro @@ -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 ? ( - - {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}`; + +

{apiReferenceModel.heading.text}

+ + {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 ( <> -

- - {`<${component}.${part.name} />`} reference - - - {tagName ? `<${tagName}>` : part.name} reference - -

+

+ {part.labelByFramework.react} + {part.labelByFramework.html} +

{part.description && (

)} - {partHasProps && ( + {partPropsSection && ( <> -

Props

- +

{partPropsSection.title}

+ )} - {partHasState && ( + {partStateSection && ( <> -

State

+

{partStateSection.title}

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.

- + )} - {partHasDataAttrs && ( + {partDataAttributesSection && ( <> -

Data attributes

- +

{partDataAttributesSection.title}

+ )} ); - })} -
-) : ( - -

API reference

+ }) + ) : ( + <> + {singlePropsSection && ( + <> +

{singlePropsSection.title}

+ + + )} - {hasProps && ( - <> -

Props

- - - )} + {singleStateSection && ( + <> +

{singleStateSection.title}

+

+ + State is accessible via the{" "} + render,{" "} + className, and{" "} + style props. + + + State is reflected as data attributes for CSS styling. + +

+ + + )} - {hasState && ( - <> -

State

-

- - State is accessible via the{" "} - render,{" "} - className, and{" "} - style props. - - - State is reflected as data attributes for CSS styling. - -

- - - )} - - {hasDataAttrs && ( - <> -

Data attributes

- - - )} -
-)} + {singleDataAttributesSection && ( + <> +

{singleDataAttributesSection.title}

+ + + )} + + )} +
diff --git a/site/src/utils/apiReferenceModel.js b/site/src/utils/apiReferenceModel.js new file mode 100644 index 00000000..2ca527de --- /dev/null +++ b/site/src/utils/apiReferenceModel.js @@ -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; +} diff --git a/site/src/utils/remarkConditionalHeadings.js b/site/src/utils/remarkConditionalHeadings.js index d0c576e9..f175e7da 100644 --- a/site/src/utils/remarkConditionalHeadings.js +++ b/site/src/utils/remarkConditionalHeadings.js @@ -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); } } diff --git a/site/src/utils/tests/apiReferenceModel.test.ts b/site/src/utils/tests/apiReferenceModel.test.ts new file mode 100644 index 00000000..535c4583 --- /dev/null +++ b/site/src/utils/tests/apiReferenceModel.test.ts @@ -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', + }, + ]); + }); +});