@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
@@ -19,142 +19,78 @@ The media-type facet (Images, Documents) is a designed-in seam: it renders only
19
19
  holds more than one distinct top-level content type, so the structure is present without dead UI
20
20
  while a site stores images only.
21
21
  -->
22
- <script module lang="ts">
23
- // The picker's library entry is the shared node-safe projection (../media/library-entry), not a
24
- // type from editor-media.ts: importing that module would pull CodeMirror into a bundle, which the
25
- // editor-boundary test bars. Re-exported so the insert popover keeps importing it from here.
26
- import type { MediaLibraryEntry } from '../media/library-entry.js';
27
- export type { MediaLibraryEntry };
28
-
29
- /** The picked asset the picker emits to its host: the library entry, its media: reference token,
30
- * and the manifest alt to prefill the placement. */
31
- export interface MediaSelection {
32
- /** The chosen library entry. */
33
- entry: MediaLibraryEntry;
34
- /** The media: reference token (`media:<slug>.<hash>`) to commit at the caret. */
35
- ref: string;
36
- /** The asset's manifest alt, prefilling the placement; empty means the placement needs alt. */
37
- alt: string;
22
+ <script module lang="ts"></script>
23
+
24
+ <script lang="ts">import { mediaToken } from "../media/reference.js";
25
+ import { publicPath } from "../media/naming.js";
26
+ let { library, onselect } = $props();
27
+ const uid = $props.id();
28
+ const idBase = `cairn-mp-${uid}`;
29
+ const listboxId = `${idBase}-listbox`;
30
+ let query = $state("");
31
+ let typeFilter = $state("all");
32
+ let activeIndex = $state(-1);
33
+ const entries = $derived(Object.values(library));
34
+ const distinctTypes = $derived.by(() => {
35
+ const seen = [];
36
+ for (const e of entries) {
37
+ const top = e.contentType.split("/")[0] ?? "";
38
+ if (top && !seen.includes(top)) seen.push(top);
38
39
  }
39
- </script>
40
-
41
- <script lang="ts">
42
- import { mediaToken } from '../media/reference.js';
43
- // The bare delivery path under transformations: false (the same path the Task 3 source chip uses).
44
- // SEAM: when transformations are on, the row thumbnail should request the `thumb` preset URL
45
- // instead of the bare path; that is a later transformations-on refinement.
46
- import { publicPath } from '../media/naming.js';
47
-
48
- interface Props {
49
- /** The committed media library projection, keyed by the 16-hex content hash. */
50
- library: Record<string, MediaLibraryEntry>;
51
- /** Emit the chosen asset to the host: the entry, its media: reference, and the manifest alt. */
52
- onselect: (selection: MediaSelection) => void;
53
- }
54
-
55
- let { library, onselect }: Props = $props();
56
-
57
- // A stable id base so the listbox and each option carry unique ids the combobox can point at.
58
- // $props.id() is Svelte's deterministic, hydration-stable id source (the same value SSR and the
59
- // client), unlike a Math.random() base that would mismatch across hydration. It must initialize a
60
- // top-level variable directly, so the prefix is composed in a second declaration.
61
- const uid = $props.id();
62
- const idBase = `cairn-mp-${uid}`;
63
- const listboxId = `${idBase}-listbox`;
64
-
65
- let query = $state('');
66
- // The media-type facet selection: 'all' or a top-level content type ('image', 'application').
67
- let typeFilter = $state<string>('all');
68
- // The index of the active option within the filtered list, or -1 for none active yet.
69
- let activeIndex = $state(-1);
70
-
71
- const entries = $derived(Object.values(library));
72
-
73
- // The distinct top-level content types in the library, in first-seen order. The facet is a seam:
74
- // it renders only when more than one distinct type exists, so a site storing images only sees no
75
- // dead UI. ('image/webp' and 'image/png' both fold to 'image'.)
76
- const distinctTypes = $derived.by(() => {
77
- const seen: string[] = [];
78
- for (const e of entries) {
79
- const top = e.contentType.split('/')[0] ?? '';
80
- if (top && !seen.includes(top)) seen.push(top);
81
- }
82
- return seen;
40
+ return seen;
41
+ });
42
+ const showFacet = $derived(distinctTypes.length > 1);
43
+ function typeLabel(top) {
44
+ if (top === "image") return "Images";
45
+ if (top === "application") return "Documents";
46
+ return top.charAt(0).toUpperCase() + top.slice(1);
47
+ }
48
+ const filtered = $derived.by(() => {
49
+ const q = query.trim().toLowerCase();
50
+ return entries.filter((e) => {
51
+ if (typeFilter !== "all" && (e.contentType.split("/")[0] ?? "") !== typeFilter) return false;
52
+ if (!q) return true;
53
+ return e.displayName.toLowerCase().includes(q) || e.alt.toLowerCase().includes(q);
83
54
  });
84
- const showFacet = $derived(distinctTypes.length > 1);
85
-
86
- /** The label for a top-level content type, for the facet chips. */
87
- function typeLabel(top: string): string {
88
- if (top === 'image') return 'Images';
89
- if (top === 'application') return 'Documents';
90
- return top.charAt(0).toUpperCase() + top.slice(1);
91
- }
92
-
93
- // The filtered, displayed options: the type facet first, then a case-insensitive substring match
94
- // across the display name and the alt. Order follows the library's insertion order.
95
- const filtered = $derived.by(() => {
96
- const q = query.trim().toLowerCase();
97
- return entries.filter((e) => {
98
- if (typeFilter !== 'all' && (e.contentType.split('/')[0] ?? '') !== typeFilter) return false;
99
- if (!q) return true;
100
- return (
101
- e.displayName.toLowerCase().includes(q) || e.alt.toLowerCase().includes(q)
102
- );
103
- });
104
- });
105
-
106
- // Keep the active index in range as the filtered list narrows; a filter that drops the active row
107
- // clears the active descendant rather than pointing at a gone option.
108
- $effect(() => {
109
- if (activeIndex >= filtered.length) activeIndex = filtered.length === 0 ? -1 : filtered.length - 1;
110
- });
111
-
112
- /** The per-row option id, used by aria-activedescendant and the live narration. */
113
- function optionId(i: number): string {
114
- return `${idBase}-opt-${i}`;
115
- }
116
-
117
- const activeEntry = $derived(activeIndex >= 0 ? (filtered[activeIndex] ?? null) : null);
118
- const activeDescendant = $derived(activeEntry ? optionId(activeIndex) : undefined);
119
-
120
- // The active-row narration text, kept in its own live region so it never clobbers the count.
121
- const activeNarration = $derived(
122
- activeEntry
123
- ? `${activeEntry.displayName}${activeEntry.alt.trim() === '' ? ', needs alt text' : ''}, ${activeIndex + 1} of ${filtered.length}`
124
- : '',
125
- );
126
-
127
- /** Build a selection from a library entry: its media: token and its manifest alt. */
128
- function select(entry: MediaLibraryEntry) {
129
- onselect({ entry, ref: mediaToken({ slug: entry.slug, hash: entry.hash }), alt: entry.alt });
130
- }
131
-
132
- function onKeydown(e: KeyboardEvent) {
133
- if (e.key === 'ArrowDown') {
134
- e.preventDefault();
135
- if (filtered.length === 0) return;
136
- // Clamp at the last option (a deliberate non-wrap, fine per the task).
137
- activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
138
- } else if (e.key === 'ArrowUp') {
139
- e.preventDefault();
140
- if (filtered.length === 0) return;
141
- activeIndex = Math.max(activeIndex - 1, 0);
142
- } else if (e.key === 'Home') {
143
- if (filtered.length === 0) return;
144
- e.preventDefault();
145
- activeIndex = 0;
146
- } else if (e.key === 'End') {
147
- if (filtered.length === 0) return;
55
+ });
56
+ $effect(() => {
57
+ if (activeIndex >= filtered.length) activeIndex = filtered.length === 0 ? -1 : filtered.length - 1;
58
+ });
59
+ function optionId(i) {
60
+ return `${idBase}-opt-${i}`;
61
+ }
62
+ const activeEntry = $derived(activeIndex >= 0 ? filtered[activeIndex] ?? null : null);
63
+ const activeDescendant = $derived(activeEntry ? optionId(activeIndex) : void 0);
64
+ const activeNarration = $derived(
65
+ activeEntry ? `${activeEntry.displayName}${activeEntry.alt.trim() === "" ? ", needs alt text" : ""}, ${activeIndex + 1} of ${filtered.length}` : ""
66
+ );
67
+ function select(entry) {
68
+ onselect({ entry, ref: mediaToken({ slug: entry.slug, hash: entry.hash }), alt: entry.alt });
69
+ }
70
+ function onKeydown(e) {
71
+ if (e.key === "ArrowDown") {
72
+ e.preventDefault();
73
+ if (filtered.length === 0) return;
74
+ activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
75
+ } else if (e.key === "ArrowUp") {
76
+ e.preventDefault();
77
+ if (filtered.length === 0) return;
78
+ activeIndex = Math.max(activeIndex - 1, 0);
79
+ } else if (e.key === "Home") {
80
+ if (filtered.length === 0) return;
81
+ e.preventDefault();
82
+ activeIndex = 0;
83
+ } else if (e.key === "End") {
84
+ if (filtered.length === 0) return;
85
+ e.preventDefault();
86
+ activeIndex = filtered.length - 1;
87
+ } else if (e.key === "Enter") {
88
+ if (activeEntry) {
148
89
  e.preventDefault();
149
- activeIndex = filtered.length - 1;
150
- } else if (e.key === 'Enter') {
151
- if (activeEntry) {
152
- e.preventDefault();
153
- select(activeEntry);
154
- }
90
+ select(activeEntry);
155
91
  }
156
- // Escape is handled by the host popover (Task 6); let it bubble.
157
92
  }
93
+ }
158
94
  </script>
159
95
 
160
96
  <div class="flex flex-col gap-3">
@@ -6,87 +6,55 @@ svelte-sortable-list (mouse, and keyboard with Space to lift, arrows to move, Sp
6
6
  depth comes from the Indent and Outdent buttons, capped at the menu's maxDepth. The engine
7
7
  validates on save.
8
8
  -->
9
- <script lang="ts">
10
- import { untrack } from 'svelte';
11
- import CsrfField from './CsrfField.svelte';
12
- import { SortableList, sortItems } from '@rodrigodagostino/svelte-sortable-list';
13
- import type { SortableList as SortableListNS } from '@rodrigodagostino/svelte-sortable-list';
14
- import '@rodrigodagostino/svelte-sortable-list/styles.css';
15
- import type { NavLoadData } from '../sveltekit/nav-routes.js';
16
- import type { NavNode } from '../nav/site-config.js';
17
-
18
- interface Props {
19
- /** The nav load's data: the menu meta, the current tree, page options, and flags. */
20
- data: NavLoadData;
21
- }
22
-
23
- let { data }: Props = $props();
24
-
25
- // A flat, ordered working model is simpler to reorder than a recursive one: each row carries an
26
- // explicit depth, and the nested tree is rebuilt from order plus depth only at submit time.
27
- interface Row {
28
- id: string;
29
- depth: number;
30
- label: string;
31
- url: string;
9
+ <script lang="ts">import { untrack } from "svelte";
10
+ import CsrfField from "./CsrfField.svelte";
11
+ import { SortableList, sortItems } from "@rodrigodagostino/svelte-sortable-list";
12
+ import "@rodrigodagostino/svelte-sortable-list/styles.css";
13
+ let { data } = $props();
14
+ let nextId = 1;
15
+ function flatten(nodes, depth, out) {
16
+ for (const n of nodes) {
17
+ out.push({ id: `row-${nextId++}`, depth, label: n.label, url: n.url ?? "" });
18
+ if (n.children?.length) flatten(n.children, depth + 1, out);
32
19
  }
33
-
34
- let nextId = 1;
35
- function flatten(nodes: NavNode[], depth: number, out: Row[]): Row[] {
36
- for (const n of nodes) {
37
- out.push({ id: `row-${nextId++}`, depth, label: n.label, url: n.url ?? '' });
38
- if (n.children?.length) flatten(n.children, depth + 1, out);
39
- }
40
- return out;
41
- }
42
-
43
- // untrack here is not for runtime behavior -- $state runs its initializer once regardless.
44
- // It suppresses the Svelte compiler warning that `data` (a prop) is referenced outside a
45
- // reactive context. The component is always remounted on save/error (both redirect), so
46
- // a one-time snapshot of the initial tree is correct.
47
- let rows = $state<Row[]>(untrack(() => flatten(data.tree, 0, [])));
48
- // depth is 0-based internally; maxDepth in the config is 1-based (1 = flat, 2 = one nesting level)
49
- const maxDepthIndex = $derived(data.menu.maxDepth - 1);
50
-
51
- // Rebuild the nested tree from the flat rows by depth, then serialize for the hidden field.
52
- function toTree(list: Row[]): NavNode[] {
53
- const root: NavNode[] = [];
54
- const stack: { depth: number; node: NavNode }[] = [];
55
- for (const r of list) {
56
- const node: NavNode = { label: r.label.trim() };
57
- if (r.url.trim()) node.url = r.url.trim();
58
- while (stack.length && stack[stack.length - 1].depth >= r.depth) stack.pop();
59
- if (stack.length) (stack[stack.length - 1].node.children ??= []).push(node);
60
- else root.push(node);
61
- stack.push({ depth: r.depth, node });
62
- }
63
- return root;
20
+ return out;
21
+ }
22
+ let rows = $state(untrack(() => flatten(data.tree, 0, [])));
23
+ const maxDepthIndex = $derived(data.menu.maxDepth - 1);
24
+ function toTree(list) {
25
+ const root = [];
26
+ const stack = [];
27
+ for (const r of list) {
28
+ const node = { label: r.label.trim() };
29
+ if (r.url.trim()) node.url = r.url.trim();
30
+ while (stack.length && stack[stack.length - 1].depth >= r.depth) stack.pop();
31
+ if (stack.length) (stack[stack.length - 1].node.children ??= []).push(node);
32
+ else root.push(node);
33
+ stack.push({ depth: r.depth, node });
64
34
  }
65
-
66
- const treeJson = $derived(JSON.stringify(toTree(rows)));
67
-
68
- function addRow() {
69
- rows = [...rows, { id: `row-${nextId++}`, depth: 0, label: 'New item', url: '' }];
70
- }
71
- function removeRow(id: string) {
72
- rows = rows.filter((r) => r.id !== id);
73
- }
74
- function indent(i: number) {
75
- // A row may nest at most one level deeper than the row above it, and never past the cap.
76
- if (i === 0) return;
77
- const ceiling = Math.min(rows[i - 1].depth + 1, maxDepthIndex);
78
- if (rows[i].depth < ceiling) rows[i].depth += 1;
79
- }
80
- function outdent(i: number) {
81
- if (rows[i].depth > 0) rows[i].depth -= 1;
82
- }
83
-
84
- function handleDragEnd(e: SortableListNS.RootEvents['ondragend']) {
85
- const { draggedItemIndex, targetItemIndex, isCanceled } = e;
86
- if (!isCanceled && typeof targetItemIndex === 'number' && draggedItemIndex !== targetItemIndex) {
87
- rows = sortItems(rows, draggedItemIndex, targetItemIndex);
88
- }
35
+ return root;
36
+ }
37
+ const treeJson = $derived(JSON.stringify(toTree(rows)));
38
+ function addRow() {
39
+ rows = [...rows, { id: `row-${nextId++}`, depth: 0, label: "New item", url: "" }];
40
+ }
41
+ function removeRow(id) {
42
+ rows = rows.filter((r) => r.id !== id);
43
+ }
44
+ function indent(i) {
45
+ if (i === 0) return;
46
+ const ceiling = Math.min(rows[i - 1].depth + 1, maxDepthIndex);
47
+ if (rows[i].depth < ceiling) rows[i].depth += 1;
48
+ }
49
+ function outdent(i) {
50
+ if (rows[i].depth > 0) rows[i].depth -= 1;
51
+ }
52
+ function handleDragEnd(e) {
53
+ const { draggedItemIndex, targetItemIndex, isCanceled } = e;
54
+ if (!isCanceled && typeof targetItemIndex === "number" && draggedItemIndex !== targetItemIndex) {
55
+ rows = sortItems(rows, draggedItemIndex, targetItemIndex);
89
56
  }
57
+ }
90
58
  </script>
91
59
 
92
60
  <h1 class="mb-6 text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.menu.label}</h1>
@@ -5,49 +5,22 @@ moves the entry and rewrites every inbound cairn link in one commit, so no inter
5
5
  dated post keeps its date; only the slug changes. Built on a native <dialog>, following the
6
6
  DeleteDialog a11y conventions.
7
7
  -->
8
- <script lang="ts">
9
- import CsrfField from './CsrfField.svelte';
10
-
11
- interface Props {
12
- /** The concept this entry belongs to, e.g. "posts". Posted with the confirm. */
13
- conceptId: string;
14
- /** The entry id within its concept. Posted with the confirm. */
15
- id: string;
16
- /** A human label for the concept, e.g. "Post", used in the prompts. */
17
- label: string;
18
- /** The current slug, prefilled into the input. */
19
- slug: string;
20
- /** Render the built-in Change URL trigger. False mounts only the dialog, for a host that
21
- * supplies its own trigger and opens the dialog through the exported open(). */
22
- trigger?: boolean;
23
- /** Called when the rename form submits, before the document navigates. The edit page uses it
24
- * to stand down its leave guard while the POST is in flight. */
25
- onsubmitting?: () => void;
26
- }
27
-
28
- let { conceptId, id, label, slug, trigger = true, onsubmitting }: Props = $props();
29
-
30
- let dialog = $state<HTMLDialogElement | null>(null);
31
- let slugInput = $state<HTMLInputElement | null>(null);
32
- // Seeded on open() rather than from the prop at declaration, so the input prefills with the
33
- // current slug each time the dialog opens without capturing only the initial prop value.
34
- let nextSlug = $state('');
35
-
36
- /** Open the dialog with a fresh prefill. Exported so a trigger={false} host can drive it. */
37
- export function open() {
38
- nextSlug = slug;
39
- dialog?.showModal();
40
- // showModal() lands focus on the first focusable element (the header Close button), so move
41
- // it to the slug input the dialog exists for, and select the prefill so the author can replace
42
- // it in one keystroke (WCAG 2.4.3). A microtask defers past the dialog's own focus handling.
43
- queueMicrotask(() => {
44
- slugInput?.focus();
45
- slugInput?.select();
46
- });
47
- }
48
- function close() {
49
- dialog?.close();
50
- }
8
+ <script lang="ts">import CsrfField from "./CsrfField.svelte";
9
+ let { conceptId, id, label, slug, trigger = true, onsubmitting } = $props();
10
+ let dialog = $state(null);
11
+ let slugInput = $state(null);
12
+ let nextSlug = $state("");
13
+ export function open() {
14
+ nextSlug = slug;
15
+ dialog?.showModal();
16
+ queueMicrotask(() => {
17
+ slugInput?.focus();
18
+ slugInput?.select();
19
+ });
20
+ }
21
+ function close() {
22
+ dialog?.close();
23
+ }
51
24
  </script>
52
25
 
53
26
  {#if trigger}
@@ -7,19 +7,15 @@ MarkdownHelpDialog recipe; the host (the edit page) drives it through the export
7
7
  it on Ctrl+/, so the component renders no trigger of its own. Esc dismisses through the dialog's
8
8
  native behavior.
9
9
  -->
10
- <script lang="ts">
11
- import { shortcutsClosingLine } from './editor-shortcuts.js';
12
- import ShortcutsGrid from './ShortcutsGrid.svelte';
13
-
14
- let dialog = $state<HTMLDialogElement | null>(null);
15
-
16
- /** Open the shortcuts sheet. The trigger lives in the host (the edit page's Ctrl+/ handler). */
17
- export function open() {
18
- dialog?.showModal();
19
- }
20
- function close() {
21
- dialog?.close();
22
- }
10
+ <script lang="ts">import { shortcutsClosingLine } from "./editor-shortcuts.js";
11
+ import ShortcutsGrid from "./ShortcutsGrid.svelte";
12
+ let dialog = $state(null);
13
+ export function open() {
14
+ dialog?.showModal();
15
+ }
16
+ function close() {
17
+ dialog?.close();
18
+ }
23
19
  </script>
24
20
 
25
21
  <dialog class="modal" aria-labelledby="cairn-shortcuts-title" bind:this={dialog}>
@@ -4,8 +4,7 @@ The two-column shortcut grid, the shared body of both discoverability sheets (Sh
4
4
  the Markdown help dialog). Each row pairs a label with its chord, read from the single
5
5
  editor-shortcuts source. The host wraps it with its own heading and any closing line.
6
6
  -->
7
- <script lang="ts">
8
- import { editorShortcuts } from './editor-shortcuts.js';
7
+ <script lang="ts">import { editorShortcuts } from "./editor-shortcuts.js";
9
8
  </script>
10
9
 
11
10
  <div class="grid grid-cols-1 gap-x-8 gap-y-1 text-sm sm:grid-cols-2">