@glw907/cairn-cms 0.5.0 → 0.6.0-rc.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 (216) hide show
  1. package/dist/auth/crypto.d.ts +13 -0
  2. package/dist/auth/crypto.d.ts.map +1 -0
  3. package/dist/auth/crypto.js +31 -0
  4. package/dist/auth/store.d.ts +41 -0
  5. package/dist/auth/store.d.ts.map +1 -0
  6. package/dist/auth/store.js +115 -0
  7. package/dist/auth/types.d.ts +25 -0
  8. package/dist/auth/types.d.ts.map +1 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/components/AdminLayout.svelte +58 -108
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -9
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +50 -0
  14. package/dist/components/ComponentPalette.svelte.d.ts +16 -0
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
  16. package/dist/components/ConceptList.svelte +81 -0
  17. package/dist/components/ConceptList.svelte.d.ts +13 -0
  18. package/dist/components/ConceptList.svelte.d.ts.map +1 -0
  19. package/dist/components/ConfirmPage.svelte +23 -20
  20. package/dist/components/ConfirmPage.svelte.d.ts +6 -0
  21. package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
  22. package/dist/components/EditPage.svelte +160 -103
  23. package/dist/components/EditPage.svelte.d.ts +17 -7
  24. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  25. package/dist/components/LoginPage.svelte +42 -52
  26. package/dist/components/LoginPage.svelte.d.ts +12 -0
  27. package/dist/components/LoginPage.svelte.d.ts.map +1 -1
  28. package/dist/components/ManageEditors.svelte +81 -0
  29. package/dist/components/ManageEditors.svelte.d.ts +24 -0
  30. package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
  31. package/dist/components/MarkdownEditor.svelte +81 -0
  32. package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
  33. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
  34. package/dist/components/NavTree.svelte +138 -0
  35. package/dist/components/NavTree.svelte.d.ts +17 -0
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -0
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +5 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +7 -4
  41. package/dist/content/compose.d.ts +7 -0
  42. package/dist/content/compose.d.ts.map +1 -0
  43. package/dist/content/compose.js +32 -0
  44. package/dist/content/concepts.d.ts +17 -0
  45. package/dist/content/concepts.d.ts.map +1 -0
  46. package/dist/content/concepts.js +41 -0
  47. package/dist/content/frontmatter.d.ts +18 -0
  48. package/dist/content/frontmatter.d.ts.map +1 -0
  49. package/dist/content/frontmatter.js +58 -0
  50. package/dist/content/ids.d.ts +17 -0
  51. package/dist/content/ids.d.ts.map +1 -0
  52. package/dist/content/ids.js +33 -0
  53. package/dist/content/types.d.ts +210 -0
  54. package/dist/content/types.d.ts.map +1 -0
  55. package/dist/content/types.js +1 -0
  56. package/dist/content/validate.d.ts +13 -0
  57. package/dist/content/validate.d.ts.map +1 -0
  58. package/dist/content/validate.js +45 -0
  59. package/dist/email.d.ts +25 -12
  60. package/dist/email.d.ts.map +1 -1
  61. package/dist/email.js +24 -24
  62. package/dist/env.d.ts +24 -0
  63. package/dist/env.d.ts.map +1 -0
  64. package/dist/env.js +29 -0
  65. package/dist/github/credentials.d.ts +12 -0
  66. package/dist/github/credentials.d.ts.map +1 -0
  67. package/dist/github/credentials.js +11 -0
  68. package/dist/github/repo.d.ts +49 -0
  69. package/dist/github/repo.d.ts.map +1 -0
  70. package/dist/github/repo.js +123 -0
  71. package/dist/github/signing.d.ts +17 -0
  72. package/dist/github/signing.d.ts.map +1 -0
  73. package/dist/github/signing.js +79 -0
  74. package/dist/github/types.d.ts +35 -0
  75. package/dist/github/types.d.ts.map +1 -0
  76. package/dist/github/types.js +19 -0
  77. package/dist/index.d.ts +27 -6
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -8
  80. package/dist/nav/site-config.d.ts +50 -0
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/nav/site-config.js +100 -0
  83. package/dist/render/glyph.d.ts +1 -1
  84. package/dist/render/glyph.d.ts.map +1 -1
  85. package/dist/render/index.d.ts +5 -5
  86. package/dist/render/index.d.ts.map +1 -1
  87. package/dist/render/index.js +6 -6
  88. package/dist/render/pipeline.d.ts +3 -3
  89. package/dist/render/pipeline.d.ts.map +1 -1
  90. package/dist/render/pipeline.js +4 -4
  91. package/dist/render/registry.d.ts +6 -4
  92. package/dist/render/registry.d.ts.map +1 -1
  93. package/dist/render/registry.js +8 -6
  94. package/dist/render/rehype-dispatch.d.ts +1 -1
  95. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  96. package/dist/render/remark-directives.d.ts +1 -1
  97. package/dist/render/remark-directives.d.ts.map +1 -1
  98. package/dist/render/sanitize.d.ts +8 -0
  99. package/dist/render/sanitize.d.ts.map +1 -0
  100. package/dist/render/sanitize.js +26 -0
  101. package/dist/sveltekit/auth-routes.d.ts +23 -0
  102. package/dist/sveltekit/auth-routes.d.ts.map +1 -0
  103. package/dist/sveltekit/auth-routes.js +85 -0
  104. package/dist/sveltekit/content-routes.d.ts +80 -0
  105. package/dist/sveltekit/content-routes.d.ts.map +1 -0
  106. package/dist/sveltekit/content-routes.js +183 -0
  107. package/dist/sveltekit/editors-routes.d.ts +24 -0
  108. package/dist/sveltekit/editors-routes.d.ts.map +1 -0
  109. package/dist/sveltekit/editors-routes.js +73 -0
  110. package/dist/sveltekit/guard.d.ts +9 -0
  111. package/dist/sveltekit/guard.d.ts.map +1 -0
  112. package/dist/sveltekit/guard.js +43 -0
  113. package/dist/sveltekit/health.d.ts +19 -0
  114. package/dist/sveltekit/health.d.ts.map +1 -0
  115. package/dist/sveltekit/health.js +12 -0
  116. package/dist/sveltekit/index.d.ts +9 -83
  117. package/dist/sveltekit/index.d.ts.map +1 -1
  118. package/dist/sveltekit/index.js +8 -149
  119. package/dist/sveltekit/nav-routes.d.ts +30 -0
  120. package/dist/sveltekit/nav-routes.d.ts.map +1 -0
  121. package/dist/sveltekit/nav-routes.js +103 -0
  122. package/dist/sveltekit/types.d.ts +32 -0
  123. package/dist/sveltekit/types.d.ts.map +1 -0
  124. package/dist/sveltekit/types.js +1 -0
  125. package/package.json +38 -58
  126. package/src/lib/auth/crypto.ts +37 -0
  127. package/src/lib/auth/store.ts +158 -0
  128. package/src/lib/auth/types.ts +27 -0
  129. package/src/lib/components/AdminLayout.svelte +58 -108
  130. package/src/lib/components/ComponentPalette.svelte +50 -0
  131. package/src/lib/components/ConceptList.svelte +81 -0
  132. package/src/lib/components/ConfirmPage.svelte +23 -20
  133. package/src/lib/components/EditPage.svelte +160 -103
  134. package/src/lib/components/LoginPage.svelte +42 -52
  135. package/src/lib/components/ManageEditors.svelte +81 -0
  136. package/src/lib/components/MarkdownEditor.svelte +81 -0
  137. package/src/lib/components/NavTree.svelte +138 -0
  138. package/src/lib/components/cairn-admin.css +42 -0
  139. package/src/lib/components/index.ts +7 -4
  140. package/src/lib/content/compose.ts +39 -0
  141. package/src/lib/content/concepts.ts +57 -0
  142. package/src/lib/content/frontmatter.ts +71 -0
  143. package/src/lib/content/ids.ts +38 -0
  144. package/src/lib/content/types.ts +235 -0
  145. package/src/lib/content/validate.ts +51 -0
  146. package/src/lib/email.ts +52 -38
  147. package/src/lib/env.ts +32 -0
  148. package/src/lib/github/credentials.ts +27 -0
  149. package/src/lib/github/repo.ts +138 -0
  150. package/src/lib/github/signing.ts +97 -0
  151. package/src/lib/github/types.ts +46 -0
  152. package/src/lib/index.ts +86 -8
  153. package/src/lib/nav/site-config.ts +124 -0
  154. package/src/lib/render/glyph.ts +6 -6
  155. package/src/lib/render/index.ts +6 -6
  156. package/src/lib/render/pipeline.ts +22 -22
  157. package/src/lib/render/registry.ts +33 -26
  158. package/src/lib/render/rehype-dispatch.ts +47 -47
  159. package/src/lib/render/remark-directives.ts +46 -46
  160. package/src/lib/render/sanitize.ts +27 -0
  161. package/src/lib/sveltekit/auth-routes.ts +107 -0
  162. package/src/lib/sveltekit/content-routes.ts +261 -0
  163. package/src/lib/sveltekit/editors-routes.ts +82 -0
  164. package/src/lib/sveltekit/guard.ts +47 -0
  165. package/src/lib/sveltekit/health.ts +24 -0
  166. package/src/lib/sveltekit/index.ts +19 -235
  167. package/src/lib/sveltekit/nav-routes.ts +139 -0
  168. package/src/lib/sveltekit/types.ts +33 -0
  169. package/dist/adapter.d.ts +0 -69
  170. package/dist/adapter.d.ts.map +0 -1
  171. package/dist/adapter.js +0 -30
  172. package/dist/auth/admins.d.ts +0 -33
  173. package/dist/auth/admins.d.ts.map +0 -1
  174. package/dist/auth/admins.js +0 -90
  175. package/dist/auth/config.d.ts +0 -2097
  176. package/dist/auth/config.d.ts.map +0 -1
  177. package/dist/auth/config.js +0 -78
  178. package/dist/auth/guard.d.ts +0 -34
  179. package/dist/auth/guard.d.ts.map +0 -1
  180. package/dist/auth/guard.js +0 -47
  181. package/dist/auth/index.d.ts +0 -4
  182. package/dist/auth/index.d.ts.map +0 -1
  183. package/dist/auth/index.js +0 -6
  184. package/dist/auth/schema.d.ts +0 -750
  185. package/dist/auth/schema.d.ts.map +0 -1
  186. package/dist/auth/schema.js +0 -93
  187. package/dist/carta.d.ts +0 -39
  188. package/dist/carta.d.ts.map +0 -1
  189. package/dist/carta.js +0 -30
  190. package/dist/components/AdminList.svelte +0 -33
  191. package/dist/components/AdminList.svelte.d.ts +0 -10
  192. package/dist/components/AdminList.svelte.d.ts.map +0 -1
  193. package/dist/components/ManageAdmins.svelte +0 -84
  194. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  195. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  196. package/dist/content.d.ts +0 -3
  197. package/dist/content.d.ts.map +0 -1
  198. package/dist/content.js +0 -10
  199. package/dist/github.d.ts +0 -72
  200. package/dist/github.d.ts.map +0 -1
  201. package/dist/github.js +0 -171
  202. package/dist/utils.d.ts +0 -3
  203. package/dist/utils.d.ts.map +0 -1
  204. package/dist/utils.js +0 -11
  205. package/src/lib/adapter.ts +0 -119
  206. package/src/lib/auth/admins.ts +0 -106
  207. package/src/lib/auth/config.ts +0 -108
  208. package/src/lib/auth/guard.ts +0 -60
  209. package/src/lib/auth/index.ts +0 -6
  210. package/src/lib/auth/schema.ts +0 -112
  211. package/src/lib/carta.ts +0 -59
  212. package/src/lib/components/AdminList.svelte +0 -33
  213. package/src/lib/components/ManageAdmins.svelte +0 -84
  214. package/src/lib/content.ts +0 -11
  215. package/src/lib/github.ts +0 -220
  216. package/src/lib/utils.ts +0 -12
