@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
@@ -3,6 +3,7 @@ export declare const REASON_CONDITION: {
3
3
  readonly https: "edge.https-not-forced";
4
4
  readonly csrf: "auth.csrf-token-invalid";
5
5
  readonly origin: "auth.csrf-origin-mismatch";
6
+ readonly bindings: "config.bindings-missing";
6
7
  };
7
8
  export type GuardReason = keyof typeof REASON_CONDITION;
8
9
  /** Render the Response the guard serves for a rejection, by its condition id. */
@@ -4,13 +4,35 @@
4
4
  import { brandedAdminPage } from './admin-response.js';
5
5
  import { httpsRequiredPage } from './https-required-page.js';
6
6
  import { csrfRequiredPage } from './csrf-required-page.js';
7
+ import { escapeHtml } from '../escape.js';
8
+ import { renderStaticAdminPage } from './static-admin-page.js';
7
9
  import { condition } from '../diagnostics/index.js';
8
10
  /** The guard.rejected reasons, each mapped to its registered condition id. */
9
11
  export const REASON_CONDITION = {
10
12
  https: 'edge.https-not-forced',
11
13
  csrf: 'auth.csrf-token-invalid',
12
14
  origin: 'auth.csrf-origin-mismatch',
15
+ bindings: 'config.bindings-missing',
13
16
  };
17
+ /**
18
+ * A branded page for an operator fault, built straight from the registered condition's fields so
19
+ * the served copy, the doctor's report, and the readiness checklist say the same thing.
20
+ */
21
+ function conditionFaultPage(cond) {
22
+ const inner = `
23
+ <span class="eyebrow">
24
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
25
+ Site setup required
26
+ </span>
27
+ <h1>${escapeHtml(cond.title)}</h1>
28
+ <p>${escapeHtml(cond.why)}</p>
29
+
30
+ <div class="fix">
31
+ <h2>If you run this site</h2>
32
+ <p>${escapeHtml(cond.remediation)}</p>
33
+ </div>`;
34
+ return renderStaticAdminPage({ title: `${cond.title} · Cairn`, innerHtml: inner });
35
+ }
14
36
  /** Render the Response the guard serves for a rejection, by its condition id. */
15
37
  export function renderConditionResponse(id, ctx = {}) {
16
38
  // Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
@@ -28,6 +50,9 @@ export function renderConditionResponse(id, ctx = {}) {
28
50
  status: 403,
29
51
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
30
52
  });
53
+ case REASON_CONDITION.bindings:
54
+ // An operator fault, not a request fault: the Worker deployed without its bindings.
55
+ return brandedAdminPage(500, conditionFaultPage(condition(id)));
31
56
  default:
32
57
  throw new Error(`no runtime renderer for condition: ${id}`);
33
58
  }
@@ -1,9 +1,9 @@
1
1
  import { fail } from '@sveltejs/kit';
2
2
  import { type GithubKeyEnv } from '../github/credentials.js';
3
3
  import { type LinkTarget, type InboundLink } from '../content/manifest.js';
4
- import type { CookieJar } from './types.js';
4
+ import type { CookieJar, EventBase } from './types.js';
5
5
  import type { CairnRuntime, FrontmatterField } from '../content/types.js';
