@glw907/cairn-cms 0.5.0 → 0.5.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/dist/adapter.d.ts +24 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/auth/capabilities.d.ts +7 -0
- package/dist/auth/capabilities.d.ts.map +1 -0
- package/dist/auth/capabilities.js +26 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/components/AdminLayout.svelte +72 -16
- package/dist/components/AdminLayout.svelte.d.ts +9 -0
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/CollectionList.svelte +96 -0
- package/dist/components/CollectionList.svelte.d.ts +8 -0
- package/dist/components/CollectionList.svelte.d.ts.map +1 -0
- package/dist/components/ComponentPalette.svelte +34 -0
- package/dist/components/ComponentPalette.svelte.d.ts +9 -0
- package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +66 -28
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/NavTree.svelte +128 -0
- package/dist/components/NavTree.svelte.d.ts +8 -0
- package/dist/components/NavTree.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -1
- package/dist/editor.d.ts +25 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +20 -0
- package/dist/frontmatter.d.ts +3 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/nav.d.ts +58 -0
- package/dist/nav.d.ts.map +1 -0
- package/dist/nav.js +86 -0
- package/dist/slug.d.ts +7 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +15 -0
- package/dist/sveltekit/index.d.ts +102 -12
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +219 -20
- package/package.json +7 -2
- package/src/lib/adapter.ts +25 -0
- package/src/lib/auth/capabilities.ts +35 -0
- package/src/lib/auth/index.ts +1 -0
- package/src/lib/components/AdminLayout.svelte +72 -16
- package/src/lib/components/CollectionList.svelte +96 -0
- package/src/lib/components/ComponentPalette.svelte +34 -0
- package/src/lib/components/EditPage.svelte +66 -28
- package/src/lib/components/NavTree.svelte +128 -0
- package/src/lib/components/index.ts +3 -1
- package/src/lib/editor.ts +38 -0
- package/src/lib/frontmatter.ts +17 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/nav.ts +117 -0
- package/src/lib/slug.ts +16 -0
- package/src/lib/sveltekit/index.ts +303 -26
- package/dist/components/AdminList.svelte +0 -33
- package/dist/components/AdminList.svelte.d.ts +0 -10
- package/dist/components/AdminList.svelte.d.ts.map +0 -1
- package/src/lib/components/AdminList.svelte +0 -33
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The insert-component palette (R10). Reads the site's component registry (R10a) and inserts a
|
|
3
|
+
// scaffolded directive snippet at the cursor via the `insert` callback. DaisyUI dropdown so it
|
|
4
|
+
// matches the Warm Stone admin theme. Shown only when the site supplies a non-empty registry; a
|
|
5
|
+
// plain-markdown site (e.g. 907.life) passes no registry and this renders nothing.
|
|
6
|
+
import type { ComponentRegistry } from '../render';
|
|
7
|
+
|
|
8
|
+
let { registry, insert }: { registry?: ComponentRegistry; insert: (template: string) => void } =
|
|
9
|
+
$props();
|
|
10
|
+
|
|
11
|
+
const defs = $derived(registry?.defs ?? []);
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#if defs.length > 0}
|
|
15
|
+
<div class="dropdown">
|
|
16
|
+
<button type="button" tabindex="0" class="btn btn-sm btn-ghost">Insert ▾</button>
|
|
17
|
+
<ul
|
|
18
|
+
class="dropdown-content menu z-10 mt-1 w-72 rounded-box border border-base-300 bg-base-100 p-2 shadow"
|
|
19
|
+
>
|
|
20
|
+
{#each defs as def (def.name)}
|
|
21
|
+
<li>
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class="flex flex-col items-start gap-0.5"
|
|
25
|
+
onclick={() => insert(def.insertTemplate)}
|
|
26
|
+
>
|
|
27
|
+
<span class="font-medium">{def.label}</span>
|
|
28
|
+
<span class="text-xs opacity-60">{def.description}</span>
|
|
29
|
+
</button>
|
|
30
|
+
</li>
|
|
31
|
+
{/each}
|
|
32
|
+
</ul>
|
|
33
|
+
</div>
|
|
34
|
+
{/if}
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// The editor: a per-field frontmatter form (driven by the adapter's `fields`)
|
|
3
|
-
// markdown editor whose preview runs the site
|
|
4
|
-
//
|
|
2
|
+
// The editor: a per-field frontmatter form (driven by the adapter's `fields`) beside a Carta
|
|
3
|
+
// markdown editor whose preview runs the site plugin set (`preview`). Content-forward layout:
|
|
4
|
+
// the editor is the prominent column, frontmatter sits in a side column (R4). A cairn control
|
|
5
|
+
// row hosts the insert-component palette (R10) and the preview toggle (R12); basic formatting
|
|
6
|
+
// stays on Carta's built-in toolbar (R11). Data comes from `editLoad` merged with the layout
|
|
7
|
+
// load (siteName); `carta-md` is a peer dependency.
|
|
5
8
|
import { onMount } from 'svelte';
|
|
6
9
|
import { Carta, MarkdownEditor } from 'carta-md';
|
|
7
10
|
import 'carta-md/default.css';
|
|
8
11
|
import { previewCartaOptions, type PreviewPlugins } from '../carta';
|
|
9
12
|
import type { CairnField } from '../adapter';
|
|
13
|
+
import type { ComponentRegistry } from '../render';
|
|
10
14
|
import type { EditData } from '../sveltekit';
|
|
15
|
+
import { cartaEditor } from '../editor';
|
|
16
|
+
import { dateInputValue } from '../frontmatter';
|
|
17
|
+
import ComponentPalette from './ComponentPalette.svelte';
|
|
11
18
|
|
|
12
|
-
let {
|
|
19
|
+
let {
|
|
20
|
+
data,
|
|
21
|
+
preview,
|
|
22
|
+
registry,
|
|
23
|
+
}: { data: EditData & { siteName: string }; preview: PreviewPlugins; registry?: ComponentRegistry } =
|
|
24
|
+
$props();
|
|
13
25
|
|
|
14
26
|
// Body is editable state; the Carta editor's preview runs the exact site plugin set, so it
|
|
15
27
|
// matches the live page. A hidden input carries the current value into the form.
|
|
@@ -18,14 +30,24 @@
|
|
|
18
30
|
|
|
19
31
|
// svelte-ignore state_referenced_locally (the preview plugin set is fixed for the load)
|
|
20
32
|
const carta = new Carta(previewCartaOptions(preview));
|
|
33
|
+
const editor = cartaEditor(() => carta);
|
|
21
34
|
|
|
22
|
-
// Carta's MarkdownEditor must not render on the worker (it pulls Shiki). onMount fires only
|
|
23
|
-
//
|
|
24
|
-
// This is the kit-free equivalent of the per-site route's `$app/environment` `browser` guard.
|
|
35
|
+
// Carta's MarkdownEditor must not render on the worker (it pulls Shiki). onMount fires only in
|
|
36
|
+
// the browser, so SSR renders the plain textarea and the client swaps in the editor.
|
|
25
37
|
let mounted = $state(false);
|
|
38
|
+
|
|
39
|
+
// Preview toggle (R12), persisted per user. 'split' shows the live preview beside the editor;
|
|
40
|
+
// 'tabs' foregrounds the editor full width with the preview one click away.
|
|
41
|
+
let mode = $state<'split' | 'tabs'>('split');
|
|
26
42
|
onMount(() => {
|
|
27
43
|
mounted = true;
|
|
44
|
+
const saved = localStorage.getItem('cairn-admin:preview');
|
|
45
|
+
if (saved === 'tabs' || saved === 'split') mode = saved;
|
|
28
46
|
});
|
|
47
|
+
function togglePreview() {
|
|
48
|
+
mode = mode === 'split' ? 'tabs' : 'split';
|
|
49
|
+
localStorage.setItem('cairn-admin:preview', mode);
|
|
50
|
+
}
|
|
29
51
|
|
|
30
52
|
// svelte-ignore state_referenced_locally (form defaults from the initial load)
|
|
31
53
|
const fm = data.frontmatter as Record<string, unknown>;
|
|
@@ -39,31 +61,58 @@
|
|
|
39
61
|
function fmFreeTags(key: string): string {
|
|
40
62
|
return Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String).join(', ') : '';
|
|
41
63
|
}
|
|
64
|
+
|
|
65
|
+
// Kind-aware header: a story leads with its date; a page leads with its slug/path.
|
|
66
|
+
const subtitle = $derived(
|
|
67
|
+
data.kind === 'page'
|
|
68
|
+
? `Page · ${data.path}`
|
|
69
|
+
: `${data.label} · ${dateInputValue(fm['date']) || data.path}`,
|
|
70
|
+
);
|
|
42
71
|
</script>
|
|
43
72
|
|
|
44
73
|
<svelte:head>
|
|
45
|
-
<title>Edit {data.title} · {data.siteName} CMS</title>
|
|
74
|
+
<title>{data.isNew ? `New ${data.label} entry` : `Edit ${data.title}`} · {data.siteName} CMS</title>
|
|
46
75
|
</svelte:head>
|
|
47
76
|
|
|
48
77
|
<div class="flex items-center justify-between gap-4">
|
|
49
78
|
<div>
|
|
50
|
-
<a href="/admin" class="text-sm opacity-70 hover:underline">← Back</a>
|
|
51
|
-
<h1 class="mt-1 text-2xl font-bold">{data.title}</h1>
|
|
52
|
-
<p class="text-sm opacity-60">{
|
|
79
|
+
<a href="/admin/{data.type}" class="text-sm opacity-70 hover:underline">← Back to {data.label}</a>
|
|
80
|
+
<h1 class="mt-1 text-2xl font-bold">{data.isNew ? `New ${data.label} entry` : data.title}</h1>
|
|
81
|
+
<p class="text-sm opacity-60">{subtitle}</p>
|
|
53
82
|
</div>
|
|
54
83
|
</div>
|
|
55
84
|
|
|
56
85
|
{#if data.saved}
|
|
57
|
-
<div class="alert alert-success mt-6"><span>Saved
|
|
86
|
+
<div class="alert alert-success mt-6"><span>Saved. Committed to main; the site will redeploy.</span></div>
|
|
58
87
|
{:else if data.error}
|
|
59
88
|
<div class="alert alert-error mt-6"><span>{data.error}</span></div>
|
|
60
89
|
{/if}
|
|
61
90
|
|
|
62
|
-
<form method="POST" action="/admin/save" class="mt-6 flex flex-col gap-5">
|
|
91
|
+
<form method="POST" action="/admin/save" class="mt-6 flex flex-col gap-5 lg:grid lg:grid-cols-[1fr_20rem] lg:items-start">
|
|
63
92
|
<input type="hidden" name="type" value={data.type} />
|
|
64
93
|
<input type="hidden" name="id" value={data.id} />
|
|
94
|
+
{#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
|
|
95
|
+
|
|
96
|
+
<!-- Editor column (content-forward: first and widest) -->
|
|
97
|
+
<div class="flex flex-col gap-3 lg:order-1">
|
|
98
|
+
<div class="flex items-center justify-between gap-2">
|
|
99
|
+
<ComponentPalette {registry} insert={(template) => editor.insertComponent(template)} />
|
|
100
|
+
<button type="button" class="btn btn-sm btn-ghost" onclick={togglePreview}>
|
|
101
|
+
{mode === 'split' ? 'Hide preview' : 'Show preview'}
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="rounded-box border border-base-300 bg-base-100 p-2">
|
|
105
|
+
<input type="hidden" name="body" value={body} />
|
|
106
|
+
{#if mounted}
|
|
107
|
+
<MarkdownEditor {carta} bind:value={body} {mode} />
|
|
108
|
+
{:else}
|
|
109
|
+
<textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
|
|
110
|
+
{/if}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
65
113
|
|
|
66
|
-
|
|
114
|
+
<!-- Frontmatter side column -->
|
|
115
|
+
<fieldset class="grid gap-4 rounded-box border border-base-300 bg-base-100 p-6 lg:order-2">
|
|
67
116
|
{#each data.fields as field (field.name)}
|
|
68
117
|
{#if field.type === 'text' || field.type === 'date'}
|
|
69
118
|
<label class="flex flex-col gap-1">
|
|
@@ -72,7 +121,7 @@
|
|
|
72
121
|
type={field.type === 'date' ? 'date' : 'text'}
|
|
73
122
|
name={field.name}
|
|
74
123
|
required={field.required}
|
|
75
|
-
value={fmString(field.name)}
|
|
124
|
+
value={field.type === 'date' ? dateInputValue(fm[field.name]) : fmString(field.name)}
|
|
76
125
|
class="input w-full"
|
|
77
126
|
/>
|
|
78
127
|
</label>
|
|
@@ -108,18 +157,7 @@
|
|
|
108
157
|
</label>
|
|
109
158
|
{/if}
|
|
110
159
|
{/each}
|
|
111
|
-
</fieldset>
|
|
112
|
-
|
|
113
|
-
<div class="rounded-box border border-base-300 bg-base-100 p-2">
|
|
114
|
-
<input type="hidden" name="body" value={body} />
|
|
115
|
-
{#if mounted}
|
|
116
|
-
<MarkdownEditor {carta} bind:value={body} mode="tabs" />
|
|
117
|
-
{:else}
|
|
118
|
-
<textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
|
|
119
|
-
{/if}
|
|
120
|
-
</div>
|
|
121
160
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
</div>
|
|
161
|
+
<button type="submit" class="btn btn-primary mt-2">{data.isNew ? 'Create & commit' : 'Save & commit'}</button>
|
|
162
|
+
</fieldset>
|
|
125
163
|
</form>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The navigation tree editor (Pass L). Edits a local copy of the menu tree and posts the whole
|
|
3
|
+
// tree as JSON to the `save` action. DaisyUI primitives under the Warm Stone admin theme. Drag a
|
|
4
|
+
// row up or down to reorder within its level; use Indent/Outdent to nest under the previous
|
|
5
|
+
// sibling or promote a level (capped at the menu's maxDepth). The engine validates on save.
|
|
6
|
+
import { untrack } from 'svelte';
|
|
7
|
+
import type { NavLoadData } from '../sveltekit';
|
|
8
|
+
import type { NavNode } from '../nav';
|
|
9
|
+
|
|
10
|
+
let { data }: { data: NavLoadData } = $props();
|
|
11
|
+
|
|
12
|
+
// A flat, ordered working model is far simpler to drag-edit than a recursive one: each row
|
|
13
|
+
// carries an explicit depth, and the tree is rebuilt from (order + depth) only at submit time.
|
|
14
|
+
interface Row {
|
|
15
|
+
id: number;
|
|
16
|
+
depth: number;
|
|
17
|
+
label: string;
|
|
18
|
+
url: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let nextId = 1;
|
|
22
|
+
function flatten(nodes: NavNode[], depth: number, out: Row[]): Row[] {
|
|
23
|
+
for (const n of nodes) {
|
|
24
|
+
out.push({ id: nextId++, depth, label: n.label, url: n.url ?? '' });
|
|
25
|
+
if (n.children?.length) flatten(n.children, depth + 1, out);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let rows = $state<Row[]>(untrack(() => flatten(data.tree, 0, [])));
|
|
31
|
+
const maxDepthIndex = $derived(data.menu.maxDepth - 1); // depth is 0-based here
|
|
32
|
+
|
|
33
|
+
// Rebuild the nested tree from the flat rows by depth, then serialize for the hidden field.
|
|
34
|
+
function toTree(list: Row[]): NavNode[] {
|
|
35
|
+
const root: NavNode[] = [];
|
|
36
|
+
const stack: { depth: number; node: NavNode }[] = [];
|
|
37
|
+
for (const r of list) {
|
|
38
|
+
const node: NavNode = { label: r.label.trim() };
|
|
39
|
+
if (r.url.trim()) node.url = r.url.trim();
|
|
40
|
+
while (stack.length && stack[stack.length - 1].depth >= r.depth) stack.pop();
|
|
41
|
+
if (stack.length) (stack[stack.length - 1].node.children ??= []).push(node);
|
|
42
|
+
else root.push(node);
|
|
43
|
+
stack.push({ depth: r.depth, node });
|
|
44
|
+
}
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const treeJson = $derived(JSON.stringify(toTree(rows)));
|
|
49
|
+
|
|
50
|
+
function addRow() {
|
|
51
|
+
rows = [...rows, { id: nextId++, depth: 0, label: 'New item', url: '' }];
|
|
52
|
+
}
|
|
53
|
+
function removeRow(id: number) {
|
|
54
|
+
rows = rows.filter((r) => r.id !== id);
|
|
55
|
+
}
|
|
56
|
+
function indent(i: number) {
|
|
57
|
+
// A row may nest at most one level deeper than the row above it, and never past the cap.
|
|
58
|
+
if (i === 0) return;
|
|
59
|
+
const ceiling = Math.min(rows[i - 1].depth + 1, maxDepthIndex);
|
|
60
|
+
if (rows[i].depth < ceiling) rows[i].depth += 1;
|
|
61
|
+
}
|
|
62
|
+
function outdent(i: number) {
|
|
63
|
+
if (rows[i].depth > 0) rows[i].depth -= 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let dragFrom = $state<number | null>(null);
|
|
67
|
+
function onDrop(to: number) {
|
|
68
|
+
if (dragFrom === null || dragFrom === to) return;
|
|
69
|
+
const next = [...rows];
|
|
70
|
+
const [moved] = next.splice(dragFrom, 1);
|
|
71
|
+
next.splice(to, 0, moved);
|
|
72
|
+
rows = next;
|
|
73
|
+
dragFrom = null;
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<div class="cairn-admin">
|
|
78
|
+
<div class="flex items-center justify-between">
|
|
79
|
+
<h1 class="text-xl font-semibold">{data.menu.label}</h1>
|
|
80
|
+
<button type="button" class="btn btn-sm" onclick={addRow}>Add item</button>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{#if data.saved}
|
|
84
|
+
<div class="alert alert-success mt-3">Navigation saved.</div>
|
|
85
|
+
{/if}
|
|
86
|
+
{#if data.error}
|
|
87
|
+
<div class="alert alert-error mt-3">{data.error}</div>
|
|
88
|
+
{/if}
|
|
89
|
+
|
|
90
|
+
<form method="POST" action="?/save" class="mt-4">
|
|
91
|
+
<input type="hidden" name="tree" value={treeJson} />
|
|
92
|
+
<ul class="menu w-full gap-1">
|
|
93
|
+
{#each rows as row, i (row.id)}
|
|
94
|
+
<li
|
|
95
|
+
draggable="true"
|
|
96
|
+
ondragstart={() => (dragFrom = i)}
|
|
97
|
+
ondragover={(e) => e.preventDefault()}
|
|
98
|
+
ondrop={() => onDrop(i)}
|
|
99
|
+
style={`margin-left:${row.depth * 1.5}rem`}
|
|
100
|
+
>
|
|
101
|
+
<div class="flex items-center gap-2 p-2">
|
|
102
|
+
<span class="cursor-grab opacity-40" aria-hidden="true">⠿</span>
|
|
103
|
+
<input class="input input-sm input-bordered flex-1" placeholder="Label" bind:value={row.label} />
|
|
104
|
+
<input
|
|
105
|
+
class="input input-sm input-bordered flex-1"
|
|
106
|
+
placeholder="/path or https://…"
|
|
107
|
+
list="cairn-nav-pages"
|
|
108
|
+
bind:value={row.url}
|
|
109
|
+
/>
|
|
110
|
+
<button type="button" class="btn btn-xs btn-ghost" onclick={() => outdent(i)} aria-label="Outdent">←</button>
|
|
111
|
+
<button type="button" class="btn btn-xs btn-ghost" onclick={() => indent(i)} aria-label="Indent">→</button>
|
|
112
|
+
<button type="button" class="btn btn-xs btn-ghost text-error" onclick={() => removeRow(row.id)} aria-label="Remove">×</button>
|
|
113
|
+
</div>
|
|
114
|
+
</li>
|
|
115
|
+
{/each}
|
|
116
|
+
</ul>
|
|
117
|
+
|
|
118
|
+
<datalist id="cairn-nav-pages">
|
|
119
|
+
{#each data.pages as p (p.url)}
|
|
120
|
+
<option value={p.url}>{p.label}</option>
|
|
121
|
+
{/each}
|
|
122
|
+
</datalist>
|
|
123
|
+
|
|
124
|
+
<div class="mt-4">
|
|
125
|
+
<button type="submit" class="btn btn-primary btn-sm">Save navigation</button>
|
|
126
|
+
</div>
|
|
127
|
+
</form>
|
|
128
|
+
</div>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// cairn-cms admin UI shell. Consumers import from 'cairn-cms/components'; each site's
|
|
2
2
|
// admin route `.svelte` files are one-line shims around these.
|
|
3
3
|
export { default as AdminLayout } from './AdminLayout.svelte';
|
|
4
|
-
export { default as
|
|
4
|
+
export { default as CollectionList } from './CollectionList.svelte';
|
|
5
5
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
6
6
|
export { default as ConfirmPage } from './ConfirmPage.svelte';
|
|
7
7
|
export { default as EditPage } from './EditPage.svelte';
|
|
8
8
|
export { default as ManageAdmins } from './ManageAdmins.svelte';
|
|
9
|
+
export { default as ComponentPalette } from './ComponentPalette.svelte';
|
|
10
|
+
export { default as NavTree } from './NavTree.svelte';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// cairn-core: the editor cursor seam (decision P3). The component palette and any later insert
|
|
2
|
+
// control talk to MarkdownEditor, never to Carta directly, so a swap to a different editing
|
|
3
|
+
// engine is contained to this file. Verified against carta-md@4.11: `input.getSelection()` and
|
|
4
|
+
// `input.insertAt(pos, text)` are public on the InputEnhancer.
|
|
5
|
+
|
|
6
|
+
// Local structural type for the Carta surface this module uses. carta-md is a peerDep and its
|
|
7
|
+
// types are erased at runtime, but the carta-boundary test bars any `.ts` file from importing
|
|
8
|
+
// `carta-md` (C4 bundle guard). A structural type avoids that import while remaining compatible.
|
|
9
|
+
interface CartaInput {
|
|
10
|
+
getSelection(): { start: number; end: number; direction: string; slice: string };
|
|
11
|
+
insertAt(position: number, text: string): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CartaLike {
|
|
15
|
+
input?: CartaInput;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The programmatic editing surface the admin relies on. */
|
|
19
|
+
export interface MarkdownEditor {
|
|
20
|
+
/** Insert a component or template at the current cursor position. */
|
|
21
|
+
insertComponent(template: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a Carta instance as a MarkdownEditor. Takes a getter (not the instance) because the
|
|
26
|
+
* EditPage component creates the Carta instance once and `carta.input` is only populated after
|
|
27
|
+
* the editor mounts; reading it lazily at call time avoids capturing an undefined `input`.
|
|
28
|
+
*/
|
|
29
|
+
export function cartaEditor(getCarta: () => CartaLike): MarkdownEditor {
|
|
30
|
+
return {
|
|
31
|
+
insertComponent(template) {
|
|
32
|
+
const input = getCarta().input;
|
|
33
|
+
if (!input) return; // editor not mounted yet; nothing to insert into
|
|
34
|
+
const { start } = input.getSelection();
|
|
35
|
+
input.insertAt(start, template);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// cairn-core: coerce a frontmatter value to the YYYY-MM-DD string an <input type="date"> wants.
|
|
2
|
+
// gray-matter parses an unquoted YAML date (date: 2026-05-14) into a JS Date, so a string-only
|
|
3
|
+
// read leaves the date input empty and drops the date on save. This normalizes a Date or an
|
|
4
|
+
// ISO-ish string to YYYY-MM-DD. A parsed YAML date is UTC midnight, so slicing the ISO string
|
|
5
|
+
// avoids a local-timezone shift. Internal (not re-exported from the barrel), like utils.ts.
|
|
6
|
+
|
|
7
|
+
/** A frontmatter date value (Date or string) to the `YYYY-MM-DD` an `<input type="date">` expects. */
|
|
8
|
+
export function dateInputValue(value: unknown): string {
|
|
9
|
+
if (value instanceof Date) {
|
|
10
|
+
return Number.isNaN(value.getTime()) ? '' : value.toISOString().slice(0, 10);
|
|
11
|
+
}
|
|
12
|
+
if (typeof value === 'string') {
|
|
13
|
+
const match = value.match(/^\d{4}-\d{2}-\d{2}/);
|
|
14
|
+
return match ? match[0] : '';
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
}
|
package/src/lib/index.ts
CHANGED
package/src/lib/nav.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// cairn-core: the navigation tree. A menu lives in the site's git-committed `site.config.yaml`
|
|
2
|
+
// under `menus.<name>`, read at build time by the public layout and edited from `/admin/nav`,
|
|
3
|
+
// which commits the file back through the GitHub-App pipeline. The engine returns data only; each
|
|
4
|
+
// site renders the tree with its own header markup.
|
|
5
|
+
|
|
6
|
+
import { parse as parseYaml, parseDocument } from 'yaml';
|
|
7
|
+
|
|
8
|
+
/** One navigation node. `url` omitted/empty is a label-only grouping header; `children` omitted is a leaf. */
|
|
9
|
+
export interface NavNode {
|
|
10
|
+
label: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
children?: NavNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Total node cap across the whole tree, a guard against a runaway payload. */
|
|
16
|
+
export const MAX_NAV_NODES = 200;
|
|
17
|
+
|
|
18
|
+
export class NavValidationError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'NavValidationError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate and normalize an untrusted value into a NavNode[]: arrays only, non-empty labels,
|
|
27
|
+
* depth within `maxDepth` (1 = flat), bounded node count, and only the three known keys kept.
|
|
28
|
+
* Throws NavValidationError on any violation. Used by `navSave` before writing.
|
|
29
|
+
*/
|
|
30
|
+
export function validateNavTree(value: unknown, maxDepth: number): NavNode[] {
|
|
31
|
+
let count = 0;
|
|
32
|
+
|
|
33
|
+
function walk(nodes: unknown, depth: number): NavNode[] {
|
|
34
|
+
if (!Array.isArray(nodes)) throw new NavValidationError('Navigation must be a list of items');
|
|
35
|
+
if (depth > maxDepth) throw new NavValidationError(`Navigation is nested deeper than ${maxDepth} levels`);
|
|
36
|
+
return nodes.map((raw) => {
|
|
37
|
+
if (typeof raw !== 'object' || raw === null) throw new NavValidationError('Each item must be an object');
|
|
38
|
+
const item = raw as Record<string, unknown>;
|
|
39
|
+
const label = typeof item.label === 'string' ? item.label.trim() : '';
|
|
40
|
+
if (!label) throw new NavValidationError('Each item needs a label');
|
|
41
|
+
if (++count > MAX_NAV_NODES) throw new NavValidationError('Too many navigation items');
|
|
42
|
+
const node: NavNode = { label };
|
|
43
|
+
if (typeof item.url === 'string' && item.url.trim()) node.url = item.url.trim();
|
|
44
|
+
if (item.children !== undefined) {
|
|
45
|
+
const children = walk(item.children, depth + 1);
|
|
46
|
+
if (children.length) node.children = children;
|
|
47
|
+
}
|
|
48
|
+
return node;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return walk(value, 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Shape of the YAML site-config file. Unknown keys are ignored so the file can grow without
|
|
57
|
+
* an engine change. Read at build time by the public site.
|
|
58
|
+
*/
|
|
59
|
+
export interface SiteConfig {
|
|
60
|
+
siteName: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
author?: string;
|
|
63
|
+
url?: string;
|
|
64
|
+
locale?: string;
|
|
65
|
+
/** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
|
|
66
|
+
menus?: Record<string, unknown>;
|
|
67
|
+
email?: { sender?: string; senderName?: string };
|
|
68
|
+
footer?: { copyrightName?: string };
|
|
69
|
+
settings?: {
|
|
70
|
+
feedMaxItems?: number;
|
|
71
|
+
homepageFeaturedCount?: number;
|
|
72
|
+
postTags?: string[];
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SiteConfigError extends Error {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'SiteConfigError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root. */
|
|
85
|
+
export function parseSiteConfig(raw: string): SiteConfig {
|
|
86
|
+
const parsed = parseYaml(raw) as unknown;
|
|
87
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
88
|
+
throw new SiteConfigError('Site config must be a YAML mapping');
|
|
89
|
+
}
|
|
90
|
+
const { siteName } = parsed as SiteConfig;
|
|
91
|
+
if (typeof siteName !== 'string' || !siteName.trim()) {
|
|
92
|
+
throw new SiteConfigError('Site config needs a siteName');
|
|
93
|
+
}
|
|
94
|
+
return parsed as SiteConfig;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
|
|
98
|
+
export function extractMenu(config: SiteConfig, name: string, maxDepth: number): NavNode[] {
|
|
99
|
+
const menu = config.menus?.[name];
|
|
100
|
+
if (menu === undefined) return [];
|
|
101
|
+
return validateNavTree(menu, maxDepth);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replace one named menu in the YAML site-config text and re-serialize, preserving every other
|
|
106
|
+
* top-level key (siteName, other menus, settings, ...). The `/admin/nav` editor commits the result.
|
|
107
|
+
* Parses into a Document so the rest of the file round-trips; YAML comments are not preserved
|
|
108
|
+
* (an accepted trade), but data keys are. A leaf node serializes without `url`/`children` keys.
|
|
109
|
+
*/
|
|
110
|
+
export function setMenu(raw: string, name: string, tree: NavNode[]): string {
|
|
111
|
+
const doc = parseDocument(raw);
|
|
112
|
+
if (doc.get('siteName') === undefined) {
|
|
113
|
+
throw new SiteConfigError('Site config must be a mapping with a siteName');
|
|
114
|
+
}
|
|
115
|
+
doc.setIn(['menus', name], tree);
|
|
116
|
+
return doc.toString();
|
|
117
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// cairn-core: derive a filename-safe slug stem from a human title, for the create-entry form.
|
|
2
|
+
// The admin is filename-based (Pass E): this produces the editable stem an author can adjust,
|
|
3
|
+
// matching the server-side SLUG_RE (lowercase alphanumerics and internal hyphens). Pure.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lowercase a title into a filename-safe slug stem.
|
|
7
|
+
* Apostrophes are dropped so "Geoff's" becomes "geoffs" (no spurious hyphen).
|
|
8
|
+
* All other non-alphanumeric runs become a single hyphen; leading/trailing hyphens are trimmed.
|
|
9
|
+
*/
|
|
10
|
+
export function slugify(title: string): string {
|
|
11
|
+
return title
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/'/g, '')
|
|
14
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, '');
|
|
16
|
+
}
|