@glw907/cairn-cms 0.18.0 → 0.24.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 (106) 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 +127 -8
  5. package/dist/components/EditPage.svelte.d.ts +8 -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/concepts.d.ts.map +1 -1
  26. package/dist/content/concepts.js +7 -0
  27. package/dist/content/frontmatter.d.ts +8 -0
  28. package/dist/content/frontmatter.d.ts.map +1 -1
  29. package/dist/content/frontmatter.js +19 -0
  30. package/dist/content/ids.d.ts +7 -0
  31. package/dist/content/ids.d.ts.map +1 -1
  32. package/dist/content/ids.js +11 -0
  33. package/dist/content/links.d.ts +7 -0
  34. package/dist/content/links.d.ts.map +1 -1
  35. package/dist/content/links.js +11 -0
  36. package/dist/content/manifest.d.ts +15 -1
  37. package/dist/content/manifest.d.ts.map +1 -1
  38. package/dist/content/manifest.js +45 -3
  39. package/dist/content/types.d.ts +6 -0
  40. package/dist/content/types.d.ts.map +1 -1
  41. package/dist/content/validate.d.ts.map +1 -1
  42. package/dist/content/validate.js +8 -1
  43. package/dist/delivery/content-index.d.ts +7 -0
  44. package/dist/delivery/content-index.d.ts.map +1 -1
  45. package/dist/delivery/content-index.js +7 -0
  46. package/dist/delivery/head.d.ts +2 -0
  47. package/dist/delivery/head.d.ts.map +1 -0
  48. package/dist/delivery/head.js +4 -0
  49. package/dist/delivery/index.d.ts +0 -1
  50. package/dist/delivery/index.d.ts.map +1 -1
  51. package/dist/delivery/index.js +0 -1
  52. package/dist/delivery/manifest.d.ts.map +1 -1
  53. package/dist/delivery/manifest.js +7 -0
  54. package/dist/github/repo.d.ts.map +1 -1
  55. package/dist/github/repo.js +8 -1
  56. package/dist/index.d.ts +7 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -3
  59. package/dist/render/pipeline.d.ts +4 -0
  60. package/dist/render/pipeline.d.ts.map +1 -1
  61. package/dist/render/pipeline.js +3 -1
  62. package/dist/render/registry.d.ts +1 -1
  63. package/dist/render/registry.d.ts.map +1 -1
  64. package/dist/render/rehype-dispatch.d.ts +5 -0
  65. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  66. package/dist/render/rehype-dispatch.js +12 -1
  67. package/dist/render/remark-directives.d.ts.map +1 -1
  68. package/dist/render/remark-directives.js +15 -6
  69. package/dist/render/sanitize-schema.d.ts +4 -3
  70. package/dist/render/sanitize-schema.d.ts.map +1 -1
  71. package/dist/render/sanitize-schema.js +6 -5
  72. package/dist/sveltekit/content-routes.d.ts +11 -2
  73. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  74. package/dist/sveltekit/content-routes.js +157 -9
  75. package/dist/sveltekit/public-routes.d.ts +1 -0
  76. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  77. package/dist/sveltekit/public-routes.js +1 -1
  78. package/package.json +7 -1
  79. package/src/lib/components/DeleteDialog.svelte +81 -0
  80. package/src/lib/components/EditPage.svelte +127 -8
  81. package/src/lib/components/LinkPicker.svelte +109 -0
  82. package/src/lib/components/MarkdownEditor.svelte +33 -3
  83. package/src/lib/components/RenameDialog.svelte +72 -0
  84. package/src/lib/components/index.ts +3 -0
  85. package/src/lib/components/link-completion.ts +57 -0
  86. package/src/lib/components/markdown-format.ts +82 -0
  87. package/src/lib/content/concepts.ts +9 -0
  88. package/src/lib/content/frontmatter.ts +21 -0
  89. package/src/lib/content/ids.ts +12 -0
  90. package/src/lib/content/links.ts +13 -0
  91. package/src/lib/content/manifest.ts +55 -3
  92. package/src/lib/content/types.ts +6 -0
  93. package/src/lib/content/validate.ts +6 -1
  94. package/src/lib/delivery/content-index.ts +13 -0
  95. package/src/lib/delivery/head.ts +4 -0
  96. package/src/lib/delivery/index.ts +0 -1
  97. package/src/lib/delivery/manifest.ts +6 -0
  98. package/src/lib/github/repo.ts +8 -1
  99. package/src/lib/index.ts +10 -2
  100. package/src/lib/render/pipeline.ts +6 -1
  101. package/src/lib/render/registry.ts +1 -1
  102. package/src/lib/render/rehype-dispatch.ts +12 -1
  103. package/src/lib/render/remark-directives.ts +16 -5
  104. package/src/lib/render/sanitize-schema.ts +6 -5
  105. package/src/lib/sveltekit/content-routes.ts +178 -11
  106. package/src/lib/sveltekit/public-routes.ts +2 -1
