diff --git a/.changeset/documentation-index-component.md b/.changeset/documentation-index-component.md
new file mode 100644
index 000000000..efedf2986
--- /dev/null
+++ b/.changeset/documentation-index-component.md
@@ -0,0 +1,5 @@
+---
+'@doc-kit/generator-react': patch
+---
+
+Add a `` MDX component that renders the stability overview of every module, backed by a new `documentationIndex` export on `#theme/config` (replaces the `` comment)
diff --git a/.changeset/mdx-degrades-in-string-pipelines.md b/.changeset/mdx-degrades-in-string-pipelines.md
new file mode 100644
index 000000000..df604754a
--- /dev/null
+++ b/.changeset/mdx-degrades-in-string-pipelines.md
@@ -0,0 +1,5 @@
+---
+'@doc-kit/core': patch
+---
+
+MDX nodes in the HTML-string pipelines are now dropped, and do not crash.
\ No newline at end of file
diff --git a/packages/core/src/utils/__tests__/generators.test.mjs b/packages/core/src/utils/__tests__/generators.test.mjs
index d6ef025be..6fe290dba 100644
--- a/packages/core/src/utils/__tests__/generators.test.mjs
+++ b/packages/core/src/utils/__tests__/generators.test.mjs
@@ -2,12 +2,71 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
+ getEntryDescription,
groupNodesByModule,
getVersionFromSemVer,
coerceSemVer,
getCompatibleVersions,
} from '../generators.mjs';
+describe('getEntryDescription', () => {
+ it('returns llm_description when available', () => {
+ const entry = {
+ llm_description: 'LLM generated description',
+ content: { children: [] },
+ };
+
+ const result = getEntryDescription(entry);
+ assert.equal(result, 'LLM generated description');
+ });
+
+ it('extracts first paragraph when no llm_description', () => {
+ const entry = {
+ content: {
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First paragraph' }],
+ },
+ ],
+ },
+ };
+
+ const result = getEntryDescription(entry);
+ assert.ok(result.length > 0);
+ });
+
+ it('returns empty string when no paragraph found', () => {
+ const entry = {
+ content: {
+ children: [
+ { type: 'heading', children: [{ type: 'text', value: 'Title' }] },
+ ],
+ },
+ };
+
+ const result = getEntryDescription(entry);
+ assert.equal(result, '');
+ });
+
+ it('removes newlines from description', () => {
+ const entry = {
+ content: {
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Line 1\nLine 2\r\nLine 3' }],
+ },
+ ],
+ },
+ };
+
+ const result = getEntryDescription(entry);
+ assert.equal(result.includes('\n'), false);
+ assert.equal(result.includes('\r'), false);
+ });
+});
+
describe('groupNodesByModule', () => {
it('groups nodes by api property', () => {
const nodes = [
diff --git a/packages/core/src/utils/__tests__/remark.test.mjs b/packages/core/src/utils/__tests__/remark.test.mjs
new file mode 100644
index 000000000..40250885d
--- /dev/null
+++ b/packages/core/src/utils/__tests__/remark.test.mjs
@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { getRemarkRehype } from '../remark.mjs';
+
+describe('getRemarkRehype', () => {
+ it('degrades MDX nodes instead of crashing rehype-stringify', () => {
+ const processor = getRemarkRehype();
+
+ const tree = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'before ' },
+ {
+ type: 'mdxJsxTextElement',
+ name: 'Tooltip',
+ attributes: [],
+ children: [{ type: 'text', value: 'inner' }],
+ },
+ { type: 'mdxTextExpression', value: '1 + 1' },
+ ],
+ },
+ {
+ type: 'mdxJsxFlowElement',
+ name: 'DocumentationIndex',
+ attributes: [],
+ children: [],
+ },
+ ],
+ };
+
+ const output = processor.stringify(processor.runSync(tree));
+
+ // JSX elements degrade to their children; expressions are dropped.
+ assert.equal(output, '
before inner
');
+ });
+});
diff --git a/packages/core/src/utils/generators.mjs b/packages/core/src/utils/generators.mjs
index ea2d97dfb..516113bca 100644
--- a/packages/core/src/utils/generators.mjs
+++ b/packages/core/src/utils/generators.mjs
@@ -2,6 +2,36 @@
import { coerce, major } from 'semver';
+import { transformNodeToString } from './unist.mjs';
+
+/**
+ * Retrieves the description of a given API doc entry. It first checks whether
+ * the entry has a llm_description property. If not, it extracts the first
+ * paragraph from the entry's content.
+ *
+ * @param {import('../generators/metadata/types').MetadataEntry} entry
+ * @returns {string}
+ */
+export const getEntryDescription = entry => {
+ if (entry.llm_description) {
+ return entry.llm_description.trim();
+ }
+
+ const descriptionNode = entry.content.children.find(
+ child => child.type === 'paragraph'
+ );
+
+ if (!descriptionNode) {
+ return '';
+ }
+
+ return (
+ transformNodeToString(descriptionNode)
+ // Remove newlines and extra spaces
+ .replace(/[\r\n]+/g, '')
+ );
+};
+
/**
* Groups all the API metadata nodes by module (`api` property) so that we can process each different file
* based on the module it belongs to.
diff --git a/packages/core/src/utils/remark.mjs b/packages/core/src/utils/remark.mjs
index 3935b7ea6..4fc3d9e9d 100644
--- a/packages/core/src/utils/remark.mjs
+++ b/packages/core/src/utils/remark.mjs
@@ -13,18 +13,33 @@ import { lazy } from './misc.mjs';
import { typeAnnotationToHast } from './type-annotations/hast.mjs';
import remarkTypeAnnotations from './type-annotations/remark.mjs';
-// MDX node types that may appear in trees parsed by `getRemarkMdx`; the
-// rehype pipelines pass them through untouched.
-const passThrough = [
- 'element',
- 'mdxJsxTextElement',
- 'mdxJsxFlowElement',
- 'mdxJsxAttribute',
- 'mdxJsxAttributeValueExpression',
- 'mdxFlowExpression',
- 'mdxTextExpression',
- 'mdxjsEsm',
-];
+// Nodes the rehype pipelines pass through untouched.
+const passThrough = ['element'];
+
+/**
+ * Renders an MDX JSX element as just its children, so the surrounding prose
+ * still renders in HTML-string output.
+ *
+ * @param {import('mdast-util-to-hast').State} state
+ * @param {import('unist').Parent} node
+ */
+const mdxElementToChildren = (state, node) => state.all(node);
+
+/**
+ * Drops a node from HTML-string output.
+ */
+const dropNode = () => undefined;
+
+// The HTML-string pipelines cannot render MDX nodes (rendering those is the
+// React generators' job): JSX elements degrade to their children so the
+// surrounding prose still renders, and expressions/ESM are dropped.
+const mdxToHastHandlers = {
+ mdxJsxTextElement: mdxElementToChildren,
+ mdxJsxFlowElement: mdxElementToChildren,
+ mdxFlowExpression: dropNode,
+ mdxTextExpression: dropNode,
+ mdxjsEsm: dropNode,
+};
/**
* Retrieves an instance of Remark configured to parse GFM (GitHub Flavored Markdown)
@@ -64,7 +79,7 @@ export const getRemarkRehype = lazy(() =>
.use(remarkRehype, {
allowDangerousHtml: true,
passThrough,
- handlers: { typeAnnotation: typeAnnotationToHast },
+ handlers: { typeAnnotation: typeAnnotationToHast, ...mdxToHastHandlers },
})
// We allow dangerous HTML to be passed through, since we have HTML within our Markdown
// and we trust the sources of the Markdown files
@@ -86,7 +101,7 @@ export const getRemarkRehypeWithShiki = lazy(() =>
allowDangerousHtml: true,
passThrough,
// legacy-html gets the minimal (unhighlighted) type rendering
- handlers: { typeAnnotation: typeAnnotationToHast },
+ handlers: { typeAnnotation: typeAnnotationToHast, ...mdxToHastHandlers },
})
// This is a custom ad-hoc within the Shiki Rehype plugin, used to highlight code
// and transform them into HAST nodes
diff --git a/packages/react/src/html/README.md b/packages/react/src/html/README.md
index 5dddd6529..54916a2d0 100644
--- a/packages/react/src/html/README.md
+++ b/packages/react/src/html/README.md
@@ -358,6 +358,11 @@ title: Welcome
There are {stats.length} APIs documented.
```
+The built-in components are available without registration. Notably,
+`` renders an index of every documented module with its
+stability badge and description, sourced from the `documentationIndex` export
+of [`#theme/config`](#themeconfig-virtual-module).
+
## `#theme/config` virtual module
The `html` generator provides a `#theme/config` virtual module that exposes pre-computed configuration as named exports. Any component (including custom overrides) can import the values it needs, and tree-shaking removes the rest.
@@ -377,6 +382,9 @@ import { project, repository, editURL } from '#theme/config';
- `editURL` {string} Partially populated "edit this page" URL template (only
`{path}` remains).
- `pages` {Array} Sorted `[name, path]` tuples for sidebar navigation.
+- `documentationIndex` {Array} Entries rendered by the built-in
+ `` component — every page with a stability index, each
+ `{ api, name, index, description }`.
- `navigation` {Object} Mirrors the configured `navigation` (consumed by the
built-in `SideBar` and `NavBar`).
- `useAbsoluteURLs` {boolean} Whether internal links use absolute URLs (mirrors
diff --git a/packages/react/src/html/constants.mjs b/packages/react/src/html/constants.mjs
index 4ca3e783a..3850a2a38 100644
--- a/packages/react/src/html/constants.mjs
+++ b/packages/react/src/html/constants.mjs
@@ -26,6 +26,10 @@ export const JSX_IMPORTS = {
name: 'CodeTabs',
source: resolve(ROOT, './ui/components/CodeTabs'),
},
+ DocumentationIndex: {
+ name: 'DocumentationIndex',
+ source: resolve(ROOT, './ui/components/DocumentationIndex'),
+ },
MDXTooltip: {
name: 'MDXTooltip',
isDefaultExport: false,
diff --git a/packages/react/src/html/ui/components/DocumentationIndex/index.jsx b/packages/react/src/html/ui/components/DocumentationIndex/index.jsx
new file mode 100644
index 000000000..83b90b91a
--- /dev/null
+++ b/packages/react/src/html/ui/components/DocumentationIndex/index.jsx
@@ -0,0 +1,48 @@
+import Badge from '@node-core/ui-components/Common/Badge';
+
+import styles from './index.module.css';
+import { STABILITY_KINDS, STABILITY_LABELS } from '../constants.mjs';
+
+import { documentationIndex } from '#theme/config';
+
+/**
+ * @typedef {Object} DocumentationIndexEntry
+ * @property {string} api - Basename of the document, linked as `${api}.html`
+ * @property {string} name - Human-readable name from the document's heading
+ * @property {string} index - Stability index (e.g. `'2'` or `'1.1'`)
+ * @property {string} [description] - The document's `llm_description`, or its first paragraph
+ */
+
+/**
+ * @param {DocumentationIndexEntry} props
+ */
+const IndexEntry = ({ api, name, index, description }) => {
+ const level = parseInt(index, 10);
+ const label = STABILITY_LABELS[level] ?? index;
+
+ return (
+
+
+ {name}
+
+
+ {label}
+
+
+
+ {description && {description}}
+
+ );
+};
+
+export default () => (
+
+);
diff --git a/packages/react/src/html/ui/components/DocumentationIndex/index.module.css b/packages/react/src/html/ui/components/DocumentationIndex/index.module.css
new file mode 100644
index 000000000..a9a29c8e8
--- /dev/null
+++ b/packages/react/src/html/ui/components/DocumentationIndex/index.module.css
@@ -0,0 +1,61 @@
+.documentationIndex {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
+ gap: 1rem;
+ margin-block: 1.5rem;
+}
+
+.entry {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ padding: 1rem;
+ border: 1px solid var(--color-neutral-200);
+ border-radius: 0.75rem;
+ color: inherit;
+ text-decoration: none;
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.entry:hover,
+.entry:focus-visible {
+ border-color: var(--color-neutral-400);
+ background-color: var(--color-neutral-100);
+}
+
+:where([data-theme='dark'], [data-theme='dark'] *) .entry {
+ border-color: var(--color-neutral-900);
+}
+
+:where([data-theme='dark'], [data-theme='dark'] *) .entry:hover,
+:where([data-theme='dark'], [data-theme='dark'] *) .entry:focus-visible {
+ border-color: var(--color-neutral-700);
+ background-color: var(--color-neutral-950);
+}
+
+.title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.name {
+ font-weight: 600;
+ color: var(--color-neutral-900);
+}
+
+:where([data-theme='dark'], [data-theme='dark'] *) .name {
+ color: var(--color-white);
+}
+
+.summary {
+ font-size: 0.875rem;
+ color: var(--color-neutral-800);
+}
+
+:where([data-theme='dark'], [data-theme='dark'] *) .summary {
+ color: var(--color-neutral-600);
+}
diff --git a/packages/react/src/html/ui/components/MetaBar/index.jsx b/packages/react/src/html/ui/components/MetaBar/index.jsx
index 5f705efd3..1a09aa8de 100644
--- a/packages/react/src/html/ui/components/MetaBar/index.jsx
+++ b/packages/react/src/html/ui/components/MetaBar/index.jsx
@@ -4,6 +4,7 @@ import MetaBar from '@node-core/ui-components/Containers/MetaBar';
import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub';
import styles from './index.module.css';
+import { STABILITY_KINDS, STABILITY_LABELS } from '../constants.mjs';
import { editURL } from '#theme/config';
@@ -12,10 +13,6 @@ const iconMap = {
MD: DocumentIcon,
};
-const STABILITY_KINDS = ['error', 'warning', null, 'info'];
-const STABILITY_LABELS = ['D', 'E', null, 'L'];
-const STABILITY_TOOLTIPS = ['Deprecated', 'Experimental', null, 'Legacy'];
-
/**
* Renders a heading value with an optional stability badge
* @param {{ value: string, stability: number }} props
@@ -25,9 +22,7 @@ const HeadingValue = ({ value, stability }) => {
return value;
}
- const ariaLabel = STABILITY_TOOLTIPS[stability]
- ? `Stability: ${STABILITY_TOOLTIPS[stability]}`
- : undefined;
+ const label = STABILITY_LABELS[stability];
return (
<>
@@ -37,11 +32,11 @@ const HeadingValue = ({ value, stability }) => {
size="small"
className={styles.badge}
kind={STABILITY_KINDS[stability]}
- data-tooltip={STABILITY_TOOLTIPS[stability]}
- aria-label={ariaLabel}
+ data-tooltip={label}
+ aria-label={label ? `Stability: ${label}` : undefined}
tabIndex={0}
>
- {STABILITY_LABELS[stability]}
+ {label?.[0]}
>
);
diff --git a/packages/react/src/html/ui/components/constants.mjs b/packages/react/src/html/ui/components/constants.mjs
new file mode 100644
index 000000000..e6fccd249
--- /dev/null
+++ b/packages/react/src/html/ui/components/constants.mjs
@@ -0,0 +1,12 @@
+/**
+ * UI badge kinds and labels for Node.js API stability levels
+ *
+ * @see https://nodejs.org/api/documentation.html#stability-index
+ */
+export const STABILITY_KINDS = ['error', 'warning', 'default', 'info'];
+export const STABILITY_LABELS = [
+ 'Deprecated',
+ 'Experimental',
+ 'Stable',
+ 'Legacy',
+];
diff --git a/packages/react/src/html/utils/__tests__/config.test.mjs b/packages/react/src/html/utils/__tests__/config.test.mjs
index 98c8d678d..ba87f86c7 100644
--- a/packages/react/src/html/utils/__tests__/config.test.mjs
+++ b/packages/react/src/html/utils/__tests__/config.test.mjs
@@ -14,8 +14,12 @@ mock.module('@node-core/rehype-shiki', {
},
});
-const { buildVersionEntries, buildPageList, buildLanguageDisplayNameMap } =
- await import('../config.mjs');
+const {
+ buildVersionEntries,
+ buildPageList,
+ buildDocumentationIndex,
+ buildLanguageDisplayNameMap,
+} = await import('../config.mjs');
await setConfig({
version: 'v22.0.0',
@@ -123,6 +127,66 @@ describe('buildPageList', () => {
});
});
+describe('buildDocumentationIndex', () => {
+ it('lists only pages with a stability index, with their descriptions', () => {
+ const input = [
+ {
+ data: {
+ api: 'fs',
+ path: '/fs',
+ heading: { depth: 1, data: { name: 'File System' } },
+ stability: { data: { index: '2' } },
+ content: {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'File system APIs.' }],
+ },
+ ],
+ },
+ },
+ },
+ {
+ data: {
+ api: 'index',
+ path: '/index',
+ heading: { depth: 1, data: { name: 'Index' } },
+ stability: null,
+ content: { type: 'root', children: [] },
+ },
+ },
+ {
+ data: {
+ api: 'quic',
+ path: '/quic',
+ heading: { depth: 1, data: { name: 'QUIC' } },
+ stability: { data: { index: '1.1' } },
+ llm_description: 'QUIC protocol support.',
+ content: { type: 'root', children: [] },
+ },
+ },
+ ];
+
+ const result = buildDocumentationIndex(input);
+
+ assert.deepStrictEqual(result, [
+ {
+ api: 'fs',
+ name: 'File System',
+ index: '2',
+ description: 'File system APIs.',
+ },
+ {
+ api: 'quic',
+ name: 'QUIC',
+ index: '1.1',
+ description: 'QUIC protocol support.',
+ },
+ ]);
+ });
+});
+
describe('buildLanguageDisplayNameMap', () => {
it('returns entries suitable for constructing a Map', () => {
const result = buildLanguageDisplayNameMap();
diff --git a/packages/react/src/html/utils/config.mjs b/packages/react/src/html/utils/config.mjs
index 4a822b3a7..5df89a213 100644
--- a/packages/react/src/html/utils/config.mjs
+++ b/packages/react/src/html/utils/config.mjs
@@ -2,7 +2,10 @@
import getConfig from '@doc-kit/core/utils/configuration/index.mjs';
import { populate } from '@doc-kit/core/utils/configuration/templates.mjs';
-import { getVersionFromSemVer } from '@doc-kit/core/utils/generators.mjs';
+import {
+ getEntryDescription,
+ getVersionFromSemVer,
+} from '@doc-kit/core/utils/generators.mjs';
import { omitKeys } from '@doc-kit/core/utils/misc.mjs';
import { LANGS } from '@node-core/rehype-shiki';
@@ -41,6 +44,24 @@ export function buildPageList(input) {
return headNodes.map(node => [node.heading.data.name, node.path]);
}
+/**
+ * Pre-compute the entries rendered by the `` component:
+ * every page with a stability index, plus its description.
+ *
+ * @param {Array} input
+ * @returns {Array<{api: string, name: string, index: string, description: string}>}
+ */
+export function buildDocumentationIndex(input) {
+ return getSortedHeadNodes(input.map(e => e.data))
+ .filter(entry => entry.stability)
+ .map(entry => ({
+ api: entry.api,
+ name: entry.heading.data.name,
+ index: entry.stability.data.index,
+ description: getEntryDescription(entry),
+ }));
+}
+
/**
* Pre-compute Shiki language display name map entries.
*
@@ -107,6 +128,7 @@ export default function createConfigSource(input, server = false) {
versions: buildVersionEntries(config.changelog, pageURL),
editURL,
pages: buildPageList(input),
+ documentationIndex: buildDocumentationIndex(input),
server,
};
diff --git a/packages/react/src/jsx-ast/README.md b/packages/react/src/jsx-ast/README.md
index a8fbd040e..dcb504913 100644
--- a/packages/react/src/jsx-ast/README.md
+++ b/packages/react/src/jsx-ast/README.md
@@ -16,6 +16,6 @@ The `jsx-ast` generator converts MDAST (Markdown Abstract Syntax Tree) to JSX AS
## Index page
`index.html` is generated when an `index` document is part of the input, and
-is rendered from that document like any other page. A section containing a
-`` comment additionally receives the Stability
-Overview table of all modules.
+is rendered from that document like any other page. An MDX `index` document
+can render the stability overview of every module by using the built-in
+`` component (see the `html` generator's README).
diff --git a/packages/react/src/jsx-ast/__tests__/generate.test.mjs b/packages/react/src/jsx-ast/__tests__/generate.test.mjs
index a3050b09e..7d09db400 100644
--- a/packages/react/src/jsx-ast/__tests__/generate.test.mjs
+++ b/packages/react/src/jsx-ast/__tests__/generate.test.mjs
@@ -94,50 +94,4 @@ describe('jsx-ast generate', () => {
['index', 'fs']
);
});
-
- it('only generates an index page when an index document is an input', async () => {
- await setConfig({ target: ['jsx-ast'] });
-
- const jsxAstConfig = getConfig('jsx-ast');
- jsxAstConfig.generateAllPage = false;
- jsxAstConfig.generateNotFoundPage = false;
-
- const seenItems = [];
- await collect(
- generate([createEntry('fs', 'File system')], createWorker(seenItems))
- );
-
- assert.deepEqual(
- seenItems.map(({ head }) => head.api),
- ['fs']
- );
- });
-
- it('places the stability overview at the DOCUMENTATION_INDEX comment', async () => {
- await setConfig({ target: ['jsx-ast'] });
-
- const jsxAstConfig = getConfig('jsx-ast');
- jsxAstConfig.generateAllPage = false;
- jsxAstConfig.generateNotFoundPage = false;
-
- const index = createEntry('index', 'Index', { stabilityIndex: null });
- // The metadata parser turns a `` comment into
- // this tag on the entry of the section containing it.
- index.tags = ['DOCUMENTATION_INDEX'];
-
- const seenItems = [];
- await collect(
- generate(
- [index, createEntry('fs', 'File system')],
- createWorker(seenItems)
- )
- );
-
- const [{ entries }] = seenItems;
- const table = entries[0].content.children.at(-1);
-
- assert.equal(table.tagName, 'table');
- const [row] = table.children.at(-1).children;
- assert.equal(row.children[0].children[0].properties.href, 'fs.html');
- });
});
diff --git a/packages/react/src/jsx-ast/generate.mjs b/packages/react/src/jsx-ast/generate.mjs
index 9f6969543..25d0c2b99 100644
--- a/packages/react/src/jsx-ast/generate.mjs
+++ b/packages/react/src/jsx-ast/generate.mjs
@@ -3,7 +3,6 @@ import { groupNodesByModule } from '@doc-kit/core/utils/generators.mjs';
import { jsx, toJs } from 'estree-util-to-js';
import buildContent from './utils/buildContent.mjs';
-import { injectDocumentationIndex } from './utils/documentationIndex.mjs';
import { getSortedHeadNodes } from './utils/getSortedHeadNodes.mjs';
import { buildNotFoundPage } from './utils/synthetic/404.mjs';
import { buildAllPage } from './utils/synthetic/all.mjs';
@@ -60,14 +59,9 @@ export async function processChunk(slicedInput, itemIndices) {
*/
export async function* generate(input, worker) {
// The `index` page is only generated when an `index` document is part of
- // the input; the module list for the synthetic pages and the stability
- // overview excludes it.
+ // the input; the module list for the synthetic pages excludes it.
const moduleInput = input.filter(entry => entry.api !== 'index');
- // Sections tagged with a `` comment (e.g. in
- // the `index` document) receive the Stability Overview of all modules.
- injectDocumentationIndex(input, moduleInput);
-
// Create sliced input: each item contains head + its module's entries
// This avoids sending all 4700+ entries to every worker
const groupedModules = groupNodesByModule(input);
diff --git a/packages/react/src/jsx-ast/utils/__tests__/documentationIndex.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/documentationIndex.test.mjs
deleted file mode 100644
index 6817f6349..000000000
--- a/packages/react/src/jsx-ast/utils/__tests__/documentationIndex.test.mjs
+++ /dev/null
@@ -1,144 +0,0 @@
-import assert from 'node:assert/strict';
-import { describe, it } from 'node:test';
-
-import {
- buildStabilityOverview,
- injectDocumentationIndex,
-} from '../documentationIndex.mjs';
-
-const fakeHead = (api, name, stabilityIndex, depth = 1) => ({
- api,
- heading: { depth, data: { name, text: name, slug: api } },
- stability:
- stabilityIndex == null
- ? null
- : {
- data: {
- index: String(stabilityIndex),
- description: `${name} stable. Long-form description.`,
- },
- },
-});
-
-const findChild = (node, tagName) =>
- node.children.find(child => child.tagName === tagName);
-
-describe('injectDocumentationIndex', () => {
- const createEntry = tags => ({
- ...fakeHead('index', 'Index', null),
- tags,
- content: { type: 'root', children: [] },
- });
-
- it('appends the overview to entries tagged DOCUMENTATION_INDEX', () => {
- const tagged = createEntry(['DOCUMENTATION_INDEX']);
- const untagged = createEntry(undefined);
-
- injectDocumentationIndex(
- [tagged, untagged],
- [fakeHead('fs', 'fs', 2), fakeHead('assert', 'assert', 2)]
- );
-
- const table = findChild(tagged.content, 'table');
- assert.equal(findChild(table, 'tbody').children.length, 2);
- assert.equal(untagged.content.children.length, 0);
- });
-
- it('sorts the stability overview rows alphabetically by API name', () => {
- const entry = createEntry(['DOCUMENTATION_INDEX']);
-
- injectDocumentationIndex(
- [entry],
- [
- fakeHead('fs', 'fs', 2),
- fakeHead('assert', 'assert', 2),
- fakeHead('crypto', 'crypto', 2),
- ]
- );
-
- const table = findChild(entry.content, 'table');
- const rows = findChild(table, 'tbody').children;
- const names = rows.map(
- row => row.children[0].children[0].children[0].value
- );
-
- assert.deepEqual(names, ['assert', 'crypto', 'fs']);
- });
-
- it('excludes module heads without a stability index', () => {
- const entry = createEntry(['DOCUMENTATION_INDEX']);
-
- injectDocumentationIndex(
- [entry],
- [fakeHead('fs', 'fs', 2), fakeHead('synopsis', 'Usage', null)]
- );
-
- const table = findChild(entry.content, 'table');
- assert.equal(findChild(table, 'tbody').children.length, 1);
- });
-});
-
-describe('buildStabilityOverview', () => {
- it('renders a header row and one body row per entry', () => {
- const table = buildStabilityOverview([
- fakeHead('fs', 'fs', 2),
- fakeHead('crypto', 'crypto', 1),
- ]);
-
- assert.equal(table.tagName, 'table');
- const headerRow = findChild(findChild(table, 'thead'), 'tr');
- assert.deepEqual(
- headerRow.children.map(c => c.children[0].value),
- ['API', 'Stability']
- );
-
- assert.equal(findChild(table, 'tbody').children.length, 2);
- });
-
- it('formats the stability cell with a colored badge and first sentence', () => {
- const table = buildStabilityOverview([fakeHead('fs', 'fs', 1)]);
-
- const row = findChild(table, 'tbody').children[0];
- const stabilityCell = row.children[1];
- const badge = stabilityCell.children[0];
-
- assert.equal(badge.name, 'Badge');
- assert.deepEqual(
- badge.attributes.map(({ name, value }) => [name, value]),
- [
- ['size', 'small'],
- ['kind', 'warning'],
- ['aria-label', 'Stability: 1'],
- ]
- );
- assert.equal(badge.children[0].value, '1');
- assert.equal(stabilityCell.children[1].value, ' fs stable');
- });
-
- it('uses a default badge for stable entries', () => {
- const table = buildStabilityOverview([fakeHead('fs', 'fs', 2)]);
-
- const row = findChild(table, 'tbody').children[0];
- const badge = row.children[1].children[0];
- const kind = badge.attributes.find(attr => attr.name === 'kind');
-
- assert.equal(kind.value, 'default');
- });
-
- it('builds a relative link to the module HTML page', () => {
- const table = buildStabilityOverview([fakeHead('fs', 'fs', 2)]);
-
- const row = findChild(table, 'tbody').children[0];
- const link = row.children[0].children[0];
-
- assert.equal(link.tagName, 'a');
- assert.equal(link.properties.href, 'fs.html');
- assert.equal(link.children[0].value, 'fs');
- });
-
- it('renders an empty body when no entries are passed', () => {
- const table = buildStabilityOverview([]);
-
- assert.equal(findChild(table, 'tbody').children.length, 0);
- });
-});
diff --git a/packages/react/src/jsx-ast/utils/documentationIndex.mjs b/packages/react/src/jsx-ast/utils/documentationIndex.mjs
deleted file mode 100644
index c12ce0bf7..000000000
--- a/packages/react/src/jsx-ast/utils/documentationIndex.mjs
+++ /dev/null
@@ -1,86 +0,0 @@
-'use strict';
-
-import { h as createElement } from 'hastscript';
-
-import { createJSXElement } from './ast.mjs';
-import { getSortedHeadNodes } from './getSortedHeadNodes.mjs';
-import { JSX_IMPORTS } from '../../html/constants.mjs';
-
-// The metadata parser turns bare HTML comments into entry tags, so a
-// `` comment in a source document surfaces as
-// this tag on the entry for the section containing it.
-export const DOCUMENTATION_INDEX_TAG = 'DOCUMENTATION_INDEX';
-
-const STABILITY_BADGE_KINDS = [
- 'error',
- 'warning',
- 'default',
- 'info',
- 'neutral',
- 'neutral',
-];
-
-/**
- * Maps a Node.js stability index to a UI badge kind.
- *
- * @param {string} index
- */
-const getStabilityBadgeKind = index =>
- STABILITY_BADGE_KINDS[parseInt(index, 10)] ?? 'neutral';
-
-/**
- * Builds the Stability Overview table from module heads that declare a
- * top-level stability index, mirroring the `legacy-html-all` overview.
- *
- * @param {Array} headEntries
- */
-export const buildStabilityOverview = headEntries =>
- createElement('table', [
- createElement('thead', [
- createElement('tr', [
- createElement('th', 'API'),
- createElement('th', 'Stability'),
- ]),
- ]),
- createElement(
- 'tbody',
- headEntries.map(({ heading, api, stability }) =>
- createElement('tr', [
- createElement(
- 'td',
- createElement('a', { href: `${api}.html` }, heading.data.name)
- ),
- createElement(
- 'td',
- createJSXElement(JSX_IMPORTS.Badge.name, {
- size: 'small',
- kind: getStabilityBadgeKind(stability.data.index),
- 'aria-label': `Stability: ${stability.data.index}`,
- children: stability.data.index,
- }),
- ` ${stability.data.description.split('. ')[0]}`
- ),
- ])
- )
- ),
- ]);
-
-/**
- * Places the Stability Overview into every entry whose source section
- * contains a `` comment. The parser strips the
- * comment itself, so the table lands at the end of the tagged section.
- *
- * @param {Array} entries - Entries to scan for the tag
- * @param {Array} moduleEntries - Entries providing the module heads for the overview
- */
-export const injectDocumentationIndex = (entries, moduleEntries) => {
- const headEntries = getSortedHeadNodes(moduleEntries).filter(
- entry => entry.stability
- );
-
- for (const entry of entries) {
- if (entry.tags?.includes(DOCUMENTATION_INDEX_TAG)) {
- entry.content.children.push(buildStabilityOverview(headEntries));
- }
- }
-};
diff --git a/packages/react/src/llms-txt/utils/__tests__/buildApiDocLink.test.mjs b/packages/react/src/llms-txt/utils/__tests__/buildApiDocLink.test.mjs
index 6120f93e1..0e8e4f202 100644
--- a/packages/react/src/llms-txt/utils/__tests__/buildApiDocLink.test.mjs
+++ b/packages/react/src/llms-txt/utils/__tests__/buildApiDocLink.test.mjs
@@ -1,65 +1,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
-import { getEntryDescription, buildApiDocLink } from '../buildApiDocLink.mjs';
-
-describe('getEntryDescription', () => {
- it('returns llm_description when available', () => {
- const entry = {
- llm_description: 'LLM generated description',
- content: { children: [] },
- };
-
- const result = getEntryDescription(entry);
- assert.equal(result, 'LLM generated description');
- });
-
- it('extracts first paragraph when no llm_description', () => {
- const entry = {
- content: {
- children: [
- {
- type: 'paragraph',
- children: [{ type: 'text', value: 'First paragraph' }],
- },
- ],
- },
- };
-
- const result = getEntryDescription(entry);
- assert.ok(result.length > 0);
- });
-
- it('returns empty string when no paragraph found', () => {
- const entry = {
- content: {
- children: [
- { type: 'heading', children: [{ type: 'text', value: 'Title' }] },
- ],
- },
- };
-
- const result = getEntryDescription(entry);
- assert.equal(result, '');
- });
-
- it('removes newlines from description', () => {
- const entry = {
- content: {
- children: [
- {
- type: 'paragraph',
- children: [{ type: 'text', value: 'Line 1\nLine 2\r\nLine 3' }],
- },
- ],
- },
- };
-
- const result = getEntryDescription(entry);
- assert.equal(result.includes('\n'), false);
- assert.equal(result.includes('\r'), false);
- });
-});
+import { buildApiDocLink } from '../buildApiDocLink.mjs';
describe('buildApiDocLink', () => {
it('builds markdown link with description', () => {
diff --git a/packages/react/src/llms-txt/utils/buildApiDocLink.mjs b/packages/react/src/llms-txt/utils/buildApiDocLink.mjs
index 7d17d04f0..d81e84976 100644
--- a/packages/react/src/llms-txt/utils/buildApiDocLink.mjs
+++ b/packages/react/src/llms-txt/utils/buildApiDocLink.mjs
@@ -1,33 +1,5 @@
import { populate } from '@doc-kit/core/utils/configuration/templates.mjs';
-import { transformNodeToString } from '@doc-kit/core/utils/unist.mjs';
-
-/**
- * Retrieves the description of a given API doc entry. It first checks whether
- * the entry has a llm_description property. If not, it extracts the first
- * paragraph from the entry's content.
- *
- * @param {import('@doc-kit/core/generators/metadata/types').MetadataEntry} entry
- * @returns {string}
- */
-export const getEntryDescription = entry => {
- if (entry.llm_description) {
- return entry.llm_description.trim();
- }
-
- const descriptionNode = entry.content.children.find(
- child => child.type === 'paragraph'
- );
-
- if (!descriptionNode) {
- return '';
- }
-
- return (
- transformNodeToString(descriptionNode)
- // Remove newlines and extra spaces
- .replace(/[\r\n]+/g, '')
- );
-};
+import { getEntryDescription } from '@doc-kit/core/utils/generators.mjs';
/**
* Builds a markdown link for an API doc entry
diff --git a/scripts/vercel-prepare.sh b/scripts/vercel-prepare.sh
index 2fa3e1b5d..5726021a7 100755
--- a/scripts/vercel-prepare.sh
+++ b/scripts/vercel-prepare.sh
@@ -26,7 +26,23 @@ cd node
# Enable sparse checkout and specify the folder
git sparse-checkout set lib doc .
-sed 's/STABILITY_OVERVIEW_SLOT_BEGIN/DOCUMENTATION_INDEX/g' ./doc/api/documentation.md > ./doc/api/index.md
+# TODO(@avivkeller): Remove this rewrite once nodejs/node embeds
+# `` directly.
+INTRODUCED_IN=$(sed -n 's/^$/\1/p' ./doc/api/documentation.md)
+
+{
+ printf -- '---\nmdx: true\ntype: misc\n'
+ if [ -n "$INTRODUCED_IN" ]; then
+ printf -- 'introduced_in: %s\n' "$INTRODUCED_IN"
+ fi
+ printf -- '---\n'
+ sed \
+ -e 's|||' \
+ -e '/^$/d' \
+ -e '/^$/d' \
+ ./doc/api/documentation.md
+} > ./doc/api/index.md
+
rm ./doc/api/documentation.md
# Move back out