@glw907/cairn-cms 0.38.0 → 0.41.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 (97) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +7 -6
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +22 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +604 -75
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +206 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/MarkdownEditor.svelte +80 -34
  16. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  17. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  18. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  19. package/dist/components/RenameDialog.svelte +13 -4
  20. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  21. package/dist/components/WebLinkDialog.svelte +89 -0
  22. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  23. package/dist/components/cairn-admin.css +353 -4
  24. package/dist/components/editor-highlight.d.ts +9 -0
  25. package/dist/components/editor-highlight.js +62 -0
  26. package/dist/components/link-completion.js +10 -3
  27. package/dist/components/markdown-directives.d.ts +7 -0
  28. package/dist/components/markdown-directives.js +22 -0
  29. package/dist/components/markdown-format.d.ts +1 -1
  30. package/dist/components/markdown-format.js +91 -12
  31. package/dist/content/pending.d.ts +9 -0
  32. package/dist/content/pending.js +24 -0
  33. package/dist/diagnostics/conditions.d.ts +8 -1
  34. package/dist/diagnostics/conditions.js +68 -1
  35. package/dist/doctor/bin.d.ts +2 -0
  36. package/dist/doctor/bin.js +44 -0
  37. package/dist/doctor/check-send.d.ts +3 -0
  38. package/dist/doctor/check-send.js +43 -0
  39. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  40. package/dist/doctor/checks-cloudflare.js +200 -0
  41. package/dist/doctor/checks-github.d.ts +2 -0
  42. package/dist/doctor/checks-github.js +57 -0
  43. package/dist/doctor/checks-local.d.ts +5 -0
  44. package/dist/doctor/checks-local.js +112 -0
  45. package/dist/doctor/cloudflare-api.d.ts +7 -0
  46. package/dist/doctor/cloudflare-api.js +24 -0
  47. package/dist/doctor/index.d.ts +23 -0
  48. package/dist/doctor/index.js +68 -0
  49. package/dist/doctor/report.d.ts +5 -0
  50. package/dist/doctor/report.js +21 -0
  51. package/dist/doctor/run.d.ts +8 -0
  52. package/dist/doctor/run.js +20 -0
  53. package/dist/doctor/types.d.ts +41 -0
  54. package/dist/doctor/types.js +10 -0
  55. package/dist/doctor/wrangler-config.d.ts +12 -0
  56. package/dist/doctor/wrangler-config.js +125 -0
  57. package/dist/github/branches.d.ts +11 -0
  58. package/dist/github/branches.js +75 -0
  59. package/dist/github/signing.d.ts +3 -1
  60. package/dist/github/signing.js +13 -5
  61. package/dist/log/events.d.ts +1 -1
  62. package/dist/sveltekit/content-routes.d.ts +22 -1
  63. package/dist/sveltekit/content-routes.js +320 -72
  64. package/package.json +8 -5
  65. package/src/lib/components/AdminLayout.svelte +53 -0
  66. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  67. package/src/lib/components/ConceptList.svelte +22 -3
  68. package/src/lib/components/DeleteDialog.svelte +18 -7
  69. package/src/lib/components/EditPage.svelte +604 -75
  70. package/src/lib/components/EditorToolbar.svelte +206 -29
  71. package/src/lib/components/LinkPicker.svelte +14 -6
  72. package/src/lib/components/MarkdownEditor.svelte +80 -34
  73. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  74. package/src/lib/components/RenameDialog.svelte +13 -4
  75. package/src/lib/components/WebLinkDialog.svelte +89 -0
  76. package/src/lib/components/cairn-admin.css +26 -4
  77. package/src/lib/components/editor-highlight.ts +67 -0
  78. package/src/lib/components/link-completion.ts +10 -3
  79. package/src/lib/components/markdown-directives.ts +23 -0
  80. package/src/lib/components/markdown-format.ts +118 -13
  81. package/src/lib/content/pending.ts +24 -0
  82. package/src/lib/diagnostics/conditions.ts +75 -2
  83. package/src/lib/doctor/bin.ts +45 -0
  84. package/src/lib/doctor/check-send.ts +43 -0
  85. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  86. package/src/lib/doctor/checks-github.ts +63 -0
  87. package/src/lib/doctor/checks-local.ts +119 -0
  88. package/src/lib/doctor/cloudflare-api.ts +33 -0
  89. package/src/lib/doctor/index.ts +93 -0
  90. package/src/lib/doctor/report.ts +30 -0
  91. package/src/lib/doctor/run.ts +23 -0
  92. package/src/lib/doctor/types.ts +52 -0
  93. package/src/lib/doctor/wrangler-config.ts +142 -0
  94. package/src/lib/github/branches.ts +83 -0
  95. package/src/lib/github/signing.ts +13 -6
  96. package/src/lib/log/events.ts +4 -0
  97. package/src/lib/sveltekit/content-routes.ts +400 -73
