@glw907/cairn-cms 0.50.0 → 0.52.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 (80) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/components/EditPage.svelte +125 -16
  3. package/dist/components/EditPage.svelte.d.ts +4 -1
  4. package/dist/components/EditorToolbar.svelte +135 -10
  5. package/dist/components/EditorToolbar.svelte.d.ts +19 -2
  6. package/dist/components/MarkdownEditor.svelte +112 -6
  7. package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
  8. package/dist/components/cairn-admin.css +69 -9
  9. package/dist/components/editor-highlight.d.ts +2 -0
  10. package/dist/components/editor-highlight.js +79 -15
  11. package/dist/components/editor-modes.d.ts +26 -0
  12. package/dist/components/editor-modes.js +92 -0
  13. package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
  14. package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  15. package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  16. package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  17. package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  18. package/dist/components/markdown-directives.d.ts +51 -0
  19. package/dist/components/markdown-directives.js +130 -1
  20. package/dist/components/preview-doc.d.ts +27 -0
  21. package/dist/components/preview-doc.js +64 -0
  22. package/dist/content/compose.js +1 -0
  23. package/dist/content/types.d.ts +33 -0
  24. package/dist/diagnostics/conditions.js +24 -0
  25. package/dist/doctor/bin.js +30 -12
  26. package/dist/doctor/check-floors.d.ts +15 -0
  27. package/dist/doctor/check-floors.js +107 -0
  28. package/dist/doctor/check-probe.d.ts +3 -0
  29. package/dist/doctor/check-probe.js +123 -0
  30. package/dist/doctor/checks-github.js +1 -1
  31. package/dist/doctor/checks-local.d.ts +1 -0
  32. package/dist/doctor/checks-local.js +28 -2
  33. package/dist/doctor/cloudflare-api.js +2 -2
  34. package/dist/doctor/index.d.ts +28 -3
  35. package/dist/doctor/index.js +47 -6
  36. package/dist/doctor/types.d.ts +2 -0
  37. package/dist/doctor/wrangler-config.d.ts +4 -0
  38. package/dist/doctor/wrangler-config.js +11 -0
  39. package/dist/env.d.ts +2 -1
  40. package/dist/env.js +9 -4
  41. package/dist/index.d.ts +1 -1
  42. package/dist/sveltekit/content-routes.d.ts +5 -1
  43. package/dist/sveltekit/content-routes.js +25 -17
  44. package/dist/sveltekit/guard.d.ts +8 -2
  45. package/dist/sveltekit/guard.js +3 -1
  46. package/dist/sveltekit/nav-routes.js +3 -9
  47. package/dist/vite/index.d.ts +16 -0
  48. package/dist/vite/index.js +57 -13
  49. package/package.json +2 -2
  50. package/src/lib/components/EditPage.svelte +125 -16
  51. package/src/lib/components/EditorToolbar.svelte +135 -10
  52. package/src/lib/components/MarkdownEditor.svelte +112 -6
  53. package/src/lib/components/cairn-admin.css +95 -5
  54. package/src/lib/components/editor-highlight.ts +91 -14
  55. package/src/lib/components/editor-modes.ts +106 -0
  56. package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
  57. package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  58. package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  59. package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  60. package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  61. package/src/lib/components/markdown-directives.ts +151 -1
  62. package/src/lib/components/preview-doc.ts +82 -0
  63. package/src/lib/content/compose.ts +1 -0
  64. package/src/lib/content/types.ts +32 -0
  65. package/src/lib/diagnostics/conditions.ts +24 -0
  66. package/src/lib/doctor/bin.ts +35 -10
  67. package/src/lib/doctor/check-floors.ts +124 -0
  68. package/src/lib/doctor/check-probe.ts +138 -0
  69. package/src/lib/doctor/checks-github.ts +3 -1
  70. package/src/lib/doctor/checks-local.ts +28 -2
  71. package/src/lib/doctor/cloudflare-api.ts +4 -2
  72. package/src/lib/doctor/index.ts +67 -6
  73. package/src/lib/doctor/types.ts +2 -0
  74. package/src/lib/doctor/wrangler-config.ts +11 -0
  75. package/src/lib/env.ts +9 -4
  76. package/src/lib/index.ts +2 -0
  77. package/src/lib/sveltekit/content-routes.ts +29 -17
  78. package/src/lib/sveltekit/guard.ts +4 -2
  79. package/src/lib/sveltekit/nav-routes.ts +3 -10
  80. package/src/lib/vite/index.ts +71 -17
