Skip to content

Commit f97991b

Browse files
feat(metadata): support advanced generics using recursion (nodejs#763)
Co-authored-by: avivkeller <me@aviv.sh> Signed-off-by: avivkeller <me@aviv.sh>
1 parent a793466 commit f97991b

4 files changed

Lines changed: 284 additions & 112 deletions

File tree

src/generators/metadata/constants.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// These openers/closers are used to determine if a type string is well-formed
2+
export const TYPE_OPENERS = new Set(['<', '(', '{', '[']);
3+
export const TYPE_CLOSERS = new Set(['>', ')', '}', ']']);
4+
15
// On "About this Documentation", we define the stability indices, and thus
26
// we don't need to check it for stability references
37
export const IGNORE_STABILITY_STEMS = ['documentation'];
@@ -56,8 +60,5 @@ export const DOC_API_HEADING_TYPES = [
5660
},
5761
];
5862

59-
// This regex is used to match basic TypeScript generic types (e.g., Promise<string>)
60-
export const TYPE_GENERIC_REGEX = /^([^<]+)<([^>]+)>$/;
61-
6263
// This is the base URL of the Man7 documentation
6364
export const DOC_MAN_BASE_URL = 'http://man7.org/linux/man-pages/man';

src/generators/metadata/utils/__tests__/transformers.test.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,41 @@ describe('transformTypeToReferenceLink', () => {
7575
'[`<Map>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)&lt;[`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type), [`<number>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type)&gt; & [`<Array>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)&lt;[`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)&gt;'
7676
);
7777
});
78+
79+
it('should transform a function returning a Generic type', () => {
80+
strictEqual(
81+
transformTypeToReferenceLink('(err: Error) => Promise<boolean>', {}),
82+
'(err: [`<Error>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error)) =&gt; [`<Promise>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)&lt;[`<boolean>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)&gt;'
83+
);
84+
});
85+
86+
it('should respect precedence: Unions (|) are weaker than Intersections (&)', () => {
87+
strictEqual(
88+
transformTypeToReferenceLink('string | number & boolean', {}),
89+
'[`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) | [`<number>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type) & [`<boolean>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)'
90+
);
91+
});
92+
93+
it('should handle extreme nested combinations of functions, arrays, generics, unions, and intersections', () => {
94+
const input =
95+
'(str: string[]) => Promise<Map<string, number & string>, Map<string | number>>';
96+
97+
const expected =
98+
'(str: [`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)[]) =&gt; [`<Promise>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)&lt;[`<Map>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)&lt;[`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type), [`<number>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type) & [`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)&gt;, [`<Map>`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)&lt;[`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type) | [`<number>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#number_type)&gt;&gt;';
99+
100+
strictEqual(transformTypeToReferenceLink(input, {}), expected);
101+
});
102+
103+
it('should parse functions with array destructuring in callbacks returning functions with object destructuring', () => {
104+
const input =
105+
'(cb: ([first, second]: string[]) => void) => ({ id, name }: User) => boolean';
106+
107+
const expected =
108+
'(cb: ([first, second]: [`<string>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#string_type)[]) =&gt; `<void>`) =&gt; ({ id, name }: [`<User>`](userLink)) =&gt; [`<boolean>`](https://developer.mozilla.org/docs/Web/JavaScript/Data_structures#boolean_type)';
109+
110+
strictEqual(
111+
transformTypeToReferenceLink(input, { User: 'userLink' }),
112+
expected
113+
);
114+
});
78115
});

src/generators/metadata/utils/transformers.mjs

