@glw907/cairn-cms 0.41.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 (113) hide show
  1. package/CHANGELOG.md +42 -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 +13 -9
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/LoginPage.svelte +2 -2
  14. package/dist/components/LoginPage.svelte.d.ts +1 -1
  15. package/dist/components/ManageEditors.svelte +4 -3
  16. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  17. package/dist/components/index.d.ts +1 -0
  18. package/dist/components/index.js +1 -0
  19. package/dist/components/markdown-format.d.ts +0 -8
  20. package/dist/components/markdown-format.js +0 -28
  21. package/dist/content/links.d.ts +8 -0
  22. package/dist/content/links.js +28 -0
  23. package/dist/content/types.d.ts +2 -2
  24. package/dist/delivery/data.d.ts +3 -5
  25. package/dist/delivery/data.js +2 -3
  26. package/dist/delivery/feeds.js +1 -7
  27. package/dist/delivery/index.d.ts +2 -2
  28. package/dist/delivery/index.js +1 -1
  29. package/dist/delivery/manifest.d.ts +0 -5
  30. package/dist/delivery/manifest.js +5 -16
  31. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  32. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  33. package/dist/delivery/site-indexes.d.ts +3 -3
  34. package/dist/delivery/site-indexes.js +3 -3
  35. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  36. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  37. package/dist/delivery/sitemap.js +1 -3
  38. package/dist/delivery/xml.d.ts +2 -0
  39. package/dist/delivery/xml.js +11 -0
  40. package/dist/email.js +4 -11
  41. package/dist/env.d.ts +1 -1
  42. package/dist/env.js +3 -2
  43. package/dist/escape.d.ts +2 -0
  44. package/dist/escape.js +11 -0
  45. package/dist/github/credentials.d.ts +2 -1
  46. package/dist/github/credentials.js +10 -2
  47. package/dist/github/types.d.ts +2 -0
  48. package/dist/github/types.js +4 -0
  49. package/dist/log/events.d.ts +1 -1
  50. package/dist/nav/site-config.d.ts +2 -0
  51. package/dist/nav/site-config.js +2 -0
  52. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  53. package/dist/sveltekit/admin-dispatch.js +62 -0
  54. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  55. package/dist/sveltekit/cairn-admin.js +126 -0
  56. package/dist/sveltekit/condition-response.d.ts +1 -0
  57. package/dist/sveltekit/condition-response.js +25 -0
  58. package/dist/sveltekit/content-routes.d.ts +34 -14
  59. package/dist/sveltekit/content-routes.js +59 -33
  60. package/dist/sveltekit/guard.js +15 -3
  61. package/dist/sveltekit/https-required-page.js +2 -1
  62. package/dist/sveltekit/index.d.ts +3 -1
  63. package/dist/sveltekit/index.js +2 -0
  64. package/dist/sveltekit/nav-routes.d.ts +3 -1
  65. package/dist/sveltekit/nav-routes.js +19 -10
  66. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  67. package/dist/sveltekit/static-admin-page.js +1 -8
  68. package/dist/sveltekit/types.d.ts +18 -11
  69. package/package.json +5 -1
  70. package/src/lib/ambient.ts +19 -0
  71. package/src/lib/components/AdminLayout.svelte +6 -8
  72. package/src/lib/components/CairnAdmin.svelte +67 -0
  73. package/src/lib/components/ConceptList.svelte +4 -5
  74. package/src/lib/components/ConfirmPage.svelte +1 -1
  75. package/src/lib/components/EditPage.svelte +13 -9
  76. package/src/lib/components/LoginPage.svelte +2 -2
  77. package/src/lib/components/ManageEditors.svelte +4 -3
  78. package/src/lib/components/index.ts +1 -0
  79. package/src/lib/components/markdown-format.ts +0 -27
  80. package/src/lib/content/links.ts +28 -0
  81. package/src/lib/content/types.ts +2 -2
  82. package/src/lib/delivery/data.ts +3 -5
  83. package/src/lib/delivery/feeds.ts +1 -8
  84. package/src/lib/delivery/index.ts +2 -2
  85. package/src/lib/delivery/manifest.ts +5 -18
  86. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  87. package/src/lib/delivery/site-indexes.ts +6 -6
  88. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  89. package/src/lib/delivery/sitemap.ts +1 -4
  90. package/src/lib/delivery/xml.ts +12 -0
  91. package/src/lib/email.ts +4 -11
  92. package/src/lib/env.ts +3 -2
  93. package/src/lib/escape.ts +12 -0
  94. package/src/lib/github/credentials.ts +6 -2
  95. package/src/lib/github/types.ts +5 -0
  96. package/src/lib/log/events.ts +1 -0
  97. package/src/lib/nav/site-config.ts +3 -0
  98. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  99. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  100. package/src/lib/sveltekit/condition-response.ts +27 -1
  101. package/src/lib/sveltekit/content-routes.ts +102 -45
  102. package/src/lib/sveltekit/guard.ts +16 -3
  103. package/src/lib/sveltekit/https-required-page.ts +2 -1
  104. package/src/lib/sveltekit/index.ts +6 -0
  105. package/src/lib/sveltekit/nav-routes.ts +21 -11
  106. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  107. package/src/lib/sveltekit/types.ts +16 -7
  108. package/dist/delivery/paginate.d.ts +0 -12
  109. package/dist/delivery/paginate.js +0 -20
  110. package/dist/render/index.d.ts +0 -5
  111. package/dist/render/index.js +0 -8
  112. package/src/lib/delivery/paginate.ts +0 -32
  113. 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;