6
- import type { Editor, Role } from '../auth/types.js';
6
+ import type { Role } from '../auth/types.js';
7
7
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
8
8
  export interface NavConcept {
9
9
  id: string;
@@ -89,25 +89,45 @@ export interface EditData {
89
89
  discardedFlash: boolean;
90
90
  }
91
91
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
92
- export interface ContentEvent {
93
- url: URL;
92
+ export interface ContentEvent extends EventBase<GithubKeyEnv> {
94
93
  params: Record<string, string>;
95
- request: Request;
96
- locals: {
97
- editor?: Editor | null;
98
- };
99
- platform?: {
100
- env?: GithubKeyEnv;
101
- };
102
94
  /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
103
95
  * token. Optional for non-route callers. */
104
96
  cookies?: CookieJar;
105
97
  }
106
98
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
107
99
  export interface ContentRoutesDeps {
108
- /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer. */
109
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
100
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
101
+ * A bare string works too; the routes await whatever comes back. */
102
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
103
+ }
104
+ /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
105
+ export interface SaveFailure {
106
+ /** The one-line human summary every content action failure carries. */
107
+ error: string;
108
+ /** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
109
+ brokenLinks: string[];
110
+ /** The author's edited markdown, so the editor reseeds with the unsaved work. */
111
+ body: string;
112
+ }
113
+ /** A refused delete: `fail(409)` while other entries still link to this one. */
114
+ export interface DeleteRefusal {
115
+ /** The one-line human summary every content action failure carries. */
116
+ error: string;
117
+ /** The entries whose bodies link to the refused one, for the blockers list. */
118
+ inboundLinks: InboundLink[];
119
+ /** The refused entry's id, so a list view marks the right row. */
120
+ id: string;
121
+ }
122
+ /** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
123
+ export interface RenameFailure {
124
+ /** The one-line human summary every content action failure carries. */
125
+ error: string;
110
126
  }
127
+ /** What a route's single `form` export presents to a view component: whichever content action
128
+ * last failed, merged with every field optional. `error` is always set on a failure; the richer
129
+ * keys identify which guard refused. */
130
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
111
131
  export declare function createContentRoutes(runtime: CairnRuntime, deps?: ContentRoutesDeps): {
112
132
  layoutLoad: (event: ContentEvent) => Promise<LayoutData>;
113
133
  indexRedirect: () => never;
@@ -121,5 +141,5 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
121
141
  deleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
122
142
  listDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
123
143
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
124
- mintToken: (env: GithubKeyEnv) => Promise<string>;
144
+ mintToken: (env: GithubKeyEnv) => string | Promise<string>;
125
145
  };
@@ -4,17 +4,16 @@
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
19
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
@@ -72,8 +71,9 @@ export function createContentRoutes(runtime, deps = {}) {
72
71
  return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
73
72
  });
74
73
  }
