@glw907/cairn-cms 0.60.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/components/AdminLayout.svelte +130 -229
  3. package/dist/components/CairnAdmin.svelte +10 -42
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +192 -259
  7. package/dist/components/ComponentForm.svelte +110 -185
  8. package/dist/components/ComponentInsertDialog.svelte +163 -283
  9. package/dist/components/ConceptList.svelte +111 -191
  10. package/dist/components/ConfirmPage.svelte +5 -12
  11. package/dist/components/CsrfField.svelte +5 -11
  12. package/dist/components/DeleteDialog.svelte +15 -42
  13. package/dist/components/EditPage.svelte +665 -1166
  14. package/dist/components/EditorToolbar.svelte +108 -170
  15. package/dist/components/IconPicker.svelte +23 -53
  16. package/dist/components/LinkPicker.svelte +34 -58
  17. package/dist/components/LoginPage.svelte +14 -27
  18. package/dist/components/ManageEditors.svelte +3 -15
  19. package/dist/components/MarkdownEditor.svelte +689 -957
  20. package/dist/components/MarkdownHelpDialog.svelte +8 -12
  21. package/dist/components/MediaCaptureCard.svelte +18 -57
  22. package/dist/components/MediaFigureControl.svelte +32 -71
  23. package/dist/components/MediaHeroField.svelte +210 -329
  24. package/dist/components/MediaInsertPopover.svelte +156 -283
  25. package/dist/components/MediaPicker.svelte +67 -131
  26. package/dist/components/NavTree.svelte +46 -78
  27. package/dist/components/RenameDialog.svelte +16 -43
  28. package/dist/components/ShortcutsDialog.svelte +9 -13
  29. package/dist/components/ShortcutsGrid.svelte +1 -2
  30. package/dist/components/TidyReview.svelte +140 -248
  31. package/dist/components/WebLinkDialog.svelte +19 -40
  32. package/dist/components/cairn-admin.css +4 -0
  33. package/dist/components/spellcheck.d.ts +3 -1
  34. package/dist/components/spellcheck.js +14 -2
  35. package/dist/delivery/CairnHead.svelte +8 -11
  36. package/package.json +2 -2
  37. package/src/lib/components/spellcheck.ts +16 -2
@@ -17,256 +17,148 @@ the model made and never a count of the author's own usage. A normalization name
17
17
  setting that authorized it; counting the author's own habit is the harmonize-to-author judgment cairn
18
18
  must never make, so no such count exists.
19
19
  -->
