@ng-doc/utils 22.0.0-beta.2 → 22.1.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
@@ -2,11 +2,14 @@ import rehypeMinifyWhitespace from 'rehype-minify-whitespace';
2
2
  import rehypeParse from 'rehype-parse';
3
3
  import rehypeStringify from 'rehype-stringify';
4
4
  import { unified } from 'unified';
5
- import { KEYWORD_ALLOWED_LANGUAGES, asArray, NG_DOC_ELEMENT } from '@ng-doc/core';
5
+ import { KEYWORD_ALLOWED_LANGUAGES, asArray, NG_DOC_ELEMENT, NG_DOC_SYNTAX_THEME_NAME, ngDocSyntaxTheme } from '@ng-doc/core';
6
6
  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,24 +574,48 @@ 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;
126
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;
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
+ });
605
+ });
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;
127
617
  });
618
+ return offsets;
128
619
  }
129
620
  /**
130
621
  *
@@ -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,6 +764,160 @@ function addHeadingAnchors(route) {
259
764
  });
260
765
  }
261
766
 
767
+ const languagePrefix$1 = 'language-';
768
+ /**
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
771
+ * every document, asks for the highlighter and loads every language and theme into it again on
772
+ * each call. A transformer whose highlighter failed to load is forgotten, so the next document
773
+ * tries again and fails with the same error, as the plain plugin does.
774
+ */
775
+ const highlighters = new Map();
776
+ /**
777
+ * The plain `@shikijs/rehype` plugin, with a cache of its results per block.
778
+ *
779
+ * It selects the blocks exactly as the plugin does (`pre` elements whose first child is a `code`
780
+ * element with properties, with the language of its `language-*` class or the default language)
781
+ * and leaves the tree unchanged while it keys them. A miss is highlighted by the plugin's own
782
+ * transformer, on a tree that holds only that block, so it gets exactly the plugin's options,
783
+ * language fallback, meta parsing and transformers. A failing block throws the plugin's error at
784
+ * the same block, since a hit cannot fail (the cache holds only blocks that were highlighted), and
785
+ * nothing is stored for it. Every value is serialized before the tree is changed, and every block
786
+ * is replaced with nodes parsed from it, so later plugins, which change the tree, never change a
787
+ * value or a node another block shares. A document whose blocks all hit (or that has none) never
788
+ * loads Shiki once the cache shows that its themes load.
789
+ */
790
+ function cachedShikiPlugin(settings) {
791
+ const { cache, themes, languages, identity, defaultLanguage } = settings;
792
+ const id = `${themes.light}\n${themes.dark}\n${languages ?? ''}\n${settings.grammars ?? 'all'}`;
793
+ return async (tree) => {
794
+ const blocks = [];
795
+ visit(tree, 'element', (node, index, parent) => {
796
+ if (!parent || index == null || node.tagName !== 'pre')
797
+ return;
798
+ const head = node.children[0];
799
+ if (!head || head.type !== 'element' || head.tagName !== 'code' || !head.properties)
800
+ return;
801
+ const classes = head.properties['className'];
802
+ const languageClass = Array.isArray(classes)
803
+ ? classes.find((item) => typeof item === 'string' && item.startsWith(languagePrefix$1))
804
+ : undefined;
805
+ const lang = typeof languageClass === 'string'
806
+ ? languageClass.slice(languagePrefix$1.length)
807
+ : defaultLanguage;
808
+ if (!lang)
809
+ return;
810
+ const data = head.data;
811
+ const meta = data?.meta ?? head.properties['metastring']?.toString() ?? '';
812
+ const code = toString(head);
813
+ blocks.push({
814
+ node,
815
+ parent,
816
+ key: cache.key({
817
+ options: identity,
818
+ themes,
819
+ ...(languages === undefined ? {} : { languages }),
820
+ lang,
821
+ meta,
822
+ code,
823
+ }),
824
+ });
825
+ });
826
+ let highlighter;
827
+ const load = async () => (highlighter ??= await highlighterFor(id, settings.transformer));
828
+ if (cache.loads?.(themes) !== true)
829
+ await load();
830
+ const values = [];
831
+ for (const block of blocks) {
832
+ const cached = cache.get(block.key);
833
+ const hit = cached === undefined ? undefined : fragment(cached);
834
+ if (hit && !cache.verify) {
835
+ values.push(hit);
836
+ continue;
837
+ }
838
+ const fresh = await highlight(await load(), block.node);
839
+ if (hit && fresh !== cached)
840
+ cache.mismatch?.(block.key);
841
+ if (fresh !== cached)
842
+ cache.set(block.key, fresh);
843
+ values.push(JSON.parse(fresh));
844
+ }
845
+ blocks.forEach((block, position) => {
846
+ const index = block.parent.children.indexOf(block.node);
847
+ // A block nested in another block's `pre` was replaced inside that detached element.
848
+ if (index >= 0)
849
+ block.parent.children.splice(index, 1, ...values[position]);
850
+ });
851
+ };
852
+ }
853
+ function highlighterFor(id, transformer) {
854
+ const known = highlighters.get(id);
855
+ if (known)
856
+ return known;
857
+ const created = (async () => {
858
+ const transform = transformer();
859
+ // An empty tree makes the transformer load its highlighter, so that a failure to load is told
860
+ // apart from a block's own failure.
861
+ await transform({ type: 'root', children: [] });
862
+ return transform;
863
+ })();
864
+ highlighters.set(id, created);
865
+ created.catch(() => {
866
+ if (highlighters.get(id) === created)
867
+ highlighters.delete(id);
868
+ });
869
+ return created;
870
+ }
871
+ /** The JSON of the nodes the plugin replaces `node` with. */
872
+ async function highlight(transform, node) {
873
+ const root = { type: 'root', children: [node] };
874
+ await transform(root);
875
+ return JSON.stringify(root.children);
876
+ }
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
+ */
881
+ function fragment(value) {
882
+ try {
883
+ const parsed = JSON.parse(value);
884
+ return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isReplacement)
885
+ ? parsed
886
+ : undefined;
887
+ }
888
+ catch {
889
+ return undefined;
890
+ }
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
+ }
904
+ function isNode(value) {
905
+ if (!value || typeof value !== 'object' || Array.isArray(value))
906
+ return false;
907
+ const node = value;
908
+ if (node['type'] === 'text')
909
+ return typeof node['value'] === 'string';
910
+ const properties = node['properties'];
911
+ const children = node['children'];
912
+ return (node['type'] === 'element' &&
913
+ typeof node['tagName'] === 'string' &&
914
+ !!properties &&
915
+ typeof properties === 'object' &&
916
+ !Array.isArray(properties) &&
917
+ Array.isArray(children) &&
918
+ children.every(isNode));
919
+ }
920
+
262
921
  /**
263
922
  * Highlight code lines
264
923
  */
