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