@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/README.md +20 -29
- package/html/plugins/cached-shiki.plugin.d.ts +68 -0
- package/html/process-html.d.ts +9 -0
- package/index.esm.js +513 -288
- package/index.esm.js.map +1 -1
- package/package.json +22 -21
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
725
|
+
return stringifyEntities$1(content);
|
|
501
726
|
}
|
|
502
727
|
|
|
503
728
|
export { minify, postProcessHtml, processHtml, removeNotIndexableContent, replaceKeywords, stringifyEntities };
|