Lines changed: 7 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import {
2-
DOC_MAN_BASE_URL,
3-
DOC_API_HEADING_TYPES,
4-
TYPE_GENERIC_REGEX,
5-
} from '../constants.mjs';
1+
import { DOC_MAN_BASE_URL, DOC_API_HEADING_TYPES } from '../constants.mjs';
62
import { slug } from './slugger.mjs';
3+
import { parseType } from './typeParser.mjs';
74
import { transformNodesToString } from '../../../utils/unist.mjs';
85
import BUILTIN_TYPE_MAP from '../maps/builtin.json' with { type: 'json' };
96
import MDN_TYPE_MAP from '../maps/mdn.json' with { type: 'json' };
@@ -22,84 +19,7 @@ export const transformUnixManualToLink = (
2219
) => {
2320
return `[\`${text}\`](${DOC_MAN_BASE_URL}${sectionNumber}/${command}.${sectionNumber}${sectionLetter}.html)`;
2421
};
25-
/**
26-
* Safely splits the string by `|` or `&` at the top level (ignoring those
27-
* inside `< >`), and returns both the pieces and the separator used.
28-
*
29-
* @param {string} str The type string to split
30-
* @returns {{ pieces: string[], separator: string }} The split pieces and the separator string used to join them (` | ` or ` & `)
31-
*/
32-
const splitByOuterSeparator = str => {
33-
const pieces = [];
34-
let current = '';
35-
let depth = 0;
36-
let separator;
37-
38-
for (const char of str) {
39-
if (char === '<') {
40-
depth++;
41-
} else if (char === '>') {
42-
depth--;
43-
} else if ((char === '|' || char === '&') && depth === 0) {
44-
pieces.push(current);
45-
current = '';
46-
separator ??= ` ${char} `;
47-
continue;
48-
}
49-
current += char;
50-
}
51-
52-
pieces.push(current);
53-
return { pieces, separator };
54-
};
55-
56-
/**
57-
* Attempts to parse and format a basic Generic type (e.g., Promise<string>).
58-
* It also supports union and multi-parameter types within the generic brackets.
59-
*
60-
* @param {string} typePiece The plain type piece to be evaluated
61-
* @param {Function} transformType The function used to resolve individual types into links
62-
* @returns {string|null} The formatted Markdown link, or null if no match is found
63-
*/
64-
const formatBasicGeneric = (typePiece, transformType) => {
65-
const genericMatch = typePiece.match(TYPE_GENERIC_REGEX);
66-
67-
if (genericMatch) {
68-
const baseType = genericMatch[1].trim();
69-
const innerType = genericMatch[2].trim();
70-
71-
const baseResult = transformType(baseType.replace(/\[\]$/, ''));
72-
const baseFormatted = baseResult
73-
? `[\`<${baseType}>\`](${baseResult})`
74-
: `\`<${baseType}>\``;
7522

76-
// Split while capturing delimiters (| or ,) to preserve original syntax
77-
const parts = innerType.split(/([|,])/);
78-
79-
const innerFormatted = parts
80-
.map(part => {
81-
const trimmed = part.trim();
82-
// If it is a delimiter, return it as is
83-
if (trimmed === '|') {
84-
return ' | ';
85-
}
86-
87-
if (trimmed === ',') {
88-
return ', ';
89-
}
90-
91-
const innerRes = transformType(trimmed.replace(/\[\]$/, ''));
92-
return innerRes
93-
? `[\`<${trimmed}>\`](${innerRes})`
94-
: `\`<${trimmed}>\``;
95-
})
96-
.join('');
97-
98-
return `${baseFormatted}&lt;${innerFormatted}&gt;`;
99-
}
100-
101-
return null;
102-
};
10323
/**
10424
* This method replaces plain text Types within the Markdown content into Markdown links
10525
* that link to the actual relevant reference for such type (either internal or external link)
@@ -111,7 +31,10 @@ const formatBasicGeneric = (typePiece, transformType) => {
11131
export const transformTypeToReferenceLink = (type, record) => {
11232
// Removes the wrapping curly braces that wrap the type references
11333
// We keep the angle brackets `<>` intact here to parse Generics later
114-
const typeInput = type.replace(/[{}]/g, '');
34+
const typeInput = type
35+
.trim()
36+
.replace(/^\{(.*)\}$/, '$1')
37+
.trim();
11538

11639
/**
11740
* Handles the mapping (if there's a match) of the input text
@@ -150,32 +73,7 @@ export const transformTypeToReferenceLink = (type, record) => {
15073
return '';
15174
};
15275

153-
const { pieces: outerPieces, separator } = splitByOuterSeparator(typeInput);
154-
155-
const typePieces = outerPieces.map(piece => {
156-
// This is the content to render as the text of the Markdown link
157-
const trimmedPiece = piece.trim();
158-
159-
// 1. Attempt to format as a basic Generic type first
160-
const genericMarkdown = formatBasicGeneric(trimmedPiece, transformType);
161-
if (genericMarkdown) {
162-
return genericMarkdown;
163-
}
164-
165-
// 2. Fallback to the logic for plain types
166-
// This is what we will compare against the API types mappings
167-
// The ReGeX below is used to remove `[]` from the end of the type
168-
const result = transformType(trimmedPiece.replace(/\[\]$/, ''));
169-
170-
// If we have a valid result and the piece is not empty, we return the Markdown link
171-
if (trimmedPiece.length && result.length) {
172-
return `[\`<${trimmedPiece}>\`](${result})`;
173-
}
174-
});
175-
176-
// Filter out pieces that we failed to map and then join the valid ones
177-
// using the same separator that appeared in the original type string
178-
const markdownLinks = typePieces.filter(Boolean).join(separator);
76+
const markdownLinks = parseType(typeInput, transformType);
17977

18078
// Return the replaced links or the original content if they all failed to be replaced
18179
// Note that if some failed to get replaced, only the valid ones will be returned

0 commit comments

Comments
 (0)