@@ -10,8 +10,10 @@ import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameI
10
10
  import { rewriteCairnLink } from '../components/markdown-format.js';
11
11
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
12
12
  import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
13
+ import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
14
+ import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
13
15
  import { cachedInstallationToken } from '../github/signing.js';
14
- import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
16
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
15
17
  import { CommitConflictError } from '../github/types.js';
16
18
  import { log } from '../log/index.js';
17
19
  import { issueCsrfToken } from './csrf.js';
@@ -41,6 +43,9 @@ export interface LayoutData {
41
43
  collapsedNav: string[];
42
44
  /** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
43
45
  csrf: string;
46
+ /** Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
47
+ * Null when GitHub is unreachable, so the topbar hides the action rather than lying. */
48
+ pendingEntries: { concept: string; id: string }[] | null;
44
49
  }
45
50
 
46
51
  /** One row in a concept's list view. */
@@ -49,6 +54,8 @@ export interface EntrySummary {
49
54
  title: string;
50
55
  date: string | null;
51
56
  draft: boolean;
57
+ /** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
58
+ status: 'published' | 'edited' | 'new';
52
59
  }
53
60
 
54
61
  /** The concept list view's data. */
@@ -62,6 +69,8 @@ export interface ListData {
62
69
  error: string | null;
63
70
  /** A create-form bounce error read from `?error`. */
64
71
  formError: string | null;
72
+ /** The entry count from a publish-all redirect (`?publishedAll=`), for the list page's flash. */
73
+ publishedAll: number | null;
65
74
  }
66
75
 
67
76
  /** The editor's data. `frontmatter` holds form-ready values (dates already `YYYY-MM-DD`). */
@@ -84,6 +93,14 @@ export interface EditData {
84
93
  linkTargets: LinkTarget[];
85
94
  /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
86
95
  inboundLinks: InboundLink[];
96
+ /** True when the entry has a pending branch, so the body above came from that branch. */
97
+ pending: boolean;
98
+ /** True when the entry file exists on the default branch (the live site shows it). */
99
+ published: boolean;
100
+ /** True after a publish redirect (`?published=1`), for the confirmation strip. */
101
+ publishedFlash: boolean;
102
+ /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
103
+ discardedFlash: boolean;
87
104
  }
88
105
 
89
106
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
@@ -122,8 +139,28 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
122
139
  const mintToken =
123
140
  deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
124
141
 
125
- /** Layout load for every admin page: the nav, the user, the active path, and the resolved theme. */
126
- function layoutLoad(event: ContentEvent): LayoutData {
142
+ /** Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
143
+ * Always read from main: pending branches carry no manifest copy. */
144
+ async function readManifest(token: string): Promise<Manifest> {
145
+ const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
146
+ return raw === null ? emptyManifest() : parseManifest(raw);
147
+ }
148
+
149
+ /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
150
+ * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
151
+ * the path confinement), or a concept this site does not configure. Every ref consumer
152
+ * (the layout count, the list view, publish-all) applies this one predicate, so a stray
153
+ * hand-pushed ref cannot inflate a count it can never clear or reach a contents read. */
154
+ function pendingEntryOf(name: string): { concept: ConceptDescriptor; id: string } | null {
155
+ const ref = parsePendingBranch(name);
156
+ if (!ref || !isValidId(ref.id)) return null;
157
+ const concept = findConcept(runtime.concepts, ref.concept);
158
+ return concept ? { concept, id: ref.id } : null;
159
+ }
160
+
161
+ /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
162
+ * and the pending entries behind the topbar's publish-all action. */
163
+ async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
127
164
  const editor = sessionOf(event);
128
165
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
129
166
  const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
@@ -131,6 +168,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
131
168
  const collapsedNav = cookieCollapsed
132
169
  ? cookieCollapsed.split(',').map((part) => decodeURIComponent(part)).filter(Boolean)
133
170
  : [];
171
+ // Any failure here (the token mint, the network, a non-ok response) degrades to null rather
172
+ // than failing the whole admin shell or showing a wrong publish-all count.
173
+ let pendingEntries: { concept: string; id: string }[] | null = null;
174
+ try {
175
+ const token = await mintToken(event.platform?.env ?? {});
176
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
177
+ pendingEntries = names.flatMap((name) => {
178
+ const entry = pendingEntryOf(name);
179
+ return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
180
+ });
181
+ } catch (err) {
182
+ pendingEntries = null;
183
+ log.warn('github.unreachable', { scope: 'layout', error: String(err) });
184
+ }
134
185
  return {
135
186
  siteName: runtime.siteName,
136
187
  user: { displayName: editor.displayName, email: editor.email, role: editor.role },
@@ -141,6 +192,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
141
192
  theme,
142
193
  collapsedNav,
143
194
  csrf: event.cookies ? issueCsrfToken({ url: event.url, cookies: event.cookies }) : '',
195
+ pendingEntries,
144
196
  };
145
197
  }
