@adecore/editor 0.22.1 → 0.23.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 (94) hide show
  1. package/dist/attribution.d.ts +9 -8
  2. package/dist/attribution.js +44 -9
  3. package/dist/bidi.d.ts +44 -0
  4. package/dist/bidi.js +26 -0
  5. package/dist/column-selection.js +2 -1
  6. package/dist/controller.d.ts +6 -1
  7. package/dist/controller.js +93 -3
  8. package/dist/editor.css +81 -1
  9. package/dist/engine.js +184 -41
  10. package/dist/fake.d.ts +43 -5
  11. package/dist/fake.js +117 -13
  12. package/dist/find-search.d.ts +26 -0
  13. package/dist/find-search.js +130 -0
  14. package/dist/find.d.ts +50 -2
  15. package/dist/find.js +575 -25
  16. package/dist/geometry-stops.d.ts +6 -0
  17. package/dist/geometry-stops.js +47 -0
  18. package/dist/grammar-preparation.d.ts +29 -0
  19. package/dist/grammar-preparation.js +147 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +2 -0
  22. package/dist/keymap-table.d.ts +10 -1
  23. package/dist/keymap-table.js +37 -1
  24. package/dist/keymap.d.ts +1 -0
  25. package/dist/keymap.js +4 -1
  26. package/dist/layout-changes.d.ts +6 -0
  27. package/dist/layout-changes.js +27 -0
  28. package/dist/layout.d.ts +45 -2
  29. package/dist/layout.js +202 -58
  30. package/dist/offsets.d.ts +1 -0
  31. package/dist/offsets.js +23 -0
  32. package/dist/outline.js +5 -4
  33. package/dist/paint.d.ts +11 -3
  34. package/dist/paint.js +180 -34
  35. package/dist/search-client.d.ts +27 -0
  36. package/dist/search-client.js +210 -0
  37. package/dist/search-protocol.d.ts +103 -0
  38. package/dist/search-protocol.js +3 -0
  39. package/dist/search-worker.d.ts +11 -0
  40. package/dist/search-worker.js +137 -0
  41. package/dist/semantic.d.ts +2 -1
  42. package/dist/semantic.js +6 -1
  43. package/dist/shiki-document.d.ts +8 -0
  44. package/dist/shiki-document.js +93 -0
  45. package/dist/shiki-worker.d.ts +65 -0
  46. package/dist/shiki-worker.js +265 -0
  47. package/dist/shiki.d.ts +1 -0
  48. package/dist/shiki.js +96 -33
  49. package/dist/testing.d.ts +1 -0
  50. package/dist/tokens.d.ts +2 -0
  51. package/dist/tokens.js +37 -7
  52. package/dist/touch.d.ts +27 -0
  53. package/dist/touch.js +100 -0
  54. package/dist/types.d.ts +84 -2
  55. package/dist/uniform-geometry.d.ts +7 -0
  56. package/dist/uniform-geometry.js +49 -0
  57. package/dist/view.d.ts +68 -17
  58. package/dist/view.js +424 -97
  59. package/dist/worker-client.d.ts +37 -0
  60. package/dist/worker-client.js +195 -0
  61. package/package.json +17 -2
  62. package/src/attribution.ts +50 -14
  63. package/src/bidi.ts +32 -0
  64. package/src/column-selection.ts +2 -1
  65. package/src/controller.ts +97 -3
  66. package/src/editor.css +81 -1
  67. package/src/engine.ts +216 -42
  68. package/src/fake.ts +152 -14
  69. package/src/find-search.ts +165 -0
  70. package/src/find.ts +622 -21
  71. package/src/geometry-stops.ts +51 -0
  72. package/src/grammar-preparation.ts +158 -0
  73. package/src/index.ts +10 -0
  74. package/src/keymap-table.ts +44 -2
  75. package/src/keymap.ts +5 -1
  76. package/src/layout-changes.ts +33 -0
  77. package/src/layout.ts +262 -63
  78. package/src/offsets.ts +24 -0
  79. package/src/outline.ts +5 -4
  80. package/src/paint.ts +202 -36
  81. package/src/search-client.ts +306 -0
  82. package/src/search-protocol.ts +100 -0
  83. package/src/search-worker.ts +171 -0
  84. package/src/semantic.ts +8 -2
  85. package/src/shiki-document.ts +143 -0
  86. package/src/shiki-worker.ts +344 -0
  87. package/src/shiki.ts +103 -33
  88. package/src/testing.ts +2 -0
  89. package/src/tokens.ts +36 -7
  90. package/src/touch.ts +134 -0
  91. package/src/types.ts +174 -8
  92. package/src/uniform-geometry.ts +57 -0
  93. package/src/view.ts +500 -102
  94. package/src/worker-client.ts +212 -0
