mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(site): clean up api reference header hierarchy
I'm ok with H4s now
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user