` via Shiki decorations.
+ * Syntax-highlighted mdast→hast handler for `typeAnnotation` nodes.
*
- * Falls back to the minimal handler when the type failed to parse or nothing
- * resolved (no point paying for highlighting then).
+ * Falls back to the minimal handler when parsing failed or nothing resolved.
*
* @param {import('mdast-util-to-hast').State} state
* @param {import('mdast').Node} node
@@ -80,34 +118,32 @@ export const typeAnnotationToHighlightedHast = (state, node) => {
const root = highlighter.shiki.codeToHast(node.value, {
lang: 'typescript',
- theme: highlighter.shiki.getLoadedThemes()[0],
+ theme: TYPE_THEME,
decorations: links.map(({ start, end, href }) => ({
start,
end,
tagName: 'a',
- properties: { href, class: 'type-link' },
+ properties: {
+ href,
+ class: 'type-link',
+ },
alwaysWrap: true,
})),
});
- // codeToHast wraps the highlighted line in ; re-shape that into
- // a single inline element ("only the outermost type opens/closes
- // the code fragment") carrying Shiki's theme styling. The 's tabindex
- // is dropped — an inline fragment is no scroll container.
- const [preElement] = root.children;
- const [codeElement] = preElement.children;
-
- const result = {
- type: 'element',
- tagName: 'code',
- properties: {
- class: `${preElement.properties.class} type`,
- style: preElement.properties.style,
- },
- children: codeElement.children,
- };
-
- state.patch(node, result);
-
- return state.applyData(node, result);
+ const preElement = root.children[0];
+ const codeElement = preElement.children[0];
+
+ return finalize(
+ state,
+ node,
+ element(
+ 'code',
+ {
+ class: `${preElement.properties.class} type`,
+ style: preElement.properties.style,
+ },
+ codeElement.children
+ )
+ );
};
diff --git a/src/utils/type-annotations/mdast.mjs b/src/utils/type-annotations/mdast.mjs
index 203a6b21..3cdcb4a2 100644
--- a/src/utils/type-annotations/mdast.mjs
+++ b/src/utils/type-annotations/mdast.mjs
@@ -5,6 +5,18 @@
// and the mandatory `\|` inside GFM table cells must be decoded here.
const CHARACTER_ESCAPE = /\\([!-/:-@[-`{-~])/g;
+// Interior line endings are normalized to a single space.
+const WHITESPACE = /\s+/g;
+
+/**
+ * Normalizes the parsed type text into its canonical form.
+ *
+ * @param {string} value
+ * @returns {string}
+ */
+const normalizeTypeAnnotation = value =>
+ value.replace(WHITESPACE, ' ').trim().replace(CHARACTER_ESCAPE, '$1');
+
/**
* Creates the mdast-util-from-markdown extension that compiles
* `typeAnnotation` tokens into `{ type: 'typeAnnotation', value }` nodes.
@@ -21,9 +33,16 @@ export const typeAnnotationFromMarkdown = () => ({
* @param {import('micromark-util-types').Token} token
*/
typeAnnotation(token) {
- this.enter({ type: 'typeAnnotation', value: '' }, token);
+ this.enter(
+ {
+ type: 'typeAnnotation',
+ value: '',
+ },
+ token
+ );
},
},
+
exit: {
/**
* @this {import('mdast-util-from-markdown').CompileContext}
@@ -33,9 +52,9 @@ export const typeAnnotationFromMarkdown = () => ({
const node = this.stack.at(-1);
const chunk = this.sliceSerialize(token);
- // Chunks are split by interior line endings; re-join with a space
- node.value = node.value ? `${node.value} ${chunk}` : chunk;
+ node.value += node.value ? ` ${chunk}` : chunk;
},
+
/**
* @this {import('mdast-util-from-markdown').CompileContext}
* @param {import('micromark-util-types').Token} token
@@ -43,10 +62,7 @@ export const typeAnnotationFromMarkdown = () => ({
typeAnnotation(token) {
const node = this.stack.at(-1);
- node.value = node.value
- .replace(/\s+/g, ' ')
- .trim()
- .replace(CHARACTER_ESCAPE, '$1');
+ node.value = normalizeTypeAnnotation(node.value);
this.exit(token);
},
@@ -63,7 +79,8 @@ export const typeAnnotationToMarkdown = () => ({
handlers: {
/**
* @param {{ value: string }} node
+ * @returns {string}
*/
- typeAnnotation: node => `{${node.value}}`,
+ typeAnnotation: ({ value }) => `{${value}}`,
},
});
diff --git a/src/utils/type-annotations/syntax.mjs b/src/utils/type-annotations/syntax.mjs
index 86b8c1d3..2a30bc41 100644
--- a/src/utils/type-annotations/syntax.mjs
+++ b/src/utils/type-annotations/syntax.mjs
@@ -9,35 +9,34 @@ const DOLLAR_SIGN = '$'.charCodeAt(0);
* (and represents EOF as `null`).
*
* @param {import('micromark-util-types').Code} code
+ * @returns {boolean}
*/
const isLineEnding = code => code !== null && code < -2;
/**
- * A `{` directly after `$` is prose about template literals, not a type
- * annotation.
+ * A `{` immediately following `$` belongs to a template literal (`${...}`),
+ * not a type annotation.
*
* @param {import('micromark-util-types').Code} code
+ * @returns {boolean}
*/
-const previous = code => code !== DOLLAR_SIGN;
+const previousNotDollar = code => code !== DOLLAR_SIGN;
/**
- * Tokenizes a balanced `{...}` span as a single `typeAnnotation` token.
+ * Tokenizes a balanced `{...}` span as a `typeAnnotation`.
*
* The outer braces become `typeAnnotationMarker` tokens; everything between
- * them becomes `typeAnnotationValue` chunks (one per line). An unbalanced or
- * empty span fails the construct, so the `{` falls back to literal text.
+ * them becomes one or more `typeAnnotationValue` tokens (split on line
+ * endings). Empty (`{}`) and unbalanced spans fail the construct so the `{`
+ * falls back to plain text.
*
* @type {import('micromark-util-types').Tokenizer}
*/
function tokenizeTypeAnnotation(effects, ok, nok) {
- // Nesting depth of inner `{`/`}` pairs (the wrapping pair is not counted)
let depth = 0;
- // `{}` (no content at all) must not become an annotation
let hasContent = false;
/**
- * At the opening `{`.
- *
* @type {import('micromark-util-types').State}
*/
const start = code => {
@@ -46,16 +45,13 @@ function tokenizeTypeAnnotation(effects, ok, nok) {
effects.consume(code);
effects.exit('typeAnnotationMarker');
- return between;
+ return beforeValue;
};
/**
- * At a position where a value chunk may start: right after the opening
- * brace or after an interior line ending.
- *
* @type {import('micromark-util-types').State}
*/
- const between = code => {
+ const beforeValue = code => {
if (code === null) {
return nok(code);
}
@@ -69,7 +65,7 @@ function tokenizeTypeAnnotation(effects, ok, nok) {
effects.consume(code);
effects.exit('lineEnding');
- return between;
+ return beforeValue;
}
effects.enter('typeAnnotationValue');
@@ -78,8 +74,6 @@ function tokenizeTypeAnnotation(effects, ok, nok) {
};
/**
- * Inside a value chunk.
- *
* @type {import('micromark-util-types').State}
*/
const value = code => {
@@ -89,14 +83,16 @@ function tokenizeTypeAnnotation(effects, ok, nok) {
(code === RIGHT_CURLY_BRACE && depth === 0)
) {
effects.exit('typeAnnotationValue');
-
- return between(code);
+ return beforeValue(code);
}
- if (code === LEFT_CURLY_BRACE) {
- depth++;
- } else if (code === RIGHT_CURLY_BRACE) {
- depth--;
+ switch (code) {
+ case LEFT_CURLY_BRACE:
+ depth++;
+ break;
+ case RIGHT_CURLY_BRACE:
+ depth--;
+ break;
}
hasContent = true;
@@ -106,8 +102,6 @@ function tokenizeTypeAnnotation(effects, ok, nok) {
};
/**
- * At the closing `}`.
- *
* @type {import('micromark-util-types').State}
*/
const finish = code => {
@@ -133,7 +127,7 @@ export const typeAnnotationSyntax = () => ({
[LEFT_CURLY_BRACE]: {
name: 'typeAnnotation',
tokenize: tokenizeTypeAnnotation,
- previous,
+ previous: previousNotDollar,
},
},
});
diff --git a/src/utils/unist.mjs b/src/utils/unist.mjs
index 51f222d6..a244038b 100644
--- a/src/utils/unist.mjs
+++ b/src/utils/unist.mjs
@@ -3,73 +3,88 @@
import { pointEnd, pointStart } from 'unist-util-position';
/**
- * Escapes HTML entities ("<" and ">") in a string
- * @param {string} string The string
+ * Escapes HTML entities in a string.
+ *
+ * @param {string} value
+ * @returns {string}
*/
-const escapeHTMLEntities = string =>
- string.replace(//g, '>');
+const escapeHTMLEntities = value =>
+ value.replace(/[<>]/g, character => (character === '<' ? '<' : '>'));
/**
- * Extracts text content from a node recursively
+ * Converts a node tree back into its source-like string representation.
*
- * @param {import('unist').Node} node The Node to be transformed into a string
- * @param {boolean} [escape] Escape HTML entities ("<", ">")?
- * @returns {string} The transformed Node as a string
+ * @param {import('unist').Node} node
+ * @param {boolean} [escape]
+ * @returns {string}
*/
export const transformNodeToString = (node, escape) => {
switch (node.type) {
case 'inlineCode':
return `\`${escape ? escapeHTMLEntities(node.value) : node.value}\``;
+
case 'typeAnnotation':
return `{${escape ? escapeHTMLEntities(node.value) : node.value}}`;
+
case 'strong':
return `**${transformNodesToString(node.children, escape)}**`;
+
case 'emphasis':
return `_${transformNodesToString(node.children, escape)}_`;
- default: {
+
+ default:
if (node.children) {
return transformNodesToString(node.children, escape);
}
- const string = node.value?.replace(/\n/g, ' ') || '';
+ if (!node.value) {
+ return '';
+ }
+
+ // eslint-disable-next-line no-case-declarations
+ const value = node.value.replace(/\n/g, ' ');
- // Replace line breaks (\n) with spaces to keep text in a single line
- return escape ? escapeHTMLEntities(string) : string;
- }
+ return escape ? escapeHTMLEntities(value) : value;
}
};
/**
- * This utility allows us to join children Nodes into one
- * and transfor them back to what their source would look like
+ * Joins child nodes into their source-like string representation.
*
- * @param {Array} nodes Nodes to parsed and joined
- * @param {boolean} [escape] Escape HTML entities ("<", ">")?
- * @returns {string} The parsed and joined nodes as a string
+ * @param {Array} nodes
+ * @param {boolean} [escape]
+ * @returns {string}
*/
export const transformNodesToString = (nodes, escape) => {
- const mappedChildren = nodes.map(node => transformNodeToString(node, escape));
+ let result = '';
- return mappedChildren.join('');
+ for (const node of nodes) {
+ result += transformNodeToString(node, escape);
+ }
+
+ return result;
};
/**
- * This method is an utility that allows us to conditionally invoke/call a callback
- * based on test conditions related to a Node's position relative to another one
- * being before or not the other Node
+ * Calls a callback when nodeA appears after nodeB.
*
- * NOTE: Not yet used, but probably going to be used by the JSON generator.
- *
- * @param {import('unist').Node | undefined} nodeA The Node to be used as a position reference to check against
- * the other Node. If the other Node is before this one, the callback will be called.
- * @param {import('unist').Node | undefined} nodeB The Node to be checked against the position of the first Node
- * @param {(nodeA: import('unist').Node, nodeB: import('unist').Node) => void} callback The callback to be called
+ * @param {import('unist').Node | undefined} nodeA
+ * @param {import('unist').Node | undefined} nodeB
+ * @param {(nodeA: import('unist').Node, nodeB: import('unist').Node) => void} callback
*/
export const callIfBefore = (nodeA, nodeB, callback) => {
+ if (!nodeA || !nodeB) {
+ return;
+ }
+
const positionA = pointEnd(nodeA);
const positionB = pointStart(nodeB);
- if (positionA && positionB && positionA.line > positionB.line) {
+ if (!positionA || !positionB) {
+ return;
+ }
+
+ if (positionA.line > positionB.line) {
callback(nodeA, nodeB);
}
};
From b10aa787869dab25d0f34f29bdf19452b4910ced Mon Sep 17 00:00:00 2001
From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com>
Date: Mon, 20 Jul 2026 23:28:39 +0200
Subject: [PATCH 2/2] fix: cursor suggestion
---
package-lock.json | 71 +-----------------
.../plugins/__tests__/transformer.test.mjs | 72 +++++++++++++++++++
.../jsx-ast/utils/plugins/transformer.mjs | 59 ++++++++-------
3 files changed, 106 insertions(+), 96 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index eff2cd84..eb6ed2b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@node-core/doc-kit",
- "version": "1.4.2",
+ "version": "1.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@node-core/doc-kit",
- "version": "1.4.2",
+ "version": "1.4.3",
"dependencies": {
"@actions/core": "^3.0.0",
"@heroicons/react": "^2.2.0",
@@ -48,10 +48,8 @@
"tinyglobby": "^0.2.17",
"unified": "^11.0.5",
"unist-builder": "^4.0.0",
- "unist-util-find-after": "^5.0.0",
"unist-util-position": "^5.0.0",
"unist-util-remove": "^4.0.0",
- "unist-util-select": "^5.1.0",
"unist-util-visit": "^5.1.0",
"yaml": "^2.9.0"
},
@@ -4126,12 +4124,6 @@
"node": ">=4"
}
},
- "node_modules/boolbase": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
- "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
- "license": "ISC"
- },
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
@@ -4555,22 +4547,6 @@
"node": ">= 8"
}
},
- "node_modules/css-selector-parser": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz",
- "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/mdevils"
- },
- {
- "type": "patreon",
- "url": "https://patreon.com/mdevils"
- }
- ],
- "license": "MIT"
- },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -8530,18 +8506,6 @@
}
}
},
- "node_modules/nth-check": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
- "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/fb55/nth-check?sponsor=1"
- }
- },
"node_modules/object-deep-merge": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz",
@@ -10624,20 +10588,6 @@
"url": "https://opencollective.com/unified"
}
},
- "node_modules/unist-util-find-after": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
- "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "unist-util-is": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
@@ -10692,23 +10642,6 @@
"url": "https://opencollective.com/unified"
}
},
- "node_modules/unist-util-select": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/unist-util-select/-/unist-util-select-5.1.0.tgz",
- "integrity": "sha512-4A5mfokSHG/rNQ4g7gSbdEs+H586xyd24sdJqF1IWamqrLHvYb+DH48fzxowyOhOfK7YSqX+XlCojAyuuyyT2A==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "css-selector-parser": "^3.0.0",
- "devlop": "^1.1.0",
- "nth-check": "^2.0.0",
- "zwitch": "^2.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
diff --git a/src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs b/src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs
index 4b7df072..7854b1e5 100644
--- a/src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs
+++ b/src/generators/jsx-ast/utils/plugins/__tests__/transformer.test.mjs
@@ -39,4 +39,76 @@ describe('jsx-ast transformer', () => {
assert.equal(tree.children.includes(footnotes), false);
assert.equal(layout.children.at(-1), footnotes);
});
+
+ it('wraps tables in an overflow container and adds responsive labels', () => {
+ const table = {
+ type: 'element',
+ tagName: 'table',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'thead',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'tr',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'th',
+ properties: {},
+ children: [{ type: 'text', value: 'Name' }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'element',
+ tagName: 'tbody',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'tr',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'td',
+ properties: {},
+ children: [{ type: 'text', value: 'Alice' }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const tree = {
+ type: 'root',
+ children: [table],
+ };
+
+ transformer()(tree);
+
+ const wrapper = tree.children[0];
+
+ assert.equal(wrapper.tagName, 'div');
+ assert.deepEqual(wrapper.properties.className, ['overflow-container']);
+
+ const transformedTable = wrapper.children[0];
+
+ assert.equal(transformedTable.tagName, 'table');
+ assert.equal(
+ transformedTable.children[1].children[0].children[0].properties[
+ 'data-label'
+ ],
+ 'Name'
+ );
+ });
});
diff --git a/src/generators/jsx-ast/utils/plugins/transformer.mjs b/src/generators/jsx-ast/utils/plugins/transformer.mjs
index 9f706c7c..0d1a89b2 100644
--- a/src/generators/jsx-ast/utils/plugins/transformer.mjs
+++ b/src/generators/jsx-ast/utils/plugins/transformer.mjs
@@ -23,39 +23,44 @@ const isLayout = node =>
node?.name === 'Layout' && Array.isArray(node.children);
/**
- * Adds responsive labels to table cells.
+ * Adds responsive labels and wraps tables.
*
* @param {import('hast').Element} table
+ * @param {import('unist').Parent | undefined} parent
+ * @param {number | undefined} index
*/
-const transformTable = table => {
+const transformTable = (table, parent, index) => {
const thead = table.children.find(node => node.tagName === 'thead');
- if (!thead) {
- return;
- }
-
- const headerRow = thead.children?.[0];
-
- if (!headerRow?.children) {
- return;
- }
-
- const headers = headerRow.children.map(toString);
-
- const tbody = table.children.find(node => node.tagName === 'tbody');
-
- if (!tbody?.children) {
- return;
- }
-
- for (const row of tbody.children) {
- for (const [index, cell] of (row.children ?? []).entries()) {
- if (cell.tagName === 'td') {
- cell.properties ??= {};
- cell.properties['data-label'] = headers[index];
+ if (thead?.children?.[0]?.children) {
+ const headers = thead.children[0].children.map(toString);
+ const tbody = table.children.find(node => node.tagName === 'tbody');
+
+ if (tbody?.children) {
+ for (const row of tbody.children) {
+ for (const [cellIndex, cell] of (row.children ?? []).entries()) {
+ if (cell.tagName === 'td') {
+ cell.properties ??= {};
+ cell.properties['data-label'] = headers[cellIndex];
+ }
+ }
}
}
}
+
+ /**
+ * Wrap in a .
+ *
+ * Site styles rely on this wrapper for horizontal scrolling.
+ */
+ if (parent && index !== undefined) {
+ parent.children[index] = {
+ type: 'element',
+ tagName: 'div',
+ properties: { className: ['overflow-container'] },
+ children: [table],
+ };
+ }
};
/**
@@ -72,7 +77,7 @@ const transformer = tree => {
* We intentionally visit every node because MDX JSX nodes
* are not HAST "element" nodes.
*/
- visit(tree, node => {
+ visit(tree, (node, index, parent) => {
/**
* Find Layout regardless of node type.
*/
@@ -100,7 +105,7 @@ const transformer = tree => {
* Tables need special handling.
*/
if (node.tagName === 'table') {
- transformTable(node);
+ transformTable(node, parent, index);
}
});