@@ -0,0 +1,143 @@
1
+ import type { GrammarState, Highlighter, ShikiTransformer, ThemedToken } from 'shiki';
2
+ import { documentLanguageOf, loadGrammar } from './shiki.ts';
3
+
4
+ export interface ShikiDocumentTokenOptions {
5
+ readonly reset?: ReadonlySet<number>;
6
+ readonly tokenizeMaxLineLength?: number;
7
+ readonly tokenizeTimeLimit?: number;
8
+ }
9
+
10
+ type TokenOptions = Parameters<Highlighter['codeToTokensBase']>[1];
11
+
12
+ export function shikiDocumentHighlighter(
13
+ highlighter: () => Promise<Highlighter>,
14
+ blocks: readonly string[],
15
+ language: string,
16
+ theme: string,
17
+ transformers: readonly ShikiTransformer[] = []
18
+ ): (index: number) => Promise<string> {
19
+ return documentHighlighter(highlighter, blocks, language, theme, (loaded, code, grammar, theme, state) => {
20
+ let root: Parameters<NonNullable<ShikiTransformer['root']>>[0] | undefined;
21
+ const value = loaded.codeToHtml(code, {
22
+ lang: grammar,
23
+ theme,
24
+ grammarState: state,
25
+ transformers: [
26
+ ...transformers,
27
+ {
28
+ root(node) {
29
+ root = node;
30
+ }
31
+ }
32
+ ]
33
+ });
34
+ return { value, state: root === undefined ? undefined : loaded.getLastGrammarState(root) };
35
+ });
36
+ }
37
+
38
+ export function shikiDocumentTokens(
39
+ highlighter: () => Promise<Highlighter>,
40
+ blocks: readonly string[],
41
+ language: string,
42
+ theme: string,
43
+ options: ShikiDocumentTokenOptions = {}
44
+ ): (index: number) => Promise<ThemedToken[][]> {
45
+ return documentHighlighter(
46
+ highlighter,
47
+ blocks,
48
+ language,
49
+ theme,
50
+ (loaded, code, grammar, theme, state) => {
51
+ // Shiki 4.4 caches the active theme by argument identity, even after a same-name replacement.
52
+ const value = loaded.codeToTokensBase(code, {
53
+ lang: grammar,
54
+ theme: loaded.getTheme(theme),
55
+ grammarState: state,
56
+ tokenizeMaxLineLength: options.tokenizeMaxLineLength,
57
+ tokenizeTimeLimit: options.tokenizeTimeLimit
58
+ } as TokenOptions);
59
+ return { value, state: loaded.getLastGrammarState(value) };
60
+ },
61
+ options.reset
62
+ );
63
+ }
64
+
65
+ function documentHighlighter<Value>(
66
+ highlighter: () => Promise<Highlighter>,
67
+ blocks: readonly string[],
68
+ language: string,
69
+ theme: string,
70
+ render: (
71
+ highlighter: Highlighter,
72
+ code: string,
73
+ language: string,
74
+ theme: string,
75
+ state: GrammarState | undefined
76
+ ) => { value: Value; state: GrammarState | undefined },
77
+ reset: ReadonlySet<number> = new Set()
78
+ ): (index: number) => Promise<Value> {
79
+ const resets = new Set(reset);
80
+ // A CR before the removed LF belongs to the block separator, not the last source line.
81
+ const code = blocks.map((block, index) => (index < blocks.length - 1 ? block.replace(/\r$/, '') : block));
82
+ let prepared: Promise<{ highlighter: Highlighter; language: string }> | undefined;
83
+ let pending = Promise.resolve();
84
+ let grammar: unknown;
85
+ let themeRegistration: unknown;
86
+ let state: GrammarState | undefined;
87
+ let values: Value[] = [];
88
+
89
+ const prepare = async (): Promise<{ highlighter: Highlighter; language: string }> => {
90
+ const loaded = await highlighter();
91
+ if (!loaded.getLoadedThemes().includes(theme)) {
92
+ await loaded.loadTheme(theme as Parameters<Highlighter['loadTheme']>[0]);
93
+ }
94
+ const documentLanguage = documentLanguageOf(language);
95
+ const known =
96
+ loaded.getLoadedLanguages().includes(documentLanguage) || documentLanguage in loaded.getBundledLanguages() || documentLanguage !== language;
97
+ const grammar = known && (await loadGrammar(loaded, documentLanguage, code.join('\n'))) ? documentLanguage : 'text';
98
+ return { highlighter: loaded, language: grammar };
99
+ };
100
+
101
+ return (index) => {
102
+ if (!Number.isInteger(index) || index < 0 || index >= code.length) {
103
+ return Promise.reject(new RangeError('The highlighted block index is outside the document.'));
104
+ }
105
+ const result = pending.then(async () => {
106
+ prepared ??= prepare().catch((error: unknown) => {
107
+ prepared = undefined;
108
+ throw error;
109
+ });
110
+ const { highlighter: loaded, language: grammarId } = await prepared;
111
+ const refresh = (): void => {
112
+ const currentGrammar = grammarId === 'text' ? undefined : loaded.getLanguage(grammarId);
113
+ const currentTheme = loaded.getTheme(theme);
114
+ // Loading an embedded language rebuilds Shiki's grammar and invalidates its old states.
115
+ if (grammar !== currentGrammar || themeRegistration !== currentTheme) {
116
+ grammar = currentGrammar;
117
+ themeRegistration = currentTheme;
118
+ state = undefined;
119
+ values = [];
120
+ }
121
+ };
122
+ refresh();
123
+ while (values.length <= index) {
124
+ const block = values.length;
125
+ const rendered = render(loaded, code[block]!, grammarId, theme, state);
126
+ state = resets.has(block + 1) ? undefined : rendered.state;
127
+ values.push(rendered.value);
128
+ if (block < index) {
129
+ // A jump deep into a file must leave room for input and painting between blocks.
130
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
131
+ refresh();
132
+ }
133
+ }
134
+ return values[index]!;
135
+ });
136
+ // A failed theme or grammar load can be retried without rejecting every later request.
137
+ pending = result.then(
138
+ () => undefined,
139
+ () => undefined
140
+ );
141
+ return result;
142
+ };
143
+ }
@@ -0,0 +1,344 @@
1
+ import type { Highlighter, ThemeRegistration, ThemedToken } from 'shiki';
2
+ import { shikiDocumentTokens } from './shiki-document.ts';
3
+ import { documentLanguageOf } from './shiki.ts';
4
+ import type { LineToken } from './types.ts';
5
+
6
+ export interface DiffSyntaxDocument {
7
+ readonly language: string;
8
+ readonly theme: string;
9
+ readonly blocks: readonly string[];
10
+ readonly resets: readonly number[];
11
+ }
12
+
13
+ export type DiffSyntaxInput =
14
+ | { readonly type: 'start'; readonly themes: readonly ThemeRegistration[]; readonly maxLines: number }
15
+ | { readonly type: 'request'; readonly generation: number; readonly index: number; readonly document?: DiffSyntaxDocument }
16
+ | { readonly type: 'cancel'; readonly generation: number }
17
+ | { readonly type: 'dispose'; readonly generation: number };
18
+
19
+ export interface DiffSyntaxTokens {
20
+ readonly type: 'tokens';
21
+ readonly generation: number;
22
+ readonly index: number;
23
+ readonly colors: readonly string[];
24
+ readonly foreground: string;
25
+ readonly background: string;
26
+ readonly lines: Uint32Array;
27
+ readonly tokens: Uint32Array;
28
+ }
29
+
30
+ export type DiffSyntaxOutput =
31
+ | DiffSyntaxTokens
32
+ | { readonly type: 'ready' }
33
+ | { readonly type: 'error'; readonly message: string }
34
+ | { readonly type: 'failed'; readonly generation: number; readonly index: number; readonly reason: 'missing' | 'highlight'; readonly message: string };
35
+
36
+ export interface DiffSyntaxService {
37
+ handle(message: DiffSyntaxInput): Promise<void>;
38
+ dispose(): void;
39
+ }
40
+
41
+ export interface DiffSyntaxServiceOptions {
42
+ readonly highlighter: () => Promise<Highlighter>;
43
+ readonly send: (message: DiffSyntaxOutput, transfer?: Transferable[]) => void;
44
+ readonly yield?: () => Promise<void>;
45
+ }
46
+
47
+ interface Entry {
48
+ document: DiffSyntaxDocument;
49
+ tokens: (index: number) => Promise<ThemedToken[][]>;
50
+ count: number;
51
+ done: number;
52
+ grammar?: unknown;
53
+ theme?: unknown;
54
+ }
55
+
56
+ interface Request {
57
+ generation: number;
58
+ index: number;
59
+ resolve: Array<() => void>;
60
+ }
61
+
62
+ function yieldToHost(): Promise<void> {
63
+ return new Promise((resolve) => {
64
+ const channel = new MessageChannel();
65
+ channel.port1.onmessage = () => {
66
+ channel.port1.close();
67
+ channel.port2.close();
68
+ resolve();
69
+ };
70
+ channel.port2.postMessage(null);
71
+ });
72
+ }
73
+
74
+ function packedTokens(
75
+ generation: number,
76
+ index: number,
77
+ code: string,
78
+ tokens: readonly ThemedToken[][],
79
+ appearance: { foreground: string; background: string }
80
+ ): DiffSyntaxTokens {
81
+ const colors: string[] = [''];
82
+ const colorIds = new Map([['', 0]]);
83
+ const starts = [0];
84
+ for (const match of code.matchAll(/\r\n|\n/g)) {
85
+ starts.push(match.index + match[0].length);
86
+ }
87
+ const lines = new Uint32Array(tokens.length + 1);
88
+ const packed = new Uint32Array(tokens.reduce((count, line) => count + line.length, 0) * 3);
89
+ let cursor = 0;
90
+ for (const [index, line] of tokens.entries()) {
91
+ lines[index] = cursor / 3;
92
+ for (const token of line) {
93
+ const color = token.color ?? '';
94
+ let colorId = colorIds.get(color);
95
+ if (colorId === undefined) {
96
+ colorId = colors.length;
97
+ colors.push(color);
98
+ colorIds.set(color, colorId);
99
+ }
100
+ packed[cursor++] = token.offset - starts[index]!;
101
+ packed[cursor++] = colorId;
102
+ packed[cursor++] = token.fontStyle ?? 0;
103
+ }
104
+ }
105
+ lines[tokens.length] = cursor / 3;
106
+ return { type: 'tokens', generation, index, colors, ...appearance, lines, tokens: packed };
107
+ }
108
+
109
+ /* The packet omits text and token lengths; its source line supplies the final token's end. */
110
+ export function lineTokensOf(packet: DiffSyntaxTokens, line: number, text: string): LineToken[] {
111
+ if (!Number.isInteger(line) || line < 0 || line + 1 >= packet.lines.length) {
112
+ throw new RangeError('Unknown syntax line');
113
+ }
114
+ const result: LineToken[] = [];
115
+ const end = packet.lines[line + 1]!;
116
+ for (let index = packet.lines[line]!; index < end; index++) {
117
+ const from = packet.tokens[index * 3]!;
118
+ const to = index + 1 < end ? packet.tokens[(index + 1) * 3]! : text.length;
119
+ if (to > from) {
120
+ result.push({ length: to - from, color: packet.colors[packet.tokens[index * 3 + 1]!] ?? '', fontStyle: packet.tokens[index * 3 + 2]! });
121
+ }
122
+ }
123
+ return result;
124
+ }
125
+
126
+ export function createSyntaxService(options: DiffSyntaxServiceOptions): DiffSyntaxService {
127
+ const entries = new Map<number, Entry>();
128
+ const requests = new Map<string, Request>();
129
+ const pause = options.yield ?? yieldToHost;
130
+ let maxLines = 100000;
131
+ let count = 0;
132
+ let ready: Promise<Highlighter> | undefined;
133
+ let running = false;
134
+ let stopped = false;
135
+ let active: Entry | undefined;
136
+ const key = (generation: number, index: number): string => `${generation}:${index}`;
137
+ const cancel = (generation: number): void => {
138
+ for (const [id, request] of requests) {
139
+ if (request.generation === generation) {
140
+ requests.delete(id);
141
+ for (const resolve of request.resolve) {
142
+ resolve();
143
+ }
144
+ }
145
+ }
146
+ };
147
+ const trim = (): void => {
148
+ for (const [generation, entry] of entries) {
149
+ if (count <= maxLines) {
150
+ break;
151
+ }
152
+ if (entry !== active) {
153
+ entries.delete(generation);
154
+ count -= entry.count;
155
+ }
156
+ }
157
+ };
158
+ const finish = (request: Request): void => {
159
+ const id = key(request.generation, request.index);
160
+ if (requests.get(id) === request) {
161
+ requests.delete(id);
162
+ }
163
+ for (const resolve of request.resolve) {
164
+ resolve();
165
+ }
166
+ };
167
+ const failed = (request: Request, reason: 'missing' | 'highlight', message: string): void => {
168
+ options.send({ type: 'failed', generation: request.generation, index: request.index, reason, message });
169
+ finish(request);
170
+ };
171
+ const grammarOf = (entry: Entry, loaded: Highlighter): unknown => {
172
+ const language = documentLanguageOf(entry.document.language);
173
+ return loaded.getLoadedLanguages().includes(language) ? loaded.getLanguage(language) : undefined;
174
+ };
175
+ const pump = async (): Promise<void> => {
176
+ if (running) {
177
+ return;
178
+ }
179
+ running = true;
180
+ try {
181
+ while (!stopped && requests.size > 0) {
182
+ const request = [...requests.values()].at(-1)!;
183
+ const id = key(request.generation, request.index);
184
+ const entry = entries.get(request.generation);
185
+ if (entry === undefined) {
186
+ failed(request, 'missing', 'The syntax document was evicted; resend its definition.');
187
+ continue;
188
+ }
189
+ active = entry;
190
+ entries.delete(request.generation);
191
+ entries.set(request.generation, entry);
192
+ try {
193
+ const loaded = await (ready ??= options.highlighter());
194
+ if (requests.get(id) !== request || entries.get(request.generation) !== entry || stopped) {
195
+ continue;
196
+ }
197
+ if (entry.done >= 0 && (entry.grammar !== grammarOf(entry, loaded) || entry.theme !== loaded.getTheme(entry.document.theme))) {
198
+ entry.done = -1;
199
+ }
200
+ if (request.index >= entry.document.blocks.length) {
201
+ throw new RangeError('The syntax block index is outside the document.');
202
+ }
203
+ if ([...requests.values()].at(-1) !== request) {
204
+ continue;
205
+ }
206
+ const index = Math.min(entry.done + 1, request.index);
207
+ const tokens = await entry.tokens(index);
208
+ entry.done = Math.max(entry.done, index);
209
+ entry.grammar = grammarOf(entry, loaded);
210
+ const theme = loaded.getTheme(entry.document.theme);
211
+ entry.theme = theme;
212
+ if (requests.get(id) === request && entries.get(request.generation) === entry && !stopped && index === request.index) {
213
+ const raw = entry.document.blocks[index]!;
214
+ const code = index < entry.document.blocks.length - 1 ? raw.replace(/\r$/, '') : raw;
215
+ const packed = packedTokens(request.generation, index, code, tokens, { foreground: theme.fg, background: theme.bg });
216
+ options.send(packed, [packed.lines.buffer, packed.tokens.buffer]);
217
+ finish(request);
218
+ }
219
+ } catch (error) {
220
+ if (requests.get(id) === request && !stopped) {
221
+ failed(request, 'highlight', error instanceof Error ? error.message : String(error));
222
+ }
223
+ }
224
+ trim();
225
+ if (requests.size > 0 && !stopped) {
226
+ await pause();
227
+ }
228
+ }
229
+ } finally {
230
+ active = undefined;
231
+ running = false;
232
+ trim();
233
+ }
234
+ };
235
+ const dispose = (): void => {
236
+ stopped = true;
237
+ for (const generation of entries.keys()) {
238
+ cancel(generation);
239
+ }
240
+ for (const request of requests.values()) {
241
+ finish(request);
242
+ }
243
+ entries.clear();
244
+ count = 0;
245
+ };
246
+ return {
247
+ dispose,
248
+ handle(message) {
249
+ if (stopped) {
250
+ return Promise.resolve();
251
+ }
252
+ if (message.type === 'start') {
253
+ if (!Number.isSafeInteger(message.maxLines) || message.maxLines < 1) {
254
+ return Promise.reject(new RangeError('The syntax cache limit must be a positive safe integer.'));
255
+ }
256
+ maxLines = message.maxLines;
257
+ for (const generation of entries.keys()) {
258
+ cancel(generation);
259
+ }
260
+ entries.clear();
261
+ count = 0;
262
+ ready = options.highlighter().then(async (loaded) => {
263
+ const known = new Set(loaded.getLoadedThemes());
264
+ const missing = message.themes.filter((theme) => theme.name === undefined || !known.has(theme.name));
265
+ if (missing.length > 0) {
266
+ await loaded.loadTheme(...missing);
267
+ }
268
+ return loaded;
269
+ });
270
+ return ready.then(
271
+ () => options.send({ type: 'ready' }),
272
+ (error: unknown) => options.send({ type: 'error', message: error instanceof Error ? error.message : String(error) })
273
+ );
274
+ }
275
+ if (message.type === 'cancel' || message.type === 'dispose') {
276
+ cancel(message.generation);
277
+ if (message.type === 'dispose') {
278
+ const entry = entries.get(message.generation);
279
+ if (entry !== undefined) {
280
+ entries.delete(message.generation);
281
+ count -= entry.count;
282
+ }
283
+ }
284
+ return Promise.resolve();
285
+ }
286
+ if (!Number.isSafeInteger(message.generation) || message.generation < 1 || !Number.isSafeInteger(message.index) || message.index < 0) {
287
+ return Promise.reject(new RangeError('Syntax generations must be positive integers and block indexes nonnegative integers.'));
288
+ }
289
+ if (message.document !== undefined && !entries.has(message.generation)) {
290
+ const document = { ...message.document, blocks: [...message.document.blocks], resets: [...message.document.resets] };
291
+ const entry: Entry = {
292
+ document,
293
+ tokens: shikiDocumentTokens(() => (ready ??= options.highlighter()), document.blocks, document.language, document.theme, {
294
+ reset: new Set(document.resets),
295
+ // Shiki skips at the cutoff; include source lines of exactly 10,000 code units.
296
+ tokenizeMaxLineLength: 10001,
297
+ tokenizeTimeLimit: 20
298
+ }),
299
+ count: document.blocks.reduce((count, block) => count + block.split(/\r\n|\n/).length, 0),
300
+ done: -1
301
+ };
302
+ count += entry.count;
303
+ entries.set(message.generation, entry);
304
+ }
305
+ return new Promise((resolve) => {
306
+ const id = key(message.generation, message.index);
307
+ const existing = requests.get(id);
308
+ const request: Request = existing ?? { generation: message.generation, index: message.index, resolve: [] };
309
+ request.resolve.push(resolve);
310
+ requests.delete(id);
311
+ requests.set(id, request);
312
+ void pump();
313
+ });
314
+ }
315
+ };
316
+ }
317
+
318
+ export interface DiffSyntaxWorkerEndpoint {
319
+ addEventListener(type: 'message', listener: (event: MessageEvent<DiffSyntaxInput>) => void): void;
320
+ removeEventListener(type: 'message', listener: (event: MessageEvent<DiffSyntaxInput>) => void): void;
321
+ postMessage(message: DiffSyntaxOutput, options?: { transfer: Transferable[] }): void;
322
+ }
323
+
324
+ export function serveDiffSyntax(endpoint: DiffSyntaxWorkerEndpoint): () => void {
325
+ let highlighter: Promise<Highlighter> | undefined;
326
+ const service = createSyntaxService({
327
+ highlighter: () => (highlighter ??= import('shiki').then(({ createHighlighter }) => createHighlighter({ langs: [], themes: [] }))),
328
+ send: (message, transfer) => endpoint.postMessage(message, { transfer: transfer ?? [] })
329
+ });
330
+ const listener = (event: MessageEvent<DiffSyntaxInput>): void => {
331
+ void service.handle(event.data).catch((error: unknown) => {
332
+ endpoint.postMessage({ type: 'error', message: error instanceof Error ? error.message : String(error) });
333
+ });
334
+ };
335
+ endpoint.addEventListener('message', listener);
336
+ return () => {
337
+ endpoint.removeEventListener('message', listener);
338
+ service.dispose();
339
+ void highlighter?.then(
340
+ (loaded) => loaded.dispose(),
341
+ () => undefined
342
+ );
343
+ };
344
+ }
package/src/shiki.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { GrammarState, Highlighter } from 'shiki';
2
2
  import { documentLanguageOf, loadDocumentLanguage } from './php-html.ts';