@@ -30,6 +30,10 @@ export interface RendererOptions {
30
30
  * vector the floor closes, so it is only for a site whose content is fully developer-controlled.
31
31
  * It is a code-level adapter decision, never an editor-facing setting. */
32
32
  unsafeDisableSanitize?: boolean;
33
+ /** The `rel` value forced on every `target="_blank"` anchor, applied last so it also covers
34
+ * component-built anchors. Defaults to `'noopener noreferrer'`. Set a different string to change
35
+ * it, or `false` to disable the injection (a site that owns its own anchor hardening). */
36
+ anchorRel?: string | false;
33
37
  }
34
38
 
35
39
  /** Compose a site's render pipeline from its component registry: directive syntax to
@@ -43,13 +47,14 @@ export function createRenderer(registry: ComponentRegistry, options: RendererOpt
43
47
  const floor: PluggableList = options.unsafeDisableSanitize
44
48
  ? []
45
49
  : [[rehypeSanitize, buildSanitizeSchema(registry, options.sanitizeSchema)]];
50
+ const rel = options.anchorRel ?? 'noopener noreferrer';
46
51
  const rehypePlugins: PluggableList = [
47
52
  rehypeRaw,
48
53
  ...floor,
49
54
  [rehypeDispatch, registry, options.stagger],
50
55
  rehypeSlug,
51
- rehypeAnchorRel,
52
56
  ];
57
+ if (rel !== false) rehypePlugins.push([rehypeAnchorRel, rel]);
53
58
  const processor = unified()
54
59
  .use(remarkParse)
55
60
  .use(remarkGfm)
@@ -19,7 +19,7 @@ export interface AttributeField {
19
19
  /** Initial value; a string for text/select/icon, a boolean for boolean. */
20
20
  default?: string | boolean;
21
21
  /** Allowed values for `type: 'select'`. */
22
- options?: string[];
22
+ options?: readonly string[];
23
23
  /** Helper text shown under the field. */
24
24
  help?: string;
25
25
  }
@@ -7,7 +7,7 @@ export function isElement(node: ElementContent | undefined): node is Element {
7
7
  }
8
8
 
9
9
  // hast Properties values are PropertyValue (string | number | boolean | array | null).
10
- // Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
10
+ // Directive markers (dataPrimitive/dataRole/dataAttr<Key>) are always stamped as strings;
11
11
  // this reads them back with that guarantee instead of casting at each call site.
