@glw907/cairn-cms 0.5.1 → 0.6.0-rc.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/auth/crypto.d.ts +13 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +31 -0
- package/dist/auth/store.d.ts +41 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +115 -0
- package/dist/auth/types.d.ts +25 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +1 -0
- package/dist/components/AdminLayout.svelte +58 -164
- package/dist/components/AdminLayout.svelte.d.ts +14 -18
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/ComponentPalette.svelte +36 -20
- package/dist/components/ComponentPalette.svelte.d.ts +11 -4
- package/dist/components/ComponentPalette.svelte.d.ts.map +1 -1
- package/dist/components/ConceptList.svelte +81 -0
- package/dist/components/ConceptList.svelte.d.ts +13 -0
- package/dist/components/ConceptList.svelte.d.ts.map +1 -0
- package/dist/components/ConfirmPage.svelte +23 -20
- package/dist/components/ConfirmPage.svelte.d.ts +6 -0
- package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +155 -136
- package/dist/components/EditPage.svelte.d.ts +16 -8
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/LoginPage.svelte +42 -52
- package/dist/components/LoginPage.svelte.d.ts +12 -0
- package/dist/components/LoginPage.svelte.d.ts.map +1 -1
- package/dist/components/ManageEditors.svelte +81 -0
- package/dist/components/ManageEditors.svelte.d.ts +23 -0
- package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
- package/dist/components/MarkdownEditor.svelte +81 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
- package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
- package/dist/components/NavTree.svelte +73 -63
- package/dist/components/NavTree.svelte.d.ts +13 -4
- package/dist/components/NavTree.svelte.d.ts.map +1 -1
- package/dist/components/cairn-admin.css +42 -0
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +5 -4
- package/dist/content/compose.d.ts +7 -0
- package/dist/content/compose.d.ts.map +1 -0
- package/dist/content/compose.js +32 -0
- package/dist/content/concepts.d.ts +17 -0
- package/dist/content/concepts.d.ts.map +1 -0
- package/dist/content/concepts.js +41 -0
- package/dist/content/frontmatter.d.ts +18 -0
- package/dist/content/frontmatter.d.ts.map +1 -0
- package/dist/content/frontmatter.js +58 -0
- package/dist/content/ids.d.ts +17 -0
- package/dist/content/ids.d.ts.map +1 -0
- package/dist/content/ids.js +33 -0
- package/dist/content/types.d.ts +210 -0
- package/dist/content/types.d.ts.map +1 -0
- package/dist/content/types.js +1 -0
- package/dist/content/validate.d.ts +13 -0
- package/dist/content/validate.d.ts.map +1 -0
- package/dist/content/validate.js +45 -0
- package/dist/email.d.ts +25 -12
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +24 -24
- package/dist/env.d.ts +24 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +29 -0
- package/dist/github/credentials.d.ts +12 -0
- package/dist/github/credentials.d.ts.map +1 -0
- package/dist/github/credentials.js +11 -0
- package/dist/github/repo.d.ts +49 -0
- package/dist/github/repo.d.ts.map +1 -0
- package/dist/github/repo.js +123 -0
- package/dist/github/signing.d.ts +17 -0
- package/dist/github/signing.d.ts.map +1 -0
- package/dist/github/signing.js +79 -0
- package/dist/github/types.d.ts +35 -0
- package/dist/github/types.d.ts.map +1 -0
- package/dist/github/types.js +19 -0
- package/dist/index.d.ts +27 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -10
- package/dist/{nav.d.ts → nav/site-config.d.ts} +16 -24
- package/dist/nav/site-config.d.ts.map +1 -0
- package/dist/{nav.js → nav/site-config.js} +27 -13
- package/dist/render/glyph.d.ts +1 -1
- package/dist/render/glyph.d.ts.map +1 -1
- package/dist/render/index.d.ts +5 -5
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +6 -6
- package/dist/render/pipeline.d.ts +7 -6
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +5 -5
- package/dist/render/registry.d.ts +10 -6
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +8 -6
- package/dist/render/rehype-dispatch.d.ts +8 -7
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +16 -14
- package/dist/render/remark-directives.d.ts +1 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/sanitize.d.ts +8 -0
- package/dist/render/sanitize.d.ts.map +1 -0
- package/dist/render/sanitize.js +26 -0
- package/dist/sveltekit/auth-routes.d.ts +23 -0
- package/dist/sveltekit/auth-routes.d.ts.map +1 -0
- package/dist/sveltekit/auth-routes.js +85 -0
- package/dist/sveltekit/content-routes.d.ts +80 -0
- package/dist/sveltekit/content-routes.d.ts.map +1 -0
- package/dist/sveltekit/content-routes.js +183 -0
- package/dist/sveltekit/editors-routes.d.ts +24 -0
- package/dist/sveltekit/editors-routes.d.ts.map +1 -0
- package/dist/sveltekit/editors-routes.js +73 -0
- package/dist/sveltekit/guard.d.ts +9 -0
- package/dist/sveltekit/guard.d.ts.map +1 -0
- package/dist/sveltekit/guard.js +43 -0
- package/dist/sveltekit/health.d.ts +19 -0
- package/dist/sveltekit/health.d.ts.map +1 -0
- package/dist/sveltekit/health.js +12 -0
- package/dist/sveltekit/index.d.ts +9 -173
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +8 -348
- package/dist/sveltekit/nav-routes.d.ts +30 -0
- package/dist/sveltekit/nav-routes.d.ts.map +1 -0
- package/dist/sveltekit/nav-routes.js +103 -0
- package/dist/sveltekit/types.d.ts +32 -0
- package/dist/sveltekit/types.d.ts.map +1 -0
- package/dist/sveltekit/types.js +1 -0
- package/package.json +33 -57
- package/src/lib/auth/crypto.ts +37 -0
- package/src/lib/auth/store.ts +158 -0
- package/src/lib/auth/types.ts +27 -0
- package/src/lib/components/AdminLayout.svelte +58 -164
- package/src/lib/components/ComponentPalette.svelte +36 -20
- package/src/lib/components/ConceptList.svelte +81 -0
- package/src/lib/components/ConfirmPage.svelte +23 -20
- package/src/lib/components/EditPage.svelte +155 -136
- package/src/lib/components/LoginPage.svelte +42 -52
- package/src/lib/components/ManageEditors.svelte +81 -0
- package/src/lib/components/MarkdownEditor.svelte +81 -0
- package/src/lib/components/NavTree.svelte +73 -63
- package/src/lib/components/cairn-admin.css +42 -0
- package/src/lib/components/index.ts +5 -4
- package/src/lib/content/compose.ts +39 -0
- package/src/lib/content/concepts.ts +57 -0
- package/src/lib/content/frontmatter.ts +71 -0
- package/src/lib/content/ids.ts +38 -0
- package/src/lib/content/types.ts +235 -0
- package/src/lib/content/validate.ts +51 -0
- package/src/lib/email.ts +52 -38
- package/src/lib/env.ts +32 -0
- package/src/lib/github/credentials.ts +27 -0
- package/src/lib/github/repo.ts +138 -0
- package/src/lib/github/signing.ts +97 -0
- package/src/lib/github/types.ts +46 -0
- package/src/lib/index.ts +86 -10
- package/src/lib/{nav.ts → nav/site-config.ts} +31 -24
- package/src/lib/render/glyph.ts +6 -6
- package/src/lib/render/index.ts +6 -6
- package/src/lib/render/pipeline.ts +23 -22
- package/src/lib/render/registry.ts +35 -26
- package/src/lib/render/rehype-dispatch.ts +58 -56
- package/src/lib/render/remark-directives.ts +46 -46
- package/src/lib/render/sanitize.ts +27 -0
- package/src/lib/sveltekit/auth-routes.ts +107 -0
- package/src/lib/sveltekit/content-routes.ts +261 -0
- package/src/lib/sveltekit/editors-routes.ts +82 -0
- package/src/lib/sveltekit/guard.ts +47 -0
- package/src/lib/sveltekit/health.ts +24 -0
- package/src/lib/sveltekit/index.ts +19 -512
- package/src/lib/sveltekit/nav-routes.ts +139 -0
- package/src/lib/sveltekit/types.ts +33 -0
- package/dist/adapter.d.ts +0 -93
- package/dist/adapter.d.ts.map +0 -1
- package/dist/adapter.js +0 -30
- package/dist/auth/admins.d.ts +0 -33
- package/dist/auth/admins.d.ts.map +0 -1
- package/dist/auth/admins.js +0 -90
- package/dist/auth/capabilities.d.ts +0 -7
- package/dist/auth/capabilities.d.ts.map +0 -1
- package/dist/auth/capabilities.js +0 -26
- package/dist/auth/config.d.ts +0 -2097
- package/dist/auth/config.d.ts.map +0 -1
- package/dist/auth/config.js +0 -78
- package/dist/auth/guard.d.ts +0 -34
- package/dist/auth/guard.d.ts.map +0 -1
- package/dist/auth/guard.js +0 -47
- package/dist/auth/index.d.ts +0 -5
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -7
- package/dist/auth/schema.d.ts +0 -750
- package/dist/auth/schema.d.ts.map +0 -1
- package/dist/auth/schema.js +0 -93
- package/dist/carta.d.ts +0 -39
- package/dist/carta.d.ts.map +0 -1
- package/dist/carta.js +0 -30
- package/dist/components/CollectionList.svelte +0 -96
- package/dist/components/CollectionList.svelte.d.ts +0 -8
- package/dist/components/CollectionList.svelte.d.ts.map +0 -1
- package/dist/components/ManageAdmins.svelte +0 -84
- package/dist/components/ManageAdmins.svelte.d.ts +0 -10
- package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
- package/dist/content.d.ts +0 -3
- package/dist/content.d.ts.map +0 -1
- package/dist/content.js +0 -10
- package/dist/editor.d.ts +0 -25
- package/dist/editor.d.ts.map +0 -1
- package/dist/editor.js +0 -20
- package/dist/frontmatter.d.ts +0 -3
- package/dist/frontmatter.d.ts.map +0 -1
- package/dist/frontmatter.js +0 -16
- package/dist/github.d.ts +0 -72
- package/dist/github.d.ts.map +0 -1
- package/dist/github.js +0 -171
- package/dist/nav.d.ts.map +0 -1
- package/dist/slug.d.ts +0 -7
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -15
- package/dist/utils.d.ts +0 -3
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -11
- package/src/lib/adapter.ts +0 -144
- package/src/lib/auth/admins.ts +0 -106
- package/src/lib/auth/capabilities.ts +0 -35
- package/src/lib/auth/config.ts +0 -108
- package/src/lib/auth/guard.ts +0 -60
- package/src/lib/auth/index.ts +0 -7
- package/src/lib/auth/schema.ts +0 -112
- package/src/lib/carta.ts +0 -59
- package/src/lib/components/CollectionList.svelte +0 -96
- package/src/lib/components/ManageAdmins.svelte +0 -84
- package/src/lib/content.ts +0 -11
- package/src/lib/editor.ts +0 -38
- package/src/lib/frontmatter.ts +0 -17
- package/src/lib/github.ts +0 -220
- package/src/lib/slug.ts +0 -16
- package/src/lib/utils.ts +0 -12
|
@@ -1,31 +1,47 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The insert-component palette: a dropdown listing the site's registered directive components
|
|
4
|
+
(seam 3). Picking one inserts its template at the cursor through the editor's insert callback.
|
|
5
|
+
Renders nothing when the site configures no registry.
|
|
6
|
+
-->
|
|
1
7
|
<script lang="ts">
|
|
2
|
-
|
|
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';
|
|
8
|
+
import type { ComponentRegistry } from '../render/registry.js';
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The site's component registry; the palette derives its catalog from it. */
|
|
12
|
+
registry?: ComponentRegistry;
|
|
13
|
+
/** Insert a template at the editor's cursor. */
|
|
14
|
+
insert: (template: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { registry, insert }: Props = $props();
|
|
10
18
|
|
|
11
19
|
const defs = $derived(registry?.defs ?? []);
|
|
20
|
+
let open = $state(false);
|
|
12
21
|
</script>
|
|
13
22
|
|
|
14
23
|
{#if defs.length > 0}
|
|
15
|
-
<div
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
<div
|
|
25
|
+
class="dropdown"
|
|
26
|
+
class:dropdown-open={open}
|
|
27
|
+
role="presentation"
|
|
28
|
+
onkeydown={(e) => { if (e.key === 'Escape') open = false; }}
|
|
29
|
+
>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="btn btn-sm btn-ghost"
|
|
33
|
+
aria-haspopup="listbox"
|
|
34
|
+
aria-expanded={open}
|
|
35
|
+
onclick={() => (open = !open)}
|
|
36
|
+
>Insert</button>
|
|
37
|
+
<ul class="dropdown-content menu rounded-box border border-base-300 bg-base-100 z-10 w-56 shadow" role="listbox">
|
|
20
38
|
{#each defs as def (def.name)}
|
|
21
|
-
<li>
|
|
22
|
-
<button
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<span class="font-medium">{def.label}</span>
|
|
28
|
-
<span class="text-xs opacity-60">{def.description}</span>
|
|
39
|
+
<li role="option" aria-selected={false}>
|
|
40
|
+
<button type="button" onclick={() => { insert(def.insertTemplate); open = false; }}>
|
|
41
|
+
<span class="flex flex-col items-start">
|
|
42
|
+
<span class="font-medium">{def.label}</span>
|
|
43
|
+
<span class="text-xs text-[var(--color-muted)]">{def.description}</span>
|
|
44
|
+
</span>
|
|
29
45
|
</button>
|
|
30
46
|
</li>
|
|
31
47
|
{/each}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
One concept's list view: every entry as a link to its editor, with title, date, and a draft badge,
|
|
4
|
+
plus a new-entry form. The slug auto-derives from the title until the author edits the slug field.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { slugify } from '../content/ids.js';
|
|
8
|
+
import type { ListData } from '../sveltekit/content-routes.js';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The list load's data: the concept, its entries, and any inline or form errors. */
|
|
12
|
+
data: ListData;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { data }: Props = $props();
|
|
16
|
+
|
|
17
|
+
let title = $state('');
|
|
18
|
+
let slug = $state('');
|
|
19
|
+
let slugEdited = $state(false);
|
|
20
|
+
|
|
21
|
+
const derivedSlug = $derived(slugEdited ? slug : slugify(title));
|
|
22
|
+
const slugPlaceholder = $derived(data.dated ? '2026-05-my-entry' : 'about-us');
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<header class="mb-4 flex items-center justify-between">
|
|
26
|
+
<h1 class="text-xl font-semibold">{data.label}</h1>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
{#if data.formError}
|
|
30
|
+
<div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
|
|
31
|
+
{/if}
|
|
32
|
+
|
|
33
|
+
{#if data.error}
|
|
34
|
+
<div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
|
|
35
|
+
{/if}
|
|
36
|
+
|
|
37
|
+
<div class="rounded-box border border-base-300 bg-base-100 mb-6">
|
|
38
|
+
{#if data.entries.length === 0}
|
|
39
|
+
<p class="p-4 text-sm opacity-70">No entries yet.</p>
|
|
40
|
+
{:else}
|
|
41
|
+
<ul class="menu w-full">
|
|
42
|
+
{#each data.entries as entry (entry.id)}
|
|
43
|
+
<li>
|
|
44
|
+
<a href={`/admin/${data.conceptId}/${entry.id}`} class="flex items-center justify-between">
|
|
45
|
+
<span>{entry.title}</span>
|
|
46
|
+
<span class="flex items-center gap-2 text-xs text-[var(--color-muted)]">
|
|
47
|
+
{#if entry.date}<span>{entry.date}</span>{/if}
|
|
48
|
+
{#if entry.draft}<span class="badge badge-warning badge-sm">Draft</span>{/if}
|
|
49
|
+
</span>
|
|
50
|
+
</a>
|
|
51
|
+
</li>
|
|
52
|
+
{/each}
|
|
53
|
+
</ul>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<form method="POST" action="?/create" class="rounded-box border border-base-300 bg-base-100 flex flex-col gap-3 p-4">
|
|
58
|
+
<h2 class="text-sm font-semibold">New entry</h2>
|
|
59
|
+
<label class="flex flex-col gap-1">
|
|
60
|
+
<span class="text-sm font-medium">Title</span>
|
|
61
|
+
<input class="input" name="title" aria-label="Title" bind:value={title} required />
|
|
62
|
+
</label>
|
|
63
|
+
<label class="flex flex-col gap-1">
|
|
64
|
+
<span class="text-sm font-medium">Slug</span>
|
|
65
|
+
<input
|
|
66
|
+
class="input"
|
|
67
|
+
name="slug"
|
|
68
|
+
aria-label="Slug"
|
|
69
|
+
placeholder={slugPlaceholder}
|
|
70
|
+
value={derivedSlug}
|
|
71
|
+
oninput={(e) => { slugEdited = true; slug = e.currentTarget.value; }}
|
|
72
|
+
/>
|
|
73
|
+
</label>
|
|
74
|
+
{#if data.dated}
|
|
75
|
+
<label class="flex flex-col gap-1">
|
|
76
|
+
<span class="text-sm font-medium">Date</span>
|
|
77
|
+
<input class="input" type="date" name="date" aria-label="Date" />
|
|
78
|
+
</label>
|
|
79
|
+
{/if}
|
|
80
|
+
<button type="submit" class="btn btn-primary self-start">Create</button>
|
|
81
|
+
</form>
|
|
@@ -1,31 +1,34 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The scanner-safe confirm page. A GET renders this static "Confirm sign-in" button with the token
|
|
4
|
+
in a hidden field and consumes nothing; only the explicit POST verifies (spec §7.1). JS-free.
|
|
5
|
+
-->
|
|
1
6
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// confirmSignIn) verifies it. Mail scanners GET URLs but don't submit forms, so prefetch can't
|
|
5
|
-
// burn the link. JS-free by design.
|
|
7
|
+
import './cairn-admin.css';
|
|
8
|
+
|
|
6
9
|
interface Props {
|
|
10
|
+
/** The confirm load's data: the token to submit, the site name, and an optional error. */
|
|
7
11
|
data: { token: string; siteName: string; error: string | null };
|
|
8
12
|
}
|
|
13
|
+
|
|
9
14
|
let { data }: Props = $props();
|
|
10
15
|
</script>
|
|
11
16
|
|
|
12
17
|
<svelte:head>
|
|
13
|
-
<
|
|
18
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
14
19
|
</svelte:head>
|
|
15
20
|
|
|
16
|
-
<div
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
</form>
|
|
30
|
-
{/if}
|
|
21
|
+
<div data-theme="cairn-admin" class="bg-base-200 text-base-content flex min-h-screen items-center justify-center p-4">
|
|
22
|
+
<div class="rounded-box border border-base-300 bg-base-100 w-full max-w-sm p-6 text-center shadow">
|
|
23
|
+
<h1 class="mb-4 text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
24
|
+
{#if data.error || !data.token}
|
|
25
|
+
<div role="alert" class="alert alert-error text-sm">This sign-in link is invalid or expired.</div>
|
|
26
|
+
<a href="/admin/login" class="btn btn-ghost btn-sm mt-4">Request a new link</a>
|
|
27
|
+
{:else}
|
|
28
|
+
<form method="POST">
|
|
29
|
+
<input type="hidden" name="token" value={data.token} />
|
|
30
|
+
<button type="submit" class="btn btn-primary btn-block">Confirm sign-in</button>
|
|
31
|
+
</form>
|
|
32
|
+
{/if}
|
|
33
|
+
</div>
|
|
31
34
|
</div>
|
|
@@ -1,163 +1,182 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the Carta
|
|
4
|
+
markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
5
|
+
`?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
|
|
6
|
+
-->
|
|
1
7
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
8
|
-
import { onMount } from 'svelte';
|
|
9
|
-
import { Carta, MarkdownEditor } from 'carta-md';
|
|
10
|
-
import 'carta-md/default.css';
|
|
11
|
-
import { previewCartaOptions, type PreviewPlugins } from '../carta';
|
|
12
|
-
import type { CairnField } from '../adapter';
|
|
13
|
-
import type { ComponentRegistry } from '../render';
|
|
14
|
-
import type { EditData } from '../sveltekit';
|
|
15
|
-
import { cartaEditor } from '../editor';
|
|
16
|
-
import { dateInputValue } from '../frontmatter';
|
|
8
|
+
import { untrack } from 'svelte';
|
|
9
|
+
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
17
10
|
import ComponentPalette from './ComponentPalette.svelte';
|
|
11
|
+
import type { ComponentRegistry } from '../render/registry.js';
|
|
12
|
+
import type { EditData } from '../sveltekit/content-routes.js';
|
|
13
|
+
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
14
|
+
import { sanitizePreviewHtml } from '../render/sanitize.js';
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
data,
|
|
21
|
-
|
|
22
|
-
registry,
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
interface Props {
|
|
17
|
+
/** The edit load's data, plus the site name for the heading. */
|
|
18
|
+
data: EditData & { siteName: string };
|
|
19
|
+
/** The site's component registry, for the insert palette. */
|
|
20
|
+
registry?: ComponentRegistry;
|
|
21
|
+
/** Carta preview plugins from the adapter, for the design-accurate preview. */
|
|
22
|
+
preview?: unknown[];
|
|
23
|
+
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
24
|
+
renderPreview?: (md: string) => string | Promise<string>;
|
|
25
|
+
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
// matches the live page. A hidden input carries the current value into the form.
|
|
28
|
-
// svelte-ignore state_referenced_locally (seeding from the initial load is intended)
|
|
29
|
-
let body = $state(data.body);
|
|
27
|
+
let { data, registry, preview = [], renderPreview }: Props = $props();
|
|
30
28
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// `body` is local editor state seeded once from the prop; it diverges as the user types.
|
|
30
|
+
// untrack() captures the initial value without subscribing to future prop changes.
|
|
31
|
+
let body = $state(untrack(() => data.body));
|
|
32
|
+
let showPreview = $state(false);
|
|
33
|
+
let previewHtml = $state('');
|
|
34
|
+
let insert = $state.raw<(text: string) => void>(() => {});
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
// the browser, so SSR renders the plain textarea and the client swaps in the editor.
|
|
37
|
-
let mounted = $state(false);
|
|
36
|
+
const PREVIEW_KEY = 'cairn-admin:preview';
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
onMount(() => {
|
|
43
|
-
mounted = true;
|
|
44
|
-
const saved = localStorage.getItem('cairn-admin:preview');
|
|
45
|
-
if (saved === 'tabs' || saved === 'split') mode = saved;
|
|
38
|
+
$effect(() => {
|
|
39
|
+
// Restore the per-user preference once, on mount.
|
|
40
|
+
showPreview = localStorage.getItem(PREVIEW_KEY) === '1';
|
|
46
41
|
});
|
|
42
|
+
|
|
47
43
|
function togglePreview() {
|
|
48
|
-
|
|
49
|
-
localStorage.setItem('
|
|
44
|
+
showPreview = !showPreview;
|
|
45
|
+
localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
//
|
|
53
|
-
|
|
48
|
+
// Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
|
|
49
|
+
// The sanitize is the one barrier between editor-authored markdown and the page (Carta is unsanitized).
|
|
50
|
+
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
51
|
+
// async renderPreview call resolves after a newer one has started, the stale result is discarded.
|
|
52
|
+
let previewRun = 0;
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (!showPreview || !renderPreview) return;
|
|
55
|
+
const md = body;
|
|
56
|
+
const run = ++previewRun;
|
|
57
|
+
const handle = setTimeout(async () => {
|
|
58
|
+
try {
|
|
59
|
+
const html = await renderPreview(md);
|
|
60
|
+
const safe = await sanitizePreviewHtml(html);
|
|
61
|
+
if (run === previewRun) previewHtml = safe;
|
|
62
|
+
} catch {
|
|
63
|
+
if (run === previewRun) previewHtml = '';
|
|
64
|
+
}
|
|
65
|
+
}, 150);
|
|
66
|
+
return () => clearTimeout(handle);
|
|
67
|
+
});
|
|
54
68
|
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
// Coerce a frontmatter value to a string for text/date/textarea inputs.
|
|
70
|
+
function str(v: unknown): string {
|
|
71
|
+
return v == null ? '' : String(v);
|
|
57
72
|
}
|
|
58
|
-
function fmTags(key: string): Set<string> {
|
|
59
|
-
return new Set(Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String) : []);
|
|
60
|
-
}
|
|
61
|
-
function fmFreeTags(key: string): string {
|
|
62
|
-
return Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String).join(', ') : '';
|
|
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
|
-
);
|
|
71
73
|
</script>
|
|
72
74
|
|
|
73
|
-
<
|
|
74
|
-
<title>{data.isNew ? `New ${data.label} entry` : `Edit ${data.title}`} · {data.siteName} CMS</title>
|
|
75
|
-
</svelte:head>
|
|
76
|
-
|
|
77
|
-
<div class="flex items-center justify-between gap-4">
|
|
75
|
+
<header class="mb-4 flex items-center justify-between gap-2">
|
|
78
76
|
<div>
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
77
|
+
<h1 class="text-xl font-semibold">{data.title}</h1>
|
|
78
|
+
<p class="text-xs text-[var(--color-muted)]">{data.label}: {data.id}</p>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="flex items-center gap-2">
|
|
81
|
+
<ComponentPalette {registry} {insert} />
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
class="btn btn-sm btn-ghost"
|
|
85
|
+
aria-expanded={showPreview}
|
|
86
|
+
aria-controls="cairn-preview"
|
|
87
|
+
onclick={togglePreview}
|
|
88
|
+
>
|
|
89
|
+
{showPreview ? 'Hide preview' : 'Show preview'}
|
|
90
|
+
</button>
|
|
82
91
|
</div>
|
|
83
|
-
</
|
|
92
|
+
</header>
|
|
84
93
|
|
|
85
94
|
{#if data.saved}
|
|
86
|
-
<div class="alert alert-success
|
|
87
|
-
{
|
|
88
|
-
|
|
95
|
+
<div role="status" class="alert alert-success mb-4 text-sm">Saved.</div>
|
|
96
|
+
{/if}
|
|
97
|
+
{#if data.error}
|
|
98
|
+
<div role="alert" class="alert alert-error mb-4 text-sm">{data.error}</div>
|
|
89
99
|
{/if}
|
|
90
100
|
|
|
91
|
-
<form method="POST" action="
|
|
92
|
-
<input type="hidden" name="type" value={data.type} />
|
|
93
|
-
<input type="hidden" name="id" value={data.id} />
|
|
101
|
+
<form method="POST" action="?/save" class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
|
|
94
102
|
{#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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}
|
|
104
|
+
<div class="lg:order-1">
|
|
105
|
+
<div class="rounded-box border border-base-300 bg-base-100 overflow-hidden">
|
|
106
|
+
<MarkdownEditor bind:value={body} name="body" plugins={preview} registerInsert={(fn) => (insert = fn)} />
|
|
111
107
|
</div>
|
|
108
|
+
{#if showPreview}
|
|
109
|
+
<section
|
|
110
|
+
id="cairn-preview"
|
|
111
|
+
aria-label="Preview"
|
|
112
|
+
class="rounded-box border border-base-300 bg-base-100 prose mt-4 max-w-none p-4"
|
|
113
|
+
>
|
|
114
|
+
{@html previewHtml}
|
|
115
|
+
</section>
|
|
116
|
+
{/if}
|
|
112
117
|
</div>
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
name={
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
class="
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
119
|
+
<aside class="lg:order-2 mt-4 lg:mt-0">
|
|
120
|
+
<fieldset class="rounded-box border border-base-300 bg-base-100 flex flex-col gap-3 p-4">
|
|
121
|
+
<legend class="sr-only">Frontmatter</legend>
|
|
122
|
+
{#each data.fields as field (field.name)}
|
|
123
|
+
{#if field.type === 'textarea'}
|
|
124
|
+
{@const f = field as TextareaField}
|
|
125
|
+
<label class="flex flex-col gap-1">
|
|
126
|
+
<span class="text-sm font-medium">{f.label}</span>
|
|
127
|
+
<textarea class="textarea" name={f.name} aria-label={f.label} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
|
|
128
|
+
</label>
|
|
129
|
+
{:else if field.type === 'date'}
|
|
130
|
+
<label class="flex flex-col gap-1">
|
|
131
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
132
|
+
<input class="input" type="date" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} />
|
|
133
|
+
</label>
|
|
134
|
+
{:else if field.type === 'boolean'}
|
|
135
|
+
<label class="label cursor-pointer justify-start gap-2">
|
|
136
|
+
<input class="checkbox checkbox-sm" type="checkbox" name={field.name} aria-label={field.label} checked={data.frontmatter[field.name] === true} />
|
|
137
|
+
<span class="text-sm">{field.label}</span>
|
|
138
|
+
</label>
|
|
139
|
+
{:else if field.type === 'tags'}
|
|
140
|
+
{@const f = field as TagsField}
|
|
141
|
+
{@const selected = (data.frontmatter[f.name] ?? []) as string[]}
|
|
142
|
+
<fieldset class="fieldset">
|
|
143
|
+
<legend class="fieldset-legend">{f.label}</legend>
|
|
144
|
+
<div class="flex flex-wrap gap-2">
|
|
145
|
+
{#each f.options as option (option)}
|
|
146
|
+
<label class="label cursor-pointer justify-start gap-2">
|
|
147
|
+
<input
|
|
148
|
+
class="checkbox checkbox-sm"
|
|
149
|
+
type="checkbox"
|
|
150
|
+
name={f.name}
|
|
151
|
+
value={option}
|
|
152
|
+
checked={selected.includes(option)}
|
|
153
|
+
/>
|
|
154
|
+
<span class="text-sm">{option}</span>
|
|
155
|
+
</label>
|
|
156
|
+
{/each}
|
|
157
|
+
</div>
|
|
158
|
+
</fieldset>
|
|
159
|
+
{:else if field.type === 'freetags'}
|
|
160
|
+
{@const f = field as FreeTagsField}
|
|
161
|
+
{@const tagValue = ((data.frontmatter[f.name] ?? []) as string[]).join(', ')}
|
|
162
|
+
<label class="flex flex-col gap-1">
|
|
163
|
+
<span class="text-sm font-medium">{f.label}</span>
|
|
164
|
+
<input
|
|
165
|
+
class="input"
|
|
166
|
+
name={f.name}
|
|
167
|
+
aria-label={f.label}
|
|
168
|
+
placeholder={f.placeholder}
|
|
169
|
+
value={tagValue}
|
|
170
|
+
/>
|
|
171
|
+
</label>
|
|
172
|
+
{:else}
|
|
173
|
+
<label class="flex flex-col gap-1">
|
|
174
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
175
|
+
<input class="input" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} required={field.required} />
|
|
176
|
+
</label>
|
|
177
|
+
{/if}
|
|
178
|
+
{/each}
|
|
179
|
+
<button type="submit" class="btn btn-primary mt-2">Save</button>
|
|
180
|
+
</fieldset>
|
|
181
|
+
</aside>
|
|
163
182
|
</form>
|
|
@@ -1,64 +1,54 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The magic-link sign-in page. A plain form POST to the page's default action (the engine's
|
|
4
|
+
`requestAction`); no client SDK. The success message is identical whether or not the email is on
|
|
5
|
+
the allowlist, so the page never leaks membership (spec §7.1).
|
|
6
|
+
-->
|
|
1
7
|
<script lang="ts">
|
|
2
|
-
|
|
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
|
-
import { createAuthClient } from 'better-auth/svelte';
|
|
6
|
-
import { magicLinkClient } from 'better-auth/client/plugins';
|
|
7
|
-
|
|
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
|
-
// A component-local const keeps better-auth's deep client types out of the packaged .d.ts.
|
|
11
|
-
const authClient = createAuthClient({ plugins: [magicLinkClient()] });
|
|
8
|
+
import './cairn-admin.css';
|
|
12
9
|
|
|
13
10
|
interface Props {
|
|
14
|
-
data:
|
|
11
|
+
/** The login load's data: the site name and an optional error. */
|
|
12
|
+
data: { siteName: string; error: string | null };
|
|
13
|
+
/** The action result: `sent` is true once a request was accepted. */
|
|
14
|
+
form: { sent?: boolean } | null;
|
|
15
15
|
}
|
|
16
|
-
let { data }: Props = $props();
|
|
17
|
-
|
|
18
|
-
let email = $state('');
|
|
19
|
-
let requested = $state(false);
|
|
20
|
-
let busy = $state(false);
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
event.preventDefault();
|
|
24
|
-
busy = true;
|
|
25
|
-
// The magic-link email points at our /admin/auth/confirm page (built in config.ts), not a
|
|
26
|
-
// GET-verify URL, so the result is the same regardless of allowlist membership.
|
|
27
|
-
await authClient.signIn.magicLink({ email });
|
|
28
|
-
busy = false;
|
|
29
|
-
requested = true;
|
|
30
|
-
}
|
|
17
|
+
let { data, form }: Props = $props();
|
|
31
18
|
</script>
|
|
32
19
|
|
|
33
20
|
<svelte:head>
|
|
34
|
-
<
|
|
21
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
35
22
|
</svelte:head>
|
|
36
23
|
|
|
37
|
-
<div
|
|
38
|
-
<
|
|
39
|
-
|
|
24
|
+
<div data-theme="cairn-admin" class="bg-base-200 text-base-content flex min-h-screen items-center justify-center p-4">
|
|
25
|
+
<div class="rounded-box border border-base-300 bg-base-100 w-full max-w-sm p-6 shadow">
|
|
26
|
+
<h1 class="mb-1 text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
27
|
+
<p class="mb-4 text-sm text-[var(--color-muted)]">Enter your email and we'll send a sign-in link.</p>
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
29
|
+
{#if form?.sent}
|
|
30
|
+
<div role="status" class="alert alert-success text-sm">
|
|
31
|
+
Check your email for a sign-in link. It expires in 10 minutes.
|
|
32
|
+
</div>
|
|
33
|
+
{:else}
|
|
34
|
+
{#if data.error}
|
|
35
|
+
<div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one.</div>
|
|
36
|
+
{/if}
|
|
37
|
+
<form method="POST" class="flex flex-col gap-3">
|
|
38
|
+
<label class="flex flex-col gap-1">
|
|
39
|
+
<span class="text-sm font-medium">Email</span>
|
|
40
|
+
<input
|
|
41
|
+
type="email"
|
|
42
|
+
name="email"
|
|
43
|
+
required
|
|
44
|
+
autocomplete="email"
|
|
45
|
+
aria-label="Email"
|
|
46
|
+
class="input w-full"
|
|
47
|
+
placeholder="you@example.com"
|
|
48
|
+
/>
|
|
49
|
+
</label>
|
|
50
|
+
<button type="submit" class="btn btn-primary">Send sign-in link</button>
|
|
51
|
+
</form>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
64
54
|
</div>
|