@glw907/cairn-cms 0.41.0 → 0.51.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 (162) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +2 -2
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +4 -5
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +107 -25
  12. package/dist/components/EditPage.svelte.d.ts +8 -10
  13. package/dist/components/EditorToolbar.svelte +79 -8
  14. package/dist/components/EditorToolbar.svelte.d.ts +10 -2
  15. package/dist/components/LoginPage.svelte +2 -2
  16. package/dist/components/LoginPage.svelte.d.ts +1 -1
  17. package/dist/components/ManageEditors.svelte +4 -3
  18. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  19. package/dist/components/MarkdownEditor.svelte +20 -2
  20. package/dist/components/cairn-admin.css +57 -9
  21. package/dist/components/editor-highlight.d.ts +1 -0
  22. package/dist/components/editor-highlight.js +31 -8
  23. package/dist/components/index.d.ts +1 -0
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/markdown-directives.d.ts +10 -0
  26. package/dist/components/markdown-directives.js +54 -1
  27. package/dist/components/markdown-format.d.ts +0 -8
  28. package/dist/components/markdown-format.js +0 -28
  29. package/dist/components/preview-doc.d.ts +27 -0
  30. package/dist/components/preview-doc.js +64 -0
  31. package/dist/content/compose.js +1 -0
  32. package/dist/content/links.d.ts +8 -0
  33. package/dist/content/links.js +28 -0
  34. package/dist/content/types.d.ts +35 -2
  35. package/dist/delivery/data.d.ts +3 -5
  36. package/dist/delivery/data.js +2 -3
  37. package/dist/delivery/feeds.js +1 -7
  38. package/dist/delivery/index.d.ts +2 -2
  39. package/dist/delivery/index.js +1 -1
  40. package/dist/delivery/manifest.d.ts +0 -5
  41. package/dist/delivery/manifest.js +5 -16
  42. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  43. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  44. package/dist/delivery/site-indexes.d.ts +3 -3
  45. package/dist/delivery/site-indexes.js +3 -3
  46. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  47. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  48. package/dist/delivery/sitemap.js +1 -3
  49. package/dist/delivery/xml.d.ts +2 -0
  50. package/dist/delivery/xml.js +11 -0
  51. package/dist/diagnostics/conditions.js +24 -0
  52. package/dist/doctor/bin.js +30 -12
  53. package/dist/doctor/check-floors.d.ts +15 -0
  54. package/dist/doctor/check-floors.js +107 -0
  55. package/dist/doctor/check-probe.d.ts +3 -0
  56. package/dist/doctor/check-probe.js +123 -0
  57. package/dist/doctor/checks-github.js +1 -1
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +28 -2
  60. package/dist/doctor/cloudflare-api.js +2 -2
  61. package/dist/doctor/index.d.ts +28 -3
  62. package/dist/doctor/index.js +47 -6
  63. package/dist/doctor/types.d.ts +2 -0
  64. package/dist/doctor/wrangler-config.d.ts +4 -0
  65. package/dist/doctor/wrangler-config.js +11 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +3 -2
  68. package/dist/env.js +12 -6
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/types.d.ts +2 -0
  74. package/dist/github/types.js +4 -0
  75. package/dist/index.d.ts +1 -1
  76. package/dist/log/events.d.ts +1 -1
  77. package/dist/nav/site-config.d.ts +2 -0
  78. package/dist/nav/site-config.js +2 -0
  79. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  80. package/dist/sveltekit/admin-dispatch.js +62 -0
  81. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  82. package/dist/sveltekit/cairn-admin.js +126 -0
  83. package/dist/sveltekit/condition-response.d.ts +1 -0
  84. package/dist/sveltekit/condition-response.js +25 -0
  85. package/dist/sveltekit/content-routes.d.ts +39 -15
  86. package/dist/sveltekit/content-routes.js +84 -50
  87. package/dist/sveltekit/guard.d.ts +8 -2
  88. package/dist/sveltekit/guard.js +18 -4
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +22 -19
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/dist/vite/index.d.ts +16 -0
  98. package/dist/vite/index.js +57 -13
  99. package/package.json +6 -2
  100. package/src/lib/ambient.ts +19 -0
  101. package/src/lib/components/AdminLayout.svelte +6 -8
  102. package/src/lib/components/CairnAdmin.svelte +67 -0
  103. package/src/lib/components/ConceptList.svelte +4 -5
  104. package/src/lib/components/ConfirmPage.svelte +1 -1
  105. package/src/lib/components/EditPage.svelte +107 -25
  106. package/src/lib/components/EditorToolbar.svelte +79 -8
  107. package/src/lib/components/LoginPage.svelte +2 -2
  108. package/src/lib/components/ManageEditors.svelte +4 -3
  109. package/src/lib/components/MarkdownEditor.svelte +20 -2
  110. package/src/lib/components/cairn-admin.css +59 -0
  111. package/src/lib/components/editor-highlight.ts +32 -7
  112. package/src/lib/components/index.ts +1 -0
  113. package/src/lib/components/markdown-directives.ts +51 -1
  114. package/src/lib/components/markdown-format.ts +0 -27
  115. package/src/lib/components/preview-doc.ts +82 -0
  116. package/src/lib/content/compose.ts +1 -0
  117. package/src/lib/content/links.ts +28 -0
  118. package/src/lib/content/types.ts +34 -2
  119. package/src/lib/delivery/data.ts +3 -5
  120. package/src/lib/delivery/feeds.ts +1 -8
  121. package/src/lib/delivery/index.ts +2 -2
  122. package/src/lib/delivery/manifest.ts +5 -18
  123. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  124. package/src/lib/delivery/site-indexes.ts +6 -6
  125. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  126. package/src/lib/delivery/sitemap.ts +1 -4
  127. package/src/lib/delivery/xml.ts +12 -0
  128. package/src/lib/diagnostics/conditions.ts +24 -0
  129. package/src/lib/doctor/bin.ts +35 -10
  130. package/src/lib/doctor/check-floors.ts +124 -0
  131. package/src/lib/doctor/check-probe.ts +138 -0
  132. package/src/lib/doctor/checks-github.ts +3 -1
  133. package/src/lib/doctor/checks-local.ts +28 -2
  134. package/src/lib/doctor/cloudflare-api.ts +4 -2
  135. package/src/lib/doctor/index.ts +67 -6
  136. package/src/lib/doctor/types.ts +2 -0
  137. package/src/lib/doctor/wrangler-config.ts +11 -0
  138. package/src/lib/email.ts +4 -11
  139. package/src/lib/env.ts +12 -6
  140. package/src/lib/escape.ts +12 -0
  141. package/src/lib/github/credentials.ts +6 -2
  142. package/src/lib/github/types.ts +5 -0
  143. package/src/lib/index.ts +2 -0
  144. package/src/lib/log/events.ts +1 -0
  145. package/src/lib/nav/site-config.ts +3 -0
  146. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  147. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  148. package/src/lib/sveltekit/condition-response.ts +27 -1
  149. package/src/lib/sveltekit/content-routes.ts +131 -62
  150. package/src/lib/sveltekit/guard.ts +20 -5
  151. package/src/lib/sveltekit/https-required-page.ts +2 -1
  152. package/src/lib/sveltekit/index.ts +6 -0
  153. package/src/lib/sveltekit/nav-routes.ts +24 -21
  154. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  155. package/src/lib/sveltekit/types.ts +16 -7
  156. package/src/lib/vite/index.ts +71 -17
  157. package/dist/delivery/paginate.d.ts +0 -12
  158. package/dist/delivery/paginate.js +0 -20
  159. package/dist/render/index.d.ts +0 -5
  160. package/dist/render/index.js +0 -8
  161. package/src/lib/delivery/paginate.ts +0 -32
  162. package/src/lib/render/index.ts +0 -8
