@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.
Files changed (33) hide show
  1. package/dist/components/button/button.svelte +6 -1
  2. package/dist/components/resizable-pane/resizable-handle.svelte +12 -3
  3. package/dist/components/select/select-tags.svelte +8 -1
  4. package/dist/components/text-editor/constants.d.ts +0 -1
  5. package/dist/components/text-editor/constants.js +1 -36
  6. package/dist/components/text-editor/core.js +24 -31
  7. package/dist/components/text-editor/lexical-root.svelte +0 -68
  8. package/dist/components/text-editor/shiki/cache.d.ts +3 -0
  9. package/dist/components/text-editor/shiki/cache.js +121 -0
  10. package/dist/components/text-editor/shiki/engine-entry.d.ts +2 -0
  11. package/dist/components/text-editor/shiki/engine-entry.js +20 -0
  12. package/dist/components/text-editor/shiki/facade.d.ts +16 -0
  13. package/dist/components/text-editor/shiki/facade.js +452 -0
  14. package/dist/components/text-editor/shiki/generated.d.ts +28 -0
  15. package/dist/components/text-editor/shiki/generated.js +25 -0
  16. package/dist/components/text-editor/shiki/highlighter.d.ts +11 -0
  17. package/dist/components/text-editor/shiki/highlighter.js +477 -0
  18. package/dist/components/text-editor/shiki/loader.d.ts +6 -0
  19. package/dist/components/text-editor/shiki/loader.js +102 -0
  20. package/dist/components/text-editor/shiki/theme.d.ts +12 -0
  21. package/dist/components/text-editor/shiki/theme.js +86 -0
  22. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -34
  23. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +1 -5
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/services/group.svelte.d.ts +2 -1
  27. package/dist/services/group.svelte.js +183 -50
  28. package/dist/services/tree.svelte.d.ts +8 -6
  29. package/dist/services/tree.svelte.js +104 -45
  30. package/dist/shiki-engine.js +152 -0
  31. package/dist/typedefs.d.ts +52 -0
  32. package/dist/typedefs.js +29 -0
  33. 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
+ };