@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,6 +10,8 @@ import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameI
10
10
  import { rewriteCairnLink } from '../components/markdown-format.js';
11
11
  import { appCredentials } from '../github/credentials.js';
12
12
  import { listMarkdown, readRaw, commitFiles } 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
16
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
15
17
  import { CommitConflictError } from '../github/types.js';
@@ -31,8 +33,27 @@ function conceptOf(runtime, params) {
31
33
  }
32
34
  export function createContentRoutes(runtime, deps = {}) {
33
35
  const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
34
- /** Layout load for every admin page: the nav, the user, the active path, and the resolved theme. */
35
- function layoutLoad(event) {
36
+ /** Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
37
+ * Always read from main: pending branches carry no manifest copy. */
38
+ async function readManifest(token) {
39
+ const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
40
+ return raw === null ? emptyManifest() : parseManifest(raw);
41
+ }
42
+ /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
43
+ * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
44
+ * the path confinement), or a concept this site does not configure. Every ref consumer
45
+ * (the layout count, the list view, publish-all) applies this one predicate, so a stray
46
+ * hand-pushed ref cannot inflate a count it can never clear or reach a contents read. */
47
+ function pendingEntryOf(name) {
48
+ const ref = parsePendingBranch(name);
49
+ if (!ref || !isValidId(ref.id))
50
+ return null;
51
+ const concept = findConcept(runtime.concepts, ref.concept);
52
+ return concept ? { concept, id: ref.id } : null;
53
+ }
54
+ /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
55
+ * and the pending entries behind the topbar's publish-all action. */
56
+ async function layoutLoad(event) {
36
57
  const editor = sessionOf(event);
37
58
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
38
59
  const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
@@ -40,6 +61,21 @@ export function createContentRoutes(runtime, deps = {}) {
40
61
  const collapsedNav = cookieCollapsed
41
62
  ? cookieCollapsed.split(',').map((part) => decodeURIComponent(part)).filter(Boolean)
42
63
  : [];
64
+ // Any failure here (the token mint, the network, a non-ok response) degrades to null rather
65
+ // than failing the whole admin shell or showing a wrong publish-all count.
66
+ let pendingEntries = null;
67
+ try {
68
+ const token = await mintToken(event.platform?.env ?? {});
69
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
70
+ pendingEntries = names.flatMap((name) => {
71
+ const entry = pendingEntryOf(name);
72
+ return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
73
+ });
74
+ }
75
+ catch (err) {
76
+ pendingEntries = null;
77
+ log.warn('github.unreachable', { scope: 'layout', error: String(err) });
78
+ }
43
79
  return {
44
80
  siteName: runtime.siteName,
45
81
  user: { displayName: editor.displayName, email: editor.email, role: editor.role },
@@ -50,6 +86,7 @@ export function createContentRoutes(runtime, deps = {}) {
50
86
  theme,
51
87
  collapsedNav,
52
88
  csrf: event.cookies ? issueCsrfToken({ url: event.url, cookies: event.cookies }) : '',
89
+ pendingEntries,
53
90
  };
54
91
  }
55
92
  /** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
@@ -59,27 +96,32 @@ export function createContentRoutes(runtime, deps = {}) {
59
96
  throw error(404, 'No content types configured');
60
97
  throw redirect(307, `/admin/${first.id}`);
61
98
  }
62
- /** Read a file's frontmatter for its list row, degrading to the id on any read failure. */
63
- async function summarize(file, token) {
99
+ /** Read a file's frontmatter for its list row, degrading to the id on any read failure. The
100
+ * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch. */
101
+ async function summarize(file, token, status, repo = runtime.backend) {
64
102
  try {
65
- const raw = await readRaw(runtime.backend, file.path, token);
103
+ const raw = await readRaw(repo, file.path, token);
66
104
  if (raw === null)
67
- return { id: file.id, title: file.id, date: null, draft: false };
105
+ return { id: file.id, title: file.id, date: null, draft: false, status };
68
106
  const { frontmatter } = parseMarkdown(raw);
69
107
  const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
70
108
  const date = dateInputValue(frontmatter.date) || null;
71
- return { id: file.id, title, date, draft: frontmatter.draft === true };
109
+ return { id: file.id, title, date, draft: frontmatter.draft === true, status };
72
110
  }
73
111
  catch {
74
- return { id: file.id, title: file.id, date: null, draft: false };
112
+ return { id: file.id, title: file.id, date: null, draft: false, status };
75
113
  }
76
114
  }
77
- /** List a concept's entries. A listing failure degrades to an inline error, not a thrown 500. */
115
+ /** List a concept's entries with their publish status. Main's files carry `edited` when a
116
+ * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
117
+ * its branch. A listing failure degrades to an inline error, not a thrown 500. */
78
118
  async function listLoad(event) {
79
119
  sessionOf(event);
80
120
  const concept = conceptOf(runtime, event.params);
81
121
  const formError = event.url.searchParams.get('error');
82
- const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError };
122
+ const publishedAllRaw = event.url.searchParams.get('publishedAll');
123
+ const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
124
+ const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError, publishedAll };
83
125
  let token;
84
126
  try {
85
127
  token = await mintToken(event.platform?.env ?? {});
@@ -88,9 +130,29 @@ export function createContentRoutes(runtime, deps = {}) {
88
130
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
89
131
  }
90
132
  try {
91
- const files = await listMarkdown(runtime.backend, concept.dir, token);
92
- const entries = await Promise.all(files.map((f) => summarize(f, token)));
93
- return { ...base, entries, error: null };
133
+ const [files, refs] = await Promise.all([
134
+ listMarkdown(runtime.backend, concept.dir, token),
135
+ listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
136
+ ]);
137
+ const pendingIds = new Set(refs.flatMap((name) => {
138
+ const entry = pendingEntryOf(name);
139
+ return entry && entry.concept.id === concept.id ? [entry.id] : [];
140
+ }));
141
+ // An edited row reads branch-first like a new row, so a pending title or draft change
142
+ // shows in the list instead of reading as a lost save.
143
+ const entries = await Promise.all(files.map((f) => pendingIds.has(f.id)
144
+ ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
145
+ : summarize(f, token, 'published')));
146
+ // A ref with no main file is a never-published entry; its row reads from its branch, and
147
+ // summarize already degrades a failed read to an id-only row.
148
+ const listed = new Set(files.map((f) => f.id));
149
+ const newRows = await Promise.all([...pendingIds]
150
+ .filter((id) => !listed.has(id))
151
+ .map((id) => summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
152
+ ...runtime.backend,
153
+ branch: pendingBranch(concept.id, id),
154
+ })));
155
+ return { ...base, entries: [...entries, ...newRows], error: null };
94
156
  }
95
157
  catch {
96
158
  return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
@@ -121,6 +183,10 @@ export function createContentRoutes(runtime, deps = {}) {
121
183
  const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
122
184
  if (existing !== null)
123
185
  return bounce('An entry with that slug already exists.');
186
+ // A pending branch is an entry too (saved but not yet published); refuse to clobber it.
187
+ if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
188
+ return bounce('An unpublished entry with that slug already exists.');
189
+ }
124
190
  throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
125
191
  }
126
192
  /** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
@@ -149,13 +215,24 @@ export function createContentRoutes(runtime, deps = {}) {
149
215
  const isNew = event.url.searchParams.get('new') === '1';
150
216
  const token = await mintToken(event.platform?.env ?? {});
151
217
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
152
- // The entry file and the manifest are independent reads sharing the token; fetch them together.
153
- const [raw, manifestRaw] = await Promise.all([
154
- readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token),
218
+ const path = `${concept.dir}/${filenameFromId(id)}`;
219
+ // A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
220
+ // (link targets and the inbound-link guard) always reads main, the authoritative copy.
221
+ // Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
222
+ // so the probe does not serialize ahead of the other two; stage 2 adds the branch read
223
+ // only when the probe found a branch, with the stage-1 main read serving as the published
224
+ // signal either way.
225
+ const branch = pendingBranch(concept.id, id);
226
+ const [headSha, mainRaw, manifestRaw] = await Promise.all([
227
+ branchHeadSha(runtime.backend, branch, token),
228
+ readRaw(runtime.backend, path, token),
155
229
  readRaw(runtime.backend, runtime.manifestPath, token),
156
230
  ]);
231
+ const pending = headSha !== null;
232
+ const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
157
233
  if (raw === null && !isNew)
158
234
  throw error(404, 'Entry not found');
235
+ const published = mainRaw !== null;
159
236
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
160
237
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
161
238
  let linkTargets = [];
@@ -187,6 +264,10 @@ export function createContentRoutes(runtime, deps = {}) {
187
264
  slug: slugFromId(id, datePrefix),
188
265
  linkTargets,
189
266
  inboundLinks: inbound,
267
+ pending,
268
+ published,
269
+ publishedFlash: event.url.searchParams.get('published') === '1',
270
+ discardedFlash: event.url.searchParams.get('discarded') === '1',
190
271
  };
191
272
  }
192
273
  /** Match a commit conflict by class and by name (bundling can alias the class identity). */
@@ -194,25 +275,32 @@ export function createContentRoutes(runtime, deps = {}) {
194
275
  return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
195
276
  }
196
277
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
197
- * reason; any other error is unexpected and logs at error with the stringified cause. The caller
198
- * still owns the redirect or rethrow, so control flow stays at the call site. */
199
- function logCommitFailed(fields, err) {
278
+ * reason; any other error is unexpected and logs at error with the stringified cause. Publish
279
+ * failures carry the same shape under their own event name. */
280
+ function logCommitFailed(fields, err, event = 'commit.failed') {
200
281
  if (isConflict(err)) {
201
- log.warn('commit.failed', { ...fields, reason: 'conflict' });
282
+ log.warn(event, { ...fields, reason: 'conflict' });
202
283
  }
203
284
  else {
204
- log.error('commit.failed', { ...fields, error: String(err) });
285
+ log.error(event, { ...fields, error: String(err) });
205
286
  }
206
287
  }
207
- /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
208
- async function saveAction(event) {
209
- const editor = sessionOf(event);
210
- const concept = conceptOf(runtime, event.params);
211
- const id = event.params.id ?? '';
212
- // Confine the commit path to the concept dir, built from a validated id (the App token can
213
- // write anywhere in the repo). Reject before touching GitHub.
214
- if (!isValidId(id))
215
- throw error(400, 'Invalid entry id');
288
+ /** The shared commit catch for the entry actions: log the failure, bounce a conflict back to
289
+ * `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
290
+ * params the bounce must carry (saveAction's `&new=1`). */
291
+ function commitFailure(fields, err, page, message, opts = {}) {
292
+ logCommitFailed(fields, err, opts.event);
293
+ if (isConflict(err)) {
294
+ throw redirect(303, `${page}?error=${encodeURIComponent(message)}${opts.query ?? ''}`);
295
+ }
296
+ throw err;
297
+ }
298
+ /** The shared core of save and publish: parse the posted form, validate the frontmatter,
299
+ * guard the body's cairn links, ensure the pending branch, and commit the entry file there
300
+ * with the session editor as author. Returns the broken-link fail for the page to render,
301
+ * or the held state; throws the redirect bounces save has always thrown (invalid
302
+ * frontmatter, a branch-commit conflict). Main stays untouched. */
303
+ async function saveToBranch(event, editor, concept, id) {
216
304
  const path = `${concept.dir}/${filenameFromId(id)}`;
217
305
  const form = await event.request.formData();
218
306
  const body = String(form.get('body') ?? '');
@@ -225,24 +313,19 @@ export function createContentRoutes(runtime, deps = {}) {
225
313
  }
226
314
  const markdown = serializeMarkdown(result.data, body);
227
315
  const token = await mintToken(event.platform?.env ?? {});
228
- // Read the committed manifest, upsert this entry's row, and commit content and manifest in one
229
- // commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
230
- // and verifies the manifest, so this incremental patch is the cheap request-time path. On a
231
- // 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
232
- // leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
233
- // it with npm run cairn:manifest to recover.
234
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
235
- const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
316
+ // Upsert this entry's row into main's manifest in memory, for the link guard here and for
317
+ // the publish commit. The save commits no manifest change; publish lands the upsert on main.
318
+ const manifest = await readManifest(token);
236
319
  const row = manifestEntryFromFile(concept, { path, raw: markdown });
237
320
  const upserted = upsertEntry(manifest, row);
238
- const nextManifest = serializeManifest(upserted);
239
- // Save guard: resolve the body's cairn links against the manifest with this entry upserted, so a
240
- // self-link and a link to any existing target resolves. A link to an absent target hard-blocks
241
- // the save (it would red the deploy build and the author would not see it); a link to a draft
242
- // target commits with a warning, since it is valid and resolves once the target is published.
321
+ // Save guard: resolve the body's cairn links against main's manifest with this entry upserted,
322
+ // so a self-link and a link to any published target resolves. A link to a target absent from
323
+ // main hard-blocks the save (publishing this entry before its target would red the deploy
324
+ // build); a link to a draft target commits with a warning, since it is valid and resolves once
325
+ // the target is published.
243
326
  const byKey = new Map(upserted.entries.map((e) => [`${e.concept}/${e.id}`, e]));
244
327
  const absent = [];
245
- const draft = [];
328
+ const draftLinks = [];
246
329
  for (const ref of extractCairnLinks(body)) {
247
330
  // A self-link is valid by construction (the upserted manifest holds this very entry), so
248
331
  // skip it before classifying. Mirrors inboundLinks's self-exclusion.
@@ -252,29 +335,182 @@ export function createContentRoutes(runtime, deps = {}) {
252
335
  if (!target)
253
336
  absent.push(formatCairnToken(ref));
254
337
  else if (target.draft)
255
- draft.push(formatCairnToken(ref));
338
+ draftLinks.push(formatCairnToken(ref));
256
339
  }
257
340
  if (absent.length) {
258
341
  return fail(400, { brokenLinks: absent, body });
259
342
  }
343
+ // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
344
+ // commit only the entry file there. Main stays untouched until publish, so the branch differs
345
+ // from main at exactly this entry's path.
346
+ const branch = pendingBranch(concept.id, id);
347
+ if ((await branchHeadSha(runtime.backend, branch, token)) === null) {
348
+ const mainHead = await branchHeadSha(runtime.backend, runtime.backend.branch, token);
349
+ if (mainHead === null)
350
+ throw error(500, 'Cannot read the default branch');
351
+ await createBranch(runtime.backend, branch, mainHead, token);
352
+ }
353
+ const commitFields = { concept: concept.id, id, editor: editor.email, branch };
354
+ let branchSha;
355
+ try {
356
+ branchSha = await commitFiles({ ...runtime.backend, branch }, [{ path, content: markdown }], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
357
+ log.info('commit.succeeded', commitFields);
358
+ }
359
+ catch (err) {
360
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
361
+ }
362
+ return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token };
363
+ }
364
+ /** Save an edit: validate, then commit to the entry's pending branch with the session editor
365
+ * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
366
+ async function saveAction(event) {
367
+ const editor = sessionOf(event);
368
+ const concept = conceptOf(runtime, event.params);
369
+ const id = event.params.id ?? '';
370
+ // Confine the commit path to the concept dir, built from a validated id (the App token can
371
+ // write anywhere in the repo). Reject before touching GitHub.
372
+ if (!isValidId(id))
373
+ throw error(400, 'Invalid entry id');
374
+ const held = await saveToBranch(event, editor, concept, id);
375
+ if (!('branchSha' in held))
376
+ return held;
377
+ const savedQuery = held.draftLinks.length
378
+ ? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
379
+ : 'saved=1';
380
+ throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
381
+ }
382
+ /** Publish an entry: validate and hold the posted form exactly like save (the branch gets the
383
+ * same commit), then copy that markdown to main with the manifest row upserted in one atomic
384
+ * commit. Publish-what-you-see: the posted form is the published content, so text typed
385
+ * after the last save goes live too, and publish works regardless of prior branch state.
386
+ * The branch is deleted only when its head still matches the commit this action made; a
387
+ * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
388
+ async function publishAction(event) {
389
+ const editor = sessionOf(event);
390
+ const concept = conceptOf(runtime, event.params);
391
+ const id = event.params.id ?? '';
392
+ if (!isValidId(id))
393
+ throw error(400, 'Invalid entry id');
394
+ const held = await saveToBranch(event, editor, concept, id);
395
+ if (!('branchSha' in held))
396
+ return held;
397
+ const { path, markdown, branch, branchSha, manifest, token } = held;
260
398
  const commitFields = { concept: concept.id, id, editor: editor.email };
261
399
  try {
262
400
  await commitFiles(runtime.backend, [
263
401
  { path, content: markdown },
264
- { path: runtime.manifestPath, content: nextManifest },
265
- ], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
266
- log.info('commit.succeeded', commitFields);
402
+ { path: runtime.manifestPath, content: serializeManifest(manifest) },
403
+ ], { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
404
+ log.info('entry.published', { ...commitFields, batch: false });
267
405
  }
268
406
  catch (err) {
269
- logCommitFailed(commitFields, err);
407
+ // The branch already holds the just-committed edits, so a conflict here loses nothing.
408
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'Your edits are saved. Reload and publish again.', { event: 'publish.failed' });
409
+ }
410
+ // Only after the main commit lands, and only when the branch head is still the commit this
411
+ // action made: a head that moved is a concurrent save, and deleting it would destroy edits.
412
+ // No log event for the skip; the pending badge is the surface.
413
+ if ((await branchHeadSha(runtime.backend, branch, token)) === branchSha) {
414
+ await deleteBranch(runtime.backend, branch, token);
415
+ }
416
+ throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
417
+ }
418
+ /** Publish every pending entry site-wide: one atomic commit on main carrying each branch's
419
+ * entry file plus the manifest with every row upserted, then delete the consumed branches.
420
+ * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
421
+ * concept param is ignored and the redirect lands on the first configured concept. */
422
+ async function publishAllAction(event) {
423
+ const editor = sessionOf(event);
424
+ const first = runtime.concepts[0];
425
+ if (!first)
426
+ throw error(404, 'No content types configured');
427
+ const token = await mintToken(event.platform?.env ?? {});
428
+ const listPage = `/admin/${first.id}`;
429
+ // Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
430
+ // than failing the whole batch on it.
431
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
432
+ const pending = names.flatMap((name) => {
433
+ const entry = pendingEntryOf(name);
434
+ return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
435
+ });
436
+ // Read every branch in parallel, capturing each head sha BEFORE its file read: the sha
437
+ // guards the post-publish delete, and probing first fails safe (a save landing between the
438
+ // probe and the read moves the head past the capture, so the delete is skipped and the
439
+ // entry stays pending). A ghost ref whose entry file is missing is skipped (discard can
440
+ // clean it up); it carries nothing to publish.
441
+ const reads = await Promise.all(pending.map(async (entry) => {
442
+ const sha = await branchHeadSha(runtime.backend, entry.branch, token);
443
+ const raw = await readRaw({ ...runtime.backend, branch: entry.branch }, entry.path, token);
444
+ return { ...entry, sha, raw };
445
+ }));
446
+ // Fold main's manifest once over every row, so the batch lands content and index together,
447
+ // the same shape as a single publish.
448
+ let next = await readManifest(token);
449
+ const changes = [];
450
+ const published = [];
451
+ for (const entry of reads) {
452
+ if (entry.raw === null || entry.sha === null)
453
+ continue;
454
+ changes.push({ path: entry.path, content: entry.raw });
455
+ next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
456
+ published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
457
+ }
458
+ if (published.length === 0) {
459
+ const message = 'Nothing to publish. Every entry is already live.';
460
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
461
+ }
462
+ changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
463
+ const noun = published.length === 1 ? 'entry' : 'entries';
464
+ try {
465
+ await commitFiles(runtime.backend, changes, { message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } }, token);
466
+ for (const entry of published) {
467
+ log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
468
+ }
469
+ }
470
+ catch (err) {
471
+ // One record per entry in the failed batch, so the log names what did not go live.
472
+ for (const entry of published) {
473
+ logCommitFailed({ concept: entry.concept, id: entry.id, editor: editor.email }, err, 'publish.failed');
474
+ }
270
475
  if (isConflict(err)) {
271
- const message = 'This file changed since you opened it. Reload and reapply your edits.';
272
- throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
476
+ const message = 'The site changed while publishing. Reload and try again.';
477
+ throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
273
478
  }
274
479
  throw err;
275
480
  }
276
- const savedQuery = draft.length ? `saved=1&drafts=${encodeURIComponent(draft.join(','))}` : 'saved=1';
277
- throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
481
+ // Only after the main commit lands: a failure above keeps every branch and its edits. Each
482
+ // branch deletes only when its head still matches the captured sha; a moved head is a
483
+ // concurrent save, so the entry stays pending and the next publish picks it up (no log
484
+ // event for the skip; the pending badge is the surface). A failed delete leaves an
485
+ // idempotent straggler (re-publishing copies the same content), so one failure does not
486
+ // abort the remaining deletes.
487
+ for (const entry of published) {
488
+ try {
489
+ if ((await branchHeadSha(runtime.backend, entry.branch, token)) === entry.sha) {
490
+ await deleteBranch(runtime.backend, entry.branch, token);
491
+ }
492
+ }
493
+ catch {
494
+ // The entry is live; the straggler just shows as still pending until the next publish.
495
+ }
496
+ }
497
+ throw redirect(303, `${listPage}?publishedAll=${published.length}`);
498
+ }
499
+ /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
500
+ * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
501
+ async function discardAction(event) {
502
+ const editor = sessionOf(event);
503
+ const concept = conceptOf(runtime, event.params);
504
+ const id = event.params.id ?? '';
505
+ if (!isValidId(id))
506
+ throw error(400, 'Invalid entry id');
507
+ const token = await mintToken(event.platform?.env ?? {});
508
+ await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
509
+ log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
510
+ const onMain = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
511
+ if (onMain !== null)
512
+ throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
513
+ throw redirect(303, `/admin/${concept.id}`);
278
514
  }
279
515
  /** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
280
516
  * commit the file removal and the manifest patch in one commit. The inbound recheck here is the
@@ -286,12 +522,20 @@ export function createContentRoutes(runtime, deps = {}) {
286
522
  const token = await mintToken(event.platform?.env ?? {});
287
523
  // An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
288
524
  // check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
289
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
290
- const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
525
+ const manifest = await readManifest(token);
291
526
  const inbound = inboundLinks(manifest, concept.id, id);
292
527
  if (inbound.length) {
293
528
  return fail(409, { inboundLinks: inbound, id });
294
529
  }
530
+ // When the entry was never published (absent from main), the branch delete is the whole
531
+ // operation; main has nothing to commit, so the only honest log record is the discard of
532
+ // the pending edits.
533
+ const onMain = await readRaw(runtime.backend, path, token);
534
+ if (onMain === null) {
535
+ await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
536
+ log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
537
+ throw redirect(303, `/admin/${concept.id}`);
538
+ }
295
539
  const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
296
540
  const commitFields = { concept: concept.id, id, editor: editor.email };
297
541
  try {
@@ -302,12 +546,17 @@ export function createContentRoutes(runtime, deps = {}) {
302
546
  log.info('commit.succeeded', commitFields);
303
547
  }
304
548
  catch (err) {
305
- logCommitFailed(commitFields, err);
306
- if (isConflict(err)) {
307
- const message = 'This file changed since you opened it. Reload and try again.';
308
- throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
309
- }
310
- throw err;
549
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'This file changed since you opened it. Reload and try again.');
550
+ }
551
+ // Cascade to the pending branch only after the removal lands on main, so a commit conflict
552
+ // keeps the unpublished edits. A straggler ref left by a failure here is idempotent and
553
+ // recoverable (it lists as a never-published row a discard can clean up), matching
554
+ // publish's posture, so the entry's deletion still completes.
555
+ try {
556
+ await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
557
+ }
558
+ catch {
559
+ // The entry is gone from main; the straggler shows as a pending row until discarded.
311
560
  }
312
561
  throw redirect(303, `/admin/${concept.id}`);
313
562
  }
@@ -340,6 +589,12 @@ export function createContentRoutes(runtime, deps = {}) {
340
589
  const id = event.params.id ?? '';
341
590
  if (!isValidId(id))
342
591
  throw error(400, 'Invalid entry id');
592
+ const token = await mintToken(event.platform?.env ?? {});
593
+ // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
594
+ // them, so refuse until the editor publishes or discards.
595
+ if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
596
+ return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
597
+ }
343
598
  const form = await event.request.formData();
344
599
  const newSlug = String(form.get('slug') ?? '').trim();
345
600
  if (!isValidId(newSlug)) {
@@ -355,7 +610,6 @@ export function createContentRoutes(runtime, deps = {}) {
355
610
  const newId = renameId(id, newSlug, datePrefix);
356
611
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
357
612
  const newPath = `${concept.dir}/${filenameFromId(newId)}`;
358
- const token = await mintToken(event.platform?.env ?? {});
359
613
  // Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
360
614
  // single readRaw cannot tell apart: a static collision with an existing entry, and a
361
615
  // concurrent-rename race where another editor renamed onto this path between load and submit.
@@ -363,13 +617,12 @@ export function createContentRoutes(runtime, deps = {}) {
363
617
  if (clobber !== null) {
364
618
  return fail(409, { renameError: 'An entry with that slug already exists.' });
365
619
  }
366
- const [entryRaw, manifestRaw] = await Promise.all([
620
+ const [entryRaw, manifest] = await Promise.all([
367
621
  readRaw(runtime.backend, oldPath, token),
368
- readRaw(runtime.backend, runtime.manifestPath, token),
622
+ readManifest(token),
369
623
  ]);
370
624
  if (entryRaw === null)
371
625
  throw error(404, 'Entry not found');
372
- const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
373
626
  const oldHref = formatCairnToken({ concept: concept.id, id });
374
627
  const newHref = formatCairnToken({ concept: concept.id, id: newId });
375
628
  // The moved file keeps its content, except a self-token rewrite. Re-derive its manifest row from
@@ -402,14 +655,9 @@ export function createContentRoutes(runtime, deps = {}) {
402
655
  log.info('commit.succeeded', commitFields);
403
656
  }
404
657
  catch (err) {
405
- logCommitFailed(commitFields, err);
406
- if (isConflict(err)) {
407
- const message = 'This file changed since you opened it. Reload and try again.';
408
- throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
409
- }
410
- throw err;
658
+ commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'This file changed since you opened it. Reload and try again.');
411
659
  }
412
660
  throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
413
661
  }
414
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, listDeleteAction, renameAction, mintToken };
662
+ return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, mintToken };
415
663
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.38.0",
3
+ "version": "0.41.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",
@@ -82,7 +83,8 @@
82
83
  "./package.json": "./package.json"
83
84
  },
84
85
  "bin": {
85
- "cairn-manifest": "./dist/vite/bin.js"
86
+ "cairn-manifest": "./dist/vite/bin.js",
87
+ "cairn-doctor": "./dist/doctor/bin.js"
86
88
  },
87
89
  "files": [
88
90
  "dist",
@@ -90,7 +92,7 @@
90
92
  "CHANGELOG.md"
91
93
  ],
92
94
  "peerDependencies": {
93
- "@sveltejs/kit": "^2",
95
+ "@sveltejs/kit": "^2.12",
94
96
  "svelte": "^5.0.0"
95
97
  },
96
98
  "dependencies": {
@@ -100,6 +102,7 @@
100
102
  "@codemirror/language": "^6.12.3",
101
103
  "@codemirror/state": "^6.6.0",
102
104
  "@codemirror/view": "^6.43.0",
105
+ "@lezer/highlight": "^1.2.3",
103
106
  "@lucide/svelte": "^1.17.0",
104
107
  "@rodrigodagostino/svelte-sortable-list": "^2.1.17",
105
108
  "@types/hast": "^3.0.4",
@@ -139,7 +142,7 @@
139
142
  "postcss": "^8.5.15",
140
143
  "postcss-prefix-selector": "^2.1.1",
141
144
  "publint": "^0.3.21",
142
- "svelte": "^5.55",
145
+ "svelte": "^5.56.3",
143
146
  "svelte-check": "^4",
144
147
  "tailwindcss": "^4.3.0",
145
148
  "typescript": "^6.0.3",