20
- <script lang="ts">
21
- import SparklesIcon from '@lucide/svelte/icons/sparkles';
22
- import CheckIcon from '@lucide/svelte/icons/check';
23
- import XIcon from '@lucide/svelte/icons/x';
24
- import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
25
- import LightbulbIcon from '@lucide/svelte/icons/lightbulb';
26
- import EyeIcon from '@lucide/svelte/icons/eye';
27
- import type { Change } from './tidy-diff.js';
28
- import { lineLabel } from './tidy-diff.js';
29
- import {
30
- categorize,
31
- isObjective,
32
- buildBecause,
33
- categoryLabel,
34
- type TidyCategory,
35
- } from './tidy-categorize.js';
36
- import type { TidyConventions } from '../nav/site-config.js';
37
-
38
- interface Props {
39
- /** The validated change set (Task 13 output), the unit the surface accepts and rejects. */
40
- changes: Change[];
41
- /** The captured original the diff was computed against; the source of every line label and the
42
- * before/after rows. Positions index this string. */
43
- original: string;
44
- /** The resolved tidy conventions, the ONLY data source for a normalization's because-line and the
45
- * category inference. Never the buffer's usage. */
46
- conventions: TidyConventions;
47
- /** The model that produced the result, for the head pill (e.g. "claude-sonnet-4-6"). */
48
- model: string;
49
- /** The document's display title, for the head. */
50
- title: string;
51
- /** The apply seam from MarkdownEditor: the surface drives the in-buffer decorations and the batched
52
- * apply through it. Typed with an inline `import(...)` so no static editor-module edge sits in this
53
- * component (the editor-boundary test bars that edge by a textual scan). */
54
- api: import('./editor-tidy.js').TidyApi;
55
- /** Called when the review closes (apply or cancel), so the host clears tidy mode and re-enables the
56
- * editor. `applied` is true when the author applied changes, false on cancel/reject-all. */
57
- onclose: (applied: boolean) => void;
58
- /** Called to scroll the editor underneath to a hunk's source line; the host drives the editor's
59
- * selectRange seam. */
60
- onshow: (from: number, to: number) => void;
61
- }
62
-
63
- let { changes, original, conventions, model, title, api, onclose, onshow }: Props = $props();
64
-
65
- // One hunk per change, with its locally-inferred category, line label, diff rows, and because-line.
66
- // Computed once from the immutable inputs; the disposition lives in its own reactive array so the
67
- // rows do not recompute on every toggle.
68
- interface Hunk {
69
- index: number;
70
- category: TidyCategory;
71
- objective: boolean;
72
- line: number;
73
- contextBefore: string;
74
- contextAfter: string;
75
- delText: string;
76
- addText: string;
77
- delRun: { pre: string; mid: string; post: string };
78
- addRun: { pre: string; mid: string; post: string };
79
- because: ReturnType<typeof buildBecause>;
80
- label: string;
81
- }
82
-
83
- const hunks: Hunk[] = $derived(changes.map((c) => {
84
- const category = categorize(c, original, conventions);
85
- const objective = isObjective(category);
86
- const removed = original.slice(c.from, c.to);
87
- const added = c.replacement;
88
- // The line containing the change, and the one line of context above and below it (graft 4).
89
- const line = lineLabel(original, c.from);
90
- const lines = original.split('\n');
91
- const contextBefore = line >= 2 ? lines[line - 2] ?? '' : '';
92
- const contextAfter = line < lines.length ? lines[line] ?? '' : '';
93
- // The changed line, split around the changed run so the diff can underline/strike just the run.
94
- const lineStart = original.lastIndexOf('\n', c.from - 1) + 1;
95
- const nextNewline = original.indexOf('\n', c.from);
96
- const lineEnd = nextNewline === -1 ? original.length : nextNewline;
97
- const fullLine = original.slice(lineStart, lineEnd);
98
- const pre = original.slice(lineStart, c.from);
99
- const post = original.slice(c.to, lineEnd);
100
- const because =
101
- category.kind === 'normalization' ? buildBecause(category.convention, conventions) : null;
102
- return {
103
- index: c.index,
104
- category,
105
- objective,
106
- line,
107
- contextBefore,
108
- contextAfter,
109
- delText: fullLine,
110
- addText: pre + added + post,
111
- delRun: { pre, mid: removed, post },
112
- addRun: { pre, mid: added, post },
113
- because,
114
- label: categoryLabel(category),
115
- };
116
- }));
117
-
118
- // The per-hunk disposition. Objective hunks open pre-kept; judgment hunks open undecided. The defaults
119
- // come from the hunks (their safety rank); the author's per-hunk and bulk choices land in `overrides`,
120
- // and `dispositions` is the merged effective map keyed by the stable change index. Splitting the two
121
- // keeps the default reactive to the derived hunks without capturing only their initial value.
122
- type Disposition = 'kept' | 'rejected' | 'undecided';
123
- let overrides = $state<Record<number, Disposition>>({});
124
-
125
- // The disposition a hunk takes under a given override map: the author's choice if present, else the
126
- // safety-rank default (objective hunks pre-kept, judgment hunks undecided). One source for the default.
127
- function effectiveDisposition(h: Hunk, map: Record<number, Disposition>): Disposition {
128
- return map[h.index] ?? (h.objective ? 'kept' : 'undecided');
129
- }
130
-
131
- const dispositions = $derived<Record<number, Disposition>>(
132
- Object.fromEntries(hunks.map((h) => [h.index, effectiveDisposition(h, overrides)] as const)),
133
- );
134
-
135
- // The keyboard step-through cursor: the focused hunk's array position. j/k move; a/r act on it.
136
- let focusedPos = $state(0);
137
-
138
- // The two live regions (the MediaPicker discipline). The tally region (role=status) speaks only on a
139
- // bulk action; the action region (aria-live=polite) narrates the single per-hunk action and each
140
- // cursor move. A live region re-announces only when its text changes, so a deterministic message
141
- // (the same hunk, the same verb) would go silent on a repeat. Each writer appends an invisible
142
- // incrementing nonce so the region text always mutates and the screen reader always speaks it.
143
- let tallyMessage = $state('');
144
- let actionMessage = $state('');
145
- let announceNonce = 0;
146
-
147
- // An invisible suffix that flips on every call, so a repeated identical announcement still changes
148
- // the region text and re-fires the live region. It is a zero-width space, never voiced, so the heard
149
- // sentence is unchanged. Each region keeps its own parity through the shared counter.
150
- function nonce(): string {
151
- return announceNonce++ % 2 === 0 ? '' : '​';
152
- }
153
-
154
- const keptCount = $derived(hunks.filter((h) => dispositions[h.index] === 'kept').length);
155
- const reviewCount = $derived(hunks.filter((h) => dispositions[h.index] === 'undecided').length);
156
- const skipCount = $derived(hunks.filter((h) => dispositions[h.index] === 'rejected').length);
157
-
158
- let dialog = $state<HTMLDialogElement | null>(null);
159
-
160
- $effect(() => {
161
- // Open the dialog once on mount; showModal supplies the focus trap and Escape.
162
- dialog?.showModal();
163
- });
164
-
165
- function setDisposition(index: number, next: Disposition) {
166
- overrides = { ...overrides, [index]: next };
167
- }
168
-
169
- // Narrate one hunk in the polite region. The verb says what just happened to it ("Kept", "Skipped",
170
- // or "Focused" as the cursor lands on it). The sentence carries the kind and the before/after text,
171
- // and for a normalization appends the config-named rationale (never a usage count). The trailing
172
- // nonce keeps a repeated identical action audible.
173
- function narrate(h: Hunk, verb: string) {
174
- const where = `Hunk ${hunks.indexOf(h) + 1} of ${hunks.length}`;
175
- const what = h.delRun.mid && h.addRun.mid ? `${h.delRun.mid.trim()} becomes ${h.addRun.mid.trim()}` : h.label;
176
- const why = h.because ? `, your ${h.because.label} setting is ${h.because.variant}` : '';
177
- actionMessage = `${where}. ${h.label}. ${what}${why}. ${verb}.${nonce()}`;
178
- }
179
-
180
- function acceptHunk(h: Hunk) {
181
- setDisposition(h.index, 'kept');
182
- narrate(h, 'Kept');
183
- }
184
- function rejectHunk(h: Hunk) {
185
- setDisposition(h.index, 'rejected');
186
- narrate(h, 'Skipped');
187
- }
188
-
189
- // Accept fixes (the bulk action): mark EVERY OBJECTIVE hunk kept and nothing else. A judgment hunk is
190
- // never touched here, so it stays undecided and is never swept. The tally region announces the result.
191
- function acceptFixes() {
192
- const next = { ...overrides };
193
- for (const h of hunks) if (h.objective) next[h.index] = 'kept';
194
- overrides = next;
195
- const n = hunks.filter((h) => h.objective).length;
196
- const stillReview = hunks.filter((h) => effectiveDisposition(h, next) === 'undecided').length;
197
- tallyMessage = `${n} fixes kept. ${stillReview} still to review.${nonce()}`;
198
- }
199
-
200
- // Reject all: mark every hunk rejected; no text is written. The tally region announces it.
201
- function rejectAll() {
202
- overrides = Object.fromEntries(hunks.map((h) => [h.index, 'rejected'] as const));
203
- tallyMessage = `All ${hunks.length} changes skipping.${nonce()}`;
204
- }
205
-
206
- // Apply: write the kept hunks in ONE batched transaction through the apply seam, then close. The
207
- // seam's acceptMany dispatches a single view.dispatch({ changes }), so the whole tidy is one undoable
208
- // step. ONLY the kept indexes are passed, so an undecided judgment hunk is never written.
209
- function apply() {
210
- const keptIndexes = hunks.filter((h) => dispositions[h.index] === 'kept').map((h) => h.index);
211
- api.acceptMany(keptIndexes);
212
- api.exit();
213
- dialog?.close();
214
- onclose(true);
215
- }
216
-
217
- // Cancel: write nothing, clear the decorations, leave the document byte-identical.
218
- function cancel() {
219
- api.exit();
220
- dialog?.close();
221
- onclose(false);
222
- }
223
-
224
- function showInText(h: Hunk) {
225
- const c = changes.find((ch) => ch.index === h.index);
226
- if (c) onshow(c.from, c.to);
227
- }
228
-
229
- // Move the step-through cursor and announce the hunk it lands on. A screen-reader user pressing j/k
230
- // hears the newly-focused hunk (kind plus before/after text, plus the because-line for a judgment
231
- // hunk), the same spec invariant the per-hunk action narration holds. Without this a move was silent.
232
- function moveFocus(next: number) {
233
- focusedPos = next;
234
- const h = hunks[focusedPos];
235
- if (h) narrate(h, 'Focused');
236
- }
237
-
238
- // Keyboard step-through on the hunk list (graft 3): j/k or n/p move; a/r accept/reject the focused
239
- // hunk; A accepts all objective; Escape cancels (the native dialog supplies Escape, handled below).
240
- function onListKeydown(e: KeyboardEvent) {
241
- const h = hunks[focusedPos];
242
- if (e.key === 'j' || e.key === 'n') {
243
- moveFocus(Math.min(focusedPos + 1, hunks.length - 1));
244
- e.preventDefault();
245
- } else if (e.key === 'k' || e.key === 'p') {
246
- moveFocus(Math.max(focusedPos - 1, 0));
247
- e.preventDefault();
248
- } else if (e.key === 'a' && !e.shiftKey) {
249
- if (h) acceptHunk(h);
250
- e.preventDefault();
251
- } else if (e.key === 'r' && !e.shiftKey) {
252
- if (h) rejectHunk(h);
253
- e.preventDefault();
254
- } else if (e.key === 'A' || (e.key === 'a' && e.shiftKey)) {
255
- acceptFixes();
256
- e.preventDefault();
257
- }
258
- }
259
-
260
- // The native dialog raises a cancel event on Escape; map it to the surface's cancel so the buffer is
261
- // left untouched and the host clears tidy mode.
262
- function onDialogCancel(e: Event) {
20
+ <script lang="ts">import SparklesIcon from "@lucide/svelte/icons/sparkles";
21
+ import CheckIcon from "@lucide/svelte/icons/check";
22
+ import XIcon from "@lucide/svelte/icons/x";
23
+ import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert";
24
+ import LightbulbIcon from "@lucide/svelte/icons/lightbulb";
25
+ import EyeIcon from "@lucide/svelte/icons/eye";
26
+ import { lineLabel } from "./tidy-diff.js";
27
+ import {
28
+ categorize,
29
+ isObjective,
30
+ buildBecause,
31
+ categoryLabel
32
+ } from "./tidy-categorize.js";
33
+ let { changes, original, conventions, model, title, api, onclose, onshow } = $props();
34
+ const hunks = $derived(changes.map((c) => {
35
+ const category = categorize(c, original, conventions);
36
+ const objective = isObjective(category);
37
+ const removed = original.slice(c.from, c.to);
38
+ const added = c.replacement;
39
+ const line = lineLabel(original, c.from);
40
+ const lines = original.split("\n");
41
+ const contextBefore = line >= 2 ? lines[line - 2] ?? "" : "";
42
+ const contextAfter = line < lines.length ? lines[line] ?? "" : "";
43
+ const lineStart = original.lastIndexOf("\n", c.from - 1) + 1;
44
+ const nextNewline = original.indexOf("\n", c.from);
45
+ const lineEnd = nextNewline === -1 ? original.length : nextNewline;
46
+ const fullLine = original.slice(lineStart, lineEnd);
47
+ const pre = original.slice(lineStart, c.from);
48
+ const post = original.slice(c.to, lineEnd);
49
+ const because = category.kind === "normalization" ? buildBecause(category.convention, conventions) : null;
50
+ return {
51
+ index: c.index,
52
+ category,
53
+ objective,
54
+ line,
55
+ contextBefore,
56
+ contextAfter,
57
+ delText: fullLine,
58
+ addText: pre + added + post,
59
+ delRun: { pre, mid: removed, post },
60
+ addRun: { pre, mid: added, post },
61
+ because,
62
+ label: categoryLabel(category)
63
+ };
64
+ }));
65
+ let overrides = $state({});
66
+ function effectiveDisposition(h, map) {
67
+ return map[h.index] ?? (h.objective ? "kept" : "undecided");
68
+ }
69
+ const dispositions = $derived(
70
+ Object.fromEntries(hunks.map((h) => [h.index, effectiveDisposition(h, overrides)]))
71
+ );
72
+ let focusedPos = $state(0);
73
+ let tallyMessage = $state("");
74
+ let actionMessage = $state("");
75
+ let announceNonce = 0;
76
+ function nonce() {
77
+ return announceNonce++ % 2 === 0 ? "" : "​";
78
+ }
79
+ const keptCount = $derived(hunks.filter((h) => dispositions[h.index] === "kept").length);
80
+ const reviewCount = $derived(hunks.filter((h) => dispositions[h.index] === "undecided").length);
81
+ const skipCount = $derived(hunks.filter((h) => dispositions[h.index] === "rejected").length);
82
+ let dialog = $state(null);
83
+ $effect(() => {
84
+ dialog?.showModal();
85
+ });
86
+ function setDisposition(index, next) {
87
+ overrides = { ...overrides, [index]: next };
88
+ }
89
+ function narrate(h, verb) {
90
+ const where = `Hunk ${hunks.indexOf(h) + 1} of ${hunks.length}`;
91
+ const what = h.delRun.mid && h.addRun.mid ? `${h.delRun.mid.trim()} becomes ${h.addRun.mid.trim()}` : h.label;
92
+ const why = h.because ? `, your ${h.because.label} setting is ${h.because.variant}` : "";
93
+ actionMessage = `${where}. ${h.label}. ${what}${why}. ${verb}.${nonce()}`;
94
+ }
95
+ function acceptHunk(h) {
96
+ setDisposition(h.index, "kept");
97
+ narrate(h, "Kept");
98
+ }
99
+ function rejectHunk(h) {
100
+ setDisposition(h.index, "rejected");
101
+ narrate(h, "Skipped");
102
+ }
103
+ function acceptFixes() {
104
+ const next = { ...overrides };
105
+ for (const h of hunks) if (h.objective) next[h.index] = "kept";
106
+ overrides = next;
107
+ const n = hunks.filter((h) => h.objective).length;
108
+ const stillReview = hunks.filter((h) => effectiveDisposition(h, next) === "undecided").length;
109
+ tallyMessage = `${n} fixes kept. ${stillReview} still to review.${nonce()}`;
110
+ }
111
+ function rejectAll() {
112
+ overrides = Object.fromEntries(hunks.map((h) => [h.index, "rejected"]));
113
+ tallyMessage = `All ${hunks.length} changes skipping.${nonce()}`;
114
+ }
115
+ function apply() {
116
+ const keptIndexes = hunks.filter((h) => dispositions[h.index] === "kept").map((h) => h.index);
117
+ api.acceptMany(keptIndexes);
118
+ api.exit();
119
+ dialog?.close();
120
+ onclose(true);
121
+ }
122
+ function cancel() {
123
+ api.exit();
124
+ dialog?.close();
125
+ onclose(false);
126
+ }
127
+ function showInText(h) {
128
+ const c = changes.find((ch) => ch.index === h.index);
129
+ if (c) onshow(c.from, c.to);
130
+ }
131
+ function moveFocus(next) {
132
+ focusedPos = next;
133
+ const h = hunks[focusedPos];
134
+ if (h) narrate(h, "Focused");
135
+ }
136
+ function onListKeydown(e) {
137
+ const h = hunks[focusedPos];
138
+ if (e.key === "j" || e.key === "n") {
139
+ moveFocus(Math.min(focusedPos + 1, hunks.length - 1));
140
+ e.preventDefault();
141
+ } else if (e.key === "k" || e.key === "p") {
142
+ moveFocus(Math.max(focusedPos - 1, 0));
143
+ e.preventDefault();
144
+ } else if (e.key === "a" && !e.shiftKey) {
145
+ if (h) acceptHunk(h);
146
+ e.preventDefault();
147
+ } else if (e.key === "r" && !e.shiftKey) {
148
+ if (h) rejectHunk(h);
149
+ e.preventDefault();
150
+ } else if (e.key === "A" || e.key === "a" && e.shiftKey) {
151
+ acceptFixes();
263
152
  e.preventDefault();
264
- cancel();
265
- }
266
-
267
- function actsLabel(h: Hunk): string {
268
- return h.objective ? 'Accept or reject this fix' : 'Accept or reject this change';
269
153
  }
154
+ }
155
+ function onDialogCancel(e) {
156
+ e.preventDefault();
157
+ cancel();
158
+ }
159
+ function actsLabel(h) {
160
+ return h.objective ? "Accept or reject this fix" : "Accept or reject this change";
161
+ }
270
162
  </script>
271
163
 
272
164
  <dialog
@@ -6,46 +6,25 @@ text; when the editor holds a selection it arrives as the default text, and the
6
6
  that selection either way. Built on a native <dialog>, following the LinkPicker a11y conventions,
7
7
  and opened by the host's Ctrl/Cmd+K shortcut through the exported open().
8
8
  -->
9
- <script lang="ts">
10
- interface Props {
11
- /** Insert an inline link at the editor cursor; the editor's registerInsertLink seam. */
12
- insert: (href: string, title: string) => void;
13
- /** Read the editor's current selection, for the Text field's default. */
14
- selection?: () => string;
15
- /** Disable the trigger; the host sets it while Preview shows. */
16
- disabled?: boolean;
17
- /** Render the built-in Web link trigger. False mounts only the dialog, for a host that
18
- * supplies its own trigger and opens the dialog through the exported open(). */
19
- trigger?: boolean;
20
- }
21
-
22
- let { insert, selection, disabled = false, trigger = true }: Props = $props();
23
-
24
- let dialog = $state<HTMLDialogElement | null>(null);
25
- let hrefInput = $state<HTMLInputElement | null>(null);
26
- let href = $state('');
27
- let text = $state('');
28
-
29
- /** Open the dialog with fresh fields; the edit page's Ctrl/Cmd+K shortcut calls it too. */
30
- export function open() {
31
- href = '';
32
- text = selection?.() ?? '';
33
- dialog?.showModal();
34
- // showModal() lands focus on the first focusable element (the header Close button), so move
35
- // it to the address input the dialog exists for (WCAG 2.4.3). A microtask defers past the
36
- // dialog's own focus handling, the RenameDialog recipe.
37
- queueMicrotask(() => hrefInput?.focus());
38
- }
39
- function close() {
40
- dialog?.close();
41
- }
42
- function submit(e: SubmitEvent) {
43
- e.preventDefault();
44
- // With no text and no selection the address itself becomes the display text, so the link
45
- // never renders as an invisible pair of brackets.
46
- insert(href, text.trim() || href);
47
- close();
48
- }
9
+ <script lang="ts">let { insert, selection, disabled = false, trigger = true } = $props();
10
+ let dialog = $state(null);
11
+ let hrefInput = $state(null);
12
+ let href = $state("");
13
+ let text = $state("");
14
+ export function open() {
15
+ href = "";
16
+ text = selection?.() ?? "";
17
+ dialog?.showModal();
18
+ queueMicrotask(() => hrefInput?.focus());
19
+ }
20
+ function close() {
21
+ dialog?.close();
22
+ }
23
+ function submit(e) {
24
+ e.preventDefault();
25
+ insert(href, text.trim() || href);
26
+ close();
27
+ }
49
28
  </script>
50
29
 
51
30
  {#if trigger}
@@ -7135,6 +7135,10 @@
7135
7135
  user-select: none;
7136
7136
  }
7137
7137
 
7138
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .\[builtin\:vite-dynamic-import-vars\] {
7139
+ builtin: vite-dynamic-import-vars;
7140
+ }
7141
+
7138
7142
  @media (hover: hover) {
7139
7143
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .group-hover\/sec\:opacity-90:is(:where(.group\/sec):hover *) {
7140
7144
  opacity: .9;
@@ -87,7 +87,9 @@ export declare function createSpellWorker(): SpellWorker;
87
87
  /** The real wasm asset URL, resolved module-relative the same way the worker is. */
88
88
  export declare function resolveWasmUrl(): string;
89
89
  /** The real dictionary asset URL for a dictionary filename, resolved module-relative. The caller
90
- * passes the dialect-resolved filename (default `dictionary-en-us.txt`). */
90
+ * passes the dialect-resolved filename (default `dictionary-en-us.txt`). `dictionaryFileForDialect`
91
+ * already collapses an unknown dialect to the default, so an unmapped name falls back the same way
92
+ * rather than pointing at an asset that does not ship. */
91
93
  export declare function resolveDictionaryUrl(dictionaryFile: string): string;
92
94
  /** Options for {@link cairnSpellcheck}, so the unit and component layers can inject a fake Worker
93
95
  * factory in place of the real `new Worker(...)`. */
@@ -290,10 +290,22 @@ export function createSpellWorker() {
290
290
  export function resolveWasmUrl() {
291
291
  return new URL('./spellcheck-assets/spellchecker-wasm.wasm', import.meta.url).href;
292
292
  }
293
+ /** Each shipped dictionary, mapped to a resolver that builds its asset URL with a LITERAL
294
+ * `new URL(..., import.meta.url)`. The literal path is load-bearing. A templated `new URL` makes Vite
295
+ * and rolldown treat the directory as a glob and parse every sibling module to build it, including the
296
+ * `.svelte` components that still carry `lang="ts"` in `dist`, and the glob parser chokes on the TS
297
+ * syntax and breaks the consumer build. This set mirrors the dialect map in `nav/site-config.ts`; add
298
+ * one line per new shipped dialect dictionary. */
299
+ const DICTIONARY_URLS = {
300
+ 'dictionary-en-us.txt': () => new URL('./spellcheck-assets/dictionary-en-us.txt', import.meta.url).href,
301
+ };
293
302
  /** 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`). */
303
+ * passes the dialect-resolved filename (default `dictionary-en-us.txt`). `dictionaryFileForDialect`
304
+ * already collapses an unknown dialect to the default, so an unmapped name falls back the same way
305
+ * rather than pointing at an asset that does not ship. */
295
306
  export function resolveDictionaryUrl(dictionaryFile) {
296
- return new URL(`./spellcheck-assets/${dictionaryFile}`, import.meta.url).href;
307
+ const resolve = DICTIONARY_URLS[dictionaryFile] ?? DICTIONARY_URLS['dictionary-en-us.txt'];
308
+ return resolve();
297
309
  }
298
310
  /** How far past the visible viewport to lint, so a small scroll does not re-lint from scratch. */
299
311
  const VIEWPORT_MARGIN = 1000;
@@ -5,17 +5,14 @@ tags, and one escaped JSON-LD script. The title renders from seo.title by defaul
5
5
  lets the site own the <title>, and a string overrides it. It carries no CSS, so it pulls in no
6
6
  admin styles.
7
7
  -->
8
- <script lang="ts">
9
- import type { SeoMeta } from './seo.js';
10
- import { jsonLdScript } from './json-ld.js';
11
-
12
- let {
13
- /** The plain-data head to render. */
14
- seo,
15
- /** Title override: a string replaces seo.title, false lets the site own <title>. */
16
- title,
17
- }: { seo: SeoMeta; title?: string | false } = $props();
18
- const titleText = $derived(title === undefined ? seo.title : title);
8
+ <script lang="ts">import { jsonLdScript } from "./json-ld.js";
9
+ let {
10
+ /** The plain-data head to render. */
11
+ seo,
12
+ /** Title override: a string replaces seo.title, false lets the site own <title>. */
13
+ title
14
+ } = $props();
15
+ const titleText = $derived(title === void 0 ? seo.title : title);
19
16
  </script>
20
17
 
21
18
  <svelte:head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.60.0",
3
+ "version": "0.60.1",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -26,7 +26,7 @@
26
26
  "markdown"
27
27
  ],
28
28
  "scripts": {
29
- "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js dist/doctor/bin.js",
29
+ "package": "svelte-package && node scripts/build-admin-css.mjs && node scripts/transpile-dist-svelte.mjs && chmod +x dist/vite/bin.js dist/doctor/bin.js",
30
30
  "check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
31
31
  "check:reference": "npm run package && node scripts/reference-coverage.mjs",
32
32
  "check:reference:signatures": "npm run package && node scripts/check-reference-signatures.mjs",
@@ -368,10 +368,24 @@ export function resolveWasmUrl(): string {
368
368
  return new URL('./spellcheck-assets/spellchecker-wasm.wasm', import.meta.url).href;
369
369
  }
370
370
 
371
+ /** Each shipped dictionary, mapped to a resolver that builds its asset URL with a LITERAL
372
+ * `new URL(..., import.meta.url)`. The literal path is load-bearing. A templated `new URL` makes Vite
373
+ * and rolldown treat the directory as a glob and parse every sibling module to build it, including the
374
+ * `.svelte` components that still carry `lang="ts"` in `dist`, and the glob parser chokes on the TS
375
+ * syntax and breaks the consumer build. This set mirrors the dialect map in `nav/site-config.ts`; add
376
+ * one line per new shipped dialect dictionary. */
377
+ const DICTIONARY_URLS: Record<string, () => string> = {
378
+ 'dictionary-en-us.txt': () =>
379
+ new URL('./spellcheck-assets/dictionary-en-us.txt', import.meta.url).href,
380
+ };
381
+
371
382
  /** The real dictionary asset URL for a dictionary filename, resolved module-relative. The caller
372
- * passes the dialect-resolved filename (default `dictionary-en-us.txt`). */
383
+ * passes the dialect-resolved filename (default `dictionary-en-us.txt`). `dictionaryFileForDialect`
384
+ * already collapses an unknown dialect to the default, so an unmapped name falls back the same way
385
+ * rather than pointing at an asset that does not ship. */
373
386
  export function resolveDictionaryUrl(dictionaryFile: string): string {
374
- return new URL(`./spellcheck-assets/${dictionaryFile}`, import.meta.url).href;
387
+ const resolve = DICTIONARY_URLS[dictionaryFile] ?? DICTIONARY_URLS['dictionary-en-us.txt'];
388
+ return resolve();
375
389
  }
376
390
 
377
391
  /** How far past the visible viewport to lint, so a small scroll does not re-lint from scratch. */