@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
@@ -4,25 +4,31 @@
4
4
  // email `send` injection in auth-routes. A shim stays one line: `export const load = routes.editLoad`.
5
5
  import { redirect, error, fail } from '@sveltejs/kit';
6
6
  import { findConcept } from '../content/concepts.js';
7
- import { extractCairnLinks, formatCairnToken } from '../content/links.js';
7
+ import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
8
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
9
9
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
10
- import { rewriteCairnLink } from '../components/markdown-format.js';
11
10
  import { appCredentials } from '../github/credentials.js';
12
11
  import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
13
12
  import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
14
13
  import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
15
14
  import { cachedInstallationToken } from '../github/signing.js';
16
15
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
17
- import { CommitConflictError } from '../github/types.js';
16
+ import { isConflict } from '../github/types.js';
18
17
  import { log } from '../log/index.js';
19
18
  import { issueCsrfToken } from './csrf.js';
20
- /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
21
- function sessionOf(event) {
22
- const editor = event.locals.editor;
23
- if (!editor)
24
- throw redirect(303, '/admin/login');
25
- 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
+ };
26
32
  }
27
33
  /** Look up the concept named by the `[concept]` route param, or a 404. */
28
34
  function conceptOf(runtime, params) {
@@ -54,7 +60,7 @@ export function createContentRoutes(runtime, deps = {}) {
54
60
  /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
55
61
  * and the pending entries behind the topbar's publish-all action. */
56
62
  async function layoutLoad(event) {
57
- const editor = sessionOf(event);
63
+ const editor = requireSession(event);
58
64
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
59
65
  const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
60
66
  const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
@@ -112,11 +118,33 @@ export function createContentRoutes(runtime, deps = {}) {
112
118
  return { id: file.id, title: file.id, date: null, draft: false, status };
113
119
  }
114
120
  }
115
- /** List a concept's entries with their publish status. Main's files carry `edited` when a
116
- * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
117
- * its branch. A listing failure degrades to an inline error, not a thrown 500. */
121
+ /** Read an entry's list row from its pending branch, so a pending title or draft change shows
122
+ * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
123
+ * an id-only row, so a ghost ref still lists. */
124
+ function pendingRow(concept, id, status, token) {
125
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
126
+ ...runtime.backend,
127
+ branch: pendingBranch(concept.id, id),
128
+ });
129
+ }
130
+ /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
131
+ * and read each one for its row, with edited and new rows reading branch-first. */
132
+ async function crawlEntries(concept, pendingIds, token) {
133
+ const files = await listMarkdown(runtime.backend, concept.dir, token);
134
+ const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
135
+ // A ref with no main file is a never-published entry; its row reads from its branch.
136
+ const listed = new Set(files.map((f) => f.id));
137
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
138
+ return [...entries, ...newRows];
139
+ }
140
+ /** List a concept's entries with their publish status. Published rows project straight from
141
+ * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
142
+ * the listing costs one manifest read plus one branch read per pending entry rather than one
143
+ * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
144
+ * with no manifest row appends a `new` row read from its branch. A listing failure degrades
145
+ * to an inline error, not a thrown 500. */
118
146
  async function listLoad(event) {
119
- sessionOf(event);
147
+ requireSession(event);
120
148
  const concept = conceptOf(runtime, event.params);
121
149
  const formError = event.url.searchParams.get('error');
122
150
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
@@ -130,28 +158,28 @@ export function createContentRoutes(runtime, deps = {}) {
130
158
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
131
159
  }
132
160
  try {
133
- const [files, refs] = await Promise.all([
134
- listMarkdown(runtime.backend, concept.dir, token),
161
+ const [manifestRaw, refs] = await Promise.all([
162
+ readRaw(runtime.backend, runtime.manifestPath, token),
135
163
  listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
136
164
  ]);
137
165
  const pendingIds = new Set(refs.flatMap((name) => {
138
166
  const entry = pendingEntryOf(name);
139
167
  return entry && entry.concept.id === concept.id ? [entry.id] : [];
140
168
  }));
141
- // An edited row reads branch-first like a new row, so a pending title or draft change
142
- // shows in the list instead of reading as a lost save.
143
- const entries = await Promise.all(files.map((f) => pendingIds.has(f.id)
144
- ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
145
- : summarize(f, token, 'published')));
146
- // A ref with no main file is a never-published entry; its row reads from its branch, and
147
- // summarize already degrades a failed read to an id-only row.
148
- const listed = new Set(files.map((f) => f.id));
149
- const newRows = await Promise.all([...pendingIds]
150
- .filter((id) => !listed.has(id))
151
- .map((id) => summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
152
- ...runtime.backend,
153
- branch: pendingBranch(concept.id, id),
154
- })));
169
+ // A repo with no committed manifest yet (a fresh site before its first publish) falls back
170
+ // to the crawl; a manifest that parses but is empty is trusted as-is.
171
+ if (manifestRaw === null) {
172
+ return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
173
+ }
174
+ // Newest id first, the same order the crawl's file listing produced.
175
+ const rows = parseManifest(manifestRaw)
176
+ .entries.filter((e) => e.concept === concept.id)
177
+ .sort((a, b) => b.id.localeCompare(a.id));
178
+ const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
179
+ ? pendingRow(concept, e.id, 'edited', token)
180
+ : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' }));
181
+ const listed = new Set(rows.map((e) => e.id));
182
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
155
183
  return { ...base, entries: [...entries, ...newRows], error: null };
156
184
  }
157
185
  catch {
@@ -160,7 +188,7 @@ export function createContentRoutes(runtime, deps = {}) {
160
188
  }
161
189
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
162
190
  async function createAction(event) {
163
- sessionOf(event);
191
+ requireSession(event);
164
192
  const concept = conceptOf(runtime, event.params);
165
193
  const form = await event.request.formData();
166
194
  const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
@@ -207,7 +235,7 @@ export function createContentRoutes(runtime, deps = {}) {
207
235
  }
208
236
  /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
209
237
  async function editLoad(event) {
210
- sessionOf(event);
238
+ requireSession(event);
211
239
  const concept = conceptOf(runtime, event.params);
212
240
  const id = event.params.id ?? '';
213
241
  if (!isValidId(id))
@@ -268,12 +296,9 @@ export function createContentRoutes(runtime, deps = {}) {
268
296
  published,
269
297
  publishedFlash: event.url.searchParams.get('published') === '1',
270
298
  discardedFlash: event.url.searchParams.get('discarded') === '1',
299
+ preview: resolvePreview(runtime.preview, concept.id),
271
300
  };
272
301
  }
273
- /** Match a commit conflict by class and by name (bundling can alias the class identity). */
274
- function isConflict(err) {
275
- return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
276
- }
277
302
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
278
303
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
279
304
  * failures carry the same shape under their own event name. */
@@ -338,7 +363,12 @@ export function createContentRoutes(runtime, deps = {}) {
338
363
  draftLinks.push(formatCairnToken(ref));
339
364
  }
340
365
  if (absent.length) {
341
- return fail(400, { brokenLinks: absent, body });
366
+ const noun = absent.length === 1 ? 'page' : 'pages';
367
+ return fail(400, {
368
+ error: `This page links to ${absent.length} missing ${noun}.`,
369
+ brokenLinks: absent,
370
+ body,
371
+ });
342
372
  }
343
373
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
344
374
  // commit only the entry file there. Main stays untouched until publish, so the branch differs
@@ -364,7 +394,7 @@ export function createContentRoutes(runtime, deps = {}) {
364
394
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
365
395
  * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
366
396
  async function saveAction(event) {
367
- const editor = sessionOf(event);
397
+ const editor = requireSession(event);
368
398
  const concept = conceptOf(runtime, event.params);
369
399
  const id = event.params.id ?? '';
370
400
  // Confine the commit path to the concept dir, built from a validated id (the App token can
@@ -386,7 +416,7 @@ export function createContentRoutes(runtime, deps = {}) {
386
416
  * The branch is deleted only when its head still matches the commit this action made; a
387
417
  * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
388
418
  async function publishAction(event) {
389
- const editor = sessionOf(event);
419
+ const editor = requireSession(event);
390
420
  const concept = conceptOf(runtime, event.params);
391
421
  const id = event.params.id ?? '';
392
422
  if (!isValidId(id))
@@ -420,7 +450,7 @@ export function createContentRoutes(runtime, deps = {}) {
420
450
  * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
421
451
  * concept param is ignored and the redirect lands on the first configured concept. */
422
452
  async function publishAllAction(event) {
423
- const editor = sessionOf(event);
453
+ const editor = requireSession(event);
424
454
  const first = runtime.concepts[0];
425
455
  if (!first)
426
456
  throw error(404, 'No content types configured');
@@ -499,7 +529,7 @@ export function createContentRoutes(runtime, deps = {}) {
499
529
  /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
500
530
  * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
501
531
  async function discardAction(event) {
502
- const editor = sessionOf(event);
532
+ const editor = requireSession(event);
503
533
  const concept = conceptOf(runtime, event.params);
504
534
  const id = event.params.id ?? '';
505
535
  if (!isValidId(id))
@@ -525,7 +555,11 @@ export function createContentRoutes(runtime, deps = {}) {
525
555
  const manifest = await readManifest(token);
526
556
  const inbound = inboundLinks(manifest, concept.id, id);
527
557
  if (inbound.length) {
528
- return fail(409, { inboundLinks: inbound, id });
558
+ return fail(409, {
559
+ error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
560
+ inboundLinks: inbound,
561
+ id,
562
+ });
529
563
  }
530
564
  // When the entry was never published (absent from main), the branch delete is the whole
531
565
  // operation; main has nothing to commit, so the only honest log record is the discard of
@@ -562,7 +596,7 @@ export function createContentRoutes(runtime, deps = {}) {
562
596
  }
563
597
  /** Delete an entry from its editor. The id comes from the route param. */
564
598
  async function deleteAction(event) {
565
- const editor = sessionOf(event);
599
+ const editor = requireSession(event);
566
600
  const concept = conceptOf(runtime, event.params);
567
601
  const id = event.params.id ?? '';
568
602
  if (!isValidId(id))
@@ -571,7 +605,7 @@ export function createContentRoutes(runtime, deps = {}) {
571
605
  }
572
606
  /** Delete an entry from the concept list. The id comes from the form body. */
573
607
  async function listDeleteAction(event) {
574
- const editor = sessionOf(event);
608
+ const editor = requireSession(event);
575
609
  const concept = conceptOf(runtime, event.params);
576
610
  const form = await event.request.formData();
577
611
  const id = String(form.get('id') ?? '');
@@ -584,7 +618,7 @@ export function createContentRoutes(runtime, deps = {}) {
584
618
  * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
585
619
  * caught by the build's fail-closed backstop. */
586
620
  async function renameAction(event) {
587
- const editor = sessionOf(event);
621
+ const editor = requireSession(event);
588
622
  const concept = conceptOf(runtime, event.params);
589
623
  const id = event.params.id ?? '';
590
624
  if (!isValidId(id))
@@ -593,19 +627,19 @@ export function createContentRoutes(runtime, deps = {}) {
593
627
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
594
628
  // them, so refuse until the editor publishes or discards.
595
629
  if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
596
- return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
630
+ return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' });
597
631
  }
598
632
  const form = await event.request.formData();
599
633
  const newSlug = String(form.get('slug') ?? '').trim();
600
634
  if (!isValidId(newSlug)) {
601
- return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
635
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
602
636
  }
603
637
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
604
638
  if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
605
- return fail(400, { renameError: 'Leave the date out of the slug.' });
639
+ return fail(400, { error: 'Leave the date out of the slug.' });
606
640
  }
607
641
  if (newSlug === slugFromId(id, datePrefix)) {
608
- return fail(400, { renameError: 'That is already the slug.' });
642
+ return fail(400, { error: 'That is already the slug.' });
609
643
  }
610
644
  const newId = renameId(id, newSlug, datePrefix);
611
645
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
@@ -615,7 +649,7 @@ export function createContentRoutes(runtime, deps = {}) {
615
649
  // concurrent-rename race where another editor renamed onto this path between load and submit.
616
650
  const clobber = await readRaw(runtime.backend, newPath, token);
617
651
  if (clobber !== null) {
618
- return fail(409, { renameError: 'An entry with that slug already exists.' });
652
+ return fail(409, { error: 'An entry with that slug already exists.' });
619
653
  }
620
654
  const [entryRaw, manifest] = await Promise.all([
621
655
  readRaw(runtime.backend, oldPath, token),
@@ -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;
@@ -6,7 +6,7 @@ import { resolveSession } from '../auth/store.js';
6
6
  import { sessionCookieName } from '../auth/crypto.js';
7
7
  import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
8
8
  import { applySecurityHeaders } from './admin-response.js';
9
- import { renderConditionResponse } from './condition-response.js';
9
+ import { renderConditionResponse, REASON_CONDITION } from './condition-response.js';
10
10
  import { log } from '../log/index.js';
11
11
  /** The login page and the auth endpoints are public; everything else under /admin is gated. */
12
12
  function isPublicAdminPath(pathname) {
@@ -49,6 +49,19 @@ export function createAuthGuard() {
49
49
  log.warn('guard.rejected', { reason: 'https', path: pathname });
50
50
  return renderConditionResponse('edge.https-not-forced', { url: event.url });
51
51
  }
52
+ // No auth store binding means no admin path can work: the gated views cannot resolve a
53
+ // session, and a login or confirm POST would die in its action with a raw 500. That is an
54
+ // operator fault, not a sign-in problem, so name the condition on every admin path, the
55
+ // public ones included, instead of rendering a login form that can never succeed.
56
+ const env = event.platform?.env ?? {};
57
+ if (!env.AUTH_DB) {
58
+ log.error('guard.rejected', {
59
+ reason: 'bindings',
60
+ conditionId: REASON_CONDITION.bindings,
61
+ path: pathname,
62
+ });
63
+ return renderConditionResponse(REASON_CONDITION.bindings);
64
+ }
52
65
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
53
66
  // 403 before resolve() runs. This covers the public login/auth posts too.
54
67
  if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
@@ -56,9 +69,8 @@ export function createAuthGuard() {
56
69
  return renderConditionResponse('auth.csrf-token-invalid');
57
70
  }
58
71
  if (!isPublicAdminPath(pathname)) {
59
- const env = event.platform?.env ?? {};
60
72
  const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
61
- const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
73
+ const editor = id ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
62
74
  if (!editor)
63
75
  throw redirect(303, '/admin/login');
64
76
  event.locals.editor = editor;
@@ -68,7 +80,9 @@ export function createAuthGuard() {
68
80
  return response;
69
81
  };
70
82
  }
71
- /** 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. */
72
86
  export function requireSession(event) {
73
87
  const editor = event.locals.editor;
74
88
  if (!editor)
@@ -4,7 +4,8 @@
4
4
  // not match, so the editor would otherwise hit an opaque 403. This page names the problem, says why
5
5
  // https is needed, and gives the exact Cloudflare fix. The shared shell lives in
6
6
  // static-admin-page.ts. See guard.ts.
7
- import { escapeHtml, renderStaticAdminPage } from './static-admin-page.js';
7
+ import { escapeHtml } from '../escape.js';
8
+ import { renderStaticAdminPage } from './static-admin-page.js';
8
9
  /**
9
10
  * Render the full HTML document for the HTTPS-required page.
10
11
  * @param httpsUrl The same request rebuilt over https, offered as the one-click recovery link.
@@ -2,9 +2,11 @@ export { createAuthGuard, requireSession, requireOwner } from './guard.js';
2
2
  export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
3
3
  export { createEditorRoutes } from './editors-routes.js';
4
4
  export { createContentRoutes } from './content-routes.js';
5
- export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, } from './content-routes.js';
5
+ export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, ContentFormFailure, } from './content-routes.js';
6
6
  export { createNavRoutes } from './nav-routes.js';
7
7
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
8
+ export { parseAdminPath, type AdminView } from './admin-dispatch.js';
9
+ export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-admin.js';
8
10
  export { healthLoad, type HealthData } from './health.js';
9
11
  export type { RequestContext, CookieJar, HandleInput } from './types.js';
10
12
  export type { GithubKeyEnv } from '../github/credentials.js';
@@ -5,4 +5,6 @@ export { createAuthRoutes } from './auth-routes.js';
5
5
  export { createEditorRoutes } from './editors-routes.js';
6
6
  export { createContentRoutes } from './content-routes.js';
7
7
  export { createNavRoutes } from './nav-routes.js';
8
+ export { parseAdminPath } from './admin-dispatch.js';
9
+ export { createCairnAdmin } from './cairn-admin.js';
8
10
  export { healthLoad } from './health.js';
@@ -21,7 +21,9 @@ export interface NavLoadData {
21
21
  }
22
22
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
23
23
  export interface NavRoutesDeps {
24
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
24
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
25
+ * A bare string works too; the routes await whatever comes back. */
26
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
25
27
  }
26
28
  export declare function createNavRoutes(runtime: CairnRuntime, deps?: NavRoutesDeps): {
27
29
  navLoad: (event: ContentEvent) => Promise<NavLoadData>;
@@ -5,20 +5,10 @@ import { redirect, error } from '@sveltejs/kit';
5
5
  import { appCredentials } from '../github/credentials.js';
6
6
  import { cachedInstallationToken } from '../github/signing.js';
7
7
  import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
- import { CommitConflictError } from '../github/types.js';
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
- }
18
- /** Match a commit conflict by class and by name (bundling can alias the class identity). */
19
- function isConflict(err) {
20
- return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
21
- }
11
+ import { requireSession } from './guard.js';
22
12
  export function createNavRoutes(runtime, deps = {}) {
23
13
  const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
24
14
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
@@ -37,7 +27,7 @@ export function createNavRoutes(runtime, deps = {}) {
37
27
  }
38
28
  /** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
39
29
  async function navLoad(event) {
40
- sessionOf(event);
30
+ requireSession(event);
41
31
  const config = runtime.navMenu;
42
32
  if (!config)
43
33
  throw error(404, 'No navigation menu configured');
@@ -51,14 +41,27 @@ export function createNavRoutes(runtime, deps = {}) {
51
41
  return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
52
42
  }
53
43
  let tree = [];
44
+ let raw = null;
54
45
  try {
55
- const raw = await readRaw(runtime.backend, config.configPath, token);
56
- if (raw !== null)
57
- tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
46
+ raw = await readRaw(runtime.backend, config.configPath, token);
58
47
  }
59
48
  catch {
60
- // A malformed or unreadable config degrades to an empty tree; the first save writes a clean menu.
61
- tree = [];
49
+ // An unreadable config degrades to an empty tree; the first save writes a clean menu.
50
+ raw = null;
51
+ }
52
+ if (raw !== null) {
53
+ try {
54
+ tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
55
+ }
56
+ catch (err) {
57
+ // A malformed config keeps the same degrade (the nav page failing closed would be worse
58
+ // for the editor), but the swallow names the operator fault in the log.
59
+ log.error('config.invalid', {
60
+ conditionId: 'config.site-config-invalid',
61
+ error: String(err),
62
+ });
63
+ tree = [];
64
+ }
62
65
  }
63
66
  return {
64
67
  menu,
@@ -70,7 +73,7 @@ export function createNavRoutes(runtime, deps = {}) {
70
73
  }
71
74
  /** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
72
75
  async function navSave(event) {
73
- const editor = sessionOf(event);
76
+ const editor = requireSession(event);
74
77
  const config = runtime.navMenu;
75
78
  if (!config)
76
79
  throw error(404, 'No navigation menu configured');
@@ -1,5 +1,3 @@
1
- /** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
2
- export declare function escapeHtml(value: string): string;
3
1
  /**
4
2
  * Render a full self-contained admin page document. The caller supplies trusted inner HTML
5
3
  * (eyebrow, heading, copy, CTA); the helper owns the head, the inlined style, the brand tile,
@@ -2,14 +2,7 @@
2
2
  // self-contained document with inlined Warm Stone tokens for both colour schemes and the system
3
3
  // font stack, served raw before SvelteKit renders. The cairn glyph is the same public-domain
4
4
  // Temaki mark the admin chrome uses. See docs/internal/admin-design-system.md.
5
- /** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
6
- export function escapeHtml(value) {
7
- return value
8
- .replace(/&/g, '&amp;')
9
- .replace(/</g, '&lt;')
10
- .replace(/>/g, '&gt;')
11
- .replace(/"/g, '&quot;');
12
- }
5
+ import { escapeHtml } from '../escape.js';
13
6
  // The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
14
7
  const CAIRN_GLYPH = '<path d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 ' +
15
8
  '13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 ' +
@@ -13,22 +13,29 @@ export interface CookieJar {
13
13
  path: string;
14
14
  }): void;
15
15
  }
16
- export interface RequestContext {
16
+ /** The Cloudflare platform wrapper an event carries; `context` is the legacy alias for `ctx`. */
17
+ export interface PlatformContext<Env> {
18
+ env?: Env;
19
+ ctx?: {
20
+ waitUntil(promise: Promise<unknown>): void;
21
+ };
22
+ context?: {
23
+ waitUntil(promise: Promise<unknown>): void;
24
+ };
25
+ }
26
+ /** The structural core every engine event type extends, parameterized by the Worker env the
27
+ * surface reads. Each shared field is defined once here; the extensions add only what their
28
+ * surface needs (cookies, params, setHeaders). */
29
+ export interface EventBase<Env> {
17
30
  url: URL;
18
31
  request: Request;
19
- cookies: CookieJar;
20
32
  locals: {
21
33
  editor?: Editor | null;
22
34
  };
23
- platform?: {
24
- env?: AuthEnv;
25
- ctx?: {
26
- waitUntil(promise: Promise<unknown>): void;
27
- };
28
- context?: {
29
- waitUntil(promise: Promise<unknown>): void;
30
- };
31
- };
35
+ platform?: PlatformContext<Env>;
36
+ }
37
+ export interface RequestContext extends EventBase<AuthEnv> {
38
+ cookies: CookieJar;
32
39
  setHeaders(headers: Record<string, string>): void;
33
40
  }
34
41
  export interface HandleInput {
@@ -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>;