From 714e6c8367e22eba25d42a4709e608ba6bdc9ecd Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 31 Jul 2026 15:57:42 +0200 Subject: [PATCH 1/2] fix: fix resolution of various type annotation edge cases Signed-off-by: Tim Perry --- .changeset/display-name-type-unions.md | 5 + .../utils/__tests__/resolveTypes.test.mjs | 155 ++++++++++++++++++ .../utils/__tests__/transformers.test.mjs | 11 ++ .../metadata/utils/resolveTypes.mjs | 111 +++++++++---- .../metadata/utils/transformers.mjs | 7 +- .../type-annotations/__tests__/hast.test.mjs | 32 ++++ .../__tests__/remark.test.mjs | 31 ++++ .../core/src/utils/type-annotations/hast.mjs | 8 +- .../src/utils/type-annotations/syntax.mjs | 53 +++++- 9 files changed, 372 insertions(+), 41 deletions(-) create mode 100644 .changeset/display-name-type-unions.md create mode 100644 packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs diff --git a/.changeset/display-name-type-unions.md b/.changeset/display-name-type-unions.md new file mode 100644 index 00000000..8f91f546 --- /dev/null +++ b/.changeset/display-name-type-unions.md @@ -0,0 +1,5 @@ +--- +'@node-core/doc-kit': patch +--- + +Resolve unions and arrays of display-name types (`{HTTP/2 Headers Object | vm.Module}`, `{HTTP/2 Headers Object[]}`), and stop capturing prose such as `U+007B ({), and U+007D (}).` as a type annotation. diff --git a/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs b/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs new file mode 100644 index 00000000..4c361e09 --- /dev/null +++ b/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock, beforeEach } from 'node:test'; + +const warnings = []; + +mock.module('../../../../logger/index.mjs', { + defaultExport: { + warn: message => warnings.push(message), + }, +}); + +const { resolveTypeAnnotations } = await import('../resolveTypes.mjs'); + +const HTTP2_TYPE_MAP = { + 'HTTP/2 Headers Object': 'http2.html#headers-object', +}; + +/** + * Resolves a single annotation and returns its node + * + * @param {string} value The type value + * @param {Record} typeMap The mapping of types to links + */ +const resolve = (value, typeMap = {}) => { + const node = { type: 'typeAnnotation', value }; + + resolveTypeAnnotations( + { type: 'root', children: [node] }, + typeMap, + 'test.md' + ); + + return node; +}; + +/** + * Resolves a single annotation and returns its links as `[text, href]` pairs, + * asserting that every range lines up with the text it links + * + * @param {string} value The type value + * @param {Record} typeMap The mapping of types to links + */ +const linksIn = (value, typeMap = {}) => { + const { data } = resolve(value, typeMap); + + for (const link of data.links) { + assert.equal(value.slice(link.start, link.end), link.text); + } + + return data.links.map(({ text, href }) => [text, href]); +}; + +describe('resolveTypeAnnotations', () => { + beforeEach(() => { + warnings.length = 0; + }); + + it('links a type that is a whole map key', () => { + assert.deepEqual(linksIn('HTTP/2 Headers Object', HTTP2_TYPE_MAP), [ + ['HTTP/2 Headers Object', 'http2.html#headers-object'], + ]); + assert.deepEqual(warnings, []); + }); + + it('links an array of a display name', () => { + assert.deepEqual(linksIn('HTTP/2 Headers Object[]', HTTP2_TYPE_MAP), [ + ['HTTP/2 Headers Object', 'http2.html#headers-object'], + ]); + assert.deepEqual(warnings, []); + }); + + it('resolves module-qualified names alongside display names', () => { + assert.deepEqual(linksIn('Module Namespace Object | vm.Module'), [ + [ + 'Module Namespace Object', + 'https://tc39.github.io/ecma262/#sec-module-namespace-exotic-objects', + ], + ['vm.Module', 'vm.html#class-vmmodule'], + ]); + assert.deepEqual(warnings, []); + }); + + it('keeps the resolvable parts of a partly unknown union', () => { + assert.deepEqual( + linksIn('HTTP/2 Headers Object | HTTP/2 Raw Headers', HTTP2_TYPE_MAP), + [['HTTP/2 Headers Object', 'http2.html#headers-object']] + ); + assert.deepEqual(warnings, []); + }); + + it('warns when no part of an unparsable type resolves', () => { + const node = resolve('HTTP/2 Raw Headers', HTTP2_TYPE_MAP); + + assert.equal(node.data.parseError, true); + assert.deepEqual(node.data.links, []); + assert.deepEqual(warnings, [ + 'Invalid type annotation: {HTTP/2 Raw Headers}', + ]); + }); + + it('marks which values are TypeScript, for the highlighter', () => { + const isTypeScript = (value, typeMap) => + resolve(value, typeMap).data.typescript; + + assert.equal(isTypeScript('Promise | null'), true); + assert.equal(isTypeScript('HTTP/2 Headers Object', HTTP2_TYPE_MAP), false); + assert.equal(isTypeScript('Module Namespace Object | vm.Module'), false); + assert.equal(isTypeScript('HTTP/2 Raw Headers', HTTP2_TYPE_MAP), false); + }); + + it('parses valid TypeScript rather than splitting it', () => { + assert.deepEqual( + linksIn('Promise | Buffer', { Buffer: 'buffer.html#buffer' }), + [ + [ + 'Promise', + 'https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise', + ], + [ + 'string', + 'https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type', + ], + ['Buffer', 'buffer.html#buffer'], + ] + ); + assert.deepEqual(warnings, []); + }); + + it('resolves every annotation in a nested tree', () => { + const STRING_URL = + 'https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type'; + + // Two per paragraph: a visitor that skips siblings still reaches the first + const nodes = ['string', 'HTTP/2 Headers Object', 'string', 'string'].map( + value => ({ type: 'typeAnnotation', value }) + ); + + resolveTypeAnnotations( + { + type: 'root', + children: [ + { type: 'paragraph', children: nodes.slice(0, 2) }, + { type: 'paragraph', children: nodes.slice(2) }, + ], + }, + HTTP2_TYPE_MAP, + 'test.md' + ); + + assert.deepEqual( + nodes.map(({ data }) => data.links.map(({ href }) => href)), + [[STRING_URL], ['http2.html#headers-object'], [STRING_URL], [STRING_URL]] + ); + }); +}); diff --git a/packages/core/src/generators/metadata/utils/__tests__/transformers.test.mjs b/packages/core/src/generators/metadata/utils/__tests__/transformers.test.mjs index b23b4ffd..b6c3355d 100644 --- a/packages/core/src/generators/metadata/utils/__tests__/transformers.test.mjs +++ b/packages/core/src/generators/metadata/utils/__tests__/transformers.test.mjs @@ -61,4 +61,15 @@ describe('resolveTypeReference', () => { it('returns an empty string for unknown plain names', () => { strictEqual(resolveTypeReference('NotAThing'), ''); }); + + it('applies the dotted heuristic to identifier paths only', () => { + strictEqual( + resolveTypeReference('os.constants.dlopen'), + 'os.html#osconstantsdlopen' + ); + + for (const name of ['Buffer or fs.Stats', 'Object.']) { + strictEqual(resolveTypeReference(name), ''); + } + }); }); diff --git a/packages/core/src/generators/metadata/utils/resolveTypes.mjs b/packages/core/src/generators/metadata/utils/resolveTypes.mjs index 00aba7de..4a56150e 100644 --- a/packages/core/src/generators/metadata/utils/resolveTypes.mjs +++ b/packages/core/src/generators/metadata/utils/resolveTypes.mjs @@ -112,52 +112,93 @@ export const parseTypeValues = values => { }; /** - * Resolves every `typeAnnotation` node in a file's tree + * Resolves a type value as a `|`-separated union of display names (e.g. + * `HTTP/2 Headers Object | HTTP/2 Raw Headers`). Map keys may contain spaces + * and slashes, so such values may not parse as TypeScript, but each part is + * still resolvable on its own. + * + * @param {string} value The type value + * @param {Record} typeMap The toolchain mapping of types to links + * @returns {Array<{ start: number, end: number, text: string, href: string }>} The resolved links */ -export const resolveTypeAnnotations = (tree, typeMap, path) => { - const pending = []; +const resolveDisplayNames = (value, typeMap) => { + const links = []; - visit(tree, 'typeAnnotation', node => { - node.data = { links: [] }; + for (const { 0: part, index } of value.matchAll(/[^|]+/g)) { + // An array of a type links to the type itself + const name = part.trim().replace(/(\[\])+$/, ''); + const href = name && resolveTypeReference(name, typeMap); - const url = lookupTypeName(node.value, typeMap); + if (href) { + const start = index + part.indexOf(name); - if (url) { - node.data.links.push({ - start: 0, - end: node.value.length, - text: node.value, - href: url, - }); - } else { - pending.push(node); + links.push({ start, end: start + name.length, text: name, href }); } - }); + } + + return links; +}; + +/** + * Resolves a type value's links: the whole value when it is a map key in its + * own right, then its parsed identifiers, then its display names + * + * @param {string} value The type value + * @param {Record} typeMap The toolchain mapping of types to links + * @param {{ error?: boolean, identifiers?: Array }} result The parse of `value` + * @returns {Array<{ start: number, end: number, text: string, href: string }>} The resolved links + */ +const resolveLinks = (value, typeMap, result) => { + const url = lookupTypeName(value, typeMap); + + if (url) { + return [{ start: 0, end: value.length, text: value, href: url }]; + } + + if (result.error) { + return resolveDisplayNames(value, typeMap); + } - parseTypeValues(pending.map(({ value }) => value)).forEach( - (result, index) => { - const node = pending[index]; + return ( + result.identifiers + .map(({ start, end, text, lookup }) => ({ + start, + end, + text, + href: resolveTypeReference(lookup, typeMap), + })) + .filter(({ href }) => href) + // The hast handlers slice the value by these ranges in order + .sort((a, b) => a.start - b.start) + ); +}; - if (result.error) { - node.data.parseError = true; +/** + * Resolves every `typeAnnotation` node in a file's tree + */ +export const resolveTypeAnnotations = (tree, typeMap, path) => { + const nodes = []; - logger.warn(`Invalid type annotation: {${node.value}}`, { - file: { path, position: node.position }, - }); + // A visitor must not return a value: `visit` reads a number as the index to + // continue from, which would skip siblings + visit(tree, 'typeAnnotation', node => { + nodes.push(node); + }); - return; - } + parseTypeValues(nodes.map(({ value }) => value)).forEach((result, index) => { + const node = nodes[index]; + const links = resolveLinks(node.value, typeMap, result); - for (const { start, end, text, lookup } of result.identifiers) { - const href = resolveTypeReference(lookup, typeMap); + // Unparseable display names must not be highlighted as the TypeScript + // they are not (which colours the `/` and `2` of `HTTP/2`) + node.data = { links, typescript: !result.error }; - if (href) { - node.data.links.push({ start, end, text, href }); - } - } + if (result.error && !links.length) { + node.data.parseError = true; - // The hast handlers slice the value by these ranges in order - node.data.links.sort((a, b) => a.start - b.start); + logger.warn(`Invalid type annotation: {${node.value}}`, { + file: { path, position: node.position }, + }); } - ); + }); }; diff --git a/packages/core/src/generators/metadata/utils/transformers.mjs b/packages/core/src/generators/metadata/utils/transformers.mjs index 211a2ec3..4061c178 100644 --- a/packages/core/src/generators/metadata/utils/transformers.mjs +++ b/packages/core/src/generators/metadata/utils/transformers.mjs @@ -48,6 +48,11 @@ export const lookupTypeName = (name, typeMap) => { return ''; }; +// A dotted identifier path, e.g. `vm.Module` or `os.constants.dlopen`. Names +// that merely contain a dot (prose, `Object.`) must not be +// turned into a module link. +const MODULE_QUALIFIED_NAME = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/; + /** * Resolves a type identifier to a documentation URL: map lookups first, then * the dotted-name heuristic for Node.js types like `vm.Module` (which links @@ -65,7 +70,7 @@ export const resolveTypeReference = (name, typeMap) => { } // Transform Node.js types like 'vm.Something'. - if (name.indexOf('.') >= 0) { + if (MODULE_QUALIFIED_NAME.test(name)) { const [mod, ...pieces] = name.split('.'); const isClass = pieces.at(-1).match(/^[A-Z][a-z]/); diff --git a/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs b/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs index 35c98e3b..0370064e 100644 --- a/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs +++ b/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs @@ -63,6 +63,7 @@ describe('typeAnnotationToHast', () => { describe('typeAnnotationToHighlightedHast', () => { it('produces one inline with shiki tokens and embedded links', () => { const node = makeNode('Promise', { + typescript: true, links: [{ start: 0, end: 7, text: 'Promise', href: 'mdn.io/promise' }], }); @@ -84,6 +85,7 @@ describe('typeAnnotationToHighlightedHast', () => { it('splits a link range that crosses token boundaries', () => { const node = makeNode('vm.Module', { + typescript: true, links: [ { start: 0, end: 9, text: 'vm.Module', href: 'vm.html#class-module' }, ], @@ -97,6 +99,36 @@ describe('typeAnnotationToHighlightedHast', () => { assert.match(html.replace(/<[^>]+>/g, ''), /^vm\.Module$/); }); + it('highlights a display name as plain text', () => { + const value = 'HTTP/2 Headers Object'; + const links = [ + { start: 0, end: value.length, text: value, href: 'http2.html#headers' }, + ]; + + const asTypeScript = toHtml( + typeAnnotationToHighlightedHast( + state, + makeNode(value, { + typescript: true, + links, + }) + ) + ); + + const asDisplayName = toHtml( + typeAnnotationToHighlightedHast(state, makeNode(value, { links })) + ); + + // TypeScript colours `/` as an operator and `2` as a numeric literal + assert.match(asTypeScript, /2<\/span>/); + assert.doesNotMatch(asDisplayName, /]+>/g, ''), value); + }); + it('falls back to plain code when nothing resolved', () => { const node = makeNode('Whatever', { links: [] }); diff --git a/packages/core/src/utils/type-annotations/__tests__/remark.test.mjs b/packages/core/src/utils/type-annotations/__tests__/remark.test.mjs index 3197166f..fcb9b48e 100644 --- a/packages/core/src/utils/type-annotations/__tests__/remark.test.mjs +++ b/packages/core/src/utils/type-annotations/__tests__/remark.test.mjs @@ -93,6 +93,37 @@ describe('remarkTypeAnnotations', () => { assert.deepEqual(valuesIn('An empty {} object.'), []); }); + it('does not match prose that cannot start a type', () => { + const markdown = 'and code points U+007B ({), and U+007D (}).'; + + assert.deepEqual(valuesIn(markdown), []); + assert.deepEqual( + selectAll('text', processor.parse(markdown)).map(node => node.value), + [markdown] + ); + }); + + it('matches an annotation opened directly after punctuation', () => { + assert.deepEqual(valuesIn('In batches ({Uint8Array\\[]} per iteration).'), [ + 'Uint8Array[]', + ]); + assert.deepEqual( + valuesIn('* `maxSessionMemory`{number} Sets the maximum'), + ['number'] + ); + }); + + it('matches types opening with an operator or an escape', () => { + assert.deepEqual(valuesIn('A {-1 | 0 | 1} literal.'), ['-1 | 0 | 1']); + assert.deepEqual(valuesIn('An {\\[]} array.'), ['[]']); + }); + + it('allows leading whitespace inside the braces', () => { + assert.deepEqual(valuesIn('Headers { string | URL | Object } here.'), [ + 'string | URL | Object', + ]); + }); + it('leaves an unbalanced brace as literal text', () => { const tree = processor.parse('An { unclosed brace.'); diff --git a/packages/core/src/utils/type-annotations/hast.mjs b/packages/core/src/utils/type-annotations/hast.mjs index 603201cc..05a0dc5c 100644 --- a/packages/core/src/utils/type-annotations/hast.mjs +++ b/packages/core/src/utils/type-annotations/hast.mjs @@ -61,8 +61,10 @@ export const typeAnnotationToHast = (state, node) => { /** * Syntax-highlighted mdast→hast handler for `typeAnnotation` nodes, used by * the web (JSX) pipeline. The whole type is highlighted as one inline - * TypeScript fragment, and each resolved identifier's exact character range - * is wrapped in an `` via Shiki decorations. + * fragment, and each resolved identifier's exact character range is wrapped + * in an `` via Shiki decorations. Values that are not TypeScript (display + * names such as `HTTP/2 Headers Object`) are highlighted as plain text, so + * their prose is not coloured as operators and numeric literals. * * Falls back to the minimal handler when the type failed to parse or nothing * resolved (no point paying for highlighting then). @@ -79,7 +81,7 @@ export const typeAnnotationToHighlightedHast = (state, node) => { } const root = highlighter.shiki.codeToHast(node.value, { - lang: 'typescript', + lang: node.data?.typescript ? 'typescript' : 'text', theme: highlighter.shiki.getLoadedThemes()[0], decorations: links.map(({ start, end, href }) => ({ start, diff --git a/packages/core/src/utils/type-annotations/syntax.mjs b/packages/core/src/utils/type-annotations/syntax.mjs index 86b8c1d3..f3864f3b 100644 --- a/packages/core/src/utils/type-annotations/syntax.mjs +++ b/packages/core/src/utils/type-annotations/syntax.mjs @@ -3,6 +3,16 @@ const LEFT_CURLY_BRACE = '{'.charCodeAt(0); const RIGHT_CURLY_BRACE = '}'.charCodeAt(0); const DOLLAR_SIGN = '$'.charCodeAt(0); +const SPACE = ' '.charCodeAt(0); + +// The type starters that are punctuation: `_` and `$` (identifiers), the +// openers of parenthesized, tuple, object, generic and string-literal types, +// the leading `-` of a negative numeric literal, the leading `&`/`|` of an +// intersection or union, and `\` (a legacy escape, decoded further down the +// pipeline). Alphanumeric and non-ASCII starters are handled by `isTypeStart`. +const TYPE_STARTER_PUNCTUATION = new Set( + [...'_$([{<\'"`-&|\\'].map(character => character.charCodeAt(0)) +); /** * micromark preprocesses `\r`, `\n` and `\r\n` into virtual codes below -2 @@ -12,6 +22,37 @@ const DOLLAR_SIGN = '$'.charCodeAt(0); */ const isLineEnding = code => code !== null && code < -2; +/** + * A tab is preprocessed into -2, followed by -1 for each column it expands to. + * + * @param {import('micromark-util-types').Code} code + */ +const isSpace = code => code === SPACE || code === -1 || code === -2; + +/** + * @param {import('micromark-util-types').Code} code + */ +const isAsciiAlphanumeric = code => + (code >= 0x30 && code <= 0x39) || // 0-9 + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x61 && code <= 0x7a); // a-z + +/** + * Whether a type expression may begin with this character: an identifier or + * numeric literal (alphanumeric or non-ASCII) or one of the punctuation + * starters above. Prose that happens to sit between two braces (as in url.md's + * `U+007B ({), and U+007D (}).`) starts with punctuation that no type can, so + * this keeps such spans out. + * + * @param {import('micromark-util-types').Code} code + */ +const isTypeStart = code => + code !== null && + (isAsciiAlphanumeric(code) || + // Non-ASCII identifier characters + code >= 0x80 || + TYPE_STARTER_PUNCTUATION.has(code)); + /** * A `{` directly after `$` is prose about template literals, not a type * annotation. @@ -25,7 +66,8 @@ const previous = code => code !== DOLLAR_SIGN; * * 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. + * empty span, or one whose content cannot start a type, fails the construct, + * so the `{` falls back to literal text. * * @type {import('micromark-util-types').Tokenizer} */ @@ -93,13 +135,20 @@ function tokenizeTypeAnnotation(effects, ok, nok) { return between(code); } + if (!hasContent && !isSpace(code)) { + if (!isTypeStart(code)) { + return nok(code); + } + + hasContent = true; + } + if (code === LEFT_CURLY_BRACE) { depth++; } else if (code === RIGHT_CURLY_BRACE) { depth--; } - hasContent = true; effects.consume(code); return value; From 2f77a399020644a4f892d0209bdd83225a4a8a80 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 31 Jul 2026 19:07:10 +0200 Subject: [PATCH 2/2] Fix review comments This now correctly warns on || in annotations, and updates tests to avoid hardcoding colours, and avoid looking like we're trying to do XSS filtering which seems to have confused the bot checks. --- .../utils/__tests__/resolveTypes.test.mjs | 7 +++++ .../metadata/utils/resolveTypes.mjs | 2 +- .../type-annotations/__tests__/hast.test.mjs | 27 +++++++++---------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs b/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs index 4c361e09..bd25c4ac 100644 --- a/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs +++ b/packages/core/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs @@ -98,6 +98,13 @@ describe('resolveTypeAnnotations', () => { ]); }); + it('does not read `||` in default-value prose as a union', () => { + const node = resolve("req.url || '/'"); + + assert.deepEqual(node.data.links, []); + assert.deepEqual(warnings, ["Invalid type annotation: {req.url || '/'}"]); + }); + it('marks which values are TypeScript, for the highlighter', () => { const isTypeScript = (value, typeMap) => resolve(value, typeMap).data.typescript; diff --git a/packages/core/src/generators/metadata/utils/resolveTypes.mjs b/packages/core/src/generators/metadata/utils/resolveTypes.mjs index 4a56150e..fe850e98 100644 --- a/packages/core/src/generators/metadata/utils/resolveTypes.mjs +++ b/packages/core/src/generators/metadata/utils/resolveTypes.mjs @@ -124,7 +124,7 @@ export const parseTypeValues = values => { const resolveDisplayNames = (value, typeMap) => { const links = []; - for (const { 0: part, index } of value.matchAll(/[^|]+/g)) { + for (const { 0: part, index } of value.matchAll(/(?:[^|]|\|\|)+/g)) { // An array of a type links to the type itself const name = part.trim().replace(/(\[\])+$/, ''); const href = name && resolveTypeReference(name, typeMap); diff --git a/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs b/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs index 0370064e..14743a5c 100644 --- a/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs +++ b/packages/core/src/utils/type-annotations/__tests__/hast.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { toHtml } from 'hast-util-to-html'; +import { toString } from 'hast-util-to-string'; import { typeAnnotationToHast, @@ -105,28 +106,24 @@ describe('typeAnnotationToHighlightedHast', () => { { start: 0, end: value.length, text: value, href: 'http2.html#headers' }, ]; - const asTypeScript = toHtml( - typeAnnotationToHighlightedHast( - state, - makeNode(value, { - typescript: true, - links, - }) - ) + const asTypeScript = typeAnnotationToHighlightedHast( + state, + makeNode(value, { typescript: true, links }) ); - const asDisplayName = toHtml( - typeAnnotationToHighlightedHast(state, makeNode(value, { links })) + const asDisplayName = typeAnnotationToHighlightedHast( + state, + makeNode(value, { links }) ); // TypeScript colours `/` as an operator and `2` as a numeric literal - assert.match(asTypeScript, /2<\/span>/); - assert.doesNotMatch(asDisplayName, /]+>/g, ''), value); + assert.match(toHtml(asDisplayName), /^]*class="[^"]*shiki[^"]*/); + assert.match(toHtml(asDisplayName), / {