@sveltia/ui 0.62.0 → 0.63.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/dist/components/button/button.svelte +6 -1
- package/dist/components/resizable-pane/resizable-handle.svelte +12 -3
- package/dist/components/select/select-tags.svelte +8 -1
- package/dist/components/text-editor/constants.d.ts +0 -1
- package/dist/components/text-editor/constants.js +1 -36
- package/dist/components/text-editor/core.js +24 -31
- package/dist/components/text-editor/lexical-root.svelte +0 -68
- package/dist/components/text-editor/shiki/cache.d.ts +3 -0
- package/dist/components/text-editor/shiki/cache.js +121 -0
- package/dist/components/text-editor/shiki/engine-entry.d.ts +2 -0
- package/dist/components/text-editor/shiki/engine-entry.js +20 -0
- package/dist/components/text-editor/shiki/facade.d.ts +16 -0
- package/dist/components/text-editor/shiki/facade.js +452 -0
- package/dist/components/text-editor/shiki/generated.d.ts +28 -0
- package/dist/components/text-editor/shiki/generated.js +25 -0
- package/dist/components/text-editor/shiki/highlighter.d.ts +11 -0
- package/dist/components/text-editor/shiki/highlighter.js +477 -0
- package/dist/components/text-editor/shiki/loader.d.ts +6 -0
- package/dist/components/text-editor/shiki/loader.js +102 -0
- package/dist/components/text-editor/shiki/theme.d.ts +12 -0
- package/dist/components/text-editor/shiki/theme.js +86 -0
- package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -34
- package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +1 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/services/group.svelte.d.ts +2 -1
- package/dist/services/group.svelte.js +183 -50
- package/dist/services/tree.svelte.d.ts +8 -6
- package/dist/services/tree.svelte.js +104 -45
- package/dist/shiki-engine.js +152 -0
- package/dist/typedefs.d.ts +52 -0
- package/dist/typedefs.js +29 -0
- package/package.json +13 -11
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical node transforms that keep code blocks syntax highlighted.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from Lexical’s `CodeHighlighterShiki.ts` (MIT licensed). The transform logic is copied
|
|
5
|
+
* as-is; only the highlighting back end differs, because upstream imports its Shiki facade directly
|
|
6
|
+
* and offers no way to substitute one that loads grammars on demand.
|
|
7
|
+
* @see https://github.com/facebook/lexical/blob/v0.49.0/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
|
|
8
|
+
* @see https://github.com/sveltia/sveltia-cms/issues/587
|
|
9
|
+
*
|
|
10
|
+
* The extension APIs (`CodeShikiExtension`, `CodeHighlighterShikiExtension`) are intentionally left
|
|
11
|
+
* out, since this library registers the transforms directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
CodeHighlightNode,
|
|
16
|
+
CodeNode,
|
|
17
|
+
DEFAULT_CODE_LANGUAGE,
|
|
18
|
+
$isCodeHighlightNode as isCodeHighlightNode,
|
|
19
|
+
$isCodeNode as isCodeNode,
|
|
20
|
+
$plainifyCodeContent as plainifyCodeContent,
|
|
21
|
+
registerCodeIndentation,
|
|
22
|
+
} from '@lexical/code-core';
|
|
23
|
+
import { mergeRegister } from '@lexical/utils';
|
|
24
|
+
import {
|
|
25
|
+
$createTextNode as createTextNode,
|
|
26
|
+
$getNodeByKey as getNodeByKey,
|
|
27
|
+
$getSelection as getSelection,
|
|
28
|
+
$isLineBreakNode as isLineBreakNode,
|
|
29
|
+
$isRangeSelection as isRangeSelection,
|
|
30
|
+
$isTabNode as isTabNode,
|
|
31
|
+
$isTextNode as isTextNode,
|
|
32
|
+
$onUpdate as onUpdate,
|
|
33
|
+
HISTORY_MERGE_TAG,
|
|
34
|
+
TextNode,
|
|
35
|
+
} from 'lexical';
|
|
36
|
+
import {
|
|
37
|
+
getHighlightNodes,
|
|
38
|
+
isCodeLanguageLoaded,
|
|
39
|
+
isCodeThemeLoaded,
|
|
40
|
+
isEngineLoaded,
|
|
41
|
+
isEngineUnavailable,
|
|
42
|
+
isPlainLanguage,
|
|
43
|
+
loadCodeLanguage,
|
|
44
|
+
loadCodeTheme,
|
|
45
|
+
loadEngine,
|
|
46
|
+
} from './facade.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @import { CodeNode as CodeNodeType } from '@lexical/code-core';
|
|
50
|
+
* @import { LexicalEditor, LexicalNode, NodeKey } from 'lexical';
|
|
51
|
+
* @import { CodeTokenizer, CodeTransformState } from '../../../typedefs';
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const DEFAULT_CODE_THEME = 'github-light';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Default tokenizer backed by Shiki.
|
|
58
|
+
*
|
|
59
|
+
* Falls back to unhighlighted text whenever highlighting is impossible — a plain language, or an
|
|
60
|
+
* engine that has not loaded yet or could not be fetched — so a code block always renders.
|
|
61
|
+
* @type {CodeTokenizer}
|
|
62
|
+
*/
|
|
63
|
+
export const shikiTokenizer = {
|
|
64
|
+
defaultLanguage: DEFAULT_CODE_LANGUAGE,
|
|
65
|
+
defaultTheme: DEFAULT_CODE_THEME,
|
|
66
|
+
/**
|
|
67
|
+
* Convert a code node’s content into Lexical nodes.
|
|
68
|
+
* @param {CodeNodeType} codeNode Code node to tokenize.
|
|
69
|
+
* @param {string} [language] Language identifier.
|
|
70
|
+
* @returns {LexicalNode[]} Lexical nodes.
|
|
71
|
+
*/
|
|
72
|
+
tokenize(codeNode, language) {
|
|
73
|
+
const lang = language || this.defaultLanguage;
|
|
74
|
+
|
|
75
|
+
if (lang === null || isPlainLanguage(lang) || !isEngineLoaded()) {
|
|
76
|
+
return plainifyCodeContent(codeNode.getTextContent());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return getHighlightNodes(codeNode, lang);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Update the line number gutter of a code block.
|
|
85
|
+
* @param {CodeNodeType} node Code node.
|
|
86
|
+
* @param {LexicalEditor} editor Editor instance.
|
|
87
|
+
*/
|
|
88
|
+
const updateCodeGutter = (node, editor) => {
|
|
89
|
+
const codeElement = editor.getElementByKey(node.getKey());
|
|
90
|
+
|
|
91
|
+
if (codeElement === null) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const children = node.getChildren();
|
|
96
|
+
const childrenLength = children.length;
|
|
97
|
+
|
|
98
|
+
// @ts-ignore Internal field
|
|
99
|
+
if (childrenLength === codeElement.__cachedChildrenLength) {
|
|
100
|
+
// Avoid updating the attribute if the children length hasn’t changed
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// @ts-ignore Internal field
|
|
105
|
+
codeElement.__cachedChildrenLength = childrenLength;
|
|
106
|
+
|
|
107
|
+
let gutter = '1';
|
|
108
|
+
let count = 1;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < childrenLength; i += 1) {
|
|
111
|
+
if (isLineBreakNode(children[i])) {
|
|
112
|
+
count += 1;
|
|
113
|
+
gutter += `\n${count}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
codeElement.setAttribute('data-gutter', gutter);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compare two nodes for the purpose of diffing highlight output. Only code highlight nodes, tabs
|
|
122
|
+
* and line breaks are considered equal; a regular text node always compares unequal so that it gets
|
|
123
|
+
* transformed into a code highlight node.
|
|
124
|
+
* @param {LexicalNode} nodeA First node.
|
|
125
|
+
* @param {LexicalNode} nodeB Second node.
|
|
126
|
+
* @returns {boolean} Result.
|
|
127
|
+
*/
|
|
128
|
+
const isEqual = (nodeA, nodeB) =>
|
|
129
|
+
(isCodeHighlightNode(nodeA) &&
|
|
130
|
+
isCodeHighlightNode(nodeB) &&
|
|
131
|
+
// @ts-ignore Internal fields
|
|
132
|
+
nodeA.__text === nodeB.__text &&
|
|
133
|
+
// @ts-ignore Internal fields
|
|
134
|
+
nodeA.__highlightType === nodeB.__highlightType &&
|
|
135
|
+
// @ts-ignore Internal fields
|
|
136
|
+
nodeA.__style === nodeB.__style) ||
|
|
137
|
+
(isTabNode(nodeA) && isTabNode(nodeB)) ||
|
|
138
|
+
(isLineBreakNode(nodeA) && isLineBreakNode(nodeB));
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Find the minimal diff range between two node lists.
|
|
142
|
+
* @param {LexicalNode[]} prevNodes Current nodes.
|
|
143
|
+
* @param {LexicalNode[]} nextNodes Desired nodes.
|
|
144
|
+
* @returns {{ from: number, to: number, nodesForReplacement: LexicalNode[] }} Boundaries of
|
|
145
|
+
* `prevNodes` to replace, and the replacement nodes.
|
|
146
|
+
*/
|
|
147
|
+
const getDiffRange = (prevNodes, nextNodes) => {
|
|
148
|
+
let leadingMatch = 0;
|
|
149
|
+
|
|
150
|
+
while (leadingMatch < prevNodes.length) {
|
|
151
|
+
if (!isEqual(prevNodes[leadingMatch], nextNodes[leadingMatch])) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
leadingMatch += 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const prevNodesLength = prevNodes.length;
|
|
159
|
+
const nextNodesLength = nextNodes.length;
|
|
160
|
+
const maxTrailingMatch = Math.min(prevNodesLength, nextNodesLength) - leadingMatch;
|
|
161
|
+
let trailingMatch = 0;
|
|
162
|
+
|
|
163
|
+
while (trailingMatch < maxTrailingMatch) {
|
|
164
|
+
trailingMatch += 1;
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
!isEqual(
|
|
168
|
+
prevNodes[prevNodesLength - trailingMatch],
|
|
169
|
+
nextNodes[nextNodesLength - trailingMatch],
|
|
170
|
+
)
|
|
171
|
+
) {
|
|
172
|
+
trailingMatch -= 1;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
from: leadingMatch,
|
|
179
|
+
to: prevNodesLength - trailingMatch,
|
|
180
|
+
nodesForReplacement: nextNodes.slice(leadingMatch, nextNodesLength - trailingMatch),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run an update while trying to keep the cursor where it was.
|
|
186
|
+
* @param {NodeKey} nodeKey Key of the code node being updated.
|
|
187
|
+
* @param {() => boolean} updateFn Update to run. Should return whether anything changed.
|
|
188
|
+
*/
|
|
189
|
+
const updateAndRetainSelection = (nodeKey, updateFn) => {
|
|
190
|
+
const node = getNodeByKey(nodeKey);
|
|
191
|
+
|
|
192
|
+
if (!isCodeNode(node) || !node.isAttached()) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const selection = getSelection();
|
|
197
|
+
|
|
198
|
+
// If it’s not a range selection there’s no need to change it, but we can still run the
|
|
199
|
+
// highlighting logic
|
|
200
|
+
if (!isRangeSelection(selection)) {
|
|
201
|
+
updateFn();
|
|
202
|
+
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { anchor } = selection;
|
|
207
|
+
const anchorOffset = anchor.offset;
|
|
208
|
+
|
|
209
|
+
const isNewLineAnchor =
|
|
210
|
+
anchor.type === 'element' && isLineBreakNode(node.getChildAtIndex(anchor.offset - 1));
|
|
211
|
+
|
|
212
|
+
let textOffset = 0;
|
|
213
|
+
|
|
214
|
+
// Calculate the previous text offset: all text nodes prior to the anchor, plus the anchor’s own
|
|
215
|
+
// text offset
|
|
216
|
+
if (!isNewLineAnchor) {
|
|
217
|
+
const anchorNode = anchor.getNode();
|
|
218
|
+
|
|
219
|
+
textOffset =
|
|
220
|
+
anchorOffset +
|
|
221
|
+
anchorNode
|
|
222
|
+
.getPreviousSiblings()
|
|
223
|
+
.reduce((offset, node_) => offset + node_.getTextContentSize(), 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!updateFn()) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Non-text anchors only happen for line breaks, otherwise the selection will be within a text
|
|
231
|
+
// node
|
|
232
|
+
if (isNewLineAnchor) {
|
|
233
|
+
anchor.getNode().select(anchorOffset, anchorOffset);
|
|
234
|
+
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// If it was a non-element anchor, walk through the child nodes looking for the position of the
|
|
239
|
+
// original text offset
|
|
240
|
+
node.getChildren().some((node_) => {
|
|
241
|
+
const isText = isTextNode(node_);
|
|
242
|
+
|
|
243
|
+
if (isText || isLineBreakNode(node_)) {
|
|
244
|
+
const textContentSize = node_.getTextContentSize();
|
|
245
|
+
|
|
246
|
+
if (isText && textContentSize >= textOffset) {
|
|
247
|
+
node_.select(textOffset, textOffset);
|
|
248
|
+
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
textOffset -= textContentSize;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return false;
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Re-highlight a code node.
|
|
261
|
+
* @param {LexicalEditor} editor Editor instance.
|
|
262
|
+
* @param {CodeTokenizer} tokenizer Tokenizer.
|
|
263
|
+
* @param {CodeTransformState} transformState Shared transform state.
|
|
264
|
+
* @param {CodeNodeType} node Code node to transform.
|
|
265
|
+
*/
|
|
266
|
+
const codeNodeTransform = (editor, tokenizer, transformState, node) => {
|
|
267
|
+
const nodeKey = node.getKey();
|
|
268
|
+
const { nodesCurrentlyHighlighting } = transformState;
|
|
269
|
+
// A newly inserted code block might not have a language yet. A tokenizer configured with
|
|
270
|
+
// `defaultLanguage: null` opts out of the implicit fallback, so that Markdown round-trips ```
|
|
271
|
+
// with no info string.
|
|
272
|
+
let language = node.getLanguage();
|
|
273
|
+
|
|
274
|
+
if (!language && tokenizer.defaultLanguage !== null) {
|
|
275
|
+
language = tokenizer.defaultLanguage;
|
|
276
|
+
node.setLanguage(language);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let theme = node.getTheme();
|
|
280
|
+
|
|
281
|
+
if (!theme) {
|
|
282
|
+
theme = tokenizer.defaultTheme;
|
|
283
|
+
node.setTheme(theme);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Plain text needs no engine, grammar or theme, so it is highlighted — that is, rendered verbatim
|
|
287
|
+
// — without a single network request. This is the common case: it is the default for a new block.
|
|
288
|
+
const isPlain = !language || isPlainLanguage(language);
|
|
289
|
+
let inFlight = false;
|
|
290
|
+
|
|
291
|
+
// `!language` is redundant with `isPlain`, but it lets the type checker narrow `language` to a
|
|
292
|
+
// string in the branches below
|
|
293
|
+
if (isPlain || !language || isEngineUnavailable()) {
|
|
294
|
+
if (node.getIsSyntaxHighlightSupported()) {
|
|
295
|
+
node.setIsSyntaxHighlightSupported(false);
|
|
296
|
+
}
|
|
297
|
+
} else if (!isEngineLoaded()) {
|
|
298
|
+
// Dynamic load of the engine itself
|
|
299
|
+
loadEngine(editor, nodeKey);
|
|
300
|
+
inFlight = true;
|
|
301
|
+
} else {
|
|
302
|
+
// Dynamic load of themes
|
|
303
|
+
if (!isCodeThemeLoaded(theme)) {
|
|
304
|
+
loadCodeTheme(theme, editor, nodeKey);
|
|
305
|
+
inFlight = true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Dynamic load of languages
|
|
309
|
+
if (isCodeLanguageLoaded(language)) {
|
|
310
|
+
if (!node.getIsSyntaxHighlightSupported()) {
|
|
311
|
+
node.setIsSyntaxHighlightSupported(true);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
const loadingTask = loadCodeLanguage(language, editor, nodeKey);
|
|
315
|
+
|
|
316
|
+
// If the language is not supported, no download will occur
|
|
317
|
+
if (!loadingTask && node.getIsSyntaxHighlightSupported()) {
|
|
318
|
+
node.setIsSyntaxHighlightSupported(false);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
inFlight = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (inFlight) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (nodesCurrentlyHighlighting.has(nodeKey)) {
|
|
330
|
+
// Upstream drops this edit, which loses the last keystroke of a fast burst. Schedule one
|
|
331
|
+
// re-highlight for after the in-progress pass settles instead, deduplicated per node so a
|
|
332
|
+
// burst does not queue an update each.
|
|
333
|
+
if (!transformState.pendingRefresh.has(nodeKey)) {
|
|
334
|
+
transformState.pendingRefresh.add(nodeKey);
|
|
335
|
+
|
|
336
|
+
onUpdate(() => {
|
|
337
|
+
transformState.pendingRefresh.delete(nodeKey);
|
|
338
|
+
|
|
339
|
+
editor.update(
|
|
340
|
+
() => {
|
|
341
|
+
const staleNode = getNodeByKey(nodeKey);
|
|
342
|
+
|
|
343
|
+
if (isCodeNode(staleNode)) {
|
|
344
|
+
staleNode.markDirty();
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{ tag: HISTORY_MERGE_TAG },
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
nodesCurrentlyHighlighting.add(nodeKey);
|
|
356
|
+
|
|
357
|
+
if (!transformState.didTransform) {
|
|
358
|
+
transformState.didTransform = true;
|
|
359
|
+
|
|
360
|
+
onUpdate(() => {
|
|
361
|
+
transformState.didTransform = false;
|
|
362
|
+
nodesCurrentlyHighlighting.clear();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
updateAndRetainSelection(nodeKey, () => {
|
|
367
|
+
const currentNode = getNodeByKey(nodeKey);
|
|
368
|
+
|
|
369
|
+
if (!isCodeNode(currentNode) || !currentNode.isAttached()) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const lang = currentNode.getLanguage() || tokenizer.defaultLanguage;
|
|
374
|
+
const highlightNodes = tokenizer.tokenize(currentNode, lang ?? undefined);
|
|
375
|
+
|
|
376
|
+
const { from, to, nodesForReplacement } = getDiffRange(
|
|
377
|
+
currentNode.getChildren(),
|
|
378
|
+
highlightNodes,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
if (from !== to || nodesForReplacement.length) {
|
|
382
|
+
node.splice(from, to - from, nodesForReplacement);
|
|
383
|
+
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return false;
|
|
388
|
+
});
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Re-highlight the code block a text node belongs to.
|
|
393
|
+
* @param {LexicalEditor} editor Editor instance.
|
|
394
|
+
* @param {CodeTokenizer} tokenizer Tokenizer.
|
|
395
|
+
* @param {CodeTransformState} transformState Shared transform state.
|
|
396
|
+
* @param {TextNode} node Text node that changed.
|
|
397
|
+
*/
|
|
398
|
+
const textNodeTransform = (editor, tokenizer, transformState, node) => {
|
|
399
|
+
// `CodeNode` has a flat children structure, so we only need to check whether the node’s parent is
|
|
400
|
+
// a code node and run highlighting if so
|
|
401
|
+
const parentNode = node.getParent();
|
|
402
|
+
|
|
403
|
+
if (isCodeNode(parentNode)) {
|
|
404
|
+
codeNodeTransform(editor, tokenizer, transformState, parentNode);
|
|
405
|
+
} else if (isCodeHighlightNode(node)) {
|
|
406
|
+
// When a code block is converted into a paragraph or other element, code highlight nodes are
|
|
407
|
+
// converted back to normal text
|
|
408
|
+
// @ts-ignore Internal field
|
|
409
|
+
node.replace(createTextNode(node.__text));
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Register syntax highlighting, along with the indentation and arrow key handlers.
|
|
415
|
+
* @param {LexicalEditor} editor Editor instance.
|
|
416
|
+
* @param {CodeTokenizer} [tokenizer] Tokenizer to use.
|
|
417
|
+
* @returns {() => void} Cleanup function.
|
|
418
|
+
* @throws {Error} When the required nodes are not registered on the editor.
|
|
419
|
+
*/
|
|
420
|
+
export const registerCodeHighlighting = (editor, tokenizer = shikiTokenizer) => {
|
|
421
|
+
if (!editor.hasNodes([CodeNode, CodeHighlightNode])) {
|
|
422
|
+
throw new Error('CodeNode or CodeHighlightNode not registered on editor');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** @type {Array<() => void>} */
|
|
426
|
+
const registrations = [];
|
|
427
|
+
|
|
428
|
+
// Only register the mutation listener if not in headless mode
|
|
429
|
+
/* v8 ignore next 18 */
|
|
430
|
+
// @ts-ignore Internal field
|
|
431
|
+
if (editor._headless !== true) {
|
|
432
|
+
registrations.push(
|
|
433
|
+
editor.registerMutationListener(
|
|
434
|
+
CodeNode,
|
|
435
|
+
(mutations) => {
|
|
436
|
+
editor.read('latest', () => {
|
|
437
|
+
mutations.forEach((type, key) => {
|
|
438
|
+
if (type !== 'destroyed') {
|
|
439
|
+
const node = getNodeByKey(key);
|
|
440
|
+
|
|
441
|
+
if (node !== null) {
|
|
442
|
+
updateCodeGutter(/** @type {CodeNodeType} */ (node), editor);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
{ skipInitialization: false },
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** @type {CodeTransformState} */
|
|
454
|
+
const transformState = {
|
|
455
|
+
didTransform: false,
|
|
456
|
+
nodesCurrentlyHighlighting: new Set(),
|
|
457
|
+
pendingRefresh: new Set(),
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
registrations.push(
|
|
461
|
+
editor.registerNodeTransform(
|
|
462
|
+
CodeNode,
|
|
463
|
+
codeNodeTransform.bind(null, editor, tokenizer, transformState),
|
|
464
|
+
),
|
|
465
|
+
editor.registerNodeTransform(
|
|
466
|
+
TextNode,
|
|
467
|
+
textNodeTransform.bind(null, editor, tokenizer, transformState),
|
|
468
|
+
),
|
|
469
|
+
editor.registerNodeTransform(
|
|
470
|
+
CodeHighlightNode,
|
|
471
|
+
textNodeTransform.bind(null, editor, tokenizer, transformState),
|
|
472
|
+
),
|
|
473
|
+
registerCodeIndentation(editor),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return mergeRegister(...registrations);
|
|
477
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function getEngineURL(): string;
|
|
2
|
+
export function getLanguageURL(id: string): string;
|
|
3
|
+
export function getThemeURL(id: string): string;
|
|
4
|
+
export function setCodeHighlighterLoaders(overrides: Partial<CodeHighlighterLoaders>): void;
|
|
5
|
+
export function getCodeHighlighterLoaders(): CodeHighlighterLoaders;
|
|
6
|
+
import type { CodeHighlighterLoaders } from '../../../typedefs';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { SHIKI_VERSION, UI_VERSION } from './generated.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import { CodeHighlighterLoaders } from '../../../typedefs';
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SHIKI_CDN_BASE_URL = 'https://unpkg.com/@shikijs';
|
|
8
|
+
const UI_CDN_BASE_URL = 'https://unpkg.com/@sveltia/ui';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the URL of the prebuilt syntax highlighting engine.
|
|
12
|
+
*
|
|
13
|
+
* The engine is a single self-contained chunk published with this package, so its version is this
|
|
14
|
+
* package’s. Loading `@shikijs/core` from a CDN directly would instead fan out to some 50 requests,
|
|
15
|
+
* because its dependency graph is preserved module by module.
|
|
16
|
+
* @returns {string} URL.
|
|
17
|
+
*/
|
|
18
|
+
export const getEngineURL = () => `${UI_CDN_BASE_URL}@${UI_VERSION}/dist/shiki-engine.js`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the URL of a syntax highlighting grammar.
|
|
22
|
+
*
|
|
23
|
+
* Grammars reference their embedded languages by relative path, so importing one grammar URL pulls
|
|
24
|
+
* in whatever else it needs without any manifest of ours. The version must match the engine’s,
|
|
25
|
+
* which `generated.js` guarantees by recording the Shiki version the engine was built from.
|
|
26
|
+
* @param {string} id Canonical language ID.
|
|
27
|
+
* @returns {string} URL.
|
|
28
|
+
*/
|
|
29
|
+
export const getLanguageURL = (id) => `${SHIKI_CDN_BASE_URL}/langs@${SHIKI_VERSION}/dist/${id}.mjs`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the URL of a syntax highlighting theme.
|
|
33
|
+
* @param {string} id Theme ID.
|
|
34
|
+
* @returns {string} URL.
|
|
35
|
+
*/
|
|
36
|
+
export const getThemeURL = (id) => `${SHIKI_CDN_BASE_URL}/themes@${SHIKI_VERSION}/dist/${id}.mjs`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load the prebuilt syntax highlighting engine.
|
|
40
|
+
* @returns {Promise<any>} Loaded module.
|
|
41
|
+
*/
|
|
42
|
+
const loadEngine = async () =>
|
|
43
|
+
// eslint-disable-next-line jsdoc/no-bad-blocks
|
|
44
|
+
import(/* @vite-ignore */ getEngineURL());
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load a syntax highlighting grammar.
|
|
48
|
+
* @param {string} id Canonical language ID.
|
|
49
|
+
* @returns {Promise<any>} Loaded module.
|
|
50
|
+
*/
|
|
51
|
+
const loadLanguage = async (id) =>
|
|
52
|
+
// eslint-disable-next-line jsdoc/no-bad-blocks
|
|
53
|
+
import(/* @vite-ignore */ getLanguageURL(id));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load a syntax highlighting theme.
|
|
57
|
+
* @param {string} id Theme ID.
|
|
58
|
+
* @returns {Promise<any>} Loaded module.
|
|
59
|
+
*/
|
|
60
|
+
const loadTheme = async (id) =>
|
|
61
|
+
// eslint-disable-next-line jsdoc/no-bad-blocks
|
|
62
|
+
import(/* @vite-ignore */ getThemeURL(id));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Loaders in effect. Everything is fetched from a CDN on demand by default, so that nothing
|
|
66
|
+
* Shiki-related ends up in the consumer’s bundle.
|
|
67
|
+
* @type {CodeHighlighterLoaders}
|
|
68
|
+
*/
|
|
69
|
+
let loaders = { loadEngine, loadLanguage, loadTheme };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Override how the code editor obtains the Shiki engine, grammars and themes.
|
|
73
|
+
*
|
|
74
|
+
* By default these are fetched from a CDN on demand, which keeps them out of the bundle entirely.
|
|
75
|
+
* Consumers who would rather bundle them, self-host them, or ship only a handful of languages can
|
|
76
|
+
* replace any of the loaders. Note that a bundler cannot resolve a dynamic import with a variable
|
|
77
|
+
* bare specifier, so bundling grammars needs a map of static imports.
|
|
78
|
+
*
|
|
79
|
+
* ```js
|
|
80
|
+
* setCodeHighlighterLoaders({
|
|
81
|
+
* loadLanguage: (id) =>
|
|
82
|
+
* ({
|
|
83
|
+
* astro: () => import('@shikijs/langs/astro'),
|
|
84
|
+
* typescript: () => import('@shikijs/langs/typescript'),
|
|
85
|
+
* })[id]?.(),
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* A loader may return a falsy value for an unsupported language or theme, in which case the code
|
|
90
|
+
* block is rendered as plain text.
|
|
91
|
+
* @param {Partial<CodeHighlighterLoaders>} overrides Loaders to replace. Any omitted loader keeps
|
|
92
|
+
* its current implementation.
|
|
93
|
+
*/
|
|
94
|
+
export const setCodeHighlighterLoaders = (overrides) => {
|
|
95
|
+
loaders = { ...loaders, ...overrides };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the loaders currently in effect.
|
|
100
|
+
* @returns {CodeHighlighterLoaders} Active loaders.
|
|
101
|
+
*/
|
|
102
|
+
export const getCodeHighlighterLoaders = () => loaders;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { LexicalEditor } from 'lexical';
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Shiki themes matching the app’s light and dark appearance. Only the per-token colours are used;
|
|
6
|
+
* the code block’s own background comes from `--sui-code-background-color`.
|
|
7
|
+
*/
|
|
8
|
+
export const CODE_THEME_LIGHT: "github-light";
|
|
9
|
+
export const CODE_THEME_DARK: "github-dark";
|
|
10
|
+
export function getCodeTheme(): string;
|
|
11
|
+
export function observeCodeTheme(editor: LexicalEditor): () => void;
|
|
12
|
+
import type { LexicalEditor } from 'lexical';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { $isCodeNode as isCodeNode } from '@lexical/code-core';
|
|
2
|
+
import { $getRoot as getRoot, HISTORY_MERGE_TAG } from 'lexical';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @import { LexicalEditor } from 'lexical';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shiki themes matching the app’s light and dark appearance. Only the per-token colours are used;
|
|
10
|
+
* the code block’s own background comes from `--sui-code-background-color`.
|
|
11
|
+
*/
|
|
12
|
+
export const CODE_THEME_LIGHT = 'github-light';
|
|
13
|
+
export const CODE_THEME_DARK = 'github-dark';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the syntax highlighting theme matching the current appearance.
|
|
17
|
+
* @returns {string} Shiki theme ID.
|
|
18
|
+
*/
|
|
19
|
+
export const getCodeTheme = () => {
|
|
20
|
+
/* v8 ignore next 3 */
|
|
21
|
+
if (typeof document === 'undefined') {
|
|
22
|
+
return CODE_THEME_LIGHT;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { theme } = document.documentElement.dataset;
|
|
26
|
+
|
|
27
|
+
if (theme) {
|
|
28
|
+
return theme === 'dark' ? CODE_THEME_DARK : CODE_THEME_LIGHT;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
32
|
+
? CODE_THEME_DARK
|
|
33
|
+
: CODE_THEME_LIGHT;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Keep code blocks in sync with the app’s appearance.
|
|
38
|
+
*
|
|
39
|
+
* Unlike Prism, Shiki bakes token colours into inline styles, so a theme change cannot be handled
|
|
40
|
+
* in CSS alone: every code node has to be re-tokenized.
|
|
41
|
+
* @param {LexicalEditor} editor Editor instance.
|
|
42
|
+
* @returns {() => void} Cleanup function.
|
|
43
|
+
*/
|
|
44
|
+
export const observeCodeTheme = (editor) => {
|
|
45
|
+
/* v8 ignore next 3 */
|
|
46
|
+
if (typeof document === 'undefined') {
|
|
47
|
+
return () => undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Apply the current theme to every code block in the editor.
|
|
52
|
+
*/
|
|
53
|
+
const update = () => {
|
|
54
|
+
const theme = getCodeTheme();
|
|
55
|
+
|
|
56
|
+
editor.update(
|
|
57
|
+
() => {
|
|
58
|
+
getRoot()
|
|
59
|
+
.getChildren()
|
|
60
|
+
.forEach((node) => {
|
|
61
|
+
if (isCodeNode(node) && node.getTheme() !== theme) {
|
|
62
|
+
node.setTheme(theme);
|
|
63
|
+
node.markDirty();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
{ tag: HISTORY_MERGE_TAG },
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const observer = new MutationObserver(update);
|
|
72
|
+
|
|
73
|
+
observer.observe(document.documentElement, {
|
|
74
|
+
attributes: true,
|
|
75
|
+
attributeFilter: ['data-theme'],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
79
|
+
|
|
80
|
+
media.addEventListener('change', update);
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
observer.disconnect();
|
|
84
|
+
media.removeEventListener('change', update);
|
|
85
|
+
};
|
|
86
|
+
};
|