@glw907/cairn-cms 0.1.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/adapter.d.ts +60 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +30 -0
  6. package/dist/auth.d.ts +16 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +93 -0
  9. package/dist/carta.d.ts +39 -0
  10. package/dist/carta.d.ts.map +1 -0
  11. package/dist/carta.js +30 -0
  12. package/dist/components/AdminLayout.svelte +18 -0
  13. package/dist/components/AdminLayout.svelte.d.ts +8 -0
  14. package/dist/components/AdminLayout.svelte.d.ts.map +1 -0
  15. package/dist/components/AdminList.svelte +41 -0
  16. package/dist/components/AdminList.svelte.d.ts +13 -0
  17. package/dist/components/AdminList.svelte.d.ts.map +1 -0
  18. package/dist/components/EditPage.svelte +125 -0
  19. package/dist/components/EditPage.svelte.d.ts +13 -0
  20. package/dist/components/EditPage.svelte.d.ts.map +1 -0
  21. package/dist/components/LoginPage.svelte +47 -0
  22. package/dist/components/LoginPage.svelte.d.ts +11 -0
  23. package/dist/components/LoginPage.svelte.d.ts.map +1 -0
  24. package/dist/components/index.d.ts +5 -0
  25. package/dist/components/index.d.ts.map +1 -0
  26. package/dist/components/index.js +6 -0
  27. package/dist/content.d.ts +3 -0
  28. package/dist/content.d.ts.map +1 -0
  29. package/dist/content.js +10 -0
  30. package/dist/email.d.ts +14 -0
  31. package/dist/email.d.ts.map +1 -0
  32. package/dist/email.js +17 -0
  33. package/dist/github.d.ts +52 -0
  34. package/dist/github.d.ts.map +1 -0
  35. package/dist/github.js +136 -0
  36. package/dist/index.d.ts +7 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +7 -0
  39. package/dist/sveltekit/index.d.ts +91 -0
  40. package/dist/sveltekit/index.d.ts.map +1 -0
  41. package/dist/sveltekit/index.js +163 -0
  42. package/dist/utils.d.ts +3 -0
  43. package/dist/utils.d.ts.map +1 -0
  44. package/dist/utils.js +11 -0
  45. package/package.json +79 -0
  46. package/src/lib/adapter.ts +110 -0
  47. package/src/lib/auth.ts +130 -0
  48. package/src/lib/carta.ts +59 -0
  49. package/src/lib/components/AdminLayout.svelte +18 -0
  50. package/src/lib/components/AdminList.svelte +41 -0
  51. package/src/lib/components/EditPage.svelte +125 -0
  52. package/src/lib/components/LoginPage.svelte +47 -0
  53. package/src/lib/components/index.ts +6 -0
  54. package/src/lib/content.ts +11 -0
  55. package/src/lib/email.ts +35 -0
  56. package/src/lib/github.ts +188 -0
  57. package/src/lib/index.ts +7 -0
  58. package/src/lib/sveltekit/index.ts +272 -0
  59. package/src/lib/utils.ts +12 -0
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@glw907/cairn-cms",
3
+ "version": "0.1.0",
4
+ "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Geoff Wright",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/glw907/cairn-cms.git"
11
+ },
12
+ "keywords": ["cms", "sveltekit", "cloudflare", "github", "magic-link", "markdown"],
13
+ "scripts": {
14
+ "package": "svelte-package",
15
+ "package:watch": "svelte-package --watch",
16
+ "prepublishOnly": "svelte-package",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/lib/index.ts",
23
+ "svelte": "./src/lib/index.ts",
24
+ "default": "./src/lib/index.ts"
25
+ },
26
+ "./sveltekit": {
27
+ "types": "./src/lib/sveltekit/index.ts",
28
+ "svelte": "./src/lib/sveltekit/index.ts",
29
+ "default": "./src/lib/sveltekit/index.ts"
30
+ },
31
+ "./components": {
32
+ "types": "./src/lib/components/index.ts",
33
+ "svelte": "./src/lib/components/index.ts",
34
+ "default": "./src/lib/components/index.ts"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "publishConfig": {
39
+ "exports": {
40
+ ".": {
41
+ "types": "./dist/index.d.ts",
42
+ "svelte": "./dist/index.js",
43
+ "default": "./dist/index.js"
44
+ },
45
+ "./sveltekit": {
46
+ "types": "./dist/sveltekit/index.d.ts",
47
+ "svelte": "./dist/sveltekit/index.js",
48
+ "default": "./dist/sveltekit/index.js"
49
+ },
50
+ "./components": {
51
+ "types": "./dist/components/index.d.ts",
52
+ "svelte": "./dist/components/index.js",
53
+ "default": "./dist/components/index.js"
54
+ },
55
+ "./package.json": "./package.json"
56
+ }
57
+ },
58
+ "files": ["dist", "src/lib"],
59
+ "peerDependencies": {
60
+ "@sveltejs/kit": "^2",
61
+ "carta-md": "^4.11",
62
+ "svelte": "^5.0.0"
63
+ },
64
+ "dependencies": {
65
+ "gray-matter": "^4"
66
+ },
67
+ "devDependencies": {
68
+ "@cloudflare/workers-types": "^4.20260405.1",
69
+ "@sveltejs/kit": "^2",
70
+ "@sveltejs/package": "^2",
71
+ "@sveltejs/vite-plugin-svelte": "^7",
72
+ "carta-md": "^4.11",
73
+ "svelte": "^5",
74
+ "svelte-check": "^4",
75
+ "typescript": "^6.0.3",
76
+ "unified": "^11.0.5",
77
+ "vitest": "^4.1.6"
78
+ }
79
+ }
@@ -0,0 +1,110 @@
1
+ // cairn-core: the adapter contract each site implements.
2
+ //
3
+ // This is the single seam that lets one admin surface serve different designs. A site
4
+ // supplies a `CairnAdapter` (see `src/lib/cairn.config.ts`) describing its backend repo,
5
+ // its editable collections (folder + form fields + frontmatter validator), and its preview
6
+ // plugin set. cairn-core never hard-codes a collection, tag, or directive — it reads them
7
+ // from the adapter. Field descriptors are plain data so a load function can hand them to
8
+ // the editor form across the server→client boundary.
9
+ import type { PreviewPlugins } from './carta';
10
+ import type { RepoRef } from './github';
11
+
12
+ interface FieldBase {
13
+ /** Frontmatter key and form input name. */
14
+ name: string;
15
+ label: string;
16
+ required?: boolean;
17
+ }
18
+
19
+ export interface TextField extends FieldBase {
20
+ type: 'text';
21
+ }
22
+ export interface DateField extends FieldBase {
23
+ type: 'date';
24
+ }
25
+ export interface TextareaField extends FieldBase {
26
+ type: 'textarea';
27
+ rows?: number;
28
+ }
29
+ export interface BooleanField extends FieldBase {
30
+ type: 'boolean';
31
+ }
32
+ export interface TagsField extends FieldBase {
33
+ type: 'tags';
34
+ /** Controlled vocabulary rendered as checkboxes. */
35
+ options: readonly string[];
36
+ }
37
+ export interface FreeTagsField extends FieldBase {
38
+ type: 'freetags';
39
+ /** Free-form tags, edited as one comma-separated text input (no controlled vocabulary). */
40
+ placeholder?: string;
41
+ }
42
+
43
+ export type CairnField =
44
+ | TextField
45
+ | DateField
46
+ | TextareaField
47
+ | BooleanField
48
+ | TagsField
49
+ | FreeTagsField;
50
+
51
+ export interface CairnCollection {
52
+ /** Route `[type]` segment and list key, e.g. `posts`. */
53
+ type: string;
54
+ label: string;
55
+ /** Repo-relative folder holding the collection's markdown files. */
56
+ dir: string;
57
+ /** Editor form fields, rendered in order. */
58
+ fields: CairnField[];
59
+ /** Validate raw frontmatter (from the form) into the on-disk object, throwing on error. */
60
+ validate(data: Record<string, unknown>, source: string): object;
61
+ }
62
+
63
+ export interface CairnAdapter {
64
+ /** Branding + magic-link email copy. */
65
+ siteName: string;
66
+ /** From: address for magic-link email — a domain-authenticated sender. */
67
+ sender: string;
68
+ /** The repository the admin reads content from and commits to. */
69
+ backend: RepoRef;
70
+ /** Site plugin set for the Carta preview (parity with the live render). */
71
+ preview: PreviewPlugins;
72
+ collections: CairnCollection[];
73
+ }
74
+
75
+ /** Look up a collection by its route segment, or undefined if the segment is unknown. */
76
+ export function findCollection(adapter: CairnAdapter, type: string): CairnCollection | undefined {
77
+ return adapter.collections.find((collection) => collection.type === type);
78
+ }
79
+
80
+ /** Read raw frontmatter from a submitted form, decoding each value per its field type. */
81
+ export function frontmatterFromForm(
82
+ collection: CairnCollection,
83
+ form: FormData,
84
+ ): Record<string, unknown> {
85
+ const data: Record<string, unknown> = {};
86
+ for (const field of collection.fields) {
87
+ switch (field.type) {
88
+ case 'boolean':
89
+ data[field.name] = form.get(field.name) === 'on';
90
+ break;
91
+ case 'tags':
92
+ data[field.name] = form.getAll(field.name).map(String);
93
+ break;
94
+ case 'freetags':
95
+ // One comma-separated input → trimmed, de-duplicated, non-empty tags.
96
+ data[field.name] = [
97
+ ...new Set(
98
+ String(form.get(field.name) ?? '')
99
+ .split(',')
100
+ .map((tag) => tag.trim())
101
+ .filter(Boolean),
102
+ ),
103
+ ];
104
+ break;
105
+ default:
106
+ data[field.name] = form.get(field.name);
107
+ }
108
+ }
109
+ return data;
110
+ }
@@ -0,0 +1,130 @@
1
+ // cairn-core: magic-link auth + signed sessions.
2
+ //
3
+ // Generic across sites — no ecnordic specifics here. Crypto is Web Crypto (HMAC-SHA256)
4
+ // so it runs unchanged on Cloudflare Workers under nodejs_compat. Single-use enforcement
5
+ // for magic links rides on a KV nonce; signature + expiry are self-contained in the token.
6
+
7
+ import type { KVNamespace } from '@cloudflare/workers-types';
8
+ import { bytesToB64url } from './utils';
9
+
10
+ export interface Editor {
11
+ email: string;
12
+ name: string;
13
+ }
14
+
15
+ export const SESSION_COOKIE = 'cairn_session';
16
+
17
+ const MAGIC_TTL_SECONDS = 600; // 10 minutes
18
+ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
19
+
20
+ export const SESSION_MAX_AGE = SESSION_TTL_SECONDS;
21
+
22
+ const encoder = new TextEncoder();
23
+ const decoder = new TextDecoder();
24
+
25
+ function b64urlToBytes(value: string): Uint8Array {
26
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
27
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
28
+ return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
29
+ }
30
+
31
+ // TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies
32
+ // Web Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
33
+ function buf(bytes: Uint8Array): ArrayBuffer {
34
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
35
+ }
36
+
37
+ async function hmacKey(secret: string): Promise<CryptoKey> {
38
+ return crypto.subtle.importKey(
39
+ 'raw',
40
+ buf(encoder.encode(secret)),
41
+ { name: 'HMAC', hash: 'SHA-256' },
42
+ false,
43
+ ['sign', 'verify'],
44
+ );
45
+ }
46
+
47
+ /** Sign an arbitrary JSON payload as `<base64url(payload)>.<base64url(hmac)>`. */
48
+ async function signToken(data: unknown, secret: string): Promise<string> {
49
+ const payload = bytesToB64url(encoder.encode(JSON.stringify(data)));
50
+ const key = await hmacKey(secret);
51
+ const sig = await crypto.subtle.sign('HMAC', key, buf(encoder.encode(payload)));
52
+ return `${payload}.${bytesToB64url(new Uint8Array(sig))}`;
53
+ }
54
+
55
+ /** Verify signature (constant-time via subtle.verify) and parse the payload, or null. */
56
+ async function verifyToken<T>(token: string, secret: string): Promise<T | null> {
57
+ const dot = token.indexOf('.');
58
+ if (dot < 0) return null;
59
+ const payload = token.slice(0, dot);
60
+ const sig = token.slice(dot + 1);
61
+ const key = await hmacKey(secret);
62
+ let ok = false;
63
+ try {
64
+ ok = await crypto.subtle.verify('HMAC', key, buf(b64urlToBytes(sig)), buf(encoder.encode(payload)));
65
+ } catch {
66
+ return null;
67
+ }
68
+ if (!ok) return null;
69
+ try {
70
+ return JSON.parse(decoder.decode(b64urlToBytes(payload))) as T;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ interface MagicPayload {
77
+ email: string;
78
+ exp: number;
79
+ nonce: string;
80
+ }
81
+
82
+ /** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
83
+ export async function createMagicLink(
84
+ email: string,
85
+ secret: string,
86
+ kv: KVNamespace,
87
+ ): Promise<string> {
88
+ const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
89
+ const exp = Date.now() + MAGIC_TTL_SECONDS * 1000;
90
+ const token = await signToken({ email, exp, nonce } satisfies MagicPayload, secret);
91
+ await kv.put(`ml:${nonce}`, email, { expirationTtl: MAGIC_TTL_SECONDS });
92
+ return token;
93
+ }
94
+
95
+ /** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
96
+ export async function redeemMagicToken(
97
+ token: string,
98
+ secret: string,
99
+ kv: KVNamespace,
100
+ ): Promise<string | null> {
101
+ const payload = await verifyToken<MagicPayload>(token, secret);
102
+ if (!payload || Date.now() > payload.exp) return null;
103
+ const stored = await kv.get(`ml:${payload.nonce}`);
104
+ if (stored !== payload.email) return null;
105
+ await kv.delete(`ml:${payload.nonce}`); // burn it — single use
106
+ return payload.email;
107
+ }
108
+
109
+ interface SessionPayload extends Editor {
110
+ exp: number;
111
+ }
112
+
113
+ export async function createSession(editor: Editor, secret: string): Promise<string> {
114
+ const exp = Date.now() + SESSION_TTL_SECONDS * 1000;
115
+ return signToken({ ...editor, exp } satisfies SessionPayload, secret);
116
+ }
117
+
118
+ export async function verifySession(token: string, secret: string): Promise<Editor | null> {
119
+ const payload = await verifyToken<SessionPayload>(token, secret);
120
+ if (!payload || Date.now() > payload.exp) return null;
121
+ return { email: payload.email, name: payload.name };
122
+ }
123
+
124
+ /** Look up an editor in the KV allowlist (`editor:<email>` → display name). */
125
+ export async function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null> {
126
+ const normalized = email.trim().toLowerCase();
127
+ const name = await kv.get(`editor:${normalized}`);
128
+ if (name === null) return null;
129
+ return { email: normalized, name };
130
+ }
@@ -0,0 +1,59 @@
1
+ // cairn-core: pure Carta options/transformer wiring for render-only preview.
2
+ //
3
+ // Plugins are passed in, not imported — that seam is what the Pass D adapter formalises.
4
+ // No `carta-md` import: its index re-exports Svelte components that the node test env
5
+ // can't load. The Svelte component calls `new Carta(previewCartaOptions(...))` directly.
6
+ import type { Pluggable, Processor } from 'unified';
7
+
8
+ export interface PreviewPlugins {
9
+ /** remark plugins, injected after gfm and before remark-rehype. */
10
+ remarkPlugins: Pluggable[];
11
+ /** rehype plugins, injected after remark-rehype. */
12
+ rehypePlugins: Pluggable[];
13
+ }
14
+
15
+ interface PreviewTransformer {
16
+ execution: 'sync';
17
+ type: 'remark' | 'rehype';
18
+ transform: (ctx: { processor: Processor }) => void;
19
+ }
20
+
21
+ function phase(plugins: Pluggable[], type: PreviewTransformer['type']): PreviewTransformer[] {
22
+ return plugins.map((plugin) => ({
23
+ execution: 'sync',
24
+ type,
25
+ transform: ({ processor }) => {
26
+ processor.use([plugin]);
27
+ },
28
+ }));
29
+ }
30
+
31
+ /**
32
+ * Map the site's plugin set to Carta sync transformers, remark phase before rehype.
33
+ * Carta's processor is remarkParse → gfm → [remark] → remark-rehype → [rehype] → stringify,
34
+ * so this ordering reproduces render.ts exactly. Pure (no Carta) so it is unit-testable.
35
+ */
36
+ export function previewTransformers({ remarkPlugins, rehypePlugins }: PreviewPlugins): PreviewTransformer[] {
37
+ return [...phase(remarkPlugins, 'remark'), ...phase(rehypePlugins, 'rehype')];
38
+ }
39
+
40
+ /** Minimal Options subset we populate — avoids importing carta-md (Svelte re-exports). */
41
+ interface PreviewCartaOptions {
42
+ sanitizer: false;
43
+ rehypeOptions: { allowDangerousHtml: boolean };
44
+ extensions: Array<{ transformers: PreviewTransformer[] }>;
45
+ }
46
+
47
+ /**
48
+ * Carta options for a render-only preview: site plugins wired in, raw HTML allowed, no
49
+ * sanitizer. Authors are trusted and the directive pipeline emits intentional raw HTML
50
+ * (render.ts uses allowDangerousHtml + rehype-raw); sanitizing here would strip EC
51
+ * primitives. The Svelte component passes this to `new Carta(...)`.
52
+ */
53
+ export function previewCartaOptions(plugins: PreviewPlugins): PreviewCartaOptions {
54
+ return {
55
+ sanitizer: false,
56
+ rehypeOptions: { allowDangerousHtml: true },
57
+ extensions: [{ transformers: previewTransformers(plugins) }],
58
+ };
59
+ }
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ // Neutral admin chrome — robots noindex + a centered container, scoped to /admin. Shared
3
+ // across sites so the admin tool looks identical everywhere (only siteName, supplied by
4
+ // pages, varies). Each site's `admin/+layout.svelte` is a one-line shim around this.
5
+ import type { Snippet } from 'svelte';
6
+
7
+ let { children }: { children: Snippet } = $props();
8
+ </script>
9
+
10
+ <svelte:head>
11
+ <meta name="robots" content="noindex, nofollow" />
12
+ </svelte:head>
13
+
14
+ <div class="min-h-screen bg-base-200" data-pagefind-ignore>
15
+ <div class="mx-auto max-w-3xl px-4 py-8">
16
+ {@render children()}
17
+ </div>
18
+ </div>
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ // The /admin content list: every collection's files, linking into the editor. Data comes
3
+ // from `adminListLoad` (collections) merged with `adminLayoutLoad` (editor, siteName).
4
+ import type { Editor } from '../auth';
5
+ import type { AdminCollectionList } from '../sveltekit';
6
+
7
+ interface Props {
8
+ data: { siteName: string; editor: Editor | null; collections: AdminCollectionList[] };
9
+ }
10
+ let { data }: Props = $props();
11
+ </script>
12
+
13
+ <div class="flex items-center justify-between">
14
+ <h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
15
+ <form method="POST" action="/admin/auth/logout">
16
+ <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
17
+ </form>
18
+ </div>
19
+
20
+ <p class="mt-2 text-sm opacity-70">
21
+ Signed in as {data.editor?.name} ({data.editor?.email})
22
+ </p>
23
+
24
+ {#each data.collections as collection (collection.type)}
25
+ <section class="mt-8">
26
+ <h2 class="mb-3 text-lg font-semibold">{collection.label}</h2>
27
+ {#if collection.error}
28
+ <div class="alert alert-warning">Couldn't load {collection.label.toLowerCase()}: {collection.error}</div>
29
+ {:else if collection.files.length === 0}
30
+ <p class="opacity-60">No content yet.</p>
31
+ {:else}
32
+ <ul class="menu rounded-box border border-base-300 bg-base-100 p-2">
33
+ {#each collection.files as file (file.path)}
34
+ <li>
35
+ <a href="/admin/edit/{collection.type}/{file.id}">{file.id}</a>
36
+ </li>
37
+ {/each}
38
+ </ul>
39
+ {/if}
40
+ </section>
41
+ {/each}
@@ -0,0 +1,125 @@
1
+ <script lang="ts">
2
+ // The editor: a per-field frontmatter form (driven by the adapter's `fields`) plus a Carta
3
+ // markdown editor whose preview runs the site's plugin set (passed as `preview`). Data comes
4
+ // from `editLoad` merged with `adminLayoutLoad` (siteName); `carta-md` is a peer dependency.
5
+ import { onMount } from 'svelte';
6
+ import { Carta, MarkdownEditor } from 'carta-md';
7
+ import 'carta-md/default.css';
8
+ import { previewCartaOptions, type PreviewPlugins } from '../carta';
9
+ import type { CairnField } from '../adapter';
10
+ import type { EditData } from '../sveltekit';
11
+
12
+ let { data, preview }: { data: EditData & { siteName: string }; preview: PreviewPlugins } = $props();
13
+
14
+ // Body is editable state; the Carta editor's preview runs the exact site plugin set, so it
15
+ // matches the live page. A hidden input carries the current value into the form.
16
+ // svelte-ignore state_referenced_locally — seeding from the initial load is intended.
17
+ let body = $state(data.body);
18
+
19
+ // svelte-ignore state_referenced_locally — the preview plugin set is fixed for the load.
20
+ const carta = new Carta(previewCartaOptions(preview));
21
+
22
+ // Carta's MarkdownEditor must not render on the worker (it pulls Shiki). onMount fires only
23
+ // in the browser, so SSR renders the plain textarea and the client swaps in the editor —
24
+ // the kit-free equivalent of the per-site route's `$app/environment` `browser` guard.
25
+ let mounted = $state(false);
26
+ onMount(() => {
27
+ mounted = true;
28
+ });
29
+
30
+ // svelte-ignore state_referenced_locally — form defaults from the initial load.
31
+ const fm = data.frontmatter as Record<string, unknown>;
32
+
33
+ function fmString(key: string): string {
34
+ return typeof fm[key] === 'string' ? (fm[key] as string) : '';
35
+ }
36
+ function fmTags(key: string): Set<string> {
37
+ return new Set(Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String) : []);
38
+ }
39
+ function fmFreeTags(key: string): string {
40
+ return Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String).join(', ') : '';
41
+ }
42
+ </script>
43
+
44
+ <svelte:head>
45
+ <title>Edit {data.title} · {data.siteName} CMS</title>
46
+ </svelte:head>
47
+
48
+ <div class="flex items-center justify-between gap-4">
49
+ <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">{data.label} · {data.path}</p>
53
+ </div>
54
+ </div>
55
+
56
+ {#if data.saved}
57
+ <div class="alert alert-success mt-6"><span>Saved — committed to main; the site will redeploy.</span></div>
58
+ {:else if data.error}
59
+ <div class="alert alert-error mt-6"><span>{data.error}</span></div>
60
+ {/if}
61
+
62
+ <form method="POST" action="/admin/save" class="mt-6 flex flex-col gap-5">
63
+ <input type="hidden" name="type" value={data.type} />
64
+ <input type="hidden" name="id" value={data.id} />
65
+
66
+ <fieldset class="grid gap-4 rounded-box border border-base-300 bg-base-100 p-6">
67
+ {#each data.fields as field (field.name)}
68
+ {#if field.type === 'text' || field.type === 'date'}
69
+ <label class="flex flex-col gap-1">
70
+ <span class="text-sm font-medium">{field.label}</span>
71
+ <input
72
+ type={field.type === 'date' ? 'date' : 'text'}
73
+ name={field.name}
74
+ required={field.required}
75
+ value={fmString(field.name)}
76
+ class="input input-bordered w-full"
77
+ />
78
+ </label>
79
+ {:else if field.type === 'textarea'}
80
+ <label class="flex flex-col gap-1">
81
+ <span class="text-sm font-medium">{field.label}</span>
82
+ <textarea name={field.name} required={field.required} rows={field.rows ?? 4}
83
+ class="textarea textarea-bordered w-full">{fmString(field.name)}</textarea>
84
+ </label>
85
+ {:else if field.type === 'tags'}
86
+ <div class="flex flex-col gap-1">
87
+ <span class="text-sm font-medium">{field.label}</span>
88
+ <div class="flex flex-wrap gap-3">
89
+ {#each field.options as option (option)}
90
+ <label class="flex items-center gap-2 text-sm">
91
+ <input type="checkbox" name={field.name} value={option}
92
+ checked={fmTags(field.name).has(option)} class="checkbox checkbox-sm" />
93
+ {option}
94
+ </label>
95
+ {/each}
96
+ </div>
97
+ </div>
98
+ {:else if field.type === 'freetags'}
99
+ <label class="flex flex-col gap-1">
100
+ <span class="text-sm font-medium">{field.label}</span>
101
+ <input type="text" name={field.name} value={fmFreeTags(field.name)}
102
+ placeholder={field.placeholder ?? 'comma, separated'} class="input input-bordered w-full" />
103
+ </label>
104
+ {:else if field.type === 'boolean'}
105
+ <label class="flex items-center gap-2 text-sm font-medium">
106
+ <input type="checkbox" name={field.name} checked={fm[field.name] === true} class="checkbox checkbox-sm" />
107
+ {field.label}
108
+ </label>
109
+ {/if}
110
+ {/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 textarea-bordered w-full font-mono"></textarea>
119
+ {/if}
120
+ </div>
121
+
122
+ <div class="flex justify-end">
123
+ <button type="submit" class="btn btn-primary">Save &amp; commit</button>
124
+ </div>
125
+ </form>
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ // The magic-link sign-in page. Posts the email to /admin/auth/request; `sent`/`error` come
3
+ // from `loginLoad` (querystring) merged with `adminLayoutLoad` (siteName).
4
+ interface Props {
5
+ data: { siteName: string; sent: boolean; error: string | null };
6
+ }
7
+ let { data }: Props = $props();
8
+
9
+ const errorMessages: Record<string, string> = {
10
+ invalid: 'Please enter a valid email address.',
11
+ denied: 'That email is not on the editor allowlist.',
12
+ expired: 'That sign-in link has expired or was already used. Request a new one.',
13
+ config: 'Sign-in is not configured. Contact the site admin.',
14
+ };
15
+ </script>
16
+
17
+ <svelte:head>
18
+ <title>Sign in · {data.siteName} CMS</title>
19
+ </svelte:head>
20
+
21
+ <div class="mx-auto mt-16 max-w-md rounded-box border border-base-300 bg-base-100 p-8">
22
+ <h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
23
+ <p class="mt-1 text-sm opacity-70">Sign in with your editor email.</p>
24
+
25
+ {#if data.sent}
26
+ <div class="alert alert-success mt-6">
27
+ <span>Check your inbox — a sign-in link is on its way. It expires in 10 minutes.</span>
28
+ </div>
29
+ {:else}
30
+ {#if data.error}
31
+ <div class="alert alert-error mt-6">
32
+ <span>{errorMessages[data.error] ?? 'Something went wrong. Try again.'}</span>
33
+ </div>
34
+ {/if}
35
+ <form method="POST" action="/admin/auth/request" class="mt-6 flex flex-col gap-3">
36
+ <input
37
+ type="email"
38
+ name="email"
39
+ required
40
+ autocomplete="email"
41
+ placeholder="you@example.com"
42
+ class="input input-bordered w-full"
43
+ />
44
+ <button type="submit" class="btn btn-primary">Email me a sign-in link</button>
45
+ </form>
46
+ {/if}
47
+ </div>
@@ -0,0 +1,6 @@
1
+ // cairn-cms admin UI shell. Consumers import from 'cairn-cms/components'; each site's
2
+ // admin route `.svelte` files are one-line shims around these.
3
+ export { default as AdminLayout } from './AdminLayout.svelte';
4
+ export { default as AdminList } from './AdminList.svelte';
5
+ export { default as LoginPage } from './LoginPage.svelte';
6
+ export { default as EditPage } from './EditPage.svelte';
@@ -0,0 +1,11 @@
1
+ // cairn-core: reassemble a markdown file from frontmatter + body for committing.
2
+ //
3
+ // The inverse of the gray-matter parse the edit loader does on read. Kept as its own seam
4
+ // so a site adapter can own the on-disk serialization contract (quoting, key order)
5
+ // without the save endpoint reaching for gray-matter directly.
6
+ import matter from 'gray-matter';
7
+
8
+ /** Serialize frontmatter data + markdown body back into a file string. */
9
+ export function serializeMarkdown(frontmatter: object, body: string): string {
10
+ return matter.stringify(body, frontmatter);
11
+ }
@@ -0,0 +1,35 @@
1
+ // cairn-core: pluggable magic-link email sender.
2
+ //
3
+ // Default adapter is Cloudflare Email Service → Email Sending (transactional, arbitrary
4
+ // recipients) — distinct from Email Routing's recipient-restricted `EmailMessage` flow.
5
+ // It is reached through the same `send_email` binding (configured without a
6
+ // destination_address) but a different call shape: `binding.send({ to, from, ... })`.
7
+ // Resend can slot in behind the same `sendMagicLink` signature if needed.
8
+
9
+ /** Cloudflare Email Sending binding surface (the object-form `send`, not the MIME form). */
10
+ export interface EmailSender {
11
+ send(message: {
12
+ to: string;
13
+ from: string;
14
+ subject: string;
15
+ text?: string;
16
+ html?: string;
17
+ }): Promise<{ messageId: string }>;
18
+ }
19
+
20
+ export async function sendMagicLink(
21
+ sender: EmailSender,
22
+ to: string,
23
+ link: string,
24
+ siteName: string,
25
+ from: string,
26
+ ): Promise<void> {
27
+ const expiry = "This link expires in 10 minutes and works only once. If you didn't request it, ignore this email.";
28
+ await sender.send({
29
+ to,
30
+ from,
31
+ subject: `Your ${siteName} sign-in link`,
32
+ text: `Sign in to ${siteName}:\n\n${link}\n\n${expiry}`,
33
+ html: `<p>Sign in to ${siteName}:</p><p><a href="${link}">Sign in</a></p><p style="color:#666;font-size:0.9em">${expiry}</p>`,
34
+ });
35
+ }