From 45416584c8aaec75d9bd95084aa10ea0ea3ab448 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 22 Aug 2026 17:25:12 -0700 Subject: [PATCH] fix(unist): convert commented markdown into HTML --- .changeset/render-inline-markdown.md | 6 ++ .../core/src/utils/__tests__/inline.test.mjs | 39 +++++++++ packages/core/src/utils/inline.mjs | 82 +++++++++++++++++++ packages/react/package.json | 2 +- .../components/DocumentationIndex/index.jsx | 11 ++- .../src/html/utils/__tests__/config.test.mjs | 23 ++++++ packages/react/src/html/utils/config.mjs | 3 +- .../utils/__tests__/buildContent.test.mjs | 48 +++++++++++ .../react/src/jsx-ast/utils/buildContent.mjs | 37 ++++----- packages/react/src/jsx-ast/utils/render.mjs | 17 ++++ packages/react/src/jsx-ast/utils/types.mjs | 10 +-- pnpm-lock.yaml | 10 +-- pnpm-workspace.yaml | 3 + 13 files changed, 255 insertions(+), 36 deletions(-) create mode 100644 .changeset/render-inline-markdown.md create mode 100644 packages/core/src/utils/__tests__/inline.test.mjs create mode 100644 packages/core/src/utils/inline.mjs create mode 100644 packages/react/src/jsx-ast/utils/render.mjs diff --git a/.changeset/render-inline-markdown.md b/.changeset/render-inline-markdown.md new file mode 100644 index 000000000..6576b1aa2 --- /dev/null +++ b/.changeset/render-inline-markdown.md @@ -0,0 +1,6 @@ +--- +'@doc-kit/generator-react': patch +'@doc-kit/core': patch +--- + +Render markdown summaries as markup instead of as their source diff --git a/packages/core/src/utils/__tests__/inline.test.mjs b/packages/core/src/utils/__tests__/inline.test.mjs new file mode 100644 index 000000000..8ad0d7692 --- /dev/null +++ b/packages/core/src/utils/__tests__/inline.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { parseInline, renderAsHTML } from '../inline.mjs'; + +describe('parseInline', () => { + it('drops the paragraph a line of prose is parsed into', () => { + const nodes = parseInline('Some `code` here.'); + + assert.deepEqual( + nodes.map(node => node.type), + ['text', 'inlineCode', 'text'] + ); + }); + + it('replaces links with their text when asked', () => { + const markdown = 'Superseded by [DEP0111](#DEP0111).'; + + assert.equal(parseInline(markdown)[1].type, 'link'); + assert.deepEqual( + parseInline(markdown, true).map(node => node.type), + ['text', 'text', 'text'] + ); + }); +}); + +describe('renderAsHTML', () => { + it('renders nodes without whitespace between them', () => { + const html = renderAsHTML(parseInline('Now returns `undefined`.')); + + assert.equal(html, 'Now returns undefined.'); + }); + + it('drops raw HTML rather than passing it through', () => { + const html = renderAsHTML(parseInline('A bold claim.')); + + assert.equal(html, 'A bold claim.'); + }); +}); diff --git a/packages/core/src/utils/inline.mjs b/packages/core/src/utils/inline.mjs new file mode 100644 index 000000000..27849a129 --- /dev/null +++ b/packages/core/src/utils/inline.mjs @@ -0,0 +1,82 @@ +'use strict'; + +import rehypeStringify from 'rehype-stringify'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import { unified } from 'unified'; +import { u as createTree } from 'unist-builder'; +import { SKIP, visit } from 'unist-util-visit'; + +import { lazy } from './misc.mjs'; + +/** + * Renders a `root` as just its children, since content placed directly on one + * is otherwise rendered with a line break between each node, which surfaces as + * stray whitespace. + * + * @param {import('mdast-util-to-hast').State} state + * @param {import('unist').Parent} node + */ +const inlineRoot = (state, node) => ({ + type: 'root', + children: state.all(node), +}); + +/** + * Retrieves an instance of Remark configured to parse plain markdown, without + * the extensions that only apply to whole documents. + */ +const getInlineParser = lazy(() => unified().use(remarkParse)); + +/** + * Retrieves an instance of Remark configured to render inline nodes as an HTML + * string. Raw HTML is dropped rather than passed through, since the result is + * inserted into the page as-is. + */ +const getInlineRenderer = lazy(() => + unified() + .use(remarkRehype, { handlers: { root: inlineRoot } }) + .use(rehypeStringify) +); + +/** + * Parses a single line of markdown (a change description, a summary, ...) into + * the inline nodes it is made of. + * + * @param {string} markdown - The markdown to parse. + * @param {boolean} [dropLinks] - Replace links with their text? Anchors cannot + * nest, so content rendered inside a link must not contain one. + * @returns {Array} The parsed nodes. + */ +export const parseInline = (markdown, dropLinks = false) => { + const tree = getInlineParser().parse(markdown); + + if (dropLinks) { + visit(tree, 'link', (node, index, parent) => { + parent.children.splice(index, 1, ...node.children); + + return [SKIP, index]; + }); + } + + const [first] = tree.children; + + // A single line of prose parses into one paragraph, which is dropped so the + // nodes can be rendered inline + return tree.children.length === 1 && first.type === 'paragraph' + ? first.children + : tree.children; +}; + +/** + * Renders inline nodes as an HTML string, for the places that hand rendered + * markup to a component through a data channel rather than as an AST. + * + * @param {Array} nodes - The nodes to render. + * @returns {string} The rendered HTML. + */ +export const renderAsHTML = nodes => { + const renderer = getInlineRenderer(); + + return renderer.stringify(renderer.runSync(createTree('root', nodes))); +}; diff --git a/packages/react/package.json b/packages/react/package.json index 1e7d63ff8..3fb38a6fd 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,7 +34,7 @@ "@heroicons/react": "^2.2.0", "@doc-kit/core": "workspace:*", "@node-core/rehype-shiki": "^1.4.3", - "@node-core/ui-components": "^1.7.4", + "@node-core/ui-components": "^1.7.6", "@orama/orama": "^3.1.18", "@orama/ui": "^1.5.4", "estree-util-to-js": "^2.0.0", diff --git a/packages/react/src/html/ui/components/DocumentationIndex/index.jsx b/packages/react/src/html/ui/components/DocumentationIndex/index.jsx index 83b90b91a..d809c82fb 100644 --- a/packages/react/src/html/ui/components/DocumentationIndex/index.jsx +++ b/packages/react/src/html/ui/components/DocumentationIndex/index.jsx @@ -10,7 +10,8 @@ import { documentationIndex } from '#theme/config'; * @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 + * @property {string} [description] - The document's `llm_description`, or its + * first paragraph, rendered to HTML at build time */ /** @@ -34,7 +35,13 @@ const IndexEntry = ({ api, name, index, description }) => { - {description && {description}} + {description && ( + + )} ); }; diff --git a/packages/react/src/html/utils/__tests__/config.test.mjs b/packages/react/src/html/utils/__tests__/config.test.mjs index ba87f86c7..294a22933 100644 --- a/packages/react/src/html/utils/__tests__/config.test.mjs +++ b/packages/react/src/html/utils/__tests__/config.test.mjs @@ -185,6 +185,29 @@ describe('buildDocumentationIndex', () => { }, ]); }); + + it('renders descriptions to HTML, without the links entries cannot nest', () => { + const input = [ + { + data: { + api: 'fs', + path: '/fs', + heading: { depth: 1, data: { name: 'File System' } }, + stability: { data: { index: '2' } }, + llm_description: + 'Enables interacting with the `file system`, see [fs](/fs).', + content: { type: 'root', children: [] }, + }, + }, + ]; + + const [{ description }] = buildDocumentationIndex(input); + + assert.equal( + description, + 'Enables interacting with the file system, see fs.' + ); + }); }); describe('buildLanguageDisplayNameMap', () => { diff --git a/packages/react/src/html/utils/config.mjs b/packages/react/src/html/utils/config.mjs index 5df89a213..e8851d42e 100644 --- a/packages/react/src/html/utils/config.mjs +++ b/packages/react/src/html/utils/config.mjs @@ -6,6 +6,7 @@ import { getEntryDescription, getVersionFromSemVer, } from '@doc-kit/core/utils/generators.mjs'; +import { parseInline, renderAsHTML } from '@doc-kit/core/utils/inline.mjs'; import { omitKeys } from '@doc-kit/core/utils/misc.mjs'; import { LANGS } from '@node-core/rehype-shiki'; @@ -58,7 +59,7 @@ export function buildDocumentationIndex(input) { api: entry.api, name: entry.heading.data.name, index: entry.stability.data.index, - description: getEntryDescription(entry), + description: renderAsHTML(parseInline(getEntryDescription(entry), true)), })); } diff --git a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs index 225f8b857..1be679643 100644 --- a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs +++ b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs @@ -22,6 +22,18 @@ const makeParent = typeText => ({ ], }); +/** + * Collects the tag names of every JSX element within a JSX AST node. + * + * @param {import('estree-jsx').JSXFragment} node + */ +const jsxElementNames = node => + (node.children ?? []).flatMap(child => + child.type === 'JSXElement' + ? [child.openingElement.name.name, ...jsxElementNames(child)] + : jsxElementNames(child) + ); + await setConfig({}); describe('transformHeadingNode (deprecation Type -> AlertBox level)', () => { @@ -111,6 +123,42 @@ describe('gatherChangeEntries', () => { assert.deepEqual(result[0].versions, ['v25.0.0']); }); + it('renders the markdown description as JSX content', () => { + const [change] = gatherChangeEntries({ + changes: [ + { + version: 'v25.0.0', + description: 'Add `modifyPrototype` option.', + }, + ], + }); + + assert.equal(change.content.type, 'JSXFragment'); + assert.deepEqual(jsxElementNames(change.content), ['code']); + }); + + it('unwraps description links when the change links to its pull request', () => { + const description = 'Superseded by [DEP0111](#DEP0111).'; + + const [linked] = gatherChangeEntries({ + changes: [ + { + version: 'v1.0.0', + description, + 'pr-url': 'https://example.com/pr/1', + }, + ], + }); + + const [unlinked] = gatherChangeEntries({ + changes: [{ version: 'v1.0.0', description }], + }); + + assert.deepEqual(jsxElementNames(linked.content), []); + assert.deepEqual(jsxElementNames(unlinked.content), ['a']); + assert.equal(linked.label, 'Superseded by DEP0111.'); + }); + it('produces a string label, not an object (regression for [object Object])', () => { const result = gatherChangeEntries({ changes: [ diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index ec2a17904..5b29a2f9c 100644 --- a/packages/react/src/jsx-ast/utils/buildContent.mjs +++ b/packages/react/src/jsx-ast/utils/buildContent.mjs @@ -6,13 +6,12 @@ import { GITHUB_BLOB_URL, populate, } from '@doc-kit/core/utils/configuration/templates.mjs'; +import { parseInline } from '@doc-kit/core/utils/inline.mjs'; import { omitKeys } from '@doc-kit/core/utils/misc.mjs'; import { UNIST } from '@doc-kit/core/utils/queries/index.mjs'; import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs'; import { h as createElement } from 'hastscript'; import { slice } from 'mdast-util-slice-markdown'; -import remarkParse from 'remark-parse'; -import { unified } from 'unified'; import { u as createTree } from 'unist-builder'; import { SKIP, visit } from 'unist-util-visit'; @@ -20,6 +19,7 @@ import { createJSXElement } from './ast.mjs'; import { extractHeadings, extractTextContent } from './buildBarProps.mjs'; import { annotateOverloads } from './overloads.mjs'; import { getRemarkRecma as remark } from './remark.mjs'; +import { renderAsJSX } from './render.mjs'; import { JSX_IMPORTS } from '../../html/constants.mjs'; import { STABILITY_LEVELS, @@ -37,18 +37,6 @@ import { getFullName, } from './signature.mjs'; -/** - * Converts a markdown string to plain text by parsing it and extracting - * text and inline code values. - * - * @param {string} markdown - The markdown string to convert. - * @returns {string} The plain text representation. - */ -const toPlainText = markdown => - transformNodesToString( - unified().use(remarkParse).parse(markdown).children - ).trim(); - /** * */ @@ -68,12 +56,21 @@ export const gatherChangeEntries = entry => { label: `${label}: ${enforceArray(entry[field]).join(', ')}`, })); - // Explicit changes with plain-text labels extracted from markdown - const explicitChanges = (entry.changes || []).map(change => ({ - versions: enforceArray(change.version), - label: toPlainText(change.description), - url: change['pr-url'], - })); + // Explicit changes, whose markdown descriptions are rendered as JSX + const explicitChanges = (entry.changes || []).map(change => { + const url = change['pr-url']; + const nodes = parseInline(change.description, Boolean(url)); + + return { + versions: enforceArray(change.version), + // The plain text backs the change's `aria-label` and React key + label: transformNodesToString(nodes).trim(), + // `content` takes a ReactNode, so inline code, emphasis and links are + // displayed as markup instead of as their markdown source + content: renderAsJSX(nodes), + url, + }; + }); return [...lifecycleChanges, ...explicitChanges]; }; diff --git a/packages/react/src/jsx-ast/utils/render.mjs b/packages/react/src/jsx-ast/utils/render.mjs new file mode 100644 index 000000000..bab668ea6 --- /dev/null +++ b/packages/react/src/jsx-ast/utils/render.mjs @@ -0,0 +1,17 @@ +'use strict'; + +import { u as createTree } from 'unist-builder'; + +import { createJSXElement } from './ast.mjs'; +import { getRemarkRecma as remark } from './remark.mjs'; + +/** + * Renders inline nodes as a JSX fragment + * + * @param {Array} nodes - The nodes to render. + * @returns {import('estree-jsx').JSXFragment} The rendered nodes. + */ +export const renderAsJSX = nodes => + remark().runSync( + createTree('root', [createJSXElement(null, { children: nodes })]) + ).body[0].expression; diff --git a/packages/react/src/jsx-ast/utils/types.mjs b/packages/react/src/jsx-ast/utils/types.mjs index 3f72d5090..d5dc23d5f 100644 --- a/packages/react/src/jsx-ast/utils/types.mjs +++ b/packages/react/src/jsx-ast/utils/types.mjs @@ -1,9 +1,8 @@ import { QUERIES, UNIST } from '@doc-kit/core/utils/queries/index.mjs'; import { DEFAULT_EXPRESSION } from '@doc-kit/core/utils/signature/constants.mjs'; import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs'; -import { u as createTree } from 'unist-builder'; -import { getRemarkRecma as remark } from './remark.mjs'; +import { renderAsJSX } from './render.mjs'; import { TRIMMABLE_PADDING_REGEX } from '../constants.mjs'; /** @@ -70,8 +69,7 @@ export const extractTypeAnnotation = nodes => { return undefined; } - return remark().runSync(createTree('root', [nodes.shift()])).body[0] - .expression; + return renderAsJSX([nodes.shift()]); }; /** @@ -101,9 +99,7 @@ export const parseListIntoProperties = node => transformNodesToString(children) ); - current.description = remark().runSync( - createTree('root', children) - ).body[0].expression; + current.description = renderAsJSX(children); } current.children = parseListIntoProperties( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c6191fc3..e37cb71b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,8 +236,8 @@ importers: specifier: ^1.4.3 version: 1.4.3(supports-color@7.2.0) '@node-core/ui-components': - specifier: ^1.7.4 - version: 1.7.4(@orama/core@1.2.19)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(supports-color@7.2.0) + specifier: ^1.7.6 + version: 1.7.6(@orama/core@1.2.19)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(supports-color@7.2.0) '@orama/orama': specifier: ^3.1.18 version: 3.1.18 @@ -707,8 +707,8 @@ packages: resolution: {integrity: sha512-3LnysU0F1MacdHc+1VHswySfD+smk2bHGob2IlqtHc9VrL5yCC+gbIBbALZYtOOSdmY9J65XvJilFm8XbXJZgA==} engines: {node: '>=20'} - '@node-core/ui-components@1.7.4': - resolution: {integrity: sha512-HmSqvXKOk8xBBpFAjWzx6FNCVYmyWSvqwqwfUVaJVAbNGyRNwtziV6HaBjW/ebhD8pqJlhIsrTYO5SFdnQ8WIQ==} + '@node-core/ui-components@1.7.6': + resolution: {integrity: sha512-4HUQKbcSAAXF/wBVHdfGrf0FW2krKfdtVDMRWWR/cELEEgVWV5TLBVqvYA9+NbiOnNvPkpTgBlMjjCJpVh7tAg==} engines: {node: '>=20'} '@nodelib/fs.scandir@2.1.5': @@ -4044,7 +4044,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@node-core/ui-components@1.7.4(@orama/core@1.2.19)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(supports-color@7.2.0)': + '@node-core/ui-components@1.7.6(@orama/core@1.2.19)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(supports-color@7.2.0)': dependencies: '@heroicons/react': 2.2.0(react@19.2.8) '@orama/orama': 3.1.18 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce5a6586f..40e71261f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,6 @@ packages: allowBuilds: unrs-resolver: false + +minimumReleaseAgeExclude: + - '@node-core/ui-components@1.7.6'