@glw907/cairn-cms 0.17.0 → 0.21.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 (86) hide show
  1. package/dist/components/DeleteDialog.svelte +81 -0
  2. package/dist/components/DeleteDialog.svelte.d.ts +21 -0
  3. package/dist/components/DeleteDialog.svelte.d.ts.map +1 -0
  4. package/dist/components/EditPage.svelte +136 -10
  5. package/dist/components/EditPage.svelte.d.ts +10 -0
  6. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  7. package/dist/components/LinkPicker.svelte +109 -0
  8. package/dist/components/LinkPicker.svelte.d.ts +18 -0
  9. package/dist/components/LinkPicker.svelte.d.ts.map +1 -0
  10. package/dist/components/MarkdownEditor.svelte +33 -3
  11. package/dist/components/MarkdownEditor.svelte.d.ts +5 -0
  12. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
  13. package/dist/components/RenameDialog.svelte +72 -0
  14. package/dist/components/RenameDialog.svelte.d.ts +20 -0
  15. package/dist/components/RenameDialog.svelte.d.ts.map +1 -0
  16. package/dist/components/index.d.ts +3 -0
  17. package/dist/components/index.d.ts.map +1 -1
  18. package/dist/components/index.js +3 -0
  19. package/dist/components/link-completion.d.ts +16 -0
  20. package/dist/components/link-completion.d.ts.map +1 -0
  21. package/dist/components/link-completion.js +48 -0
  22. package/dist/components/markdown-format.d.ts +25 -5
  23. package/dist/components/markdown-format.d.ts.map +1 -1
  24. package/dist/components/markdown-format.js +85 -0
  25. package/dist/content/compose.d.ts.map +1 -1
  26. package/dist/content/compose.js +1 -0
  27. package/dist/content/ids.d.ts +7 -0
  28. package/dist/content/ids.d.ts.map +1 -1
  29. package/dist/content/ids.js +11 -0
  30. package/dist/content/links.d.ts +21 -0
  31. package/dist/content/links.d.ts.map +1 -0
  32. package/dist/content/links.js +52 -0
  33. package/dist/content/manifest.d.ts +69 -0
  34. package/dist/content/manifest.d.ts.map +1 -0
  35. package/dist/content/manifest.js +140 -0
  36. package/dist/content/types.d.ts +10 -1
  37. package/dist/content/types.d.ts.map +1 -1
  38. package/dist/delivery/index.d.ts +1 -0
  39. package/dist/delivery/index.d.ts.map +1 -1
  40. package/dist/delivery/index.js +1 -0
  41. package/dist/delivery/manifest.d.ts +13 -0
  42. package/dist/delivery/manifest.d.ts.map +1 -0
  43. package/dist/delivery/manifest.js +38 -0
  44. package/dist/github/repo.d.ts +21 -0
  45. package/dist/github/repo.d.ts.map +1 -1
  46. package/dist/github/repo.js +86 -0
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/render/pipeline.d.ts +4 -1
  51. package/dist/render/pipeline.d.ts.map +1 -1
  52. package/dist/render/pipeline.js +7 -2
  53. package/dist/render/resolve-links.d.ts +8 -0
  54. package/dist/render/resolve-links.d.ts.map +1 -0
  55. package/dist/render/resolve-links.js +36 -0
  56. package/dist/render/sanitize-schema.d.ts.map +1 -1
  57. package/dist/render/sanitize-schema.js +9 -0
  58. package/dist/sveltekit/content-routes.d.ts +13 -1
  59. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  60. package/dist/sveltekit/content-routes.js +182 -7
  61. package/dist/sveltekit/public-routes.d.ts +2 -0
  62. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  63. package/dist/sveltekit/public-routes.js +2 -1
  64. package/package.json +2 -1
  65. package/src/lib/components/DeleteDialog.svelte +81 -0
  66. package/src/lib/components/EditPage.svelte +136 -10
  67. package/src/lib/components/LinkPicker.svelte +109 -0
  68. package/src/lib/components/MarkdownEditor.svelte +33 -3
  69. package/src/lib/components/RenameDialog.svelte +72 -0
  70. package/src/lib/components/index.ts +3 -0
  71. package/src/lib/components/link-completion.ts +57 -0
  72. package/src/lib/components/markdown-format.ts +82 -0
  73. package/src/lib/content/compose.ts +1 -0
  74. package/src/lib/content/ids.ts +12 -0
  75. package/src/lib/content/links.ts +61 -0
  76. package/src/lib/content/manifest.ts +190 -0
  77. package/src/lib/content/types.ts +10 -3
  78. package/src/lib/delivery/index.ts +1 -0
  79. package/src/lib/delivery/manifest.ts +44 -0
  80. package/src/lib/github/repo.ts +110 -0
  81. package/src/lib/index.ts +17 -0
  82. package/src/lib/render/pipeline.ts +8 -2
  83. package/src/lib/render/resolve-links.ts +42 -0
  84. package/src/lib/render/sanitize-schema.ts +9 -0
  85. package/src/lib/sveltekit/content-routes.ts +209 -10
  86. package/src/lib/sveltekit/public-routes.ts +4 -2
