@remit/ui 0.0.105 → 0.0.107

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.
@@ -0,0 +1,638 @@
1
+ /**
2
+ * The marks, driven through a mounted editor: a provider answers, the ranges
3
+ * land in the highlight registry, and the document itself never changes. The
4
+ * registry is stubbed because jsdom carries no CSS Custom Highlight API, so
5
+ * what is asserted is the ranges the editor would have handed a browser.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
9
+ import type { JSDOM } from "jsdom";
10
+ import type { $getRoot as getRootType, LexicalEditor } from "lexical";
11
+ import type {
12
+ act as reactAct,
13
+ createElement as reactCreateElement,
14
+ useEffect as reactUseEffect,
15
+ } from "react";
16
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
17
+ import type { RichTextEditor as RichTextEditorType } from "./rich-text-editor.js";
18
+ import type {
19
+ CheckRequest,
20
+ CheckResponse,
21
+ Finding,
22
+ ProviderStatus,
23
+ SpellcheckOptions,
24
+ SpellProvider,
25
+ } from "./rich-text-spellcheck.js";
26
+ import {
27
+ dictionaryFor,
28
+ findMisspellings,
29
+ } from "./rich-text-spellcheck-words.js";
30
+
31
+ const SENTENCE = "Ths report is redy today";
32
+ const IDLE_MS = 400;
33
+
34
+ class StubMarks {
35
+ readonly ranges: Range[] = [];
36
+ add(range: Range): void {
37
+ this.ranges.push(range);
38
+ }
39
+ }
40
+
41
+ interface MarkHost {
42
+ CSS: { highlights: Map<string, StubMarks> };
43
+ Highlight: typeof StubMarks;
44
+ }
45
+
46
+ let dom: JSDOM;
47
+ let container: HTMLElement;
48
+ let root: Root;
49
+ const roots: Root[] = [];
50
+ const containers: HTMLElement[] = [];
51
+ let act: typeof reactAct;
52
+ let createElement: typeof reactCreateElement;
53
+ let useEffect: typeof reactUseEffect;
54
+ let createRoot: typeof reactCreateRoot;
55
+ let RichTextEditor: typeof RichTextEditorType;
56
+ let useLexicalComposerContext: () => [LexicalEditor];
57
+ let $getRoot: typeof getRootType;
58
+
59
+ const marks = (): StubMarks | undefined =>
60
+ (globalThis as unknown as MarkHost).CSS.highlights.get("spell-error");
61
+
62
+ const offsets = (): [number, number][] =>
63
+ (marks()?.ranges ?? []).map((range) => [range.startOffset, range.endOffset]);
64
+
65
+ interface Stub extends SpellcheckOptions {
66
+ readonly asked: CheckRequest[];
67
+ closed(): number;
68
+ /** Drives the provider's own status, the way a worker falling over does. */
69
+ push(status: ProviderStatus): void;
70
+ /** Settles the answers a held provider is sitting on. */
71
+ release(): void;
72
+ }
73
+
74
+ /** What the composer will hand the editor, without a worker in the way. */
75
+ const stubSpellcheck = (
76
+ tune: {
77
+ revisionOf?: (request: CheckRequest, nth: number) => number;
78
+ hold?: boolean;
79
+ } = {},
80
+ ): Stub => {
81
+ const asked: CheckRequest[] = [];
82
+ const held: (() => void)[] = [];
83
+ const listeners = new Set<(status: ProviderStatus) => void>();
84
+ let closed = 0;
85
+ const words = dictionaryFor("en") ?? new Set<string>();
86
+
87
+ const answer = (request: CheckRequest, nth: number): CheckResponse => ({
88
+ requestId: request.requestId,
89
+ revision: tune.revisionOf?.(request, nth) ?? request.revision,
90
+ findings: request.spans.flatMap((span) =>
91
+ findMisspellings(span.text, words).map(
92
+ (range): Finding => ({
93
+ spanId: span.spanId,
94
+ start: range.start,
95
+ end: range.end,
96
+ kind: "spelling",
97
+ suggestions: [],
98
+ }),
99
+ ),
100
+ ),
101
+ });
102
+
103
+ const provider: SpellProvider = {
104
+ language: "en",
105
+ onStatus: (listener) => {
106
+ listeners.add(listener);
107
+ listener({ state: "ready", language: "en" });
108
+ return () => {
109
+ listeners.delete(listener);
110
+ };
111
+ },
112
+ check: (request) => {
113
+ asked.push(request);
114
+ const response = answer(request, asked.length);
115
+ if (!tune.hold) return Promise.resolve(response);
116
+ return new Promise((resolve) => {
117
+ held.push(() => resolve(response));
118
+ });
119
+ },
120
+ close: () => {
121
+ closed += 1;
122
+ },
123
+ };
124
+
125
+ return {
126
+ asked,
127
+ closed: () => closed,
128
+ push: (status) => {
129
+ for (const listener of listeners) listener(status);
130
+ },
131
+ release: () => {
132
+ for (const settle of held.splice(0)) settle();
133
+ },
134
+ provider: (language) =>
135
+ Promise.resolve(language === "en" ? provider : null),
136
+ };
137
+ };
138
+
139
+ // A container is used once: React refuses to create a second root over one it
140
+ // has already owned. The editor reaches the test through a pinned control,
141
+ // which is the one place a caller's own node renders inside the composer.
142
+ const mount = async (
143
+ props: Record<string, unknown>,
144
+ ): Promise<LexicalEditor> => {
145
+ let editor: LexicalEditor | undefined;
146
+ const Probe = () => {
147
+ const [found] = useLexicalComposerContext();
148
+ useEffect(() => {
149
+ editor = found;
150
+ }, [found]);
151
+ return null;
152
+ };
153
+
154
+ container = dom.window.document.createElement("div");
155
+ dom.window.document.body.append(container);
156
+ containers.push(container);
157
+ await act(async () => {
158
+ root = createRoot(container);
159
+ roots.push(root);
160
+ root.render(
161
+ createElement(RichTextEditor, {
162
+ ...props,
163
+ trailing: createElement(Probe),
164
+ }),
165
+ );
166
+ });
167
+ if (!editor) throw new Error("the editor never reached the probe");
168
+ return editor;
169
+ };
170
+
171
+ const unmountAll = async (): Promise<void> => {
172
+ const live = [...roots];
173
+ roots.length = 0;
174
+ containers.length = 0;
175
+ await act(async () => {
176
+ for (const mounted of live) mounted.unmount();
177
+ });
178
+ };
179
+
180
+ const unmountLast = async (): Promise<void> => {
181
+ const last = roots.pop();
182
+ containers.pop();
183
+ await act(async () => {
184
+ last?.unmount();
185
+ });
186
+ };
187
+
188
+ const settle = async (): Promise<void> => {
189
+ await act(async () => {
190
+ await new Promise((resolve) => setTimeout(resolve, IDLE_MS));
191
+ });
192
+ };
193
+
194
+ const editable = (scope: HTMLElement = container): HTMLElement => {
195
+ const element = scope.querySelector<HTMLElement>(
196
+ "[data-testid=compose-body]",
197
+ );
198
+ if (!element) throw new Error("the editable surface is not mounted");
199
+ return element;
200
+ };
201
+
202
+ before(async () => {
203
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
204
+ dom = new JSDOMCtor(
205
+ "<!doctype html><html><body><div id=root></div></body></html>",
206
+ { url: "http://localhost/", pretendToBeVisual: true },
207
+ );
208
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
209
+ globalThis.document = dom.window.document;
210
+ globalThis.HTMLElement = dom.window.HTMLElement;
211
+ globalThis.Element = dom.window.Element;
212
+ globalThis.Node = dom.window.Node;
213
+ globalThis.Event = dom.window.Event;
214
+ globalThis.MouseEvent = dom.window.MouseEvent;
215
+ globalThis.DOMParser = dom.window.DOMParser;
216
+ globalThis.MutationObserver = dom.window.MutationObserver;
217
+ globalThis.Range = dom.window.Range;
218
+ // jsdom has no layout, and Lexical measures the caret's range whenever it
219
+ // writes the DOM selection.
220
+ Object.defineProperty(dom.window.Range.prototype, "getBoundingClientRect", {
221
+ value: () => ({
222
+ top: 0,
223
+ bottom: 0,
224
+ left: 0,
225
+ right: 0,
226
+ width: 0,
227
+ height: 0,
228
+ x: 0,
229
+ y: 0,
230
+ }),
231
+ configurable: true,
232
+ });
233
+ globalThis.AbortController = dom.window.AbortController;
234
+ globalThis.AbortSignal = dom.window.AbortSignal;
235
+ globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
236
+ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
237
+ dom.window,
238
+ );
239
+ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
240
+ dom.window,
241
+ );
242
+ Object.defineProperty(globalThis, "navigator", {
243
+ value: dom.window.navigator,
244
+ configurable: true,
245
+ });
246
+ Object.defineProperty(globalThis, "CSS", {
247
+ value: { highlights: new Map<string, StubMarks>() },
248
+ configurable: true,
249
+ });
250
+ Object.defineProperty(globalThis, "Highlight", {
251
+ value: StubMarks,
252
+ configurable: true,
253
+ });
254
+ (
255
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
256
+ ).IS_REACT_ACT_ENVIRONMENT = true;
257
+
258
+ ({ act, createElement, useEffect } = await import("react"));
259
+ ({ createRoot } = await import("react-dom/client"));
260
+ ({ $getRoot } = await import("lexical"));
261
+ ({ useLexicalComposerContext } = (await import(
262
+ "@lexical/react/LexicalComposerContext"
263
+ )) as unknown as { useLexicalComposerContext: () => [LexicalEditor] });
264
+ ({ RichTextEditor } = await import("./rich-text-editor.js"));
265
+ });
266
+
267
+ beforeEach(() => {
268
+ (globalThis as unknown as MarkHost).CSS.highlights.clear();
269
+ });
270
+
271
+ afterEach(async () => {
272
+ await unmountAll();
273
+ container.remove();
274
+ });
275
+
276
+ after(() => {
277
+ dom.window.close();
278
+ });
279
+
280
+ describe("spellcheck marks", () => {
281
+ it("marks the misspelt words in the document it opens on", async () => {
282
+ const spellcheck = stubSpellcheck();
283
+ await mount({
284
+ initialHtml: `<p>${SENTENCE}</p>`,
285
+ lang: "en",
286
+ spellcheck,
287
+ });
288
+ await settle();
289
+
290
+ assert.deepEqual(offsets(), [
291
+ [0, 3],
292
+ [14, 18],
293
+ ]);
294
+ assert.equal(
295
+ marks()?.ranges[0]?.startContainer.textContent,
296
+ SENTENCE,
297
+ "the ranges sit on the text the reader sees",
298
+ );
299
+ assert.equal(
300
+ editable().innerHTML.includes("mark"),
301
+ false,
302
+ "nothing entered the document",
303
+ );
304
+ });
305
+
306
+ it("leaves the word the caret sits in alone", async () => {
307
+ const spellcheck = stubSpellcheck();
308
+ const editor = await mount({
309
+ initialHtml: `<p>${SENTENCE}</p>`,
310
+ lang: "en",
311
+ spellcheck,
312
+ });
313
+ await settle();
314
+
315
+ const caretTo = async (offset: number) => {
316
+ await act(async () => {
317
+ editor.update(() => {
318
+ const [text] = $getRoot().getAllTextNodes();
319
+ text?.select(offset, offset);
320
+ });
321
+ });
322
+ await settle();
323
+ };
324
+
325
+ await caretTo(16);
326
+ assert.deepEqual(
327
+ offsets(),
328
+ [[0, 3]],
329
+ "the word being written is not marked",
330
+ );
331
+
332
+ await caretTo(1);
333
+ assert.deepEqual(
334
+ offsets(),
335
+ [[14, 18]],
336
+ "leaving a word marks it and marks nothing else twice",
337
+ );
338
+ });
339
+
340
+ it("marks what an edit touched and leaves the rest of the document alone", async () => {
341
+ const spellcheck = stubSpellcheck();
342
+ const editor = await mount({
343
+ initialHtml: `<p>${SENTENCE}</p><p>Notes</p>`,
344
+ lang: "en",
345
+ spellcheck,
346
+ });
347
+ await settle();
348
+ const opening = spellcheck.asked.length;
349
+
350
+ await act(async () => {
351
+ editor.update(() => {
352
+ const written = $getRoot().getAllTextNodes().at(-1);
353
+ written?.setTextContent("Notes recieve");
354
+ written?.select(13, 13);
355
+ });
356
+ });
357
+ await settle();
358
+
359
+ const last = spellcheck.asked.at(-1);
360
+ assert.ok(last, "the edit was checked");
361
+ assert.equal(spellcheck.asked.length, opening + 1, "one pass per edit");
362
+ assert.equal(
363
+ last.spans.length,
364
+ 1,
365
+ "only the leaf the edit touched was sent",
366
+ );
367
+ assert.equal(last.spans[0]?.text, "Notes recieve");
368
+ assert.deepEqual(
369
+ offsets(),
370
+ [
371
+ [0, 3],
372
+ [14, 18],
373
+ ],
374
+ "the marks in the untouched paragraph kept their offsets",
375
+ );
376
+ });
377
+
378
+ it("drops an answer the document has moved past", async () => {
379
+ const spellcheck = stubSpellcheck({
380
+ revisionOf: (request) => request.revision - 1,
381
+ });
382
+ await mount({
383
+ initialHtml: `<p>${SENTENCE}</p>`,
384
+ lang: "en",
385
+ spellcheck,
386
+ });
387
+ await settle();
388
+
389
+ assert.equal(spellcheck.asked.length, 1, "the document was checked");
390
+ assert.equal(marks(), undefined, "a stale revision paints nothing");
391
+ });
392
+
393
+ it("hands checking back to the browser unless a provider is ready", async () => {
394
+ await mount({ initialHtml: `<p>${SENTENCE}</p>`, lang: "en" });
395
+ await settle();
396
+ assert.equal(
397
+ editable().getAttribute("spellcheck"),
398
+ "true",
399
+ "without the prop the browser checks, as it does today",
400
+ );
401
+
402
+ await unmountAll();
403
+
404
+ await mount({
405
+ initialHtml: `<p>${SENTENCE}</p>`,
406
+ lang: "de",
407
+ spellcheck: stubSpellcheck(),
408
+ });
409
+ await settle();
410
+ assert.equal(
411
+ editable().getAttribute("spellcheck"),
412
+ "true",
413
+ "a language with no dictionary keeps the browser's own checking",
414
+ );
415
+
416
+ await unmountAll();
417
+
418
+ await mount({
419
+ initialHtml: `<p>${SENTENCE}</p>`,
420
+ lang: "en",
421
+ spellcheck: stubSpellcheck(),
422
+ });
423
+ await settle();
424
+ assert.equal(
425
+ editable().getAttribute("spellcheck"),
426
+ "false",
427
+ "two sets of squiggles never coexist",
428
+ );
429
+ });
430
+
431
+ it("takes the marks off a leaf the moment its characters move", async () => {
432
+ const spellcheck = stubSpellcheck();
433
+ const editor = await mount({
434
+ initialHtml: `<p>${SENTENCE}</p><p>Notes redy</p>`,
435
+ lang: "en",
436
+ spellcheck,
437
+ });
438
+ await settle();
439
+ assert.deepEqual(offsets(), [
440
+ [0, 3],
441
+ [14, 18],
442
+ [6, 10],
443
+ ]);
444
+
445
+ await act(async () => {
446
+ editor.update(() => {
447
+ $getRoot().getAllTextNodes().at(-1)?.setTextContent("Well Notes redy");
448
+ });
449
+ });
450
+
451
+ assert.deepEqual(
452
+ offsets(),
453
+ [
454
+ [0, 3],
455
+ [14, 18],
456
+ ],
457
+ "the edited leaf loses its squiggles rather than wearing them on the wrong letters",
458
+ );
459
+
460
+ await settle();
461
+ assert.deepEqual(
462
+ offsets(),
463
+ [
464
+ [0, 3],
465
+ [14, 18],
466
+ [11, 15],
467
+ ],
468
+ "and gets them back where the words now are",
469
+ );
470
+ });
471
+
472
+ it("checks a leaf again when its answer came back too late", async () => {
473
+ const spellcheck = stubSpellcheck({
474
+ revisionOf: (request, nth) =>
475
+ nth === 1 ? request.revision - 1 : request.revision,
476
+ });
477
+ await mount({
478
+ initialHtml: `<p>${SENTENCE}</p>`,
479
+ lang: "en",
480
+ spellcheck,
481
+ });
482
+ await settle();
483
+ await settle();
484
+
485
+ assert.equal(
486
+ spellcheck.asked.length,
487
+ 2,
488
+ "the dropped leaf was asked again",
489
+ );
490
+ assert.deepEqual(
491
+ offsets(),
492
+ [
493
+ [0, 3],
494
+ [14, 18],
495
+ ],
496
+ "a dropped answer costs a pass, not the marks",
497
+ );
498
+ });
499
+
500
+ it("clears its marks when the provider stops answering", async () => {
501
+ const seen: ProviderStatus[] = [];
502
+ const spellcheck = stubSpellcheck();
503
+ await mount({
504
+ initialHtml: `<p>${SENTENCE}</p>`,
505
+ lang: "en",
506
+ spellcheck: {
507
+ ...spellcheck,
508
+ onStatus: (status: ProviderStatus) => seen.push(status),
509
+ },
510
+ });
511
+ await settle();
512
+ assert.equal(offsets().length, 2);
513
+
514
+ await act(async () => {
515
+ spellcheck.push({
516
+ state: "failed",
517
+ language: "en",
518
+ reason: "worker",
519
+ detail: "the worker stopped",
520
+ });
521
+ });
522
+
523
+ assert.equal(marks(), undefined, "ours go when the browser's come back");
524
+ assert.equal(editable().getAttribute("spellcheck"), "true");
525
+ assert.equal(seen.at(-1)?.state, "failed", "the caller is told why");
526
+ });
527
+
528
+ it("says when a language has no dictionary", async () => {
529
+ const seen: ProviderStatus[] = [];
530
+ await mount({
531
+ initialHtml: `<p>${SENTENCE}</p>`,
532
+ lang: "de",
533
+ spellcheck: {
534
+ ...stubSpellcheck(),
535
+ onStatus: (status: ProviderStatus) => seen.push(status),
536
+ },
537
+ });
538
+ await settle();
539
+
540
+ assert.deepEqual(seen, [{ state: "unavailable", language: "de" }]);
541
+ assert.equal(editable().getAttribute("spellcheck"), "true");
542
+ });
543
+
544
+ it("marks a leaf whose formatting nests its characters", async () => {
545
+ const spellcheck = stubSpellcheck();
546
+ const editor = await mount({
547
+ initialHtml: `<p>${SENTENCE}</p>`,
548
+ lang: "en",
549
+ spellcheck,
550
+ });
551
+ await settle();
552
+
553
+ await act(async () => {
554
+ editor.update(() => {
555
+ $getRoot().getAllTextNodes()[0]?.toggleFormat("subscript");
556
+ });
557
+ });
558
+ await settle();
559
+
560
+ const nested = editable().querySelector("sub");
561
+ assert.ok(nested, "the format put the text under a tag of its own");
562
+ assert.notEqual(
563
+ nested.firstChild?.nodeType,
564
+ Node.TEXT_NODE,
565
+ "the characters are no longer the element's first child",
566
+ );
567
+ assert.deepEqual(
568
+ offsets(),
569
+ [
570
+ [0, 3],
571
+ [14, 18],
572
+ ],
573
+ "the marks found the characters anyway",
574
+ );
575
+ });
576
+
577
+ it("keeps two editors' marks apart", async () => {
578
+ await mount({
579
+ initialHtml: `<p>${SENTENCE}</p>`,
580
+ lang: "en",
581
+ spellcheck: stubSpellcheck(),
582
+ });
583
+ await mount({
584
+ initialHtml: "<p>Redy notes</p>",
585
+ lang: "en",
586
+ spellcheck: stubSpellcheck(),
587
+ });
588
+ await settle();
589
+
590
+ assert.equal(offsets().length, 3, "both editors drew");
591
+
592
+ await unmountLast();
593
+
594
+ assert.deepEqual(
595
+ offsets(),
596
+ [
597
+ [0, 3],
598
+ [14, 18],
599
+ ],
600
+ "closing one composer leaves the other's marks alone",
601
+ );
602
+ assert.equal(editable(containers[0]).getAttribute("spellcheck"), "false");
603
+ });
604
+
605
+ it("closes the provider and clears its marks when the editor goes away", async () => {
606
+ const spellcheck = stubSpellcheck();
607
+ await mount({
608
+ initialHtml: `<p>${SENTENCE}</p>`,
609
+ lang: "en",
610
+ spellcheck,
611
+ });
612
+ await settle();
613
+ assert.equal(offsets().length, 2);
614
+
615
+ await unmountAll();
616
+
617
+ assert.equal(spellcheck.closed(), 1, "the provider was closed");
618
+ assert.equal(marks(), undefined, "the marks left with it");
619
+ });
620
+
621
+ it("paints nothing when an answer lands after the editor closed", async () => {
622
+ const spellcheck = stubSpellcheck({ hold: true });
623
+ await mount({
624
+ initialHtml: `<p>${SENTENCE}</p>`,
625
+ lang: "en",
626
+ spellcheck,
627
+ });
628
+ await settle();
629
+ assert.equal(spellcheck.asked.length, 1, "a check is in flight");
630
+
631
+ await unmountAll();
632
+ await act(async () => {
633
+ spellcheck.release();
634
+ });
635
+
636
+ assert.equal(marks(), undefined, "a late answer draws into nothing");
637
+ });
638
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What the editor asks and what an answer looks like. Nothing here is shaped by
3
+ * a particular engine: a span is one text node's characters, a finding is a
4
+ * range inside it, and `revision` is the document the spans were read at, so an
5
+ * answer that arrives after the text moved is dropped rather than painted onto
6
+ * characters that are no longer there (#692).
7
+ */
8
+
9
+ export type LanguageTag = string;
10
+
11
+ export interface CheckSpan {
12
+ readonly spanId: string;
13
+ readonly text: string;
14
+ }
15
+
16
+ export interface CheckRequest {
17
+ readonly requestId: string;
18
+ readonly language: LanguageTag;
19
+ readonly revision: number;
20
+ readonly spans: readonly CheckSpan[];
21
+ }
22
+
23
+ export interface Finding {
24
+ readonly spanId: string;
25
+ readonly start: number;
26
+ readonly end: number;
27
+ readonly kind: "spelling";
28
+ readonly suggestions: readonly string[];
29
+ }
30
+
31
+ export interface CheckResponse {
32
+ readonly requestId: string;
33
+ readonly revision: number;
34
+ readonly findings: readonly Finding[];
35
+ }
36
+
37
+ export type ProviderStatus =
38
+ | { readonly state: "opening"; readonly language: LanguageTag }
39
+ | { readonly state: "ready"; readonly language: LanguageTag }
40
+ /** Nothing here checks this language, and the browser is welcome to. */
41
+ | { readonly state: "unavailable"; readonly language: LanguageTag }
42
+ | {
43
+ readonly state: "failed";
44
+ readonly language: LanguageTag;
45
+ readonly reason: "download" | "engine" | "worker";
46
+ readonly detail: string;
47
+ };
48
+
49
+ export interface SpellProvider {
50
+ readonly language: LanguageTag;
51
+ /** Emits the current status to the listener before any later one. */
52
+ onStatus(listener: (status: ProviderStatus) => void): () => void;
53
+ check(request: CheckRequest): Promise<CheckResponse>;
54
+ close(): void;
55
+ }
56
+
57
+ export interface SpellcheckOptions {
58
+ /** Resolving null means no dictionary for that language. */
59
+ provider(language: LanguageTag): Promise<SpellProvider | null>;
60
+ onStatus?(status: ProviderStatus): void;
61
+ }
62
+
63
+ export type SpellWorkerRequest =
64
+ | { readonly type: "open"; readonly language: LanguageTag }
65
+ | ({ readonly type: "check" } & CheckRequest);
66
+
67
+ export type SpellWorkerResponse =
68
+ | { readonly type: "ready"; readonly language: LanguageTag }
69
+ | {
70
+ readonly type: "failed";
71
+ readonly language: LanguageTag;
72
+ readonly reason: "download" | "engine";
73
+ readonly detail: string;
74
+ }
75
+ | ({ readonly type: "checked" } & CheckResponse);