@glw907/cairn-cms 0.59.0 → 0.60.1

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 (106) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/components/AdminLayout.svelte +130 -229
  3. package/dist/components/CairnAdmin.svelte +12 -41
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +486 -0
  7. package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
  8. package/dist/components/ComponentForm.svelte +110 -185
  9. package/dist/components/ComponentInsertDialog.svelte +163 -283
  10. package/dist/components/ConceptList.svelte +111 -191
  11. package/dist/components/ConfirmPage.svelte +5 -12
  12. package/dist/components/CsrfField.svelte +5 -11
  13. package/dist/components/DeleteDialog.svelte +15 -42
  14. package/dist/components/EditPage.svelte +786 -918
  15. package/dist/components/EditorToolbar.svelte +108 -170
  16. package/dist/components/IconPicker.svelte +23 -53
  17. package/dist/components/LinkPicker.svelte +34 -58
  18. package/dist/components/LoginPage.svelte +14 -27
  19. package/dist/components/ManageEditors.svelte +3 -15
  20. package/dist/components/MarkdownEditor.svelte +688 -789
  21. package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
  22. package/dist/components/MarkdownHelpDialog.svelte +8 -12
  23. package/dist/components/MediaCaptureCard.svelte +18 -57
  24. package/dist/components/MediaFigureControl.svelte +32 -71
  25. package/dist/components/MediaHeroField.svelte +210 -329
  26. package/dist/components/MediaInsertPopover.svelte +156 -283
  27. package/dist/components/MediaPicker.svelte +67 -131
  28. package/dist/components/NavTree.svelte +46 -78
  29. package/dist/components/RenameDialog.svelte +16 -43
  30. package/dist/components/ShortcutsDialog.svelte +9 -13
  31. package/dist/components/ShortcutsGrid.svelte +1 -2
  32. package/dist/components/TidyReview.svelte +355 -0
  33. package/dist/components/TidyReview.svelte.d.ts +47 -0
  34. package/dist/components/WebLinkDialog.svelte +19 -40
  35. package/dist/components/cairn-admin.css +768 -0
  36. package/dist/components/editor-tidy.d.ts +31 -0
  37. package/dist/components/editor-tidy.js +199 -0
  38. package/dist/components/index.d.ts +1 -0
  39. package/dist/components/index.js +1 -0
  40. package/dist/components/markdown-directives.d.ts +16 -0
  41. package/dist/components/markdown-directives.js +34 -0
  42. package/dist/components/objective-errors.d.ts +30 -0
  43. package/dist/components/objective-errors.js +113 -0
  44. package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  45. package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  46. package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  47. package/dist/components/spellcheck-worker.d.ts +80 -0
  48. package/dist/components/spellcheck-worker.js +161 -0
  49. package/dist/components/spellcheck.d.ts +148 -0
  50. package/dist/components/spellcheck.js +553 -0
  51. package/dist/components/tidy-categorize.d.ts +67 -0
  52. package/dist/components/tidy-categorize.js +392 -0
  53. package/dist/components/tidy-diff.d.ts +60 -0
  54. package/dist/components/tidy-diff.js +147 -0
  55. package/dist/components/tidy-validate.d.ts +37 -0
  56. package/dist/components/tidy-validate.js +174 -0
  57. package/dist/content/compose.d.ts +1 -1
  58. package/dist/content/compose.js +11 -0
  59. package/dist/content/site-dictionary.d.ts +31 -0
  60. package/dist/content/site-dictionary.js +82 -0
  61. package/dist/content/types.d.ts +25 -0
  62. package/dist/delivery/CairnHead.svelte +8 -11
  63. package/dist/doctor/checks-local.d.ts +1 -0
  64. package/dist/doctor/checks-local.js +55 -6
  65. package/dist/doctor/index.js +2 -1
  66. package/dist/log/events.d.ts +1 -1
  67. package/dist/nav/site-config.d.ts +98 -0
  68. package/dist/nav/site-config.js +132 -0
  69. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  70. package/dist/sveltekit/admin-dispatch.js +6 -2
  71. package/dist/sveltekit/cairn-admin.d.ts +13 -1
  72. package/dist/sveltekit/cairn-admin.js +22 -3
  73. package/dist/sveltekit/content-routes.d.ts +135 -1
  74. package/dist/sveltekit/content-routes.js +351 -3
  75. package/dist/sveltekit/tidy-prompt.d.ts +11 -0
  76. package/dist/sveltekit/tidy-prompt.js +118 -0
  77. package/package.json +11 -2
  78. package/src/lib/components/CairnAdmin.svelte +3 -0
  79. package/src/lib/components/CairnTidySettings.svelte +553 -0
  80. package/src/lib/components/EditPage.svelte +371 -2
  81. package/src/lib/components/MarkdownEditor.svelte +168 -1
  82. package/src/lib/components/TidyReview.svelte +463 -0
  83. package/src/lib/components/cairn-admin.css +25 -0
  84. package/src/lib/components/editor-tidy.ts +241 -0
  85. package/src/lib/components/index.ts +1 -0
  86. package/src/lib/components/markdown-directives.ts +35 -0
  87. package/src/lib/components/objective-errors.ts +155 -0
  88. package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  89. package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  90. package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  91. package/src/lib/components/spellcheck-worker.ts +279 -0
  92. package/src/lib/components/spellcheck.ts +693 -0
  93. package/src/lib/components/tidy-categorize.ts +460 -0
  94. package/src/lib/components/tidy-diff.ts +196 -0
  95. package/src/lib/components/tidy-validate.ts +202 -0
  96. package/src/lib/content/compose.ts +11 -1
  97. package/src/lib/content/site-dictionary.ts +84 -0
  98. package/src/lib/content/types.ts +25 -0
  99. package/src/lib/doctor/checks-local.ts +59 -5
  100. package/src/lib/doctor/index.ts +2 -0
  101. package/src/lib/log/events.ts +7 -1
  102. package/src/lib/nav/site-config.ts +197 -0
  103. package/src/lib/sveltekit/admin-dispatch.ts +7 -3
  104. package/src/lib/sveltekit/cairn-admin.ts +32 -4
  105. package/src/lib/sveltekit/content-routes.ts +504 -4
  106. package/src/lib/sveltekit/tidy-prompt.ts +153 -0
@@ -0,0 +1,693 @@
1
+ // cairn-cms: the spellcheck lint source and the single skip authority. The markdown-aware skip
2
+ // classifier and the word extractor are pure, so they unit-test without a DOM or a Worker; the
3
+ // linter() wiring at the bottom is the only side that touches CodeMirror, loaded the lazy way
4
+ // link-completion.ts loads it so CodeMirror never lands in a consumer's server bundle.
5
+ //
6
+ // The skip authority precedence (spec 1.4): the Lezer syntax tree is the single authority for
7
+ // node-kind skips (code, links/URLs, HTML, emphasis/strong markers). The deterministic
8
+ // frontmatterSpan helper covers the `---` region the grammar does not model, and the line-based
9
+ // fenceTokens scan covers the directive machinery the grammar parses as plain paragraph text. A
10
+ // fence-classified range wins inside a directive. A bare `media:` token in text is matched so it is
11
+ // never split into "media" plus a flagged hash.
12
+ import type { Tree } from '@lezer/common';
13
+ import type { Diagnostic } from '@codemirror/lint';
14
+ import type { Extension } from '@codemirror/state';
15
+ import type { EditorView } from '@codemirror/view';
16
+ import { frontmatterSpan, fenceTokens } from './markdown-directives.js';
17
+ import { parseMediaToken } from '../media/reference.js';
18
+ import { objectiveErrors, type ObjectiveError } from './objective-errors.js';
19
+
20
+ /** An absolute character range in the document. */
21
+ export interface Range {
22
+ from: number;
23
+ to: number;
24
+ }
25
+
26
+ /** A word extracted for lookup: the lowercased form the Worker checks, and its absolute range so a
27
+ * verdict maps straight back to an underline. */
28
+ export interface ExtractedWord {
29
+ /** The lowercased word, as the engine's case-insensitive lookup expects. */
30
+ text: string;
31
+ from: number;
32
+ to: number;
33
+ }
34
+
35
+ // The Lezer node kinds that are never spellchecked, verified empirically against the actual
36
+ // @codemirror/lang-markdown grammar (parse a fixture, inspect node names) rather than trusting the
37
+ // spec list blind. The grammar models:
38
+ // code: InlineCode, FencedCode, CodeText, CodeBlock (CodeBlock is the indented form; its body is a
39
+ // CodeText too). CodeMark/CodeInfo are the fence/lang markers.
40
+ // links and URLs: URL (a link destination and the URL inside an autolink), Autolink (the whole
41
+ // <...> form), LinkLabel (a [ref] label and a reference-definition label), LinkReference (the
42
+ // whole `[ref]: url "title"` definition), LinkTitle (the quoted title on a definition).
43
+ // HTML: HTMLTag (inline), HTMLBlock.
44
+ // emphasis/strong MARKERS: EmphasisMark (the *,_,**,__ runs), not the prose inside Emphasis or
45
+ // StrongEmphasis. The same goes for the other *Mark nodes (LinkMark, HeaderMark, ListMark,
46
+ // QuoteMark), which are punctuation, never prose.
47
+ // Note the spec listed "link destinations, autolinks, link labels, reference definitions"; the real
48
+ // node names for those are URL, Autolink, LinkLabel, and LinkReference. LinkTitle is added because
49
+ // the grammar emits the definition's quoted title as its own node and it is machinery, not prose.
50
+ const SKIP_NODES = new Set<string>([
51
+ 'InlineCode',
52
+ 'FencedCode',
53
+ 'CodeText',
54
+ 'CodeBlock',
55
+ 'CodeMark',
56
+ 'CodeInfo',
57
+ 'URL',
58
+ 'Autolink',
59
+ 'LinkLabel',
60
+ 'LinkReference',
61
+ 'LinkTitle',
62
+ 'HTMLTag',
63
+ 'HTMLBlock',
64
+ 'EmphasisMark',
65
+ 'LinkMark',
66
+ 'HeaderMark',
67
+ 'ListMark',
68
+ 'QuoteMark',
69
+ ]);
70
+
71
+ // A bare `media:` token shaped like the reference grammar (a slug-and-hash or bare-hash run with no
72
+ // surrounding whitespace). A token inside an image is already caught by the URL skip; this catches
73
+ // the form authors type directly in prose, so it is never split into "media" plus a flagged hash.
74
+ const MEDIA_TOKEN = /media:[\w.-]+/g;
75
+
76
+ /** Merge overlapping or touching ranges into a sorted, disjoint set, so the keep-span computation
77
+ * subtracts one clean list of skip regions. */
78
+ function mergeRanges(ranges: Range[]): Range[] {
79
+ if (ranges.length === 0) return [];
80
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
81
+ const out: Range[] = [{ ...sorted[0]! }];
82
+ for (let i = 1; i < sorted.length; i++) {
83
+ const next = sorted[i]!;
84
+ const last = out[out.length - 1]!;
85
+ if (next.from <= last.to) last.to = Math.max(last.to, next.to);
86
+ else out.push({ ...next });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** Every absolute skip range in the document, from all three mechanisms, merged. This is the single
92
+ * skip authority the spec calls for: the tree decides node kind, frontmatterSpan covers the `---`
93
+ * region, and fenceTokens covers the directive machinery the tree parses as plain text. */
94
+ function skipRanges(text: string, tree: Tree): Range[] {
95
+ const skips: Range[] = [];
96
+
97
+ // 1. The Lezer tree: the single authority for node-kind skips.
98
+ tree.iterate({
99
+ enter(node) {
100
+ if (SKIP_NODES.has(node.name)) skips.push({ from: node.from, to: node.to });
101
+ },
102
+ });
103
+
104
+ // 2. The frontmatter `---` region (slugs, dates, keys never flagged). The grammar models no
105
+ // frontmatter node, so this deterministic helper is the only authority for the region.
106
+ const fm = frontmatterSpan(text);
107
+ if (fm) skips.push(fm);
108
+
109
+ // 3. The directive machinery, line by line: the colon runs, the `{attrs}` braces, and the
110
+ // directive name. A `[label]`'s prose and the directive body stay checkable, so only the
111
+ // machinery tokens are skipped. fenceTokens emits the directive name and a bracket label both
112
+ // as `label` kind; the name is the lone `label` that precedes any bracket on the line, so it is
113
+ // skipped while the bracketed label is kept.
114
+ let lineStart = 0;
115
+ for (const line of text.split('\n')) {
116
+ const tokens = fenceTokens(line);
117
+ if (tokens.length > 0) {
118
+ let seenBracket = false;
119
+ for (const token of tokens) {
120
+ // A single-character `[` mark opens the bracket label; everything after it on this line is
121
+ // bracketed, so the directive name can only be a `label` before it.
122
+ const isOpenBracket = token.kind === 'mark' && line[token.from] === '[';
123
+ if (token.kind === 'mark') {
124
+ skips.push({ from: lineStart + token.from, to: lineStart + token.to });
125
+ if (isOpenBracket) seenBracket = true;
126
+ } else if (token.kind === 'label' && !seenBracket) {
127
+ // The directive name (a label before any bracket) is machinery, not prose.
128
+ skips.push({ from: lineStart + token.from, to: lineStart + token.to });
129
+ }
130
+ }
131
+ }
132
+
133
+ // 4. A bare `media:` token anywhere on the line, kept whole so the hash never reads as a word.
134
+ for (const m of line.matchAll(MEDIA_TOKEN)) {
135
+ if (parseMediaToken(m[0])) {
136
+ skips.push({ from: lineStart + m.index, to: lineStart + m.index + m[0].length });
137
+ }
138
+ }
139
+
140
+ lineStart += line.length + 1;
141
+ }
142
+
143
+ return mergeRanges(skips);
144
+ }
145
+
146
+ /**
147
+ * The keep spans inside one text window [from, to): the window with every skip range subtracted.
148
+ * This is the lower-level primitive {@link spellcheckRanges} composes over the whole document, and
149
+ * the one the lint source runs over `view.visibleRanges` plus a margin. The skip authority and its
150
+ * precedence live in {@link skipRanges}.
151
+ */
152
+ export function classifyProse(text: string, tree: Tree, from: number, to: number): Range[] {
153
+ const skips = skipRanges(text, tree);
154
+ const out: Range[] = [];
155
+ let cursor = from;
156
+ for (const skip of skips) {
157
+ if (skip.to <= from || skip.from >= to) continue; // outside the window
158
+ const start = Math.max(skip.from, from);
159
+ if (start > cursor) out.push({ from: cursor, to: start });
160
+ cursor = Math.max(cursor, Math.min(skip.to, to));
161
+ }
162
+ if (cursor < to) out.push({ from: cursor, to });
163
+ return out;
164
+ }
165
+
166
+ /** The prose ranges worth checking across the whole document. The lint source narrows this to the
167
+ * visible window; the unit test reads the whole-document set. */
168
+ export function spellcheckRanges(text: string, tree: Tree): Range[] {
169
+ return classifyProse(text, tree, 0, text.length);
170
+ }
171
+
172
+ // A word boundary that keeps intra-word apostrophes and hyphens (so "it's" and "well-known" stay
173
+ // whole) while breaking on everything else. The class is Unicode letters and digits via the u flag,
174
+ // with the inner apostrophe (straight or curly) and hyphen allowed only between word characters.
175
+ const WORD = /[\p{L}\p{N}]+(?:[-'’][\p{L}\p{N}]+)*/gu;
176
+ const ALL_DIGITS = /^\p{N}+$/u;
177
+
178
+ /** Whether a word is worth a lookup. Words under three characters, pure numbers, and all-caps tokens
179
+ * are skipped to cut false positives (the conservative posture VSCode's spell checker takes). */
180
+ function isCheckable(word: string): boolean {
181
+ if (word.length < 3) return false;
182
+ if (ALL_DIGITS.test(word)) return false;
183
+ // An all-caps token (acronym or constant) is skipped; a word with any lowercase letter is checked.
184
+ if (word === word.toUpperCase() && word !== word.toLowerCase()) return false;
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * The checkable words inside [from, to), each lowercased for lookup with its absolute range recorded
190
+ * so a verdict maps straight back to an underline. Sub-three-character words, pure numbers, and
191
+ * all-caps tokens are dropped.
192
+ */
193
+ export function extractWords(text: string, from: number, to: number): ExtractedWord[] {
194
+ const slice = text.slice(from, to);
195
+ const out: ExtractedWord[] = [];
196
+ for (const m of slice.matchAll(WORD)) {
197
+ const raw = m[0];
198
+ if (!isCheckable(raw)) continue;
199
+ const start = from + m.index;
200
+ out.push({ text: raw.toLowerCase(), from: start, to: start + raw.length });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ // The most suggestions shown in one tooltip. SymSpell can return a long ranked list; five is the
206
+ // spec cap, enough to cover the likely correction without burying the management actions below a
207
+ // wall of near-ties.
208
+ const MAX_SUGGESTIONS = 5;
209
+
210
+ /** The callbacks the management actions invoke. The lint source supplies these so the pure builder
211
+ * never touches the Worker or the re-lint mechanism: it only wires the buttons to these handlers. */
212
+ export interface SpellDiagnosticActions {
213
+ /** Add the word to the personal dictionary (posts addWord, records the pending addition, re-lints). */
214
+ onAddWord(word: string): void;
215
+ /** Ignore the word for this session only (posts ignoreWord, re-lints). */
216
+ onIgnoreWord(word: string): void;
217
+ }
218
+
219
+ /**
220
+ * Build the correction popover for one misspelled word, as a @codemirror/lint Diagnostic whose
221
+ * `actions` CodeMirror renders as tooltip buttons (no custom popover code). The actions, in order:
222
+ * up to five ranked suggestions (each replaces the word's range with one transaction), then "Add to
223
+ * dictionary", then "Ignore". The severity is `info` so the underline is quiet, and the message names
224
+ * the word so the underline is never the only signal. Pure: it takes canned suggestions and callbacks,
225
+ * so the unit test asserts the actions array without a browser or a real Worker.
226
+ */
227
+ export function buildSpellDiagnostic(
228
+ word: string,
229
+ range: Range,
230
+ suggestions: readonly string[],
231
+ callbacks: SpellDiagnosticActions,
232
+ ): Diagnostic {
233
+ const ranked = suggestions.slice(0, MAX_SUGGESTIONS).map((suggestion) => ({
234
+ name: suggestion,
235
+ // CodeMirror passes the diagnostic's current position, which may have shifted since the lint ran;
236
+ // the replace uses that live range so a suggestion never overwrites the wrong span after an edit.
237
+ apply: (view: EditorView, from: number, to: number) => {
238
+ view.dispatch({ changes: { from, to, insert: suggestion } });
239
+ },
240
+ }));
241
+
242
+ return {
243
+ from: range.from,
244
+ to: range.to,
245
+ severity: 'info',
246
+ source: 'cairn-spellcheck',
247
+ message: `\`${word}\` may be misspelled.`,
248
+ actions: [
249
+ ...ranked,
250
+ {
251
+ name: 'Add to dictionary',
252
+ apply: () => callbacks.onAddWord(word),
253
+ },
254
+ {
255
+ name: 'Ignore',
256
+ apply: () => callbacks.onIgnoreWord(word),
257
+ },
258
+ ],
259
+ };
260
+ }
261
+
262
+ /**
263
+ * The latest-wins arbiter (the media-preview settling pattern). The lint source hands out a
264
+ * monotonic seq with {@link next} on each run and posts it on the check message; when a `checked`
265
+ * answer lands, {@link accept} returns true only for the highest seq seen, so a stale answer from an
266
+ * older document state is dropped and the underlines never lag the text. Pure, so the seq logic
267
+ * unit-tests without a Worker.
268
+ */
269
+ export interface SeqArbiter {
270
+ /** The next monotonic seq, recorded as the current run. */
271
+ next(): number;
272
+ /** True when this seq is still the latest one issued or accepted, false for a stale answer. */
273
+ accept(seq: number): boolean;
274
+ }
275
+
276
+ /** Build a fresh {@link SeqArbiter}. */
277
+ export function arbitrateChecked(): SeqArbiter {
278
+ let current = 0;
279
+ return {
280
+ next() {
281
+ current += 1;
282
+ return current;
283
+ },
284
+ accept(seq) {
285
+ if (seq < current) return false;
286
+ current = seq;
287
+ return true;
288
+ },
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Build the quick-fix popover for one objective-error finding, as a @codemirror/lint Diagnostic whose
294
+ * one `actions` entry applies the finding's deterministic fix. The severity is `info` so the underline
295
+ * shares the spellcheck surface and the locked amber color (an editor reads spelling and these
296
+ * mechanical errors as one "spellcheck" layer). The fix range is recomputed from the live diagnostic
297
+ * position CodeMirror passes, offset by the same delta as the original finding, so an edit elsewhere
298
+ * never makes the fix overwrite the wrong span. Pure: it takes a finding, so the unit test asserts the
299
+ * diagnostic without a browser.
300
+ */
301
+ export function buildObjectiveDiagnostic(error: ObjectiveError): Diagnostic {
302
+ // The fix range sits inside the flagged range; record its offset from the flagged start so the apply
303
+ // can re-anchor against the live position CodeMirror reports (which may have shifted since the lint).
304
+ const fixOffsetFrom = error.fix.from - error.from;
305
+ const fixOffsetTo = error.fix.to - error.from;
306
+ const insert = error.fix.insert;
307
+ return {
308
+ from: error.from,
309
+ to: error.to,
310
+ severity: 'info',
311
+ source: 'cairn-objective',
312
+ message: error.message,
313
+ actions: [
314
+ {
315
+ name: 'Fix',
316
+ apply: (view: EditorView, from: number) => {
317
+ view.dispatch({
318
+ changes: { from: from + fixOffsetFrom, to: from + fixOffsetTo, insert },
319
+ });
320
+ },
321
+ },
322
+ ],
323
+ };
324
+ }
325
+
326
+ // ----- The linter() wiring (the CodeMirror side) -----
327
+ //
328
+ // Only this half touches CodeMirror, and it never value-imports an @codemirror/* package at module
329
+ // scope: EditPage imports the component .ts helpers statically, so a static value import here would
330
+ // pull CodeMirror into a consumer's server bundle (the editor-boundary test enforces this). The
331
+ // modules resolve lazily inside the source, the same boundary link-completion.ts keeps. The Worker
332
+ // construction sits behind a small injectable seam so the lint logic can be exercised without a real
333
+ // Worker; production passes the seam that builds the spike's `new Worker(...)`.
334
+
335
+ let lintMod: typeof import('@codemirror/lint') | null = null;
336
+ let langMod: typeof import('@codemirror/language') | null = null;
337
+ let viewMod: typeof import('@codemirror/view') | null = null;
338
+ let stateMod: typeof import('@codemirror/state') | null = null;
339
+
340
+ /** The narrow Worker surface the lint source drives: it posts check, suggest, addWord, and ignoreWord
341
+ * messages and listens for the answers. A `suggest` answer is a one-shot, so the source removes its
342
+ * own listener once it lands. A test injects a fake; production injects a real Worker. */
343
+ export interface SpellWorker {
344
+ postMessage(message: unknown): void;
345
+ addEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
346
+ removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
347
+ }
348
+
349
+ /** Construct the real spellcheck Worker, the spike's delivery shape. Kept behind the seam so the
350
+ * lint source never references `Worker` at module scope and a test can swap it. */
351
+ export function createSpellWorker(): SpellWorker {
352
+ return new Worker(new URL('./spellcheck-worker.js', import.meta.url), {
353
+ type: 'module',
354
+ }) as unknown as SpellWorker;
355
+ }
356
+
357
+ // The wasm and dictionary URLs are resolved module-relative with `import.meta.url`, the same
358
+ // mechanism createSpellWorker uses for the worker. The spike (docs/internal/design/
359
+ // 2026-06-20-editor-copyedit-spike-result.md) proved Vite's `?worker`/`?url` package-subpath imports
360
+ // from CONSUMER app code; the library's own source cannot self-import by package name, so it uses the
361
+ // portable `new URL('./asset', import.meta.url)` form Vite resolves inside dependencies too. Task 16's
362
+ // showcase E2E proves the whole chain through the real consumer build; if resolution ever fails there,
363
+ // only these two URL lines change. The dictionary filename is the dialect-resolved one the main thread
364
+ // passes in; the wasm filename is fixed.
365
+
366
+ /** The real wasm asset URL, resolved module-relative the same way the worker is. */
367
+ export function resolveWasmUrl(): string {
368
+ return new URL('./spellcheck-assets/spellchecker-wasm.wasm', import.meta.url).href;
369
+ }
370
+
371
+ /** Each shipped dictionary, mapped to a resolver that builds its asset URL with a LITERAL
372
+ * `new URL(..., import.meta.url)`. The literal path is load-bearing. A templated `new URL` makes Vite
373
+ * and rolldown treat the directory as a glob and parse every sibling module to build it, including the
374
+ * `.svelte` components that still carry `lang="ts"` in `dist`, and the glob parser chokes on the TS
375
+ * syntax and breaks the consumer build. This set mirrors the dialect map in `nav/site-config.ts`; add
376
+ * one line per new shipped dialect dictionary. */
377
+ const DICTIONARY_URLS: Record<string, () => string> = {
378
+ 'dictionary-en-us.txt': () =>
379
+ new URL('./spellcheck-assets/dictionary-en-us.txt', import.meta.url).href,
380
+ };
381
+
382
+ /** The real dictionary asset URL for a dictionary filename, resolved module-relative. The caller
383
+ * passes the dialect-resolved filename (default `dictionary-en-us.txt`). `dictionaryFileForDialect`
384
+ * already collapses an unknown dialect to the default, so an unmapped name falls back the same way
385
+ * rather than pointing at an asset that does not ship. */
386
+ export function resolveDictionaryUrl(dictionaryFile: string): string {
387
+ const resolve = DICTIONARY_URLS[dictionaryFile] ?? DICTIONARY_URLS['dictionary-en-us.txt'];
388
+ return resolve();
389
+ }
390
+
391
+ /** How far past the visible viewport to lint, so a small scroll does not re-lint from scratch. */
392
+ const VIEWPORT_MARGIN = 1000;
393
+
394
+ /** Options for {@link cairnSpellcheck}, so the unit and component layers can inject a fake Worker
395
+ * factory in place of the real `new Worker(...)`. */
396
+ export interface SpellcheckOptions {
397
+ /** The Worker factory; defaults to {@link createSpellWorker}. Created lazily on the first lint. */
398
+ createWorker?: () => SpellWorker;
399
+ /** The pending personal-dictionary additions, owned by the caller. When an author chooses "Add to
400
+ * dictionary" the source posts addWord to the Worker (the underline clears at once) and records the
401
+ * word here. The set is the seam Task 9 commits to the git-backed dictionary file; this source only
402
+ * fills it and never persists. A caller that does not pass one gets a fresh internal set. */
403
+ pendingAdditions?: Set<string>;
404
+ /** The committed personal-dictionary words (spec 1.6) the source seeds the Worker's personal layer
405
+ * with, posted as one batch `addWord` right after `init`. The git-backed site dictionary is the
406
+ * durable layer; the editor reads it at load (EditData.siteDictionary) and hands it here, so a word
407
+ * another editor committed answers correct from the first lint. Empty by default (dialect-only). */
408
+ siteWords?: ReadonlyArray<string>;
409
+ /** The dialect-resolved dictionary filename, e.g. "dictionary-en-us.txt". The source resolves it to
410
+ * a real asset URL and posts it in the Worker's `init`. Defaults to US English. */
411
+ dictionaryFile?: string;
412
+ /** Override the resolved wasm and dictionary URLs the source posts in `init`. The real resolution
413
+ * uses {@link resolveWasmUrl}/{@link resolveDictionaryUrl} (module-relative `import.meta.url`); a
414
+ * component test that injects a fake Worker can pass canned URLs so it never touches the asset
415
+ * resolver. */
416
+ assetUrls?: { wasmUrl: string; dictionaryUrl: string };
417
+ /** Treat the Worker as ready without waiting for a `ready` message. The production path is strict
418
+ * (it posts `init` and waits for `ready` before painting); a fake Worker in a test that does not
419
+ * answer `ready` can set this so a lint run is not held back. Defaults to false. */
420
+ assumeReady?: boolean;
421
+ /** The already-loaded CodeMirror modules to reuse instead of importing them again. The editor
422
+ * component loads `@codemirror/view`/`@codemirror/language` for its own extensions, so passing them
423
+ * here keeps the lint source on the SAME module instances; a second dynamic import can resolve to a
424
+ * separate copy (the test bundler's dedup quirk), and CodeMirror's instanceof checks then reject the
425
+ * extension. When omitted, the source imports them itself (the standalone path). `@codemirror/lint`
426
+ * is loaded here when not supplied, since the editor does not otherwise need it. */
427
+ modules?: {
428
+ lint?: typeof import('@codemirror/lint');
429
+ language?: typeof import('@codemirror/language');
430
+ view?: typeof import('@codemirror/view');
431
+ state?: typeof import('@codemirror/state');
432
+ };
433
+ }
434
+
435
+ // The lint underline is LOCKED to --cairn-warning-ink (a muted amber, the closest shipped token to
436
+ // the spec's "neither the directive accent nor error red"; there is no --cairn-info-ink). Spellcheck
437
+ // diagnostics carry severity `info`, so the override targets the `info` underline and tooltip row.
438
+ // --cairn-error-ink red is reserved for tidy deletions, so a spellcheck underline and a tidy deletion
439
+ // are never the same color. The tooltip rides the admin Warm Stone tokens and the focus rules; the
440
+ // lint action buttons are CodeMirror's own focusable buttons, so the theme only restores a visible
441
+ // focus ring (the admin base button reset strips the UA outline). The wavy underline uses a CSS
442
+ // text-decoration so the locked token resolves at render rather than being baked into a static SVG.
443
+ function lockedUnderlineTheme(EditorViewMod: typeof import('@codemirror/view').EditorView): Extension {
444
+ return EditorViewMod.theme({
445
+ // The amber wavy underline, the one spellcheck underline color across the feature.
446
+ '.cm-lintRange-info': {
447
+ backgroundImage: 'none',
448
+ textDecoration: 'underline wavy var(--cairn-warning-ink, oklch(50% 0.13 70))',
449
+ textDecorationSkipInk: 'none',
450
+ textUnderlineOffset: '0.2em',
451
+ },
452
+ // The tooltip surface rides the admin Warm Stone tokens.
453
+ '.cm-tooltip.cm-tooltip-lint': {
454
+ backgroundColor: 'var(--color-base-100, #fff)',
455
+ border: '1px solid var(--color-base-300, oklch(90% 0.01 75))',
456
+ borderRadius: '0.5rem',
457
+ color: 'var(--color-base-content, oklch(28% 0.01 75))',
458
+ },
459
+ '.cm-diagnostic-info': {
460
+ borderLeftColor: 'var(--cairn-warning-ink, oklch(50% 0.13 70))',
461
+ },
462
+ // The action buttons are real focusable buttons; the admin base reset strips the UA outline, so a
463
+ // visible focus ring is restored here to keep them keyboard-discoverable (the a11y focus rule).
464
+ '.cm-diagnosticAction:focus-visible': {
465
+ outline: '2px solid var(--cairn-warning-ink, oklch(50% 0.13 70))',
466
+ outlineOffset: '1px',
467
+ },
468
+ });
469
+ }
470
+
471
+ /**
472
+ * The @codemirror/lint linter() source, made markdown-aware by the Lezer tree. It runs over the
473
+ * visible viewport plus a margin (not the whole document), extracts the checkable words via the pure
474
+ * classifier, posts them to the Worker keyed by a monotonic latest-wins seq, and maps the
475
+ * `correct: false` answers back to ranges. Each wrong word becomes a correction popover: the source
476
+ * fetches the ranked suggestions in the same batch, then {@link buildSpellDiagnostic} wires the
477
+ * quick-fix actions (the suggestions, then add-to-dictionary, then ignore). The returned extension
478
+ * bundles the spellcheck linter, a second deterministic objective-error linter over the same prose
479
+ * spans (doubled words, double spaces, repeated punctuation), and the locked amber underline theme,
480
+ * so Task 7's single on/off toggle gates both surfaces by reconfiguring this one extension.
481
+ */
482
+ export async function cairnSpellcheck(options: SpellcheckOptions = {}): Promise<Extension> {
483
+ // Reuse the caller's already-loaded modules when supplied (the editor passes its own view/language
484
+ // copies so the lint extension lands on the same instances), else lazily value-import them. The lazy
485
+ // imports keep CodeMirror off the server bundle (the boundary the editor relies on) and match how
486
+ // link-completion.ts resolves @codemirror/language inside its source.
487
+ lintMod = options.modules?.lint ?? lintMod ?? (await import('@codemirror/lint'));
488
+ langMod = options.modules?.language ?? langMod ?? (await import('@codemirror/language'));
489
+ viewMod = options.modules?.view ?? viewMod ?? (await import('@codemirror/view'));
490
+ stateMod = options.modules?.state ?? stateMod ?? (await import('@codemirror/state'));
491
+ const { linter, forceLinting } = lintMod;
492
+ const { syntaxTree } = langMod;
493
+ const { EditorView } = viewMod;
494
+ const { StateEffect } = stateMod;
495
+
496
+ // A re-lint nudge for the no-doc-change case. Add-to-dictionary and ignore change the Worker's merged
497
+ // set but not the document, and @codemirror/lint's forceLinting() is a no-op when the editor is idle
498
+ // (its internal force() only fires when a lint is already scheduled). The linter's needsRefresh hook
499
+ // is the supported way to schedule a run on a state change that is not a doc edit: a callback
500
+ // dispatches this effect, needsRefresh sees it and schedules the lint, and forceLinting then runs it
501
+ // at once so the underlines clear immediately.
502
+ const relintEffect = StateEffect.define<null>();
503
+
504
+ const createWorker = options.createWorker ?? createSpellWorker;
505
+ const pendingAdditions = options.pendingAdditions ?? new Set<string>();
506
+ // The committed site dictionary words, seeded into the Worker's personal layer at init.
507
+ const siteWords = options.siteWords ?? [];
508
+ const arbiter = arbitrateChecked();
509
+ // The wasm and dictionary URLs the Worker fetches, resolved module-relative unless the caller
510
+ // overrides them (a test injecting a fake Worker passes canned URLs). The dictionary filename is the
511
+ // dialect-resolved one; the wasm filename is fixed.
512
+ const assetUrls = options.assetUrls ?? {
513
+ wasmUrl: resolveWasmUrl(),
514
+ dictionaryUrl: resolveDictionaryUrl(options.dictionaryFile ?? 'dictionary-en-us.txt'),
515
+ };
516
+
517
+ let worker: SpellWorker | null = null;
518
+ // The Worker answers `ready` once its dictionary has streamed into wasm; until then a lint run paints
519
+ // nothing rather than throwing. A test can set assumeReady to skip the wait when its fake Worker does
520
+ // not answer `ready`.
521
+ let ready = options.assumeReady ?? false;
522
+ // The view from the latest lint run, captured so a management action can re-lint through it. The
523
+ // action callbacks fire long after the source promise resolved, so the source cannot close over a
524
+ // run-local view; it stores the last one here.
525
+ let lastView: EditorView | null = null;
526
+ // The in-flight check requests keyed by their seq, each resolved by the matching `checked` answer.
527
+ const pending = new Map<
528
+ number,
529
+ { words: ExtractedWord[]; resolve: (diagnostics: Diagnostic[]) => void }
530
+ >();
531
+ // A monotonic seq for suggest round-trips, separate from the check seq so the two answer streams
532
+ // never collide on a shared counter.
533
+ let suggestSeq = 0;
534
+
535
+ // Re-run the lint at once after a state change that is not a doc edit (the Worker became ready, or a
536
+ // word was added or ignored). The dispatched effect makes the linter's needsRefresh schedule a run,
537
+ // so the following forceLinting is no longer a no-op (its internal force() only fires on a scheduled
538
+ // lint), and the underlines repaint immediately.
539
+ function relint(): void {
540
+ if (!lastView) return;
541
+ lastView.dispatch({ effects: relintEffect.of(null) });
542
+ forceLinting(lastView);
543
+ }
544
+
545
+ function ensureWorker(): SpellWorker {
546
+ if (worker) return worker;
547
+ worker = createWorker();
548
+ worker.addEventListener('message', (event: MessageEvent) => {
549
+ const data = event.data as {
550
+ type?: string;
551
+ seq?: number;
552
+ results?: { id: number; correct: boolean }[];
553
+ detail?: string;
554
+ };
555
+ // The init ack: lookups are live. Re-lint so the viewport paints the underlines the not-ready
556
+ // runs withheld; the latest-wins seq keeps the re-lint from racing an in-flight run.
557
+ if (data.type === 'ready') {
558
+ ready = true;
559
+ relint();
560
+ return;
561
+ }
562
+ // An init or lookup failure: log it and leave the editor usable (no underlines is the graceful
563
+ // degrade, never a thrown error). A `check` that arrives after this still resolves to [] below.
564
+ if (data.type === 'error') {
565
+ console.warn('cairn spellcheck worker error:', data.detail ?? 'unknown');
566
+ return;
567
+ }
568
+ if (data.type !== 'checked' || typeof data.seq !== 'number') return;
569
+ const request = pending.get(data.seq);
570
+ if (!request) return;
571
+ pending.delete(data.seq);
572
+ // Drop a stale answer that landed after a newer run; only the latest seq paints underlines.
573
+ if (!arbiter.accept(data.seq)) {
574
+ request.resolve([]);
575
+ return;
576
+ }
577
+ const wrongIds = new Set((data.results ?? []).filter((r) => !r.correct).map((r) => r.id));
578
+ const wrong = request.words.filter((_, id) => wrongIds.has(id));
579
+ // Fetch the ranked suggestions for every wrong word, then build the popovers from the answers.
580
+ void buildDiagnostics(wrong).then(request.resolve);
581
+ });
582
+ // The handshake: post init so the Worker streams its dictionary, then wait for `ready` before any
583
+ // check/suggest. The Worker answers `error` on a check that lands before ready, so the not-ready
584
+ // early return in the lint source keeps a check from ever racing the init.
585
+ worker.postMessage({ type: 'init', ...assetUrls });
586
+ // Seed the personal layer from the committed site dictionary. The Worker merges addWord into its
587
+ // in-memory personal set regardless of ready state (it touches no engine), so a site word answers
588
+ // correct from the first lint without waiting for the dialect stream.
589
+ if (siteWords.length > 0) worker.postMessage({ type: 'addWord', words: siteWords });
590
+ return worker;
591
+ }
592
+
593
+ /** Fetch a single word's ranked suggestions over the Worker, a one-shot listener removed on the
594
+ * answer. The suggest path is independent of the check seq, so a slow suggest never blocks a fresh
595
+ * check; an empty list (the engine returned nothing) still yields a popover with the two
596
+ * management actions. */
597
+ function fetchSuggestions(w: SpellWorker, word: string): Promise<string[]> {
598
+ suggestSeq += 1;
599
+ const seq = suggestSeq;
600
+ return new Promise<string[]>((resolve) => {
601
+ const listener = (event: MessageEvent) => {
602
+ const data = event.data as { type?: string; seq?: number; suggestions?: string[] };
603
+ if (data.type !== 'suggested' || data.seq !== seq) return;
604
+ w.removeEventListener('message', listener);
605
+ resolve(data.suggestions ?? []);
606
+ };
607
+ w.addEventListener('message', listener);
608
+ w.postMessage({ type: 'suggest', seq, word });
609
+ });
610
+ }
611
+
612
+ /** Turn the wrong words into correction popovers, each carrying its ranked suggestions and the two
613
+ * management actions. */
614
+ async function buildDiagnostics(wrong: ExtractedWord[]): Promise<Diagnostic[]> {
615
+ const w = ensureWorker();
616
+ const callbacks: SpellDiagnosticActions = {
617
+ onAddWord(word) {
618
+ // Post addWord so the Worker's merged set now answers correct, record the pending addition for
619
+ // Task 9 to commit, and re-lint so every instance of the word clears at once.
620
+ w.postMessage({ type: 'addWord', word });
621
+ pendingAdditions.add(word.toLowerCase());
622
+ relint();
623
+ },
624
+ onIgnoreWord(word) {
625
+ // Session-only ignore, never persisted; re-lint so the underline clears everywhere.
626
+ w.postMessage({ type: 'ignoreWord', word });
627
+ relint();
628
+ },
629
+ };
630
+ return Promise.all(
631
+ wrong.map(async (word) => {
632
+ const suggestions = await fetchSuggestions(w, word.text);
633
+ return buildSpellDiagnostic(word.text, { from: word.from, to: word.to }, suggestions, callbacks);
634
+ }),
635
+ );
636
+ }
637
+
638
+ // The prose keep-spans for one view, scoped to the visible viewport plus a margin. The spellcheck
639
+ // source and the objective source both run over exactly these spans, so a doubled word inside a
640
+ // code fence is never flagged and the two surfaces can never drift on what counts as prose.
641
+ function visibleProseSpans(view: EditorView): { text: string; spans: Range[] } {
642
+ const text = view.state.doc.toString();
643
+ const tree = syntaxTree(view.state);
644
+ const docLength = text.length;
645
+ const spans: Range[] = [];
646
+ for (const vr of view.visibleRanges) {
647
+ const from = Math.max(0, vr.from - VIEWPORT_MARGIN);
648
+ const to = Math.min(docLength, vr.to + VIEWPORT_MARGIN);
649
+ spans.push(...classifyProse(text, tree, from, to));
650
+ }
651
+ return { text, spans };
652
+ }
653
+
654
+ const source = linter(async (view) => {
655
+ lastView = view;
656
+ // Create the Worker (and post init) on the first run even when not yet ready, so the dictionary
657
+ // starts streaming. Until `ready` lands this run paints nothing; the `ready` handler re-lints.
658
+ ensureWorker();
659
+ if (!ready) return [];
660
+
661
+ const { text, spans } = visibleProseSpans(view);
662
+ const words: ExtractedWord[] = [];
663
+ for (const span of spans) {
664
+ words.push(...extractWords(text, span.from, span.to));
665
+ }
666
+ if (words.length === 0) return [];
667
+
668
+ const seq = arbiter.next();
669
+ const checkWords = words.map((word, id) => ({ id, word: word.text }));
670
+ return new Promise<Diagnostic[]>((resolve) => {
671
+ pending.set(seq, { words, resolve });
672
+ ensureWorker().postMessage({ type: 'check', seq, words: checkWords });
673
+ });
674
+ }, {
675
+ // Schedule a fresh lint when relint() dispatches its effect. The hook covers the state changes that
676
+ // are not doc edits (the Worker became ready, a word was added or ignored); without it the linter
677
+ // only re-runs on a doc change, so an add-to-dictionary would never clear the standing underlines.
678
+ needsRefresh: (update) =>
679
+ update.transactions.some((tr) => tr.effects.some((e) => e.is(relintEffect))),
680
+ });
681
+
682
+ // The objective-error source: a second linter() over the SAME viewport-scoped prose spans the
683
+ // spellcheck source uses, so a doubled word inside a code fence is never flagged. It is synchronous
684
+ // and deterministic (no Worker, no dictionary), and its diagnostics carry `info` so they share the
685
+ // locked amber underline. It ships in the same returned extension as the spellcheck source, so the
686
+ // Task 7 toggle reconfigures one compartment to gate both surfaces at once.
687
+ const objectiveSource = linter((view) => {
688
+ const { text, spans } = visibleProseSpans(view);
689
+ return objectiveErrors(text, spans).map(buildObjectiveDiagnostic);
690
+ });
691
+
692
+ return [source, objectiveSource, lockedUnderlineTheme(EditorView)];
693
+ }