@glw907/cairn-cms 0.40.0 → 0.50.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 (156) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -3
  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 +18 -10
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +47 -19
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/EditorToolbar.svelte +4 -0
  14. package/dist/components/LoginPage.svelte +2 -2
  15. package/dist/components/LoginPage.svelte.d.ts +1 -1
  16. package/dist/components/ManageEditors.svelte +4 -3
  17. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.js +1 -0
  20. package/dist/components/link-completion.js +10 -3
  21. package/dist/components/markdown-format.d.ts +0 -8
  22. package/dist/components/markdown-format.js +0 -28
  23. package/dist/content/links.d.ts +8 -0
  24. package/dist/content/links.js +28 -0
  25. package/dist/content/types.d.ts +2 -2
  26. package/dist/delivery/data.d.ts +3 -5
  27. package/dist/delivery/data.js +2 -3
  28. package/dist/delivery/feeds.js +1 -7
  29. package/dist/delivery/index.d.ts +2 -2
  30. package/dist/delivery/index.js +1 -1
  31. package/dist/delivery/manifest.d.ts +0 -5
  32. package/dist/delivery/manifest.js +5 -16
  33. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  34. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  35. package/dist/delivery/site-indexes.d.ts +3 -3
  36. package/dist/delivery/site-indexes.js +3 -3
  37. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  38. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  39. package/dist/delivery/sitemap.js +1 -3
  40. package/dist/delivery/xml.d.ts +2 -0
  41. package/dist/delivery/xml.js +11 -0
  42. package/dist/diagnostics/conditions.d.ts +8 -1
  43. package/dist/diagnostics/conditions.js +68 -1
  44. package/dist/doctor/bin.d.ts +2 -0
  45. package/dist/doctor/bin.js +44 -0
  46. package/dist/doctor/check-send.d.ts +3 -0
  47. package/dist/doctor/check-send.js +43 -0
  48. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  49. package/dist/doctor/checks-cloudflare.js +200 -0
  50. package/dist/doctor/checks-github.d.ts +2 -0
  51. package/dist/doctor/checks-github.js +57 -0
  52. package/dist/doctor/checks-local.d.ts +5 -0
  53. package/dist/doctor/checks-local.js +112 -0
  54. package/dist/doctor/cloudflare-api.d.ts +7 -0
  55. package/dist/doctor/cloudflare-api.js +24 -0
  56. package/dist/doctor/index.d.ts +23 -0
  57. package/dist/doctor/index.js +68 -0
  58. package/dist/doctor/report.d.ts +5 -0
  59. package/dist/doctor/report.js +21 -0
  60. package/dist/doctor/run.d.ts +8 -0
  61. package/dist/doctor/run.js +20 -0
  62. package/dist/doctor/types.d.ts +41 -0
  63. package/dist/doctor/types.js +10 -0
  64. package/dist/doctor/wrangler-config.d.ts +12 -0
  65. package/dist/doctor/wrangler-config.js +125 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +1 -1
  68. package/dist/env.js +3 -2
  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/signing.d.ts +3 -1
  74. package/dist/github/signing.js +13 -5
  75. package/dist/github/types.d.ts +2 -0
  76. package/dist/github/types.js +4 -0
  77. package/dist/log/events.d.ts +1 -1
  78. package/dist/nav/site-config.d.ts +2 -0
  79. package/dist/nav/site-config.js +2 -0
  80. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  81. package/dist/sveltekit/admin-dispatch.js +62 -0
  82. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  83. package/dist/sveltekit/cairn-admin.js +126 -0
  84. package/dist/sveltekit/condition-response.d.ts +1 -0
  85. package/dist/sveltekit/condition-response.js +25 -0
  86. package/dist/sveltekit/content-routes.d.ts +34 -14
  87. package/dist/sveltekit/content-routes.js +78 -44
  88. package/dist/sveltekit/guard.js +15 -3
  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 +19 -10
  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/package.json +10 -4
  98. package/src/lib/ambient.ts +19 -0
  99. package/src/lib/components/AdminLayout.svelte +6 -8
  100. package/src/lib/components/CairnAdmin.svelte +67 -0
  101. package/src/lib/components/ConceptList.svelte +18 -10
  102. package/src/lib/components/ConfirmPage.svelte +1 -1
  103. package/src/lib/components/EditPage.svelte +47 -19
  104. package/src/lib/components/EditorToolbar.svelte +4 -0
  105. package/src/lib/components/LoginPage.svelte +2 -2
  106. package/src/lib/components/ManageEditors.svelte +4 -3
  107. package/src/lib/components/index.ts +1 -0
  108. package/src/lib/components/link-completion.ts +10 -3
  109. package/src/lib/components/markdown-format.ts +0 -27
  110. package/src/lib/content/links.ts +28 -0
  111. package/src/lib/content/types.ts +2 -2
  112. package/src/lib/delivery/data.ts +3 -5
  113. package/src/lib/delivery/feeds.ts +1 -8
  114. package/src/lib/delivery/index.ts +2 -2
  115. package/src/lib/delivery/manifest.ts +5 -18
  116. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  117. package/src/lib/delivery/site-indexes.ts +6 -6
  118. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  119. package/src/lib/delivery/sitemap.ts +1 -4
  120. package/src/lib/delivery/xml.ts +12 -0
  121. package/src/lib/diagnostics/conditions.ts +75 -2
  122. package/src/lib/doctor/bin.ts +45 -0
  123. package/src/lib/doctor/check-send.ts +43 -0
  124. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  125. package/src/lib/doctor/checks-github.ts +63 -0
  126. package/src/lib/doctor/checks-local.ts +119 -0
  127. package/src/lib/doctor/cloudflare-api.ts +33 -0
  128. package/src/lib/doctor/index.ts +93 -0
  129. package/src/lib/doctor/report.ts +30 -0
  130. package/src/lib/doctor/run.ts +23 -0
  131. package/src/lib/doctor/types.ts +52 -0
  132. package/src/lib/doctor/wrangler-config.ts +142 -0
  133. package/src/lib/email.ts +4 -11
  134. package/src/lib/env.ts +3 -2
  135. package/src/lib/escape.ts +12 -0
  136. package/src/lib/github/credentials.ts +6 -2
  137. package/src/lib/github/signing.ts +13 -6
  138. package/src/lib/github/types.ts +5 -0
  139. package/src/lib/log/events.ts +2 -0
  140. package/src/lib/nav/site-config.ts +3 -0
  141. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  142. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  143. package/src/lib/sveltekit/condition-response.ts +27 -1
  144. package/src/lib/sveltekit/content-routes.ts +121 -55
  145. package/src/lib/sveltekit/guard.ts +16 -3
  146. package/src/lib/sveltekit/https-required-page.ts +2 -1
  147. package/src/lib/sveltekit/index.ts +6 -0
  148. package/src/lib/sveltekit/nav-routes.ts +21 -11
  149. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  150. package/src/lib/sveltekit/types.ts +16 -7
  151. package/dist/delivery/paginate.d.ts +0 -12
  152. package/dist/delivery/paginate.js +0 -20
  153. package/dist/render/index.d.ts +0 -5
  154. package/dist/render/index.js +0 -8
  155. package/src/lib/delivery/paginate.ts +0 -32
  156. package/src/lib/render/index.ts +0 -8