146
198
 
@@ -151,26 +203,36 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
151
203
  throw redirect(307, `/admin/${first.id}`);
152
204
  }
153
205
 
154
- /** Read a file's frontmatter for its list row, degrading to the id on any read failure. */
155
- async function summarize(file: { id: string; path: string }, token: string): Promise<EntrySummary> {
206
+ /** Read a file's frontmatter for its list row, degrading to the id on any read failure. The
207
+ * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch. */
208
+ async function summarize(
209
+ file: { id: string; path: string },
210
+ token: string,
211
+ status: EntrySummary['status'],
212
+ repo = runtime.backend,
213
+ ): Promise<EntrySummary> {
156
214
  try {
157
- const raw = await readRaw(runtime.backend, file.path, token);
158
- if (raw === null) return { id: file.id, title: file.id, date: null, draft: false };
215
+ const raw = await readRaw(repo, file.path, token);
216
+ if (raw === null) return { id: file.id, title: file.id, date: null, draft: false, status };
159
217
  const { frontmatter } = parseMarkdown(raw);
160
218
  const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
161
219
  const date = dateInputValue(frontmatter.date) || null;
162
- return { id: file.id, title, date, draft: frontmatter.draft === true };
220
+ return { id: file.id, title, date, draft: frontmatter.draft === true, status };
163
221
  } catch {
164
- return { id: file.id, title: file.id, date: null, draft: false };
222
+ return { id: file.id, title: file.id, date: null, draft: false, status };
165
223
  }
166
224
  }
167
225
 
168
- /** List a concept's entries. A listing failure degrades to an inline error, not a thrown 500. */
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. */
169
229
  async function listLoad(event: ContentEvent): Promise<ListData> {
170
230
  sessionOf(event);
171
231
  const concept = conceptOf(runtime, event.params);
172
232
  const formError = event.url.searchParams.get('error');
173
- const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError };
233
+ const publishedAllRaw = event.url.searchParams.get('publishedAll');
234
+ const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
235
+ const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError, publishedAll };
174
236
  let token: string;
175
237
  try {
176
238
  token = await mintToken(event.platform?.env ?? {});
@@ -178,9 +240,39 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
178
240
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
179
241
  }
180
242
  try {
181
- const files = await listMarkdown(runtime.backend, concept.dir, token);
182
- const entries = await Promise.all(files.map((f) => summarize(f, token)));
183
- return { ...base, entries, error: null };
243
+ const [files, refs] = await Promise.all([
244
+ listMarkdown(runtime.backend, concept.dir, token),
245
+ listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
246
+ ]);
247
+ const pendingIds = new Set(
248
+ refs.flatMap((name) => {
249
+ const entry = pendingEntryOf(name);
250
+ return entry && entry.concept.id === concept.id ? [entry.id] : [];
251
+ }),
252
+ );
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.
255
+ 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'),
260
+ ),
261
+ );
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));
265
+ 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
+ ),
274
+ );
275
+ return { ...base, entries: [...entries, ...newRows], error: null };
184
276
  } catch {
185
277
  return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
186
278
  }
