@glw907/cairn-cms 0.59.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/components/AdminLayout.svelte +130 -229
  3. package/dist/components/CairnAdmin.svelte +12 -41
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +486 -0
  7. package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
  8. package/dist/components/ComponentForm.svelte +110 -185
  9. package/dist/components/ComponentInsertDialog.svelte +163 -283
  10. package/dist/components/ConceptList.svelte +111 -191
  11. package/dist/components/ConfirmPage.svelte +5 -12
  12. package/dist/components/CsrfField.svelte +5 -11
  13. package/dist/components/DeleteDialog.svelte +15 -42
  14. package/dist/components/EditPage.svelte +786 -918
  15. package/dist/components/EditorToolbar.svelte +108 -170
  16. package/dist/components/IconPicker.svelte +23 -53
  17. package/dist/components/LinkPicker.svelte +34 -58
  18. package/dist/components/LoginPage.svelte +14 -27
  19. package/dist/components/ManageEditors.svelte +3 -15
  20. package/dist/components/MarkdownEditor.svelte +688 -789
  21. package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
  22. package/dist/components/MarkdownHelpDialog.svelte +8 -12
  23. package/dist/components/MediaCaptureCard.svelte +18 -57
  24. package/dist/components/MediaFigureControl.svelte +32 -71
  25. package/dist/components/MediaHeroField.svelte +210 -329
  26. package/dist/components/MediaInsertPopover.svelte +156 -283
  27. package/dist/components/MediaPicker.svelte +67 -131
  28. package/dist/components/NavTree.svelte +46 -78
  29. package/dist/components/RenameDialog.svelte +16 -43
  30. package/dist/components/ShortcutsDialog.svelte +9 -13
  31. package/dist/components/ShortcutsGrid.svelte +1 -2
  32. package/dist/components/TidyReview.svelte +355 -0
  33. package/dist/components/TidyReview.svelte.d.ts +47 -0
  34. package/dist/components/WebLinkDialog.svelte +19 -40
  35. package/dist/components/cairn-admin.css +768 -0
  36. package/dist/components/editor-tidy.d.ts +31 -0
  37. package/dist/components/editor-tidy.js +199 -0
  38. package/dist/components/index.d.ts +1 -0
  39. package/dist/components/index.js +1 -0
  40. package/dist/components/markdown-directives.d.ts +16 -0
  41. package/dist/components/markdown-directives.js +34 -0
  42. package/dist/components/objective-errors.d.ts +30 -0
  43. package/dist/components/objective-errors.js +113 -0
  44. package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  45. package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  46. package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  47. package/dist/components/spellcheck-worker.d.ts +80 -0
  48. package/dist/components/spellcheck-worker.js +161 -0
  49. package/dist/components/spellcheck.d.ts +148 -0
  50. package/dist/components/spellcheck.js +553 -0
  51. package/dist/components/tidy-categorize.d.ts +67 -0
  52. package/dist/components/tidy-categorize.js +392 -0
  53. package/dist/components/tidy-diff.d.ts +60 -0
  54. package/dist/components/tidy-diff.js +147 -0
  55. package/dist/components/tidy-validate.d.ts +37 -0
  56. package/dist/components/tidy-validate.js +174 -0
  57. package/dist/content/compose.d.ts +1 -1
  58. package/dist/content/compose.js +11 -0
  59. package/dist/content/site-dictionary.d.ts +31 -0
  60. package/dist/content/site-dictionary.js +82 -0
  61. package/dist/content/types.d.ts +25 -0
  62. package/dist/delivery/CairnHead.svelte +8 -11
  63. package/dist/doctor/checks-local.d.ts +1 -0
  64. package/dist/doctor/checks-local.js +55 -6
  65. package/dist/doctor/index.js +2 -1
  66. package/dist/log/events.d.ts +1 -1
  67. package/dist/nav/site-config.d.ts +98 -0
  68. package/dist/nav/site-config.js +132 -0
  69. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  70. package/dist/sveltekit/admin-dispatch.js +6 -2
  71. package/dist/sveltekit/cairn-admin.d.ts +13 -1
  72. package/dist/sveltekit/cairn-admin.js +22 -3
  73. package/dist/sveltekit/content-routes.d.ts +135 -1
  74. package/dist/sveltekit/content-routes.js +351 -3
  75. package/dist/sveltekit/tidy-prompt.d.ts +11 -0
  76. package/dist/sveltekit/tidy-prompt.js +118 -0
  77. package/package.json +11 -2
  78. package/src/lib/components/CairnAdmin.svelte +3 -0
  79. package/src/lib/components/CairnTidySettings.svelte +553 -0
  80. package/src/lib/components/EditPage.svelte +371 -2
  81. package/src/lib/components/MarkdownEditor.svelte +168 -1
  82. package/src/lib/components/TidyReview.svelte +463 -0
  83. package/src/lib/components/cairn-admin.css +25 -0
  84. package/src/lib/components/editor-tidy.ts +241 -0
  85. package/src/lib/components/index.ts +1 -0
  86. package/src/lib/components/markdown-directives.ts +35 -0
  87. package/src/lib/components/objective-errors.ts +155 -0
  88. package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  89. package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  90. package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  91. package/src/lib/components/spellcheck-worker.ts +279 -0
  92. package/src/lib/components/spellcheck.ts +693 -0
  93. package/src/lib/components/tidy-categorize.ts +460 -0
  94. package/src/lib/components/tidy-diff.ts +196 -0
  95. package/src/lib/components/tidy-validate.ts +202 -0
  96. package/src/lib/content/compose.ts +11 -1
  97. package/src/lib/content/site-dictionary.ts +84 -0
  98. package/src/lib/content/types.ts +25 -0
  99. package/src/lib/doctor/checks-local.ts +59 -5
  100. package/src/lib/doctor/index.ts +2 -0
  101. package/src/lib/log/events.ts +7 -1
  102. package/src/lib/nav/site-config.ts +197 -0
  103. package/src/lib/sveltekit/admin-dispatch.ts +7 -3
  104. package/src/lib/sveltekit/cairn-admin.ts +32 -4
  105. package/src/lib/sveltekit/content-routes.ts +504 -4
  106. package/src/lib/sveltekit/tidy-prompt.ts +153 -0