@@ -4,20 +4,19 @@
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, type GithubKeyEnv } from '../github/credentials.js';
12
11
  import { listMarkdown, readRaw, commitFiles, type FileChange } 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, type Manifest, type LinkTarget, type InboundLink } 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
- import type { CookieJar } from './types.js';
19
+ import type { CookieJar, EventBase } from './types.js';
21
20
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
22
21
  import type { Editor, Role } from '../auth/types.js';
23
22
 
@@ -104,12 +103,8 @@ export interface EditData {
104
103
  }
105
104
 
106
105
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
107
- export interface ContentEvent {
108
- url: URL;
106
+ export interface ContentEvent extends EventBase<GithubKeyEnv> {
109
107
  params: Record<string, string>;
110
- request: Request;
111
- locals: { editor?: Editor | null };
112
- platform?: { env?: GithubKeyEnv };
113
108
  /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
114
109
  * token. Optional for non-route callers. */
115
110
  cookies?: CookieJar;
@@ -117,10 +112,42 @@ export interface ContentEvent {
117
112
 
118
113
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
119
114
  export interface ContentRoutesDeps {
120
- /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer. */
121
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
115
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
116
+ * A bare string works too; the routes await whatever comes back. */
117
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
122
118
  }
123
119
 
120
+ /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
121
+ export interface SaveFailure {
122
+ /** The one-line human summary every content action failure carries. */
123
+ error: string;
124
+ /** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
125
+ brokenLinks: string[];
126
+ /** The author's edited markdown, so the editor reseeds with the unsaved work. */
127
+ body: string;
128
+ }
129
+
130
+ /** A refused delete: `fail(409)` while other entries still link to this one. */
131
+ export interface DeleteRefusal {
132
+ /** The one-line human summary every content action failure carries. */
133
+ error: string;
134
+ /** The entries whose bodies link to the refused one, for the blockers list. */
135
+ inboundLinks: InboundLink[];
136
+ /** The refused entry's id, so a list view marks the right row. */
137
+ id: string;
138
+ }
139
+
140
+ /** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
141
+ export interface RenameFailure {
142
+ /** The one-line human summary every content action failure carries. */
143
+ error: string;
144
+ }
145
+
146
+ /** What a route's single `form` export presents to a view component: whichever content action
147
+ * last failed, merged with every field optional. `error` is always set on a failure; the richer
148
+ * keys identify which guard refused. */
149
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
150
+
124
151
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
125
152
  function sessionOf(event: ContentEvent): Editor {
126
153
  const editor = event.locals.editor;
@@ -178,8 +205,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
178
205
  const entry = pendingEntryOf(name);
179
206
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
180
207
  });
181
- } catch {
208
+ } catch (err) {
182
209
  pendingEntries = null;
210
+ log.warn('github.unreachable', { scope: 'layout', error: String(err) });
183
211
  }