75
- catch {
74
+ catch (err) {
76
75
  pendingEntries = null;
76
+ log.warn('github.unreachable', { scope: 'layout', error: String(err) });
77
77
  }
78
78
  return {
79
79
  siteName: runtime.siteName,
@@ -111,9 +111,31 @@ export function createContentRoutes(runtime, deps = {}) {
111
111
  return { id: file.id, title: file.id, date: null, draft: false, status };
112
112
  }
113
113
  }
114
- /** List a concept's entries with their publish status. Main's files carry `edited` when a
115
- * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
116
- * its branch. A listing failure degrades to an inline error, not a thrown 500. */
114
+ /** Read an entry's list row from its pending branch, so a pending title or draft change shows
115
+ * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
116
+ * an id-only row, so a ghost ref still lists. */
117
+ function pendingRow(concept, id, status, token) {
118
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
119
+ ...runtime.backend,
120
+ branch: pendingBranch(concept.id, id),
121
+ });
122
+ }
123
+ /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
124
+ * and read each one for its row, with edited and new rows reading branch-first. */
125
+ async function crawlEntries(concept, pendingIds, token) {
126
+ const files = await listMarkdown(runtime.backend, concept.dir, token);
127
+ const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
128
+ // A ref with no main file is a never-published entry; its row reads from its branch.
129
+ const listed = new Set(files.map((f) => f.id));
130
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
131
+ return [...entries, ...newRows];
132
+ }
133
+ /** List a concept's entries with their publish status. Published rows project straight from
134
+ * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
135
+ * the listing costs one manifest read plus one branch read per pending entry rather than one
136
+ * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
137
+ * with no manifest row appends a `new` row read from its branch. A listing failure degrades
138
+ * to an inline error, not a thrown 500. */
117
139
  async function listLoad(event) {
118
140
  sessionOf(event);
119
141
  const concept = conceptOf(runtime, event.params);
@@ -129,28 +151,28 @@ export function createContentRoutes(runtime, deps = {}) {
129
151
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
130
152
  }
131
153
  try {
132
- const [files, refs] = await Promise.all([
133
- listMarkdown(runtime.backend, concept.dir, token),
154
+ const [manifestRaw, refs] = await Promise.all([
155
+ readRaw(runtime.backend, runtime.manifestPath, token),
134
156
  listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
135
157
  ]);
136
158
  const pendingIds = new Set(refs.flatMap((name) => {
137
159
  const entry = pendingEntryOf(name);
138
160
  return entry && entry.concept.id === concept.id ? [entry.id] : [];
139
161
  }));
140
- // An edited row reads branch-first like a new row, so a pending title or draft change
141
- // shows in the list instead of reading as a lost save.
142
- const entries = await Promise.all(files.map((f) => pendingIds.has(f.id)
143
- ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
144
- : summarize(f, token, 'published')));
145
- // A ref with no main file is a never-published entry; its row reads from its branch, and
146
- // summarize already degrades a failed read to an id-only row.
147
- const listed = new Set(files.map((f) => f.id));
148
- const newRows = await Promise.all([...pendingIds]
149
- .filter((id) => !listed.has(id))
150
- .map((id) => summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
151
- ...runtime.backend,
152
- branch: pendingBranch(concept.id, id),
153
- })));
162
+ // A repo with no committed manifest yet (a fresh site before its first publish) falls back
163
+ // to the crawl; a manifest that parses but is empty is trusted as-is.
164
+ if (manifestRaw === null) {
165
+ return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
166
+ }
167
+ // Newest id first, the same order the crawl's file listing produced.
168
+ const rows = parseManifest(manifestRaw)
169
+ .entries.filter((e) => e.concept === concept.id)
170
+ .sort((a, b) => b.id.localeCompare(a.id));
171
+ const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
172
+ ? pendingRow(concept, e.id, 'edited', token)
173
+ : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' }));
174
+ const listed = new Set(rows.map((e) => e.id));
175
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
154
176
  return { ...base, entries: [...entries, ...newRows], error: null };
155
177
  }
156
178
  catch {
@@ -216,18 +238,22 @@ export function createContentRoutes(runtime, deps = {}) {
216
238
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
217
239
  const path = `${concept.dir}/${filenameFromId(id)}`;
218
240
  // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
219
- // (link targets and the inbound-link guard) always reads main, the authoritative copy, and a
220
- // pending entry adds a main read of its own path to derive its published state.
241
+ // (link targets and the inbound-link guard) always reads main, the authoritative copy.
242
+ // Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
243
+ // so the probe does not serialize ahead of the other two; stage 2 adds the branch read
244
+ // only when the probe found a branch, with the stage-1 main read serving as the published
245
+ // signal either way.
221
246
  const branch = pendingBranch(concept.id, id);
222
- const pending = (await branchHeadSha(runtime.backend, branch, token)) !== null;
223
- const [raw, manifestRaw, mainRaw] = await Promise.all([
224
- readRaw(pending ? { ...runtime.backend, branch } : runtime.backend, path, token),
247
+ const [headSha, mainRaw, manifestRaw] = await Promise.all([
248
+ branchHeadSha(runtime.backend, branch, token),
249
+ readRaw(runtime.backend, path, token),
225
250
  readRaw(runtime.backend, runtime.manifestPath, token),
226
- pending ? readRaw(runtime.backend, path, token) : Promise.resolve(null),
227
251
  ]);
252
+ const pending = headSha !== null;
253
+ const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
228
254
  if (raw === null && !isNew)
229
255
  throw error(404, 'Entry not found');
230
- const published = pending ? mainRaw !== null : raw !== null;
256
+ const published = mainRaw !== null;
231
257
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
232
258
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
233
259
  let linkTargets = [];
@@ -265,10 +291,6 @@ export function createContentRoutes(runtime, deps = {}) {
265
291
  discardedFlash: event.url.searchParams.get('discarded') === '1',
266
292
  };
267
293
  }
268
- /** Match a commit conflict by class and by name (bundling can alias the class identity). */
269
- function isConflict(err) {
270
- return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
271
- }
272
294
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
273
295
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
274
296
  * failures carry the same shape under their own event name. */
@@ -333,7 +355,12 @@ export function createContentRoutes(runtime, deps = {}) {
333
355
  draftLinks.push(formatCairnToken(ref));
334
356
  }
335
357
  if (absent.length) {
336
- return fail(400, { brokenLinks: absent, body });
358
+ const noun = absent.length === 1 ? 'page' : 'pages';
359
+ return fail(400, {
360
+ error: `This page links to ${absent.length} missing ${noun}.`,
361
+ brokenLinks: absent,
362
+ body,
363
+ });
337
364
  }
338
365
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
339
366
  // commit only the entry file there. Main stays untouched until publish, so the branch differs
@@ -450,11 +477,14 @@ export function createContentRoutes(runtime, deps = {}) {
450
477
  next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
451
478
  published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
452
479
  }
453
- if (published.length === 0)
454
- throw redirect(303, listPage);
480
+ if (published.length === 0) {
481
+ const message = 'Nothing to publish. Every entry is already live.';
482
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
483
+ }
455
484
  changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
485
+ const noun = published.length === 1 ? 'entry' : 'entries';
456
486
  try {
457
- await commitFiles(runtime.backend, changes, { message: `Publish ${published.length} entries`, author: { name: editor.displayName, email: editor.email } }, token);
487
+ await commitFiles(runtime.backend, changes, { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } }, token);
458
488
  for (const entry of published) {
459
489
  log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
460
490
  }
@@ -517,7 +547,11 @@ export function createContentRoutes(runtime, deps = {}) {
517
547
  const manifest = await readManifest(token);
518
548
  const inbound = inboundLinks(manifest, concept.id, id);
519
549
  if (inbound.length) {
520
- return fail(409, { inboundLinks: inbound, id });
550
+ return fail(409, {
551
+ error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
552
+ inboundLinks: inbound,
553
+ id,
554
+ });
521
555
  }
522
556
  // When the entry was never published (absent from main), the branch delete is the whole
523
557
  // operation; main has nothing to commit, so the only honest log record is the discard of
@@ -585,19 +619,19 @@ export function createContentRoutes(runtime, deps = {}) {
585
619
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
586
620
  // them, so refuse until the editor publishes or discards.
587
621
  if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
588
- return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
622
+ return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' });
589
623
  }
590
624
  const form = await event.request.formData();
591
625
  const newSlug = String(form.get('slug') ?? '').trim();
592
626
  if (!isValidId(newSlug)) {
593
- return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
627
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
594
628
  }
595
629
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
596
630
  if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
597
- return fail(400, { renameError: 'Leave the date out of the slug.' });
631
+ return fail(400, { error: 'Leave the date out of the slug.' });
598
632
  }
599
633
  if (newSlug === slugFromId(id, datePrefix)) {
600
- return fail(400, { renameError: 'That is already the slug.' });
634
+ return fail(400, { error: 'That is already the slug.' });
601
635
  }
602
636
  const newId = renameId(id, newSlug, datePrefix);
603
637
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
@@ -607,7 +641,7 @@ export function createContentRoutes(runtime, deps = {}) {
607
641
  // concurrent-rename race where another editor renamed onto this path between load and submit.
608
642
  const clobber = await readRaw(runtime.backend, newPath, token);
609
643
  if (clobber !== null) {
610
- return fail(409, { renameError: 'An entry with that slug already exists.' });
644
+ return fail(409, { error: 'An entry with that slug already exists.' });
611
645
  }
612
646
  const [entryRaw, manifest] = await Promise.all([
613
647
  readRaw(runtime.backend, oldPath, token),
@@ -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;
@@ -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,7 +5,7 @@ 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
11
  /** The signed-in editor the guard resolved, or a login redirect. */
@@ -15,10 +15,6 @@ function sessionOf(event) {
15
15
  throw redirect(303, '/admin/login');
16
16
  return editor;
17
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
- }
22
18
  export function createNavRoutes(runtime, deps = {}) {
23
19
  const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
24
20
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
@@ -51,14 +47,27 @@ export function createNavRoutes(runtime, deps = {}) {
51
47
  return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
52
48
  }
53
49
  let tree = [];
50
+ let raw = null;
54
51
  try {
55
- const raw = await readRaw(runtime.backend, config.configPath, token);
56
- if (raw !== null)
57
- tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
52
+ raw = await readRaw(runtime.backend, config.configPath, token);
58
53
  }
59
54
  catch {
60
- // A malformed or unreadable config degrades to an empty tree; the first save writes a clean menu.
61
- tree = [];
55
+ // An unreadable config degrades to an empty tree; the first save writes a clean menu.
56
+ raw = null;
57
+ }
58
+ if (raw !== null) {
59
+ try {
60
+ tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
61
+ }
62
+ catch (err) {
63
+ // A malformed config keeps the same degrade (the nav page failing closed would be worse
64
+ // for the editor), but the swallow names the operator fault in the log.
65
+ log.error('config.invalid', {
66
+ conditionId: 'config.site-config-invalid',
67
+ error: String(err),
68
+ });
69
+ tree = [];
70
+ }
62
71
  }
63
72
  return {
64
73
  menu,
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.40.0",
3
+ "version": "0.50.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -26,9 +26,10 @@
26
26
  "markdown"
27
27
  ],
28
28
  "scripts": {
29
- "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js",
29
+ "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js dist/doctor/bin.js",
30
30
  "check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
31
31
  "check:reference": "npm run package && node scripts/reference-coverage.mjs",
32
+ "check:readiness": "npm run package && node scripts/check-readiness.mjs",
32
33
  "check:docs": "node scripts/docs-links.mjs",
33
34
  "check:prose": "node scripts/check-admin-prose.mjs",
34
35
  "prepare": "npm run package",
@@ -79,10 +80,15 @@
79
80
  "types": "./dist/vite/index.d.ts",
80
81
  "default": "./dist/vite/index.js"
81
82
  },
83
+ "./ambient": {
84
+ "types": "./dist/ambient.d.ts",
85
+ "default": "./dist/ambient.js"
86
+ },
82
87
  "./package.json": "./package.json"
83
88
  },
84
89
  "bin": {
85
- "cairn-manifest": "./dist/vite/bin.js"
90
+ "cairn-manifest": "./dist/vite/bin.js",
91
+ "cairn-doctor": "./dist/doctor/bin.js"
86
92
  },
87
93
  "files": [
88
94
  "dist",
@@ -90,7 +96,7 @@
90
96
  "CHANGELOG.md"
91
97
  ],
92
98
  "peerDependencies": {
93
- "@sveltejs/kit": "^2",
99
+ "@sveltejs/kit": "^2.12",
94
100
  "svelte": "^5.0.0"
95
101
  },
96
102
  "dependencies": {
@@ -0,0 +1,19 @@
1
+ // The one-line App.Locals augmentation a consumer site imports from src/app.d.ts:
2
+ //
3
+ // import '@glw907/cairn-cms/ambient';
4
+ //
5
+ // The guard sets `event.locals.editor`, and this declaration types it, so a site no longer
6
+ // hand-writes the `declare global` block. The field is optional: the engine's own structural
7
+ // event types read it as `editor?: Editor | null`, and a request the guard has not touched
8
+ // carries no editor at all.
9
+ import type { Editor } from './auth/types.js';
10
+
11
+ declare global {
12
+ namespace App {
13
+ interface Locals {
14
+ editor?: Editor | null;
15
+ }
16
+ }
17
+ }
18
+
19
+ export {};