12
12
  export function strProp(node: Element, name: string): string | undefined {
13
13
  const value = node.properties?.[name];
@@ -28,6 +28,17 @@ export function cardShell(classes: string[], body: ElementContent[]): Element {
28
28
  return h('section', { className: classes }, [h('div', { className: ['card-body'] }, body)]);
29
29
  }
30
30
 
31
+ /** Card head row: `<div class="ec-head">[icon]<h2 class="card-title">{title}</h2></div>`.
32
+ * Pass the title's inline children and an optional pre-built icon element, the way `cardShell`
33
+ * takes already-built body content. This factors the icon-plus-heading head that a titled
34
+ * component build would otherwise rebuild by hand (the shape the removed `splitHead` produced). */
35
+ export function headRow(title: ElementContent[], icon?: Element): Element {
36
+ const children: ElementContent[] = [];
37
+ if (icon) children.push(icon);
38
+ children.push(h('h2', { className: ['card-title'] }, title));
39
+ return h('div', { className: ['ec-head'] }, children);
40
+ }
41
+
31
42
  /** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
32
43
  * text nodes so the bare list serializes without newlines. Returns that <ul>. */
33
44
  export function markFirstList(children: ElementContent[]): Element | undefined {
@@ -59,17 +59,22 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
59
59
  const def = registry.get(node.name);
60
60
  const attrs = node.attributes ?? {};
61
61
  const role = attrs.role || undefined;
62
- let icon = attrs.icon || undefined;
62
+ const iconField = def?.attributes?.find((field) => field.type === 'icon');
63
+ const iconKey = iconField?.key ?? 'icon';
64
+ let icon = attrs[iconKey] || undefined;
63
65
  if (!icon && role) icon = registry.defaultIcon(node.name, role);
64
66
 
65
67
  const properties: Record<string, string> = { dataPrimitive: node.name };
66
- if (icon) properties.dataIcon = icon;
67
68
  if (role) properties.dataRole = role;
68
69
  // Carry every declared attribute to hast so the dispatch partitioner can build the
69
- // component context. data-attr-<key> survives to the element; build() consumes it and
70
- // returns a fresh element, so the marker never reaches the published DOM.
70
+ // component context. The icon attribute uses the already-resolved `icon` (the author value
71
+ // coerced through the same empty-is-absent rule above, or the defaultIconByRole default), so
72
+ // a role default reaches the build through the one declared path and a blank `icon=` falls
73
+ // back to that default the same way a missing one does. data-attr-<key> survives to the
74
+ // element; build() consumes it and returns a fresh element, so the marker never reaches the
75
+ // published DOM.
71
76
  for (const field of def?.attributes ?? []) {
72
- const raw = attrs[field.key];
77
+ const raw = field === iconField ? icon : attrs[field.key];
73
78
  if (raw != null) properties[dataAttrProp(field.key)] = raw;
74
79
  }
75
80
 
@@ -91,6 +96,12 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
91
96
  markSlot(child, (child as { name: string }).name);
92
97
  }
93
98
  }
99
+
100
+ // A directive [label] that the component has no `title` slot to claim would otherwise fall
101
+ // through as body content and render as a stray paragraph. Drop it.
102
+ if (!slotNames.has('title')) {
103
+ node.children = node.children.filter((child) => !isDirectiveLabel(child)) as typeof node.children;
104
+ }
94
105
  });
95
106
 
96
107
  visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
@@ -5,7 +5,7 @@ import { dataAttrProp, type ComponentRegistry } from './registry.js';
5
5
 
6
6
  // The fixed directive markers the stamp writes and the dispatch reads. They are inert data
7
7
  // attributes, never a script vector, and must survive the floor so the dispatch still runs.
8
- const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataIcon', 'dataRole', 'dataRise'];
8
+ const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataRole', 'dataRise'];
9
9
 
10
10
  /**
11
11
  * Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
@@ -51,15 +51,16 @@ export function buildSanitizeSchema(
51
51
  }
52
52
 
53
53
  /**
54
- * Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
54
+ * Force a `rel` value on every target="_blank" anchor, to prevent reverse-tabnabbing.
55
55
  * hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
56
- * DOMPurify preview pass enforced, now on the delivered output as well.
56
+ * DOMPurify preview pass enforced, now on the delivered output as well. The value is the renderer's
57
+ * `anchorRel` option (default `noopener noreferrer`); a site can override it or disable it entirely.
57
58
  */
