@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.
- package/CHANGELOG.md +47 -0
- package/dist/components/CairnAdmin.svelte +3 -0
- package/dist/components/CairnTidySettings.svelte +553 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- package/dist/components/EditPage.svelte +371 -2
- package/dist/components/MarkdownEditor.svelte +168 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- package/dist/components/TidyReview.svelte +463 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/cairn-admin.css +764 -0
- package/dist/components/editor-tidy.d.ts +31 -0
- package/dist/components/editor-tidy.js +199 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +16 -0
- package/dist/components/markdown-directives.js +34 -0
- package/dist/components/objective-errors.d.ts +30 -0
- package/dist/components/objective-errors.js +113 -0
- package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/dist/components/spellcheck-worker.d.ts +80 -0
- package/dist/components/spellcheck-worker.js +161 -0
- package/dist/components/spellcheck.d.ts +146 -0
- package/dist/components/spellcheck.js +541 -0
- package/dist/components/tidy-categorize.d.ts +67 -0
- package/dist/components/tidy-categorize.js +392 -0
- package/dist/components/tidy-diff.d.ts +60 -0
- package/dist/components/tidy-diff.js +147 -0
- package/dist/components/tidy-validate.d.ts +37 -0
- package/dist/components/tidy-validate.js +174 -0
- package/dist/content/compose.d.ts +1 -1
- package/dist/content/compose.js +11 -0
- package/dist/content/site-dictionary.d.ts +31 -0
- package/dist/content/site-dictionary.js +82 -0
- package/dist/content/types.d.ts +25 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +55 -6
- package/dist/doctor/index.js +2 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +98 -0
- package/dist/nav/site-config.js +132 -0
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +6 -2
- package/dist/sveltekit/cairn-admin.d.ts +13 -1
- package/dist/sveltekit/cairn-admin.js +22 -3
- package/dist/sveltekit/content-routes.d.ts +135 -1
- package/dist/sveltekit/content-routes.js +351 -3
- package/dist/sveltekit/tidy-prompt.d.ts +11 -0
- package/dist/sveltekit/tidy-prompt.js +118 -0
- package/package.json +10 -1
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +553 -0
- package/src/lib/components/EditPage.svelte +371 -2
- package/src/lib/components/MarkdownEditor.svelte +168 -1
- package/src/lib/components/TidyReview.svelte +463 -0
- package/src/lib/components/cairn-admin.css +25 -0
- package/src/lib/components/editor-tidy.ts +241 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +35 -0
- package/src/lib/components/objective-errors.ts +155 -0
- package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/src/lib/components/spellcheck-worker.ts +279 -0
- package/src/lib/components/spellcheck.ts +679 -0
- package/src/lib/components/tidy-categorize.ts +460 -0
- package/src/lib/components/tidy-diff.ts +196 -0
- package/src/lib/components/tidy-validate.ts +202 -0
- package/src/lib/content/compose.ts +11 -1
- package/src/lib/content/site-dictionary.ts +84 -0
- package/src/lib/content/types.ts +25 -0
- package/src/lib/doctor/checks-local.ts +59 -5
- package/src/lib/doctor/index.ts +2 -0
- package/src/lib/log/events.ts +7 -1
- package/src/lib/nav/site-config.ts +197 -0
- package/src/lib/sveltekit/admin-dispatch.ts +7 -3
- package/src/lib/sveltekit/cairn-admin.ts +32 -4
- package/src/lib/sveltekit/content-routes.ts +504 -4
- 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;
|