@@ -223,9 +250,37 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
223
250
  }
224
251
  }
225
252
 
226
- /** List a concept's entries with their publish status. Main's files carry `edited` when a
227
- * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
228
- * 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. */
229
284
  async function listLoad(event: ContentEvent): Promise<ListData> {
230
285
  sessionOf(event);
231
286
  const concept = conceptOf(runtime, event.params);
@@ -240,8 +295,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
240
295
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
241
296
  }
242
297
  try {
243
- const [files, refs] = await Promise.all([
244
- listMarkdown(runtime.backend, concept.dir, token),
298
+ const [manifestRaw, refs] = await Promise.all([
299
+ readRaw(runtime.backend, runtime.manifestPath, token),
245
300
  listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
246
301
  ]);
247
302
  const pendingIds = new Set(
@@ -250,27 +305,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
250
305
  return entry && entry.concept.id === concept.id ? [entry.id] : [];
251
306
  }),
252
307
  );
253
- // An edited row reads branch-first like a new row, so a pending title or draft change
254
- // 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));
255
317
  const entries = await Promise.all(
256
- files.map((f) =>
257
- pendingIds.has(f.id)
258
- ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
259
- : 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 },
260
322
  ),
261
323
  );
262
- // A ref with no main file is a never-published entry; its row reads from its branch, and
263
- // summarize already degrades a failed read to an id-only row.
264
- const listed = new Set(files.map((f) => f.id));
324
+ const listed = new Set(rows.map((e) => e.id));
265
325
  const newRows = await Promise.all(
266
- [...pendingIds]
267
- .filter((id) => !listed.has(id))
268
- .map((id) =>
269
- summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
270
- ...runtime.backend,
271
- branch: pendingBranch(concept.id, id),
272
- }),
273
- ),
326
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
274
327
  );
275
328
  return { ...base, entries: [...entries, ...newRows], error: null };
276
329
  } catch {
@@ -390,11 +443,6 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
390
443
  };
391
444
  }
392
445
 
393
- /** Match a commit conflict by class and by name (bundling can alias the class identity). */
394
- function isConflict(err: unknown): boolean {
395
- return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
396
- }
397
-
398
446
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
399
447
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
400
448
  * failures carry the same shape under their own event name. */
@@ -492,7 +540,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
492
540
  else if (target.draft) draftLinks.push(formatCairnToken(ref));
493
541
  }
494
542
  if (absent.length) {
495
- 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);
496
549
  }
497
550
 
498
551
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
@@ -705,7 +758,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
705
758
  const manifest = await readManifest(token);
706
759
  const inbound = inboundLinks(manifest, concept.id, id);
707
760
  if (inbound.length) {
708
- 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);
709
766
  }
710
767
 
711
768
  // When the entry was never published (absent from main), the branch delete is the whole
@@ -780,20 +837,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
780
837
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
781
838
  // them, so refuse until the editor publishes or discards.
782
839
  if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
783
- 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);
784
841
  }
785
842
 
786
843
  const form = await event.request.formData();
787
844
  const newSlug = String(form.get('slug') ?? '').trim();
788
845
  if (!isValidId(newSlug)) {
789
- 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);
790
847
  }
791
848
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
792
849
  if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
793
- 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);
794
851
  }
795
852
  if (newSlug === slugFromId(id, datePrefix)) {
796
- return fail(400, { renameError: 'That is already the slug.' });
853
+ return fail(400, { error: 'That is already the slug.' } satisfies RenameFailure);
797
854
  }
798
855
  const newId = renameId(id, newSlug, datePrefix);
799
856
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
@@ -804,7 +861,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
804
861
  // concurrent-rename race where another editor renamed onto this path between load and submit.
805
862
  const clobber = await readRaw(runtime.backend, newPath, token);
806
863
  if (clobber !== null) {
807
- 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);
808
865
  }
809
866
 
810
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';