58
- export function rehypeAnchorRel() {
59
+ export function rehypeAnchorRel(rel: string) {
59
60
  return (tree: Root) => {
60
61
  visit(tree, 'element', (node: Element) => {
61
62
  if (node.tagName === 'a' && node.properties?.target === '_blank') {
62
- node.properties.rel = 'noopener noreferrer';
63
+ node.properties.rel = rel;
63
64
  }
64
65
  });
65
66
  };
@@ -2,14 +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, commitFiles } from '../github/repo.js';
12
+ import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
11
13
  import { cachedInstallationToken } from '../github/signing.js';
12
- import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, type LinkTarget } from '../content/manifest.js';
14
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
13
15
  import { CommitConflictError } from '../github/types.js';
14
16
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
15
17
  import type { Editor, Role } from '../auth/types.js';
@@ -63,9 +65,15 @@ export interface EditData {
63
65
  title: string;
64
66
  isNew: boolean;
65
67
  saved: boolean;
68
+ /** True after a successful rename redirect (`?renamed=1`), to confirm the new URL to the author. */
69
+ renamed: boolean;
66
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;
67
73
  /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
68
74
  linkTargets: LinkTarget[];
75
+ /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
76
+ inboundLinks: InboundLink[];
69
77
  }
70
78
 
71
79
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
@@ -205,16 +213,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
205
213
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
206
214
  const isNew = event.url.searchParams.get('new') === '1';
207
215
  const token = await mintToken(event.platform?.env ?? {});
208
- 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
+ ]);
209
222
  if (raw === null && !isNew) throw error(404, 'Entry not found');
210
223
 
211
224
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
212
225
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
213
226
 
214
227
  let linkTargets: LinkTarget[] = [];
215
- const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
228
+ let inbound: InboundLink[] = [];
216
229
  if (manifestRaw !== null) {
217
- linkTargets = parseManifest(manifestRaw).entries.map((e) => ({
230
+ const manifest = parseManifest(manifestRaw);
231
+ linkTargets = manifest.entries.map((e) => ({
218
232
  concept: e.concept,
219
233
  id: e.id,
220
234
  permalink: e.permalink,
@@ -222,6 +236,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
222
236
  date: e.date,
223
237
  draft: e.draft,
224
238
  }));
239
+ inbound = inboundLinks(manifest, concept.id, id);
225
240
  }
226
241
 
227
242
  return {
@@ -234,8 +249,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
234
249
  title,
235
250
  isNew,
236
251
  saved: event.url.searchParams.get('saved') === '1',
252
+ renamed: event.url.searchParams.get('renamed') === '1',
237
253
  error: event.url.searchParams.get('error'),
254
+ slug: slugFromId(id, datePrefix),
238
255
  linkTargets,
256
+ inboundLinks: inbound,
239
257
  };
240
258
  }
241
259
 
@@ -245,7 +263,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
245
263
  }
246
264
 
247
265
  /** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
248
- async function saveAction(event: ContentEvent): Promise<never> {
266
+ async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
249
267
  const editor = sessionOf(event);
250
268
  const concept = conceptOf(runtime, event.params);
251
269
  const id = event.params.id ?? '';
@@ -277,7 +295,27 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
277
295
  const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
278
296
  const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
279
297
  const row = manifestEntryFromFile(concept, { path, raw: markdown });
280
- const nextManifest = serializeManifest(upsertEntry(manifest, row));
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
+ }
281
319
 
282
320
  try {
283
321
  await commitFiles(
@@ -296,8 +334,137 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
296
334
  }
297
335
  throw err;
298
336
  }
299
- 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`);
300
467
  }
301
468
 
302
- return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, mintToken };
469
+ return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, renameAction, mintToken };
303
470
  }
@@ -45,6 +45,7 @@ export interface TagIndexData {
45
45
 
46
46
  /** One entry's data: the detail entry, its rendered html, and its canonical URL. */
47
47
  export interface EntryData {
48
+ concept: string;
48
49
  entry: ContentEntry;
49
50
  html: string;
50
51
  canonicalUrl: string;
@@ -87,7 +88,7 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
87
88
  ...(fields.author ? { author: fields.author } : {}),
88
89
  ...(entry.date ? { feeds } : {}),
89
90
  });
90
- return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
91
+ return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
91
92
  }
92
93
 
93
94
  /** The chronological archive for one concept: every non-draft summary, newest-first. */