@@ -2,13 +2,16 @@
2
2
  // A factory closes over the composed runtime and the GitHub token mint, so the read and
3
3
  // commit paths are unit-testable against a fetch double with an injected token, mirroring the
4
4
  // email `send` injection in auth-routes. A shim stays one line: `export const load = routes.editLoad`.
5
- import { redirect, error } from '@sveltejs/kit';
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
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
8
- import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
9
+ import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
10
+ import { rewriteCairnLink } from '../components/markdown-format.js';
9
11
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
10
- import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
12
+ import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
11
13
  import { cachedInstallationToken } from '../github/signing.js';
14
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
12
15
  import { CommitConflictError } from '../github/types.js';
13
16
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
14
17
  import type { Editor, Role } from '../auth/types.js';
@@ -62,7 +65,15 @@ export interface EditData {
62
65
  title: string;
63
66
  isNew: boolean;
64
67
  saved: boolean;
68
+ /** True after a successful rename redirect (`?renamed=1`), to confirm the new URL to the author. */
69
+ renamed: boolean;
65
70
  error: string | null;
71
+ /** The current URL slug (the date-stripped id for a dated concept), for the rename dialog prefill. */
72
+ slug: string;
73
+ /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
74
+ linkTargets: LinkTarget[];
75
+ /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
76
+ inboundLinks: InboundLink[];
66
77
  }
67
78
 
68
79
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
@@ -202,11 +213,32 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
202
213
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
203
214
  const isNew = event.url.searchParams.get('new') === '1';
204
215
  const token = await mintToken(event.platform?.env ?? {});
205
- const raw = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
216
+ const datePrefix = concept.routing.dated ? concept.datePrefix : null;
217
+ // The entry file and the manifest are independent reads sharing the token; fetch them together.
218
+ const [raw, manifestRaw] = await Promise.all([
219
+ readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token),
220
+ readRaw(runtime.backend, runtime.manifestPath, token),
221
+ ]);
206
222
  if (raw === null && !isNew) throw error(404, 'Entry not found');
207
223
 
208
224
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
209
225
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
226
+
227
+ let linkTargets: LinkTarget[] = [];
228
+ let inbound: InboundLink[] = [];
229
+ if (manifestRaw !== null) {
230
+ const manifest = parseManifest(manifestRaw);
231
+ linkTargets = manifest.entries.map((e) => ({
232
+ concept: e.concept,
233
+ id: e.id,
234
+ permalink: e.permalink,
235
+ title: e.title,
236
+ date: e.date,
237
+ draft: e.draft,
238
+ }));
239
+ inbound = inboundLinks(manifest, concept.id, id);
240
+ }
241
+
210
242
  return {
211
243
  conceptId: concept.id,
212
244
  id,
@@ -217,7 +249,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
217
249
  title,
218
250
  isNew,
219
251
  saved: event.url.searchParams.get('saved') === '1',
252
+ renamed: event.url.searchParams.get('renamed') === '1',
220
253
  error: event.url.searchParams.get('error'),
254
+ slug: slugFromId(id, datePrefix),
255
+ linkTargets,
256
+ inboundLinks: inbound,
221
257
  };
222
258
  }
223
259
 
@@ -227,7 +263,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
227
263
  }
228
264
 
229
265
  /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