@@ -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
- // The scanner-safe confirm surface (C2). A GET renders this static page and consumes nothing.
3
- // The token rides in a hidden field; only the explicit form POST (the route's default action,
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
- <title>Confirm sign-in · {data.siteName} CMS</title>
18
+ <meta name="robots" content="noindex, nofollow" />
14
19
  </svelte:head>
15
20
 
16
- <div class="mx-auto mt-16 max-w-md rounded-box border border-base-300 bg-base-100 p-8">
17
- <h1 class="text-2xl font-bold">Confirm sign-in</h1>
18
- <p class="mt-1 text-sm opacity-70">to {data.siteName} CMS</p>
19
-
20
- {#if data.error || !data.token}
21
- <div class="alert alert-error mt-6">
22
- <span>This sign-in link is invalid or expired. Request a new one.</span>
23
- </div>
24
- <a href="/admin/login" class="btn btn-primary mt-6">Back to sign-in</a>
25
- {:else}
26
- <form method="POST" class="mt-6 flex flex-col gap-3">
27
- <input type="hidden" name="token" value={data.token} />
28
- <button type="submit" class="btn btn-primary">Confirm sign-in</button>
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,10 +1,16 @@
1
+ import './cairn-admin.css';
1
2
  interface Props {
3
+ /** The confirm load's data: the token to submit, the site name, and an optional error. */
2
4
  data: {
3
5
  token: string;
4
6
  siteName: string;
5
7
  error: string | null;
6
8
  };
7
9
  }
10
+ /**
11
+ * The scanner-safe confirm page. A GET renders this static "Confirm sign-in" button with the token
12
+ * in a hidden field and consumes nothing; only the explicit POST verifies (spec §7.1). JS-free.
13
+ */
8
14
  declare const ConfirmPage: import("svelte").Component<Props, {}, "">;
9
15
  type ConfirmPage = ReturnType<typeof ConfirmPage>;
10
16
  export default ConfirmPage;
@@ -1 +1 @@
1
- {"version":3,"file":"ConfirmPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConfirmPage.svelte.ts"],"names":[],"mappings":"AAOE,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACjE;AA6BH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"ConfirmPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConfirmPage.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,mBAAmB,CAAC;AAGzB,UAAU,KAAK;IACb,0FAA0F;IAC1F,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACjE;AA8BH;;;GAGG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -1,125 +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
- // 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';
8
+ import { untrack } from 'svelte';
9
+ import MarkdownEditor from './MarkdownEditor.svelte';
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';
11
15
 
12
- let { data, preview }: { data: EditData & { siteName: string }; preview: PreviewPlugins } = $props();
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
+ }
13
26
 
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);
27
+ let { data, registry, preview = [], renderPreview }: Props = $props();
18
28
 