184
212
  return {
185
213
  siteName: runtime.siteName,
@@ -222,9 +250,37 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
222
250
  }
223
251
  }
224
252
 
225
- /** List a concept's entries with their publish status. Main's files carry `edited` when a
226
- * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
227
- * its branch. A listing failure degrades to an inline error, not a thrown 500. */
253
+ /** Read an entry's list row from its pending branch, so a pending title or draft change shows
254
+ * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
255
+ * an id-only row, so a ghost ref still lists. */
256
+ function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
257
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
258
+ ...runtime.backend,
259
+ branch: pendingBranch(concept.id, id),
260
+ });
261
+ }
262
+
263
+ /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
264
+ * and read each one for its row, with edited and new rows reading branch-first. */
265
+ async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
266
+ const files = await listMarkdown(runtime.backend, concept.dir, token);
267
+ const entries = await Promise.all(
268
+ files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))),
269
+ );
270
+ // A ref with no main file is a never-published entry; its row reads from its branch.
271
+ const listed = new Set(files.map((f) => f.id));
272
+ const newRows = await Promise.all(
273
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
274
+ );
275
+ return [...entries, ...newRows];
276
+ }
277
+
278
+ /** List a concept's entries with their publish status. Published rows project straight from
279
+ * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
280
+ * the listing costs one manifest read plus one branch read per pending entry rather than one
281
+ * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
282
+ * with no manifest row appends a `new` row read from its branch. A listing failure degrades
283
+ * to an inline error, not a thrown 500. */
228
284
  async function listLoad(event: ContentEvent): Promise<ListData> {
229
285
  sessionOf(event);
230
286
  const concept = conceptOf(runtime, event.params);
@@ -239,8 +295,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
239
295
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
240
296
  }