@@ -349,6 +1008,9 @@ function sluggerPlugin(addAnchor, headings = ['h1', 'h2', 'h3', 'h4']) {
349
1008
  if (node.properties) {
350
1009
  const id = attrSlug && attrSlugType === 'member' ? attrSlug : slugger.slug(dataToSlug);
351
1010
  node.properties['id'] = id;
1011
+ if (isHeading) {
1012
+ hideLeadingEmoji(node);
1013
+ }
352
1014
  addAnchor({
353
1015
  anchorId: id,
354
1016
  anchor: new GithubSlugger().slug(dataToSlug),
@@ -363,6 +1025,31 @@ function sluggerPlugin(addAnchor, headings = ['h1', 'h2', 'h3', 'h4']) {
363
1025
  });
364
1026
  };
365
1027
  }
1028
+ /**
1029
+ * A leading emoji of a heading: one pictograph with its modifiers and joined parts, or a flag.
1030
+ * `libs/builder/helpers/keyword-heading-title.ts` strips the same emoji from keyword titles; keep
1031
+ * both patterns in step.
1032
+ */
1033
+ const LEADING_EMOJI = /^(?:\p{Regional_Indicator}{2}|(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)(?:\p{Emoji_Modifier}|\uFE0F|\u200D\p{Extended_Pictographic}\uFE0F?)*)(?=\s)/u;
1034
+ /**
1035
+ * Wraps a decorative leading emoji of a heading in an `aria-hidden` span, so screen readers skip
1036
+ * it. It runs after the heading's id and anchor are taken from the text, so neither changes.
1037
+ * @param heading - The heading element.
1038
+ */
1039
+ function hideLeadingEmoji(heading) {
1040
+ const first = heading.children[0];
1041
+ const emoji = first?.type === 'text' ? LEADING_EMOJI.exec(first.value)?.[0] : undefined;
1042
+ if (!emoji) {
1043
+ return;
1044
+ }
1045
+ const rest = { type: 'text', value: first.value.slice(emoji.length) };
1046
+ heading.children.splice(0, 1, {
1047
+ type: 'element',
1048
+ tagName: 'span',
1049
+ properties: { ariaHidden: 'true' },
1050
+ children: [{ type: 'text', value: emoji }],
1051
+ }, rest);
1052
+ }
366
1053
  /**
367
1054
  *
368
1055
  * @param ancestors
@@ -401,6 +1088,185 @@ function wrapTablePlugin () {
401
1088
  };
402
1089
  }
403
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
+
404
1270
  /**
405
1271
  *
406
1272
  * @param html
@@ -408,6 +1274,12 @@ function wrapTablePlugin () {
408
1274
  */
409
1275
  async function processHtml(html, config) {
410
1276
  const anchors = new Set();
1277
+ const themes = {
1278
+ light: config.lightTheme ?? 'github-light',
1279
+ dark: config.darkTheme ?? 'ayu-dark',
1280
+ };
1281
+ const langs = config.langs?.length ? config.langs : undefined;
1282
+ const used = !langs && config.grammars === 'used';
411
1283
  try {
412
1284
  const content = await unified()
413
1285
  .use(rehypeParse, { fragment: true })
@@ -415,16 +1287,30 @@ async function processHtml(html, config) {
415
1287
  .use(mermaidPlugin)
416
1288
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
417
1289
  // @ts-ignore
418
- .use(rehypeShiki, {
419
- defaultLanguage: 'ts',
420
- fallbackLanguage: 'text',
421
- addLanguageClass: true,
422
- parseMetaString: (meta) => JSON.parse(meta?.replace(/\\/g, '') || '{}'),
423
- themes: {
424
- light: config.lightTheme ?? 'github-light',
425
- dark: config.darkTheme ?? 'ayu-dark',
426
- },
427
- })
1290
+ .use(config.highlight
1291
+ ? [
1292
+ [
1293
+ cachedShikiPlugin,
1294
+ {
1295
+ cache: config.highlight,
1296
+ themes,
1297
+ ...(langs ? { languages: languagesId(langs) } : {}),
1298
+ ...(used ? { grammars: 'used' } : {}),
1299
+ identity: HIGHLIGHT_IDENTITY,
1300
+ defaultLanguage: DEFAULT_LANGUAGE,
1301
+ transformer: () => langs
1302
+ ? withLanguages(themes, langs)
1303
+ : used
1304
+ ? withUsedGrammars(themes)
1305
+ : rehypeShiki(shikiOptions(themes)),
1306
+ },
1307
+ ],
1308
+ ]
1309
+ : langs
1310
+ ? [[() => withLanguages(themes, langs)]]
1311
+ : used
1312
+ ? [[() => withUsedGrammars(themes)]]
1313
+ : [[rehypeShiki, shikiOptions(themes)]])
428
1314
  .use(highlightCodeLines)
429
1315
  .use(wrapTablePlugin)
430
1316
  .use(sluggerPlugin, anchors.add.bind(anchors), config.headings)
@@ -439,6 +1325,135 @@ async function processHtml(html, config) {
439
1325
  return { content: html, anchors: [], error };
440
1326
  }
441
1327
  }
1328
+ const DEFAULT_LANGUAGE = 'ts';
1329
+ /**
1330
+ * What a cached block's options stand for in its key ({@link NgDocHighlightCache}). Bump `format`
1331
+ * whenever {@link shikiOptions} or the cached plugin change.
1332
+ */
1333
+ const HIGHLIGHT_IDENTITY = {
1334
+ format: 2,
1335
+ defaultLanguage: DEFAULT_LANGUAGE,
1336
+ fallbackLanguage: 'text',
1337
+ addLanguageClass: true,
1338
+ parseMetaString: 'json-without-backslashes',
1339
+ tokenizeTimeLimit: 0,
1340
+ };
1341
+ /**
1342
+ * The options both highlighting paths give `@shikijs/rehype`.
1343
+ * @param themes - The theme names.
1344
+ * @param themes.light - The light theme.
1345
+ * @param themes.dark - The dark theme.
1346
+ */
1347
+ function shikiOptions(themes) {
1348
+ return {
1349
+ defaultLanguage: DEFAULT_LANGUAGE,
1350
+ fallbackLanguage: 'text',
1351
+ addLanguageClass: true,
1352
+ parseMetaString: (meta) => JSON.parse(meta?.replace(/\\/g, '') || '{}'),
1353
+ // Shiki stops tokenizing a line after 500 ms by default and silently colours the rest of
1354
+ // it like the last token. A slow or pre-empted process then renders other HTML than a fast
1355
+ // one, so the build output would depend on machine load. No limit keeps it deterministic.
1356
+ tokenizeTimeLimit: 0,
1357
+ themes: {
1358
+ light: shikiTheme(themes.light),
1359
+ dark: shikiTheme(themes.dark),
1360
+ },
1361
+ };
1362
+ }
1363
+ /**
1364
+ * A Shiki theme by name. NgDoc's own theme is not bundled with Shiki, so its name resolves to a new
1365
+ * copy of it; any other name is left to Shiki.
1366
+ * @param name - The theme name.
1367
+ */
1368
+ function shikiTheme(name) {
1369
+ return name === NG_DOC_SYNTAX_THEME_NAME ? ngDocSyntaxTheme() : name;
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
+ }
442
1457
 
443
1458
  /**
444
1459
  *
@@ -447,6 +1462,14 @@ async function processHtml(html, config) {
447
1462
  */
448
1463
  function removeNotIndexableContentPlugin() {
449
1464
  return (tree) => {
1465
+ // The slugger wraps a heading's decorative leading emoji in an `aria-hidden` span. It is not
1466
+ // part of the heading's name: indexed, it became a search record of its own, under the
1467
+ // previous section.
1468
+ visit(tree, 'element', (node) => {
1469
+ if (isHeading(node) && isHiddenSpan(node.children[0])) {
1470
+ node.children = node.children.slice(1);
1471
+ }
1472
+ });
450
1473
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
451
1474
  // @ts-ignore
452
1475
  return filter(tree, { cascade: true }, (node) => {
@@ -458,6 +1481,16 @@ function removeNotIndexableContentPlugin() {
458
1481
  });
459
1482
  };
460
1483
  }
1484
+ /**
1485
+ * Whether the node is a span hidden from assistive technology, as the slugger renders the leading
1486
+ * emoji of a heading.
1487
+ * @param node - The first child of a heading.
1488
+ */
1489
+ function isHiddenSpan(node) {
1490
+ return (node?.type === 'element' &&
1491
+ node.tagName === 'span' &&
1492
+ node.properties?.['ariaHidden'] === 'true');
1493
+ }
461
1494
 
462
1495
  /**
463
1496
  *