@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/README.md +20 -29
- package/html/plugins/cached-shiki.plugin.d.ts +83 -0
- package/html/plugins/keyword-positions.d.ts +58 -0
- package/html/process-html.d.ts +27 -0
- package/html/shiki-grammars.d.ts +58 -0
- package/index.esm.js +1066 -33
- package/index.esm.js.map +1 -1
- package/package.json +24 -23
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
|
|
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
|
|
111
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
*
|