@@ -16,12 +16,19 @@ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest,
16
16
  import { isConflict } from '../github/types.js';
17
17
  import { log } from '../log/index.js';
18
18
  import { issueCsrfToken } from './csrf.js';
19
- /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
20
- function sessionOf(event) {
21
- const editor = event.locals.editor;
22
- if (!editor)
23
- throw redirect(303, '/admin/login');
24
- return editor;
19
+ import { requireSession } from './guard.js';
20
+ /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
21
+ * nullish coalescing so an override key that is present but undefined keeps the top-level value.
22
+ * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
23
+ function resolvePreview(preview, conceptId) {
24
+ if (!preview)
25
+ return null;
26
+ const override = preview.byConcept?.[conceptId];
27
+ return {
28
+ stylesheets: preview.stylesheets,
29
+ bodyClass: override?.bodyClass ?? preview.bodyClass,
30
+ containerClass: override?.containerClass ?? preview.containerClass,
31
+ };
25
32
  }
26
33
  /** Look up the concept named by the `[concept]` route param, or a 404. */
27
34
  function conceptOf(runtime, params) {
@@ -53,7 +60,7 @@ export function createContentRoutes(runtime, deps = {}) {
53
60
  /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
54
61
  * and the pending entries behind the topbar's publish-all action. */
55
62
  async function layoutLoad(event) {
56
- const editor = sessionOf(event);
63
+ const editor = requireSession(event);
57
64
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
58
65
  const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
59
66
  const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
@@ -137,7 +144,7 @@ export function createContentRoutes(runtime, deps = {}) {
137
144
  * with no manifest row appends a `new` row read from its branch. A listing failure degrades
138
145
  * to an inline error, not a thrown 500. */
139
146
  async function listLoad(event) {
140
- sessionOf(event);
147
+ requireSession(event);
141
148
  const concept = conceptOf(runtime, event.params);
142
149
  const formError = event.url.searchParams.get('error');
143
150
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
@@ -181,7 +188,7 @@ export function createContentRoutes(runtime, deps = {}) {
181
188
  }
182
189
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
183
190
  async function createAction(event) {
184
- sessionOf(event);
191
+ requireSession(event);
185
192
  const concept = conceptOf(runtime, event.params);
186
193
  const form = await event.request.formData();
187
194
  const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
@@ -228,7 +235,7 @@ export function createContentRoutes(runtime, deps = {}) {
228
235
  }
229
236
  /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
230
237
  async function editLoad(event) {
231
- sessionOf(event);
238
+ requireSession(event);
232
239
  const concept = conceptOf(runtime, event.params);
233
240
  const id = event.params.id ?? '';
234
241
  if (!isValidId(id))
@@ -289,6 +296,7 @@ export function createContentRoutes(runtime, deps = {}) {
289
296
  published,
290
297
  publishedFlash: event.url.searchParams.get('published') === '1',
291
298
  discardedFlash: event.url.searchParams.get('discarded') === '1',
299
+ preview: resolvePreview(runtime.preview, concept.id),
292
300
  };
293
301
  }
294
302
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
@@ -386,7 +394,7 @@ export function createContentRoutes(runtime, deps = {}) {
386
394
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
387
395
  * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
388
396
  async function saveAction(event) {
389
- const editor = sessionOf(event);
397
+ const editor = requireSession(event);
390
398
  const concept = conceptOf(runtime, event.params);
391
399
  const id = event.params.id ?? '';
392
400
  // Confine the commit path to the concept dir, built from a validated id (the App token can
@@ -408,7 +416,7 @@ export function createContentRoutes(runtime, deps = {}) {
408
416
  * The branch is deleted only when its head still matches the commit this action made; a
409
417
  * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
410
418
  async function publishAction(event) {
411
- const editor = sessionOf(event);
419
+ const editor = requireSession(event);
412
420
  const concept = conceptOf(runtime, event.params);
413
421
  const id = event.params.id ?? '';
414
422
  if (!isValidId(id))
@@ -442,7 +450,7 @@ export function createContentRoutes(runtime, deps = {}) {
442
450
  * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
443
451
  * concept param is ignored and the redirect lands on the first configured concept. */
444
452
  async function publishAllAction(event) {
445
- const editor = sessionOf(event);
453
+ const editor = requireSession(event);
446
454
  const first = runtime.concepts[0];
447
455
  if (!first)
448
456
  throw error(404, 'No content types configured');
@@ -521,7 +529,7 @@ export function createContentRoutes(runtime, deps = {}) {
521
529
  /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
522
530
  * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
523
531
  async function discardAction(event) {
524
- const editor = sessionOf(event);
532
+ const editor = requireSession(event);
525
533
  const concept = conceptOf(runtime, event.params);
526
534
  const id = event.params.id ?? '';
527
535
  if (!isValidId(id))
@@ -588,7 +596,7 @@ export function createContentRoutes(runtime, deps = {}) {
588
596
  }
589
597
  /** Delete an entry from its editor. The id comes from the route param. */
590
598
  async function deleteAction(event) {
591
- const editor = sessionOf(event);
599
+ const editor = requireSession(event);
592
600
  const concept = conceptOf(runtime, event.params);
593
601
  const id = event.params.id ?? '';
594
602
  if (!isValidId(id))
@@ -597,7 +605,7 @@ export function createContentRoutes(runtime, deps = {}) {
597
605
  }
598
606
  /** Delete an entry from the concept list. The id comes from the form body. */
599
607
  async function listDeleteAction(event) {
600
- const editor = sessionOf(event);
608
+ const editor = requireSession(event);
601
609
  const concept = conceptOf(runtime, event.params);
602
610
  const form = await event.request.formData();
603
611
  const id = String(form.get('id') ?? '');
@@ -610,7 +618,7 @@ export function createContentRoutes(runtime, deps = {}) {
610
618
  * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
611
619
  * caught by the build's fail-closed backstop. */
612
620
  async function renameAction(event) {
613
- const editor = sessionOf(event);
621
+ const editor = requireSession(event);
614
622
  const concept = conceptOf(runtime, event.params);
615
623
  const id = event.params.id ?? '';
616
624
  if (!isValidId(id))
@@ -2,7 +2,13 @@ import type { Editor } from '../auth/types.js';
2
2
  import type { HandleInput, RequestContext } from './types.js';
3
3
  /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
4
4
  export declare function createAuthGuard(): ({ event, resolve }: HandleInput) => Promise<Response>;
5
- /** For a protected load/action: the session the guard already resolved, or a login redirect. */
6
- export declare function requireSession(event: RequestContext): Editor;
5
+ /** For a protected load/action: the session the guard already resolved, or a login redirect.
6
+ * The parameter is the minimal structural need (just `locals`), so every engine event shape
7
+ * (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
8
+ export declare function requireSession(event: {
9
+ locals: {
10
+ editor?: Editor | null;
11
+ };
12
+ }): Editor;
7
13
  /** For the management surface: a signed-in owner, or 403 for an editor. */
8
14
  export declare function requireOwner(event: RequestContext): Editor;
@@ -80,7 +80,9 @@ export function createAuthGuard() {
80
80
  return response;
81
81
  };
82
82
  }
83
- /** For a protected load/action: the session the guard already resolved, or a login redirect. */
83
+ /** For a protected load/action: the session the guard already resolved, or a login redirect.
84
+ * The parameter is the minimal structural need (just `locals`), so every engine event shape
85
+ * (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
84
86
  export function requireSession(event) {
85
87
  const editor = event.locals.editor;
86
88
  if (!editor)
@@ -8,13 +8,7 @@ import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
8
  import { isConflict } from '../github/types.js';
9
9
  import { log } from '../log/index.js';
10
10
  import { parseSiteConfig, extractMenu, validateNavTree, setMenu } from '../nav/site-config.js';
11
- /** The signed-in editor the guard resolved, or a login redirect. */
12
- function sessionOf(event) {
13
- const editor = event.locals.editor;
14
- if (!editor)
15
- throw redirect(303, '/admin/login');
16
- return editor;
17
- }
11
+ import { requireSession } from './guard.js';
18
12
  export function createNavRoutes(runtime, deps = {}) {
19
13
  const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
20
14
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
@@ -33,7 +27,7 @@ export function createNavRoutes(runtime, deps = {}) {
33
27
  }
34
28
  /** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
35
29
  async function navLoad(event) {
36
- sessionOf(event);
30
+ requireSession(event);
37
31
  const config = runtime.navMenu;
38
32
  if (!config)
39
33
  throw error(404, 'No navigation menu configured');
@@ -79,7 +73,7 @@ export function createNavRoutes(runtime, deps = {}) {
79
73
  }
80
74
  /** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
81
75
  async function navSave(event) {
82
- const editor = sessionOf(event);
76
+ const editor = requireSession(event);
83
77
  const config = runtime.navMenu;
84
78
  if (!config)
85
79
  throw error(404, 'No navigation menu configured');
@@ -30,3 +30,19 @@ export declare function cairnManifest(opts: CairnManifestOptions): Plugin;
30
30
  * resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
31
31
  * so the write logic is testable apart from the CLI shell. */
32
32
  export declare function writeManifest(cwd?: string): Promise<void>;
33
+ /** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
34
+ export interface AdapterFacts {
35
+ /** `cairn.backend.owner`. */
36
+ owner?: string;
37
+ /** `cairn.backend.repo`. */
38
+ repo?: string;
39
+ /** `cairn.sender.from`. */
40
+ from?: string;
41
+ }
42
+ /** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
43
+ * through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
44
+ * cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
45
+ * any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
46
+ * null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
47
+ * never in a Worker. */
48
+ export declare function readAdapterFacts(cwd?: string): Promise<AdapterFacts | null>;
@@ -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.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -97,7 +97,7 @@
97
97
  ],
98
98
  "peerDependencies": {
99
99
  "@sveltejs/kit": "^2.12",
100
- "svelte": "^5.0.0"
100
+ "svelte": "^5.56.3"
101
101
  },
102
102
  "dependencies": {
103
103
  "@codemirror/autocomplete": "^6.20.2",
@@ -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,6 +29,7 @@ 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';
@@ -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,43 @@ 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
+ // The writing modes (focus, typewriter), per-browser preferences on the device pick's pattern:
182
+ // off by default, read in an effect so SSR never touches localStorage, written by the
183
+ // toolbar's toggles. The effect tracks nothing reactive, so it runs once.
184
+ const focusStorageKey = 'cairn-editor-focus-mode';
185
+ const typewriterStorageKey = 'cairn-editor-typewriter';
186
+ let focusMode = $state(false);
187
+ let typewriter = $state(false);
188
+ $effect(() => {
189
+ focusMode = localStorage.getItem(focusStorageKey) === 'true';
190
+ typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
191
+ });
192
+ function setFocusMode(on: boolean) {
193
+ focusMode = on;
194
+ localStorage.setItem(focusStorageKey, String(on));
195
+ }
196
+ function setTypewriter(on: boolean) {
197
+ typewriter = on;
198
+ localStorage.setItem(typewriterStorageKey, String(on));
199
+ }
200
+ const activeDevice = $derived(previewDevice(device));
201
+ // The iframe document around the rendered html: the site's stylesheets from the adapter's
202
+ // preview knob, or a styleless document (behind the hint below) when the site sets none.
203
+ const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
153
204
  let insert = $state.raw<(text: string) => void>(() => {});
154
205
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
155
206
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
@@ -388,7 +439,13 @@ transient flashes, and the editor card's footer holds the word count and the Mar
388
439
  }
389
440
  }
390
441
  }, 150);
391
- return () => clearTimeout(handle);
442
+ return () => {
443
+ clearTimeout(handle);
444
+ // Every re-run and the final teardown invalidate the in-flight render. The entry-key reset
445
+ // above cannot reach this counter, so without the bump a slow render for entry A could
446
+ // resolve after a same-route hop and write A's html into entry B's pane.
447
+ previewRun++;
448
+ };
392
449
  });
393
450
 
394
451
  // Coerce a frontmatter value to a string for text/date/textarea inputs.
@@ -575,7 +632,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
575
632
  bind:this={editForm}
576
633
  onsubmit={onEditSubmit}
577
634
  oninput={onFormInput}
578
- class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6"
635
+ oninvalidcapture={onFormInvalid}
636
+ class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
579
637
  >
580
638
  <CsrfField />
581
639
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
@@ -602,7 +660,17 @@ transient flashes, and the editor card's footer holds the word count and the Mar
602
660
  role="group"
603
661
  aria-label="Editor"
604
662
  >
605
- <EditorToolbar {format} {mode} onMode={setMode}>
663
+ <EditorToolbar
664
+ {format}
665
+ {mode}
666
+ onMode={setMode}
667
+ {device}
668
+ onDevice={setDevice}
669
+ {focusMode}
670
+ onFocusMode={setFocusMode}
671
+ {typewriter}
672
+ onTypewriter={setTypewriter}
673
+ >
606
674
  {#snippet insertControls()}
607
675
  <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
608
676
  dialogs themselves mount outside the edit form at the bottom of this component. -->
@@ -665,19 +733,58 @@ transient flashes, and the editor card's footer holds the word count and the Mar
665
733
  registerGetSelection={(fn) => (getSelection = fn)}
666
734
  registerFormat={(fn) => (format = fn)}
667
735
  {completionSources}
736
+ {focusMode}
737
+ {typewriter}
668
738
  />
669
739
  </div>
670
740
  {#if mode === 'preview'}
671
- <!-- tabindex 0: the pane holds no focusable content, so it is itself a tab stop (the
672
- tabpanel pattern's completeness requirement). -->
673
- <div id="cairn-pane-preview" role="tabpanel" aria-labelledby="cairn-tab-preview" tabindex="0" class="prose max-w-none p-4">
674
- {#if previewHtml}
675
- {@html previewHtml}
676
- {:else if previewFailed}
677
- <p class="text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
678
- {:else}
679
- <p class="text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
680
- {/if}
741
+ <!-- The preview ground: recessed under the floating frame card so the page reads as a
742
+ sheet on the desk. tabindex 0 only while a message shows in place of the iframe;
743
+ with the iframe up the frame itself is the pane's focusable content (the tabpanel
744
+ pattern's completeness requirement). -->
745
+ <div
746
+ id="cairn-pane-preview"
747
+ role="tabpanel"
748
+ aria-labelledby="cairn-tab-preview"
749
+ tabindex={previewHtml && !previewFailed ? undefined : 0}
750
+ class="bg-base-200 px-4 py-6 lg:px-8"
751
+ >
752
+ <!-- The frame column: centered, sized by the picked device (capped at the pane), with
753
+ the width eased; the admin sheet's prefers-reduced-motion rule squashes the move. -->
754
+ <div
755
+ class="cairn-preview-frame mx-auto max-w-full transition-[width] duration-300"
756
+ style:width={activeDevice.width === null ? '100%' : `${activeDevice.width}px`}
757
+ >
758
+ {#if activeDevice.width !== null}
759
+ <p class="mb-2 text-right text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">
760
+ {deviceLabel(activeDevice)}
761
+ </p>
762
+ {/if}
763
+ {#if !data.preview}
764
+ <p class="mb-2 text-xs text-[var(--color-muted)]">
765
+ Preview shows unstyled markup until the adapter's preview option names the site's stylesheets.
766
+ </p>
767
+ {/if}
768
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
769
+ {#if previewFailed}
770
+ <p class="p-4 text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
771
+ {:else if !previewHtml}
772
+ <p class="p-4 text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
773
+ {:else}
774
+ <!-- The site's render pipeline already sanitized the html (the floor strips
775
+ scripts and handlers); the empty sandbox is belt and braces on top. The
776
+ frame document's base tag targets every link at a new tab, which the
777
+ sandbox (no allow-popups) blocks, so a proofing click never navigates the
778
+ admin or the frame itself. tabindex 0 keeps the scrollable preview
779
+ keyboard-reachable (an iframe is not a sequential tab stop by itself); on
780
+ a link-heavy page that one inert Tab stop is a deliberate tradeoff. The
781
+ a11y rule reads any tabindex on a non-interactive element as a smell, but
782
+ a scrollable region is the recognized exception. -->
783
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
784
+ <iframe sandbox="" tabindex="0" title="Page preview" srcdoc={previewDoc} class="block h-[70vh] w-full"></iframe>
785
+ {/if}
786
+ </div>
787
+ </div>
681
788
  </div>
682
789
  {/if}
683
790
  <!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
@@ -696,7 +803,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
696
803
  </div>
697
804
  </div>
698
805
 
699
- <aside class="lg:order-2 mt-4 lg:mt-0">
806
+ <!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
807
+ field edits survive the round trip) and the editor column above spans the whole width. -->
808
+ <aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
700
809
  <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
701
810
  a real legend that screen readers announce with the fields it holds. -->
702
811
  <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">