@ng-doc/utils 22.0.0-beta.1 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -2,7 +2,7 @@ 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';
@@ -20,9 +20,12 @@ import { stringifyEntities as stringifyEntities$1 } from 'stringify-entities';
20
20
  * @param html
21
21
  */
22
22
  function minify(html) {
23
- return unified().use(rehypeParse, {
24
- fragment: true
25
- }).use(rehypeStringify).use(rehypeMinifyWhitespace).processSync(html).toString();
23
+ return unified()
24
+ .use(rehypeParse, { fragment: true })
25
+ .use(rehypeStringify)
26
+ .use(rehypeMinifyWhitespace)
27
+ .processSync(html)
28
+ .toString();
26
29
  }
27
30
 
28
31
  /**
@@ -31,8 +34,8 @@ function minify(html) {
31
34
  * @param attr
32
35
  */
33
36
  function attrValue(node, attr) {
34
- const attrKey = attr.toLowerCase();
35
- return node.properties?.[attrKey] ? String(node.properties[attrKey]) : undefined;
37
+ const attrKey = attr.toLowerCase();
38
+ return node.properties?.[attrKey] ? String(node.properties[attrKey]) : undefined;
36
39
  }
37
40
 
38
41
  /**
@@ -41,8 +44,8 @@ function attrValue(node, attr) {
41
44
  * @param cls
42
45
  */
43
46
  function hasClass(node, cls) {
44
- const className = node.properties?.['className']?.toString() ?? '';
45
- return !!className && className.includes(cls);
47
+ const className = node.properties?.['className']?.toString() ?? '';
48
+ return !!className && className.includes(cls);
46
49
  }
47
50
 
48
51
  /**
@@ -50,7 +53,7 @@ function hasClass(node, cls) {
50
53
  * @param ancestors
51
54
  */
52
55
  function hasLinkAncestor(ancestors) {
53
- return ancestors.some(ancestor => isElement$1(ancestor, 'a'));
56
+ return ancestors.some((ancestor) => isElement$1(ancestor, 'a'));
54
57
  }
55
58
 
56
59
  /**
@@ -59,7 +62,7 @@ function hasLinkAncestor(ancestors) {
59
62
  * @param node
60
63
  */
61
64
  function isCodeBlock(parent, node) {
62
- return parent?.type === 'element' && parent.tagName === 'pre' && node.tagName === 'code';
65
+ return parent?.type === 'element' && parent.tagName === 'pre' && node.tagName === 'code';
63
66
  }
64
67
 
65
68
  /**
@@ -67,7 +70,7 @@ function isCodeBlock(parent, node) {
67
70
  * @param node
68
71
  */
69
72
  function isCodeNode(node) {
70
- return isElement$1(node, 'code') || isElement$1(node, 'ng-doc-code');
73
+ return isElement$1(node, 'code') || isElement$1(node, 'ng-doc-code');
71
74
  }
72
75
 
73
76
  /**
@@ -75,7 +78,7 @@ function isCodeNode(node) {
75
78
  * @param node
76
79
  */
77
80
  function isElement(node) {
78
- return node?.type === 'element';
81
+ return node?.type === 'element';
79
82
  }
80
83
 
81
84
  const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
@@ -84,7 +87,7 @@ const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
84
87
  * @param node
85
88
  */
86
89
  function isHeading(node) {
87
- return isElement$1(node, HEADINGS);
90
+ return isElement$1(node, HEADINGS);
88
91
  }
89
92
 
90
93
  const ALWAYS_ALLOWED_LANGUAGES = ['typescript', 'ts', 'angular-ts'];