3
3
  import { scopeColorsOf, type ThemeRule } from './theme-scopes.ts';
4
- import type { LineToken, LineTokenizer, ScopeColorSource, TokenizerSource } from './types.ts';
4
+ import type { LineToken, LineTokenKind, LineTokenizer, ScopeColorSource, TokenizerSource } from './types.ts';
5
5
 
6
6
  type LanguageId = Parameters<Highlighter['loadLanguage']>[0];
7
7
  type ThemeId = Parameters<Highlighter['loadTheme']>[0];
@@ -9,12 +9,16 @@ type TokenOptions = Parameters<Highlighter['codeToTokensBase']>[1];
9
9
 
10
10
  export { documentLanguageOf };
11
11
 
12
+ /* The standard token types of TextMate: 1 comment, 2 string, 3 regex. */
13
+ const KINDS: Record<number, LineTokenKind | undefined> = { 1: 'comment', 2: 'string', 3: 'regex' };
14
+
12
15
  const PLAIN_TEXT = new Set(['', 'text', 'txt', 'plain', 'plaintext']);
13
16
 
14
17
  /* Colors one line at a time and hands the grammar's state from one line to the next. A grammar Shiki rebuilt does not know the states of the old one. */
15
- function lineTokenizer(highlighter: Highlighter, language: string, theme: string): LineTokenizer {
18
+ function lineTokenizer(highlighter: Highlighter, language: string, theme: string, embedded: EmbeddedIndex): LineTokenizer {
16
19
  let grammar = highlighter.getLanguage(language);
17
20
  return {
21
+ prepare: embedded.matcher === null ? undefined : (text) => loadEmbedded(highlighter, embedded, text),
18
22
  stale() {
19
23
  const current = highlighter.getLanguage(language);
20
24
  const changed = current !== grammar;
@@ -22,12 +26,18 @@ function lineTokenizer(highlighter: Highlighter, language: string, theme: string
22
26
  return changed;
23
27
  },
24
28
  tokenizeLine(text, state) {
25
- const options = { lang: language, theme, grammarState: (state ?? undefined) as GrammarState | undefined } as TokenOptions;
29
+ const options = {
30
+ lang: language,
31
+ theme,
32
+ grammarState: (state ?? undefined) as GrammarState | undefined,
33
+ includeExplanation: 'tokenType'
34
+ } as TokenOptions;
26
35
  const lines = highlighter.codeToTokensBase(text, options);
27
36
  const tokens: LineToken[] = (lines[0] ?? []).map((token) => ({
28
37
  length: token.content.length,
29
38
  color: token.color ?? '',
30
- fontStyle: token.fontStyle ?? 0
39
+ fontStyle: token.fontStyle ?? 0,
40
+ kind: KINDS[token.type ?? 0]
31
41
  }));
32
42
  return { tokens, state: highlighter.getLastGrammarState(lines) };
33
43
  },
@@ -42,41 +52,99 @@ function lineTokenizer(highlighter: Highlighter, language: string, theme: string
42
52
  };
43
53
  }
44
54
 
45
- /* The words of a document that name a language, an alias or the id itself, and not part of a longer word. */
46
- function mentions(text: string, names: readonly string[]): boolean {
47
- return names.some((name) => new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'i').test(text));
55
+ interface EmbeddedIndex {
56
+ readonly names: ReadonlyMap<string, string>;
57
+ readonly matcher: RegExp | null;
48
58
  }
49
59
 
50
- /*
51
- * A grammar embeds some languages only on demand (Vue's `lang="scss"` styles, the fences of Markdown),
52
- * and Shiki builds the host grammar anew each time one of them loads later, which drops the states
53
- * of every line colored before. So the ones a document names are loaded together with the host.
54
- */
55
- async function loadEmbedded(highlighter: Highlighter, grammar: string, text: string): Promise<void> {
56
- const { bundledLanguages, bundledLanguagesInfo } = await import('shiki');
57
- const bundled = bundledLanguages[grammar as keyof typeof bundledLanguages];
58
- if (bundled === undefined) {
59
- return;
60
+ const languageLoads = new WeakMap<Highlighter, Map<string, Promise<void>>>();
61
+ const embeddedIndexes = new Map<string, Promise<EmbeddedIndex>>();
62
+
63
+ /* Concurrent documents await one load, so each learns the grammar was rebuilt. */
64
+ function loadOnce(highlighter: Highlighter, id: string): Promise<void> {
65
+ if (highlighter.getLoadedLanguages().includes(id)) {
66
+ return Promise.resolve();
67
+ }
68
+ let loads = languageLoads.get(highlighter);
69
+ if (loads === undefined) {
70
+ loads = new Map();
71
+ languageLoads.set(highlighter, loads);
72
+ }
73
+ const pending = loads.get(id);
74
+ if (pending !== undefined) {
75
+ return pending;
60
76
  }
61
- const registrations = (await bundled()).default as readonly { readonly name: string; readonly embeddedLangsLazy?: readonly string[] }[];
62
- const lazy = registrations.find((registration) => registration.name === grammar)?.embeddedLangsLazy ?? [];
63
- const named = lazy.filter((id) => {
64
- const aliases = bundledLanguagesInfo.find((info) => info.id === id)?.aliases ?? [];
65
- return !highlighter.getLoadedLanguages().includes(id) && mentions(text, [id, ...aliases]);
77
+ const load = (async () => {
78
+ await loadDocumentLanguage(highlighter, id);
79
+ if (!highlighter.getLoadedLanguages().includes(id)) {
80
+ await highlighter.loadLanguage(id as LanguageId);
81
+ }
82
+ })();
83
+ loads.set(id, load);
84
+ void load.catch(() => {
85
+ if (loads.get(id) === load) {
86
+ loads.delete(id);
87
+ }
66
88
  });
67
- await Promise.allSettled(named.map((id) => highlighter.loadLanguage(id as LanguageId)));
89
+ return load;
68
90
  }
69
91
 
70
- /*
71
- * Loads the grammar of a language with what a document in it embeds, and says whether Shiki has it.
72
- * `text` is the document, which decides the embedded languages that load only when named.
73
- */
74
- export async function loadGrammar(highlighter: Highlighter, grammar: string, text = ''): Promise<boolean> {
75
- await loadDocumentLanguage(highlighter, grammar);
76
- if (!highlighter.getLoadedLanguages().includes(grammar)) {
77
- await highlighter.loadLanguage(grammar as LanguageId);
92
+ function embedIndex(grammar: string): Promise<EmbeddedIndex> {
93
+ const cached = embeddedIndexes.get(grammar);
94
+ if (cached !== undefined) {
95
+ return cached;
96
+ }
97
+ const pending = (async (): Promise<EmbeddedIndex> => {
98
+ const { bundledLanguages, bundledLanguagesInfo } = await import('shiki');
99
+ const bundled = bundledLanguages[grammar as keyof typeof bundledLanguages];
100
+ const registrations = bundled === undefined ? [] : (await bundled()).default;
101
+ const lazy = registrations.find((registration) => registration.name === grammar)?.embeddedLangsLazy ?? [];
102
+ const names = new Map<string, string>();
103
+ for (const id of lazy) {
104
+ const aliases = bundledLanguagesInfo.find((info) => info.id === id)?.aliases ?? [];
105
+ for (const name of [id, ...aliases]) {
106
+ names.set(name.toLowerCase(), id);
107
+ }
108
+ }
109
+ if (names.size === 0) {
110
+ return { names, matcher: null };
111
+ }
112
+ let matcher: RegExp;
113
+ if (grammar === 'markdown' || grammar === 'mdx') {
114
+ matcher = /^\s*(?:`{3,}|~{3,})[ \t]*([\w#+.-]+)/gm;
115
+ } else if (grammar === 'vue' || grammar === 'svelte' || grammar === 'astro') {
116
+ matcher = /\blang\s*=\s*["']([\w#+.-]+)["']/gi;
117
+ } else {
118
+ const alternatives = [...names.keys()].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
119
+ matcher = new RegExp(`(?<![\\w-])(${alternatives})(?![\\w-])`, 'gi');
120
+ }
121
+ return { names, matcher };
122
+ })();
123
+ embeddedIndexes.set(grammar, pending);
124
+ void pending.catch(() => embeddedIndexes.delete(grammar));
125
+ return pending;
126
+ }
127
+
128
+ async function loadEmbedded(highlighter: Highlighter, embedded: EmbeddedIndex, text: string): Promise<boolean> {
129
+ if (embedded.matcher === null) {
130
+ return false;
78
131
  }
79
- await loadEmbedded(highlighter, grammar, text);
132
+ const loaded = new Set(highlighter.getLoadedLanguages());
133
+ const named = new Set<string>();
134
+ for (const match of text.matchAll(embedded.matcher)) {
135
+ const id = embedded.names.get(match[1]!.toLowerCase());
136
+ if (id !== undefined && !loaded.has(id)) {
137
+ named.add(id);
138
+ }
139
+ }
140
+ const results = await Promise.allSettled([...named].map((id) => loadOnce(highlighter, id)));
141
+ return results.some((result) => result.status === 'fulfilled');
142
+ }
143
+
144
+ /* Text names the optional embedded grammars that must load before the host hands out states. */
145
+ export async function loadGrammar(highlighter: Highlighter, grammar: string, text = ''): Promise<boolean> {
146
+ await loadOnce(highlighter, grammar);
147
+ await loadEmbedded(highlighter, await embedIndex(grammar), text);
80
148
  return highlighter.getLoadedLanguages().includes(grammar);
81
149
  }
82
150
 
@@ -95,7 +163,7 @@ export function shikiTokenizers(highlighter: () => Promise<Highlighter>): Tokeni
95
163
  if (!shiki.getLoadedThemes().includes(theme)) {
96
164
  await shiki.loadTheme(theme as ThemeId);
97
165
  }
98
- return (await loadGrammar(shiki, grammar, text)) ? lineTokenizer(shiki, grammar, theme) : null;
166
+ return (await loadGrammar(shiki, grammar, text)) ? lineTokenizer(shiki, grammar, theme, await embedIndex(grammar)) : null;
99
167
  } catch {
100
168
  return null;
101
169
  }
@@ -116,3 +184,5 @@ export function shikiScopeColors(highlighter: () => Promise<Highlighter>): Scope
116
184
  }
117
185
  };
118
186
  }
187
+
188
+ export { shikiDocumentHighlighter, shikiDocumentTokens, type ShikiDocumentTokenOptions } from './shiki-document.ts';