diff --git a/docs/specification.md b/docs/specification.md index 9f7c9628..7ff09cb3 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -1,6 +1,6 @@ # Specification -**Version**: 1.0.0 +**Version**: 1.0.1 **Authored By**: Aviv Keller () @@ -98,15 +98,9 @@ in the relevant section. - [7.6. Placement](#76-placement) - [8. Type Annotations](#8-type-annotations) - [8.1. Syntax](#81-syntax) - - [8.2. Primitive Types](#82-primitive-types) - - [8.3. Global Types](#83-global-types) - - [8.4. Custom Types](#84-custom-types) - - [8.5. Compound Types](#85-compound-types) - - [8.5.1. Union Types](#851-union-types) - - [8.5.2. Array Types](#852-array-types) - - [8.5.3. Generic Types](#853-generic-types) - - [8.6. Resolution Order](#86-resolution-order) - - [8.7. Unresolved Types](#87-unresolved-types) + - [8.2. Parsing](#82-parsing) + - [8.3. Resolution](#83-resolution) + - [8.4. Rendering](#84-rendering) - [9. Typed Parameter Lists](#9-typed-parameter-lists) - [9.1. Identification](#91-identification) - [9.2. Item Structure](#92-item-structure) @@ -178,9 +172,10 @@ providing version-history and classification metadata for the enclosing the compact `key=value` pattern, providing [document][term-document]-level or [section][term-section]-level metadata. See [§6.2][§6.2]. -**Type annotation.** A reference to a type enclosed in `{curly braces}` within -a [typed list][term-typed-list] item, or in prose, that is resolved to a -hyperlink. See [§8][§8]. +**Type annotation.** A [TypeScript][typescript] type expression enclosed in +balanced `{curly braces}` within a [typed list][term-typed-list] item, or in +prose. It is rendered as a single code fragment whose type names are resolved +to hyperlinks. See [§8][§8]. --- @@ -844,91 +839,61 @@ A [stability indicator][term-stability] on the depth-1 heading applies to the ### 8.1. Syntax -[Type annotations][term-type-annotation] are enclosed in curly braces, such as -`{Type}`. They appear in two contexts: - -- **In [typed parameter lists][§9]:** as part of item structure. -- **In prose text:** as inline references. - -Both forms are auto-linked to documentation for the referenced type (see -[§8.6][§8.6]). - -### 8.2. Primitive Types - -Primitive types use lowercase names: `string`, `number`, `boolean`, `bigint`, -`symbol`, `null`, `undefined`, `integer`. - -These MUST be resolved to documentation for JavaScript primitive data types. - -### 8.3. Global Types - -JavaScript built-in globals use PascalCase: `Array`, `Object`, `Function`, -`Promise`, `Error`, `RegExp`, `Map`, `Set`, `Date`, `Uint8Array`, `Buffer`, -and others. - -These MUST be resolved to documentation for JavaScript global objects. - -### 8.4. Custom Types - -Types not in the primitive or global sets are resolved via a configurable type -map provided to the toolchain. The type map associates type names with -documentation URLs. An example type-map is available for viewing [here][nodejs-typemap]. - -### 8.5. Compound Types - -#### 8.5.1. Union Types - -Multiple types are separated by `|` (pipe): - -``` -{string|Buffer|URL} -``` - -Spaces around the pipe are OPTIONAL. Both `{string|Buffer}` and -`{string | Buffer}` MUST be accepted. - -#### 8.5.2. Array Types - -The `[]` suffix denotes an array of the base type: - -``` -{string[]} -{Buffer[]} -``` - -#### 8.5.3. Generic Types - -Generic types use angle brackets: - -``` -{Promise} -{Map} -``` - -The outer type and each inner type parameter are resolved independently. Inner -types MAY include [unions][§8.5.1]: - -``` -{Promise} -``` - -### 8.6. Resolution Order - -Types are resolved in the following order. The first tier that produces a -match terminates resolution. - -1. JavaScript primitives (see [§8.2][§8.2]). -2. JavaScript built-in globals (see [§8.3][§8.3]). -3. Implementation-defined external type mappings. -4. Custom type map entries (see [§8.4][§8.4]). -5. Dotted-name heuristic: types containing `.` are split on the first `.` to +A [type annotation][term-type-annotation] is any balanced `{curly brace}` +span in text. The content between the braces is a [TypeScript][typescript] +type expression — anything valid on the right-hand side of a TypeScript +`type X = ...` alias. + +Annotations appear both in [typed parameter lists][§9] (as part of item +structure) and in prose text (as inline references); both forms are parsed +and rendered identically. + +### 8.2. Parsing + +Implementations MUST parse each annotation whose value is not a whole-value +[map match][§8.3] under the TypeScript type grammar. Implementations SHOULD +collect every annotation in a [document][term-document] and parse them in a +single batch. + +An annotation that fails to parse MUST produce a warning identifying the +source location, and MUST be rendered as plain, unlinked code. + +### 8.3. Resolution + +Each annotation resolves to a set of hyperlinks: + +1. **Whole-value lookup.** If the entire annotation value matches a type-map + entry verbatim, the whole annotation becomes a single link. This permits + display-name keys that are not valid TypeScript identifiers (e.g. + `zlib options`). +2. **Identifier resolution.** Otherwise, every type name in the parsed + expression resolves individually: keyword types (`string`, `number`, + `boolean`, ...), type-reference names, qualified names (`vm.Module`), + `typeof` targets, and `import('mod').X` qualifiers. Structural names — + function parameter names, object property keys — are never resolved. + +Each name resolves through the following tiers; the first match wins: + +1. The configurable type map provided to the toolchain, which associates + names with documentation URLs. An example type map is available for + viewing [here][nodejs-typemap]. +2. JavaScript primitives and built-in (TC39) globals, matched + case-insensitively: `string`, `number`, `boolean`, `bigint`, `symbol`, + `null`, `undefined`, `integer`, `Array`, `Object`, `Function`, `Promise`, + and others. +3. Web platform APIs documented on MDN, matched case-insensitively. +4. Dotted-name heuristic: names containing `.` are split on the first `.` to derive a module name and member identifier, producing a cross-document link of the form `.md#`. -### 8.7. Unresolved Types +A name that no tier resolves stays plain — this is not an error. + +### 8.4. Rendering -If a type cannot be resolved through any tier, it WILL be rendered as -unlinked formatted text. +The whole annotation renders as ONE inline code fragment. Each resolved name +becomes a hyperlink spanning exactly its character range within the fragment; +unresolved names remain plain text inside it. Implementations MAY +syntax-highlight the fragment as TypeScript. --- @@ -945,10 +910,9 @@ its first item matches any of these conditions: 1. It begins with text matching one of the [special prefixes][§9.3] (`Returns:`, `Extends:`, `Type:`). -2. It begins with a [type-reference][§8] link (a link whose label is a type - annotation such as `{Type}`). +2. It begins with a [type annotation][§8]. 3. It begins with an inline code span (the parameter name) followed by a - space and then a [type-reference][§8] link. + space and then a [type annotation][§8]. ### 9.2. Item Structure @@ -964,7 +928,8 @@ The components, in order, are: 2. **Parameter name** - A backtick code span. REQUIRED for parameter items; absent for [`Returns:`][§9.3.1], [`Type:`][§9.3.3], and [`Extends:`][§9.3.2] items. -3. **[Type annotation][§8]** - A `{Type}` reference. OPTIONAL. +3. **[Type annotation][§8]** - A single `{...}` TypeScript type expression + (a union belongs inside it, e.g. `{string|Buffer}`). OPTIONAL. 4. **Description** - Free-form prose. OPTIONAL. 5. **[Default value][§9.5]** - The exact pattern `**Default:** \`value\``. OPTIONAL. @@ -1106,9 +1071,9 @@ documentation by conforming implementations. ### 11.5. Type Auto-Linking -[Type annotations][term-type-annotation] in prose text matching the -`{Type}` pattern ([§8.1][§8.1]) are automatically converted to hyperlinks -following the [resolution order][§8.6]. +[Type annotations][term-type-annotation] in prose text ([§8.1][§8.1]) are +parsed ([§8.2][§8.2]), resolved ([§8.3][§8.3]), and rendered ([§8.4][§8.4]) +exactly like annotations in [typed parameter lists][§9]. ### 11.6. Link Reference Definitions @@ -1132,6 +1097,7 @@ of the [document][term-document], after all content [rfc-8174]: https://www.rfc-editor.org/rfc/rfc8174 [yaml-1.2]: https://yaml.org/spec/1.2.2/ [semver]: https://semver.org/ +[typescript]: https://www.typescriptlang.org/ [nodejs-typemap]: https://github.com/nodejs/node/blob/main/doc/type-map.json @@ -1187,11 +1153,9 @@ of the [document][term-document], after all content [§7.3]: #73-sub-levels [§8]: #8-type-annotations [§8.1]: #81-syntax -[§8.2]: #82-primitive-types -[§8.3]: #83-global-types -[§8.4]: #84-custom-types -[§8.5.1]: #851-union-types -[§8.6]: #86-resolution-order +[§8.2]: #82-parsing +[§8.3]: #83-resolution +[§8.4]: #84-rendering [§9]: #9-typed-parameter-lists [§9.3]: #93-special-prefixes [§9.3.1]: #931-returns diff --git a/package-lock.json b/package-lock.json index ec3d6bb1..a7382356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "semver": "^7.8.5", "shiki": "^4.3.0", "tinyglobby": "^0.2.17", + "typescript": "^6.0.3", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-find-after": "^5.0.0", @@ -436,7 +437,6 @@ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -448,7 +448,6 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1324,6 +1323,7 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1554,6 +1554,7 @@ "resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz", "integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==", "license": "MIT", + "peer": true, "dependencies": { "@noble/hashes": "^1.1.5" } @@ -1571,7 +1572,8 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz", "integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==", - "license": "AGPL-3.0" + "license": "AGPL-3.0", + "peer": true }, "node_modules/@orama/stopwords": { "version": "3.1.18", @@ -2910,7 +2912,6 @@ "integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3068,7 +3069,6 @@ "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", @@ -3533,7 +3533,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4230,7 +4229,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -4734,7 +4734,6 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -8532,7 +8531,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8609,7 +8607,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.4.tgz", "integrity": "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8722,7 +8719,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9352,7 +9348,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.8.5", @@ -10159,7 +10156,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10379,7 +10375,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, diff --git a/package.json b/package.json index cefc6454..368b4642 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "semver": "^7.8.5", "shiki": "^4.3.0", "tinyglobby": "^0.2.17", + "typescript": "^6.0.3", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-find-after": "^5.0.0", diff --git a/src/generators/jsx-ast/utils/__tests__/types.test.mjs b/src/generators/jsx-ast/utils/__tests__/types.test.mjs index b50c9810..0c4a7c3e 100644 --- a/src/generators/jsx-ast/utils/__tests__/types.test.mjs +++ b/src/generators/jsx-ast/utils/__tests__/types.test.mjs @@ -12,89 +12,8 @@ mock.module('../../../../utils/remark.mjs', { }, }); -const { - classifyTypeNode, - extractPropertyName, - extractTypeAnnotations, - parseListIntoProperties, -} = await import('../types.mjs'); - -describe('classifyTypeNode', () => { - it('returns 2 for union separator text node', () => { - const node = { type: 'text', value: ' | ' }; - assert.strictEqual(classifyTypeNode(node), 2); - }); - - it('returns 1 for type reference link with angle bracket inline code', () => { - const node = { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }; - assert.strictEqual(classifyTypeNode(node), 1); - }); - - it('returns 1 for type reference with complex angle bracket content', () => { - const node = { - type: 'link', - children: [{ type: 'inlineCode', value: '>' }], - }; - assert.strictEqual(classifyTypeNode(node), 1); - }); - - it('returns 0 for regular text node', () => { - const node = { type: 'text', value: 'regular text' }; - assert.strictEqual(classifyTypeNode(node), 0); - }); - - it('returns 0 for link without inline code child', () => { - const node = { - type: 'link', - children: [{ type: 'text', value: 'regular link' }], - }; - assert.strictEqual(classifyTypeNode(node), 0); - }); - - it('returns 0 for link with inline code not starting with angle bracket', () => { - const node = { - type: 'link', - children: [{ type: 'inlineCode', value: 'regularCode' }], - }; - assert.strictEqual(classifyTypeNode(node), 0); - }); - - it('returns 0 for node without children', () => { - const node = { type: 'link' }; - assert.strictEqual(classifyTypeNode(node), 0); - }); - - it('returns 0 for node with empty children array', () => { - const node = { type: 'link', children: [] }; - assert.strictEqual(classifyTypeNode(node), 0); - }); - - it('returns 0 for different text values', () => { - const node1 = { type: 'text', value: '|' }; - const node2 = { type: 'text', value: ' |' }; - const node3 = { type: 'text', value: '| ' }; - - assert.strictEqual(classifyTypeNode(node1), 0); - assert.strictEqual(classifyTypeNode(node2), 0); - assert.strictEqual(classifyTypeNode(node3), 0); - }); - - it('returns 0 for non-link, non-text nodes', () => { - const nodes = [ - { type: 'paragraph' }, - { type: 'emphasis' }, - { type: 'strong' }, - { type: 'inlineCode' }, - ]; - - nodes.forEach(node => { - assert.strictEqual(classifyTypeNode(node), 0); - }); - }); -}); +const { extractPropertyName, extractTypeAnnotation, parseListIntoProperties } = + await import('../types.mjs'); describe('extractPropertyName', () => { it('extracts name from inline code and removes it from nodes', () => { @@ -177,114 +96,67 @@ describe('extractPropertyName', () => { }); }); -describe('extractTypeAnnotations', () => { - it('extracts single type reference and returns expression', () => { +describe('extractTypeAnnotation', () => { + it('extracts a leading type annotation and returns its expression', () => { const nodes = [ - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, + { type: 'typeAnnotation', value: 'string' }, { type: 'text', value: ' description follows' }, ]; - const result = extractTypeAnnotations(nodes); + const result = extractTypeAnnotation(nodes); assert.strictEqual(result, 'mock-expression'); assert.strictEqual(nodes.length, 1); assert.strictEqual(nodes[0].value, ' description follows'); }); - it('extracts union types with separator', () => { + it('extracts union types written as one annotation', () => { const nodes = [ - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, - { type: 'text', value: ' | ' }, - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, + { type: 'typeAnnotation', value: 'string|number' }, { type: 'text', value: ' description' }, ]; - const result = extractTypeAnnotations(nodes); + const result = extractTypeAnnotation(nodes); assert.strictEqual(result, 'mock-expression'); assert.strictEqual(nodes.length, 1); assert.strictEqual(nodes[0].value, ' description'); }); - it('handles union separator at end without following type', () => { - const nodes = [ - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, - { type: 'text', value: ' | ' }, - ]; - - const result = extractTypeAnnotations(nodes); - - assert.strictEqual(result, 'mock-expression'); - assert.strictEqual(nodes.length, 0); - }); - - it('returns undefined when no type annotations found', () => { + it('returns undefined when no type annotation leads', () => { const nodes = [ { type: 'text', value: 'regular text' }, - { type: 'emphasis', children: [{ type: 'text', value: 'emphasized' }] }, + { type: 'typeAnnotation', value: 'string' }, ]; - const result = extractTypeAnnotations(nodes); + const result = extractTypeAnnotation(nodes); assert.strictEqual(result, undefined); assert.strictEqual(nodes.length, 2); }); - it('stops extraction at first non-type node', () => { + it('consumes only the leading annotation', () => { const nodes = [ - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, + { type: 'typeAnnotation', value: 'string' }, { type: 'emphasis', children: [{ type: 'text', value: 'not a type' }] }, - { type: 'text', value: ' | ' }, // This shouldn't be consumed + { type: 'typeAnnotation', value: 'number' }, ]; - const result = extractTypeAnnotations(nodes); + const result = extractTypeAnnotation(nodes); assert.strictEqual(result, 'mock-expression'); assert.strictEqual(nodes.length, 2); assert.strictEqual(nodes[0].type, 'emphasis'); - assert.strictEqual(nodes[1].value, ' | '); }); it('handles empty nodes array', () => { const nodes = []; - const result = extractTypeAnnotations(nodes); + const result = extractTypeAnnotation(nodes); assert.strictEqual(result, undefined); assert.strictEqual(nodes.length, 0); }); - - it('processes complex union types with multiple separators', () => { - const nodes = [ - { type: 'link', children: [{ type: 'inlineCode', value: '' }] }, - { type: 'text', value: ' | ' }, - { type: 'link', children: [{ type: 'inlineCode', value: '' }] }, - { type: 'text', value: ' | ' }, - { type: 'link', children: [{ type: 'inlineCode', value: '' }] }, - { type: 'text', value: ' description' }, - ]; - - const result = extractTypeAnnotations(nodes); - - assert.strictEqual(result, 'mock-expression'); - assert.strictEqual(nodes.length, 1); - assert.strictEqual(nodes[0].value, ' description'); - }); }); describe('parseListIntoProperties', () => { @@ -317,7 +189,7 @@ describe('parseListIntoProperties', () => { ]); }); - it('parses property with type annotations', () => { + it('parses property with a type annotation', () => { const node = { children: [ { @@ -325,10 +197,7 @@ describe('parseListIntoProperties', () => { { children: [ { type: 'inlineCode', value: 'prop' }, - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, + { type: 'typeAnnotation', value: 'string' }, { type: 'text', value: ' description' }, ], }, @@ -404,7 +273,6 @@ describe('parseListIntoProperties', () => { }); it('trims padding from description text', () => { - // Mock TRIMMABLE_PADDING_REGEX const node = { children: [ { @@ -578,10 +446,7 @@ describe('parseListIntoProperties', () => { { children: [ { type: 'inlineCode', value: 'config' }, - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, + { type: 'typeAnnotation', value: 'Object' }, { type: 'text', value: ' configuration object' }, ], }, @@ -593,10 +458,7 @@ describe('parseListIntoProperties', () => { { children: [ { type: 'inlineCode', value: 'timeout' }, - { - type: 'link', - children: [{ type: 'inlineCode', value: '' }], - }, + { type: 'typeAnnotation', value: 'number' }, { type: 'text', value: ' timeout in milliseconds' }, ], }, diff --git a/src/generators/jsx-ast/utils/types.mjs b/src/generators/jsx-ast/utils/types.mjs index d16ec1a5..aab6b70f 100644 --- a/src/generators/jsx-ast/utils/types.mjs +++ b/src/generators/jsx-ast/utils/types.mjs @@ -6,30 +6,6 @@ import { transformNodesToString } from '../../../utils/unist.mjs'; import { DEFAULT_EXPRESSION } from '../../legacy-json/constants.mjs'; import { TRIMMABLE_PADDING_REGEX } from '../constants.mjs'; -/** - * Checks if the node is a union separator (`' | '`) or a type reference - * (a link wrapping `` inline code). - * - * @param {import('mdast').Node} node - * @returns {0 | 1 | 2} 0 = not type-related, 1 = type ref, 2 = union separator - */ -export const classifyTypeNode = node => { - if (node.type === 'text' && node.value === ' | ') { - return 2; - } - - const child = node.children?.[0]; - if ( - node.type === 'link' && - child?.type === 'inlineCode' && - child.value?.startsWith('<') - ) { - return 1; - } - - return 0; -}; - /** * Removes and returns the leading node if it's blank text. * @@ -84,31 +60,18 @@ export const extractPropertyName = (nodes, current) => { }; /** - * Consumes consecutive type-annotation nodes (type refs and union - * separators) from the front of `nodes` and updates the current object. + * Consumes a leading type annotation from the front of `nodes` and compiles + * it to a JSX expression (unions live inside a single annotation). * * @param {Array} nodes */ -export const extractTypeAnnotations = nodes => { - const types = []; - - while (nodes.length) { - const kind = classifyTypeNode(nodes[0]); - if (kind === 0) { - break; - } - - types.push(nodes.shift()); - - // A union separator implies another type follows - if (kind === 2 && nodes.length) { - types.push(nodes.shift()); - } +export const extractTypeAnnotation = nodes => { + if (nodes[0]?.type !== 'typeAnnotation') { + return undefined; } - if (types.length > 0) { - return remark().runSync(createTree('root', types)).body[0].expression; - } + return remark().runSync(createTree('root', [nodes.shift()])).body[0] + .expression; }; /** @@ -126,7 +89,7 @@ export const parseListIntoProperties = node => // Strip stale whitespace left over after name extraction shiftIfBlankText(children); - current.type = extractTypeAnnotations(children); + current.type = extractTypeAnnotation(children); if (children.length > 0) { children[0].value &&= children[0].value.replace( diff --git a/src/generators/legacy-html/utils/buildContent.mjs b/src/generators/legacy-html/utils/buildContent.mjs index 73545d7f..c22a4396 100644 --- a/src/generators/legacy-html/utils/buildContent.mjs +++ b/src/generators/legacy-html/utils/buildContent.mjs @@ -11,7 +11,7 @@ import { GITHUB_BLOB_URL, populate, } from '../../../utils/configuration/templates.mjs'; -import { QUERIES, UNIST } from '../../../utils/queries/index.mjs'; +import { UNIST } from '../../../utils/queries/index.mjs'; import { getRemarkRehypeWithShiki as remark } from '../../../utils/remark.mjs'; /** @@ -72,18 +72,6 @@ const buildStability = ({ children, data }, index, parent) => { return [SKIP]; }; -/** - * Transforms the node Markdown link into an HTML link - * - * @param {import('@types/mdast').Html} node The node containing the HTML content - */ -const buildHtmlTypeLink = node => { - node.value = node.value.replace( - QUERIES.linksWithTypes, - (_, type, link) => `<${type}>` - ); -}; - /** * Creates a history table row. * @@ -238,10 +226,6 @@ export default (headNodes, metadataEntries) => { // within the content, so we can't just remove it and append it to the metadata visit(entry.content, UNIST.isStabilityNode, buildStability); - // Parses the type references that got replaced into Markdown links (raw) - // into actual HTML links, these then get parsed into HAST nodes on `runSync` - visit(entry.content, UNIST.isHtmlWithType, buildHtmlTypeLink); - // Splits the content into the Heading node and the rest of the content const [headingNode, ...restNodes] = entry.content.children; diff --git a/src/generators/legacy-json/constants.mjs b/src/generators/legacy-json/constants.mjs index 3b017d4d..4ae94f6f 100644 --- a/src/generators/legacy-json/constants.mjs +++ b/src/generators/legacy-json/constants.mjs @@ -1,9 +1,6 @@ // Grabs a method's name export const NAME_EXPRESSION = /^['`"]?([^'`": {]+)['`"]?\s*:?\s*/; -// Denotes a method's type -export const TYPE_EXPRESSION = /^\{([^}]+)\}\s*/; - // Checks if there's a leading hyphen export const LEADING_HYPHEN = /^-\s*/; diff --git a/src/generators/legacy-json/utils/__tests__/parseList.test.mjs b/src/generators/legacy-json/utils/__tests__/parseList.test.mjs index 648b2723..927079e0 100644 --- a/src/generators/legacy-json/utils/__tests__/parseList.test.mjs +++ b/src/generators/legacy-json/utils/__tests__/parseList.test.mjs @@ -1,35 +1,15 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { - transformTypeReferences, - extractPattern, - parseListItem, - parseList, -} from '../parseList.mjs'; +import { extractPattern, parseListItem, parseList } from '../parseList.mjs'; const validTypedList = [ { type: 'inlineCode', value: 'option' }, // inline code { type: 'text', value: ' ' }, // space - { - type: 'link', - children: [{ type: 'text', value: '' }], // link with < value - }, + { type: 'typeAnnotation', value: 'boolean' }, { type: 'text', value: ' option description' }, ]; -describe('transformTypeReferences', () => { - it('replaces template syntax with curly braces', () => { - const result = transformTypeReferences('``'); - assert.equal(result, '{string}'); - }); - - it('normalizes multiple types', () => { - const result = transformTypeReferences('`` | ``'); - assert.equal(result, '{string|number}'); - }); -}); - describe('extractPattern', () => { it('extracts pattern and removes from text', () => { const current = {}; @@ -64,14 +44,81 @@ describe('parseListItem', () => { children: [ { type: 'paragraph', - children: [{ type: 'text', value: 'param {string} description' }], + children: [ + { type: 'inlineCode', value: 'param' }, + { type: 'text', value: ' ' }, + { type: 'typeAnnotation', value: 'string' }, + { type: 'text', value: ' description' }, + ], }, ], }; const result = parseListItem(child); - assert.equal(typeof result, 'object'); - assert.ok(result.textRaw); + assert.equal(result.textRaw, '`param` {string} description'); + assert.equal(result.name, 'param'); + assert.equal(result.type, 'string'); + assert.equal(result.desc, 'description'); + }); + + it('keeps union types verbatim', () => { + const child = { + children: [ + { + type: 'paragraph', + children: [ + { type: 'inlineCode', value: 'data' }, + { type: 'text', value: ' ' }, + { type: 'typeAnnotation', value: 'Buffer | string' }, + { type: 'text', value: ' the data' }, + ], + }, + ], + }; + + const result = parseListItem(child); + assert.equal(result.textRaw, '`data` {Buffer | string} the data'); + assert.equal(result.type, 'Buffer | string'); + }); + + it('handles nested braces in types', () => { + const child = { + children: [ + { + type: 'paragraph', + children: [ + { type: 'inlineCode', value: 'maps' }, + { type: 'text', value: ' ' }, + { type: 'typeAnnotation', value: 'Record' }, + { type: 'text', value: ' the maps' }, + ], + }, + ], + }; + + const result = parseListItem(child); + assert.equal(result.type, 'Record'); + assert.equal(result.desc, 'the maps'); + }); + + it('does not mistake a prose annotation for the type', () => { + const child = { + children: [ + { + type: 'paragraph', + children: [ + { type: 'inlineCode', value: 'param' }, + { type: 'text', value: ' see also ' }, + { type: 'typeAnnotation', value: 'string' }, + { type: 'text', value: ' values' }, + ], + }, + ], + }; + + const result = parseListItem(child); + assert.equal(result.type, undefined); + assert.equal(result.desc, 'see also {string} values'); }); it('identifies return items', () => { @@ -87,6 +134,29 @@ describe('parseListItem', () => { const result = parseListItem(child); assert.equal(result.name, 'return'); }); + + it('extracts the default value', () => { + const child = { + children: [ + { + type: 'paragraph', + children: [ + { type: 'inlineCode', value: 'flag' }, + { type: 'text', value: ' ' }, + { type: 'typeAnnotation', value: 'boolean' }, + { type: 'text', value: ' turns it on. ' }, + { type: 'strong', children: [{ type: 'text', value: 'Default:' }] }, + { type: 'text', value: ' ' }, + { type: 'inlineCode', value: 'false' }, + ], + }, + ], + }; + + const result = parseListItem(child); + assert.equal(result.type, 'boolean'); + assert.equal(result.default, '`false`'); + }); }); describe('parseList', () => { @@ -110,6 +180,7 @@ describe('parseList', () => { parseList(section, nodes); assert.ok(section.textRaw); + assert.equal(section.type, 'boolean'); }); it('processes event sections', () => { diff --git a/src/generators/legacy-json/utils/parseList.mjs b/src/generators/legacy-json/utils/parseList.mjs index 5e1a5d89..75936e15 100644 --- a/src/generators/legacy-json/utils/parseList.mjs +++ b/src/generators/legacy-json/utils/parseList.mjs @@ -2,23 +2,12 @@ import { DEFAULT_EXPRESSION, LEADING_HYPHEN, NAME_EXPRESSION, - TYPE_EXPRESSION, } from '../constants.mjs'; import parseSignature from './parseSignature.mjs'; import { leftHandAssign } from '../../../utils/generators.mjs'; import { QUERIES, UNIST } from '../../../utils/queries/index.mjs'; import { transformNodesToString } from '../../../utils/unist.mjs'; -/** - * Modifies type references in a string by replacing template syntax (`<...>`) with curly braces `{...}` - * and normalizing formatting. - * @param {string} string - * @returns {string} - */ -export function transformTypeReferences(string) { - return string.replace(/`<([^>]+)>`/g, '{$1}').replaceAll('} | {', '|'); -} - /** * Extracts and removes a specific pattern from a text string while storing the result in a key of the `current` object. * @param {string} text @@ -49,12 +38,13 @@ export function parseListItem(child) { const subList = child.children.find(UNIST.isLooselyTypedList); - // Extract and clean raw text from the node, excluding nested lists - current.textRaw = transformTypeReferences( - transformNodesToString(child.children.filter(node => node !== subList)) - .replace(/\s+/g, ' ') - .replace(//gs, '') - ); + // Extract and clean raw text from the node, excluding nested lists. + // Type annotations serialize back to their `{Type}` source form. + current.textRaw = transformNodesToString( + child.children.filter(node => node !== subList) + ) + .replace(/\s+/g, ' ') + .replace(//gs, ''); let text = current.textRaw; @@ -68,7 +58,17 @@ export function parseListItem(child) { text = extractPattern(text, NAME_EXPRESSION, 'name', current); } - text = extractPattern(text, TYPE_EXPRESSION, 'type', current); + // The type is the item's typeAnnotation node; it's only a type (and not + // prose) when its serialized form sits at the head of the remaining text + const annotation = child.children + .find(node => node.type === 'paragraph') + ?.children.find(node => node.type === 'typeAnnotation'); + + if (annotation && text.startsWith(`{${annotation.value}}`)) { + current.type = annotation.value; + text = text.slice(annotation.value.length + 2).replace(/^\s+/, ''); + } + text = extractPattern(text, DEFAULT_EXPRESSION, 'default', current); // Set the remaining text as the description, removing any leading hyphen diff --git a/src/generators/man-page/utils/converter.mjs b/src/generators/man-page/utils/converter.mjs index 2662ad7d..fe496285 100644 --- a/src/generators/man-page/utils/converter.mjs +++ b/src/generators/man-page/utils/converter.mjs @@ -49,6 +49,10 @@ export function convertNodeToMandoc(node, isListItem = false) { // Format inline code using Mandoc's bold markup (\\fB ... \\fR). return `\\fB${escapeText()}\\fR`; + case 'typeAnnotation': + // Type annotations render like inline code, in their `{...}` form. + return `\\fB{${escapeText()}}\\fR`; + case 'strong': // Format inline code + strong using Mandoc's bold markup (\\fB ... \\fR). return `\\fB${convertChildren()}\\fR`; diff --git a/src/generators/metadata/constants.mjs b/src/generators/metadata/constants.mjs index 12ed7fd5..262e90a0 100644 --- a/src/generators/metadata/constants.mjs +++ b/src/generators/metadata/constants.mjs @@ -1,8 +1,3 @@ -// These openers/closers are used to determine if a type string is well-formed -export const TYPE_OPENERS = new Set(['<', '(', '{', '[']); -export const TYPE_CLOSERS = new Set(['>', ')', '}', ']']); -export const PREFIXES = ['typeof ', 'keyof ', 'readonly ', 'unique ']; - // On "About this Documentation", we define the stability indices, and thus // we don't need to check it for stability references export const IGNORE_STABILITY_STEMS = ['documentation']; diff --git a/src/generators/metadata/generate.mjs b/src/generators/metadata/generate.mjs index b5691258..9c12cf13 100644 --- a/src/generators/metadata/generate.mjs +++ b/src/generators/metadata/generate.mjs @@ -1,6 +1,7 @@ 'use strict'; import { parseApiDoc } from './utils/parse.mjs'; +import { createTypeResolver } from './utils/resolveTypes.mjs'; import getConfig from '../../utils/configuration/index.mjs'; import { loadFromURL } from '../../utils/loaders.mjs'; @@ -11,10 +12,12 @@ import { loadFromURL } from '../../utils/loaders.mjs'; * @type {import('./types').Generator['processChunk']} */ export async function processChunk(fullInput, itemIndices, typeMap) { + const typeResolver = await createTypeResolver(); + const results = []; for (const idx of itemIndices) { - results.push(...parseApiDoc(fullInput[idx], typeMap)); + results.push(...parseApiDoc(fullInput[idx], typeMap, typeResolver)); } return results; diff --git a/src/generators/metadata/utils/__tests__/parse.test.mjs b/src/generators/metadata/utils/__tests__/parse.test.mjs index 9197ce4c..1279fcdd 100644 --- a/src/generators/metadata/utils/__tests__/parse.test.mjs +++ b/src/generators/metadata/utils/__tests__/parse.test.mjs @@ -6,9 +6,11 @@ import { describe, it } from 'node:test'; import { u } from 'unist-builder'; import { parseApiDoc } from '../parse.mjs'; +import { createTypeResolver } from '../resolveTypes.mjs'; const path = 'fs'; const typeMap = {}; +const typeResolver = await createTypeResolver(); const h = (text, depth = 1) => u('heading', { depth }, [u('text', text)]); const yaml = content => u('html', ``); @@ -182,16 +184,39 @@ describe('parseApiDoc', () => { }); describe('type references', () => { - it('transforms {type} references into links', () => { + const findAnnotation = entry => { + const paragraph = entry.content.children.find( + n => n.type === 'paragraph' + ); + return paragraph?.children?.find(n => n.type === 'typeAnnotation'); + }; + + it('resolves typeAnnotation nodes into links', () => { const tree = u('root', [ h('fs'), - u('paragraph', [u('text', '{string}')]), + u('paragraph', [u('typeAnnotation', 'string')]), ]); - const [entry] = parseApiDoc({ path, tree }, typeMap); + const [entry] = parseApiDoc({ path, tree }, typeMap, typeResolver); + const annotation = findAnnotation(entry); + + assert.strictEqual(annotation.data.links.length, 1); + assert.strictEqual(annotation.data.links[0].text, 'string'); + }); - assert.ok( - findLink(entry) !== undefined, - 'expected a link node from type reference transformation' + it('makes typeMap links relative to the current document', () => { + const tree = u('root', [ + h('fs'), + u('paragraph', [u('typeAnnotation', 'Buffer')]), + ]); + const [entry] = parseApiDoc( + { path, tree }, + { Buffer: 'buffer.html#class-buffer' }, + typeResolver + ); + + assert.strictEqual( + findAnnotation(entry).data.links[0].href, + 'buffer.html#class-buffer' ); }); }); @@ -237,16 +262,27 @@ describe('parseApiDoc', () => { assert.strictEqual(entry.mdx, true); }); - it('skips {type} reference transformation in MDX mode', () => { - // In MDX, a bare `{string}` is a real expression node, not a type - // annotation, so it must be left untouched (no link generated). + it('skips type annotation resolution in MDX mode', () => { + // MDX trees never contain typeAnnotation nodes in production (there, + // `{...}` is a real expression) — but even if one appears, the + // resolution pass must not run. const tree = u('root', [ h('fs'), - u('paragraph', [u('text', '{string}')]), + u('paragraph', [u('typeAnnotation', 'string')]), ]); - const [entry] = parseApiDoc({ path, tree, mdx: true }, typeMap); + const [entry] = parseApiDoc( + { path, tree, mdx: true }, + typeMap, + typeResolver + ); + const paragraph = entry.content.children.find( + n => n.type === 'paragraph' + ); + const annotation = paragraph?.children?.find( + n => n.type === 'typeAnnotation' + ); - assert.strictEqual(findLink(entry), undefined); + assert.strictEqual(annotation.data, undefined); }); }); diff --git a/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs b/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs new file mode 100644 index 00000000..00920a3d --- /dev/null +++ b/src/generators/metadata/utils/__tests__/resolveTypes.test.mjs @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; + +const warn = mock.fn(); + +mock.module('../../../../logger/index.mjs', { + defaultExport: { warn }, +}); + +const { createTypeResolver } = await import('../resolveTypes.mjs'); + +const { parseTypeValues, resolveTypeAnnotations } = await createTypeResolver(); + +const MDN_JS = 'https://developer.mozilla.org/docs/Web/JavaScript'; +const STRING = `${MDN_JS}/Data_structures#string_type`; +const NUMBER = `${MDN_JS}/Data_structures#number_type`; +const BOOLEAN = `${MDN_JS}/Data_structures#boolean_type`; +const PROMISE = `${MDN_JS}/Reference/Global_Objects/Promise`; +const MAP = `${MDN_JS}/Reference/Global_Objects/Map`; +const ARRAY = `${MDN_JS}/Reference/Global_Objects/Array`; +const ERROR = `${MDN_JS}/Reference/Global_Objects/Error`; +const ITERABLE = `${MDN_JS}/Reference/Iteration_protocols#the_iterable_protocol`; +const VOID = `${MDN_JS}/Reference/Operators/void`; + +/** + * Resolves a single annotation value and returns its `data`. + */ +const resolve = (value, typeMap = {}) => { + const node = { type: 'typeAnnotation', value }; + const tree = { + type: 'root', + children: [{ type: 'paragraph', children: [node] }], + }; + + resolveTypeAnnotations(tree, typeMap, '/test'); + + return node.data; +}; + +/** Shorthand: `[start, end, href]` triples for terser assertions. */ +const linksOf = (value, typeMap) => + resolve(value, typeMap).links.map(({ start, end, href }) => [ + start, + end, + href, + ]); + +describe('resolveTypeAnnotations', () => { + it('resolves a JavaScript primitive', () => { + assert.deepEqual(linksOf('string'), [[0, 6, STRING]]); + }); + + it('resolves a JavaScript global', () => { + assert.deepEqual(linksOf('Array'), [[0, 5, ARRAY]]); + }); + + it('resolves a type from the type map', () => { + assert.deepEqual(linksOf('SomeOtherType', { SomeOtherType: 'fromMap' }), [ + [0, 13, 'fromMap'], + ]); + }); + + it('resolves both parts of a generic', () => { + assert.deepEqual(linksOf('Promise'), [ + [0, 7, PROMISE], + [8, 14, STRING], + ]); + }); + + it('partially resolves a generic when only one part is known', () => { + assert.deepEqual(linksOf('CustomType'), [[11, 17, STRING]]); + }); + + it('resolves a generic with an inner union', () => { + assert.deepEqual(linksOf('Promise'), [ + [0, 7, PROMISE], + [8, 14, STRING], + [15, 22, BOOLEAN], + ]); + }); + + it('resolves multi-parameter generics', () => { + assert.deepEqual(linksOf('Map'), [ + [0, 3, MAP], + [4, 10, STRING], + [12, 18, NUMBER], + ]); + }); + + it('resolves unions (spacing as authored)', () => { + assert.deepEqual(linksOf('Promise | Iterable'), [ + [0, 7, PROMISE], + [8, 14, STRING], + [15, 21, NUMBER], + [25, 33, ITERABLE], + [34, 41, BOOLEAN], + ]); + }); + + it('resolves intersections', () => { + assert.deepEqual(linksOf('string&boolean'), [ + [0, 6, STRING], + [7, 14, BOOLEAN], + ]); + }); + + it('resolves types inside function signatures, but not parameter names', () => { + assert.deepEqual(linksOf('(err: Error) => Promise'), [ + [6, 11, ERROR], + [16, 23, PROMISE], + [24, 31, BOOLEAN], + ]); + }); + + it('resolves the target of a typeof query', () => { + assert.deepEqual(linksOf('typeof Compiler', { Compiler: '/api/C' }), [ + [7, 15, '/api/C'], + ]); + }); + + it('does not confuse identifiers starting with an operator keyword', () => { + assert.deepEqual( + linksOf('typeofSomething', { typeofSomething: '/api/t' }), + [[0, 15, '/api/t']] + ); + }); + + it('resolves array element types (not the brackets)', () => { + assert.deepEqual(linksOf('string[]'), [[0, 6, STRING]]); + }); + + it('resolves dotted names via the module heuristic', () => { + assert.deepEqual(linksOf('vm.Module'), [[0, 9, 'vm.html#class-vmmodule']]); + }); + + it('resolves import() types through the dotted heuristic', () => { + assert.deepEqual(linksOf("import('fs').Stats"), [ + [13, 18, 'fs.html#class-fsstats'], + ]); + }); + + it('resolves object literal types (nested braces)', () => { + assert.deepEqual(linksOf('Record'), [ + [7, 13, STRING], + [19, 25, NUMBER], + ]); + }); + + it('resolves keyword types: this, null, undefined, void', () => { + assert.equal(resolve('this').links.length, 1); + assert.equal(resolve('null').links.length, 1); + assert.equal(resolve('undefined').links.length, 1); + assert.deepEqual(linksOf('() => void'), [[6, 10, VOID]]); + }); + + it('resolves deeply nested combinations', () => { + const value = + '(str: string[]) => Promise, Map>'; + + const hrefs = resolve(value).links.map(({ href }) => href); + + assert.deepEqual(hrefs, [ + STRING, + PROMISE, + MAP, + STRING, + NUMBER, + STRING, + MAP, + STRING, + NUMBER, + ]); + }); + + it('resolves destructured callback signatures', () => { + const value = + '(cb: ([first, second]: string[]) => void) => ({ id, name }: User) => boolean'; + + const links = resolve(value, { User: 'userLink' }).links; + + assert.deepEqual( + links.map(({ text }) => text), + ['string', 'void', 'User', 'boolean'] + ); + assert.equal(links[2].href, 'userLink'); + }); + + it('resolves display-name map keys as one whole link', () => { + assert.deepEqual( + linksOf('zlib options', { 'zlib options': 'zlib.html#options' }), + [[0, 12, 'zlib.html#options']] + ); + }); + + describe('invalid annotations', () => { + it('flags invalid TypeScript and warns', () => { + warn.mock.resetCalls(); + + const data = resolve('), and U+007D ('); + + assert.equal(data.parseError, true); + assert.deepEqual(data.links, []); + assert.equal(warn.mock.callCount(), 1); + assert.match(warn.mock.calls[0].arguments[0], /\{\), and U\+007D \(\}/); + assert.equal(warn.mock.calls[0].arguments[1].file.path, '/test'); + }); + + it('does not let one invalid annotation poison the batch', () => { + const good = { type: 'typeAnnotation', value: 'Promise' }; + const bad = { type: 'typeAnnotation', value: 'not a type!' }; + const tree = { + type: 'root', + children: [{ type: 'paragraph', children: [good, bad] }], + }; + + resolveTypeAnnotations(tree, {}, '/test'); + + assert.equal(good.data.links.length, 2); + assert.equal(good.data.parseError, undefined); + assert.equal(bad.data.parseError, true); + }); + + it('flags statement smuggling as invalid', () => { + const data = resolve('string; type X = 1'); + + assert.equal(data.parseError, true); + }); + }); + + it('leaves unresolvable (but valid) types unlinked without error', () => { + const data = resolve('SomeUnknownThing'); + + assert.deepEqual(data.links, []); + assert.equal(data.parseError, undefined); + }); + + it('resolves every annotation in a tree (batched)', () => { + const nodes = ['string', 'Buffer|Blob', 'Promise'].map(value => ({ + type: 'typeAnnotation', + value, + })); + const tree = { + type: 'root', + children: [{ type: 'paragraph', children: nodes }], + }; + + resolveTypeAnnotations(tree, { Buffer: 'buffer.html' }, '/test'); + + assert.equal(nodes[0].data.links.length, 1); + assert.equal(nodes[1].data.links.length, 2); + assert.equal(nodes[2].data.links.length, 2); + }); +}); + +describe('parseTypeValues', () => { + it('parses all values in one batch and maps offsets back', () => { + const [first, second] = parseTypeValues(['string', 'Map']); + + assert.deepEqual( + first.identifiers.map(({ start, end, text }) => [start, end, text]), + [[0, 6, 'string']] + ); + assert.deepEqual( + second.identifiers.map(({ text }) => text), + ['Map', 'Buffer', 'this'] + ); + assert.deepEqual(second.identifiers[1], { + start: 4, + end: 10, + text: 'Buffer', + lookup: 'Buffer', + }); + }); + + it('isolates invalid values without failing the valid ones', () => { + const [bad, good] = parseTypeValues(['%%%', 'boolean']); + + assert.equal(bad.error, true); + assert.equal(good.identifiers[0].text, 'boolean'); + }); +}); diff --git a/src/generators/metadata/utils/__tests__/transformers.test.mjs b/src/generators/metadata/utils/__tests__/transformers.test.mjs index fd5a8cd8..b23b4ffd 100644 --- a/src/generators/metadata/utils/__tests__/transformers.test.mjs +++ b/src/generators/metadata/utils/__tests__/transformers.test.mjs @@ -1,136 +1,64 @@ import { strictEqual } from 'node:assert'; import { describe, it } from 'node:test'; -import { transformTypeToReferenceLink } from '../transformers.mjs'; +import { lookupTypeName, resolveTypeReference } from '../transformers.mjs'; -describe('transformTypeToReferenceLink', () => { - it('should transform a JavaScript primitive type into a Markdown link', () => { - strictEqual( - transformTypeToReferenceLink('string'), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)' - ); - }); - - it('should transform a JavaScript global type into a Markdown link', () => { - strictEqual( - transformTypeToReferenceLink('Array'), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)' - ); +describe('lookupTypeName', () => { + it('resolves from the type map first', () => { + strictEqual(lookupTypeName('string', { string: 'override' }), 'override'); }); - it('should transform a type into a Markdown link', () => { + it('resolves JavaScript primitives from the built-in map', () => { strictEqual( - transformTypeToReferenceLink('SomeOtherType', { - SomeOtherType: 'fromTypeMap', - }), - '[``](fromTypeMap)' + lookupTypeName('string'), + 'https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type' ); }); - it('should transform a basic Generic type into a Markdown link', () => { + it('resolves JavaScript globals from the built-in map (case-insensitive)', () => { strictEqual( - transformTypeToReferenceLink('{Promise}'), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)>' + lookupTypeName('Array'), + 'https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array' ); }); - it('should partially transform a Generic type if only one part is known', () => { + it('resolves Web APIs from the MDN map', () => { strictEqual( - transformTypeToReferenceLink('{CustomType}', {}), - '``<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)>' + lookupTypeName('AbortSignal'), + 'https://developer.mozilla.org/docs/Web/API/AbortSignal' ); }); - it('should transform a Generic type with an inner union like {Promise}', () => { + it('resolves display-name map keys verbatim', () => { strictEqual( - transformTypeToReferenceLink('{Promise}', {}), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) | [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)>' + lookupTypeName('zlib options', { 'zlib options': 'zlib.html#options' }), + 'zlib.html#options' ); }); - it('should transform multi-parameter generics like {Map}', () => { - strictEqual( - transformTypeToReferenceLink('{Map}', {}), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type), [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type)>' - ); - }); - - it('should handle outer unions with generics like {Promise | Iterable}', () => { - strictEqual( - transformTypeToReferenceLink( - '{Promise | Iterable}', - {} - ), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) | [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type)> | [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)>' - ); - }); - - it('should transform an intersection type joined with & into linked parts', () => { - strictEqual( - transformTypeToReferenceLink('{string&boolean}', {}), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) & [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)' - ); - }); - - it('should handle an intersection with generics like {Map&Array}', () => { - strictEqual( - transformTypeToReferenceLink('{Map&Array}', {}), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type), [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type)> & [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)>' - ); - }); - - it('should transform a function returning a Generic type', () => { - strictEqual( - transformTypeToReferenceLink('(err: Error) => Promise', {}), - '(err: [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error)) => [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)>' - ); + it('returns an empty string on a miss (no dotted heuristic)', () => { + strictEqual(lookupTypeName('vm.Module'), ''); + strictEqual(lookupTypeName('NotAThing'), ''); }); +}); - it('should respect precedence: Unions (|) are weaker than Intersections (&)', () => { - strictEqual( - transformTypeToReferenceLink('string | number & boolean', {}), - '[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) | [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type) & [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)' - ); +describe('resolveTypeReference', () => { + it('falls back to the dotted-name heuristic for classes', () => { + strictEqual(resolveTypeReference('vm.Module'), 'vm.html#class-vmmodule'); }); - it('should correctly extract TS prefix operators and link the underlying type', () => { - strictEqual( - transformTypeToReferenceLink('typeof Compiler', { - Compiler: '/api/Compiler', - }), - 'typeof [``](/api/Compiler)' - ); + it('does not use the class prefix for non-class members', () => { + strictEqual(resolveTypeReference('vm.constants'), 'vm.html#vmconstants'); }); - it('should not incorrectly strip prefixes if they are part of the type name (word boundary)', () => { + it('prefers the map over the dotted heuristic', () => { strictEqual( - transformTypeToReferenceLink('typeofSomething', { - typeofSomething: '/api/typeofSomething', - }), - '[``](/api/typeofSomething)' + resolveTypeReference('vm.Module', { 'vm.Module': 'mapped' }), + 'mapped' ); }); - it('should handle extreme nested combinations of functions, arrays, generics, unions, and intersections', () => { - const input = - '(str: string[]) => Promise, Map>'; - - const expected = - '(str: [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)[]) => [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type), [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type) & [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)>, [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)<[``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) | [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type)>>'; - - strictEqual(transformTypeToReferenceLink(input, {}), expected); - }); - - it('should parse functions with array destructuring in callbacks returning functions with object destructuring', () => { - const input = - '(cb: ([first, second]: string[]) => void) => ({ id, name }: User) => boolean'; - - const expected = - '(cb: ([first, second]: [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)[]) => [``](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/void)) => ({ id, name }: [``](userLink)) => [``](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)'; - - strictEqual( - transformTypeToReferenceLink(input, { User: 'userLink' }), - expected - ); + it('returns an empty string for unknown plain names', () => { + strictEqual(resolveTypeReference('NotAThing'), ''); }); }); diff --git a/src/generators/metadata/utils/parse.mjs b/src/generators/metadata/utils/parse.mjs index 840a2120..b053e2c4 100644 --- a/src/generators/metadata/utils/parse.mjs +++ b/src/generators/metadata/utils/parse.mjs @@ -15,7 +15,6 @@ import { visitLinkReference, visitMarkdownLink, visitStability, - visitTextWithTypeNode, visitTextWithUnixManualNode, visitYAML, } from './visitors.mjs'; @@ -29,9 +28,14 @@ import { IGNORE_STABILITY_STEMS } from '../constants.mjs'; * * @param {{ tree: import('mdast.Root'), mdx?: boolean } & import('../types').MetadataEntry} input * @param {Record} typeMap - * @returns {Promise>} + * @param {Awaited>} [typeResolver] + * @returns {Array} */ -export const parseApiDoc = ({ path, tree, mdx = false }, typeMap) => { +export const parseApiDoc = ( + { path, tree, mdx = false }, + typeMap, + typeResolver +) => { /** * Collection of metadata entries for the file * @type {Array} @@ -63,6 +67,13 @@ export const parseApiDoc = ({ path, tree, mdx = false }, typeMap) => { Object.entries(typeMap).map(([type, url]) => [type, relative(url, path)]) ); + // Resolve every type annotation in the file at once (one TypeScript parse + // per file). MDX trees never contain `typeAnnotation` nodes — there, + // `{...}` is a real expression — so this is skipped. + if (!mdx) { + typeResolver?.resolveTypeAnnotations(tree, relativeTypeMap, path); + } + // Handles the normalisation URLs that reference to API doc files with .md extension visit(tree, UNIST.isMarkdownUrl, node => visitMarkdownLink(node)); @@ -117,14 +128,6 @@ export const parseApiDoc = ({ path, tree, mdx = false }, typeMap) => { metadata.heading.data.type = metadata.type; } - // Process type references. Skipped for MDX, where bare `<...>`/`{...}` are - // real JSX/expression nodes (not type annotations) and must not be rewritten. - if (!mdx) { - visit(subTree, UNIST.isTextWithType, (node, _, parent) => - visitTextWithTypeNode(node, parent, relativeTypeMap) - ); - } - // Process Unix manual references visit(subTree, UNIST.isTextWithUnixManual, (node, _, parent) => visitTextWithUnixManualNode(node, parent) diff --git a/src/generators/metadata/utils/resolveTypes.mjs b/src/generators/metadata/utils/resolveTypes.mjs new file mode 100644 index 00000000..fea69c3b --- /dev/null +++ b/src/generators/metadata/utils/resolveTypes.mjs @@ -0,0 +1,260 @@ +'use strict'; + +import { visit } from 'unist-util-visit'; + +import { lookupTypeName, resolveTypeReference } from './transformers.mjs'; +import logger from '../../../logger/index.mjs'; + +/** + * @typedef {Object} TypeLink A resolved link inside a type annotation + * @property {number} start Start offset within the annotation value + * @property {number} end End offset (exclusive) within the annotation value + * @property {string} text The linked text + * @property {string} href The resolved documentation URL + * + * @typedef {Object} TypeIdentifier A link candidate found by the TS parser + * @property {number} start Start offset within the parsed value + * @property {number} end End offset (exclusive) within the parsed value + * @property {string} text The identifier text as written + * @property {string} lookup The name to resolve (differs for `import()` types) + */ + +/** + * Creates the type resolver. TypeScript is imported here — it's a heavy + * module, and every worker thread preloads every generator, so only callers + * that actually resolve types (the metadata chunks) pay for it. The runtime + * module registry caches the import, so creating multiple resolvers is cheap. + */ +export const createTypeResolver = async () => { + const { default: ts } = await import('typescript'); + + // Keyword type nodes that resolve through the built-in map (`{string}`, + // `{number}`, ...) + const keywordKinds = new Set([ + ts.SyntaxKind.AnyKeyword, + ts.SyntaxKind.BigIntKeyword, + ts.SyntaxKind.BooleanKeyword, + ts.SyntaxKind.NeverKeyword, + ts.SyntaxKind.NumberKeyword, + ts.SyntaxKind.ObjectKeyword, + ts.SyntaxKind.StringKeyword, + ts.SyntaxKind.SymbolKeyword, + ts.SyntaxKind.UndefinedKeyword, + ts.SyntaxKind.UnknownKeyword, + ts.SyntaxKind.VoidKeyword, + ]); + + /** + * Walks a parsed type and collects every node that should become a link: + * keyword types, type-reference names (including qualified names like + * `vm.Module`), `typeof X` targets, `import('mod').X` qualifiers, `this`, + * and the `null` literal type. Structural names (parameters, properties) + * produce no candidates. + * + * @param {import('typescript').SourceFile} sourceFile + * @param {import('typescript').TypeNode} root + * @param {number} base Offset of the value within the synthetic source + * @returns {Array} + */ + const collectIdentifiers = (sourceFile, root, base) => { + const identifiers = []; + + /** + * @param {import('typescript').Node} node The node to record + * @param {string} [lookup] Override for the name to resolve + */ + const add = (node, lookup) => { + const start = node.getStart(sourceFile); + const text = sourceFile.text.slice(start, node.end); + + identifiers.push({ + start: start - base, + end: node.end - base, + text, + lookup: lookup ?? text, + }); + }; + + /** + * @param {import('typescript').Node} node + */ + const visitType = node => { + if (ts.isTypeReferenceNode(node)) { + add(node.typeName); + node.typeArguments?.forEach(visitType); + } else if (ts.isTypeQueryNode(node)) { + add(node.exprName); + node.typeArguments?.forEach(visitType); + } else if (ts.isImportTypeNode(node)) { + // `import('fs').Stats` resolves like the dotted name `fs.Stats`, + // but only the qualifier is linked + const moduleName = ts.isLiteralTypeNode(node.argument) + ? node.argument.literal.text + : undefined; + + if (node.qualifier) { + const qualifierText = sourceFile.text.slice( + node.qualifier.getStart(sourceFile), + node.qualifier.end + ); + + add( + node.qualifier, + moduleName ? `${moduleName}.${qualifierText}` : undefined + ); + } + + node.typeArguments?.forEach(visitType); + } else if ( + keywordKinds.has(node.kind) || + node.kind === ts.SyntaxKind.ThisType || + (ts.isLiteralTypeNode(node) && + node.literal.kind === ts.SyntaxKind.NullKeyword) + ) { + add(node); + } else { + ts.forEachChild(node, visitType); + } + }; + + visitType(root); + + return identifiers; + }; + + /** + * Parses a wrapped `type $T = ;` alias statement list and + * validates it round-trips to exactly one alias per value. + * + * @param {Array} values + * @returns {{ sourceFile: import('typescript').SourceFile, bases: Array } | undefined} + */ + const parseAliases = values => { + let source = ''; + const bases = []; + + values.forEach((value, index) => { + source += `type $T${index} = `; + bases.push(source.length); + source += `${value};\n`; + }); + + const sourceFile = ts.createSourceFile( + 'types.d.ts', + source, + ts.ScriptTarget.Latest, + false + ); + + const valid = + (sourceFile.parseDiagnostics ?? []).length === 0 && + sourceFile.statements.length === values.length && + sourceFile.statements.every(ts.isTypeAliasDeclaration); + + return valid ? { sourceFile, bases } : undefined; + }; + + /** + * Parses type values with the TypeScript compiler — all of them in ONE + * synthetic source file. Only when that batch fails to parse cleanly is + * each value re-parsed individually to isolate the invalid ones. + * + * @param {Array} values + * @returns {Array<{ identifiers: Array } | { error: true }>} + */ + const parseTypeValues = values => { + const batch = parseAliases(values); + + if (batch) { + return batch.sourceFile.statements.map((statement, index) => ({ + identifiers: collectIdentifiers( + batch.sourceFile, + statement.type, + batch.bases[index] + ), + })); + } + + return values.map(value => { + const single = parseAliases([value]); + + if (!single) { + return { error: true }; + } + + return { + identifiers: collectIdentifiers( + single.sourceFile, + single.sourceFile.statements[0].type, + single.bases[0] + ), + }; + }); + }; + + /** + * Resolves every `typeAnnotation` node in a file's tree, attaching + * `node.data.links` (and `node.data.parseError` when the annotation isn't + * valid TypeScript). + * + * An annotation whose whole value matches a type-map entry verbatim (which + * permits display-name keys like `zlib options`) becomes a single link. + * The rest are batch-parsed as TypeScript — one parse for the entire file — + * and each identifier inside them resolves through the same map tiers plus + * the dotted-name heuristic. + * + * @param {import('unist').Node} tree The file's mdast tree + * @param {Record} typeMap The mapping of types to links + * @param {string} path The file path, for diagnostics + */ + const resolveTypeAnnotations = (tree, typeMap, path) => { + /** @type {Array<{ type: string, value: string, data?: Object }>} */ + const pending = []; + + visit(tree, 'typeAnnotation', node => { + node.data = { links: [] }; + + const url = lookupTypeName(node.value, typeMap); + + if (url) { + node.data.links.push({ + start: 0, + end: node.value.length, + text: node.value, + href: url, + }); + } else { + pending.push(node); + } + }); + + parseTypeValues(pending.map(({ value }) => value)).forEach( + (result, index) => { + const node = pending[index]; + + if (result.error) { + node.data.parseError = true; + + logger.warn(`Invalid type annotation: {${node.value}}`, { + file: { path, position: node.position }, + }); + + return; + } + + for (const { start, end, text, lookup } of result.identifiers) { + const href = resolveTypeReference(lookup, typeMap); + + if (href) { + node.data.links.push({ start, end, text, href }); + } + } + + // The hast handlers slice the value by these ranges in order + node.data.links.sort((a, b) => a.start - b.start); + } + ); + }; + + return { parseTypeValues, resolveTypeAnnotations }; +}; diff --git a/src/generators/metadata/utils/transformers.mjs b/src/generators/metadata/utils/transformers.mjs index dd6de8b9..211a2ec3 100644 --- a/src/generators/metadata/utils/transformers.mjs +++ b/src/generators/metadata/utils/transformers.mjs @@ -1,6 +1,5 @@ import { DOC_MAN_BASE_URL, DOC_API_HEADING_TYPES } from '../constants.mjs'; import { slug } from './slugger.mjs'; -import { parseType } from './typeParser.mjs'; import { transformNodesToString } from '../../../utils/unist.mjs'; import BUILTIN_TYPE_MAP from '../maps/builtin.json' with { type: 'json' }; import MDN_TYPE_MAP from '../maps/mdn.json' with { type: 'json' }; @@ -21,64 +20,59 @@ export const transformUnixManualToLink = ( }; /** - * This method replaces plain text Types within the Markdown content into Markdown links - * that link to the actual relevant reference for such type (either internal or external link) + * Looks a type name up in the type maps: the toolchain-provided map first + * (Node.js types/modules), then the built-in (TC39) map, then MDN. Map keys + * may be arbitrary display names (e.g. `zlib options`), not just identifiers. * - * @param {string} type The plain type to be transformed into a Markdown link - * @param {Record} record The mapping of types to links - * @returns {string} The Markdown link as a string (formatted in Markdown) + * @param {string} name The type name to look up + * @param {Record} typeMap The toolchain mapping of types to links + * @returns {string} The reference URL or empty string if no match */ -export const transformTypeToReferenceLink = (type, record) => { - // Removes the wrapping curly braces that wrap the type references - // We keep the angle brackets `<>` intact here to parse Generics later - const typeInput = type - .trim() - .replace(/^\{(.*)\}$/, '$1') - .trim(); +export const lookupTypeName = (name, typeMap) => { + if (typeMap && name in typeMap) { + return typeMap[name]; + } - /** - * Handles the mapping (if there's a match) of the input text - * into the reference type from the API docs - * - * @param {string} lookupPiece - * @returns {string} The reference URL or empty string if no match - */ - const transformType = lookupPiece => { - // Transform Node.js type/module references into Markdown links - // that refer to other API docs pages within the Node.js API docs - if (record && lookupPiece in record) { - return record[lookupPiece]; - } + const key = name.toLowerCase(); - const key = lookupPiece.toLowerCase(); + // Check in our built-in map (i.e. TC39 objects) + if (key in BUILTIN_TYPE_MAP) { + return BUILTIN_TYPE_MAP[key]; + } - // Check in our built-in map (i.e. TC39 objects) - if (key in BUILTIN_TYPE_MAP) { - return BUILTIN_TYPE_MAP[key]; - } + // Check in MDN + if (key in MDN_TYPE_MAP) { + return MDN_TYPE_MAP[key]; + } - // Check in MDN - if (key in MDN_TYPE_MAP) { - return MDN_TYPE_MAP[key]; - } + return ''; +}; - // Transform Node.js types like 'vm.Something'. - if (lookupPiece.indexOf('.') >= 0) { - const [mod, ...pieces] = lookupPiece.split('.'); - const isClass = pieces.at(-1).match(/^[A-Z][a-z]/); +/** + * 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 + * to the module's page). + * + * @param {string} name The type identifier to resolve + * @param {Record} typeMap The toolchain mapping of types to links + * @returns {string} The reference URL or empty string if no match + */ +export const resolveTypeReference = (name, typeMap) => { + const url = lookupTypeName(name, typeMap); - return `${mod}.html#${isClass ? 'class-' : ''}${slug(lookupPiece)}`; - } + if (url) { + return url; + } - return ''; - }; + // Transform Node.js types like 'vm.Something'. + if (name.indexOf('.') >= 0) { + const [mod, ...pieces] = name.split('.'); + const isClass = pieces.at(-1).match(/^[A-Z][a-z]/); - const markdownLinks = parseType(typeInput, transformType); + return `${mod}.html#${isClass ? 'class-' : ''}${slug(name)}`; + } - // Return the replaced links or the original content if they all failed to be replaced - // Note that if some failed to get replaced, only the valid ones will be returned - // If no valid entry exists, we return the original string/type - return markdownLinks || type; + return ''; }; /** diff --git a/src/generators/metadata/utils/typeParser.mjs b/src/generators/metadata/utils/typeParser.mjs deleted file mode 100644 index e2fcd10b..00000000 --- a/src/generators/metadata/utils/typeParser.mjs +++ /dev/null @@ -1,243 +0,0 @@ -import { TYPE_OPENERS, TYPE_CLOSERS, PREFIXES } from '../constants.mjs'; - -/** True when the `>` at `i` is the tail of `=>` and shouldn't pop depth. */ -const isArrowTail = (str, i) => str[i] === '>' && str[i - 1] === '='; - -/** - * Walks `str` once, invoking `onToken(i, char)` for each character that - * sits at depth 0. `onToken` may return: - * - a number: advance the cursor by that many extra positions - * - `true`: stop iteration altogether - */ -const walkAtDepthZero = (str, onToken) => { - let depth = 0; - for (let i = 0; i < str.length; i++) { - const char = str[i]; - if (TYPE_OPENERS.has(char)) { - depth++; - } else if (TYPE_CLOSERS.has(char) && !isArrowTail(str, i)) { - depth--; - } - - if (depth === 0) { - const skip = onToken(i, char); - if (skip === true) { - return; - } - if (typeof skip === 'number') { - i += skip; - } - } - } -}; - -/** Format a known type as a Markdown link, or as a bare code span. */ -const formatType = (name, transformType) => { - const url = transformType(name); - return url ? `[\`<${name}>\`](${url})` : `\`<${name}>\``; -}; - -/** Resolve a sub-expression recursively, falling back to a code span. */ -const resolveOr = (part, transformType) => - parseType(part, transformType) || `\`<${part.trim()}>\``; - -/** - * Splits `str` by `separator` at depth 0. `separator` is a single - * character or the two-char string '=>'. - */ -const splitByOuterSeparator = (str, separator) => { - const isArrow = separator === '=>'; - const pieces = []; - let start = 0; - - walkAtDepthZero(str, (i, char) => { - const matches = isArrow - ? char === '=' && str[i + 1] === '>' - : char === separator; - if (!matches) { - return; - } - pieces.push(str.slice(start, i).trim()); - start = i + (isArrow ? 2 : 1); - if (isArrow) { - return 1; - } // skip the '>' - }); - - pieces.push(str.slice(start).trim()); - return pieces; -}; - -/** - * Strips redundant outer parens like `((A | B))` → `A | B`, while - * leaving `(A) | (B)` alone. - */ -const stripOuterParentheses = typeString => { - let s = typeString.trim(); - while (s.length >= 2 && s.startsWith('(') && s.endsWith(')')) { - // The outer `(` matches the outer `)` if depth doesn't hit 0 - // anywhere before the final character. - let wrapsWhole = true; - walkAtDepthZero(s.slice(0, -1), i => { - if (i > 0) { - wrapsWhole = false; - return true; // stop early - } - }); - if (!wrapsWhole) { - break; - } - s = s.slice(1, -1).trim(); - } - return s; -}; - -/** - * Finds the lowest-precedence top-level operator: `=>` beats `|` beats - * `&`. - */ -const findTopLevelOperator = str => { - let arrowIdx = -1; - let unionIdx = -1; - let intersectIdx = -1; - - walkAtDepthZero(str, (i, char) => { - if (char === '=' && str[i + 1] === '>') { - if (arrowIdx === -1) { - arrowIdx = i; - } - return 1; // skip '>' - } - if (char === '|' && unionIdx === -1) { - unionIdx = i; - } else if (char === '&' && intersectIdx === -1) { - intersectIdx = i; - } - }); - - if (arrowIdx !== -1) { - return { op: '=>', index: arrowIdx, width: 2 }; - } - - if (unionIdx !== -1) { - return { op: '|', index: unionIdx, width: 1 }; - } - - if (intersectIdx !== -1) { - return { op: '&', index: intersectIdx, width: 1 }; - } - - return null; -}; - -/** - * Parses the left side of an arrow function (e.g. `(a: string, b: number)` - * or `(x: T)`). Locates the parameter list as the last `(` that opens - * at depth 0. - */ -const parseFunctionSignature = (signature, transformType) => { - const trimmed = signature.trim(); - if (!trimmed.endsWith(')')) { - return signature; - } - - // Find the `(` that opens the outermost group ending at the final `)`. - let depth = 0; - let openIdx = -1; - for (let i = 0; i < trimmed.length; i++) { - const char = trimmed[i]; - if (depth === 0 && char === '(') { - openIdx = i; - } - if (TYPE_OPENERS.has(char)) { - depth++; - } else if (TYPE_CLOSERS.has(char) && !isArrowTail(trimmed, i)) { - depth--; - } - } - if (openIdx === -1) { - return signature; - } - - const prefix = trimmed.slice(0, openIdx); - const paramsString = trimmed.slice(openIdx + 1, -1); - if (!paramsString.trim()) { - return `${prefix}()`; - } - - const parsedArgs = splitByOuterSeparator(paramsString, ',').map(arg => { - const colonParts = splitByOuterSeparator(arg, ':'); - if (colonParts.length > 1) { - const paramName = colonParts[0]; - const paramType = colonParts.slice(1).join(':'); - return `${paramName}: ${resolveOr(paramType, transformType)}`; - } - return parseType(arg, transformType) || arg; - }); - - return `${prefix}(${parsedArgs.join(', ')})`; -}; - -/** - * Recursively parses TypeScript types into Markdown links. - * - * @param {string} typeString The type string to evaluate. - * @param {(name: string) => string | null | undefined} transformType Resolves a bare type name to a URL, or returns falsy. - * @returns {string | null} Markdown for the type, or null when the base type doesn't resolve. - */ -export const parseType = (typeString, transformType) => { - const trimmed = stripOuterParentheses(typeString); - if (!trimmed) { - return null; - } - - const op = findTopLevelOperator(trimmed); - if (op) { - if (op.op === '=>') { - const left = trimmed.slice(0, op.index).trim(); - const right = trimmed.slice(op.index + op.width).trim(); - const sig = parseFunctionSignature(left, transformType); - return `${sig} => ${resolveOr(right, transformType)}`; - } - - // Union / intersection - const parts = splitByOuterSeparator(trimmed, op.op); - const joiner = op.op === '|' ? ' | ' : ' & '; - return parts.map(p => resolveOr(p, transformType)).join(joiner); - } - - for (const prefix of PREFIXES) { - if (trimmed.startsWith(prefix)) { - const rest = trimmed.slice(prefix.length).trim(); - return `${prefix.trim()} ${resolveOr(rest, transformType)}`; - } - } - - // Strip a trailing `[]` for now; reapply on the way out. - const isArray = trimmed.endsWith('[]'); - const core = isArray ? trimmed.slice(0, -2).trim() : trimmed; - const arrayTail = isArray ? '[]' : ''; - - // Generic: `Base<...>`. - const ltIdx = core.indexOf('<'); - if (ltIdx !== -1 && core.endsWith('>')) { - const baseType = core.slice(0, ltIdx).trim(); - const innerType = core.slice(ltIdx + 1, -1).trim(); - const inner = splitByOuterSeparator(innerType, ',') - .map(arg => resolveOr(arg, transformType)) - .join(', '); - return `${formatType(baseType, transformType)}<${inner}>${arrayTail}`; - } - - // Plain base type. - if (!core.length) { - return null; - } - - const url = transformType(core); - if (!url) { - return null; - } - - return `[\`<${core}>\`](${url})${arrayTail}`; -}; diff --git a/src/generators/metadata/utils/visitors.mjs b/src/generators/metadata/utils/visitors.mjs index f260079d..b3c9f694 100644 --- a/src/generators/metadata/utils/visitors.mjs +++ b/src/generators/metadata/utils/visitors.mjs @@ -2,10 +2,7 @@ import { SKIP } from 'unist-util-visit'; -import { - transformTypeToReferenceLink, - transformUnixManualToLink, -} from './transformers.mjs'; +import { transformUnixManualToLink } from './transformers.mjs'; import { extractYamlContent, parseYAMLIntoMetadata } from './yaml.mjs'; import { QUERIES } from '../../../utils/queries/index.mjs'; import { getRemark as remark } from '../../../utils/remark.mjs'; @@ -54,20 +51,6 @@ const updateReferences = (query, transformer, node, parent) => { return [SKIP]; }; -/** - * Updates type references to be markdown links - * @param {import('@types/mdast').Text} node The text node - * @param {import('@types/mdast').Parent} parent The parent node - * @param {Record} typeMap The type mapping - */ -export const visitTextWithTypeNode = (node, parent, typeMap) => - updateReferences( - QUERIES.normalizeTypes, - type => transformTypeToReferenceLink(type, typeMap), - node, - parent - ); - /** * Updates unix manual references to be markdown links * @param {import('@types/mdast').Text} node The text node diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index 3b0deb3e..fb436e2e 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -117,4 +117,20 @@ main { } } } + + code.type { + /* Inline type annotations - one highlighted fragment per type */ + padding: 0 calc(var(--spacing, 0.25rem) * 1); + border-radius: calc(var(--spacing, 0.25rem) * 1); + + .type-link { + /* Links keep their token color; underline marks the affordance */ + color: inherit; + text-decoration: underline dotted; + + &:hover { + text-decoration: underline; + } + } + } } diff --git a/src/utils/queries/__tests__/index.test.mjs b/src/utils/queries/__tests__/index.test.mjs index 992c9162..a57762a0 100644 --- a/src/utils/queries/__tests__/index.test.mjs +++ b/src/utils/queries/__tests__/index.test.mjs @@ -33,61 +33,6 @@ describe('QUERIES', () => { strictEqual(QUERIES.standardYamlFrontmatter.test(content), false); }); }); - - describe('normalizeTypes', () => { - it('matches basic types', () => { - const content = '{string}'; - QUERIES.normalizeTypes.lastIndex = 0; - ok(QUERIES.normalizeTypes.test(content)); - }); - - it('matches complex types with generics', () => { - const content1 = '{Readonly}'; - const content2 = '{Map}'; - - QUERIES.normalizeTypes.lastIndex = 0; - ok( - QUERIES.normalizeTypes.test(content1), - 'Should match Readonly' - ); - QUERIES.normalizeTypes.lastIndex = 0; - ok(QUERIES.normalizeTypes.test(content2), 'Should match Map<...>'); - }); - - it('matches complex union types with parentheses', () => { - const content = '{(string|number)}'; - QUERIES.normalizeTypes.lastIndex = 0; - ok( - QUERIES.normalizeTypes.test(content), - 'Should match union with parentheses' - ); - }); - }); - - describe('linksWithTypes', () => { - it('matches basic type links', () => { - const content = '[``](https://mdn...)'; - QUERIES.linksWithTypes.lastIndex = 0; - ok(QUERIES.linksWithTypes.test(content)); - }); - - it('matches complex type links with generics', () => { - const content1 = - '[``](https://mdn...)<[``](https://mdn...)>'; - QUERIES.linksWithTypes.lastIndex = 0; - ok( - QUERIES.linksWithTypes.test(content1), - 'Should match generic type link' - ); - }); - - it('matches complex type links with unions', () => { - const content2 = - '<([``](https://mdn...)|[``](https://mdn...))>'; - QUERIES.linksWithTypes.lastIndex = 0; - ok(QUERIES.linksWithTypes.test(content2), 'Should match union type link'); - }); - }); }); describe('UNIST', () => { @@ -114,29 +59,39 @@ describe('UNIST', () => { expected: true, }, { - name: 'direct type link pattern', + name: 'direct type annotation pattern', node: createTree('list', [ createTree('listItem', [ - createTree('paragraph', [ - createTree('link', [createTree('inlineCode', '')]), - ]), + createTree('paragraph', [createTree('typeAnnotation', 'Type')]), ]), ]), expected: true, }, { - name: 'inlineCode + space + type link pattern', + name: 'inlineCode + space + type annotation pattern', node: createTree('list', [ createTree('listItem', [ createTree('paragraph', [ createTree('inlineCode', 'foo'), createTree('text', ' '), - createTree('link', [createTree('text', '')]), + createTree('typeAnnotation', 'Bar'), ]), ]), ]), expected: true, }, + { + name: 'inlineCode without a type annotation', + node: createTree('list', [ + createTree('listItem', [ + createTree('paragraph', [ + createTree('inlineCode', 'foo'), + createTree('text', ' just prose'), + ]), + ]), + ]), + expected: false, + }, { name: 'non-matching content', node: createTree('list', [ @@ -144,7 +99,7 @@ describe('UNIST', () => { createTree('paragraph', [ createTree('inlineCode', 'not a valid prop'), createTree('text', ' '), - createTree('link', [createTree('text', '')]), + createTree('typeAnnotation', 'Bar'), ]), ]), ]), @@ -182,12 +137,10 @@ describe('UNIST', () => { expected: true, }, { - name: 'direct type link pattern', + name: 'direct type annotation pattern', node: createTree('list', [ createTree('listItem', [ - createTree('paragraph', [ - createTree('link', [createTree('inlineCode', '')]), - ]), + createTree('paragraph', [createTree('typeAnnotation', 'Type')]), ]), ]), expected: true, @@ -211,7 +164,7 @@ describe('UNIST', () => { createTree('paragraph', [ createTree('inlineCode', 'not a valid prop'), createTree('text', ' '), - createTree('link', [createTree('text', '')]), + createTree('typeAnnotation', 'Bar'), ]), ]), ]), diff --git a/src/utils/queries/index.mjs b/src/utils/queries/index.mjs index bdf94cfb..abe289fb 100644 --- a/src/utils/queries/index.mjs +++ b/src/utils/queries/index.mjs @@ -7,11 +7,6 @@ import { isTypedList } from './utils.mjs'; export const QUERIES = { // Fixes the references to Markdown pages into the API documentation markdownUrl: /^(?![+a-zA-Z]+:)([^#?]+)\.md(#.+)?$/, - // ReGeX to match the {Type} (API type references) - normalizeTypes: /(\{|<)(?! )[^{}]+(?! )(\}|>)/g, - // ReGex to match the type API type references that got already parsed - // so that they can be transformed into HTML links - linksWithTypes: /\[`<[^{}]+>`\]\((\S+)\)/g, // ReGeX for handling Stability Indexes Metadata stabilityIndex: /^Stability: ([0-5](?:\.[0-3])?)(?:\s*-\s*)?(.*)$/s, // ReGeX for handling the Stability Index Prefix @@ -42,13 +37,6 @@ export const UNIST = { isYamlNode: ({ type, value }) => type === 'html' && QUERIES.yamlInnerContent.test(value), - /** - * @param {import('@types/mdast').Text} text - * @returns {boolean} - */ - isTextWithType: ({ type, value }) => - type === 'text' && QUERIES.normalizeTypes.test(value), - /** * @param {import('@types/mdast').Text} text * @returns {boolean} @@ -56,13 +44,6 @@ export const UNIST = { isTextWithUnixManual: ({ type, value }) => type === 'text' && QUERIES.unixManualPage.test(value), - /** - * @param {import('@types/mdast').Html} html - * @returns {boolean} - */ - isHtmlWithType: ({ type, value }) => - type === 'html' && QUERIES.linksWithTypes.test(value), - /** * @param {import('@types/mdast').Link} link * @returns {boolean} @@ -103,9 +84,7 @@ export const UNIST = { list.children?.[0]?.children?.[0]?.children ?? []; return ( - secondNode?.value?.trim() === '' && - thirdNode?.type === 'link' && - thirdNode?.children?.[0]?.value?.[0] === '<' + secondNode?.value?.trim() === '' && thirdNode?.type === 'typeAnnotation' ); } diff --git a/src/utils/queries/utils.mjs b/src/utils/queries/utils.mjs index fc3037eb..698294f1 100644 --- a/src/utils/queries/utils.mjs +++ b/src/utils/queries/utils.mjs @@ -27,11 +27,8 @@ export const isTypedList = list => { return 2; } - // Direct type link: - if ( - firstNode.type === 'link' && - firstNode.children?.[0]?.value?.startsWith('<') - ) { + // Direct type annotation: {Type} + if (firstNode.type === 'typeAnnotation') { return 2; } diff --git a/src/utils/remark.mjs b/src/utils/remark.mjs index 58550945..8c88297e 100644 --- a/src/utils/remark.mjs +++ b/src/utils/remark.mjs @@ -15,6 +15,11 @@ import { unified } from 'unified'; import syntaxHighlighter, { highlighter } from './highlighter.mjs'; import { lazy } from './misc.mjs'; +import { + typeAnnotationToHast, + typeAnnotationToHighlightedHast, +} from './type-annotations/hast.mjs'; +import remarkTypeAnnotations from './type-annotations/remark.mjs'; import { AST_NODE_TYPES } from '../generators/jsx-ast/constants.mjs'; import transformAlerts from '../generators/jsx-ast/utils/plugins/alerts.mjs'; import transformElements from '../generators/jsx-ast/utils/plugins/transformer.mjs'; @@ -23,9 +28,15 @@ const passThrough = ['element', ...Object.values(AST_NODE_TYPES.MDX)]; /** * Retrieves an instance of Remark configured to parse GFM (GitHub Flavored Markdown) + * plus `{...}` type annotations (see `./type-annotations`), which only exist + * in non-MDX files — the MDX pipeline below never registers them. */ export const getRemark = lazy(() => - unified().use(remarkParse).use(remarkGfm).use(remarkStringify) + unified() + .use(remarkParse) + .use(remarkTypeAnnotations) + .use(remarkGfm) + .use(remarkStringify) ); /** @@ -50,7 +61,11 @@ export const getRemarkRehype = lazy(() => // as these are nodes we manually created during the rehype process // We also allow dangerous HTML to be passed through, since we have HTML within our Markdown // and we trust the sources of the Markdown files - .use(remarkRehype, { allowDangerousHtml: true, passThrough }) + .use(remarkRehype, { + allowDangerousHtml: true, + passThrough, + handlers: { typeAnnotation: typeAnnotationToHast }, + }) // We allow dangerous HTML to be passed through, since we have HTML within our Markdown // and we trust the sources of the Markdown files .use(rehypeStringify, { allowDangerousHtml: true }) @@ -67,7 +82,12 @@ export const getRemarkRehypeWithShiki = lazy(() => // as these are nodes we manually created during the rehype process // We also allow dangerous HTML to be passed through, since we have HTML within our Markdown // and we trust the sources of the Markdown files - .use(remarkRehype, { allowDangerousHtml: true, passThrough }) + .use(remarkRehype, { + allowDangerousHtml: true, + passThrough, + // legacy-html gets the minimal (unhighlighted) type rendering + handlers: { typeAnnotation: typeAnnotationToHast }, + }) // This is a custom ad-hoc within the Shiki Rehype plugin, used to highlight code // and transform them into HAST nodes .use(syntaxHighlighter) @@ -90,7 +110,12 @@ export const getRemarkRecma = lazy(() => // as these are nodes we manually created during the generation process // We also allow dangerous HTML to be passed through, since we have HTML within our Markdown // and we trust the sources of the Markdown files - .use(remarkRehype, { allowDangerousHtml: true, passThrough }) + .use(remarkRehype, { + allowDangerousHtml: true, + passThrough, + // The web pipeline gets Shiki-highlighted types with embedded links + handlers: { typeAnnotation: typeAnnotationToHighlightedHast }, + }) // Any `raw` HTML in the markdown must be converted to AST in order for Recma to understand it .use(rehypeRaw, { passThrough }) .use(() => singletonShiki) diff --git a/src/utils/type-annotations/__tests__/hast.test.mjs b/src/utils/type-annotations/__tests__/hast.test.mjs new file mode 100644 index 00000000..35c98e3b --- /dev/null +++ b/src/utils/type-annotations/__tests__/hast.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { toHtml } from 'hast-util-to-html'; + +import { + typeAnnotationToHast, + typeAnnotationToHighlightedHast, +} from '../hast.mjs'; + +// A minimal mdast-util-to-hast state — the handlers only use patch/applyData +const state = { patch: () => {}, applyData: (_, result) => result }; + +const makeNode = (value, data) => ({ type: 'typeAnnotation', value, data }); + +describe('typeAnnotationToHast', () => { + it('renders one with anchors over link ranges', () => { + const node = makeNode('Buffer|string', { + links: [ + { start: 0, end: 6, text: 'Buffer', href: 'buffer.html#class-buffer' }, + { start: 7, end: 13, text: 'string', href: 'mdn.io/string' }, + ], + }); + + assert.equal( + toHtml(typeAnnotationToHast(state, node)), + '' + + 'Buffer' + + '|' + + 'string' + + '' + ); + }); + + it('renders unresolved types as plain code', () => { + const node = makeNode('SomethingUnknown', { links: [] }); + + assert.equal( + toHtml(typeAnnotationToHast(state, node)), + 'SomethingUnknown' + ); + }); + + it('renders parse errors as plain code', () => { + const node = makeNode('not a type!', { links: [], parseError: true }); + + assert.equal( + toHtml(typeAnnotationToHast(state, node)), + 'not a type!' + ); + }); + + it('handles missing data', () => { + const node = makeNode('string'); + + assert.equal( + toHtml(typeAnnotationToHast(state, node)), + 'string' + ); + }); +}); + +describe('typeAnnotationToHighlightedHast', () => { + it('produces one inline with shiki tokens and embedded links', () => { + const node = makeNode('Promise', { + links: [{ start: 0, end: 7, text: 'Promise', href: 'mdn.io/promise' }], + }); + + const element = typeAnnotationToHighlightedHast(state, node); + + assert.equal(element.tagName, 'code'); + assert.ok(element.properties.class.includes('type')); + assert.ok(element.properties.class.includes('shiki')); + + const html = toHtml(element); + + // The resolved identifier is an anchor inside the highlighted fragment + assert.match(html, /]*>(<[^>]+>)*Promise/); + // The rest of the type is present as highlighted tokens + assert.match(html, /string/); + // No block
 wrapper survives
