@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.
- package/CHANGELOG.md +60 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +12 -41
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +486 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +786 -918
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +688 -789
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +355 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +768 -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 +148 -0
- package/dist/components/spellcheck.js +553 -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/delivery/CairnHead.svelte +8 -11
- 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 +11 -2
- 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 +693 -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,241 @@
|
|
|
1
|
+
// The tidy apply primitives (spec 2.5, Task 14). The author's original stays in the buffer until they
|
|
2
|
+
// accept: tidy never overwrites the document and asks for an undo. A StateField holds the change set
|
|
3
|
+
// and each change's disposition, and a decoration plugin shows the proposed edits IN PLACE over the
|
|
4
|
+
// untouched source. An insertion renders as a mark/widget showing the new text in --color-positive-ink
|
|
5
|
+
// (the inserted text is decoration CONTENT, never buffer text); a deletion renders as a strike-through
|
|
6
|
+
// over the original run in --cairn-error-ink (reserved for tidy deletions). The author sees exactly
|
|
7
|
+
// what tidy wants to remove, which is the safety contract.
|
|
8
|
+
//
|
|
9
|
+
// Client-only like editor-placeholder and editor-media: MarkdownEditor reaches this through a dynamic
|
|
10
|
+
// import, so the static @codemirror imports never enter a server bundle (the editor-boundary test's
|
|
11
|
+
// DYNAMIC_ONLY list names this file). The architecture mirrors editor-placeholder: a StateField over
|
|
12
|
+
// StateEffects, with positions mapped across doc changes so an accepted change shifts the others.
|
|
13
|
+
//
|
|
14
|
+
// Accept lands in ONE batched transaction. accept-fixes collects every named change into a single
|
|
15
|
+
// view.dispatch({ changes }), so the whole edit is one undoable step (the session-level "Undo tidy").
|
|
16
|
+
// accept-one dispatches that change alone; reject-one and reject-all change no text, leaving the
|
|
17
|
+
// original byte-identical.
|
|
18
|
+
|
|
19
|
+
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
|
|
20
|
+
import { StateEffect, StateField, RangeSet, type Extension, type Range } from '@codemirror/state';
|
|
21
|
+
import type { Change } from './tidy-diff.js';
|
|
22
|
+
|
|
23
|
+
/** A change plus its live disposition and current mapped span. `pending` is undecided-in-the-buffer:
|
|
24
|
+
* it still carries decorations. `accepted` has been written (its edit dispatched), so it carries no
|
|
25
|
+
* decoration. `rejected` was dropped, so it also carries no decoration and never writes. The `from`
|
|
26
|
+
* and `to` are the change's current offsets, mapped across every accepted edit since tidy opened. */
|
|
27
|
+
interface TidyEntry {
|
|
28
|
+
index: number;
|
|
29
|
+
from: number;
|
|
30
|
+
to: number;
|
|
31
|
+
replacement: string;
|
|
32
|
+
status: 'pending' | 'accepted' | 'rejected';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The tidy state: the entry per change keyed by its stable index. Empty when tidy is not open. */
|
|
36
|
+
interface TidyState {
|
|
37
|
+
entries: Map<number, TidyEntry>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The effects that drive the field. enter seeds the change set (tidy opened); accept marks one or many
|
|
41
|
+
// changes accepted (their edits ride the SAME transaction); reject marks changes rejected; clear empties
|
|
42
|
+
// the set (tidy closed or reject-all). Accept and reject carry the index list so one effect covers the
|
|
43
|
+
// bulk action.
|
|
44
|
+
const enterTidy = StateEffect.define<Change[]>();
|
|
45
|
+
const markAccepted = StateEffect.define<number[]>();
|
|
46
|
+
const markRejected = StateEffect.define<number[]>();
|
|
47
|
+
const clearTidy = StateEffect.define<void>();
|
|
48
|
+
|
|
49
|
+
// The deletion widget: a zero-width marker is not enough, since the deletion's original text stays in
|
|
50
|
+
// the buffer; the strike-through mark below carries the visible deletion. This widget renders the small
|
|
51
|
+
// non-color marker that pairs with the color, so the deletion reads without relying on hue (WCAG 1.4.1).
|
|
52
|
+
// It sits at the start of the deleted run.
|
|
53
|
+
class DeletionMarkerWidget extends WidgetType {
|
|
54
|
+
eq(other: WidgetType): boolean {
|
|
55
|
+
return other instanceof DeletionMarkerWidget;
|
|
56
|
+
}
|
|
57
|
+
toDOM(): HTMLElement {
|
|
58
|
+
const span = document.createElement('span');
|
|
59
|
+
span.className = 'cm-cairn-tidy-del-marker';
|
|
60
|
+
span.setAttribute('aria-hidden', 'true');
|
|
61
|
+
span.textContent = '';
|
|
62
|
+
return span;
|
|
63
|
+
}
|
|
64
|
+
ignoreEvent(): boolean {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The insertion widget: the proposed new text shown as decoration CONTENT after the change's point in
|
|
70
|
+
// the buffer, so the source is untouched. It carries the addition color and a leading marker glyph so
|
|
71
|
+
// the insertion reads without hue alone. A pure insertion (from === to) and a replacement both render
|
|
72
|
+
// their insertion this way; the replacement also strikes the original run through the deletion mark.
|
|
73
|
+
class InsertionWidget extends WidgetType {
|
|
74
|
+
constructor(readonly text: string) {
|
|
75
|
+
super();
|
|
76
|
+
}
|
|
77
|
+
eq(other: WidgetType): boolean {
|
|
78
|
+
return other instanceof InsertionWidget && other.text === this.text;
|
|
79
|
+
}
|
|
80
|
+
toDOM(): HTMLElement {
|
|
81
|
+
const span = document.createElement('span');
|
|
82
|
+
span.className = 'cm-cairn-tidy-ins';
|
|
83
|
+
span.setAttribute('aria-hidden', 'true');
|
|
84
|
+
span.textContent = this.text;
|
|
85
|
+
return span;
|
|
86
|
+
}
|
|
87
|
+
ignoreEvent(): boolean {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build the decoration set for the pending entries. A deletion (a non-empty span) draws a strike-through
|
|
93
|
+
// mark over its run plus a leading marker widget. An insertion (replacement text) draws an insertion
|
|
94
|
+
// widget at the change's end point. A replacement does both: the original strikes through and the new
|
|
95
|
+
// text shows beside it. RangeSet requires ascending, side-ordered ranges, so the ranges are sorted.
|
|
96
|
+
function buildDecorations(state: TidyState): DecorationSet {
|
|
97
|
+
const ranges: Range<Decoration>[] = [];
|
|
98
|
+
for (const e of state.entries.values()) {
|
|
99
|
+
if (e.status !== 'pending') continue;
|
|
100
|
+
if (e.to > e.from) {
|
|
101
|
+
// A deletion run: strike it through, and mark its start.
|
|
102
|
+
ranges.push(Decoration.widget({ widget: new DeletionMarkerWidget(), side: -1 }).range(e.from));
|
|
103
|
+
ranges.push(Decoration.mark({ class: 'cm-cairn-tidy-del' }).range(e.from, e.to));
|
|
104
|
+
}
|
|
105
|
+
if (e.replacement.length > 0) {
|
|
106
|
+
// The proposed insertion, shown as decoration content after the (possibly struck) original.
|
|
107
|
+
ranges.push(Decoration.widget({ widget: new InsertionWidget(e.replacement), side: 1 }).range(e.to));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Sort by from, then by startSide so a -1 marker precedes the run it leads and a +1 insertion follows.
|
|
111
|
+
ranges.sort((a, b) => a.from - b.from || a.value.startSide - b.value.startSide);
|
|
112
|
+
return RangeSet.of(ranges, true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const tidyField = StateField.define<TidyState>({
|
|
116
|
+
create() {
|
|
117
|
+
return { entries: new Map() };
|
|
118
|
+
},
|
|
119
|
+
update(value, tr) {
|
|
120
|
+
let entries = value.entries;
|
|
121
|
+
let changed = false;
|
|
122
|
+
|
|
123
|
+
// Map every entry's span across a doc change (an accepted edit) so the remaining pending changes
|
|
124
|
+
// shift with the text rather than stranding on stale offsets.
|
|
125
|
+
if (tr.docChanged && entries.size > 0) {
|
|
126
|
+
const next = new Map<number, TidyEntry>();
|
|
127
|
+
for (const [i, e] of entries) {
|
|
128
|
+
next.set(i, {
|
|
129
|
+
...e,
|
|
130
|
+
from: tr.changes.mapPos(e.from, 1),
|
|
131
|
+
to: tr.changes.mapPos(e.to, -1),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
entries = next;
|
|
135
|
+
changed = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const effect of tr.effects) {
|
|
139
|
+
if (effect.is(enterTidy)) {
|
|
140
|
+
const next = new Map<number, TidyEntry>();
|
|
141
|
+
for (const c of effect.value) {
|
|
142
|
+
next.set(c.index, { index: c.index, from: c.from, to: c.to, replacement: c.replacement, status: 'pending' });
|
|
143
|
+
}
|
|
144
|
+
entries = next;
|
|
145
|
+
changed = true;
|
|
146
|
+
} else if (effect.is(clearTidy)) {
|
|
147
|
+
entries = new Map();
|
|
148
|
+
changed = true;
|
|
149
|
+
} else if (effect.is(markAccepted)) {
|
|
150
|
+
const next = new Map(entries);
|
|
151
|
+
for (const i of effect.value) {
|
|
152
|
+
const e = next.get(i);
|
|
153
|
+
if (e) next.set(i, { ...e, status: 'accepted' });
|
|
154
|
+
}
|
|
155
|
+
entries = next;
|
|
156
|
+
changed = true;
|
|
157
|
+
} else if (effect.is(markRejected)) {
|
|
158
|
+
const next = new Map(entries);
|
|
159
|
+
for (const i of effect.value) {
|
|
160
|
+
const e = next.get(i);
|
|
161
|
+
if (e) next.set(i, { ...e, status: 'rejected' });
|
|
162
|
+
}
|
|
163
|
+
entries = next;
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!changed) return value;
|
|
169
|
+
return { entries };
|
|
170
|
+
},
|
|
171
|
+
provide: (f) => EditorView.decorations.from(f, (v) => buildDecorations(v)),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/** The api the host drives over one editor view (spec 2.5). Mirrors imagePlaceholderApi: the host
|
|
175
|
+
* registers it through registerTidy, and the review surface calls it as the author works the list.
|
|
176
|
+
* Every accept lands as a CodeMirror transaction; reject and reject-all write no text. */
|
|
177
|
+
export interface TidyApi {
|
|
178
|
+
/** Open tidy with the validated change set: seed the field, show the decorations. The buffer is
|
|
179
|
+
* untouched; the originals stay until an accept writes. */
|
|
180
|
+
enter(changes: Change[]): void;
|
|
181
|
+
/** Accept one change: dispatch its replacement over its current span in one transaction and mark it
|
|
182
|
+
* accepted. The other pending changes map across the edit. */
|
|
183
|
+
acceptOne(index: number): void;
|
|
184
|
+
/** Reject one change: mark it rejected so its decorations clear, leaving the original untouched. */
|
|
185
|
+
rejectOne(index: number): void;
|
|
186
|
+
/** Accept many changes (the bulk action) in ONE transaction: the whole edit is one undoable step.
|
|
187
|
+
* The caller passes ONLY the indexes it has decided to keep; this never sweeps an index the caller
|
|
188
|
+
* did not name, which is how Accept-fixes confines itself to objective hunks. */
|
|
189
|
+
acceptMany(indexes: number[]): void;
|
|
190
|
+
/** Reject every remaining pending change, leaving the document byte-identical. */
|
|
191
|
+
rejectAll(): void;
|
|
192
|
+
/** Close tidy: clear the field and the decorations. The buffer holds whatever the accepts wrote. */
|
|
193
|
+
exit(): void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The tidy extension: the StateField holding the change set and its decorations. The host adds it to
|
|
197
|
+
* the initial editor state (in its own compartment beside media and folding), then builds the driving
|
|
198
|
+
* api with tidyApi once the view exists. */
|
|
199
|
+
export function cairnTidy(): Extension {
|
|
200
|
+
return tidyField;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Build the api that drives tidy against one editor view. The host registers it through registerTidy;
|
|
204
|
+
* the review surface calls enter, the per-hunk and bulk accept/reject, and exit. */
|
|
205
|
+
export function tidyApi(view: EditorView): TidyApi {
|
|
206
|
+
// Dispatch the named changes' replacements over their CURRENT mapped spans in one transaction, mark
|
|
207
|
+
// them accepted, and let the field map any remaining pending entries. The changes are read from the
|
|
208
|
+
// field so they carry the live offsets, and they are sorted ascending (CodeMirror requires it).
|
|
209
|
+
const applyIndexes = (indexes: number[]) => {
|
|
210
|
+
const entries = view.state.field(tidyField).entries;
|
|
211
|
+
const specs = indexes
|
|
212
|
+
.map((i) => entries.get(i))
|
|
213
|
+
.filter((e): e is TidyEntry => !!e && e.status === 'pending')
|
|
214
|
+
.sort((a, b) => a.from - b.from)
|
|
215
|
+
.map((e) => ({ from: e.from, to: e.to, insert: e.replacement }));
|
|
216
|
+
if (specs.length === 0) return;
|
|
217
|
+
view.dispatch({ changes: specs, effects: markAccepted.of(indexes) });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
enter(changes) {
|
|
222
|
+
view.dispatch({ effects: enterTidy.of(changes) });
|
|
223
|
+
},
|
|
224
|
+
acceptOne(index) {
|
|
225
|
+
applyIndexes([index]);
|
|
226
|
+
},
|
|
227
|
+
rejectOne(index) {
|
|
228
|
+
view.dispatch({ effects: markRejected.of([index]) });
|
|
229
|
+
},
|
|
230
|
+
acceptMany(indexes) {
|
|
231
|
+
applyIndexes(indexes);
|
|
232
|
+
},
|
|
233
|
+
rejectAll() {
|
|
234
|
+
const indexes = [...view.state.field(tidyField).entries.keys()];
|
|
235
|
+
view.dispatch({ effects: markRejected.of(indexes) });
|
|
236
|
+
},
|
|
237
|
+
exit() {
|
|
238
|
+
view.dispatch({ effects: clearTidy.of() });
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -7,6 +7,7 @@ export { default as ConfirmPage } from './ConfirmPage.svelte';
|
|
|
7
7
|
export { default as CsrfField } from './CsrfField.svelte';
|
|
8
8
|
export { default as ConceptList } from './ConceptList.svelte';
|
|
9
9
|
export { default as CairnMediaLibrary } from './CairnMediaLibrary.svelte';
|
|
10
|
+
export { default as CairnTidySettings } from './CairnTidySettings.svelte';
|
|
10
11
|
export { default as EditPage } from './EditPage.svelte';
|
|
11
12
|
export { default as ManageEditors } from './ManageEditors.svelte';
|
|
12
13
|
export { default as MarkdownEditor } from './MarkdownEditor.svelte';
|
|
@@ -262,6 +262,41 @@ export function markerPrefix(line: string): string | null {
|
|
|
262
262
|
return m ? m[0] : null;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// A YAML-frontmatter fence: exactly three dashes on their own line, no leading whitespace. The
|
|
266
|
+
// base markdown grammar does not parse frontmatter, so this line-based check is the single source
|
|
267
|
+
// of the region. The strict shape (line start, three dashes, line end) is what separates a leading
|
|
268
|
+
// frontmatter fence from a body `---` thematic break, which carries surrounding prose or blank
|
|
269
|
+
// lines and so never sits on line 0.
|
|
270
|
+
const FRONTMATTER_FENCE = /^---$/;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The frontmatter block at the very top of the document, as absolute character offsets, or null
|
|
274
|
+
* when there is none. The block must open with an exact `---` on the first line and close with a
|
|
275
|
+
* later exact `---` line; a body `---` thematic break (which has prose or blank lines above it) is
|
|
276
|
+
* never frontmatter, a fence with leading whitespace or blank lines above it does not count, and
|
|
277
|
+
* an unterminated opening fence returns null.
|
|
278
|
+
*
|
|
279
|
+
* The span covers the whole block, both fences included: `from` is the start of the opening `---`
|
|
280
|
+
* (offset 0) and `to` is the end of the closing `---` line (no trailing newline). So
|
|
281
|
+
* `text.slice(from, to)` is the entire frontmatter, which is what the spellcheck skip and the tidy
|
|
282
|
+
* byte-for-byte validator both compare against. This is the single source of the frontmatter region.
|
|
283
|
+
*/
|
|
284
|
+
export function frontmatterSpan(text: string): { from: number; to: number } | null {
|
|
285
|
+
const lines = text.split('\n');
|
|
286
|
+
if (lines.length < 2 || !FRONTMATTER_FENCE.test(lines[0]!)) return null;
|
|
287
|
+
// Walk the offset forward line by line; the opening fence sits at offset 0, and each line costs
|
|
288
|
+
// its length plus the one newline that joined it. The closing fence's end is its start plus its
|
|
289
|
+
// own length, which is 3 for an exact `---`.
|
|
290
|
+
let offset = lines[0]!.length + 1;
|
|
291
|
+
for (let i = 1; i < lines.length; i++) {
|
|
292
|
+
if (FRONTMATTER_FENCE.test(lines[i]!)) {
|
|
293
|
+
return { from: 0, to: offset + lines[i]!.length };
|
|
294
|
+
}
|
|
295
|
+
offset += lines[i]!.length + 1;
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
266
301
|
export function findInlineDirectives(text: string): { from: number; to: number }[] {
|
|
267
302
|
const out: { from: number; to: number }[] = [];
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// cairn-cms: the objective-error layer (spec 1.7). Three deterministic mechanical checks over the
|
|
2
|
+
// prose spans the spellcheck classifier already kept: doubled words, double spaces inside a line,
|
|
3
|
+
// and stray repeated punctuation. No Worker, no dictionary, no `retext` pipeline (that stays out of
|
|
4
|
+
// the client and remains the right tool for a future CI-side prose check). There is NO style or
|
|
5
|
+
// opinion linter here by decision 2; the `retext` passive/simplify/equality/readability plugins are
|
|
6
|
+
// never enabled. This module is pure and CodeMirror-free: text plus prose ranges in, findings (each
|
|
7
|
+
// a flagged range and a single-edit fix) out, so it unit-tests in node. The thin lint-source wiring
|
|
8
|
+
// lives in spellcheck.ts, where the findings join the spellcheck diagnostics on the same locked
|
|
9
|
+
// amber underline.
|
|
10
|
+
import type { Range } from './spellcheck.js';
|
|
11
|
+
|
|
12
|
+
/** The three objective-error kinds, each its own check. */
|
|
13
|
+
export type ObjectiveErrorKind = 'doubled-word' | 'double-space' | 'repeated-punct';
|
|
14
|
+
|
|
15
|
+
/** A single deterministic edit that resolves one finding: replace [from, to) with `insert`. The lint
|
|
16
|
+
* source turns this into the diagnostic's quick-fix action. */
|
|
17
|
+
export interface ObjectiveFix {
|
|
18
|
+
from: number;
|
|
19
|
+
to: number;
|
|
20
|
+
insert: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One objective-error finding: the flagged range a reader sees underlined, the error kind, a plain
|
|
24
|
+
* message, and the one-edit fix. */
|
|
25
|
+
export interface ObjectiveError {
|
|
26
|
+
kind: ObjectiveErrorKind;
|
|
27
|
+
/** The flagged range (absolute document offsets), the span the underline covers. */
|
|
28
|
+
from: number;
|
|
29
|
+
to: number;
|
|
30
|
+
/** A plain message naming the error, so the underline is never the only signal. */
|
|
31
|
+
message: string;
|
|
32
|
+
/** The deterministic fix. */
|
|
33
|
+
fix: ObjectiveFix;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A word for the doubled-word check, matched the same way the spellcheck extractor matches words:
|
|
37
|
+
// Unicode letters and digits, with an intra-word apostrophe (straight or curly) or hyphen kept so
|
|
38
|
+
// "it's" and "well-known" stay whole. Kept consistent with extractWords so the two surfaces agree on
|
|
39
|
+
// what a word is.
|
|
40
|
+
const WORD = /[\p{L}\p{N}]+(?:[-'’][\p{L}\p{N}]+)*/u;
|
|
41
|
+
|
|
42
|
+
// A doubled word: a word, then whitespace (a space run or a line break), then a second word. The two
|
|
43
|
+
// words are compared case-insensitively in code rather than with a backreference so the comparison
|
|
44
|
+
// stays explicit. The WORD class is greedy to the boundary, so each side is a whole word and a repeat
|
|
45
|
+
// is never matched mid-word ("the theater" is not a doubled "the"). The match runs only inside one
|
|
46
|
+
// prose span, so two equal words separated by a skipped region never read as a doubled pair.
|
|
47
|
+
const DOUBLED_WORD = new RegExp(`(${WORD.source})(\\s+)(${WORD.source})`, 'gu');
|
|
48
|
+
|
|
49
|
+
// Two or more spaces NOT at the start of a line (leading indentation is markdown-significant and is
|
|
50
|
+
// left alone). The check is same-line only: \n is not part of the run, so a line break is never
|
|
51
|
+
// collapsed. The run must follow a non-whitespace character on the line, so a space run after leading
|
|
52
|
+
// indentation (a newline or a tab) is never flagged. A run is flagged from its second space, the
|
|
53
|
+
// surplus the fix removes.
|
|
54
|
+
const DOUBLE_SPACE = /[^\s] ( +)/g;
|
|
55
|
+
|
|
56
|
+
// Stray repeated punctuation: two or more of `!`, `?`, or `,` in a run. The period is deliberately
|
|
57
|
+
// excluded so an ellipsis ("...") is left alone, the most judgment-laden case. The threshold is two:
|
|
58
|
+
// a single mark is correct, two or more of these three is plainly a typo. A mixed run ("?!") is not
|
|
59
|
+
// flagged because it is a legitimate construction; only a run of one identical mark counts.
|
|
60
|
+
const REPEATED_PUNCT = /([!?,])\1+/g;
|
|
61
|
+
|
|
62
|
+
/** Whether two matched word strings are the same word, case-insensitively. Both are already plain
|
|
63
|
+
* word runs from the same WORD pattern, so a locale-insensitive lowercase compare is enough. */
|
|
64
|
+
function sameWord(a: string, b: string): boolean {
|
|
65
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run the three objective checks over one prose span [from, to), returning every finding with an
|
|
69
|
+
* absolute range and fix. The doubled-word check is bounded to this span so a repeat that straddles
|
|
70
|
+
* a skipped region is never matched. */
|
|
71
|
+
function checkSpan(text: string, from: number, to: number): ObjectiveError[] {
|
|
72
|
+
const out: ObjectiveError[] = [];
|
|
73
|
+
const slice = text.slice(from, to);
|
|
74
|
+
|
|
75
|
+
// 1. Doubled words. lastIndex stepping handles overlapping triples ("the the the") by restarting
|
|
76
|
+
// the scan at the second word, so each adjacent pair is examined.
|
|
77
|
+
DOUBLED_WORD.lastIndex = 0;
|
|
78
|
+
let dw: RegExpExecArray | null;
|
|
79
|
+
while ((dw = DOUBLED_WORD.exec(slice)) !== null) {
|
|
80
|
+
const [whole, first, gap, second] = dw;
|
|
81
|
+
if (gap === undefined || first === undefined || second === undefined) continue;
|
|
82
|
+
if (sameWord(first, second)) {
|
|
83
|
+
const matchStart = from + dw.index;
|
|
84
|
+
const matchEnd = matchStart + whole.length;
|
|
85
|
+
// The fix deletes the gap and the second word, leaving the first word alone.
|
|
86
|
+
const fixFrom = matchStart + first.length;
|
|
87
|
+
out.push({
|
|
88
|
+
kind: 'doubled-word',
|
|
89
|
+
from: matchStart,
|
|
90
|
+
to: matchEnd,
|
|
91
|
+
message: `Doubled word \`${first}\`.`,
|
|
92
|
+
fix: { from: fixFrom, to: matchEnd, insert: '' },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Restart at the second word so an overlapping triple is caught as two pairs.
|
|
96
|
+
DOUBLED_WORD.lastIndex = dw.index + first.length + gap.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Double (or more) spaces inside a line, never leading indentation. The capture group is the
|
|
100
|
+
// surplus spaces (every space past the first); the fix removes them, collapsing the run to one.
|
|
101
|
+
DOUBLE_SPACE.lastIndex = 0;
|
|
102
|
+
let ds: RegExpExecArray | null;
|
|
103
|
+
while ((ds = DOUBLE_SPACE.exec(slice)) !== null) {
|
|
104
|
+
const surplus = ds[1];
|
|
105
|
+
if (surplus === undefined) continue;
|
|
106
|
+
// The run begins one space after the leading non-space-non-newline character the pattern anchored
|
|
107
|
+
// on, so the flagged range is the whole space run (the one kept space plus the surplus).
|
|
108
|
+
const runStart = from + ds.index + 1;
|
|
109
|
+
const runEnd = runStart + 1 + surplus.length;
|
|
110
|
+
out.push({
|
|
111
|
+
kind: 'double-space',
|
|
112
|
+
from: runStart,
|
|
113
|
+
to: runEnd,
|
|
114
|
+
message: 'Repeated space.',
|
|
115
|
+
fix: { from: runStart + 1, to: runEnd, insert: '' },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Stray repeated punctuation (`!`, `?`, `,`), collapsed to one. The period is excluded so an
|
|
120
|
+
// ellipsis is never touched.
|
|
121
|
+
REPEATED_PUNCT.lastIndex = 0;
|
|
122
|
+
let rp: RegExpExecArray | null;
|
|
123
|
+
while ((rp = REPEATED_PUNCT.exec(slice)) !== null) {
|
|
124
|
+
const mark = rp[1];
|
|
125
|
+
if (mark === undefined) continue;
|
|
126
|
+
const runStart = from + rp.index;
|
|
127
|
+
const runEnd = runStart + rp[0].length;
|
|
128
|
+
out.push({
|
|
129
|
+
kind: 'repeated-punct',
|
|
130
|
+
from: runStart,
|
|
131
|
+
to: runEnd,
|
|
132
|
+
message: `Repeated \`${mark}\`.`,
|
|
133
|
+
// Keep the first mark, delete the rest.
|
|
134
|
+
fix: { from: runStart + 1, to: runEnd, insert: '' },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every objective-error finding across the given prose spans. The spans are the same keep ranges the
|
|
143
|
+
* spellcheck classifier produced (via {@link classifyProse}), so an error inside code, a URL,
|
|
144
|
+
* frontmatter, or directive machinery is never flagged. Each finding carries an absolute range and a
|
|
145
|
+
* single-edit fix. Deterministic and CodeMirror-free, so the unit test asserts the findings without a
|
|
146
|
+
* browser or a Worker. The findings are returned in document order across the spans.
|
|
147
|
+
*/
|
|
148
|
+
export function objectiveErrors(text: string, spans: Range[]): ObjectiveError[] {
|
|
149
|
+
const out: ObjectiveError[] = [];
|
|
150
|
+
for (const span of spans) {
|
|
151
|
+
out.push(...checkSpan(text, span.from, span.to));
|
|
152
|
+
}
|
|
153
|
+
out.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
154
|
+
return out;
|
|
155
|
+
}
|