241
297
  try {
242
- const [files, refs] = await Promise.all([
243
- listMarkdown(runtime.backend, concept.dir, token),
298
+ const [manifestRaw, refs] = await Promise.all([
299
+ readRaw(runtime.backend, runtime.manifestPath, token),
244
300
  listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
245
301
  ]);
246
302
  const pendingIds = new Set(
@@ -249,27 +305,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
249
305
  return entry && entry.concept.id === concept.id ? [entry.id] : [];
250
306
  }),
251
307
  );
252
- // An edited row reads branch-first like a new row, so a pending title or draft change
253
- // shows in the list instead of reading as a lost save.
308
+ // A repo with no committed manifest yet (a fresh site before its first publish) falls back
309
+ // to the crawl; a manifest that parses but is empty is trusted as-is.
310
+ if (manifestRaw === null) {
311
+ return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
312
+ }
313
+ // Newest id first, the same order the crawl's file listing produced.
314
+ const rows = parseManifest(manifestRaw)
315
+ .entries.filter((e) => e.concept === concept.id)
316
+ .sort((a, b) => b.id.localeCompare(a.id));
254
317
  const entries = await Promise.all(
255
- files.map((f) =>
256
- pendingIds.has(f.id)
257
- ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
258
- : summarize(f, token, 'published'),
318
+ rows.map((e) =>
319
+ pendingIds.has(e.id)
320
+ ? pendingRow(concept, e.id, 'edited', token)
321
+ : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const },
259
322
  ),
260
323
  );
261
- // A ref with no main file is a never-published entry; its row reads from its branch, and
262
- // summarize already degrades a failed read to an id-only row.
263
- const listed = new Set(files.map((f) => f.id));
324
+ const listed = new Set(rows.map((e) => e.id));
264
325
  const newRows = await Promise.all(
265
- [...pendingIds]
266
- .filter((id) => !listed.has(id))
267
- .map((id) =>
268
- summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
269
- ...runtime.backend,
270
- branch: pendingBranch(concept.id, id),
271
- }),
272
- ),
326
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
273
327
  );
274
328
  return { ...base, entries: [...entries, ...newRows], error: null };
275
329
  } catch {
@@ -333,17 +387,21 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
333
387
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
334
388
  const path = `${concept.dir}/${filenameFromId(id)}`;
335
389
  // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
336
- // (link targets and the inbound-link guard) always reads main, the authoritative copy, and a
337
- // pending entry adds a main read of its own path to derive its published state.
390
+ // (link targets and the inbound-link guard) always reads main, the authoritative copy.
391
+ // Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
392
+ // so the probe does not serialize ahead of the other two; stage 2 adds the branch read
393
+ // only when the probe found a branch, with the stage-1 main read serving as the published
394
+ // signal either way.
338
395
  const branch = pendingBranch(concept.id, id);
339
- const pending = (await branchHeadSha(runtime.backend, branch, token)) !== null;
340
- const [raw, manifestRaw, mainRaw] = await Promise.all([
341
- readRaw(pending ? { ...runtime.backend, branch } : runtime.backend, path, token),
396
+ const [headSha, mainRaw, manifestRaw] = await Promise.all([
397
+ branchHeadSha(runtime.backend, branch, token),
398
+ readRaw(runtime.backend, path, token),
342
399
  readRaw(runtime.backend, runtime.manifestPath, token),
343
- pending ? readRaw(runtime.backend, path, token) : Promise.resolve(null),
344
400
  ]);
401
+ const pending = headSha !== null;
402
+ const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
345
403
  if (raw === null && !isNew) throw error(404, 'Entry not found');
346
- const published = pending ? mainRaw !== null : raw !== null;
404
+ const published = mainRaw !== null;
347
405
 
348
406
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
349
407
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
@@ -385,11 +443,6 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
385
443
  };
386
444
  }
387
445
 
388
- /** Match a commit conflict by class and by name (bundling can alias the class identity). */
389
- function isConflict(err: unknown): boolean {
390
- return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
391
- }
392
-
393
446
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
394
447
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
395
448
  * failures carry the same shape under their own event name. */
@@ -487,7 +540,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
487
540
  else if (target.draft) draftLinks.push(formatCairnToken(ref));
488
541
  }
489
542
  if (absent.length) {
490
- return fail(400, { brokenLinks: absent, body });
543
+ const noun = absent.length === 1 ? 'page' : 'pages';
544
+ return fail(400, {
545
+ error: `This page links to ${absent.length} missing ${noun}.`,
546
+ brokenLinks: absent,
547
+ body,
548
+ } satisfies SaveFailure);
491
549
  }
492
550
 
493
551
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
@@ -618,14 +676,18 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
618
676
  next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
619
677
  published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
620
678
  }