@@ -30,17 +30,17 @@ const built = buildSiteManifest(cairn, siteConfig, globs);
30
30
  export const result = ${resultExpr};
31
31
  `;
32
32
  }
33
- /** Evaluate the virtual module in the given mode inside the consumer's own Vite resolution, then
34
- * return the module's `result`. It reuses the consumer's loaded config (so `$lib`, the config
35
- * module, `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the
36
- * cairnManifest plugin from the nested server's plugin list, so its buildStart never recurses.
37
- * This runs at build time and in the bin, never in the request lifecycle. */
38
- async function evalVirtual(opts, mode, root) {
33
+ /** Evaluate a virtual module source inside the consumer's own Vite resolution, then return the
34
+ * module's `result`. It reuses the consumer's loaded config (so `$lib`, the config module,
35
+ * `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the cairnManifest
36
+ * plugin from the nested server's plugin list, so its buildStart never recurses. This runs at
37
+ * build time and in the bins, never in the request lifecycle. */
38
+ async function evalVirtual(source, root) {
39
39
  const { createServer, loadConfigFromFile } = await import('vite');
40
40
  // Load the consumer's real Vite config so the nested server inherits SvelteKit's resolution
41
41
  // (the $lib alias, the app root, the ?raw and import.meta.glob handling). Drop cairnManifest from
42
42
  // it so the nested server's buildStart does not recurse, and add a plugin that serves only the
43
- // virtual module in the requested mode.
43
+ // given virtual module source.
44
44
  const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
45
45
  const inlineConfig = loaded?.config ?? {};
46
46
  const server = await createServer({
@@ -49,7 +49,7 @@ async function evalVirtual(opts, mode, root) {
49
49
  configFile: false,
50
50
  logLevel: 'silent',
51
51
  server: { middlewareMode: true, hmr: false, watch: null },
52
- plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(opts, mode)],
52
+ plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(source)],
53
53
  });
54
54
  try {
55
55
  const mod = (await server.ssrLoadModule(VIRTUAL_ID));
@@ -80,12 +80,12 @@ export function stripCairnManifest(plugins) {
80
80
  /** Verify the committed manifest against the corpus from a Vite context, throwing on drift. The bin
81
81
  * and the plugin share this; the spike proved it runs cleanly inside the consumer's config. */
82
82
  export async function verifyManifestFromVite(opts, root) {
83
- await evalVirtual(opts, 'verify', root);
83
+ await evalVirtual(virtualSource(opts, 'verify'), root);
84
84
  }
85
85
  /** Regenerate the serialized manifest from the corpus in a Vite context, sharing the build's
86
86
  * resolution. The cairn-manifest bin (a later task) will call this and write the result. */
87
87
  export async function buildManifestFromVite(opts, root) {
88
- return evalVirtual(opts, 'write', root);
88
+ return evalVirtual(virtualSource(opts, 'write'), root);
89
89
  }
90
90
  /** The cairnManifest plugin. It serves the verify virtual module to the app graph and, in
91
91
  * buildStart, evaluates it through a nested Vite SSR load so a manifest drift fails the build. */
@@ -161,9 +161,9 @@ function findCairnOptions(plugins) {
161
161
  }
162
162
  return null;
163
163
  }
164
- /** A minimal plugin that serves only the virtual module in one mode, for the nested SSR load. It
164
+ /** A minimal plugin that serves only the given virtual module source, for the nested SSR load. It
165
165
  * carries no buildStart, so the nested server never recurses into the verify. */
166
- function cairnVirtualOnly(opts, mode) {
166
+ function cairnVirtualOnly(source) {
167
167
  return {
168
168
  name: 'cairn-manifest-virtual',
169
169
  resolveId(id) {
@@ -172,7 +172,51 @@ function cairnVirtualOnly(opts, mode) {
172
172
  },
173
173
  load(id) {
174
174
  if (id === RESOLVED_ID)
175
- return virtualSource(opts, mode);
175
+ return source;
176
176
  },
177
177
  };
178
178
  }
179
+ /** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
180
+ * configured config module and exports the string-typed `owner`, `repo`, and `from` as JSON, so
181
+ * nothing else of the adapter (least of all a secret) crosses the boundary. */
182
+ function adapterFactsSource(opts) {
183
+ return `
184
+ import { cairn } from ${JSON.stringify(opts.configModule)};
185
+ const backend = cairn?.backend ?? {};
186
+ const sender = cairn?.sender ?? {};
187
+ const facts = {};
188
+ if (typeof backend.owner === 'string') facts.owner = backend.owner;
189
+ if (typeof backend.repo === 'string') facts.repo = backend.repo;
190
+ if (typeof sender.from === 'string') facts.from = sender.from;
191
+ export const result = JSON.stringify(facts);
192
+ `;
193
+ }
194
+ /** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
195
+ * through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
196
+ * cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
197
+ * any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
198
+ * null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
199
+ * never in a Worker. */
200
+ export async function readAdapterFacts(cwd = process.cwd()) {
201
+ try {
202
+ const { loadConfigFromFile } = await import('vite');
203
+ const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd, 'silent');
204
+ if (!loaded)
205
+ return null;
206
+ const opts = findCairnOptions(loaded.config.plugins);
207
+ if (!opts)
208
+ return null;
209
+ const parsed = JSON.parse(await evalVirtual(adapterFactsSource(opts), cwd));
210
+ const facts = {};
211
+ if (typeof parsed.owner === 'string')
212
+ facts.owner = parsed.owner;
213
+ if (typeof parsed.repo === 'string')
214
+ facts.repo = parsed.repo;
215
+ if (typeof parsed.from === 'string')
216
+ facts.from = parsed.from;
217
+ return facts;
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.41.0",
3
+ "version": "0.51.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -80,6 +80,10 @@
80
80
  "types": "./dist/vite/index.d.ts",
81
81
  "default": "./dist/vite/index.js"
82
82
  },
83
+ "./ambient": {
84
+ "types": "./dist/ambient.d.ts",
85
+ "default": "./dist/ambient.js"
86
+ },
83
87
  "./package.json": "./package.json"
84
88
  },
85
89
  "bin": {
@@ -93,7 +97,7 @@
93
97
  ],
94
98
  "peerDependencies": {
95
99
  "@sveltejs/kit": "^2.12",
96
- "svelte": "^5.0.0"
100
+ "svelte": "^5.56.3"
97
101
  },
98
102
  "dependencies": {
99
103
  "@codemirror/autocomplete": "^6.20.2",
@@ -0,0 +1,19 @@
1
+ // The one-line App.Locals augmentation a consumer site imports from src/app.d.ts:
2
+ //
3
+ // import '@glw907/cairn-cms/ambient';
4
+ //
5
+ // The guard sets `event.locals.editor`, and this declaration types it, so a site no longer
6
+ // hand-writes the `declare global` block. The field is optional: the engine's own structural
7
+ // event types read it as `editor?: Editor | null`, and a request the guard has not touched
8
+ // carries no editor at all.
9
+ import type { Editor } from './auth/types.js';
10
+
11
+ declare global {
12
+ namespace App {
13
+ interface Locals {
14
+ editor?: Editor | null;
15
+ }
16
+ }
17
+ }
18
+
19
+ export {};
@@ -279,7 +279,7 @@ identical on every host regardless of the site's own theme.
279
279
  <kbd class="ml-auto hidden rounded border border-[var(--cairn-card-border)] px-1.5 text-[0.6875rem] font-medium sm:inline">&#8984;K</kbd>
280
280
  </button>
281
281
  </div>
282
- {#if pendingCount > 0 && data.concepts.length > 0}
282
+ {#if pendingCount > 0}
283
283
  <div class="flex-none">
284
284
  <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => publishAllDialog?.showModal()}>
285
285
  Publish site ({pendingCount})
@@ -349,9 +349,7 @@ identical on every host regardless of the site's own theme.
349
349
  <form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
350
350
  </dialog>
351
351
 
352
- <!-- The form action below reads data.concepts[0], so zero configured concepts (with a stray
353
- pending ref) must hide the dialog along with its trigger. -->
354
- {#if pendingCount > 0 && data.concepts.length > 0}
352
+ {#if pendingCount > 0}
355
353
  <dialog bind:this={publishAllDialog} class="modal" aria-labelledby="cairn-publish-all-title">
356
354
  <div class="modal-box">
357
355
  <div class="mb-3 flex items-center justify-between">
@@ -367,9 +365,9 @@ identical on every host regardless of the site's own theme.
367
365
  {/each}
368
366
  </ul>
369
367
  {/each}
370
- <!-- The publishAll action is mounted on every concept-list shim; the first concept's
371
- route hosts the site-wide POST so the topbar works from any admin page. -->
372
- <form method="POST" action={`/admin/${data.concepts[0].id}?/publishAll`} class="mt-4 flex justify-end gap-2">
368
+ <!-- The publishAll named action is valid on every authed admin view, so the confirm
369
+ posts to the current page and the topbar works from anywhere. -->
370
+ <form method="POST" action="?/publishAll" class="mt-4 flex justify-end gap-2">
373
371
  <CsrfField token={data.csrf} />
374
372
  <button type="button" class="btn btn-sm" onclick={() => publishAllDialog?.close()}>Cancel</button>
375
373
  <button type="submit" class="btn btn-sm btn-primary">Publish site</button>
@@ -455,7 +453,7 @@ identical on every host regardless of the site's own theme.
455
453
  <div class="text-xs capitalize text-[var(--color-subtle)]">{data.user.role}</div>
456
454
  </div>
457
455
  </div>
458
- <form method="POST" action="/admin/auth/logout" class="mt-4">
456
+ <form method="POST" action="?/logout" class="mt-4">
459
457
  <CsrfField token={data.csrf} />
460
458
  <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">
461
459
  <LogOutIcon class="h-4 w-4" /> Sign out
@@ -0,0 +1,67 @@
1
+ <!--
2
+ @component
3
+ The single-mount admin page. A site's catch-all `/admin/[...path]` route renders this one
4
+ component for every admin view, feeding it the discriminated `AdminData` from `createCairnAdmin`'s
5
+ load. It is a pure switcher on `data.view`: the public auth pages mount bare, and the authed views
6
+ mount inside `AdminLayout`. No styling or wrapper elements of its own.
7
+ -->
8
+ <script lang="ts">
9
+ import AdminLayout from './AdminLayout.svelte';
10
+ import LoginPage from './LoginPage.svelte';
11
+ import ConfirmPage from './ConfirmPage.svelte';
12
+ import ConceptList from './ConceptList.svelte';
13
+ import EditPage from './EditPage.svelte';
14
+ import ManageEditors from './ManageEditors.svelte';
15
+ import NavTree from './NavTree.svelte';
16
+ import type { AdminData } from '../sveltekit/cairn-admin.js';
17
+ import type { ContentFormFailure } from '../sveltekit/content-routes.js';
18
+ import type { ComponentRegistry } from '../render/registry.js';
19
+ import type { IconSet } from '../render/glyph.js';
20
+ import type { LinkResolve } from '../content/links.js';
21
+
22
+ interface Props {
23
+ /** The discriminated view data from `createCairnAdmin`'s load. */
24
+ data: AdminData;
25
+ /** The last action's result, forwarded to whichever view rendered: the shared content-action
26
+ * failure family (every failure carries `error`), merged with the auth and editors results,
27
+ * so the route's one `form` export covers every view. */
28
+ form?:
29
+ | (ContentFormFailure & {
30
+ sent?: boolean;
31
+ status?: 'sent' | 'send_error' | 'throttled';
32
+ ok?: boolean;
33
+ })
34
+ | null;
35
+ /** The site's design-accurate render pipeline, for the edit view's preview pane. */
36
+ render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
37
+ /** The site's component registry, for the edit view's insert palette. */
38
+ registry?: ComponentRegistry;
39
+ /** The site's icon set, for the edit view's guided form fields. */
40
+ icons?: IconSet;
41
+ }
42
+
43
+ let { data, form = null, render, registry, icons }: Props = $props();
44
+ </script>
45
+
46
+ {#if data.view === 'login'}
47
+ <LoginPage data={data.page} {form} />
48
+ {:else if data.view === 'confirm'}
49
+ <ConfirmPage data={data.page} />
50
+ {:else}
51
+ <AdminLayout data={data.layout}>
52
+ {#if data.view === 'list'}
53
+ <!-- The single mount reuses this component across /admin/posts -> /admin/pages, so the
54
+ concept id keys the list: crossing concepts remounts it and drops the old query,
55
+ sort, page, and dialog state. -->
56
+ {#key data.page.conceptId}
57
+ <ConceptList data={data.page} {form} />
58
+ {/key}
59
+ {:else if data.view === 'edit'}
60
+ <EditPage data={{ ...data.page, siteName: data.layout.siteName }} {render} {registry} {icons} {form} />
61
+ {:else if data.view === 'editors'}
62
+ <ManageEditors data={data.page} {form} />
63
+ {:else if data.view === 'nav'}
64
+ <NavTree data={data.page} />
65
+ {/if}
66
+ </AdminLayout>
67
+ {/if}
@@ -7,8 +7,7 @@ content sizes. The header New button opens a dialog holding the create form.
7
7
  -->
8
8
  <script lang="ts">
9
9
  import { slugify } from '../content/ids.js';
10
- import type { EntrySummary, ListData } from '../sveltekit/content-routes.js';
11
- import type { InboundLink } from '../content/manifest.js';
10
+ import type { DeleteRefusal, EntrySummary, ListData } from '../sveltekit/content-routes.js';
12
11
  import CsrfField from './CsrfField.svelte';
13
12
  import DeleteDialog from './DeleteDialog.svelte';
14
13
  import CairnLogo from './CairnLogo.svelte';
@@ -17,10 +16,10 @@ content sizes. The header New button opens a dialog holding the create form.
17
16
  interface Props {
18
17
  /** The list load's data: the concept, its entries, and any inline or form errors. */
19
18
  data: ListData;
20
- /** The `?/delete` action result. A blocked delete returns the refused entry id and the inbound
21
- * links that link to it (the flat `fail(409, { inboundLinks, id })` shape), so the list names
19
+ /** The `?/delete` action result. A blocked delete returns the `DeleteRefusal` payload (the
20
+ * shared `error` summary, the refused entry id, and its inbound linkers), so the list names
22
21
  * the blockers and refuses (block-until-clean). */
23
- form?: { id?: string; inboundLinks?: InboundLink[] } | null;
22
+ form?: Partial<DeleteRefusal> | null;
24
23
  }
25
24
 
26
25
  let { data, form = null }: Props = $props();
@@ -40,7 +40,7 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
40
40
  {:else}
41
41
  <h1 class="text-lg font-semibold">Almost there</h1>
42
42
  <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Confirm to finish signing in to {data.siteName}.</p>
43
- <form method="POST">
43
+ <form method="POST" action="?/confirm">
44
44
  <input type="hidden" name="token" value={data.token} />
45
45
  <CsrfField token={data.csrf} />
46
46
  <button type="submit" class="btn btn-primary btn-block">Confirm sign-in</button>
@@ -6,13 +6,16 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
6
6
  remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
7
7
  toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
8
8
  swap the editing surface for the rendered preview inside the same card; every visit lands on
9
- Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
9
+ Write. Preview renders inside a sandboxed iframe that links the site's own stylesheets (the
10
+ adapter's `preview` knob), takes the full content width (the sidebar hides until Write), and
11
+ sizes to a persisted device width picked from the toolbar's capsule. A sticky glass header
12
+ carries the breadcrumb, the status badges, the save-state indicator,
10
13
  and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
11
14
  pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
12
15
  transient flashes, and the editor card's footer holds the word count and the Markdown help.
13
16
  -->
14
17
  <script lang="ts">
15
- import { untrack } from 'svelte';
18
+ import { flushSync, untrack } from 'svelte';
16
19
  import { beforeNavigate } from '$app/navigation';
17
20
  import { page } from '$app/state';
18
21
  import CsrfField from './CsrfField.svelte';
@@ -26,10 +29,11 @@ transient flashes, and the editor card's footer holds the word count and the Mar
26
29
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
27
30
  import { cairnLinkCompletionSource } from './link-completion.js';
28
31
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
32
+ import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
29
33
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
30
34
  import type { ComponentRegistry } from '../render/registry.js';
31
35
  import type { IconSet } from '../render/glyph.js';
32
- import type { EditData } from '../sveltekit/content-routes.js';
36
+ import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
33
37
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
34
38
  import type { LinkResolve } from '../content/links.js';
35
39
  import { manifestLinkResolver } from '../content/manifest.js';
@@ -43,9 +47,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
43
47
  render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
44
48
  /** The site's icon set, for the guided form's icon fields. */
45
49
  icons?: IconSet;
46
- /** The `?/save` or `?/delete` action result. Carries the save guard's broken links when a save was
47
- * blocked, or the delete guard's inbound linkers when a delete was refused. */
48
- form?: { brokenLinks?: string[]; body?: string; inboundLinks?: import('../content/manifest.js').InboundLink[]; renameError?: string } | null;
50
+ /** The last content action's failure: the save guard's broken links, the delete guard's
51
+ * inbound linkers, or a rename refusal, each carrying the shared `error` summary. */
52
+ form?: ContentFormFailure | null;
49
53
  }
50
54
 
51
55
  let { data, registry, render, icons, form }: Props = $props();
@@ -97,6 +101,16 @@ transient flashes, and the editor card's footer holds the word count and the Mar
97
101
  // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
98
102
  let editForm = $state<HTMLFormElement | null>(null);
99
103
 
104
+ // A required sidebar field hidden by Preview cannot take the browser's validation report: an
105
+ // invisible control is unfocusable, so the browser cancels the save silently with no message.
106
+ // This capture-phase invalid listener flips back to Write first, and flushSync forces the pane
107
+ // swap inside the event, so the report that follows the invalid events lands on a visible
108
+ // control and the author sees what blocked the save.
109
+ function onFormInvalid() {
110
+ if (mode === 'write') return;
111
+ flushSync(() => (mode = 'write'));
112
+ }
113
+
100
114
  // The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
101
115
  // onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
102
116
  // navigation passes through because busy flips before it starts, and a non-edit POST's because
@@ -150,6 +164,24 @@ transient flashes, and the editor card's footer holds the word count and the Mar
150
164
  let previewHtml = $state('');
151
165
  // True after a render call threw, so the preview pane can say so instead of going blank.
152
166
  let previewFailed = $state(false);
167
+ // The preview frame's device width, a per-browser preference under its own key (the legacy
168
+ // 'cairn-admin:preview' key from the removed split-pane preview stays untouched). Desktop is
169
+ // the default; the storage read sits in an effect so it never runs during SSR, and it tracks
170
+ // nothing reactive, so it runs once.
171
+ const deviceStorageKey = 'cairn-editor-preview-device';
172
+ let device = $state<PreviewDeviceId>('desktop');
173
+ $effect(() => {
174
+ const stored = localStorage.getItem(deviceStorageKey);
175
+ if (previewDevices.some((d) => d.id === stored)) device = stored as PreviewDeviceId;
176
+ });
177
+ function setDevice(id: PreviewDeviceId) {
178
+ device = id;
179
+ localStorage.setItem(deviceStorageKey, id);
180
+ }
181
+ const activeDevice = $derived(previewDevice(device));
182
+ // The iframe document around the rendered html: the site's stylesheets from the adapter's
183
+ // preview knob, or a styleless document (behind the hint below) when the site sets none.
184
+ const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
153
185
  let insert = $state.raw<(text: string) => void>(() => {});
154
186
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
155
187
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
@@ -220,8 +252,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
220
252
  // not refused. When set, a delete was blocked by a link that appeared since the page loaded.
221
253
  const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
222
254
 
223
- // A rename that hit a collision or an invalid slug returns form.renameError.
224
- const renameError = $derived(form?.renameError ?? '');
255
+ // The shared failure summary, rendered only when no richer banner claims the failure: the save
256
+ // and delete guards get their own banners from brokenLinks and inboundLinks below, so this
257
+ // surfaces the rest (a rename refusal, today).
258
+ const formError = $derived(
259
+ form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : '',
260
+ );
225
261
 
226
262
  // The entry this surface is editing. SvelteKit reuses the page component across a same-route
227
263
  // navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
@@ -279,7 +315,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
279
315
  );
280
316
  const assertiveMessage = $derived.by(() => {
281
317
  if (data.error) return data.error;
282
- if (renameError) return renameError;
318
+ if (formError) return formError;
283
319
  if (deleteRefusedLinks.length) {
284
320
  const count = deleteRefusedLinks.length;
285
321
  return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
@@ -384,7 +420,13 @@ transient flashes, and the editor card's footer holds the word count and the Mar
384
420
  }
385
421
  }
386
422
  }, 150);
387
- return () => clearTimeout(handle);
423
+ return () => {
424
+ clearTimeout(handle);
425
+ // Every re-run and the final teardown invalidate the in-flight render. The entry-key reset
426
+ // above cannot reach this counter, so without the bump a slow render for entry A could
427
+ // resolve after a same-route hop and write A's html into entry B's pane.
428
+ previewRun++;
429
+ };
388
430
  });
389
431
 
390
432
  // Coerce a frontmatter value to a string for text/date/textarea inputs.
@@ -529,8 +571,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
529
571
  {#if data.error}
530
572
  <div class="alert alert-error mb-4 text-sm">{data.error}</div>
531
573
  {/if}
532
- {#if renameError}
533
- <div class="alert alert-error mb-4 text-sm">{renameError}</div>
574
+ {#if formError}
575
+ <div class="alert alert-error mb-4 text-sm">{formError}</div>
534
576
  {/if}
535
577
  {#if deleteRefusedLinks.length}
536
578
  <div class="alert alert-error mb-4 flex-col items-start text-sm">
@@ -571,7 +613,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
571
613
  bind:this={editForm}
572
614
  onsubmit={onEditSubmit}
573
615
  oninput={onFormInput}
574
- class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6"
616
+ oninvalidcapture={onFormInvalid}
617
+ class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
575
618
  >
576
619
  <CsrfField />
577
620
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
@@ -598,7 +641,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
598
641
  role="group"
599
642
  aria-label="Editor"
600
643
  >
601
- <EditorToolbar {format} {mode} onMode={setMode}>
644
+ <EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
602
645
  {#snippet insertControls()}
603
646
  <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
604
647
  dialogs themselves mount outside the edit form at the bottom of this component. -->
@@ -664,16 +707,53 @@ transient flashes, and the editor card's footer holds the word count and the Mar
664
707
  />
665
708
  </div>
666
709
  {#if mode === 'preview'}
667
- <!-- tabindex 0: the pane holds no focusable content, so it is itself a tab stop (the
668
- tabpanel pattern's completeness requirement). -->
669
- <div id="cairn-pane-preview" role="tabpanel" aria-labelledby="cairn-tab-preview" tabindex="0" class="prose max-w-none p-4">
670
- {#if previewHtml}
671
- {@html previewHtml}
672
- {:else if previewFailed}
673
- <p class="text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
674
- {:else}
675
- <p class="text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
676
- {/if}
710
+ <!-- The preview ground: recessed under the floating frame card so the page reads as a
711
+ sheet on the desk. tabindex 0 only while a message shows in place of the iframe;
712
+ with the iframe up the frame itself is the pane's focusable content (the tabpanel
713
+ pattern's completeness requirement). -->
714
+ <div
715
+ id="cairn-pane-preview"
716
+ role="tabpanel"
717
+ aria-labelledby="cairn-tab-preview"
718
+ tabindex={previewHtml && !previewFailed ? undefined : 0}
719
+ class="bg-base-200 px-4 py-6 lg:px-8"
720
+ >
721
+ <!-- The frame column: centered, sized by the picked device (capped at the pane), with
722
+ the width eased; the admin sheet's prefers-reduced-motion rule squashes the move. -->
723
+ <div
724
+ class="cairn-preview-frame mx-auto max-w-full transition-[width] duration-300"
725
+ style:width={activeDevice.width === null ? '100%' : `${activeDevice.width}px`}
726
+ >
727
+ {#if activeDevice.width !== null}
728
+ <p class="mb-2 text-right text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">
729
+ {deviceLabel(activeDevice)}
730
+ </p>
731
+ {/if}
732
+ {#if !data.preview}
733
+ <p class="mb-2 text-xs text-[var(--color-muted)]">
734
+ Preview shows unstyled markup until the adapter's preview option names the site's stylesheets.
735
+ </p>
736
+ {/if}
737
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
738
+ {#if previewFailed}
739
+ <p class="p-4 text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
740
+ {:else if !previewHtml}
741
+ <p class="p-4 text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
742
+ {:else}
743
+ <!-- The site's render pipeline already sanitized the html (the floor strips
744
+ scripts and handlers); the empty sandbox is belt and braces on top. The
745
+ frame document's base tag targets every link at a new tab, which the
746
+ sandbox (no allow-popups) blocks, so a proofing click never navigates the
747
+ admin or the frame itself. tabindex 0 keeps the scrollable preview
748
+ keyboard-reachable (an iframe is not a sequential tab stop by itself); on
749
+ a link-heavy page that one inert Tab stop is a deliberate tradeoff. The
750
+ a11y rule reads any tabindex on a non-interactive element as a smell, but
751
+ a scrollable region is the recognized exception. -->
752
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
753
+ <iframe sandbox="" tabindex="0" title="Page preview" srcdoc={previewDoc} class="block h-[70vh] w-full"></iframe>
754
+ {/if}
755
+ </div>
756
+ </div>
677
757
  </div>
678
758
  {/if}
679
759
  <!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
@@ -692,7 +772,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
692
772
  </div>
693
773
  </div>
694
774
 
695
- <aside class="lg:order-2 mt-4 lg:mt-0">
775
+ <!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
776
+ field edits survive the round trip) and the editor column above spans the whole width. -->
777
+ <aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
696
778
  <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
697
779
  a real legend that screen readers announce with the fields it holds. -->
698
780
  <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">