@@ -9,196 +9,121 @@ This is not a nested HTML form; Insert calls a callback. The dialog owns the hea
9
9
  group breadcrumb and the Back control) and, in the two-pane case, the preview pane; this component
10
10
  binds out its live `values` and `incomplete` so the dialog can render that preview.
11
11
  -->
12
- <script lang="ts">
13
- import { untrack } from 'svelte';
14
- import { previewValues, type ComponentDef, type ComponentValues } from '../render/registry.js';
15
- import { buildComponentInsert } from '../render/component-insert.js';
16
- import type { IconSet } from '../render/glyph.js';
17
- import IconPicker from './IconPicker.svelte';
18
-
19
- interface Props {
20
- def: ComponentDef;
21
- icons?: IconSet;
22
- /** Called with the serialized markdown when the form validates. */
23
- onInsert: (markdown: string) => void;
24
- /** The live working values, bound out so a host (the dialog) can render a preview from them. */
25
- values?: ComponentValues;
26
- /** True while a required attribute or slot is still empty, bound out so the host's preview can
27
- * show the incomplete state and the host can mirror the disabled Insert. */
28
- incomplete?: boolean;
29
- /** Seed the working values from these instead of the schema's preview sample. The dialog passes
30
- * it in edit mode to re-open a placed component into its own values; the catalog insert path
31
- * leaves it unset and keeps the previewValues seed. */
32
- initial?: ComponentValues;
33
- /** The submit button's label. The dialog passes 'Update' in edit mode; the insert path keeps
34
- * the default. */
35
- submitLabel?: string;
36
- }
37
-
38
- let {
39
- def,
40
- icons,
41
- onInsert,
42
- values = $bindable(),
43
- incomplete = $bindable(),
44
- initial,
45
- submitLabel = 'Insert',
46
- }: Props = $props();
47
-
48
- // Working values, seeded once from `initial` in edit mode, otherwise from the schema and any
49
- // declared preview sample. $state makes the nested records deeply reactive. untrack marks the
50
- // seed as a deliberate one-time read, not a reactive miss. previewValues falls back to
51
- // emptyValues when no preview.
52
- let working = $state(untrack(() => initial ?? previewValues(def)));
53
- // Mirror the working values out to the bindable prop so the dialog's preview reads them live.
54
- $effect(() => {
55
- values = working;
56
- });
57
-
58
- const attributes = $derived(def.attributes ?? []);
59
- // Non-repeatable slots render here; the repeatable list is handled separately.
60
- const flatSlots = $derived((def.slots ?? []).filter((s) => s.kind !== 'repeatable'));
61
- const repeatableSlots = $derived((def.slots ?? []).filter((s) => s.kind === 'repeatable'));
62
-
63
- // The live $state proxy array for a repeatable slot, so push/splice stay reactive.
64
- function slotItems(name: string): string[] {
65
- const v = working.slots[name];
66
- return Array.isArray(v) ? v : [];
12
+ <script lang="ts">import { untrack } from "svelte";
13
+ import { previewValues } from "../render/registry.js";
14
+ import { buildComponentInsert } from "../render/component-insert.js";
15
+ import IconPicker from "./IconPicker.svelte";
16
+ let {
17
+ def,
18
+ icons,
19
+ onInsert,
20
+ values = $bindable(),
21
+ incomplete = $bindable(),
22
+ initial,
23
+ submitLabel = "Insert"
24
+ } = $props();
25
+ let working = $state(untrack(() => initial ?? previewValues(def)));
26
+ $effect(() => {
27
+ values = working;
28
+ });
29
+ const attributes = $derived(def.attributes ?? []);
30
+ const flatSlots = $derived((def.slots ?? []).filter((s) => s.kind !== "repeatable"));
31
+ const repeatableSlots = $derived((def.slots ?? []).filter((s) => s.kind === "repeatable"));
32
+ function slotItems(name) {
33
+ const v = working.slots[name];
34
+ return Array.isArray(v) ? v : [];
35
+ }
36
+ let nextId = 0;
37
+ const itemIds = $state(
38
+ untrack(
39
+ () => Object.fromEntries(
40
+ (def.slots ?? []).filter((s) => s.kind === "repeatable").map((s) => {
41
+ const seeded = working.slots[s.name];
42
+ const count = Array.isArray(seeded) ? seeded.length : 0;
43
+ return [s.name, Array.from({ length: count }, () => nextId++)];
44
+ })
45
+ )
46
+ )
47
+ );
48
+ function slotIds(name) {
49
+ return itemIds[name] ?? [];
50
+ }
51
+ function addItem(name) {
52
+ slotItems(name).push("");
53
+ slotIds(name).push(nextId++);
54
+ }
55
+ function removeItem(name, index) {
56
+ slotItems(name).splice(index, 1);
57
+ slotIds(name).splice(index, 1);
58
+ }
59
+ function rowLabel(slot, value, index) {
60
+ const fallback = `${slot.label} ${index + 1}`;
61
+ if (!slot.itemLabel) return fallback;
62
+ const key = slot.itemFields?.[0]?.key ?? "text";
63
+ const derived = slot.itemLabel({ [key]: value }, index);
64
+ return derived && derived.trim() ? derived : fallback;
65
+ }
66
+ function asString(key) {
67
+ const v = working.attributes[key];
68
+ return typeof v === "string" ? v : "";
69
+ }
70
+ function asBool(key) {
71
+ return working.attributes[key] === true;
72
+ }
73
+ function slotString(name) {
74
+ const v = working.slots[name];
75
+ return typeof v === "string" ? v : "";
76
+ }
77
+ const incompleteState = $derived.by(() => {
78
+ for (const field of attributes) {
79
+ if (!field.required || field.type === "boolean") continue;
80
+ if (asString(field.key) === "") return true;
67
81
  }
68
-
69
- // Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
70
- // identity instead of index. A mid-list removal then drops the right DOM node and the focused
71
- // item follows the data. Ids come from a monotonic module-local counter, never Math.random or
72
- // Date.now. The value arrays in working.slots stay the canonical string lists serializeComponent
73
- // reads, so the emitted markdown is unchanged. The preview seed can fill a repeatable slot, so
74
- // each seeded item needs a parallel id from the start.
75
- let nextId = 0;
76
- const itemIds = $state<Record<string, number[]>>(
77
- untrack(() =>
78
- Object.fromEntries(
79
- (def.slots ?? [])
80
- .filter((s) => s.kind === 'repeatable')
81
- .map((s) => {
82
- const seeded = working.slots[s.name];
83
- const count = Array.isArray(seeded) ? seeded.length : 0;
84
- return [s.name, Array.from({ length: count }, () => nextId++)];
85
- }),
86
- ),
87
- ),
88
- );
89
-
90
- // previewValues and the itemIds seed both cover every repeatable slot, so this read always hits.
91
- function slotIds(name: string): number[] {
92
- return itemIds[name] ?? [];
82
+ for (const slot of def.slots ?? []) {
83
+ if (!slot.required) continue;
84
+ const v = working.slots[slot.name];
85
+ const filled = Array.isArray(v) ? v.some((i) => i !== "") : typeof v === "string" && v !== "";
86
+ if (!filled) return true;
93
87
  }
94
-
95
- function addItem(name: string): void {
96
- slotItems(name).push('');
97
- slotIds(name).push(nextId++);
98
- }
99
-
100
- function removeItem(name: string, index: number): void {
101
- slotItems(name).splice(index, 1);
102
- slotIds(name).splice(index, 1);
103
- }
104
-
105
- // The row label for a repeatable item: the slot's itemLabel over the item's values and index,
106
- // falling back to `${label} ${i + 1}` when it returns nothing. v1 repeatable items hold a single
107
- // string under the first item field's key, so the item record passed to itemLabel carries it.
108
- function rowLabel(slot: (typeof repeatableSlots)[number], value: string, index: number): string {
109
- const fallback = `${slot.label} ${index + 1}`;
110
- if (!slot.itemLabel) return fallback;
111
- const key = slot.itemFields?.[0]?.key ?? 'text';
112
- const derived = slot.itemLabel({ [key]: value }, index);
113
- return derived && derived.trim() ? derived : fallback;
114
- }
115
-
116
- // Typed accessors over the unions so explicit value targets stay sound.
117
- function asString(key: string): string {
118
- const v = working.attributes[key];
119
- return typeof v === 'string' ? v : '';
120
- }
121
- function asBool(key: string): boolean {
122
- return working.attributes[key] === true;
123
- }
124
- function slotString(name: string): string {
125
- const v = working.slots[name];
126
- return typeof v === 'string' ? v : '';
127
- }
128
-
129
- // A required attribute is unmet only for a text/select/icon field left empty; a boolean is always
130
- // met (its false is a real choice). A required slot is unmet when its string is empty or its
131
- // repeatable list has no non-empty item. This drives the asterisk-marked fields, the disabled
132
- // Insert, and (through the bound `incomplete`) the dialog's incomplete preview state.
133
- const incompleteState = $derived.by(() => {
134
- for (const field of attributes) {
135
- if (!field.required || field.type === 'boolean') continue;
136
- if (asString(field.key) === '') return true;
88
+ return false;
89
+ });
90
+ $effect(() => {
91
+ incomplete = incompleteState;
92
+ });
93
+ let submitErrors = $state({});
94
+ let touched = $state({});
95
+ function markTouched(key) {
96
+ touched[key] = true;
97
+ }
98
+ const errors = $derived.by(() => {
99
+ const out = {};
100
+ for (const field of attributes) {
101
+ if (field.required && field.type !== "boolean" && touched[field.key] && asString(field.key) === "") {
102
+ out[field.key] = `${field.label} is required.`;
137
103
  }
138
- for (const slot of def.slots ?? []) {
139
- if (!slot.required) continue;
140
- const v = working.slots[slot.name];
141
- const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
142
- if (!filled) return true;
143
- }
144
- return false;
145
- });
146
- $effect(() => {
147
- incomplete = incompleteState;
148
- });
149
-
150
- // Field-keyed validation errors from the last submit (pattern, validate, select-domain), keyed by
151
- // attribute key or slot name.
152
- let submitErrors = $state<Record<string, string>>({});
153
-
154
- // Fields the editor has touched, so a required-empty error shows after interaction rather than on
155
- // a fresh open. Keyed by attribute key or slot name.
156
- let touched = $state<Record<string, boolean>>({});
157
- function markTouched(key: string): void {
158
- touched[key] = true;
159
104
  }
160
-
161
- // The visible field errors. Only the required-empty message ("{label} is required.") shows live,
162
- // for a touched-and-empty required field; pattern and validate errors surface on submit. Both are
163
- // merged here, the submit errors last so a pattern or validate message wins. Insert stays disabled
164
- // while incompleteState holds, so a required-empty field never serializes; this surfaces the why
165
- // next to the field meanwhile.
166
- const errors = $derived.by(() => {
167
- const out: Record<string, string> = {};
168
- for (const field of attributes) {
169
- if (field.required && field.type !== 'boolean' && touched[field.key] && asString(field.key) === '') {
170
- out[field.key] = `${field.label} is required.`;
171
- }
172
- }
173
- for (const slot of def.slots ?? []) {
174
- if (!slot.required) continue;
175
- const v = working.slots[slot.name];
176
- const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
177
- if (touched[slot.name] && !filled) out[slot.name] = `${slot.label} is required.`;
178
- }
179
- return { ...out, ...submitErrors };
180
- });
181
-
182
- // The form container. Once it is bound, focus its first focusable control so the editor types
183
- // straight into the form. The effect tracks formEl so it runs when the node lands; the focus call
184
- // is untracked so a later value change does not steal focus back to the first field.
185
- let formEl = $state<HTMLElement | null>(null);
186
- $effect(() => {
187
- if (!formEl) return;
188
- untrack(() => formEl!.querySelector<HTMLElement>('input, select, textarea')?.focus());
189
- });
190
-
191
- // Serialize and validate through the pure helper. On success clear errors and emit the markdown;
192
- // on failure keep the field-keyed errors so each field can show its message and insert nothing.
193
- async function submit() {
194
- const result = await buildComponentInsert(def, working);
195
- if (result.ok) {
196
- submitErrors = {};
197
- onInsert(result.markdown);
198
- } else {
199
- submitErrors = result.errors;
200
- }
105
+ for (const slot of def.slots ?? []) {
106
+ if (!slot.required) continue;
107
+ const v = working.slots[slot.name];
108
+ const filled = Array.isArray(v) ? v.some((i) => i !== "") : typeof v === "string" && v !== "";
109
+ if (touched[slot.name] && !filled) out[slot.name] = `${slot.label} is required.`;
110
+ }
111
+ return { ...out, ...submitErrors };
112
+ });
113
+ let formEl = $state(null);
114
+ $effect(() => {
115
+ if (!formEl) return;
116
+ untrack(() => formEl.querySelector("input, select, textarea")?.focus());
117
+ });
118
+ async function submit() {
119
+ const result = await buildComponentInsert(def, working);
120
+ if (result.ok) {
121
+ submitErrors = {};
122
+ onInsert(result.markdown);
123
+ } else {
124
+ submitErrors = result.errors;
201
125
  }
126
+ }
202
127
  </script>
203
128
 
204
129
  <div class="flex flex-col gap-3" bind:this={formEl}>