@@ -210,6 +302,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
210
302
  const token = await mintToken(event.platform?.env ?? {});
211
303
  const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
212
304
  if (existing !== null) return bounce('An entry with that slug already exists.');
305
+ // A pending branch is an entry too (saved but not yet published); refuse to clobber it.
306
+ if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
307
+ return bounce('An unpublished entry with that slug already exists.');
308
+ }
213
309
 
214
310
  throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
215
311
  }
@@ -236,12 +332,23 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
236
332
  const isNew = event.url.searchParams.get('new') === '1';
237
333
  const token = await mintToken(event.platform?.env ?? {});
238
334
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
239
- // The entry file and the manifest are independent reads sharing the token; fetch them together.
240
- const [raw, manifestRaw] = await Promise.all([
241
- readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token),
335
+ const path = `${concept.dir}/${filenameFromId(id)}`;
336
+ // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
337
+ // (link targets and the inbound-link guard) always reads main, the authoritative copy.
338
+ // Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
339
+ // so the probe does not serialize ahead of the other two; stage 2 adds the branch read
340
+ // only when the probe found a branch, with the stage-1 main read serving as the published
341
+ // signal either way.
342
+ const branch = pendingBranch(concept.id, id);
343
+ const [headSha, mainRaw, manifestRaw] = await Promise.all([
344
+ branchHeadSha(runtime.backend, branch, token),
345
+ readRaw(runtime.backend, path, token),
242
346
  readRaw(runtime.backend, runtime.manifestPath, token),
243
347
  ]);
348
+ const pending = headSha !== null;
349
+ const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
244
350
  if (raw === null && !isNew) throw error(404, 'Entry not found');
351
+ const published = mainRaw !== null;
245
352
 
246
353
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
247
354
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
@@ -276,6 +383,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
276
383
  slug: slugFromId(id, datePrefix),
277
384
  linkTargets,
278
385
  inboundLinks: inbound,
386
+ pending,
387
+ published,
388
+ publishedFlash: event.url.searchParams.get('published') === '1',
389
+ discardedFlash: event.url.searchParams.get('discarded') === '1',
279
390
  };
280
391
  }
281
392
 
@@ -285,26 +396,65 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
285
396
  }
286
397
 