+    assert.doesNotMatch(html, /
 {
+    const node = makeNode('vm.Module', {
+      links: [
+        { start: 0, end: 9, text: 'vm.Module', href: 'vm.html#class-module' },
+      ],
+    });
+
+    const html = toHtml(typeAnnotationToHighlightedHast(state, node));
+
+    // Shiki tokenizes `vm`, `.`, `Module` separately; the whole range links
+    const anchors = html.match(/href="vm.html#class-module"/g);
+    assert.ok(anchors.length >= 1);
+    assert.match(html.replace(/<[^>]+>/g, ''), /^vm\.Module$/);
+  });
+
+  it('falls back to plain code when nothing resolved', () => {
+    const node = makeNode('Whatever', { links: [] });
+
+    assert.equal(
+      toHtml(typeAnnotationToHighlightedHast(state, node)),
+      'Whatever'
+    );
+  });
+
+  it('falls back to plain code on parse errors', () => {
+    const node = makeNode(') weird (', { links: [], parseError: true });
+
+    assert.equal(
+      toHtml(typeAnnotationToHighlightedHast(state, node)),
+      ') weird ('
+    );
+  });
+});
diff --git a/src/utils/type-annotations/__tests__/remark.test.mjs b/src/utils/type-annotations/__tests__/remark.test.mjs
new file mode 100644
index 00000000..60070c84
--- /dev/null
+++ b/src/utils/type-annotations/__tests__/remark.test.mjs
@@ -0,0 +1,127 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import remarkGfm from 'remark-gfm';
+import remarkParse from 'remark-parse';
+import remarkStringify from 'remark-stringify';
+import { unified } from 'unified';
+import { selectAll } from 'unist-util-select';
+
+import remarkTypeAnnotations from '../remark.mjs';
+
+const processor = unified()
+  .use(remarkParse)
+  .use(remarkTypeAnnotations)
+  .use(remarkGfm)
+  .use(remarkStringify);
+
+const annotationsIn = markdown =>
+  selectAll('typeAnnotation', processor.parse(markdown));
+
+const valuesIn = markdown => annotationsIn(markdown).map(node => node.value);
+
+describe('remarkTypeAnnotations', () => {
+  it('parses a simple annotation in prose', () => {
+    assert.deepEqual(valuesIn('A {string} here.'), ['string']);
+  });
+
+  it('parses multiple annotations in one paragraph', () => {
+    assert.deepEqual(valuesIn('{string} and {Buffer|Blob} and {integer}'), [
+      'string',
+      'Buffer|Blob',
+      'integer',
+    ]);
+  });
+
+  it('supports nested braces (object literal types)', () => {
+    assert.deepEqual(valuesIn('Takes {Record} maps.'), [
+      'Record',
+    ]);
+  });
+
+  it('supports generics with angle brackets', () => {
+    assert.deepEqual(valuesIn('Returns {Promise} always.'), [
+      'Promise',
+    ]);
+  });
+
+  it('supports arrow function types', () => {
+    assert.deepEqual(valuesIn('A {(err: Error) => void} callback.'), [
+      '(err: Error) => void',
+    ]);
+  });
+
+  it('decodes legacy character escapes', () => {
+    assert.deepEqual(valuesIn('An array {string\\[]} of them.'), ['string[]']);
+  });
+
+  it('decodes escaped pipes inside GFM table cells', () => {
+    const markdown = [
+      '| Option | Type |',
+      '| ------ | ---- |',
+      '| `flag` | {string\\|number} |',
+    ].join('\n');
+
+    assert.deepEqual(valuesIn(markdown), ['string|number']);
+  });
+
+  it('normalizes interior line breaks to spaces', () => {
+    assert.deepEqual(valuesIn('Some {string |\nBuffer} union.'), [
+      'string | Buffer',
+    ]);
+  });
+
+  it('does not match an escaped opening brace', () => {
+    assert.deepEqual(valuesIn('Literal \\{not a type} braces.'), []);
+  });
+
+  it('does not match template literal prose (`${...}`)', () => {
+    assert.deepEqual(valuesIn('Use ${foo} for interpolation.'), []);
+  });
+
+  it('does not match empty braces', () => {
+    assert.deepEqual(valuesIn('An empty {} object.'), []);
+  });
+
+  it('leaves an unbalanced brace as literal text', () => {
+    const tree = processor.parse('An { unclosed brace.');
+
+    assert.deepEqual(selectAll('typeAnnotation', tree), []);
+    assert.match(
+      selectAll('text', tree)
+        .map(node => node.value)
+        .join(''),
+      /\{ unclosed brace\./
+    );
+  });
+
+  it('does not match inside code spans', () => {
+    assert.deepEqual(valuesIn('Use `{string}` literally.'), []);
+  });
+
+  it('does not match inside fenced code blocks', () => {
+    assert.deepEqual(valuesIn('```js\nconst a = {string: 1};\n```'), []);
+  });
+
+  it('consumes URLs in braces as (invalid) annotations', () => {
+    // "ANYTHING within {} is a Type" — this parses but won't be valid TS
+    assert.deepEqual(valuesIn('See {http://example.com} for more.'), [
+      'http://example.com',
+    ]);
+  });
+
+  it('parses annotations inside emphasis and headings', () => {
+    assert.deepEqual(valuesIn('_a {string} inside_\n\n## Head {integer}'), [
+      'string',
+      'integer',
+    ]);
+  });
+
+  it('round-trips through stringify', () => {
+    const output = processor.stringify(
+      processor.parse('Returns: {Promise} on success.')
+    );
+
+    assert.match(output, /\{Promise\}/);
+  });
+});
diff --git a/src/utils/type-annotations/hast.mjs b/src/utils/type-annotations/hast.mjs
new file mode 100644
index 00000000..603201cc
--- /dev/null
+++ b/src/utils/type-annotations/hast.mjs
@@ -0,0 +1,113 @@
+'use strict';
+
+import { highlighter } from '../highlighter.mjs';
+
+/**
+ * Slices a type's text by its resolved link ranges into hast children —
+ * plain text segments interleaved with `` anchors.
+ *
+ * @param {string} value The type text
+ * @param {Array<{ start: number, end: number, href: string }>} links Sorted, disjoint ranges
+ * @returns {Array}
+ */
+const buildLinkedChildren = (value, links) => {
+  const children = [];
+  let cursor = 0;
+
+  for (const { start, end, href } of links) {
+    if (start > cursor) {
+      children.push({ type: 'text', value: value.slice(cursor, start) });
+    }
+
+    children.push({
+      type: 'element',
+      tagName: 'a',
+      properties: { href, className: ['type-link'] },
+      children: [{ type: 'text', value: value.slice(start, end) }],
+    });
+
+    cursor = end;
+  }
+
+  if (cursor < value.length) {
+    children.push({ type: 'text', value: value.slice(cursor) });
+  }
+
+  return children;
+};
+
+/**
+ * Minimal mdast→hast handler for `typeAnnotation` nodes: one
+ * `` whose resolved identifiers are plain `` links.
+ * No syntax highlighting — used by the legacy generators.
+ *
+ * @param {import('mdast-util-to-hast').State} state
+ * @param {import('mdast').Node} node
+ * @returns {import('hast').Element}
+ */
+export const typeAnnotationToHast = (state, node) => {
+  const result = {
+    type: 'element',
+    tagName: 'code',
+    properties: { className: ['type'] },
+    children: buildLinkedChildren(node.value, node.data?.links ?? []),
+  };
+
+  state.patch(node, result);
+
+  return state.applyData(node, result);
+};
+
+/**
+ * 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.
+ *
+ * Falls back to the minimal handler when the type failed to parse or nothing
+ * resolved (no point paying for highlighting then).
+ *
+ * @param {import('mdast-util-to-hast').State} state
+ * @param {import('mdast').Node} node
+ * @returns {import('hast').Element}
+ */
+export const typeAnnotationToHighlightedHast = (state, node) => {
+  const links = node.data?.links ?? [];
+
+  if (node.data?.parseError || links.length === 0) {
+    return typeAnnotationToHast(state, node);
+  }
+
+  const root = highlighter.shiki.codeToHast(node.value, {
+    lang: 'typescript',
+    theme: highlighter.shiki.getLoadedThemes()[0],
+    decorations: links.map(({ start, end, href }) => ({
+      start,
+      end,
+      tagName: 'a',
+      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);
+};
diff --git a/src/utils/type-annotations/mdast.mjs b/src/utils/type-annotations/mdast.mjs
new file mode 100644
index 00000000..203a6b21
--- /dev/null
+++ b/src/utils/type-annotations/mdast.mjs
@@ -0,0 +1,69 @@
+'use strict';
+
+// A backslash escape before ASCII punctuation (CommonMark's escapable set).
+// The tokenizer captures the raw source, so legacy escapes like `{string\[]}`
+// and the mandatory `\|` inside GFM table cells must be decoded here.
+const CHARACTER_ESCAPE = /\\([!-/:-@[-`{-~])/g;
+
+/**
+ * Creates the mdast-util-from-markdown extension that compiles
+ * `typeAnnotation` tokens into `{ type: 'typeAnnotation', value }` nodes.
+ *
+ * The stored `value` is the canonical, single-line, unescaped TypeScript type
+ * text; link offsets computed later are relative to it.
+ *
+ * @returns {import('mdast-util-from-markdown').Extension}
+ */
+export const typeAnnotationFromMarkdown = () => ({
+  enter: {
+    /**
+     * @this {import('mdast-util-from-markdown').CompileContext}
+     * @param {import('micromark-util-types').Token} token
+     */
+    typeAnnotation(token) {
+      this.enter({ type: 'typeAnnotation', value: '' }, token);
+    },
+  },
+  exit: {
+    /**
+     * @this {import('mdast-util-from-markdown').CompileContext}
+     * @param {import('micromark-util-types').Token} token
+     */
+    typeAnnotationValue(token) {
+      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;
+    },
+    /**
+     * @this {import('mdast-util-from-markdown').CompileContext}
+     * @param {import('micromark-util-types').Token} token
+     */
+    typeAnnotation(token) {
+      const node = this.stack.at(-1);
+
+      node.value = node.value
+        .replace(/\s+/g, ' ')
+        .trim()
+        .replace(CHARACTER_ESCAPE, '$1');
+
+      this.exit(token);
+    },
+  },
+});
+
+/**
+ * Creates the mdast-util-to-markdown extension that serializes
+ * `typeAnnotation` nodes back to their `{...}` source form.
+ *
+ * @returns {import('mdast-util-to-markdown').Options}
+ */
+export const typeAnnotationToMarkdown = () => ({
+  handlers: {
+    /**
+     * @param {{ value: string }} node
+     */
+    typeAnnotation: node => `{${node.value}}`,
+  },
+});
diff --git a/src/utils/type-annotations/remark.mjs b/src/utils/type-annotations/remark.mjs
new file mode 100644
index 00000000..82279efe
--- /dev/null
+++ b/src/utils/type-annotations/remark.mjs
@@ -0,0 +1,24 @@
+'use strict';
+
+import {
+  typeAnnotationFromMarkdown,
+  typeAnnotationToMarkdown,
+} from './mdast.mjs';
+import { typeAnnotationSyntax } from './syntax.mjs';
+
+/**
+ * Remark plugin that teaches the parser to treat any balanced `{...}` span in
+ * text as a `typeAnnotation` node whose value is a TypeScript type expression.
+ *
+ * Only registered on the non-MDX pipeline — in MDX, `{...}` is a real
+ * expression and is handled by remark-mdx instead.
+ *
+ * @this {import('unified').Processor}
+ */
+export default function remarkTypeAnnotations() {
+  const data = this.data();
+
+  (data.micromarkExtensions ??= []).push(typeAnnotationSyntax());
+  (data.fromMarkdownExtensions ??= []).push(typeAnnotationFromMarkdown());
+  (data.toMarkdownExtensions ??= []).push(typeAnnotationToMarkdown());
+}
diff --git a/src/utils/type-annotations/syntax.mjs b/src/utils/type-annotations/syntax.mjs
new file mode 100644
index 00000000..86b8c1d3
--- /dev/null
+++ b/src/utils/type-annotations/syntax.mjs
@@ -0,0 +1,139 @@
+'use strict';
+
+const LEFT_CURLY_BRACE = '{'.charCodeAt(0);
+const RIGHT_CURLY_BRACE = '}'.charCodeAt(0);
+const DOLLAR_SIGN = '$'.charCodeAt(0);
+
+/**
+ * micromark preprocesses `\r`, `\n` and `\r\n` into virtual codes below -2
+ * (and represents EOF as `null`).
+ *
+ * @param {import('micromark-util-types').Code} code
+ */
+const isLineEnding = code => code !== null && code < -2;
+
+/**
+ * A `{` directly after `$` is prose about template literals, not a type
+ * annotation.
+ *
+ * @param {import('micromark-util-types').Code} code
+ */
+const previous = code => code !== DOLLAR_SIGN;
+
+/**
+ * Tokenizes a balanced `{...}` span as a single `typeAnnotation` token.
+ *
+ * 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.
+ *
+ * @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 => {
+    effects.enter('typeAnnotation');
+    effects.enter('typeAnnotationMarker');
+    effects.consume(code);
+    effects.exit('typeAnnotationMarker');
+
+    return between;
+  };
+
+  /**
+   * 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 => {
+    if (code === null) {
+      return nok(code);
+    }
+
+    if (code === RIGHT_CURLY_BRACE && depth === 0) {
+      return hasContent ? finish(code) : nok(code);
+    }
+
+    if (isLineEnding(code)) {
+      effects.enter('lineEnding');
+      effects.consume(code);
+      effects.exit('lineEnding');
+
+      return between;
+    }
+
+    effects.enter('typeAnnotationValue');
+
+    return value(code);
+  };
+
+  /**
+   * Inside a value chunk.
+   *
+   * @type {import('micromark-util-types').State}
+   */
+  const value = code => {
+    if (
+      code === null ||
+      isLineEnding(code) ||
+      (code === RIGHT_CURLY_BRACE && depth === 0)
+    ) {
+      effects.exit('typeAnnotationValue');
+
+      return between(code);
+    }
+
+    if (code === LEFT_CURLY_BRACE) {
+      depth++;
+    } else if (code === RIGHT_CURLY_BRACE) {
+      depth--;
+    }
+
+    hasContent = true;
+    effects.consume(code);
+
+    return value;
+  };
+
+  /**
+   * At the closing `}`.
+   *
+   * @type {import('micromark-util-types').State}
+   */
+  const finish = code => {
+    effects.enter('typeAnnotationMarker');
+    effects.consume(code);
+    effects.exit('typeAnnotationMarker');
+    effects.exit('typeAnnotation');
+
+    return ok;
+  };
+
+  return start;
+}
+
+/**
+ * Creates the micromark syntax extension that recognizes `{...}` type
+ * annotations in text.
+ *
+ * @returns {import('micromark-util-types').Extension}
+ */
+export const typeAnnotationSyntax = () => ({
+  text: {
+    [LEFT_CURLY_BRACE]: {
+      name: 'typeAnnotation',
+      tokenize: tokenizeTypeAnnotation,
+      previous,
+    },
+  },
+});
diff --git a/src/utils/unist.mjs b/src/utils/unist.mjs
index fe3bccff..51f222d6 100644
--- a/src/utils/unist.mjs
+++ b/src/utils/unist.mjs
@@ -20,6 +20,8 @@ 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':