@ng-doc/utils 22.0.0 → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -7,6 +7,9 @@ import { isElement as isElement$1 } from 'hast-util-is-element';
7
7
  import { toString } from 'hast-util-to-string';
8
8
  import { visitParents, SKIP } from 'unist-util-visit-parents';
9
9
  import rehypeShiki from '@shikijs/rehype';
10
+ import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
11
+ import { createHash } from 'node:crypto';
12
+ import { createHighlighter, bundledLanguages } from 'shiki';
10
13
  import { visit } from 'unist-util-visit';
11
14
  import GithubSlugger from 'github-slugger';
12
15
  import { hasProperty } from 'hast-util-has-property';
@@ -90,8 +93,472 @@ function isHeading(node) {
90
93
  return isElement$1(node, HEADINGS);
91
94
  }
92
95
 
96
+ /**
97
+ * Where a keyword may link inside code.
98
+ *
99
+ * The keywords plugin splits code into words and looks each one up. A word that happens to be a
100
+ * keyword is not always a reference to it: an object key (`group: 'Colors'`), a property after a
101
+ * dot, a string, an HTML attribute value or a file name in inline code (`vite.config.mjs`) only
102
+ * share its name. Highlighted code carries no token scopes (Shiki emits colours only, and adjacent
103
+ * tokens of one colour are merged), so this module reads the position of a word from the code text
104
+ * itself with a small lexer: strings and comments, the characters around the word, and the names
105
+ * the code declares. The result only depends on the code, never on the keyword set, so the words a
106
+ * page records as used keywords are exactly the ones that may link.
107
+ */
108
+ const WORD_CHARACTER = /[*\p{L}\w$@-]/u;
109
+ const IDENTIFIER_BEFORE = /[\p{L}\w$@-]+$/u;
110
+ const WHOLE_KEYWORD = /^\s*[*\p{L}\w$@-]+(?:[.#][\p{L}\w-]+)?(?:\?[\w=&]+)?\s*$/u;
111
+ /** Characters that make inline code read as TypeScript rather than as a name, path or prose. */
112
+ const CODE_PUNCTUATION = /[(){}[\]<>|:=@]/;
113
+ /** Inline code that starts like a TypeScript declaration. */
114
+ const DECLARATION_START = /^\s*(?:export|import|class|interface|type|const|let|var|function|enum|abstract|declare)\s/;
115
+ /** Inline code that starts with an HTML tag. */
116
+ const HTML_START = /^\s*<\/?[A-Za-z]/;
117
+ /** Syntax words. They never link, even before `(` (`if (`, `function (`, `super(`). */
118
+ const SYNTAX_WORDS = new Set([
119
+ 'await',
120
+ 'break',
121
+ 'case',
122
+ 'catch',
123
+ 'continue',
124
+ 'debugger',
125
+ 'delete',
126
+ 'do',
127
+ 'else',
128
+ 'false',
129
+ 'finally',
130
+ 'for',
131
+ 'function',
132
+ 'if',
133
+ 'instanceof',
134
+ 'new',
135
+ 'null',
136
+ 'return',
137
+ 'super',
138
+ 'switch',
139
+ 'this',
140
+ 'throw',
141
+ 'true',
142
+ 'try',
143
+ 'typeof',
144
+ 'void',
145
+ 'while',
146
+ 'with',
147
+ 'yield',
148
+ ]);
149
+ /**
150
+ * TypeScript keywords and modifiers that are also valid names. They link only when called, so that
151
+ * RxJS `of(…)` and `from(…)` still link while `readonly` in `readonly size = input()` or `from`
152
+ * in an import does not.
153
+ */
154
+ const RESERVED_WORDS = new Set([
155
+ 'abstract',
156
+ 'accessor',
157
+ 'any',
158
+ 'as',
159
+ 'asserts',
160
+ 'async',
161
+ 'bigint',
162
+ 'boolean',
163
+ 'class',
164
+ 'const',
165
+ 'constructor',
166
+ 'declare',
167
+ 'default',
168
+ 'enum',
169
+ 'export',
170
+ 'extends',
171
+ 'from',
172
+ 'get',
173
+ 'implements',
174
+ 'import',
175
+ 'in',
176
+ 'infer',
177
+ 'interface',
178
+ 'is',
179
+ 'keyof',
180
+ 'let',
181
+ 'module',
182
+ 'namespace',
183
+ 'never',
184
+ 'number',
185
+ 'object',
186
+ 'of',
187
+ 'out',
188
+ 'override',
189
+ 'package',
190
+ 'private',
191
+ 'protected',
192
+ 'public',
193
+ 'readonly',
194
+ 'require',
195
+ 'satisfies',
196
+ 'set',
197
+ 'static',
198
+ 'string',
199
+ 'symbol',
200
+ 'type',
201
+ 'undefined',
202
+ 'unique',
203
+ 'unknown',
204
+ 'var',
205
+ ]);
206
+ /** The characters before a name in a type position: `: Type`, `A | B`, `A & B`, `Generic<Type`. */
207
+ const TYPE_CONTEXT = [':', '|', '&', '<'];
208
+ /** The words before a name in a type position, or before a type alias's name. */
209
+ const TYPE_WORDS = ['type', 'extends', 'keyof', 'typeof'];
210
+ /** What a character of code is, in a scan's marks. */
211
+ const CODE = 0;
212
+ const STRING = 1;
213
+ const COMMENT = 2;
214
+ /** The text of a TypeScript template literal, outside its `${…}` expressions. */
215
+ const TEMPLATE = 3;
216
+ /** An Angular control flow block in an inline template (`@if`, `@for`, `@defer`). */
217
+ const CONTROL_FLOW = /^@[a-z]+$/;
218
+ const NOT_A_REFERENCE = Object.freeze({ link: false });
219
+ const REFERENCE = Object.freeze({ link: true });
220
+ /**
221
+ * The positions of the words of inline code, or `undefined` when no word of it may link.
222
+ *
223
+ * Inline code links when it is one keyword reference as a whole (`NgDocPage`, `*GuidePage#anchor`,
224
+ * `NgDocPage.title`, with an optional `?query`), or when it reads as TypeScript: it contains code
225
+ * punctuation (a call, generics, a union, an array type, a decorator, a type annotation) or starts
226
+ * with a declaration. Then its words link where they would in a TypeScript code block. Anything
227
+ * else (a file name like `vite.ng-doc.config.mjs`, a path, a command, prose, an HTML tag) stays
228
+ * plain text.
229
+ * @param text - The text of the inline code element.
230
+ */
231
+ function scanInlineCode(text) {
232
+ if (WHOLE_KEYWORD.test(text)) {
233
+ return { whole: true, position: () => REFERENCE };
234
+ }
235
+ if (HTML_START.test(text) || !(CODE_PUNCTUATION.test(text) || DECLARATION_START.test(text))) {
236
+ return undefined;
237
+ }
238
+ const scan = scanTypeScript(text);
239
+ // A message or a sentence with some punctuation (`Refusing to overwrite unowned output`) is not
240
+ // code: TypeScript never has three plain names in a row.
241
+ return isProse(text, scan.masked) ? undefined : scan;
242
+ }
243
+ /**
244
+ * The positions of the words of a code block.
245
+ * @param text - The text of the block's `code` element.
246
+ * @param mode - How the block's language is read.
247
+ */
248
+ function scanCodeBlock(text, mode) {
249
+ return mode === 'html' ? scanHtml(text) : scanTypeScript(text);
250
+ }
251
+ /**
252
+ * TypeScript: a word links unless it is inside a string or a comment (in a template literal, only
253
+ * Angular control flow blocks and the names of custom elements link), a syntax word, a reserved
254
+ * word that is not called, a property after a dot (it then links only as a whole `Owner.member`
255
+ * keyword), a declared name (an object key, a property, a parameter or a variable before `:`,
256
+ * `?:`, `!:` or `=`), or a name the code binds itself (`const`/`let`/`var`, or an import from a
257
+ * relative module).
258
+ * @param text - The code.
259
+ */
260
+ function scanTypeScript(text) {
261
+ const masked = maskTypeScript(text);
262
+ const locals = localBindings(text, masked);
263
+ return {
264
+ whole: false,
265
+ masked,
266
+ position(start, end, key) {
267
+ if (masked[start] === TEMPLATE) {
268
+ // A template literal is usually an inline Angular template: its control flow blocks and
269
+ // the names of custom elements (`<ng-content>`, `</ng-container>`; a standard element
270
+ // such as `<input>` has no dash) refer to Angular and components, while its attributes
271
+ // and text are the template's own.
272
+ const before = text[start - 1] ?? '';
273
+ const element = key.includes('-') && (before === '<' || (before === '/' && text[start - 2] === '<'));
274
+ const controlFlow = CONTROL_FLOW.test(key) && !WORD_CHARACTER.test(before);
275
+ return element || controlFlow ? REFERENCE : NOT_A_REFERENCE;
276
+ }
277
+ if (masked[start] !== CODE) {
278
+ return NOT_A_REFERENCE;
279
+ }
280
+ const previous = previousSignificant(text, start, masked);
281
+ const next = nextSignificant(text, end, masked);
282
+ const nextCharacter = text[next] ?? '';
283
+ if (SYNTAX_WORDS.has(key) || (RESERVED_WORDS.has(key) && nextCharacter !== '(')) {
284
+ return NOT_A_REFERENCE;
285
+ }
286
+ if (text[previous] === '.' && text[previous - 1] !== '.') {
287
+ // `owner.member` and `owner?.member`
288
+ const ownerEnd = text[previous - 1] === '?' ? previous - 1 : previous;
289
+ const owner = text.slice(0, ownerEnd).match(IDENTIFIER_BEFORE)?.[0];
290
+ const word = text.slice(start, end);
291
+ return owner && !/[.#]/.test(word)
292
+ ? { link: true, keyword: `${owner}.${key.toLowerCase()}` }
293
+ : NOT_A_REFERENCE;
294
+ }
295
+ if (isDeclaredName(text, masked, previous, next)) {
296
+ return NOT_A_REFERENCE;
297
+ }
298
+ return locals.has(key) ? NOT_A_REFERENCE : REFERENCE;
299
+ },
300
+ };
301
+ }
302
+ /**
303
+ * Whether the word between `previous` and `next` is a name being declared rather than used.
304
+ * @param text - The code.
305
+ * @param masked - The marks of its strings and comments.
306
+ * @param previous - The index of the significant character before the word, or -1.
307
+ * @param next - The index of the significant character after the word.
308
+ */
309
+ function isDeclaredName(text, masked, previous, next) {
310
+ const after = text[next];
311
+ const afterNext = text[nextSignificant(text, next + 1, masked)];
312
+ if (after === ':' && text[next + 1] !== ':') {
313
+ // `cond ? Value : other` and `case Value:` use the name.
314
+ const ternary = text[previous] === '?' && text[previous + 1] !== '.';
315
+ return !ternary && previousWord(text, previous) !== 'case';
316
+ }
317
+ if ((after === '?' || after === '!') && afterNext === ':') {
318
+ return true;
319
+ }
320
+ if (after === '?' && (afterNext === ')' || afterNext === ',')) {
321
+ return true;
322
+ }
323
+ if (after === '=' && text[next + 1] !== '=' && text[next + 1] !== '>') {
324
+ // A type before `=` (`size: NgDocSize = …`, `<T extends Base = Base>`) is used, and
325
+ // `type Name = …` declares a type alias, which is linked like a class.
326
+ return (!TYPE_CONTEXT.includes(text[previous] ?? '') &&
327
+ !TYPE_WORDS.includes(previousWord(text, previous)));
328
+ }
329
+ return false;
330
+ }
331
+ /**
332
+ * HTML: a word links unless it is inside a comment or an attribute value. Which keywords may link
333
+ * in HTML at all (the selectors of components and directives) is decided by their languages.
334
+ * @param text - The code.
335
+ */
336
+ function scanHtml(text) {
337
+ const masked = maskHtml(text);
338
+ return {
339
+ whole: false,
340
+ position: (start) => (masked[start] === CODE ? REFERENCE : NOT_A_REFERENCE),
341
+ };
342
+ }
343
+ /**
344
+ * Marks the characters of TypeScript strings and comments. The `${…}` expressions of template
345
+ * literals stay code. Regular expression literals are not recognised.
346
+ * @param text - The code.
347
+ */
348
+ function maskTypeScript(text) {
349
+ const masked = new Uint8Array(text.length);
350
+ // The brace depth of every open `${` of a template literal, innermost last.
351
+ const templates = [];
352
+ let depth = 0;
353
+ let index = 0;
354
+ const string = (quote) => {
355
+ const start = index++;
356
+ while (index < text.length && text[index] !== quote && text[index] !== '\n') {
357
+ index += text[index] === '\\' ? 2 : 1;
358
+ }
359
+ masked.fill(STRING, start, Math.min(++index, text.length));
360
+ };
361
+ const template = () => {
362
+ const start = index++;
363
+ while (index < text.length && text[index] !== '`') {
364
+ if (text[index] === '\\') {
365
+ index += 2;
366
+ }
367
+ else if (text[index] === '$' && text[index + 1] === '{') {
368
+ masked.fill(TEMPLATE, start, index);
369
+ templates.push(depth);
370
+ index += 2;
371
+ return;
372
+ }
373
+ else {
374
+ index++;
375
+ }
376
+ }
377
+ masked.fill(TEMPLATE, start, Math.min(++index, text.length));
378
+ };
379
+ while (index < text.length) {
380
+ const character = text[index];
381
+ if (character === '/' && text[index + 1] === '/') {
382
+ const end = text.indexOf('\n', index);
383
+ const stop = end < 0 ? text.length : end;
384
+ masked.fill(COMMENT, index, stop);
385
+ index = stop;
386
+ }
387
+ else if (character === '/' && text[index + 1] === '*') {
388
+ const end = text.indexOf('*/', index + 2);
389
+ const stop = end < 0 ? text.length : end + 2;
390
+ masked.fill(COMMENT, index, stop);
391
+ index = stop;
392
+ }
393
+ else if (character === "'" || character === '"') {
394
+ string(character);
395
+ }
396
+ else if (character === '`') {
397
+ template();
398
+ }
399
+ else if (character === '{') {
400
+ depth++;
401
+ index++;
402
+ }
403
+ else if (character === '}') {
404
+ if (templates.length && templates[templates.length - 1] === depth) {
405
+ // The end of a `${…}` expression: the template literal goes on.
406
+ templates.pop();
407
+ template();
408
+ }
409
+ else {
410
+ depth--;
411
+ index++;
412
+ }
413
+ }
414
+ else {
415
+ index++;
416
+ }
417
+ }
418
+ return masked;
419
+ }
420
+ /**
421
+ * Marks the characters of HTML comments and attribute values.
422
+ * @param text - The code.
423
+ */
424
+ function maskHtml(text) {
425
+ const masked = new Uint8Array(text.length);
426
+ let index = 0;
427
+ while (index < text.length) {
428
+ if (text.startsWith('<!--', index)) {
429
+ const end = text.indexOf('-->', index + 4);
430
+ const stop = end < 0 ? text.length : end + 3;
431
+ masked.fill(COMMENT, index, stop);
432
+ index = stop;
433
+ }
434
+ else if (text[index] === '<' && /[A-Za-z/]/.test(text[index + 1] ?? '')) {
435
+ index = maskTag(text, index + 1, masked);
436
+ }
437
+ else {
438
+ index++;
439
+ }
440
+ }
441
+ return masked;
442
+ }
443
+ /**
444
+ * Marks the attribute values of the tag that starts at `index` and returns the index after it.
445
+ * @param text - The code.
446
+ * @param index - The index after the tag's `<`.
447
+ * @param masked - The marks.
448
+ */
449
+ function maskTag(text, index, masked) {
450
+ while (index < text.length && text[index] !== '>') {
451
+ if (text[index] !== '=') {
452
+ index++;
453
+ continue;
454
+ }
455
+ index++;
456
+ while (text[index] === ' ' || text[index] === '\t' || text[index] === '\n')
457
+ index++;
458
+ const quote = text[index];
459
+ const start = index;
460
+ if (quote === '"' || quote === "'") {
461
+ const end = text.indexOf(quote, index + 1);
462
+ index = end < 0 ? text.length : end + 1;
463
+ }
464
+ else {
465
+ while (index < text.length && !/[\s>]/.test(text[index] ?? ''))
466
+ index++;
467
+ }
468
+ masked.fill(STRING, start, index);
469
+ }
470
+ return index + 1;
471
+ }
472
+ /**
473
+ * The names the code binds itself: `const`, `let` and `var` declarations and the names imported
474
+ * from relative modules. A use of such a name refers to the code's own binding, not to a keyword
475
+ * of the same name (`const config = …; export default config;`).
476
+ * @param text - The code.
477
+ * @param masked - The marks of its strings and comments.
478
+ */
479
+ function localBindings(text, masked) {
480
+ const names = new Set();
481
+ for (const match of text.matchAll(/\b(?:const|let|var)\s+([\p{L}_$][\p{L}\w$]*)/gu)) {
482
+ if (!masked[match.index])
483
+ names.add(match[1] ?? '');
484
+ }
485
+ for (const match of text.matchAll(/\bimport\s+(?:type\s+)?([^;]*?)\s+from\s+(['"])\.{1,2}\/[^'"]*\2/gu)) {
486
+ if (masked[match.index])
487
+ continue;
488
+ for (const name of (match[1] ?? '').matchAll(/([\p{L}_$][\p{L}\w$]*)(?:\s+as\s+([\p{L}_$][\p{L}\w$]*))?/gu)) {
489
+ const local = name[2] ?? name[1] ?? '';
490
+ if (local !== 'type' && local !== 'as')
491
+ names.add(local);
492
+ }
493
+ }
494
+ return names;
495
+ }
496
+ /**
497
+ * Whether inline code reads as a sentence: three plain names in a row, separated by spaces only,
498
+ * outside strings and comments.
499
+ * @param text - The inline code.
500
+ * @param masked - The marks of its strings and comments.
501
+ */
502
+ function isProse(text, masked) {
503
+ for (const match of text.matchAll(/[\p{L}_$][\p{L}\w$]*(?: [\p{L}_$][\p{L}\w$]*){2,}/gu)) {
504
+ const words = match[0].split(' ');
505
+ const plain = words.filter((word) => !SYNTAX_WORDS.has(word) && !RESERVED_WORDS.has(word));
506
+ if (!masked[match.index] && plain.length >= 3) {
507
+ return true;
508
+ }
509
+ }
510
+ return false;
511
+ }
512
+ /**
513
+ * The index of the last character before `index` that is neither whitespace nor in a comment, or
514
+ * -1.
515
+ * @param text - The code.
516
+ * @param index - The index to look back from.
517
+ * @param masked - The marks of its strings and comments.
518
+ */
519
+ function previousSignificant(text, index, masked) {
520
+ let position = index - 1;
521
+ while (position >= 0 && insignificant(text, position, masked))
522
+ position--;
523
+ return position;
524
+ }
525
+ /**
526
+ * The index of the first character at or after `index` that is neither whitespace nor in a
527
+ * comment.
528
+ * @param text - The code.
529
+ * @param index - The index to look from.
530
+ * @param masked - The marks of its strings and comments.
531
+ */
532
+ function nextSignificant(text, index, masked) {
533
+ let position = index;
534
+ while (position < text.length && insignificant(text, position, masked))
535
+ position++;
536
+ return position;
537
+ }
538
+ /**
539
+ * Whether the character at `index` is whitespace or part of a comment.
540
+ * @param text - The code.
541
+ * @param index - The index of the character.
542
+ * @param masked - The marks of its strings and comments.
543
+ */
544
+ function insignificant(text, index, masked) {
545
+ return masked[index] === COMMENT || /\s/.test(text[index] ?? '');
546
+ }
547
+ /**
548
+ * The word that ends at `index` (inclusive), or an empty string.
549
+ * @param text - The code.
550
+ * @param index - The index of the word's last character.
551
+ */
552
+ function previousWord(text, index) {
553
+ let start = index;
554
+ while (start >= 0 && WORD_CHARACTER.test(text[start] ?? ''))
555
+ start--;
556
+ return text.slice(start + 1, index + 1);
557
+ }
558
+
93
559
  const ALWAYS_ALLOWED_LANGUAGES = ['typescript', 'ts', 'angular-ts'];
94
- const LANGUAGES = ['typescript', 'ts', 'angular-ts', ...KEYWORD_ALLOWED_LANGUAGES];
560
+ const HTML_LANGUAGES = [...KEYWORD_ALLOWED_LANGUAGES];
561
+ const LANGUAGES = [...ALWAYS_ALLOWED_LANGUAGES, ...HTML_LANGUAGES];
95
562
  const SPLIT_REGEXP = /([*\p{L}\w$@-]+(?:[.#][\p{L}\w-]+)?(?:\?[\w=&]+)?)/u;
96
563
  const MATCH_KEYWORD_REGEXP = /(?<key>[*\p{L}\w$@-]+)((?<delimiter>[.#])(?<anchor>[\p{L}\w-]+))?(?<queryParams>\?[\w=&]+)?/u;
97
564
  /**
@@ -107,25 +574,49 @@ function keywordsPlugin(config) {
107
574
  const lang = asArray(node.properties?.['className'] ?? [])
108
575
  .find((className) => className.startsWith('language-'))
109
576
  ?.replace('language-', '') ?? '';
110
- if (isInlineCode || LANGUAGES.includes(lang)) {
111
- visitParents(node, 'text', (node, ancestors) => {
112
- if (hasLinkAncestor(ancestors)) {
113
- return;
114
- }
115
- const parent = ancestors[ancestors.length - 1];
116
- const index = parent.children.indexOf(node);
117
- // Parse the text for words that we can convert to links
118
- const nodes = getNodes(node, parent, isInlineCode, config, lang);
119
- // Replace the text node with the links and leftover text nodes
120
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
121
- // @ts-ignore
122
- Array.prototype.splice.apply(parent.children, [index, 1].concat(nodes));
123
- // Do not visit this node's children or the newly added nodes
124
- return [SKIP, index + nodes.length];
125
- });
577
+ if (!isInlineCode && !LANGUAGES.includes(lang)) {
578
+ return;
579
+ }
580
+ const offsets = textOffsets(node);
581
+ const text = Array.from(offsets.keys())
582
+ .map((textNode) => textNode.value)
583
+ .join('');
584
+ const scan = isInlineCode
585
+ ? scanInlineCode(text)
586
+ : scanCodeBlock(text, HTML_LANGUAGES.includes(lang) ? 'html' : 'typescript');
587
+ if (!scan) {
588
+ return;
126
589
  }
590
+ visitParents(node, 'text', (node, ancestors) => {
591
+ if (hasLinkAncestor(ancestors)) {
592
+ return;
593
+ }
594
+ const parent = ancestors[ancestors.length - 1];
595
+ const index = parent.children.indexOf(node);
596
+ // Parse the text for words that we can convert to links
597
+ const nodes = getNodes(node, parent, isInlineCode, config, lang, scan, offsets.get(node) ?? 0);
598
+ // Replace the text node with the links and leftover text nodes
599
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
600
+ // @ts-ignore
601
+ Array.prototype.splice.apply(parent.children, [index, 1].concat(nodes));
602
+ // Do not visit this node's children or the newly added nodes
603
+ return [SKIP, index + nodes.length];
604
+ });
127
605
  });
128
606
  }
607
+ /**
608
+ * The offset of every text node of a code element in the element's text, in document order.
609
+ * @param node - The code element.
610
+ */
611
+ function textOffsets(node) {
612
+ const offsets = new Map();
613
+ let offset = 0;
614
+ visitParents(node, 'text', (text) => {
615
+ offsets.set(text, offset);
616
+ offset += text.value.length;
617
+ });
618
+ return offsets;
619
+ }
129
620
  /**
130
621
  *
131
622
  * @param node
@@ -133,19 +624,31 @@ function keywordsPlugin(config) {
133
624
  * @param isInlineCode
134
625
  * @param config
135
626
  * @param language
627
+ * @param scan - The positions of the code element's words.
628
+ * @param offset - The offset of the text node in the code element's text.
136
629
  */
137
- function getNodes(node, parent, isInlineCode, config, language) {
630
+ function getNodes(node, parent, isInlineCode, config, language, scan, offset) {
138
631
  const { addUsedKeyword, getKeyword } = config;
632
+ let start = offset;
139
633
  return toString(node)
140
634
  .split(SPLIT_REGEXP)
141
635
  .map((word) => {
636
+ const wordStart = start;
637
+ start += word.length;
142
638
  const match = word.match(MATCH_KEYWORD_REGEXP);
143
639
  if (!match) {
144
640
  return { type: 'text', value: word };
145
641
  }
146
642
  const { key = '', delimiter = '', anchor = '', queryParams = '', } = match.groups;
147
- const usedKeyword = `${key}${delimiter}${anchor?.toLowerCase()}`;
148
- const rootKeyword = getKeyword?.(key);
643
+ const position = scan.position(wordStart, wordStart + word.length, key);
644
+ // Not a reference to a keyword: an object key, a string, a file name and the like
645
+ if (!position.link) {
646
+ return { type: 'text', value: word };
647
+ }
648
+ // A member after a dot is looked up as a whole `Owner.member`
649
+ const isMember = position.keyword !== undefined;
650
+ const usedKeyword = position.keyword ?? `${key}${delimiter}${anchor?.toLowerCase()}`;
651
+ const rootKeyword = isMember ? undefined : getKeyword?.(key);
149
652
  const keyword = getKeyword?.(usedKeyword);
150
653
  const isGuideKeyword = key.startsWith('*');
151
654
  const isLanguageAllowed = (!keyword?.languages && ALWAYS_ALLOWED_LANGUAGES.includes(language)) ||
@@ -161,7 +664,9 @@ function getNodes(node, parent, isInlineCode, config, language) {
161
664
  addUsedKeyword?.(usedKeyword);
162
665
  const notFoundGuideKeyword = isGuideKeyword && !keyword;
163
666
  const notFoundApiAnchorKeyword = !!rootKeyword && !!anchor && !keyword;
164
- if (getKeyword && isInlineCode && (notFoundGuideKeyword || notFoundApiAnchorKeyword)) {
667
+ // Only inline code that is one keyword reference as a whole fails on a missing one: in code,
668
+ // `*` and `.` are operators, not keyword syntax.
669
+ if (getKeyword && scan.whole && (notFoundGuideKeyword || notFoundApiAnchorKeyword)) {
165
670
  throw new Error(`Route with keyword "${word}" is missing.`);
166
671
  }
167
672
  // Convert code tag to a link tag or highlight it with a class
@@ -180,7 +685,7 @@ function getNodes(node, parent, isInlineCode, config, language) {
180
685
  }
181
686
  // Add link inside the code if it's a link to the API entity
182
687
  return keyword
183
- ? createLinkNode(isInlineCode ? keyword.title : word, keyword.path, keyword.type, keyword.description)
688
+ ? createLinkNode(isInlineCode && !isMember ? keyword.title : word, keyword.path, keyword.type, keyword.description)
184
689
  : { type: 'text', value: word };
185
690
  });
186
691
  }
@@ -259,10 +764,10 @@ function addHeadingAnchors(route) {
259
764
  });
260
765
  }
261
766
 
262
- const languagePrefix = 'language-';
767
+ const languagePrefix$1 = 'language-';
263
768
  /**
264
- * One `@shikijs/rehype` transformer per theme pair in this thread. It loads its highlighter once
265
- * (every bundled language, the themes and the WASM), where the plain plugin, created again for
769
+ * One `@shikijs/rehype` transformer per theme pair and languages in this thread. It loads its
770
+ * highlighter once (every bundled language, the extra ones, the themes and the WASM), where the plain plugin, created again for
266
771
  * every document, asks for the highlighter and loads every language and theme into it again on
267
772
  * each call. A transformer whose highlighter failed to load is forgotten, so the next document
268
773
  * tries again and fails with the same error, as the plain plugin does.
@@ -283,8 +788,8 @@ const highlighters = new Map();
283
788
  * loads Shiki once the cache shows that its themes load.
284
789
  */
285
790
  function cachedShikiPlugin(settings) {
286
- const { cache, themes, identity, defaultLanguage } = settings;
287
- const id = `${themes.light}\n${themes.dark}`;
791
+ const { cache, themes, languages, identity, defaultLanguage } = settings;
792
+ const id = `${themes.light}\n${themes.dark}\n${languages ?? ''}\n${settings.grammars ?? 'all'}`;
288
793
  return async (tree) => {
289
794
  const blocks = [];
290
795
  visit(tree, 'element', (node, index, parent) => {
@@ -295,10 +800,10 @@ function cachedShikiPlugin(settings) {
295
800
  return;
296
801
  const classes = head.properties['className'];
297
802
  const languageClass = Array.isArray(classes)
298
- ? classes.find((item) => typeof item === 'string' && item.startsWith(languagePrefix))
803
+ ? classes.find((item) => typeof item === 'string' && item.startsWith(languagePrefix$1))
299
804
  : undefined;
300
805
  const lang = typeof languageClass === 'string'
301
- ? languageClass.slice(languagePrefix.length)
806
+ ? languageClass.slice(languagePrefix$1.length)
302
807
  : defaultLanguage;
303
808
  if (!lang)
304
809
  return;
@@ -308,11 +813,18 @@ function cachedShikiPlugin(settings) {
308
813
  blocks.push({
309
814
  node,
310
815
  parent,
311
- key: cache.key({ options: identity, themes, lang, meta, code }),
816
+ key: cache.key({
817
+ options: identity,
818
+ themes,
819
+ ...(languages === undefined ? {} : { languages }),
820
+ lang,
821
+ meta,
822
+ code,
823
+ }),
312
824
  });
313
825
  });
314
826
  let highlighter;
315
- const load = async () => (highlighter ??= await highlighterFor(id, settings.options));
827
+ const load = async () => (highlighter ??= await highlighterFor(id, settings.transformer));
316
828
  if (cache.loads?.(themes) !== true)
317
829
  await load();
318
830
  const values = [];
@@ -338,12 +850,12 @@ function cachedShikiPlugin(settings) {
338
850
  });
339
851
  };
340
852
  }
341
- function highlighterFor(id, options) {
853
+ function highlighterFor(id, transformer) {
342
854
  const known = highlighters.get(id);
343
855
  if (known)
344
856
  return known;
345
857
  const created = (async () => {
346
- const transform = rehypeShiki(options());
858
+ const transform = transformer();
347
859
  // An empty tree makes the transformer load its highlighter, so that a failure to load is told
348
860
  // apart from a block's own failure.
349
861
  await transform({ type: 'root', children: [] });
@@ -362,13 +874,14 @@ async function highlight(transform, node) {
362
874
  await transform(root);
363
875
  return JSON.stringify(root.children);
364
876
  }
365
- /** A cached value as fresh nodes, or undefined when it is not a JSON array of HAST elements. */
877
+ /**
878
+ * A cached value as fresh nodes, or undefined when it is not a JSON array of HAST elements or of
879
+ * roots of elements (what `@shikijs/rehype` puts in place of a block).
880
+ */
366
881
  function fragment(value) {
367
882
  try {
368
883
  const parsed = JSON.parse(value);
369
- return Array.isArray(parsed) &&
370
- parsed.length > 0 &&
371
- parsed.every((item) => isNode(item) && item.type === 'element')
884
+ return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isReplacement)
372
885
  ? parsed
373
886
  : undefined;
374
887
  }
@@ -376,6 +889,18 @@ function fragment(value) {
376
889
  return undefined;
377
890
  }
378
891
  }
892
+ function isReplacement(value) {
893
+ if (isNode(value))
894
+ return value.type === 'element';
895
+ if (!value || typeof value !== 'object' || Array.isArray(value))
896
+ return false;
897
+ const node = value;
898
+ const children = node['children'];
899
+ return (node['type'] === 'root' &&
900
+ Array.isArray(children) &&
901
+ children.length > 0 &&
902
+ children.every((child) => isNode(child) && child.type === 'element'));
903
+ }
379
904
  function isNode(value) {
380
905
  if (!value || typeof value !== 'object' || Array.isArray(value))
381
906
  return false;
@@ -563,6 +1088,185 @@ function wrapTablePlugin () {
563
1088
  };
564
1089
  }
565
1090
 
1091
+ /**
1092
+ * Loading only the Shiki grammars that code blocks use, with the same result as loading every
1093
+ * bundled grammar.
1094
+ *
1095
+ * `@shikijs/rehype` sets its highlighter up with every bundled grammar, which takes seconds. A
1096
+ * grammar's highlighting depends only on the grammars it can reach: the languages it embeds
1097
+ * (`embeddedLangs`, and `embeddedLangsLazy`, which it uses once they are loaded), the scopes its
1098
+ * rules include, and the grammars that inject into its scopes (`injectTo`, matched by scope
1099
+ * prefix, in registration order). {@link ShikiGrammars.closure} is that set, closed under the
1100
+ * same rules, plus every grammar that shares an injection target with an injector in it, so the
1101
+ * injections of a scope are always registered together. Loaded in the order in which loading every
1102
+ * bundled grammar registers them (the first mention of each name), a closure gives the
1103
+ * highlighter the same grammars, injections and injection order for its languages as loading
1104
+ * everything, whatever else was loaded before: a highlighter only grows by whole closures, and
1105
+ * each scope's grammars are complete when the scope is first loaded.
1106
+ */
1107
+ class ShikiGrammars {
1108
+ constructor(registrations) {
1109
+ this.byName = new Map();
1110
+ this.byScope = new Map();
1111
+ this.injectors = new Map();
1112
+ this.order = new Map();
1113
+ this.closures = new Map();
1114
+ this.loaded = new WeakMap();
1115
+ const mention = (name) => {
1116
+ if (!this.order.has(name))
1117
+ this.order.set(name, this.order.size);
1118
+ };
1119
+ for (const registration of registrations) {
1120
+ mention(registration.name);
1121
+ for (const embedded of registration.embeddedLangs ?? [])
1122
+ mention(embedded);
1123
+ if (this.byScope.has(registration.scopeName))
1124
+ continue;
1125
+ this.byScope.set(registration.scopeName, registration);
1126
+ this.byName.set(registration.name, registration);
1127
+ for (const alias of registration.aliases ?? [])
1128
+ this.byName.set(alias, registration);
1129
+ for (const target of registration.injectTo ?? []) {
1130
+ const list = this.injectors.get(target) ?? [];
1131
+ list.push(registration);
1132
+ this.injectors.set(target, list);
1133
+ }
1134
+ }
1135
+ }
1136
+ /**
1137
+ * The bundled grammars, in the order `@shikijs/rehype` registers them: every bundled language
1138
+ * name, each with the registrations of its module.
1139
+ */
1140
+ static async create() {
1141
+ const modules = await Promise.all(Object.keys(bundledLanguages).map((name) => bundledLanguages[name]()));
1142
+ return new ShikiGrammars(modules.flatMap((module) => module.default));
1143
+ }
1144
+ /**
1145
+ * The grammars that a block of `language` can reach, in registration order; empty for a
1146
+ * language that no bundled grammar names (it falls back to plain text, as before).
1147
+ * @param language
1148
+ */
1149
+ closure(language) {
1150
+ const known = this.closures.get(language);
1151
+ if (known)
1152
+ return known;
1153
+ const set = new Set();
1154
+ const queue = [this.byName.get(language)];
1155
+ while (queue.length) {
1156
+ const registration = queue.pop();
1157
+ if (!registration || set.has(registration))
1158
+ continue;
1159
+ set.add(registration);
1160
+ for (const name of [
1161
+ ...(registration.embeddedLangs ?? []),
1162
+ ...(registration.embeddedLangsLazy ?? []),
1163
+ ])
1164
+ queue.push(this.byName.get(name));
1165
+ for (const scope of includedScopes(registration))
1166
+ queue.push(this.byScope.get(scope));
1167
+ for (const prefix of prefixes(registration.scopeName))
1168
+ queue.push(...(this.injectors.get(prefix) ?? []));
1169
+ for (const target of registration.injectTo ?? [])
1170
+ queue.push(...(this.injectors.get(target) ?? []));
1171
+ }
1172
+ const closure = [...set].sort((left, right) => this.order.get(left.name) - this.order.get(right.name));
1173
+ this.closures.set(language, closure);
1174
+ return closure;
1175
+ }
1176
+ /**
1177
+ * Loads the closures of `languages` that `highlighter` does not hold yet.
1178
+ * @param highlighter
1179
+ * @param languages
1180
+ */
1181
+ load(highlighter, languages) {
1182
+ let loaded = this.loaded.get(highlighter);
1183
+ if (!loaded)
1184
+ this.loaded.set(highlighter, (loaded = new Set()));
1185
+ const missing = new Set();
1186
+ for (const language of languages)
1187
+ for (const registration of this.closure(language))
1188
+ if (!loaded.has(registration.name))
1189
+ missing.add(registration);
1190
+ if (!missing.size)
1191
+ return;
1192
+ const batch = [...missing].sort((left, right) => this.order.get(left.name) - this.order.get(right.name));
1193
+ highlighter.loadLanguageSync(batch);
1194
+ for (const registration of batch)
1195
+ loaded.add(registration.name);
1196
+ }
1197
+ }
1198
+ let grammars;
1199
+ /** The bundled grammars of this thread, read once. */
1200
+ function shikiGrammars() {
1201
+ grammars ??= ShikiGrammars.create().catch((error) => {
1202
+ grammars = undefined;
1203
+ throw error;
1204
+ });
1205
+ return grammars;
1206
+ }
1207
+ /**
1208
+ * A highlighter with the given themes and no language: {@link ShikiGrammars.load} adds them.
1209
+ * @param themes
1210
+ */
1211
+ function createGrammarlessHighlighter(themes) {
1212
+ return createHighlighter({ themes, langs: [] });
1213
+ }
1214
+ const languagePrefix = 'language-';
1215
+ /**
1216
+ * The language of every block `@shikijs/rehype` highlights in `tree` (a `pre` whose first child is
1217
+ * a `code` element with properties): its `language-*` class, or `defaultLanguage`.
1218
+ * @param tree
1219
+ * @param defaultLanguage
1220
+ */
1221
+ function blockLanguages(tree, defaultLanguage) {
1222
+ const languages = new Set();
1223
+ visit(tree, 'element', (node) => {
1224
+ if (node.tagName !== 'pre')
1225
+ return;
1226
+ const head = node.children[0];
1227
+ if (!head || head.type !== 'element' || head.tagName !== 'code' || !head.properties)
1228
+ return;
1229
+ const classes = head.properties['className'];
1230
+ const languageClass = Array.isArray(classes)
1231
+ ? classes.find((item) => typeof item === 'string' && item.startsWith(languagePrefix))
1232
+ : undefined;
1233
+ languages.add(typeof languageClass === 'string'
1234
+ ? languageClass.slice(languagePrefix.length)
1235
+ : defaultLanguage);
1236
+ });
1237
+ return languages;
1238
+ }
1239
+ /**
1240
+ * The scopes that a grammar's rules include from other grammars (`include: "source.x#rule"`).
1241
+ * @param registration
1242
+ */
1243
+ function includedScopes(registration) {
1244
+ const scopes = new Set();
1245
+ const walk = (value) => {
1246
+ if (Array.isArray(value))
1247
+ value.forEach(walk);
1248
+ else if (value && typeof value === 'object')
1249
+ for (const [key, item] of Object.entries(value)) {
1250
+ if (key === 'include' && typeof item === 'string') {
1251
+ if (!item.startsWith('#') && !item.startsWith('$'))
1252
+ scopes.add(item.split('#')[0]);
1253
+ }
1254
+ else
1255
+ walk(item);
1256
+ }
1257
+ };
1258
+ walk(registration);
1259
+ return scopes;
1260
+ }
1261
+ /**
1262
+ * `a`, `a.b` and `a.b.c` for `a.b.c`: the scopes an injection target matches.
1263
+ * @param scope
1264
+ */
1265
+ function prefixes(scope) {
1266
+ const parts = scope.split('.');
1267
+ return parts.map((_, index) => parts.slice(0, index + 1).join('.'));
1268
+ }
1269
+
566
1270
  /**
567
1271
  *
568
1272
  * @param html
@@ -574,6 +1278,8 @@ async function processHtml(html, config) {
574
1278
  light: config.lightTheme ?? 'github-light',
575
1279
  dark: config.darkTheme ?? 'ayu-dark',
576
1280
  };
1281
+ const langs = config.langs?.length ? config.langs : undefined;
1282
+ const used = !langs && config.grammars === 'used';
577
1283
  try {
578
1284
  const content = await unified()
579
1285
  .use(rehypeParse, { fragment: true })
@@ -588,13 +1294,23 @@ async function processHtml(html, config) {
588
1294
  {
589
1295
  cache: config.highlight,
590
1296
  themes,
1297
+ ...(langs ? { languages: languagesId(langs) } : {}),
1298
+ ...(used ? { grammars: 'used' } : {}),
591
1299
  identity: HIGHLIGHT_IDENTITY,
592
1300
  defaultLanguage: DEFAULT_LANGUAGE,
593
- options: () => shikiOptions(themes),
1301
+ transformer: () => langs
1302
+ ? withLanguages(themes, langs)
1303
+ : used
1304
+ ? withUsedGrammars(themes)
1305
+ : rehypeShiki(shikiOptions(themes)),
594
1306
  },
595
1307
  ],
596
1308
  ]
597
- : [[rehypeShiki, shikiOptions(themes)]])
1309
+ : langs
1310
+ ? [[() => withLanguages(themes, langs)]]
1311
+ : used
1312
+ ? [[() => withUsedGrammars(themes)]]
1313
+ : [[rehypeShiki, shikiOptions(themes)]])
598
1314
  .use(highlightCodeLines)
599
1315
  .use(wrapTablePlugin)
600
1316
  .use(sluggerPlugin, anchors.add.bind(anchors), config.headings)
@@ -615,7 +1331,7 @@ const DEFAULT_LANGUAGE = 'ts';
615
1331
  * whenever {@link shikiOptions} or the cached plugin change.
616
1332
  */
617
1333
  const HIGHLIGHT_IDENTITY = {
618
- format: 1,
1334
+ format: 2,
619
1335
  defaultLanguage: DEFAULT_LANGUAGE,
620
1336
  fallbackLanguage: 'text',
621
1337
  addLanguageClass: true,
@@ -652,6 +1368,92 @@ function shikiOptions(themes) {
652
1368
  function shikiTheme(name) {
653
1369
  return name === NG_DOC_SYNTAX_THEME_NAME ? ngDocSyntaxTheme() : name;
654
1370
  }
1371
+ /**
1372
+ * The highlighters with extra languages in this thread, by themes and languages. Each loads every
1373
+ * bundled language and the given ones once; nothing loads into it later, so a block highlights
1374
+ * the same whatever was highlighted before, and documents with other languages (or none) never
1375
+ * see these.
1376
+ */
1377
+ const languageHighlighters = new Map();
1378
+ const languageIds = new WeakMap();
1379
+ /**
1380
+ * The identity of a list of languages: the digest of its JSON.
1381
+ * @param langs
1382
+ */
1383
+ function languagesId(langs) {
1384
+ let id = languageIds.get(langs);
1385
+ if (id === undefined) {
1386
+ id = createHash('sha256').update(JSON.stringify(langs)).digest('hex');
1387
+ languageIds.set(langs, id);
1388
+ }
1389
+ return id;
1390
+ }
1391
+ /**
1392
+ * The `@shikijs/rehype` transformer over the highlighter with these languages, with the options of
1393
+ * the plain plugin. A highlighter that failed to load is forgotten, so that the next document tries
1394
+ * again and fails with the same error.
1395
+ * @param themes - The theme names.
1396
+ * @param themes.light - The light theme.
1397
+ * @param themes.dark - The dark theme.
1398
+ * @param langs - The extra languages.
1399
+ */
1400
+ function withLanguages(themes, langs) {
1401
+ const id = `${themes.light}\n${themes.dark}\n${languagesId(langs)}`;
1402
+ return async (tree) => {
1403
+ let highlighter = languageHighlighters.get(id);
1404
+ if (!highlighter) {
1405
+ highlighter = createHighlighter({
1406
+ themes: [shikiTheme(themes.light), shikiTheme(themes.dark)],
1407
+ // Later registrations of a name replace earlier ones, so the given languages win.
1408
+ langs: [...Object.keys(bundledLanguages), ...langs],
1409
+ });
1410
+ languageHighlighters.set(id, highlighter);
1411
+ const created = highlighter;
1412
+ created.catch(() => {
1413
+ if (languageHighlighters.get(id) === created)
1414
+ languageHighlighters.delete(id);
1415
+ });
1416
+ }
1417
+ const transform = rehypeShikiFromHighlighter(await highlighter, shikiOptions(themes));
1418
+ await transform(tree);
1419
+ };
1420
+ }
1421
+ /**
1422
+ * The highlighters that load only the grammars code blocks use, by theme pair, in this thread. A
1423
+ * highlighter that failed to load is forgotten, so that the next document tries again and fails
1424
+ * with the same error.
1425
+ */
1426
+ const usedHighlighters = new Map();
1427
+ /**
1428
+ * The `@shikijs/rehype` transformer, with the options of the plain plugin, over this thread's
1429
+ * highlighter for the theme pair, after it has loaded the grammars of the tree's code blocks
1430
+ * (`./shiki-grammars`).
1431
+ * @param themes - The theme names.
1432
+ * @param themes.light - The light theme.
1433
+ * @param themes.dark - The dark theme.
1434
+ */
1435
+ function withUsedGrammars(themes) {
1436
+ const id = `${themes.light}\n${themes.dark}`;
1437
+ return async (tree) => {
1438
+ let highlighter = usedHighlighters.get(id);
1439
+ if (!highlighter) {
1440
+ highlighter = createGrammarlessHighlighter([
1441
+ shikiTheme(themes.light),
1442
+ shikiTheme(themes.dark),
1443
+ ]);
1444
+ usedHighlighters.set(id, highlighter);
1445
+ const created = highlighter;
1446
+ created.catch(() => {
1447
+ if (usedHighlighters.get(id) === created)
1448
+ usedHighlighters.delete(id);
1449
+ });
1450
+ }
1451
+ const [grammars, ready] = await Promise.all([shikiGrammars(), highlighter]);
1452
+ grammars.load(ready, blockLanguages(tree, DEFAULT_LANGUAGE));
1453
+ const transform = rehypeShikiFromHighlighter(ready, shikiOptions(themes));
1454
+ await transform(tree);
1455
+ };
1456
+ }
655
1457
 
656
1458
  /**
657
1459
  *