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