287
398
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
288
- * reason; any other error is unexpected and logs at error with the stringified cause. The caller
289
- * still owns the redirect or rethrow, so control flow stays at the call site. */
290
- function logCommitFailed(fields: { concept: string; id: string; editor: string }, err: unknown): void {
399
+ * reason; any other error is unexpected and logs at error with the stringified cause. Publish
400
+ * failures carry the same shape under their own event name. */
401
+ function logCommitFailed(
402
+ fields: { concept: string; id: string; editor: string },
403
+ err: unknown,
404
+ event: 'commit.failed' | 'publish.failed' = 'commit.failed',
405
+ ): void {
291
406
  if (isConflict(err)) {
292
- log.warn('commit.failed', { ...fields, reason: 'conflict' });
407
+ log.warn(event, { ...fields, reason: 'conflict' });
293
408
  } else {
294
- log.error('commit.failed', { ...fields, error: String(err) });
409
+ log.error(event, { ...fields, error: String(err) });
295
410
  }
296
411
  }
297
412
 
298
- /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
299
- async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
300
- const editor = sessionOf(event);
301
- const concept = conceptOf(runtime, event.params);
302
- const id = event.params.id ?? '';
303
- // Confine the commit path to the concept dir, built from a validated id (the App token can
304
- // write anywhere in the repo). Reject before touching GitHub.
305
- if (!isValidId(id)) throw error(400, 'Invalid entry id');
306
- const path = `${concept.dir}/${filenameFromId(id)}`;
413
+ /** The shared commit catch for the entry actions: log the failure, bounce a conflict back to
414
+ * `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
415
+ * params the bounce must carry (saveAction's `&new=1`). */
416
+ function commitFailure(
417
+ fields: { concept: string; id: string; editor: string },
418
+ err: unknown,
419
+ page: string,
420
+ message: string,
421
+ opts: { event?: 'commit.failed' | 'publish.failed'; query?: string } = {},
422
+ ): never {
423
+ logCommitFailed(fields, err, opts.event);
424
+ if (isConflict(err)) {
425
+ throw redirect(303, `${page}?error=${encodeURIComponent(message)}${opts.query ?? ''}`);
426
+ }
427
+ throw err;
428
+ }
429
+
430
+ /** The held outcome of a validated save: everything publish needs to copy the same markdown
431
+ * to main without re-reading the branch. `branchSha` is the branch commit saveToBranch just
432
+ * made, the guard for the post-publish branch delete; `manifest` is main's manifest with
433
+ * this entry's row upserted from the new markdown (the same last-writer-wins manifest race
434
+ * as delete and rename applies, caught by the build's fail-closed backstop). */
435
+ interface SaveHold {
436
+ path: string;
437
+ markdown: string;
438
+ branch: string;
439
+ branchSha: string;
440
+ manifest: Manifest;
441
+ /** The draft-target tokens the body links to, for save's warning query. */
442
+ draftLinks: string[];
443
+ token: string;
444
+ }
307
445
 
446
+ /** The shared core of save and publish: parse the posted form, validate the frontmatter,
447
+ * guard the body's cairn links, ensure the pending branch, and commit the entry file there
448
+ * with the session editor as author. Returns the broken-link fail for the page to render,
449
+ * or the held state; throws the redirect bounces save has always thrown (invalid
450
+ * frontmatter, a branch-commit conflict). Main stays untouched. */
451
+ async function saveToBranch(
452
+ event: ContentEvent,
453
+ editor: Editor,
454
+ concept: ConceptDescriptor,
455
+ id: string,
456
+ ): Promise<ReturnType<typeof fail> | SaveHold> {
457
+ const path = `${concept.dir}/${filenameFromId(id)}`;
308
458
  const form = await event.request.formData();
309
459
  const body = String(form.get('body') ?? '');
310
460
  const isNew = form.get('new') === '1';
@@ -319,59 +469,221 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
319
469
  const markdown = serializeMarkdown(result.data, body);
320
470
  const token = await mintToken(event.platform?.env ?? {});
321
471
 
322
- // Read the committed manifest, upsert this entry's row, and commit content and manifest in one
323
- // commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
324
- // and verifies the manifest, so this incremental patch is the cheap request-time path. On a
325
- // 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
326
- // leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
327
- // it with npm run cairn:manifest to recover.
328
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
329
- const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
472
+ // Upsert this entry's row into main's manifest in memory, for the link guard here and for
473
+ // the publish commit. The save commits no manifest change; publish lands the upsert on main.
474
+ const manifest = await readManifest(token);
330
475
  const row = manifestEntryFromFile(concept, { path, raw: markdown });
331
476
  const upserted = upsertEntry(manifest, row);
332
- const nextManifest = serializeManifest(upserted);
333
477
 
334
- // Save guard: resolve the body's cairn links against the manifest with this entry upserted, so a
335
- // self-link and a link to any existing target resolves. A link to an absent target hard-blocks
336
- // the save (it would red the deploy build and the author would not see it); a link to a draft
337
- // target commits with a warning, since it is valid and resolves once the target is published.
478
+ // Save guard: resolve the body's cairn links against main's manifest with this entry upserted,
479
+ // so a self-link and a link to any published target resolves. A link to a target absent from
480
+ // main hard-blocks the save (publishing this entry before its target would red the deploy
481
+ // build); a link to a draft target commits with a warning, since it is valid and resolves once
482
+ // the target is published.
338
483
  const byKey = new Map(upserted.entries.map((e) => [`${e.concept}/${e.id}`, e]));
339
484
  const absent: string[] = [];
340
- const draft: string[] = [];
485
+ const draftLinks: string[] = [];
341
486
  for (const ref of extractCairnLinks(body)) {
342
487
  // A self-link is valid by construction (the upserted manifest holds this very entry), so
343
488
  // skip it before classifying. Mirrors inboundLinks's self-exclusion.
344
489
  if (ref.concept === concept.id && ref.id === id) continue;
345
490
  const target = byKey.get(`${ref.concept}/${ref.id}`);
346
491
  if (!target) absent.push(formatCairnToken(ref));
347
- else if (target.draft) draft.push(formatCairnToken(ref));
492
+ else if (target.draft) draftLinks.push(formatCairnToken(ref));
348
493
  }
349
494
  if (absent.length) {
350
495
  return fail(400, { brokenLinks: absent, body });
351
496
  }
352
497
 
498
+ // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
499
+ // commit only the entry file there. Main stays untouched until publish, so the branch differs
500
+ // from main at exactly this entry's path.
501
+ const branch = pendingBranch(concept.id, id);
502
+ if ((await branchHeadSha(runtime.backend, branch, token)) === null) {
503
+ const mainHead = await branchHeadSha(runtime.backend, runtime.backend.branch, token);
504
+ if (mainHead === null) throw error(500, 'Cannot read the default branch');
505
+ await createBranch(runtime.backend, branch, mainHead, token);
506
+ }
507
+
508
+ const commitFields = { concept: concept.id, id, editor: editor.email, branch };
509
+ let branchSha: string;
510
+ try {
511
+ branchSha = await commitFiles(
512
+ { ...runtime.backend, branch },
513
+ [{ path, content: markdown }],
514
+ { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
515
+ token,
516
+ );
517
+ log.info('commit.succeeded', commitFields);
518
+ } catch (err) {
519
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
520
+ 'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
521
+ }
522
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token };
523
+ }
524
+
525
+ /** Save an edit: validate, then commit to the entry's pending branch with the session editor
526
+ * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
527
+ async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
528
+ const editor = sessionOf(event);
529
+ const concept = conceptOf(runtime, event.params);
530
+ const id = event.params.id ?? '';
531
+ // Confine the commit path to the concept dir, built from a validated id (the App token can
532
+ // write anywhere in the repo). Reject before touching GitHub.
533
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
534
+ const held = await saveToBranch(event, editor, concept, id);
535
+ if (!('branchSha' in held)) return held;
536
+ const savedQuery = held.draftLinks.length
537
+ ? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
538
+ : 'saved=1';
539
+ throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
540
+ }
541
+
542
+ /** Publish an entry: validate and hold the posted form exactly like save (the branch gets the
543
+ * same commit), then copy that markdown to main with the manifest row upserted in one atomic
544
+ * commit. Publish-what-you-see: the posted form is the published content, so text typed
545
+ * after the last save goes live too, and publish works regardless of prior branch state.
546
+ * The branch is deleted only when its head still matches the commit this action made; a
547
+ * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
548
+ async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
549
+ const editor = sessionOf(event);
550
+ const concept = conceptOf(runtime, event.params);
551
+ const id = event.params.id ?? '';
552
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
553
+ const held = await saveToBranch(event, editor, concept, id);
554
+ if (!('branchSha' in held)) return held;
555
+ const { path, markdown, branch, branchSha, manifest, token } = held;
556
+
353
557
  const commitFields = { concept: concept.id, id, editor: editor.email };
354
558
  try {
355
559
  await commitFiles(
356
560
  runtime.backend,
357
561
  [
358
562
  { path, content: markdown },
359
- { path: runtime.manifestPath, content: nextManifest },
563
+ { path: runtime.manifestPath, content: serializeManifest(manifest) },
360
564
  ],
361
- { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
565
+ { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
362
566
  token,
363
567
  );
364
- log.info('commit.succeeded', commitFields);
568
+ log.info('entry.published', { ...commitFields, batch: false });
365
569
  } catch (err) {
366
- logCommitFailed(commitFields, err);
570
+ // The branch already holds the just-committed edits, so a conflict here loses nothing.
571
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
572
+ 'Your edits are saved. Reload and publish again.', { event: 'publish.failed' });
573
+ }
574
+ // Only after the main commit lands, and only when the branch head is still the commit this
575
+ // action made: a head that moved is a concurrent save, and deleting it would destroy edits.
576
+ // No log event for the skip; the pending badge is the surface.
577
+ if ((await branchHeadSha(runtime.backend, branch, token)) === branchSha) {
578
+ await deleteBranch(runtime.backend, branch, token);
579
+ }
580
+ throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
581
+ }
582
+
583
+ /** Publish every pending entry site-wide: one atomic commit on main carrying each branch's
584
+ * entry file plus the manifest with every row upserted, then delete the consumed branches.
585
+ * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
586
+ * concept param is ignored and the redirect lands on the first configured concept. */
587
+ async function publishAllAction(event: ContentEvent): Promise<never> {
588
+ const editor = sessionOf(event);
589
+ const first = runtime.concepts[0];
590
+ if (!first) throw error(404, 'No content types configured');
591
+ const token = await mintToken(event.platform?.env ?? {});
592
+ const listPage = `/admin/${first.id}`;
593
+
594
+ // Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
595
+ // than failing the whole batch on it.
596
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
597
+ const pending = names.flatMap((name) => {
598
+ const entry = pendingEntryOf(name);
599
+ return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
600
+ });
601
+
602
+ // Read every branch in parallel, capturing each head sha BEFORE its file read: the sha
603
+ // guards the post-publish delete, and probing first fails safe (a save landing between the
604
+ // probe and the read moves the head past the capture, so the delete is skipped and the
605
+ // entry stays pending). A ghost ref whose entry file is missing is skipped (discard can
606
+ // clean it up); it carries nothing to publish.
607
+ const reads = await Promise.all(
608
+ pending.map(async (entry) => {
609
+ const sha = await branchHeadSha(runtime.backend, entry.branch, token);
610
+ const raw = await readRaw({ ...runtime.backend, branch: entry.branch }, entry.path, token);
611
+ return { ...entry, sha, raw };
612
+ }),
613
+ );
614
+
615
+ // Fold main's manifest once over every row, so the batch lands content and index together,
616
+ // the same shape as a single publish.
617
+ let next = await readManifest(token);
618
+ const changes: FileChange[] = [];
619
+ const published: { concept: string; id: string; branch: string; sha: string }[] = [];
620
+ for (const entry of reads) {
621
+ if (entry.raw === null || entry.sha === null) continue;
622
+ changes.push({ path: entry.path, content: entry.raw });
623
+ next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
624
+ published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
625
+ }
626
+ if (published.length === 0) {
627
+ const message = 'Nothing to publish. Every entry is already live.';
628
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
629
+ }
630
+ changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
631
+
632
+ const noun = published.length === 1 ? 'entry' : 'entries';
633
+ try {
634
+ await commitFiles(
635
+ runtime.backend,
636
+ changes,
637
+ { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } },
638
+ token,
639
+ );
640
+ for (const entry of published) {
641
+ log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
642
+ }
643
+ } catch (err) {
644
+ // One record per entry in the failed batch, so the log names what did not go live.
645
+ for (const entry of published) {
646
+ logCommitFailed({ concept: entry.concept, id: entry.id, editor: editor.email }, err, 'publish.failed');
647
+ }
367
648
  if (isConflict(err)) {
368
- const message = 'This file changed since you opened it. Reload and reapply your edits.';
369
- throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
649
+ const message = 'The site changed while publishing. Reload and try again.';
650
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
370
651
  }
371
652
  throw err;
372
653
  }
373
- const savedQuery = draft.length ? `saved=1&drafts=${encodeURIComponent(draft.join(','))}` : 'saved=1';
374
- throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
654
+ // Only after the main commit lands: a failure above keeps every branch and its edits. Each
655
+ // branch deletes only when its head still matches the captured sha; a moved head is a
656
+ // concurrent save, so the entry stays pending and the next publish picks it up (no log
657
+ // event for the skip; the pending badge is the surface). A failed delete leaves an
658
+ // idempotent straggler (re-publishing copies the same content), so one failure does not
659
+ // abort the remaining deletes.
660
+ for (const entry of published) {
661
+ try {
662
+ if ((await branchHeadSha(runtime.backend, entry.branch, token)) === entry.sha) {
663
+ await deleteBranch(runtime.backend, entry.branch, token);
664
+ }
665
+ } catch {
666
+ // The entry is live; the straggler just shows as still pending until the next publish.
667
+ }
668
+ }
669
+ throw redirect(303, `${listPage}?publishedAll=${published.length}`);
670
+ }
671
+
672
+ /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
673
+ * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
674
+ async function discardAction(event: ContentEvent): Promise<never> {
675
+ const editor = sessionOf(event);
676
+ const concept = conceptOf(runtime, event.params);
677
+ const id = event.params.id ?? '';
678
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
679
+ const token = await mintToken(event.platform?.env ?? {});
680
+
681
+ await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
682
+ log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
683
+
684
+ const onMain = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
685
+ if (onMain !== null) throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
686
+ throw redirect(303, `/admin/${concept.id}`);
375
687
  }