621
- if (published.length === 0) throw redirect(303, listPage);
679
+ if (published.length === 0) {
680
+ const message = 'Nothing to publish. Every entry is already live.';
681
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
682
+ }
622
683
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
623
684
 
685
+ const noun = published.length === 1 ? 'entry' : 'entries';
624
686
  try {
625
687
  await commitFiles(
626
688
  runtime.backend,
627
689
  changes,
628
- { message: `Publish ${published.length} entries`, author: { name: editor.displayName, email: editor.email } },
690
+ { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } },
629
691
  token,
630
692
  );
631
693
  for (const entry of published) {
@@ -696,7 +758,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
696
758
  const manifest = await readManifest(token);
697
759
  const inbound = inboundLinks(manifest, concept.id, id);
698
760
  if (inbound.length) {
699
- return fail(409, { inboundLinks: inbound, id });
761
+ return fail(409, {
762
+ error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
763
+ inboundLinks: inbound,
764
+ id,
765
+ } satisfies DeleteRefusal);
700
766
  }
701
767
 
702
768
  // When the entry was never published (absent from main), the branch delete is the whole
@@ -771,20 +837,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
771
837
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
772
838
  // them, so refuse until the editor publishes or discards.
773
839
  if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
774
- return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
840
+ return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
775
841
  }
776
842
 
777
843
  const form = await event.request.formData();
778
844
  const newSlug = String(form.get('slug') ?? '').trim();
779
845
  if (!isValidId(newSlug)) {
780
- return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
846
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies RenameFailure);
781
847
  }
782
848
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
783
849
  if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
784
- return fail(400, { renameError: 'Leave the date out of the slug.' });
850
+ return fail(400, { error: 'Leave the date out of the slug.' } satisfies RenameFailure);
785
851
  }
786
852
  if (newSlug === slugFromId(id, datePrefix)) {
787
- return fail(400, { renameError: 'That is already the slug.' });
853
+ return fail(400, { error: 'That is already the slug.' } satisfies RenameFailure);
788
854
  }
789
855
  const newId = renameId(id, newSlug, datePrefix);
790
856
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
@@ -795,7 +861,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
795
861
  // concurrent-rename race where another editor renamed onto this path between load and submit.
796
862
  const clobber = await readRaw(runtime.backend, newPath, token);
797
863
  if (clobber !== null) {
798
- return fail(409, { renameError: 'An entry with that slug already exists.' });
864
+ return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
799
865
  }
800
866
 
801
867
  const [entryRaw, manifest] = await Promise.all([
@@ -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
  import type { Editor } from '../auth/types.js';
12
12
  import type { HandleInput, RequestContext } from './types.js';
@@ -60,6 +60,20 @@ export function createAuthGuard() {
60
60
  return renderConditionResponse('edge.https-not-forced', { url: event.url });
61
61
  }
62
62
 
63
+ // No auth store binding means no admin path can work: the gated views cannot resolve a
64
+ // session, and a login or confirm POST would die in its action with a raw 500. That is an
65
+ // operator fault, not a sign-in problem, so name the condition on every admin path, the
66
+ // public ones included, instead of rendering a login form that can never succeed.
67
+ const env = event.platform?.env ?? {};
68
+ if (!env.AUTH_DB) {
69
+ log.error('guard.rejected', {
70
+ reason: 'bindings',
71
+ conditionId: REASON_CONDITION.bindings,
72
+ path: pathname,
73
+ });
74
+ return renderConditionResponse(REASON_CONDITION.bindings);
75
+ }
76
+
63
77
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
64
78
  // 403 before resolve() runs. This covers the public login/auth posts too.
65
79
  if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
@@ -68,9 +82,8 @@ export function createAuthGuard() {
68
82
  }
69
83
 
70
84
  if (!isPublicAdminPath(pathname)) {
71
- const env = event.platform?.env ?? {};
72
85
  const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
73
- const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
86
+ const editor = id ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
74
87
  if (!editor) throw redirect(303, '/admin/login');
75
88
  event.locals.editor = editor;
76
89
  }
@@ -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
  /**
10
11
  * Render the full HTML document for the HTTPS-required page.
@@ -12,9 +12,15 @@ export type {
12
12
  EditData,
13
13
  ContentEvent,
14
14
  ContentRoutesDeps,
15
+ SaveFailure,
16
+ DeleteRefusal,
17
+ RenameFailure,
18
+ ContentFormFailure,
15
19
  } from './content-routes.js';
16
20
  export { createNavRoutes } from './nav-routes.js';
17
21
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
22
+ export { parseAdminPath, type AdminView } from './admin-dispatch.js';
23
+ export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-admin.js';
18
24
  export { healthLoad, type HealthData } from './health.js';
19
25
  export type { RequestContext, CookieJar, HandleInput } from './types.js';
20
26
  // Re-exported here, not from root, so the public ContentRoutesDeps consumer can name it.
@@ -5,7 +5,7 @@ import { redirect, error } from '@sveltejs/kit';
5
5
  import { appCredentials, type GithubKeyEnv } 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, type NavNode } from '../nav/site-config.js';
11
11
  import type { CairnRuntime } from '../content/types.js';
@@ -29,7 +29,9 @@ export interface NavLoadData {
29
29
 
30
30
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
31
31
  export interface NavRoutesDeps {
32
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
32
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
33
+ * A bare string works too; the routes await whatever comes back. */
34
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
33
35
  }
34
36
 
35
37
  /** The signed-in editor the guard resolved, or a login redirect. */
@@ -39,11 +41,6 @@ function sessionOf(event: ContentEvent): Editor {
39
41
  return editor;
40
42
  }
41
43
 
42
- /** Match a commit conflict by class and by name (bundling can alias the class identity). */
43
- function isConflict(err: unknown): boolean {
44
- return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
45
- }
46
-
47
44
  export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
48
45
  const mintToken =
49
46
  deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
@@ -80,12 +77,25 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
80
77
  }