230
- async function saveAction(event: ContentEvent): Promise<never> {
266
+ async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
231
267
  const editor = sessionOf(event);
232
268
  const concept = conceptOf(runtime, event.params);
233
269
  const id = event.params.id ?? '';
@@ -249,11 +285,45 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
249
285
 
250
286
  const markdown = serializeMarkdown(result.data, body);
251
287
  const token = await mintToken(event.platform?.env ?? {});
288
+
289
+ // Read the committed manifest, upsert this entry's row, and commit content and manifest in one
290
+ // commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
291
+ // and verifies the manifest, so this incremental patch is the cheap request-time path. On a
292
+ // 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
293
+ // leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
294
+ // it with npm run cairn:manifest to recover.
295
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
296
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
297
+ const row = manifestEntryFromFile(concept, { path, raw: markdown });
298
+ const upserted = upsertEntry(manifest, row);
299
+ const nextManifest = serializeManifest(upserted);
300
+
301
+ // Save guard: resolve the body's cairn links against the manifest with this entry upserted, so a
302
+ // self-link and a link to any existing target resolves. A link to an absent target hard-blocks
303
+ // the save (it would red the deploy build and the author would not see it); a link to a draft
304
+ // target commits with a warning, since it is valid and resolves once the target is published.
305
+ const byKey = new Map(upserted.entries.map((e) => [`${e.concept}/${e.id}`, e]));
306
+ const absent: string[] = [];
307
+ const draft: string[] = [];
308
+ for (const ref of extractCairnLinks(body)) {
309
+ // A self-link is valid by construction (the upserted manifest holds this very entry), so
310
+ // skip it before classifying. Mirrors inboundLinks's self-exclusion.
311
+ if (ref.concept === concept.id && ref.id === id) continue;
312
+ const target = byKey.get(`${ref.concept}/${ref.id}`);
313
+ if (!target) absent.push(formatCairnToken(ref));
314
+ else if (target.draft) draft.push(formatCairnToken(ref));
315
+ }
316
+ if (absent.length) {
317
+ return fail(400, { brokenLinks: absent, body });
318
+ }
319
+
252
320
  try {
253
- await commitFile(
321
+ await commitFiles(
254
322
  runtime.backend,
255
- path,
256
- markdown,
323
+ [
324
+ { path, content: markdown },
325
+ { path: runtime.manifestPath, content: nextManifest },
326
+ ],
257
327
  { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
258
328
  token,
259
329
  );
@@ -264,8 +334,137 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
264
334
  }
265
335
  throw err;
266
336
  }
267
- throw redirect(303, `/admin/${concept.id}/${id}?saved=1`);
337
+ const savedQuery = draft.length ? `saved=1&drafts=${encodeURIComponent(draft.join(','))}` : 'saved=1';
338
+ throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
339
+ }
340
+
341
+ /** Delete an entry. Block-until-clean: refuse while inbound links exist (naming them), else commit
342
+ * the file removal and the manifest patch in one commit. The inbound recheck here is the
343
+ * authoritative gate, closing the load-to-delete race. */
344
+ async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
345
+ const editor = sessionOf(event);
346
+ const concept = conceptOf(runtime, event.params);
347
+ const id = event.params.id ?? '';
348
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
349
+ const path = `${concept.dir}/${filenameFromId(id)}`;
350
+ const token = await mintToken(event.platform?.env ?? {});
351
+
352
+ // An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
353
+ // check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
354
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
355
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
356
+ const inbound = inboundLinks(manifest, concept.id, id);
357
+ if (inbound.length) {
358
+ return fail(409, { inboundLinks: inbound });
359
+ }
360
+
361
+ const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
362
+ try {
363
+ await commitFiles(
364
+ runtime.backend,
365
+ [
366
+ { path, content: null },
367
+ { path: runtime.manifestPath, content: nextManifest },
368
+ ],
369
+ { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
370
+ token,
371
+ );
372
+ } catch (err) {
373
+ if (isConflict(err)) {
374
+ const message = 'This file changed since you opened it. Reload and try again.';
375
+ throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
376
+ }
377
+ throw err;
378
+ }
379
+ throw redirect(303, `/admin/${concept.id}`);
380
+ }
381
+
382
+ /** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
383
+ * atomic commit, so no internal link breaks. The collision check and the inbound recompute here
384
+ * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
385
+ * caught by the build's fail-closed backstop. */
386
+ async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
387
+ const editor = sessionOf(event);
388
+ const concept = conceptOf(runtime, event.params);
389
+ const id = event.params.id ?? '';
390
+ if (!isValidId(id)) throw error(400, 'Invalid entry id');
391
+
392
+ const form = await event.request.formData();
393
+ const newSlug = String(form.get('slug') ?? '').trim();
394
+ if (!isValidId(newSlug)) {
395
+ return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
396
+ }
397
+ const datePrefix = concept.routing.dated ? concept.datePrefix : null;
398
+ if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
399
+ return fail(400, { renameError: 'Leave the date out of the slug.' });
400
+ }
401
+ if (newSlug === slugFromId(id, datePrefix)) {
402
+ return fail(400, { renameError: 'That is already the slug.' });
403
+ }
404
+ const newId = renameId(id, newSlug, datePrefix);
405
+ const oldPath = `${concept.dir}/${filenameFromId(id)}`;
406
+ const newPath = `${concept.dir}/${filenameFromId(newId)}`;
407
+ const token = await mintToken(event.platform?.env ?? {});
408
+
409
+ // Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
410
+ // single readRaw cannot tell apart: a static collision with an existing entry, and a
411
+ // concurrent-rename race where another editor renamed onto this path between load and submit.
412
+ const clobber = await readRaw(runtime.backend, newPath, token);
413
+ if (clobber !== null) {
414
+ return fail(409, { renameError: 'An entry with that slug already exists.' });
415
+ }
416
+
417
+ const [entryRaw, manifestRaw] = await Promise.all([
418
+ readRaw(runtime.backend, oldPath, token),
419
+ readRaw(runtime.backend, runtime.manifestPath, token),
420
+ ]);
421
+ if (entryRaw === null) throw error(404, 'Entry not found');
422
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
423
+
424
+ const oldHref = formatCairnToken({ concept: concept.id, id });
425
+ const newHref = formatCairnToken({ concept: concept.id, id: newId });
426
+
427
+ // The moved file keeps its content, except a self-token rewrite. Re-derive its manifest row from
428
+ // the new path so the row carries the new id and permalink by construction.
429
+ const movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
430
+ const changes: FileChange[] = [
431
+ { path: oldPath, content: null },
432
+ { path: newPath, content: movedRaw },
433
+ ];
434
+ let next = removeEntry(manifest, concept.id, id);
435
+ next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
436
+
437
+ // Rewrite every inbound linker's body and re-derive its row, so its outbound edge points at the
438
+ // new id. A linker missing from the repo is skipped; the build backstop catches any drift.
439
+ for (const linker of inboundLinks(manifest, concept.id, id)) {
440
+ const linkerConcept = findConcept(runtime.concepts, linker.concept);
441
+ if (!linkerConcept) continue;
442
+ const linkerPath = `${linkerConcept.dir}/${filenameFromId(linker.id)}`;
443
+ const linkerRaw = await readRaw(runtime.backend, linkerPath, token);
444
+ if (linkerRaw === null) continue;
445
+ const rewritten = rewriteCairnLink(linkerRaw, oldHref, newHref);
446
+ changes.push({ path: linkerPath, content: rewritten });
447
+ next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: rewritten }));
448
+ }
449
+
450
+ changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
451
+
452
+ try {
453
+ await commitFiles(
454
+ runtime.backend,
455
+ changes,
456
+ { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } },
457
+ token,
458
+ );
459
+ } catch (err) {
460
+ if (isConflict(err)) {
461
+ const message = 'This file changed since you opened it. Reload and try again.';
462
+ throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
463
+ }
464
+ throw err;
465
+ }
466
+ throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
268
467
  }
269
468
 
270
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, mintToken };
469
+ return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, renameAction, mintToken };
271
470
  }
@@ -9,11 +9,13 @@ import type { SiteIndex } from '../delivery/site-index.js';
9
9
  import { buildSeoMeta } from '../delivery/seo.js';
10
10
  import type { SeoMeta } from '../delivery/seo.js';
11
11
  import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
12
+ import { buildLinkResolver } from '../delivery/manifest.js';
13
+ import type { LinkResolve } from '../content/links.js';
12
14
 
13
15
  /** Injected dependencies for the public loaders. */
14
16
  export interface PublicRoutesDeps {
15
17
  site: SiteIndex;
16
- render: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
18
+ render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
17
19
  origin: string;
18
20
  /** Site name for og:site_name and the SEO head. */
19
21
  siteName: string;
@@ -85,7 +87,7 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
85
87
  ...(fields.author ? { author: fields.author } : {}),
86
88
  ...(entry.date ? { feeds } : {}),
87
89
  });
88
- return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
90
+ return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
89
91
  }
90
92
 
91
93
  /** The chronological archive for one concept: every non-draft summary, newest-first. */