376
688
 
377
689
  /** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
@@ -390,13 +702,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
390
702
 
391
703
  // An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
392
704
  // check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
393
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
394
- const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
705
+ const manifest = await readManifest(token);
395
706
  const inbound = inboundLinks(manifest, concept.id, id);
396
707
  if (inbound.length) {
397
708
  return fail(409, { inboundLinks: inbound, id });
398
709
  }
399
710
 
711
+ // When the entry was never published (absent from main), the branch delete is the whole
712
+ // operation; main has nothing to commit, so the only honest log record is the discard of
713
+ // the pending edits.
714
+ const onMain = await readRaw(runtime.backend, path, token);
715
+ if (onMain === null) {
716
+ await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
717
+ log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
718
+ throw redirect(303, `/admin/${concept.id}`);
719
+ }
720
+
400
721
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
401
722
  const commitFields = { concept: concept.id, id, editor: editor.email };
402
723
  try {
@@ -411,12 +732,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
411
732
  );
412
733
  log.info('commit.succeeded', commitFields);
413
734
  } catch (err) {
414
- logCommitFailed(commitFields, err);
415
- if (isConflict(err)) {
416
- const message = 'This file changed since you opened it. Reload and try again.';
417
- throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
418
- }
419
- throw err;
735
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
736
+ 'This file changed since you opened it. Reload and try again.');
737
+ }
738
+ // Cascade to the pending branch only after the removal lands on main, so a commit conflict
739
+ // keeps the unpublished edits. A straggler ref left by a failure here is idempotent and
740
+ // recoverable (it lists as a never-published row a discard can clean up), matching
741
+ // publish's posture, so the entry's deletion still completes.
742
+ try {
743
+ await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
744
+ } catch {
745
+ // The entry is gone from main; the straggler shows as a pending row until discarded.
420
746
  }
421
747
  throw redirect(303, `/admin/${concept.id}`);
422
748
  }
@@ -449,6 +775,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
449
775
  const concept = conceptOf(runtime, event.params);
450
776
  const id = event.params.id ?? '';
451
777
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
778
+ const token = await mintToken(event.platform?.env ?? {});
779
+
780
+ // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
781
+ // them, so refuse until the editor publishes or discards.
782
+ 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.' });
784
+ }
452
785
 
453
786
  const form = await event.request.formData();
454
787
  const newSlug = String(form.get('slug') ?? '').trim();
@@ -465,7 +798,6 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
465
798
  const newId = renameId(id, newSlug, datePrefix);
466
799
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
467
800
  const newPath = `${concept.dir}/${filenameFromId(newId)}`;
468
- const token = await mintToken(event.platform?.env ?? {});
469
801
 
470
802
  // Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
471
803
  // single readRaw cannot tell apart: a static collision with an existing entry, and a
@@ -475,12 +807,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
475
807
  return fail(409, { renameError: 'An entry with that slug already exists.' });
476
808
  }
477
809
 
478
- const [entryRaw, manifestRaw] = await Promise.all([
810
+ const [entryRaw, manifest] = await Promise.all([
479
811
  readRaw(runtime.backend, oldPath, token),
480
- readRaw(runtime.backend, runtime.manifestPath, token),
812
+ readManifest(token),
481
813
  ]);
482
814
  if (entryRaw === null) throw error(404, 'Entry not found');
483
- const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
484
815
 
485
816
  const oldHref = formatCairnToken({ concept: concept.id, id });
486
817
  const newHref = formatCairnToken({ concept: concept.id, id: newId });
@@ -520,15 +851,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
520
851
  );
521
852
  log.info('commit.succeeded', commitFields);
522
853
  } catch (err) {
523
- logCommitFailed(commitFields, err);
524
- if (isConflict(err)) {
525
- const message = 'This file changed since you opened it. Reload and try again.';
526
- throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
527
- }
528
- throw err;
854
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
855
+ 'This file changed since you opened it. Reload and try again.');
529
856
  }
530
857
  throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
531
858
  }
532
859
 
533
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, listDeleteAction, renameAction, mintToken };
860
+ return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, mintToken };
534
861
  }