@glw907/cairn-cms 0.4.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/README.md +4 -4
- package/dist/adapter.d.ts +34 -1
- 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/config.d.ts +9 -9
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/config.js +5 -5
- package/dist/auth/guard.d.ts +1 -1
- package/dist/auth/guard.d.ts.map +1 -1
- package/dist/auth/guard.js +2 -2
- 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/carta.d.ts +1 -1
- package/dist/carta.d.ts.map +1 -1
- package/dist/components/AdminLayout.svelte +74 -18
- 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/ConfirmPage.svelte +2 -2
- package/dist/components/EditPage.svelte +69 -31
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/LoginPage.svelte +5 -5
- 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/email.js +4 -4
- package/dist/frontmatter.d.ts +3 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +16 -0
- package/dist/github.d.ts +22 -2
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +40 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/nav.d.ts +58 -0
- package/dist/nav.d.ts.map +1 -0
- package/dist/nav.js +86 -0
- package/dist/render/glyph.d.ts +6 -0
- package/dist/render/glyph.d.ts.map +1 -0
- package/dist/render/glyph.js +5 -0
- package/dist/render/index.d.ts +6 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/pipeline.d.ts +16 -0
- package/dist/render/pipeline.d.ts.map +1 -0
- package/dist/render/pipeline.js +29 -0
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +11 -0
- package/dist/render/rehype-dispatch.d.ts +24 -0
- package/dist/render/rehype-dispatch.d.ts.map +1 -0
- package/dist/render/rehype-dispatch.js +86 -0
- package/dist/render/remark-directives.d.ts +4 -0
- package/dist/render/remark-directives.d.ts.map +1 -0
- package/dist/render/remark-directives.js +74 -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 +118 -13
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +250 -24
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -2
- package/package.json +20 -3
- package/src/lib/adapter.ts +37 -3
- package/src/lib/auth/capabilities.ts +35 -0
- package/src/lib/auth/config.ts +6 -6
- package/src/lib/auth/guard.ts +3 -3
- package/src/lib/auth/index.ts +1 -0
- package/src/lib/carta.ts +2 -2
- package/src/lib/components/AdminLayout.svelte +74 -18
- package/src/lib/components/CollectionList.svelte +96 -0
- package/src/lib/components/ComponentPalette.svelte +34 -0
- package/src/lib/components/ConfirmPage.svelte +2 -2
- package/src/lib/components/EditPage.svelte +69 -31
- package/src/lib/components/LoginPage.svelte +5 -5
- 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/email.ts +4 -4
- package/src/lib/frontmatter.ts +17 -0
- package/src/lib/github.ts +38 -6
- package/src/lib/index.ts +3 -0
- package/src/lib/nav.ts +117 -0
- package/src/lib/render/glyph.ts +14 -0
- package/src/lib/render/index.ts +8 -0
- package/src/lib/render/pipeline.ts +37 -0
- package/src/lib/render/registry.ts +36 -0
- package/src/lib/render/rehype-dispatch.ts +97 -0
- package/src/lib/render/remark-directives.ts +71 -0
- package/src/lib/slug.ts +16 -0
- package/src/lib/sveltekit/index.ts +355 -37
- package/src/lib/utils.ts +2 -2
- 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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// The magic-link sign-in page. Requests a link via the better-auth client (client-side, same
|
|
3
|
-
// origin). To avoid enumeration the UI shows the
|
|
4
|
-
// on the allowlist
|
|
3
|
+
// origin). To avoid enumeration the UI shows the same neutral copy whether or not the email is
|
|
4
|
+
// on the allowlist. The server only emails actual editors (see auth/config.ts send gate).
|
|
5
5
|
import { createAuthClient } from 'better-auth/svelte';
|
|
6
6
|
import { magicLinkClient } from 'better-auth/client/plugins';
|
|
7
7
|
|
|
8
8
|
// The browser client lives in the one component that needs it (requesting a link). Sign-out
|
|
9
|
-
// and editor management go through server endpoints, so no shared client module is needed
|
|
10
|
-
//
|
|
9
|
+
// and editor management go through server endpoints, so no shared client module is needed.
|
|
10
|
+
// A component-local const keeps better-auth's deep client types out of the packaged .d.ts.
|
|
11
11
|
const authClient = createAuthClient({ plugins: [magicLinkClient()] });
|
|
12
12
|
|
|
13
13
|
interface Props {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
event.preventDefault();
|
|
24
24
|
busy = true;
|
|
25
25
|
// The magic-link email points at our /admin/auth/confirm page (built in config.ts), not a
|
|
26
|
-
// GET-verify URL
|
|
26
|
+
// GET-verify URL, so the result is the same regardless of allowlist membership.
|
|
27
27
|
await authClient.signIn.magicLink({ email });
|
|
28
28
|
busy = false;
|
|
29
29
|
requested = true;
|
|
@@ -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
|
+
}
|
package/src/lib/email.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// cairn-core: pluggable magic-link email sender.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// recipients)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The default adapter targets Cloudflare Email Service (Email Sending, transactional,
|
|
4
|
+
// arbitrary recipients), distinct from Email Routing's recipient-restricted `EmailMessage`
|
|
5
|
+
// flow. Both share the same `send_email` binding (configured without a destination_address)
|
|
6
|
+
// but use a different call shape: `binding.send({ to, from, ... })`.
|
|
7
7
|
// Resend can slot in behind the same `sendMagicLink` signature if needed.
|
|
8
8
|
|
|
9
9
|
/** Cloudflare Email Sending binding surface (the object-form `send`, not the MIME form). */
|
|
@@ -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/github.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Reads (Pass B) list a collection directory and fetch a file's raw markdown; the token
|
|
4
4
|
// is optional because ecnordic's repo is public. Writes (Pass C) mint a short-lived
|
|
5
|
-
// GitHub App installation token
|
|
6
|
-
// dependency
|
|
5
|
+
// GitHub App installation token (App JWT, RS256 signed with Web Crypto, no octokit
|
|
6
|
+
// dependency) and commit through the contents API with author = editor, committer = the
|
|
7
7
|
// App (cairn-cms[bot]). The same token also lifts reads to the authenticated rate limit
|
|
8
8
|
// and unlocks private repos (e.g. 907-life).
|
|
9
9
|
|
|
@@ -90,7 +90,7 @@ function derLength(n: number): number[] {
|
|
|
90
90
|
// AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
|
|
91
91
|
const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
|
|
92
92
|
|
|
93
|
-
/** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8
|
|
93
|
+
/** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 (the only RSA form Web Crypto importKey takes). */
|
|
94
94
|
function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array {
|
|
95
95
|
const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
|
|
96
96
|
const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
|
|
@@ -124,7 +124,7 @@ export async function appJwt(appId: string, privateKeyPem: string): Promise<stri
|
|
|
124
124
|
export interface AppCredentials {
|
|
125
125
|
appId: string;
|
|
126
126
|
installationId: string;
|
|
127
|
-
/** The stored GITHUB_APP_PRIVATE_KEY_B64
|
|
127
|
+
/** The stored GITHUB_APP_PRIVATE_KEY_B64: base64 of the PEM, single line. */
|
|
128
128
|
privateKeyB64: string;
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -139,7 +139,7 @@ export async function installationToken(creds: AppCredentials): Promise<string>
|
|
|
139
139
|
return ((await res.json()) as { token: string }).token;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/** Standard (padded) base64 of UTF-8 text
|
|
142
|
+
/** Standard (padded) base64 of UTF-8 text, as the contents API expects. */
|
|
143
143
|
function toBase64(text: string): string {
|
|
144
144
|
return btoa(Array.from(encoder.encode(text), (b) => String.fromCharCode(b)).join(''));
|
|
145
145
|
}
|
|
@@ -157,11 +157,24 @@ export interface CommitAuthor {
|
|
|
157
157
|
email: string;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A concurrent edit lost the SHA race (C3): the file changed between the read and the PUT,
|
|
162
|
+
* from another editor or the site's own CI. Thrown so callers can fail safe (re-fetch and ask
|
|
163
|
+
* the editor to reapply) instead of surfacing a raw 409. Defined and caught inside the package
|
|
164
|
+
* so `instanceof` is reliable (no peer-boundary identity split, unlike kit's `redirect`/`error`).
|
|
165
|
+
*/
|
|
166
|
+
export class CommitConflictError extends Error {
|
|
167
|
+
constructor(public readonly path: string) {
|
|
168
|
+
super(`Commit conflict on ${path}: it changed since it was opened`);
|
|
169
|
+
this.name = 'CommitConflictError';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
/**
|
|
161
174
|
* Commit `content` to `path` on the configured branch via the contents API. Author is the
|
|
162
175
|
* editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
|
|
163
176
|
* the file in place when it exists (passing its sha), creates it otherwise. Returns the
|
|
164
|
-
* commit sha.
|
|
177
|
+
* commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`.
|
|
165
178
|
*/
|
|
166
179
|
export async function commitFile(
|
|
167
180
|
repo: RepoRef,
|
|
@@ -183,6 +196,25 @@ export async function commitFile(
|
|
|
183
196
|
...(sha ? { sha } : {}),
|
|
184
197
|
}),
|
|
185
198
|
});
|
|
199
|
+
// 409 = the blob sha we read is no longer current. Fail safe: the caller re-fetches and the
|
|
200
|
+
// editor reapplies. (Full three-way merge stays out of scope; see ARCHITECTURE §5.)
|
|
201
|
+
if (res.status === 409) throw new CommitConflictError(path);
|
|
186
202
|
if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
|
|
187
203
|
return ((await res.json()) as { commit: { sha: string } }).commit.sha;
|
|
188
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Deploy-time self-test for the GitHub App signer (M2): sign a dummy JWT with the configured
|
|
208
|
+
* private key. Exercises the brittle PKCS#1→PKCS#8 conversion + Web Crypto import/sign without
|
|
209
|
+
* any network call or secret in the result, so `/admin/healthz` catches a bad/rotated key
|
|
210
|
+
* before an editor's save fails. Returns `{ ok: false, detail }` rather than throwing.
|
|
211
|
+
*/
|
|
212
|
+
export async function signingSelfTest(appId: string, privateKeyB64: string): Promise<{ ok: boolean; detail?: string }> {
|
|
213
|
+
try {
|
|
214
|
+
const jwt = await appJwt(appId, atob(privateKeyB64));
|
|
215
|
+
if (jwt.split('.').length !== 3) return { ok: false, detail: 'malformed JWT' };
|
|
216
|
+
return { ok: true };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { ok: false, detail: err instanceof Error ? err.message : 'sign failed' };
|
|
219
|
+
}
|
|
220
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { s } from 'hastscript';
|
|
2
|
+
import type { Element } from 'hast';
|
|
3
|
+
|
|
4
|
+
/** A glyph name → SVG path-data map (the site owns the icon set). */
|
|
5
|
+
export type IconSet = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
|
|
8
|
+
export function glyph(name: string, icons: IconSet): Element {
|
|
9
|
+
return s(
|
|
10
|
+
'svg',
|
|
11
|
+
{ className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' },
|
|
12
|
+
[s('path', { d: icons[name] })],
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// cairn-cms render engine: a directive-driven markdown → HTML pipeline whose
|
|
2
|
+
// component vocabulary is supplied by a site's component registry. The site owns the
|
|
3
|
+
// component builders, class names, icon set, and CSS; the engine owns the machinery.
|
|
4
|
+
export * from './registry';
|
|
5
|
+
export * from './glyph';
|
|
6
|
+
export * from './remark-directives';
|
|
7
|
+
export * from './rehype-dispatch';
|
|
8
|
+
export * from './pipeline';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { unified, type PluggableList } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import remarkDirective from 'remark-directive';
|
|
5
|
+
import remarkRehype from 'remark-rehype';
|
|
6
|
+
import rehypeRaw from 'rehype-raw';
|
|
7
|
+
import rehypeSlug from 'rehype-slug';
|
|
8
|
+
import rehypeStringify from 'rehype-stringify';
|
|
9
|
+
import { remarkDirectiveStamp } from './remark-directives';
|
|
10
|
+
import { rehypeDispatch } from './rehype-dispatch';
|
|
11
|
+
import type { ComponentRegistry } from './registry';
|
|
12
|
+
|
|
13
|
+
export interface RendererOptions {
|
|
14
|
+
/** A site's per-index motion formula for the top-level rise stagger
|
|
15
|
+
* (e.g. ecnordic's `(i) => '--rise:' + …`). Omit for no stagger. */
|
|
16
|
+
rise?: (idx: number) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compose a site's render pipeline from its component registry: directive syntax →
|
|
20
|
+
* stamped markers → registry-built hast. Returns `renderMarkdown` plus the remark/
|
|
21
|
+
* rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
|
|
22
|
+
export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
|
|
23
|
+
const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
|
|
24
|
+
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.rise], rehypeSlug];
|
|
25
|
+
const processor = unified()
|
|
26
|
+
.use(remarkParse)
|
|
27
|
+
.use(remarkGfm)
|
|
28
|
+
.use(remarkPlugins)
|
|
29
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
30
|
+
.use(rehypePlugins)
|
|
31
|
+
.use(rehypeStringify);
|
|
32
|
+
return {
|
|
33
|
+
remarkPlugins,
|
|
34
|
+
rehypePlugins,
|
|
35
|
+
renderMarkdown: async (content: string): Promise<string> => String(await processor.process(content)),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Element } from 'hast';
|
|
2
|
+
|
|
3
|
+
/** A site component: how it inserts (editor) and how it renders (rehype). */
|
|
4
|
+
export interface ComponentDef {
|
|
5
|
+
/** Directive name, e.g. 'card' (matches `:::card`). */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Palette label. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Palette description. */
|
|
10
|
+
description: string;
|
|
11
|
+
/** Markdown scaffold inserted at the cursor by the editor palette. */
|
|
12
|
+
insertTemplate: string;
|
|
13
|
+
/** Build the final hast element from the stamped directive element. */
|
|
14
|
+
build: (node: Element, rise?: string) => Element;
|
|
15
|
+
/** Optional role→default-icon (e.g. `{ caution: 'warning' }`). */
|
|
16
|
+
defaultIconByRole?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ComponentRegistry {
|
|
20
|
+
defs: ComponentDef[];
|
|
21
|
+
names: string[];
|
|
22
|
+
get(name: string): ComponentDef | undefined;
|
|
23
|
+
defaultIcon(name: string, role?: string): string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build a registry from a site's component definitions. The single source the
|
|
27
|
+
* render pipeline (directive stamp + rehype dispatch) and the editor palette read. */
|
|
28
|
+
export function defineRegistry(input: { components: ComponentDef[] }): ComponentRegistry {
|
|
29
|
+
const byName = new Map(input.components.map((c) => [c.name, c]));
|
|
30
|
+
return {
|
|
31
|
+
defs: input.components,
|
|
32
|
+
names: input.components.map((c) => c.name),
|
|
33
|
+
get: (name) => byName.get(name),
|
|
34
|
+
defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Root, Element, ElementContent, Properties } from 'hast';
|
|
2
|
+
import { h } from 'hastscript';
|
|
3
|
+
import type { ComponentRegistry } from './registry';
|
|
4
|
+
|
|
5
|
+
export function isElement(node: ElementContent | undefined): node is Element {
|
|
6
|
+
return !!node && node.type === 'element';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// hast Properties values are PropertyValue (string | number | boolean | array | null).
|
|
10
|
+
// Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
|
|
11
|
+
// this reads them back with that guarantee instead of casting at each call site.
|
|
12
|
+
export function strProp(node: Element, name: string): string | undefined {
|
|
13
|
+
const value = node.properties?.[name];
|
|
14
|
+
return typeof value === 'string' ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
|
|
18
|
+
export function iconSpan(glyphEl: Element, role?: string): Element {
|
|
19
|
+
const className = role === 'secondary' ? ['ec-icon', 'ec-icon-secondary'] : ['ec-icon'];
|
|
20
|
+
return h('span', { className }, [glyphEl]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A site's icon factory: turn a stamped icon name + role into a hast element. */
|
|
24
|
+
export type MakeIcon = (name: string, role?: string) => Element;
|
|
25
|
+
|
|
26
|
+
// Pull the section's <h2> out, retag it .card-title, and build the .ec-head row
|
|
27
|
+
// (optional icon + heading). Returns the head plus the remaining body children.
|
|
28
|
+
// `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
|
|
29
|
+
// for a head with no icon.
|
|
30
|
+
export function splitHead(node: Element, makeIcon?: MakeIcon): { head: Element; rest: ElementContent[] } {
|
|
31
|
+
const children = node.children as ElementContent[];
|
|
32
|
+
const i = children.findIndex((c) => isElement(c) && c.tagName === 'h2');
|
|
33
|
+
const h2 = children[i] as Element;
|
|
34
|
+
h2.properties = { ...h2.properties, className: ['card-title'] };
|
|
35
|
+
const rest = children.filter((_, j) => j !== i);
|
|
36
|
+
const icon = strProp(node, 'dataIcon');
|
|
37
|
+
const role = strProp(node, 'dataRole');
|
|
38
|
+
const headKids: ElementContent[] = [];
|
|
39
|
+
if (makeIcon && icon) headKids.push(makeIcon(icon, role));
|
|
40
|
+
headKids.push(h2);
|
|
41
|
+
return { head: h('div', { className: ['ec-head'] }, headKids), rest };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Section wrapper: `<section class=…><div class="card-body">…</div></section>`,
|
|
45
|
+
* with an optional inline rise style. */
|
|
46
|
+
export function cardShell(classes: string[], rise: string | undefined, body: ElementContent[]): Element {
|
|
47
|
+
const properties: Properties = { className: classes };
|
|
48
|
+
if (rise) properties.style = rise;
|
|
49
|
+
return h('section', properties, [h('div', { className: ['card-body'] }, body)]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
|
|
53
|
+
* text nodes so the bare list serializes without newlines. Returns that <ul>. */
|
|
54
|
+
export function markFirstList(children: ElementContent[]): Element | undefined {
|
|
55
|
+
const ul = children.find((c) => isElement(c) && c.tagName === 'ul') as Element | undefined;
|
|
56
|
+
if (ul) {
|
|
57
|
+
ul.properties = { ...ul.properties, className: ['ec-grid'] };
|
|
58
|
+
ul.children = (ul.children as ElementContent[]).filter(
|
|
59
|
+
(c) => !(c.type === 'text' && /^\s*$/.test(c.value)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return ul;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Recurse into a node's children, transforming any nested primitive sections
|
|
66
|
+
// (a grid inside a card, panels inside a split) WITHOUT a rise stagger.
|
|
67
|
+
function transformChildren(children: ElementContent[], registry: ComponentRegistry): ElementContent[] {
|
|
68
|
+
return children.map((c) => {
|
|
69
|
+
if (isElement(c) && c.properties?.dataPrimitive) return transformNode(c, registry);
|
|
70
|
+
if (isElement(c)) c.children = transformChildren(c.children as ElementContent[], registry);
|
|
71
|
+
return c;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function transformNode(node: Element, registry: ComponentRegistry, rise?: string): Element {
|
|
76
|
+
node.children = transformChildren(node.children as ElementContent[], registry);
|
|
77
|
+
const name = strProp(node, 'dataPrimitive');
|
|
78
|
+
const def = name ? registry.get(name) : undefined;
|
|
79
|
+
return def ? def.build(node, rise) : node;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Rehype transformer: dispatch each stamped element through its registry `build`
|
|
83
|
+
* fn. Top-level primitives get a document-order rise stagger when `rise` is
|
|
84
|
+
* supplied (a site's per-index motion formula); nested ones don't. Non-primitive
|
|
85
|
+
* content (lede, intro paragraphs, the page-toc nav) passes through untouched. */
|
|
86
|
+
export function rehypeDispatch(registry: ComponentRegistry, rise?: (idx: number) => string) {
|
|
87
|
+
return (tree: Root) => {
|
|
88
|
+
let idx = 0;
|
|
89
|
+
tree.children = (tree.children as ElementContent[]).map((child) => {
|
|
90
|
+
if (isElement(child) && child.properties?.dataPrimitive) {
|
|
91
|
+
return transformNode(child, registry, rise ? rise(idx++) : undefined);
|
|
92
|
+
}
|
|
93
|
+
if (isElement(child)) child.children = transformChildren(child.children as ElementContent[], registry);
|
|
94
|
+
return child;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|