Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/render-inline-markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@doc-kit/generator-react': patch
'@doc-kit/core': patch
---

Render markdown summaries as markup instead of as their source
39 changes: 39 additions & 0 deletions packages/core/src/utils/__tests__/inline.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <code>undefined</code>.');
});

it('drops raw HTML rather than passing it through', () => {
const html = renderAsHTML(parseInline('A <b>bold</b> claim.'));

assert.equal(html, 'A bold claim.');
});
});
82 changes: 82 additions & 0 deletions packages/core/src/utils/inline.mjs
Original file line number Diff line number Diff line change
@@ -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<import('mdast').PhrasingContent>} 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<import('mdast').PhrasingContent>} 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)));
};
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

/**
Expand All @@ -34,7 +35,13 @@ const IndexEntry = ({ api, name, index, description }) => {
</Badge>
</span>

{description && <span className={styles.summary}>{description}</span>}
{description && (
<span
className={styles.summary}
// Rendered from the document's own markdown at build time
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
</a>
);
};
Expand Down
23 changes: 23 additions & 0 deletions packages/react/src/html/utils/__tests__/config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>file system</code>, see fs.'
);
});
});

describe('buildLanguageDisplayNameMap', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/html/utils/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the performance cost of this?

}));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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: [
Expand Down
37 changes: 17 additions & 20 deletions packages/react/src/jsx-ast/utils/buildContent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ 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';

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,
Expand All @@ -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();

/**
*
*/
Expand All @@ -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];
};
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/jsx-ast/utils/render.mjs
Original file line number Diff line number Diff line change
@@ -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<import('mdast').PhrasingContent>} 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

break down the [0] access and object .expression access

const node = createTree('root', [createJSXElement(null, { children: nodes })]);

const [{ expression }] = remark().runSync(node);

10 changes: 3 additions & 7 deletions packages/react/src/jsx-ast/utils/types.mjs
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -70,8 +69,7 @@ export const extractTypeAnnotation = nodes => {
return undefined;
}

return remark().runSync(createTree('root', [nodes.shift()])).body[0]
.expression;
return renderAsJSX([nodes.shift()]);
};

/**
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading