@glw907/cairn-cms 0.59.0 → 0.60.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 (80) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/components/CairnAdmin.svelte +3 -0
  3. package/dist/components/CairnTidySettings.svelte +553 -0
  4. package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
  5. package/dist/components/EditPage.svelte +371 -2
  6. package/dist/components/MarkdownEditor.svelte +168 -1
  7. package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
  8. package/dist/components/TidyReview.svelte +463 -0
  9. package/dist/components/TidyReview.svelte.d.ts +47 -0
  10. package/dist/components/cairn-admin.css +764 -0
  11. package/dist/components/editor-tidy.d.ts +31 -0
  12. package/dist/components/editor-tidy.js +199 -0
  13. package/dist/components/index.d.ts +1 -0
  14. package/dist/components/index.js +1 -0
  15. package/dist/components/markdown-directives.d.ts +16 -0
  16. package/dist/components/markdown-directives.js +34 -0
  17. package/dist/components/objective-errors.d.ts +30 -0
  18. package/dist/components/objective-errors.js +113 -0
  19. package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  20. package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  21. package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  22. package/dist/components/spellcheck-worker.d.ts +80 -0
  23. package/dist/components/spellcheck-worker.js +161 -0
  24. package/dist/components/spellcheck.d.ts +146 -0
  25. package/dist/components/spellcheck.js +541 -0
  26. package/dist/components/tidy-categorize.d.ts +67 -0
  27. package/dist/components/tidy-categorize.js +392 -0
  28. package/dist/components/tidy-diff.d.ts +60 -0
  29. package/dist/components/tidy-diff.js +147 -0
  30. package/dist/components/tidy-validate.d.ts +37 -0
  31. package/dist/components/tidy-validate.js +174 -0
  32. package/dist/content/compose.d.ts +1 -1
  33. package/dist/content/compose.js +11 -0
  34. package/dist/content/site-dictionary.d.ts +31 -0
  35. package/dist/content/site-dictionary.js +82 -0
  36. package/dist/content/types.d.ts +25 -0
  37. package/dist/doctor/checks-local.d.ts +1 -0
  38. package/dist/doctor/checks-local.js +55 -6
  39. package/dist/doctor/index.js +2 -1
  40. package/dist/log/events.d.ts +1 -1
  41. package/dist/nav/site-config.d.ts +98 -0
  42. package/dist/nav/site-config.js +132 -0
  43. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  44. package/dist/sveltekit/admin-dispatch.js +6 -2
  45. package/dist/sveltekit/cairn-admin.d.ts +13 -1
  46. package/dist/sveltekit/cairn-admin.js +22 -3
  47. package/dist/sveltekit/content-routes.d.ts +135 -1
  48. package/dist/sveltekit/content-routes.js +351 -3
  49. package/dist/sveltekit/tidy-prompt.d.ts +11 -0
  50. package/dist/sveltekit/tidy-prompt.js +118 -0
  51. package/package.json +10 -1
  52. package/src/lib/components/CairnAdmin.svelte +3 -0
  53. package/src/lib/components/CairnTidySettings.svelte +553 -0
  54. package/src/lib/components/EditPage.svelte +371 -2
  55. package/src/lib/components/MarkdownEditor.svelte +168 -1
  56. package/src/lib/components/TidyReview.svelte +463 -0
  57. package/src/lib/components/cairn-admin.css +25 -0
  58. package/src/lib/components/editor-tidy.ts +241 -0
  59. package/src/lib/components/index.ts +1 -0
  60. package/src/lib/components/markdown-directives.ts +35 -0
  61. package/src/lib/components/objective-errors.ts +155 -0
  62. package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  63. package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  64. package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  65. package/src/lib/components/spellcheck-worker.ts +279 -0
  66. package/src/lib/components/spellcheck.ts +679 -0
  67. package/src/lib/components/tidy-categorize.ts +460 -0
  68. package/src/lib/components/tidy-diff.ts +196 -0
  69. package/src/lib/components/tidy-validate.ts +202 -0
  70. package/src/lib/content/compose.ts +11 -1
  71. package/src/lib/content/site-dictionary.ts +84 -0
  72. package/src/lib/content/types.ts +25 -0
  73. package/src/lib/doctor/checks-local.ts +59 -5
  74. package/src/lib/doctor/index.ts +2 -0
  75. package/src/lib/log/events.ts +7 -1
  76. package/src/lib/nav/site-config.ts +197 -0
  77. package/src/lib/sveltekit/admin-dispatch.ts +7 -3
  78. package/src/lib/sveltekit/cairn-admin.ts +32 -4
  79. package/src/lib/sveltekit/content-routes.ts +504 -4
  80. package/src/lib/sveltekit/tidy-prompt.ts +153 -0
@@ -0,0 +1,541 @@
1
+ import { frontmatterSpan, fenceTokens } from './markdown-directives.js';
2
+ import { parseMediaToken } from '../media/reference.js';
3
+ import { objectiveErrors } from './objective-errors.js';
4
+ // The Lezer node kinds that are never spellchecked, verified empirically against the actual
5
+ // @codemirror/lang-markdown grammar (parse a fixture, inspect node names) rather than trusting the
6
+ // spec list blind. The grammar models:
7
+ // code: InlineCode, FencedCode, CodeText, CodeBlock (CodeBlock is the indented form; its body is a
8
+ // CodeText too). CodeMark/CodeInfo are the fence/lang markers.
9
+ // links and URLs: URL (a link destination and the URL inside an autolink), Autolink (the whole
10
+ // <...> form), LinkLabel (a [ref] label and a reference-definition label), LinkReference (the
11
+ // whole `[ref]: url "title"` definition), LinkTitle (the quoted title on a definition).
12
+ // HTML: HTMLTag (inline), HTMLBlock.
13
+ // emphasis/strong MARKERS: EmphasisMark (the *,_,**,__ runs), not the prose inside Emphasis or
14
+ // StrongEmphasis. The same goes for the other *Mark nodes (LinkMark, HeaderMark, ListMark,
15
+ // QuoteMark), which are punctuation, never prose.
16
+ // Note the spec listed "link destinations, autolinks, link labels, reference definitions"; the real
17
+ // node names for those are URL, Autolink, LinkLabel, and LinkReference. LinkTitle is added because
18
+ // the grammar emits the definition's quoted title as its own node and it is machinery, not prose.
19
+ const SKIP_NODES = new Set([
20
+ 'InlineCode',
21
+ 'FencedCode',
22
+ 'CodeText',
23
+ 'CodeBlock',
24
+ 'CodeMark',
25
+ 'CodeInfo',
26
+ 'URL',
27
+ 'Autolink',
28
+ 'LinkLabel',
29
+ 'LinkReference',
30
+ 'LinkTitle',
31
+ 'HTMLTag',
32
+ 'HTMLBlock',
33
+ 'EmphasisMark',
34
+ 'LinkMark',
35
+ 'HeaderMark',
36
+ 'ListMark',
37
+ 'QuoteMark',
38
+ ]);
39
+ // A bare `media:` token shaped like the reference grammar (a slug-and-hash or bare-hash run with no
40
+ // surrounding whitespace). A token inside an image is already caught by the URL skip; this catches
41
+ // the form authors type directly in prose, so it is never split into "media" plus a flagged hash.
42
+ const MEDIA_TOKEN = /media:[\w.-]+/g;
43
+ /** Merge overlapping or touching ranges into a sorted, disjoint set, so the keep-span computation
44
+ * subtracts one clean list of skip regions. */
45
+ function mergeRanges(ranges) {
46
+ if (ranges.length === 0)
47
+ return [];
48
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
49
+ const out = [{ ...sorted[0] }];
50
+ for (let i = 1; i < sorted.length; i++) {
51
+ const next = sorted[i];
52
+ const last = out[out.length - 1];
53
+ if (next.from <= last.to)
54
+ last.to = Math.max(last.to, next.to);
55
+ else
56
+ out.push({ ...next });
57
+ }
58
+ return out;
59
+ }
60
+ /** Every absolute skip range in the document, from all three mechanisms, merged. This is the single
61
+ * skip authority the spec calls for: the tree decides node kind, frontmatterSpan covers the `---`
62
+ * region, and fenceTokens covers the directive machinery the tree parses as plain text. */
63
+ function skipRanges(text, tree) {
64
+ const skips = [];
65
+ // 1. The Lezer tree: the single authority for node-kind skips.
66
+ tree.iterate({
67
+ enter(node) {
68
+ if (SKIP_NODES.has(node.name))
69
+ skips.push({ from: node.from, to: node.to });
70
+ },
71
+ });
72
+ // 2. The frontmatter `---` region (slugs, dates, keys never flagged). The grammar models no
73
+ // frontmatter node, so this deterministic helper is the only authority for the region.
74
+ const fm = frontmatterSpan(text);
75
+ if (fm)
76
+ skips.push(fm);
77
+ // 3. The directive machinery, line by line: the colon runs, the `{attrs}` braces, and the
78
+ // directive name. A `[label]`'s prose and the directive body stay checkable, so only the
79
+ // machinery tokens are skipped. fenceTokens emits the directive name and a bracket label both
80
+ // as `label` kind; the name is the lone `label` that precedes any bracket on the line, so it is
81
+ // skipped while the bracketed label is kept.
82
+ let lineStart = 0;
83
+ for (const line of text.split('\n')) {
84
+ const tokens = fenceTokens(line);
85
+ if (tokens.length > 0) {
86
+ let seenBracket = false;
87
+ for (const token of tokens) {
88
+ // A single-character `[` mark opens the bracket label; everything after it on this line is
89
+ // bracketed, so the directive name can only be a `label` before it.
90
+ const isOpenBracket = token.kind === 'mark' && line[token.from] === '[';
91
+ if (token.kind === 'mark') {
92
+ skips.push({ from: lineStart + token.from, to: lineStart + token.to });
93
+ if (isOpenBracket)
94
+ seenBracket = true;
95
+ }
96
+ else if (token.kind === 'label' && !seenBracket) {
97
+ // The directive name (a label before any bracket) is machinery, not prose.
98
+ skips.push({ from: lineStart + token.from, to: lineStart + token.to });
99
+ }
100
+ }
101
+ }
102
+ // 4. A bare `media:` token anywhere on the line, kept whole so the hash never reads as a word.
103
+ for (const m of line.matchAll(MEDIA_TOKEN)) {
104
+ if (parseMediaToken(m[0])) {
105
+ skips.push({ from: lineStart + m.index, to: lineStart + m.index + m[0].length });
106
+ }
107
+ }
108
+ lineStart += line.length + 1;
109
+ }
110
+ return mergeRanges(skips);
111
+ }
112
+ /**
113
+ * The keep spans inside one text window [from, to): the window with every skip range subtracted.
114
+ * This is the lower-level primitive {@link spellcheckRanges} composes over the whole document, and
115
+ * the one the lint source runs over `view.visibleRanges` plus a margin. The skip authority and its
116
+ * precedence live in {@link skipRanges}.
117
+ */
118
+ export function classifyProse(text, tree, from, to) {
119
+ const skips = skipRanges(text, tree);
120
+ const out = [];
121
+ let cursor = from;
122
+ for (const skip of skips) {
123
+ if (skip.to <= from || skip.from >= to)
124
+ continue; // outside the window
125
+ const start = Math.max(skip.from, from);
126
+ if (start > cursor)
127
+ out.push({ from: cursor, to: start });
128
+ cursor = Math.max(cursor, Math.min(skip.to, to));
129
+ }
130
+ if (cursor < to)
131
+ out.push({ from: cursor, to });
132
+ return out;
133
+ }
134
+ /** The prose ranges worth checking across the whole document. The lint source narrows this to the
135
+ * visible window; the unit test reads the whole-document set. */
136
+ export function spellcheckRanges(text, tree) {
137
+ return classifyProse(text, tree, 0, text.length);
138
+ }
139
+ // A word boundary that keeps intra-word apostrophes and hyphens (so "it's" and "well-known" stay
140
+ // whole) while breaking on everything else. The class is Unicode letters and digits via the u flag,
141
+ // with the inner apostrophe (straight or curly) and hyphen allowed only between word characters.
142
+ const WORD = /[\p{L}\p{N}]+(?:[-'’][\p{L}\p{N}]+)*/gu;
143
+ const ALL_DIGITS = /^\p{N}+$/u;
144
+ /** Whether a word is worth a lookup. Words under three characters, pure numbers, and all-caps tokens
145
+ * are skipped to cut false positives (the conservative posture VSCode's spell checker takes). */
146
+ function isCheckable(word) {
147
+ if (word.length < 3)
148
+ return false;
149
+ if (ALL_DIGITS.test(word))
150
+ return false;
151
+ // An all-caps token (acronym or constant) is skipped; a word with any lowercase letter is checked.
152
+ if (word === word.toUpperCase() && word !== word.toLowerCase())
153
+ return false;
154
+ return true;
155
+ }
156
+ /**
157
+ * The checkable words inside [from, to), each lowercased for lookup with its absolute range recorded
158
+ * so a verdict maps straight back to an underline. Sub-three-character words, pure numbers, and
159
+ * all-caps tokens are dropped.
160
+ */
161
+ export function extractWords(text, from, to) {
162
+ const slice = text.slice(from, to);
163
+ const out = [];
164
+ for (const m of slice.matchAll(WORD)) {
165
+ const raw = m[0];
166
+ if (!isCheckable(raw))
167
+ continue;
168
+ const start = from + m.index;
169
+ out.push({ text: raw.toLowerCase(), from: start, to: start + raw.length });
170
+ }
171
+ return out;
172
+ }
173
+ // The most suggestions shown in one tooltip. SymSpell can return a long ranked list; five is the
174
+ // spec cap, enough to cover the likely correction without burying the management actions below a
175
+ // wall of near-ties.
176
+ const MAX_SUGGESTIONS = 5;
177
+ /**
178
+ * Build the correction popover for one misspelled word, as a @codemirror/lint Diagnostic whose
179
+ * `actions` CodeMirror renders as tooltip buttons (no custom popover code). The actions, in order:
180
+ * up to five ranked suggestions (each replaces the word's range with one transaction), then "Add to
181
+ * dictionary", then "Ignore". The severity is `info` so the underline is quiet, and the message names
182
+ * the word so the underline is never the only signal. Pure: it takes canned suggestions and callbacks,
183
+ * so the unit test asserts the actions array without a browser or a real Worker.
184
+ */
185
+ export function buildSpellDiagnostic(word, range, suggestions, callbacks) {
186
+ const ranked = suggestions.slice(0, MAX_SUGGESTIONS).map((suggestion) => ({
187
+ name: suggestion,
188
+ // CodeMirror passes the diagnostic's current position, which may have shifted since the lint ran;
189
+ // the replace uses that live range so a suggestion never overwrites the wrong span after an edit.
190
+ apply: (view, from, to) => {
191
+ view.dispatch({ changes: { from, to, insert: suggestion } });
192
+ },
193
+ }));
194
+ return {
195
+ from: range.from,
196
+ to: range.to,
197
+ severity: 'info',
198
+ source: 'cairn-spellcheck',
199
+ message: `\`${word}\` may be misspelled.`,
200
+ actions: [
201
+ ...ranked,
202
+ {
203
+ name: 'Add to dictionary',
204
+ apply: () => callbacks.onAddWord(word),
205
+ },
206
+ {
207
+ name: 'Ignore',
208
+ apply: () => callbacks.onIgnoreWord(word),
209
+ },
210
+ ],
211
+ };
212
+ }
213
+ /** Build a fresh {@link SeqArbiter}. */
214
+ export function arbitrateChecked() {
215
+ let current = 0;
216
+ return {
217
+ next() {
218
+ current += 1;
219
+ return current;
220
+ },
221
+ accept(seq) {
222
+ if (seq < current)
223
+ return false;
224
+ current = seq;
225
+ return true;
226
+ },
227
+ };
228
+ }
229
+ /**
230
+ * Build the quick-fix popover for one objective-error finding, as a @codemirror/lint Diagnostic whose
231
+ * one `actions` entry applies the finding's deterministic fix. The severity is `info` so the underline
232
+ * shares the spellcheck surface and the locked amber color (an editor reads spelling and these
233
+ * mechanical errors as one "spellcheck" layer). The fix range is recomputed from the live diagnostic
234
+ * position CodeMirror passes, offset by the same delta as the original finding, so an edit elsewhere
235
+ * never makes the fix overwrite the wrong span. Pure: it takes a finding, so the unit test asserts the
236
+ * diagnostic without a browser.
237
+ */
238
+ export function buildObjectiveDiagnostic(error) {
239
+ // The fix range sits inside the flagged range; record its offset from the flagged start so the apply
240
+ // can re-anchor against the live position CodeMirror reports (which may have shifted since the lint).
241
+ const fixOffsetFrom = error.fix.from - error.from;
242
+ const fixOffsetTo = error.fix.to - error.from;
243
+ const insert = error.fix.insert;
244
+ return {
245
+ from: error.from,
246
+ to: error.to,
247
+ severity: 'info',
248
+ source: 'cairn-objective',
249
+ message: error.message,
250
+ actions: [
251
+ {
252
+ name: 'Fix',
253
+ apply: (view, from) => {
254
+ view.dispatch({
255
+ changes: { from: from + fixOffsetFrom, to: from + fixOffsetTo, insert },
256
+ });
257
+ },
258
+ },
259
+ ],
260
+ };
261
+ }
262
+ // ----- The linter() wiring (the CodeMirror side) -----
263
+ //
264
+ // Only this half touches CodeMirror, and it never value-imports an @codemirror/* package at module
265
+ // scope: EditPage imports the component .ts helpers statically, so a static value import here would
266
+ // pull CodeMirror into a consumer's server bundle (the editor-boundary test enforces this). The
267
+ // modules resolve lazily inside the source, the same boundary link-completion.ts keeps. The Worker
268
+ // construction sits behind a small injectable seam so the lint logic can be exercised without a real
269
+ // Worker; production passes the seam that builds the spike's `new Worker(...)`.
270
+ let lintMod = null;
271
+ let langMod = null;
272
+ let viewMod = null;
273
+ let stateMod = null;
274
+ /** Construct the real spellcheck Worker, the spike's delivery shape. Kept behind the seam so the
275
+ * lint source never references `Worker` at module scope and a test can swap it. */
276
+ export function createSpellWorker() {
277
+ return new Worker(new URL('./spellcheck-worker.js', import.meta.url), {
278
+ type: 'module',
279
+ });
280
+ }
281
+ // The wasm and dictionary URLs are resolved module-relative with `import.meta.url`, the same
282
+ // mechanism createSpellWorker uses for the worker. The spike (docs/internal/design/
283
+ // 2026-06-20-editor-copyedit-spike-result.md) proved Vite's `?worker`/`?url` package-subpath imports
284
+ // from CONSUMER app code; the library's own source cannot self-import by package name, so it uses the
285
+ // portable `new URL('./asset', import.meta.url)` form Vite resolves inside dependencies too. Task 16's
286
+ // showcase E2E proves the whole chain through the real consumer build; if resolution ever fails there,
287
+ // only these two URL lines change. The dictionary filename is the dialect-resolved one the main thread
288
+ // passes in; the wasm filename is fixed.
289
+ /** The real wasm asset URL, resolved module-relative the same way the worker is. */
290
+ export function resolveWasmUrl() {
291
+ return new URL('./spellcheck-assets/spellchecker-wasm.wasm', import.meta.url).href;
292
+ }
293
+ /** The real dictionary asset URL for a dictionary filename, resolved module-relative. The caller
294
+ * passes the dialect-resolved filename (default `dictionary-en-us.txt`). */
295
+ export function resolveDictionaryUrl(dictionaryFile) {
296
+ return new URL(`./spellcheck-assets/${dictionaryFile}`, import.meta.url).href;
297
+ }
298
+ /** How far past the visible viewport to lint, so a small scroll does not re-lint from scratch. */
299
+ const VIEWPORT_MARGIN = 1000;
300
+ // The lint underline is LOCKED to --cairn-warning-ink (a muted amber, the closest shipped token to
301
+ // the spec's "neither the directive accent nor error red"; there is no --cairn-info-ink). Spellcheck
302
+ // diagnostics carry severity `info`, so the override targets the `info` underline and tooltip row.
303
+ // --cairn-error-ink red is reserved for tidy deletions, so a spellcheck underline and a tidy deletion
304
+ // are never the same color. The tooltip rides the admin Warm Stone tokens and the focus rules; the
305
+ // lint action buttons are CodeMirror's own focusable buttons, so the theme only restores a visible
306
+ // focus ring (the admin base button reset strips the UA outline). The wavy underline uses a CSS
307
+ // text-decoration so the locked token resolves at render rather than being baked into a static SVG.
308
+ function lockedUnderlineTheme(EditorViewMod) {
309
+ return EditorViewMod.theme({
310
+ // The amber wavy underline, the one spellcheck underline color across the feature.
311
+ '.cm-lintRange-info': {
312
+ backgroundImage: 'none',
313
+ textDecoration: 'underline wavy var(--cairn-warning-ink, oklch(50% 0.13 70))',
314
+ textDecorationSkipInk: 'none',
315
+ textUnderlineOffset: '0.2em',
316
+ },
317
+ // The tooltip surface rides the admin Warm Stone tokens.
318
+ '.cm-tooltip.cm-tooltip-lint': {
319
+ backgroundColor: 'var(--color-base-100, #fff)',
320
+ border: '1px solid var(--color-base-300, oklch(90% 0.01 75))',
321
+ borderRadius: '0.5rem',
322
+ color: 'var(--color-base-content, oklch(28% 0.01 75))',
323
+ },
324
+ '.cm-diagnostic-info': {
325
+ borderLeftColor: 'var(--cairn-warning-ink, oklch(50% 0.13 70))',
326
+ },
327
+ // The action buttons are real focusable buttons; the admin base reset strips the UA outline, so a
328
+ // visible focus ring is restored here to keep them keyboard-discoverable (the a11y focus rule).
329
+ '.cm-diagnosticAction:focus-visible': {
330
+ outline: '2px solid var(--cairn-warning-ink, oklch(50% 0.13 70))',
331
+ outlineOffset: '1px',
332
+ },
333
+ });
334
+ }
335
+ /**
336
+ * The @codemirror/lint linter() source, made markdown-aware by the Lezer tree. It runs over the
337
+ * visible viewport plus a margin (not the whole document), extracts the checkable words via the pure
338
+ * classifier, posts them to the Worker keyed by a monotonic latest-wins seq, and maps the
339
+ * `correct: false` answers back to ranges. Each wrong word becomes a correction popover: the source
340
+ * fetches the ranked suggestions in the same batch, then {@link buildSpellDiagnostic} wires the
341
+ * quick-fix actions (the suggestions, then add-to-dictionary, then ignore). The returned extension
342
+ * bundles the spellcheck linter, a second deterministic objective-error linter over the same prose
343
+ * spans (doubled words, double spaces, repeated punctuation), and the locked amber underline theme,
344
+ * so Task 7's single on/off toggle gates both surfaces by reconfiguring this one extension.
345
+ */
346
+ export async function cairnSpellcheck(options = {}) {
347
+ // Reuse the caller's already-loaded modules when supplied (the editor passes its own view/language
348
+ // copies so the lint extension lands on the same instances), else lazily value-import them. The lazy
349
+ // imports keep CodeMirror off the server bundle (the boundary the editor relies on) and match how
350
+ // link-completion.ts resolves @codemirror/language inside its source.
351
+ lintMod = options.modules?.lint ?? lintMod ?? (await import('@codemirror/lint'));
352
+ langMod = options.modules?.language ?? langMod ?? (await import('@codemirror/language'));
353
+ viewMod = options.modules?.view ?? viewMod ?? (await import('@codemirror/view'));
354
+ stateMod = options.modules?.state ?? stateMod ?? (await import('@codemirror/state'));
355
+ const { linter, forceLinting } = lintMod;
356
+ const { syntaxTree } = langMod;
357
+ const { EditorView } = viewMod;
358
+ const { StateEffect } = stateMod;
359
+ // A re-lint nudge for the no-doc-change case. Add-to-dictionary and ignore change the Worker's merged
360
+ // set but not the document, and @codemirror/lint's forceLinting() is a no-op when the editor is idle
361
+ // (its internal force() only fires when a lint is already scheduled). The linter's needsRefresh hook
362
+ // is the supported way to schedule a run on a state change that is not a doc edit: a callback
363
+ // dispatches this effect, needsRefresh sees it and schedules the lint, and forceLinting then runs it
364
+ // at once so the underlines clear immediately.
365
+ const relintEffect = StateEffect.define();
366
+ const createWorker = options.createWorker ?? createSpellWorker;
367
+ const pendingAdditions = options.pendingAdditions ?? new Set();
368
+ // The committed site dictionary words, seeded into the Worker's personal layer at init.
369
+ const siteWords = options.siteWords ?? [];
370
+ const arbiter = arbitrateChecked();
371
+ // The wasm and dictionary URLs the Worker fetches, resolved module-relative unless the caller
372
+ // overrides them (a test injecting a fake Worker passes canned URLs). The dictionary filename is the
373
+ // dialect-resolved one; the wasm filename is fixed.
374
+ const assetUrls = options.assetUrls ?? {
375
+ wasmUrl: resolveWasmUrl(),
376
+ dictionaryUrl: resolveDictionaryUrl(options.dictionaryFile ?? 'dictionary-en-us.txt'),
377
+ };
378
+ let worker = null;
379
+ // The Worker answers `ready` once its dictionary has streamed into wasm; until then a lint run paints
380
+ // nothing rather than throwing. A test can set assumeReady to skip the wait when its fake Worker does
381
+ // not answer `ready`.
382
+ let ready = options.assumeReady ?? false;
383
+ // The view from the latest lint run, captured so a management action can re-lint through it. The
384
+ // action callbacks fire long after the source promise resolved, so the source cannot close over a
385
+ // run-local view; it stores the last one here.
386
+ let lastView = null;
387
+ // The in-flight check requests keyed by their seq, each resolved by the matching `checked` answer.
388
+ const pending = new Map();
389
+ // A monotonic seq for suggest round-trips, separate from the check seq so the two answer streams
390
+ // never collide on a shared counter.
391
+ let suggestSeq = 0;
392
+ // Re-run the lint at once after a state change that is not a doc edit (the Worker became ready, or a
393
+ // word was added or ignored). The dispatched effect makes the linter's needsRefresh schedule a run,
394
+ // so the following forceLinting is no longer a no-op (its internal force() only fires on a scheduled
395
+ // lint), and the underlines repaint immediately.
396
+ function relint() {
397
+ if (!lastView)
398
+ return;
399
+ lastView.dispatch({ effects: relintEffect.of(null) });
400
+ forceLinting(lastView);
401
+ }
402
+ function ensureWorker() {
403
+ if (worker)
404
+ return worker;
405
+ worker = createWorker();
406
+ worker.addEventListener('message', (event) => {
407
+ const data = event.data;
408
+ // The init ack: lookups are live. Re-lint so the viewport paints the underlines the not-ready
409
+ // runs withheld; the latest-wins seq keeps the re-lint from racing an in-flight run.
410
+ if (data.type === 'ready') {
411
+ ready = true;
412
+ relint();
413
+ return;
414
+ }
415
+ // An init or lookup failure: log it and leave the editor usable (no underlines is the graceful
416
+ // degrade, never a thrown error). A `check` that arrives after this still resolves to [] below.
417
+ if (data.type === 'error') {
418
+ console.warn('cairn spellcheck worker error:', data.detail ?? 'unknown');
419
+ return;
420
+ }
421
+ if (data.type !== 'checked' || typeof data.seq !== 'number')
422
+ return;
423
+ const request = pending.get(data.seq);
424
+ if (!request)
425
+ return;
426
+ pending.delete(data.seq);
427
+ // Drop a stale answer that landed after a newer run; only the latest seq paints underlines.
428
+ if (!arbiter.accept(data.seq)) {
429
+ request.resolve([]);
430
+ return;
431
+ }
432
+ const wrongIds = new Set((data.results ?? []).filter((r) => !r.correct).map((r) => r.id));
433
+ const wrong = request.words.filter((_, id) => wrongIds.has(id));
434
+ // Fetch the ranked suggestions for every wrong word, then build the popovers from the answers.
435
+ void buildDiagnostics(wrong).then(request.resolve);
436
+ });
437
+ // The handshake: post init so the Worker streams its dictionary, then wait for `ready` before any
438
+ // check/suggest. The Worker answers `error` on a check that lands before ready, so the not-ready
439
+ // early return in the lint source keeps a check from ever racing the init.
440
+ worker.postMessage({ type: 'init', ...assetUrls });
441
+ // Seed the personal layer from the committed site dictionary. The Worker merges addWord into its
442
+ // in-memory personal set regardless of ready state (it touches no engine), so a site word answers
443
+ // correct from the first lint without waiting for the dialect stream.
444
+ if (siteWords.length > 0)
445
+ worker.postMessage({ type: 'addWord', words: siteWords });
446
+ return worker;
447
+ }
448
+ /** Fetch a single word's ranked suggestions over the Worker, a one-shot listener removed on the
449
+ * answer. The suggest path is independent of the check seq, so a slow suggest never blocks a fresh
450
+ * check; an empty list (the engine returned nothing) still yields a popover with the two
451
+ * management actions. */
452
+ function fetchSuggestions(w, word) {
453
+ suggestSeq += 1;
454
+ const seq = suggestSeq;
455
+ return new Promise((resolve) => {
456
+ const listener = (event) => {
457
+ const data = event.data;
458
+ if (data.type !== 'suggested' || data.seq !== seq)
459
+ return;
460
+ w.removeEventListener('message', listener);
461
+ resolve(data.suggestions ?? []);
462
+ };
463
+ w.addEventListener('message', listener);
464
+ w.postMessage({ type: 'suggest', seq, word });
465
+ });
466
+ }
467
+ /** Turn the wrong words into correction popovers, each carrying its ranked suggestions and the two
468
+ * management actions. */
469
+ async function buildDiagnostics(wrong) {
470
+ const w = ensureWorker();
471
+ const callbacks = {
472
+ onAddWord(word) {
473
+ // Post addWord so the Worker's merged set now answers correct, record the pending addition for
474
+ // Task 9 to commit, and re-lint so every instance of the word clears at once.
475
+ w.postMessage({ type: 'addWord', word });
476
+ pendingAdditions.add(word.toLowerCase());
477
+ relint();
478
+ },
479
+ onIgnoreWord(word) {
480
+ // Session-only ignore, never persisted; re-lint so the underline clears everywhere.
481
+ w.postMessage({ type: 'ignoreWord', word });
482
+ relint();
483
+ },
484
+ };
485
+ return Promise.all(wrong.map(async (word) => {
486
+ const suggestions = await fetchSuggestions(w, word.text);
487
+ return buildSpellDiagnostic(word.text, { from: word.from, to: word.to }, suggestions, callbacks);
488
+ }));
489
+ }
490
+ // The prose keep-spans for one view, scoped to the visible viewport plus a margin. The spellcheck
491
+ // source and the objective source both run over exactly these spans, so a doubled word inside a
492
+ // code fence is never flagged and the two surfaces can never drift on what counts as prose.
493
+ function visibleProseSpans(view) {
494
+ const text = view.state.doc.toString();
495
+ const tree = syntaxTree(view.state);
496
+ const docLength = text.length;
497
+ const spans = [];
498
+ for (const vr of view.visibleRanges) {
499
+ const from = Math.max(0, vr.from - VIEWPORT_MARGIN);
500
+ const to = Math.min(docLength, vr.to + VIEWPORT_MARGIN);
501
+ spans.push(...classifyProse(text, tree, from, to));
502
+ }
503
+ return { text, spans };
504
+ }
505
+ const source = linter(async (view) => {
506
+ lastView = view;
507
+ // Create the Worker (and post init) on the first run even when not yet ready, so the dictionary
508
+ // starts streaming. Until `ready` lands this run paints nothing; the `ready` handler re-lints.
509
+ ensureWorker();
510
+ if (!ready)
511
+ return [];
512
+ const { text, spans } = visibleProseSpans(view);
513
+ const words = [];
514
+ for (const span of spans) {
515
+ words.push(...extractWords(text, span.from, span.to));
516
+ }
517
+ if (words.length === 0)
518
+ return [];
519
+ const seq = arbiter.next();
520
+ const checkWords = words.map((word, id) => ({ id, word: word.text }));
521
+ return new Promise((resolve) => {
522
+ pending.set(seq, { words, resolve });
523
+ ensureWorker().postMessage({ type: 'check', seq, words: checkWords });
524
+ });
525
+ }, {
526
+ // Schedule a fresh lint when relint() dispatches its effect. The hook covers the state changes that
527
+ // are not doc edits (the Worker became ready, a word was added or ignored); without it the linter
528
+ // only re-runs on a doc change, so an add-to-dictionary would never clear the standing underlines.
529
+ needsRefresh: (update) => update.transactions.some((tr) => tr.effects.some((e) => e.is(relintEffect))),
530
+ });
531
+ // The objective-error source: a second linter() over the SAME viewport-scoped prose spans the
532
+ // spellcheck source uses, so a doubled word inside a code fence is never flagged. It is synchronous
533
+ // and deterministic (no Worker, no dictionary), and its diagnostics carry `info` so they share the
534
+ // locked amber underline. It ships in the same returned extension as the spellcheck source, so the
535
+ // Task 7 toggle reconfigures one compartment to gate both surfaces at once.
536
+ const objectiveSource = linter((view) => {
537
+ const { text, spans } = visibleProseSpans(view);
538
+ return objectiveErrors(text, spans).map(buildObjectiveDiagnostic);
539
+ });
540
+ return [source, objectiveSource, lockedUnderlineTheme(EditorView)];
541
+ }
@@ -0,0 +1,67 @@
1
+ import type { Change } from './tidy-diff.js';
2
+ import type { TidyConventions } from '../nav/site-config.js';
3
+ /** A change's locally-inferred category. The first four are objective (safe to sweep); `normalization`
4
+ * and `grammar` are judgment (held undecided, never swept by Accept-fixes). `normalization` carries
5
+ * the convention key that authorized it, so the surface can name the setting and label the badge. */
6
+ export type TidyCategory = {
7
+ kind: 'spelling';
8
+ } | {
9
+ kind: 'typo';
10
+ } | {
11
+ kind: 'doubled';
12
+ } | {
13
+ kind: 'whitespace';
14
+ } | {
15
+ kind: 'normalization';
16
+ convention: NormalizationKey;
17
+ } | {
18
+ kind: 'grammar';
19
+ };
20
+ /** True for the objective categories: the safe, pre-kept, Accept-fixes-swept rank. A judgment
21
+ * category (`normalization` or `grammar`) returns false. The bulk action and the surface both read
22
+ * this, so the safety rank is one source of truth. */
23
+ export declare function isObjective(category: TidyCategory): boolean;
24
+ /** The enabled-convention keys a normalization can be attributed to. Each maps to one config field on
25
+ * TidyConventions and to a because-line. A change is only ever labelled a normalization when it matches
26
+ * one of these AND the config has the matching variant enabled; otherwise it is never a normalization. */
27
+ export type NormalizationKey = 'oxfordComma' | 'numberStyle' | 'measurements' | 'percent' | 'emDash' | 'enDashRanges' | 'ellipsis' | 'timeFormat' | 'smartQuotes';
28
+ /**
29
+ * Categorize one change against the captured original and the resolved conventions. The rules are
30
+ * deterministic and ordered by safety, objective first:
31
+ * 1. a pure whitespace change (both sides whitespace, or a whitespace insert/delete) is whitespace;
32
+ * 2. a removed repeated word (the original run is two of the same word collapsing to one) is doubled;
33
+ * 3. a single-token punctuation-only change is a typo;
34
+ * 4. a single word replaced by another single word is spelling;
35
+ * 5. a change matching an ENABLED config convention's signature is that convention's normalization;
36
+ * 6. anything else (a multi-token reword) is grammar.
37
+ * A change that looks like a normalization but whose convention is not enabled falls through to typo,
38
+ * spelling, or grammar by its shape, never to a normalization it cannot name. So the surface never
39
+ * offers a normalization that cannot cite an enabled setting.
40
+ */
41
+ export declare function categorize(change: Change, original: string, conventions: TidyConventions): TidyCategory;
42
+ /** The because-line data for a hunk: the convention's display name and the variant phrasing, both pure
43
+ * strings derived from the config. The surface renders "Your <label> setting is <variant>, ..." from
44
+ * these. Only a normalization carries a because-line; an objective or grammar hunk returns null (a
45
+ * grammar hunk's rationale, when shown, is the local subject-verb note the surface composes, not a
46
+ * config citation). */
47
+ export interface BecauseLine {
48
+ /** The convention's display label, e.g. "Oxford-comma". */
49
+ label: string;
50
+ /** The setting's variant phrasing, e.g. "always" or "5 PM". */
51
+ variant: string;
52
+ /** The trailing clause describing what tidy did, e.g. the serial-comma effect. */
53
+ effect: string;
54
+ }
55
+ /**
56
+ * Build the because-line for a normalization category. Its ONLY data source is the config-declared
57
+ * setting that authorized the hunk: the convention key indexes the enabled variant on the conventions,
58
+ * and the line names that setting and variant. It NEVER counts the author's own usage. Counting the
59
+ * author's habit to justify a change is the harmonize-to-author judgment cairn must never make, so no
60
+ * code path here reads the buffer or any usage statistic; the conventions are the sole input. Returns
61
+ * null when the convention is somehow not enabled (defensive: categorize never produces such a hunk).
62
+ */
63
+ export declare function buildBecause(key: NormalizationKey, conventions: TidyConventions): BecauseLine | null;
64
+ /** The human badge label for a category, the word shown in the hunk's category pill. A normalization's
65
+ * label is the convention's display name (its comma style, its time format), never "consistency" and
66
+ * never a count. */
67
+ export declare function categoryLabel(category: TidyCategory): string;