81
78
 
82
79
  let tree: NavNode[] = [];
80
+ let raw: string | null = null;
83
81
  try {
84
- const raw = await readRaw(runtime.backend, config.configPath, token);
85
- if (raw !== null) tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
82
+ raw = await readRaw(runtime.backend, config.configPath, token);
86
83
  } catch {
87
- // A malformed or unreadable config degrades to an empty tree; the first save writes a clean menu.
88
- tree = [];
84
+ // An unreadable config degrades to an empty tree; the first save writes a clean menu.
85
+ raw = null;
86
+ }
87
+ if (raw !== null) {
88
+ try {
89
+ tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
90
+ } catch (err) {
91
+ // A malformed config keeps the same degrade (the nav page failing closed would be worse
92
+ // for the editor), but the swallow names the operator fault in the log.
93
+ log.error('config.invalid', {
94
+ conditionId: 'config.site-config-invalid',
95
+ error: String(err),
96
+ });
97
+ tree = [];
98
+ }
89
99
  }
90
100
 
91
101
  return {
@@ -2,15 +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
-
6
- /** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
7
- export function escapeHtml(value: string): string {
8
- return value
9
- .replace(/&/g, '&amp;')
10
- .replace(/</g, '&lt;')
11
- .replace(/>/g, '&gt;')
12
- .replace(/"/g, '&quot;');
13
- }
5
+ import { escapeHtml } from '../escape.js';
14
6
 
15
7
  // The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
16
8
  const CAIRN_GLYPH =
@@ -16,16 +16,25 @@ export interface CookieJar {
16
16
  delete(name: string, opts: { path: string }): void;
17
17
  }
18
18
 
19
- export interface RequestContext {
19
+ /** The Cloudflare platform wrapper an event carries; `context` is the legacy alias for `ctx`. */
20
+ export interface PlatformContext<Env> {
21
+ env?: Env;
22
+ ctx?: { waitUntil(promise: Promise<unknown>): void };
23
+ context?: { 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> {
20
30
  url: URL;
21
31
  request: Request;
22
- cookies: CookieJar;
23
32
  locals: { editor?: Editor | null };
24
- platform?: {
25
- env?: AuthEnv;
26
- ctx?: { waitUntil(promise: Promise<unknown>): void };
27
- context?: { waitUntil(promise: Promise<unknown>): void };
28
- };
33
+ platform?: PlatformContext<Env>;
34
+ }
35
+
36
+ export interface RequestContext extends EventBase<AuthEnv> {
37
+ cookies: CookieJar;
29
38
  // Required so a site cannot silently drop the confirm page's Referrer-Policy header
30
39
  // (spec 7.1). A real SvelteKit RequestEvent always supplies it.
31
40
  setHeaders(headers: Record<string, string>): void;
@@ -1,12 +0,0 @@
1
- /** A page of items plus its navigation state. */
2
- export interface Page<T> {
3
- items: T[];
4
- page: number;
5
- perPage: number;
6
- total: number;
7
- totalPages: number;
8
- hasPrev: boolean;
9
- hasNext: boolean;
10
- }
11
- /** Slice `items` into the 1-based `page` of size `perPage`, clamping the page into bounds. */
12
- export declare function paginate<T>(items: T[], page: number, perPage: number): Page<T>;
@@ -1,20 +0,0 @@
1
- // cairn-cms: pagination helper (public-delivery design). Pure slice math; the template renders
2
- // the controls. An out-of-range page clamps into bounds.
3
- /** Slice `items` into the 1-based `page` of size `perPage`, clamping the page into bounds. */
4
- export function paginate(items, page, perPage) {
5
- const total = items.length;
6
- // A non-positive page size would make totalPages Infinity, so clamp it to one.
7
- const size = Math.max(1, Math.floor(perPage) || 1);
8
- const totalPages = Math.max(1, Math.ceil(total / size));
9
- const current = Math.min(Math.max(1, Math.floor(page) || 1), totalPages);
10
- const start = (current - 1) * size;
11
- return {
12
- items: items.slice(start, start + size),
13
- page: current,
14
- perPage: size,
15
- total,
16
- totalPages,
17
- hasPrev: current > 1,
18
- hasNext: current < totalPages,
19
- };
20
- }
@@ -1,5 +0,0 @@
1
- export * from './registry.js';
2
- export * from './glyph.js';
3
- export * from './remark-directives.js';
4
- export * from './rehype-dispatch.js';
5
- export * from './pipeline.js';
@@ -1,8 +0,0 @@
1
- // cairn-cms render engine: a directive-driven markdown to HTML pipeline whose
2
- // component vocabulary is supplied by a site's component registry. The site owns the
3
- // component builders, class names, icon set, and CSS; the engine owns the machinery.
4
- export * from './registry.js';
5
- export * from './glyph.js';
6
- export * from './remark-directives.js';
7
- export * from './rehype-dispatch.js';
8
- export * from './pipeline.js';
@@ -1,32 +0,0 @@
1
- // cairn-cms: pagination helper (public-delivery design). Pure slice math; the template renders
2
- // the controls. An out-of-range page clamps into bounds.
3
-
4
- /** A page of items plus its navigation state. */
5
- export interface Page<T> {
6
- items: T[];
7
- page: number;
8
- perPage: number;
9
- total: number;
10
- totalPages: number;
11
- hasPrev: boolean;
12
- hasNext: boolean;
13
- }
14
-
15
- /** Slice `items` into the 1-based `page` of size `perPage`, clamping the page into bounds. */
16
- export function paginate<T>(items: T[], page: number, perPage: number): Page<T> {
17
- const total = items.length;
18
- // A non-positive page size would make totalPages Infinity, so clamp it to one.
19
- const size = Math.max(1, Math.floor(perPage) || 1);
20
- const totalPages = Math.max(1, Math.ceil(total / size));
21
- const current = Math.min(Math.max(1, Math.floor(page) || 1), totalPages);
22
- const start = (current - 1) * size;
23
- return {
24
- items: items.slice(start, start + size),
25
- page: current,
26
- perPage: size,
27
- total,
28
- totalPages,
29
- hasPrev: current > 1,
30
- hasNext: current < totalPages,
31
- };
32
- }
@@ -1,8 +0,0 @@
1
- // cairn-cms render engine: a directive-driven markdown to HTML pipeline whose
2
- // component vocabulary is supplied by a site's component registry. The site owns the
3
- // component builders, class names, icon set, and CSS; the engine owns the machinery.
4
- export * from './registry.js';
5
- export * from './glyph.js';
6
- export * from './remark-directives.js';
7
- export * from './rehype-dispatch.js';
8
- export * from './pipeline.js';