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'