19
- // svelte-ignore state_referenced_locally (the preview plugin set is fixed for the load)
20
- const carta = new Carta(previewCartaOptions(preview));
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>(() => {});
21
35
 
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
- // This is 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
- });
36
+ const PREVIEW_KEY = 'cairn-admin:preview';
29
37
 
30
- // svelte-ignore state_referenced_locally (form defaults from the initial load)
31
- const fm = data.frontmatter as Record<string, unknown>;
38
+ $effect(() => {
39
+ // Restore the per-user preference once, on mount.
40
+ showPreview = localStorage.getItem(PREVIEW_KEY) === '1';
41
+ });
32
42
 
33
- function fmString(key: string): string {
34
- return typeof fm[key] === 'string' ? (fm[key] as string) : '';
43
+ function togglePreview() {
44
+ showPreview = !showPreview;
45
+ localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
35
46
  }
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(', ') : '';
47
+
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
+ });
68
+
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);
41
72
  }
42
73
  </script>
43
74
 
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">
75
+ <header class="mb-4 flex items-center justify-between gap-2">
49
76
  <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>
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>
53
91
  </div>
54
- </div>
92
+ </header>
55
93
 
56
94
  {#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>
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>
60
99
  {/if}
61
100
 
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 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 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 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>
101
+ <form method="POST" action="?/save" class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
102
+ {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
112
103
 
113
- <div class="rounded-box border border-base-300 bg-base-100 p-2">
114
- <input type="hidden" name="body" value={body} />
115
- {#if mounted}
116
- <MarkdownEditor {carta} bind:value={body} mode="tabs" />
117
- {:else}
118
- <textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
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)} />
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>
119
116
  {/if}
120
117
  </div>
121
118
 
122
- <div class="flex justify-end">
123
- <button type="submit" class="btn btn-primary">Save &amp; commit</button>
124
- </div>
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>
125
182
  </form>
@@ -1,13 +1,23 @@
1
- import 'carta-md/default.css';
2
- import { type PreviewPlugins } from '../carta';
3
- import type { EditData } from '../sveltekit';
4
- type $$ComponentProps = {
1
+ import type { ComponentRegistry } from '../render/registry.js';
2
+ import type { EditData } from '../sveltekit/content-routes.js';
3
+ interface Props {
4
+ /** The edit load's data, plus the site name for the heading. */
5
5
  data: EditData & {
6
6
  siteName: string;
7
7
  };
8
- preview: PreviewPlugins;
9
- };
10
- declare const EditPage: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ /** The site's component registry, for the insert palette. */
9
+ registry?: ComponentRegistry;
10
+ /** Carta preview plugins from the adapter, for the design-accurate preview. */
11
+ preview?: unknown[];
12
+ /** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
13
+ renderPreview?: (md: string) => string | Promise<string>;
14
+ }
15
+ /**
16
+ * The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the Carta
17
+ * markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
18
+ * `?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
19
+ */
20
+ declare const EditPage: import("svelte").Component<Props, {}, "">;
11
21
  type EditPage = ReturnType<typeof EditPage>;
12
22
  export default EditPage;
13
23
  //# sourceMappingURL=EditPage.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,sBAAsB,CAAC;AAC9B,OAAO,EAAuB,KAAK,cAAc,EAAE,MAAM,UAAU,CAAC;AAEpE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE5C,KAAK,gBAAgB,GAAI;IAAE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,CAAC;AAwH7F,QAAA,MAAM,QAAQ,sDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAK7D,UAAU,KAAK;IACb,gEAAgE;IAChE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,+EAA+E;IAC/E,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC;IACpB,yFAAyF;IACzF,aAAa,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC1D;AAqJH;;;;GAIG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -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
- // 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 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: { siteName: string };
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
- async function request(event: SubmitEvent) {
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
- <title>Sign in · {data.siteName} CMS</title>
21
+ <meta name="robots" content="noindex, nofollow" />
35
22
  </svelte:head>
36
23
 
37
- <div class="mx-auto mt-16 max-w-md rounded-box border border-base-300 bg-base-100 p-8">
38
- <h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
39
- <p class="mt-1 text-sm opacity-70">Sign in with your editor email.</p>
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
- {#if requested}
42
- <div class="alert alert-success mt-6">
43
- <span>
44
- If that address is on the editor list, a sign-in link is on its way. It expires in 10
45
- minutes.
46
- </span>
47
- </div>
48
- {:else}
49
- <form onsubmit={request} class="mt-6 flex flex-col gap-3">
50
- <input
51
- type="email"
52
- name="email"
53
- bind:value={email}
54
- required
55
- autocomplete="email"
56
- placeholder="you@example.com"
57
- class="input w-full"
58
- />
59
- <button type="submit" class="btn btn-primary" disabled={busy}>
60
- {busy ? 'Sending…' : 'Email me a sign-in link'}
61
- </button>
62
- </form>
63
- {/if}
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>
@@ -1,8 +1,20 @@
1
+ import './cairn-admin.css';
1
2
  interface Props {
3
+ /** The login load's data: the site name and an optional error. */
2
4
  data: {
3
5
  siteName: string;
6
+ error: string | null;
4
7
  };
8
+ /** The action result: `sent` is true once a request was accepted. */
9
+ form: {
10
+ sent?: boolean;
11
+ } | null;
5
12
  }
13
+ /**
14
+ * The magic-link sign-in page. A plain form POST to the page's default action (the engine's
15
+ * `requestAction`); no client SDK. The success message is identical whether or not the email is on
16
+ * the allowlist, so the page never leaks membership (spec §7.1).
17
+ */
6
18
  declare const LoginPage: import("svelte").Component<Props, {}, "">;
7
19
  type LoginPage = ReturnType<typeof LoginPage>;
8
20
  export default LoginPage;
@@ -1 +1 @@
1
- {"version":3,"file":"LoginPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/LoginPage.svelte.ts"],"names":[],"mappings":"AAUE,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5B;AAyDH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"LoginPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/LoginPage.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,mBAAmB,CAAC;AAGzB,UAAU,KAAK;IACb,kEAAkE;IAClE,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACjD,qEAAqE;IACrE,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC;CACjC;AAuCH;;;;GAIG;AACH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -0,0 +1,81 @@
1
+ <!--
2
+ @component
3
+ The owner-gated editor management surface: a table of editors with role-flip and remove actions,
4
+ and an add-editor form. The acting owner's own row disables its destructive controls; the
5
+ last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
6
+ named `?/setRole`, `?/remove`, and `?/add` actions.
7
+ -->
8
+ <script lang="ts">
9
+ import type { Editor } from '../auth/types.js';
10
+
11
+ interface Props {
12
+ /** The editors load's data, plus the site name. */
13
+ data: { editors: Editor[]; self: string; siteName: string };
14
+ /** The last action's result (an error message when it failed). */
15
+ form: { error?: string; ok?: boolean } | null;
16
+ }
17
+
18
+ let { data, form }: Props = $props();
19
+ </script>
20
+
21
+ <header class="mb-4">
22
+ <h1 class="text-xl font-semibold">Editors</h1>
23
+ </header>
24
+
25
+ {#if form?.error}
26
+ <div role="alert" class="alert alert-error mb-4 text-sm">{form.error}</div>
27
+ {/if}
28
+
29
+ <div class="overflow-x-auto rounded-box border border-base-300 bg-base-100 mb-6">
30
+ <table class="table">
31
+ <thead>
32
+ <tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Role</th><th scope="col"><span class="sr-only">Actions</span></th></tr>
33
+ </thead>
34
+ <tbody>
35
+ {#each data.editors as editor (editor.email)}
36
+ {@const isSelf = editor.email === data.self}
37
+ <tr>
38
+ <td>{editor.displayName}</td>
39
+ <td>{editor.email}</td>
40
+ <td>
41
+ <span class="badge {editor.role === 'owner' ? 'badge-primary' : 'badge-ghost'}">{editor.role}</span>
42
+ </td>
43
+ <td class="flex justify-end gap-2">
44
+ <form method="POST" action="?/setRole">
45
+ <input type="hidden" name="email" value={editor.email} />
46
+ <input type="hidden" name="role" value={editor.role === 'owner' ? 'editor' : 'owner'} />
47
+ <button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf} aria-label={`Toggle role for ${editor.displayName}`}>
48
+ {editor.role === 'owner' ? 'Make editor' : 'Make owner'}
49
+ </button>
50
+ </form>
51
+ <form method="POST" action="?/remove">
52
+ <input type="hidden" name="email" value={editor.email} />
53
+ <button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf} aria-label={`Remove ${editor.displayName}`}>
54
+ Remove
55
+ </button>
56
+ </form>
57
+ </td>
58
+ </tr>
59
+ {/each}
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+
64
+ <form method="POST" action="?/add" class="rounded-box border border-base-300 bg-base-100 grid gap-3 p-4 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
65
+ <label class="flex flex-col gap-1">
66
+ <span class="text-sm font-medium">Name</span>
67
+ <input class="input" name="name" aria-label="Name" required />
68
+ </label>
69
+ <label class="flex flex-col gap-1">
70
+ <span class="text-sm font-medium">Email</span>
71
+ <input class="input" type="email" name="email" aria-label="Email" autocomplete="off" required />
72
+ </label>
73
+ <label class="flex flex-col gap-1">
74
+ <span class="text-sm font-medium">Role</span>
75
+ <select class="select" name="role" aria-label="Role">
76
+ <option value="editor">editor</option>
77
+ <option value="owner">owner</option>
78
+ </select>
79
+ </label>
80
+ <button type="submit" class="btn btn-primary">Add editor</button>
81
+ </form>