@@ -96,30 +99,32 @@ const MATCH_KEYWORD_REGEXP = /(?<key>[*\p{L}\w$@-]+)((?<delimiter>[.#])(?<anchor
96
99
  * @param config
97
100
  */
98
101
  function keywordsPlugin(config) {
99
- return tree => visitParents(tree, 'element', (node, ancestors) => {
100
- if (!isCodeNode(node)) {
101
- return;
102
- }
103
- const isInlineCode = !isElement$1(ancestors[ancestors.length - 1], 'pre');
104
- const lang = asArray(node.properties?.['className'] ?? []).find(className => className.startsWith('language-'))?.replace('language-', '') ?? '';
105
- if (isInlineCode || LANGUAGES.includes(lang)) {
106
- visitParents(node, 'text', (node, ancestors) => {
107
- if (hasLinkAncestor(ancestors)) {
108
- return;
102
+ return (tree) => visitParents(tree, 'element', (node, ancestors) => {
103
+ if (!isCodeNode(node)) {
104
+ return;
109
105
  }
110
- const parent = ancestors[ancestors.length - 1];
111
- const index = parent.children.indexOf(node);
112
- // Parse the text for words that we can convert to links
113
- const nodes = getNodes(node, parent, isInlineCode, config, lang);
114
- // Replace the text node with the links and leftover text nodes
115
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
116
- // @ts-ignore
117
- Array.prototype.splice.apply(parent.children, [index, 1].concat(nodes));
118
- // Do not visit this node's children or the newly added nodes
119
- return [SKIP, index + nodes.length];
120
- });
121
- }
122
- });
106
+ const isInlineCode = !isElement$1(ancestors[ancestors.length - 1], 'pre');
107
+ const lang = asArray(node.properties?.['className'] ?? [])
108
+ .find((className) => className.startsWith('language-'))
109
+ ?.replace('language-', '') ?? '';
110
+ if (isInlineCode || LANGUAGES.includes(lang)) {
111
+ visitParents(node, 'text', (node, ancestors) => {
112
+ if (hasLinkAncestor(ancestors)) {
113
+ return;
114
+ }
115
+ const parent = ancestors[ancestors.length - 1];
116
+ const index = parent.children.indexOf(node);
117
+ // Parse the text for words that we can convert to links
118
+ const nodes = getNodes(node, parent, isInlineCode, config, lang);
119
+ // Replace the text node with the links and leftover text nodes
120
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
121
+ // @ts-ignore
122
+ Array.prototype.splice.apply(parent.children, [index, 1].concat(nodes));
123
+ // Do not visit this node's children or the newly added nodes
124
+ return [SKIP, index + nodes.length];
125
+ });
126
+ }
127
+ });
123
128
  }
124
129
  /**
125
130
  *
@@ -130,71 +135,54 @@ function keywordsPlugin(config) {
130
135
  * @param language
131
136
  */
132
137
  function getNodes(node, parent, isInlineCode, config, language) {
133
- const {
134
- addUsedKeyword,
135
- getKeyword
136
- } = config;
137
- return toString(node).split(SPLIT_REGEXP).map(word => {
138
- const match = word.match(MATCH_KEYWORD_REGEXP);
139
- if (!match) {
140
- return {
141
- type: 'text',
142
- value: word
143
- };
144
- }
145
- const {
146
- key = '',
147
- delimiter = '',
148
- anchor = '',
149
- queryParams = ''
150
- } = match.groups;
151
- const usedKeyword = `${key}${delimiter}${anchor?.toLowerCase()}`;
152
- const rootKeyword = getKeyword?.(key);
153
- const keyword = getKeyword?.(usedKeyword);
154
- const isGuideKeyword = key.startsWith('*');
155
- const isLanguageAllowed = !keyword?.languages && ALWAYS_ALLOWED_LANGUAGES.includes(language) || !!keyword?.languages?.includes(language);
156
- // If language of code block is not allowed, return the word as is
157
- if (!isInlineCode && keyword && !isLanguageAllowed) {
158
- return {
159
- type: 'text',
160
- value: word
161
- };
162
- }
163
- // If the keyword is just an asterisk, return the word as is
164
- if (isGuideKeyword && key.length === 1) {
165
- return {
166
- type: 'text',
167
- value: word
168
- };
169
- }
170
- addUsedKeyword?.(usedKeyword);
171
- const notFoundGuideKeyword = isGuideKeyword && !keyword;
172
- const notFoundApiAnchorKeyword = !!rootKeyword && !!anchor && !keyword;
173
- if (getKeyword && isInlineCode && (notFoundGuideKeyword || notFoundApiAnchorKeyword)) {
174
- throw new Error(`Route with keyword "${word}" is missing.`);
175
- }
176
- // Convert code tag to a link tag or highlight it with a class
177
- if (parent.properties) {
178
- if (isInlineCode && keyword?.type === 'link') {
179
- parent.tagName = 'a';
180
- parent.properties = {
181
- href: `${keyword.path}${queryParams ?? ''}`,
182
- className: [NG_DOC_ELEMENT]
183
- };
184
- return {
185
- type: 'text',
186
- value: keyword.title
187
- };
188
- } else if (isInlineCode && keyword) {
189
- parent.properties['className'] = [NG_DOC_ELEMENT, 'ng-doc-code-with-link'];
190
- }
191
- }
192
- // Add link inside the code if it's a link to the API entity
193
- return keyword ? createLinkNode(isInlineCode ? keyword.title : word, keyword.path, keyword.type, keyword.description) : {
194
- type: 'text',
195
- value: word
196
- };
197
- });
138
+ const { addUsedKeyword, getKeyword } = config;
139
+ return toString(node)
140
+ .split(SPLIT_REGEXP)
141
+ .map((word) => {
142
+ const match = word.match(MATCH_KEYWORD_REGEXP);
143
+ if (!match) {
144
+ return { type: 'text', value: word };
145
+ }
146
+ const { key = '', delimiter = '', anchor = '', queryParams = '', } = match.groups;
147
+ const usedKeyword = `${key}${delimiter}${anchor?.toLowerCase()}`;
148
+ const rootKeyword = getKeyword?.(key);
149
+ const keyword = getKeyword?.(usedKeyword);
150
+ const isGuideKeyword = key.startsWith('*');
151
+ const isLanguageAllowed = (!keyword?.languages && ALWAYS_ALLOWED_LANGUAGES.includes(language)) ||
152
+ !!keyword?.languages?.includes(language);
153
+ // If language of code block is not allowed, return the word as is
154
+ if (!isInlineCode && keyword && !isLanguageAllowed) {
155
+ return { type: 'text', value: word };
156
+ }
157
+ // If the keyword is just an asterisk, return the word as is
158
+ if (isGuideKeyword && key.length === 1) {
159
+ return { type: 'text', value: word };
160
+ }
161
+ addUsedKeyword?.(usedKeyword);
162
+ const notFoundGuideKeyword = isGuideKeyword && !keyword;
163
+ const notFoundApiAnchorKeyword = !!rootKeyword && !!anchor && !keyword;
164
+ if (getKeyword && isInlineCode && (notFoundGuideKeyword || notFoundApiAnchorKeyword)) {
165
+ throw new Error(`Route with keyword "${word}" is missing.`);
166
+ }
167
+ // Convert code tag to a link tag or highlight it with a class
168
+ if (parent.properties) {
169
+ if (isInlineCode && keyword?.type === 'link') {
170
+ parent.tagName = 'a';
171
+ parent.properties = {
172
+ href: `${keyword.path}${queryParams ?? ''}`,
173
+ className: [NG_DOC_ELEMENT],
174
+ };
175
+ return { type: 'text', value: keyword.title };
176
+ }
177
+ else if (isInlineCode && keyword) {
178
+ parent.properties['className'] = [NG_DOC_ELEMENT, 'ng-doc-code-with-link'];
179
+ }
180
+ }
181
+ // Add link inside the code if it's a link to the API entity
182
+ return keyword
183
+ ? createLinkNode(isInlineCode ? keyword.title : word, keyword.path, keyword.type, keyword.description)
184
+ : { type: 'text', value: word };
185
+ });
198
186
  }
199
187
  /**
200
188
  *
@@ -204,20 +192,17 @@ function getNodes(node, parent, isInlineCode, config, language) {
204
192
  * @param description
205
193
  */
206
194
  function createLinkNode(text, href, type, description) {
207
- return {
208
- type: 'element',
209
- tagName: 'a',
210
- properties: {
211
- href: href,
212
- class: ['ng-doc-code-anchor', NG_DOC_ELEMENT],
213
- 'data-link-type': type,
214
- ngDocTooltip: description
215
- },
216
- children: [{
217
- type: 'text',
218
- value: text
219
- }]
220
- };
195
+ return {
196
+ type: 'element',
197
+ tagName: 'a',
198
+ properties: {
199
+ href: href,
200
+ class: ['ng-doc-code-anchor', NG_DOC_ELEMENT],
201
+ 'data-link-type': type,
202
+ ngDocTooltip: description,
203
+ },
204
+ children: [{ type: 'text', value: text }],
205
+ };
221
206
  }
222
207
 
223
208
  /**
@@ -228,24 +213,19 @@ function createLinkNode(text, href, type, description) {
228
213
  * @param addUsedKeyword
229
214
  */
230
215
  async function postProcessHtml(html) {
231
- const usedKeywords = new Set();
232
- try {
233
- const content = await unified().use(rehypeParse, {
234
- fragment: true
235
- }).use(rehypeStringify).use(keywordsPlugin, {
236
- addUsedKeyword: usedKeywords.add.bind(usedKeywords)
237
- }).process(html).then(file => file.toString());
238
- return {
239
- content,
240
- usedKeywords: Array.from(usedKeywords)
241
- };
242
- } catch (error) {
243
- return {
244
- content: html,
245
- usedKeywords: [],
246
- error
247
- };
248
- }
216
+ const usedKeywords = new Set();
217
+ try {
218
+ const content = await unified()
219
+ .use(rehypeParse, { fragment: true })
220
+ .use(rehypeStringify)
221
+ .use(keywordsPlugin, { addUsedKeyword: usedKeywords.add.bind(usedKeywords) })
222
+ .process(html)
223
+ .then((file) => file.toString());
224
+ return { content, usedKeywords: Array.from(usedKeywords) };
225
+ }
226
+ catch (error) {
227
+ return { content: html, usedKeywords: [], error };
228
+ }
249
229
  }
250
230
 
251
231
  const NO_ANCHOR_CLASS = 'no-anchor';
@@ -255,46 +235,185 @@ const NO_ANCHOR_CLASS = 'no-anchor';
255
235
  * @param route
256
236
  */
257
237
  function addHeadingAnchors(route) {
258
- return tree => visit(tree, 'element', node => {
259
- if (route && isHeading(node) && node.properties && node.properties['id'] && !hasClass(node, NO_ANCHOR_CLASS)) {
260
- node.children.push({
261
- type: 'element',
262
- tagName: 'ng-doc-heading-anchor',
263
- properties: {
264
- className: ['ng-doc-anchor'],
265
- anchor: node.properties['id']
266
- },
267
- children: []
268
- });
269
- node.properties = {
270
- ...node.properties,
271
- href: route,
272
- headingLink: 'true'
273
- };
238
+ return (tree) => visit(tree, 'element', (node) => {
239
+ if (route &&
240
+ isHeading(node) &&
241
+ node.properties &&
242
+ node.properties['id'] &&
243
+ !hasClass(node, NO_ANCHOR_CLASS)) {
244
+ node.children.push({
245
+ type: 'element',
246
+ tagName: 'ng-doc-heading-anchor',
247
+ properties: {
248
+ className: ['ng-doc-anchor'],
249
+ anchor: node.properties['id'],
250
+ },
251
+ children: [],
252
+ });
253
+ node.properties = {
254
+ ...node.properties,
255
+ href: route,
256
+ headingLink: 'true',
257
+ };
258
+ }
259
+ });
260
+ }
261
+
262
+ const languagePrefix = 'language-';
263
+ /**
264
+ * One `@shikijs/rehype` transformer per theme pair in this thread. It loads its highlighter once
265
+ * (every bundled language, the themes and the WASM), where the plain plugin, created again for
266
+ * every document, asks for the highlighter and loads every language and theme into it again on
267
+ * each call. A transformer whose highlighter failed to load is forgotten, so the next document
268
+ * tries again and fails with the same error, as the plain plugin does.
269
+ */
270
+ const highlighters = new Map();
271
+ /**
272
+ * The plain `@shikijs/rehype` plugin, with a cache of its results per block.
273
+ *
274
+ * It selects the blocks exactly as the plugin does (`pre` elements whose first child is a `code`
275
+ * element with properties, with the language of its `language-*` class or the default language)
276
+ * and leaves the tree unchanged while it keys them. A miss is highlighted by the plugin's own
277
+ * transformer, on a tree that holds only that block, so it gets exactly the plugin's options,
278
+ * language fallback, meta parsing and transformers. A failing block throws the plugin's error at
279
+ * the same block, since a hit cannot fail (the cache holds only blocks that were highlighted), and
280
+ * nothing is stored for it. Every value is serialized before the tree is changed, and every block
281
+ * is replaced with nodes parsed from it, so later plugins, which change the tree, never change a
282
+ * value or a node another block shares. A document whose blocks all hit (or that has none) never
283
+ * loads Shiki once the cache shows that its themes load.
284
+ */
285
+ function cachedShikiPlugin(settings) {
286
+ const { cache, themes, identity, defaultLanguage } = settings;
287
+ const id = `${themes.light}\n${themes.dark}`;
288
+ return async (tree) => {
289
+ const blocks = [];
290
+ visit(tree, 'element', (node, index, parent) => {
291
+ if (!parent || index == null || node.tagName !== 'pre')
292
+ return;
293
+ const head = node.children[0];
294
+ if (!head || head.type !== 'element' || head.tagName !== 'code' || !head.properties)
295
+ return;
296
+ const classes = head.properties['className'];
297
+ const languageClass = Array.isArray(classes)
298
+ ? classes.find((item) => typeof item === 'string' && item.startsWith(languagePrefix))
299
+ : undefined;
300
+ const lang = typeof languageClass === 'string'
301
+ ? languageClass.slice(languagePrefix.length)
302
+ : defaultLanguage;
303
+ if (!lang)
304
+ return;
305
+ const data = head.data;
306
+ const meta = data?.meta ?? head.properties['metastring']?.toString() ?? '';
307
+ const code = toString(head);
308
+ blocks.push({
309
+ node,
310
+ parent,
311
+ key: cache.key({ options: identity, themes, lang, meta, code }),
312
+ });
313
+ });
314
+ let highlighter;
315
+ const load = async () => (highlighter ??= await highlighterFor(id, settings.options));
316
+ if (cache.loads?.(themes) !== true)
317
+ await load();
318
+ const values = [];
319
+ for (const block of blocks) {
320
+ const cached = cache.get(block.key);
321
+ const hit = cached === undefined ? undefined : fragment(cached);
322
+ if (hit && !cache.verify) {
323
+ values.push(hit);
324
+ continue;
325
+ }
326
+ const fresh = await highlight(await load(), block.node);
327
+ if (hit && fresh !== cached)
328
+ cache.mismatch?.(block.key);
329
+ if (fresh !== cached)
330
+ cache.set(block.key, fresh);
331
+ values.push(JSON.parse(fresh));
332
+ }
333
+ blocks.forEach((block, position) => {
334
+ const index = block.parent.children.indexOf(block.node);
335
+ // A block nested in another block's `pre` was replaced inside that detached element.
336
+ if (index >= 0)
337
+ block.parent.children.splice(index, 1, ...values[position]);
338
+ });
339
+ };
340
+ }
341
+ function highlighterFor(id, options) {
342
+ const known = highlighters.get(id);
343
+ if (known)
344
+ return known;
345
+ const created = (async () => {
346
+ const transform = rehypeShiki(options());
347
+ // An empty tree makes the transformer load its highlighter, so that a failure to load is told
348
+ // apart from a block's own failure.
349
+ await transform({ type: 'root', children: [] });
350
+ return transform;
351
+ })();
352
+ highlighters.set(id, created);
353
+ created.catch(() => {
354
+ if (highlighters.get(id) === created)
355
+ highlighters.delete(id);
356
+ });
357
+ return created;
358
+ }
359
+ /** The JSON of the nodes the plugin replaces `node` with. */
360
+ async function highlight(transform, node) {
361
+ const root = { type: 'root', children: [node] };
362
+ await transform(root);
363
+ return JSON.stringify(root.children);
364
+ }
365
+ /** A cached value as fresh nodes, or undefined when it is not a JSON array of HAST elements. */
366
+ function fragment(value) {
367
+ try {
368
+ const parsed = JSON.parse(value);
369
+ return Array.isArray(parsed) &&
370
+ parsed.length > 0 &&
371
+ parsed.every((item) => isNode(item) && item.type === 'element')
372
+ ? parsed
373
+ : undefined;
374
+ }
375
+ catch {
376
+ return undefined;
274
377
  }
275
- });
378
+ }
379
+ function isNode(value) {
380
+ if (!value || typeof value !== 'object' || Array.isArray(value))
381
+ return false;
382
+ const node = value;
383
+ if (node['type'] === 'text')
384
+ return typeof node['value'] === 'string';
385
+ const properties = node['properties'];
386
+ const children = node['children'];
387
+ return (node['type'] === 'element' &&
388
+ typeof node['tagName'] === 'string' &&
389
+ !!properties &&
390
+ typeof properties === 'object' &&
391
+ !Array.isArray(properties) &&
392
+ Array.isArray(children) &&
393
+ children.every(isNode));
276
394
  }
277
395
 
278
396
  /**
279
397
  * Highlight code lines
280
398
  */
281
399
  function highlightCodeLines() {
282
- return tree => {
283
- visit(tree, 'element', (node, _i, parent) => {
284
- if (isCodeBlock(parent, node) && parent?.type === 'element') {
285
- const highlightedLines = JSON.parse(attrValue(parent, 'highlightedLines') ?? '[]');
286
- highlightedLines.length && node.children.filter(isElement).forEach((child, i) => {
287
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
288
- // @ts-ignore
289
- if (child.properties.class === 'line' && highlightedLines.includes(i + 1)) {
290
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
291
- // @ts-ignore
292
- child.properties.class = 'line highlighted';
293
- }
400
+ return (tree) => {
401
+ visit(tree, 'element', (node, _i, parent) => {
402
+ if (isCodeBlock(parent, node) && parent?.type === 'element') {
403
+ const highlightedLines = JSON.parse(attrValue(parent, 'highlightedLines') ?? '[]');
404
+ highlightedLines.length &&
405
+ node.children.filter(isElement).forEach((child, i) => {
406
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
407
+ // @ts-ignore
408
+ if (child.properties.class === 'line' && highlightedLines.includes(i + 1)) {
409
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
410
+ // @ts-ignore
411
+ child.properties.class = 'line highlighted';
412
+ }
413
+ });
414
+ }
294
415
  });
295
- }
296
- });
297
- };
416
+ };
298
417
  }
299
418
 
300
419
  /**
@@ -303,15 +422,15 @@ function highlightCodeLines() {
303
422
  * @param headings
304
423
  */
305
424
  function markElementsPlugin() {
306
- return tree => {
307
- visit(tree, 'element', node => {
308
- if (node.properties) {
309
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
310
- // @ts-ignore
311
- node.properties['className'] = [...asArray(node.properties.className), NG_DOC_ELEMENT];
312
- }
313
- });
314
- };
425
+ return (tree) => {
426
+ visit(tree, 'element', (node) => {
427
+ if (node.properties) {
428
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
429
+ // @ts-ignore
430
+ node.properties['className'] = [...asArray(node.properties.className), NG_DOC_ELEMENT];
431
+ }
432
+ });
433
+ };
315
434
  }
316
435
 
317
436
  /**
@@ -320,22 +439,22 @@ function markElementsPlugin() {
320
439
  * @param headings
321
440
  */
322
441
  function mermaidPlugin() {
323
- return tree => {
324
- visit(tree, 'element', node => {
325
- if (node.tagName === 'pre') {
326
- const codeNode = node.children[0];
327
- if (codeNode.tagName === 'code' && codeNode.properties?.['lang'] === 'mermaid') {
328
- node.properties = {
329
- className: 'mermaid'
330
- };
331
- node.children = [{
332
- type: 'text',
333
- value: toString(codeNode)
334
- }];
335
- }
336
- }
337
- });
338
- };
442
+ return (tree) => {
443
+ visit(tree, 'element', (node) => {
444
+ if (node.tagName === 'pre') {
445
+ const codeNode = node.children[0];
446
+ if (codeNode.tagName === 'code' && codeNode.properties?.['lang'] === 'mermaid') {
447
+ node.properties = { className: ['mermaid'] };
448
+ node.children = [
449
+ {
450
+ type: 'text',
451
+ value: toString(codeNode),
452
+ },
453
+ ];
454
+ }
455
+ }
456
+ });
457
+ };
339
458
  }
340
459
 
341
460
  /**
@@ -345,69 +464,103 @@ function mermaidPlugin() {
345
464
  * @param headings
346
465
  */
347
466
  function sluggerPlugin(addAnchor, headings = ['h1', 'h2', 'h3', 'h4']) {
348
- if (!addAnchor) {
349
- return () => {};
350
- }
351
- const slugger = new GithubSlugger();
352
- return tree => {
353
- slugger.reset();
354
- visitParents(tree, 'element', (node, ancestors) => {
355
- const scope = getKeywordScope(ancestors);
356
- const isHeading = !!headingRank(node) && !hasProperty(node, 'id') && !!headings?.includes(node.tagName.toLowerCase());
357
- const attrSlug = attrValue(node, 'dataSlug');
358
- const attrSlugTitle = attrValue(node, 'dataSlugTitle');
359
- const attrSlugType = attrValue(node, 'dataSlugType');
360
- const dataToSlug = isHeading ? toString(node).trim() : attrSlug;
361
- if (dataToSlug) {
362
- if (node.properties) {
363
- const id = attrSlug && attrSlugType === 'member' ? attrSlug : slugger.slug(dataToSlug);
364
- node.properties['id'] = id;
365
- addAnchor({
366
- anchorId: id,
367
- anchor: new GithubSlugger().slug(dataToSlug),
368
- title: attrSlugTitle || dataToSlug,
369
- scope,
370
- type: isHeading && attrSlugType !== 'member' || attrSlug && attrSlugType === 'heading' ? 'heading' : 'member'
371
- });
372
- }
373
- }
374
- });
375
- };
467
+ if (!addAnchor) {
468
+ return () => { };
469
+ }
470
+ const slugger = new GithubSlugger();
471
+ return (tree) => {
472
+ slugger.reset();
473
+ visitParents(tree, 'element', (node, ancestors) => {
474
+ const scope = getKeywordScope(ancestors);
475
+ const isHeading = !!headingRank(node) &&
476
+ !hasProperty(node, 'id') &&
477
+ !!headings?.includes(node.tagName.toLowerCase());
478
+ const attrSlug = attrValue(node, 'dataSlug');
479
+ const attrSlugTitle = attrValue(node, 'dataSlugTitle');
480
+ const attrSlugType = attrValue(node, 'dataSlugType');
481
+ const dataToSlug = isHeading ? toString(node).trim() : attrSlug;
482
+ if (dataToSlug) {
483
+ if (node.properties) {
484
+ const id = attrSlug && attrSlugType === 'member' ? attrSlug : slugger.slug(dataToSlug);
485
+ node.properties['id'] = id;
486
+ if (isHeading) {
487
+ hideLeadingEmoji(node);
488
+ }
489
+ addAnchor({
490
+ anchorId: id,
491
+ anchor: new GithubSlugger().slug(dataToSlug),
492
+ title: attrSlugTitle || dataToSlug,
493
+ scope,
494
+ type: (isHeading && attrSlugType !== 'member') || (attrSlug && attrSlugType === 'heading')
495
+ ? 'heading'
496
+ : 'member',
497
+ });
498
+ }
499
+ }
500
+ });
501
+ };
502
+ }
503
+ /**
504
+ * A leading emoji of a heading: one pictograph with its modifiers and joined parts, or a flag.
505
+ * `libs/builder/helpers/keyword-heading-title.ts` strips the same emoji from keyword titles; keep
506
+ * both patterns in step.
507
+ */
508
+ 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;
509
+ /**
510
+ * Wraps a decorative leading emoji of a heading in an `aria-hidden` span, so screen readers skip
511
+ * it. It runs after the heading's id and anchor are taken from the text, so neither changes.
512
+ * @param heading - The heading element.
513
+ */
514
+ function hideLeadingEmoji(heading) {
515
+ const first = heading.children[0];
516
+ const emoji = first?.type === 'text' ? LEADING_EMOJI.exec(first.value)?.[0] : undefined;
517
+ if (!emoji) {
518
+ return;
519
+ }
520
+ const rest = { type: 'text', value: first.value.slice(emoji.length) };
521
+ heading.children.splice(0, 1, {
522
+ type: 'element',
523
+ tagName: 'span',
524
+ properties: { ariaHidden: 'true' },
525
+ children: [{ type: 'text', value: emoji }],
526
+ }, rest);
376
527
  }
377
528
  /**
378
529
  *
379
530
  * @param ancestors
380
531
  */
381
532
  function getKeywordScope(ancestors) {
382
- const keywordScope = ancestors.find(ancestor => isElement$1(ancestor) && ancestor.tagName === 'ng-doc-keyword-scope');
383
- const key = keywordScope?.properties?.['id'];
384
- return key ? {
385
- key: String(key),
386
- title: String(keywordScope?.properties?.['title'])
387
- } : undefined;
533
+ const keywordScope = ancestors.find((ancestor) => isElement$1(ancestor) && ancestor.tagName === 'ng-doc-keyword-scope');
534
+ const key = keywordScope?.properties?.['id'];
535
+ return key
536
+ ? {
537
+ key: String(key),
538
+ title: String(keywordScope?.properties?.['title']),
539
+ }
540
+ : undefined;
388
541
  }
389
542
 
390
543
  /**
391
544
  *
392
545
  */
393
546
  function wrapTablePlugin () {
394
- return tree => {
395
- visit(tree, 'element', (node, index, parent) => {
396
- const table = node?.tagName === 'table';
397
- if (table) {
398
- const wrapper = {
399
- type: 'element',
400
- tagName: 'div',
401
- properties: {
402
- className: 'ng-doc-table-wrapper'
403
- },
404
- children: [node]
405
- };
406
- // @ts-expect-error - parent is a hast node
407
- parent.children[index] = wrapper;
408
- }
409
- });
410
- };
547
+ return (tree) => {
548
+ visit(tree, 'element', (node, index, parent) => {
549
+ const table = node?.tagName === 'table';
550
+ if (table) {
551
+ const wrapper = {
552
+ type: 'element',
553
+ tagName: 'div',
554
+ properties: {
555
+ className: 'ng-doc-table-wrapper',
556
+ },
557
+ children: [node],
558
+ };
559
+ // @ts-expect-error - parent is a hast node
560
+ parent.children[index] = wrapper;
561
+ }
562
+ });
563
+ };
411
564
  }
412
565
 
413
566
  /**
@@ -416,34 +569,88 @@ function wrapTablePlugin () {
416
569
  * @param config
417
570
  */
418
571
  async function processHtml(html, config) {
419
- const anchors = new Set();
420
- try {
421
- const content = await unified().use(rehypeParse, {
422
- fragment: true
423
- }).use(rehypeStringify).use(mermaidPlugin)
424
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
425
- // @ts-ignore
426
- .use(rehypeShiki, {
427
- defaultLanguage: 'ts',
428
- fallbackLanguage: 'text',
429
- addLanguageClass: true,
430
- parseMetaString: meta => JSON.parse(meta?.replace(/\\/g, '') || '{}'),
431
- themes: {
572
+ const anchors = new Set();
573
+ const themes = {
432
574
  light: config.lightTheme ?? 'github-light',
433
- dark: config.darkTheme ?? 'ayu-dark'
434
- }
435
- }).use(highlightCodeLines).use(wrapTablePlugin).use(sluggerPlugin, anchors.add.bind(anchors), config.headings).use(rehypeMinifyWhitespace).use(addHeadingAnchors, config.route).use(markElementsPlugin).process(html).then(file => file.toString());
436
- return {
437
- content,
438
- anchors: Array.from(anchors)
575
+ dark: config.darkTheme ?? 'ayu-dark',
439
576
  };
440
- } catch (error) {
577
+ try {
578
+ const content = await unified()
579
+ .use(rehypeParse, { fragment: true })
580
+ .use(rehypeStringify)
581
+ .use(mermaidPlugin)
582
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
583
+ // @ts-ignore
584
+ .use(config.highlight
585
+ ? [
586
+ [
587
+ cachedShikiPlugin,
588
+ {
589
+ cache: config.highlight,
590
+ themes,
591
+ identity: HIGHLIGHT_IDENTITY,
592
+ defaultLanguage: DEFAULT_LANGUAGE,
593
+ options: () => shikiOptions(themes),
594
+ },
595
+ ],
596
+ ]
597
+ : [[rehypeShiki, shikiOptions(themes)]])
598
+ .use(highlightCodeLines)
599
+ .use(wrapTablePlugin)
600
+ .use(sluggerPlugin, anchors.add.bind(anchors), config.headings)
601
+ .use(rehypeMinifyWhitespace)
602
+ .use(addHeadingAnchors, config.route)
603
+ .use(markElementsPlugin)
604
+ .process(html)
605
+ .then((file) => file.toString());
606
+ return { content, anchors: Array.from(anchors) };
607
+ }
608
+ catch (error) {
609
+ return { content: html, anchors: [], error };
610
+ }
611
+ }
612
+ const DEFAULT_LANGUAGE = 'ts';
613
+ /**
614
+ * What a cached block's options stand for in its key ({@link NgDocHighlightCache}). Bump `format`
615
+ * whenever {@link shikiOptions} or the cached plugin change.
616
+ */
617
+ const HIGHLIGHT_IDENTITY = {
618
+ format: 1,
619
+ defaultLanguage: DEFAULT_LANGUAGE,
620
+ fallbackLanguage: 'text',
621
+ addLanguageClass: true,
622
+ parseMetaString: 'json-without-backslashes',
623
+ tokenizeTimeLimit: 0,
624
+ };
625
+ /**
626
+ * The options both highlighting paths give `@shikijs/rehype`.
627
+ * @param themes - The theme names.
628
+ * @param themes.light - The light theme.
629
+ * @param themes.dark - The dark theme.
630
+ */
631
+ function shikiOptions(themes) {
441
632
  return {
442
- content: html,
443
- anchors: [],
444
- error
633
+ defaultLanguage: DEFAULT_LANGUAGE,
634
+ fallbackLanguage: 'text',
635
+ addLanguageClass: true,
636
+ parseMetaString: (meta) => JSON.parse(meta?.replace(/\\/g, '') || '{}'),
637
+ // Shiki stops tokenizing a line after 500 ms by default and silently colours the rest of
638
+ // it like the last token. A slow or pre-empted process then renders other HTML than a fast
639
+ // one, so the build output would depend on machine load. No limit keeps it deterministic.
640
+ tokenizeTimeLimit: 0,
641
+ themes: {
642
+ light: shikiTheme(themes.light),
643
+ dark: shikiTheme(themes.dark),
644
+ },
445
645
  };
446
- }
646
+ }
647
+ /**
648
+ * A Shiki theme by name. NgDoc's own theme is not bundled with Shiki, so its name resolves to a new
649
+ * copy of it; any other name is left to Shiki.
650
+ * @param name - The theme name.
651
+ */
652
+ function shikiTheme(name) {
653
+ return name === NG_DOC_SYNTAX_THEME_NAME ? ngDocSyntaxTheme() : name;
447
654
  }
448
655
 
449
656
  /**
@@ -452,19 +659,35 @@ async function processHtml(html, config) {
452
659
  * @param headings
453
660
  */
454
661
  function removeNotIndexableContentPlugin() {
455
- return tree => {
456
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
457
- // @ts-ignore
458
- return filter(tree, {
459
- cascade: true
460
- }, node => {
461
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
462
- // @ts-ignore
463
- const preWithCode = node?.tagName === 'pre' && node?.children?.some(isCodeNode);
464
- const notIndexable = node?.properties?.['indexable'] === 'false';
465
- return !node?.tagName || !preWithCode && !notIndexable;
466
- });
467
- };
662
+ return (tree) => {
663
+ // The slugger wraps a heading's decorative leading emoji in an `aria-hidden` span. It is not
664
+ // part of the heading's name: indexed, it became a search record of its own, under the
665
+ // previous section.
666
+ visit(tree, 'element', (node) => {
667
+ if (isHeading(node) && isHiddenSpan(node.children[0])) {
668
+ node.children = node.children.slice(1);
669
+ }
670
+ });
671
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
672
+ // @ts-ignore
673
+ return filter(tree, { cascade: true }, (node) => {
674
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
675
+ // @ts-ignore
676
+ const preWithCode = node?.tagName === 'pre' && node?.children?.some(isCodeNode);
677
+ const notIndexable = node?.properties?.['indexable'] === 'false';
678
+ return !node?.tagName || (!preWithCode && !notIndexable);
679
+ });
680
+ };
681
+ }
682
+ /**
683
+ * Whether the node is a span hidden from assistive technology, as the slugger renders the leading
684
+ * emoji of a heading.
685
+ * @param node - The first child of a heading.
686
+ */
687
+ function isHiddenSpan(node) {
688
+ return (node?.type === 'element' &&
689
+ node.tagName === 'span' &&
690
+ node.properties?.['ariaHidden'] === 'true');
468
691
  }
469
692
 
470
693
  /**
@@ -472,7 +695,10 @@ function removeNotIndexableContentPlugin() {
472
695
  * @param html
473
696
  */
474
697
  async function removeNotIndexableContent(html) {
475
- return rehype().use(removeNotIndexableContentPlugin).process(html).then(file => file.toString());
698
+ return rehype()
699
+ .use(removeNotIndexableContentPlugin)
700
+ .process(html)
701
+ .then((file) => file.toString());
476
702
  }
477
703
 
478
704
  /**
@@ -482,14 +708,13 @@ async function removeNotIndexableContent(html) {
482
708
  * @param config.getKeyword
483
709
  * @param getKeyword
484
710
  */
485
- async function replaceKeywords(html, {
486
- getKeyword
487
- }) {
488
- return unified().use(rehypeParse, {
489
- fragment: true
490
- }).use(rehypeStringify).use(keywordsPlugin, {
491
- getKeyword
492
- }).process(html).then(file => file.toString());
711
+ async function replaceKeywords(html, { getKeyword }) {
712
+ return unified()
713
+ .use(rehypeParse, { fragment: true })
714
+ .use(rehypeStringify)
715
+ .use(keywordsPlugin, { getKeyword })
716
+ .process(html)
717
+ .then((file) => file.toString());
493
718
  }
494
719
 
495
720
  /**
@@ -497,7 +722,7 @@ async function replaceKeywords(html, {
497
722
  * @param content
498
723
  */
499
724
  function stringifyEntities(content) {
500
- return stringifyEntities$1(content);
725
+ return stringifyEntities$1(content);
501
726
  }
502
727
 
503
728
  export { minify, postProcessHtml, processHtml, removeNotIndexableContent, replaceKeywords, stringifyEntities };