@glw907/cairn-cms 0.60.1 → 0.62.1

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 (254) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/components/AdminLayout.svelte +22 -0
  3. package/dist/components/CairnAdmin.svelte +3 -0
  4. package/dist/components/CairnTidySettings.svelte +2 -2
  5. package/dist/components/CairnTidySettings.svelte.d.ts +1 -1
  6. package/dist/components/EditPage.svelte +116 -39
  7. package/dist/components/HelpHome.svelte +824 -0
  8. package/dist/components/HelpHome.svelte.d.ts +22 -0
  9. package/dist/components/MarkdownHelpDialog.svelte +4 -15
  10. package/dist/components/client-ingest.d.ts +16 -8
  11. package/dist/components/client-ingest.js +12 -6
  12. package/dist/components/editor-media.js +16 -8
  13. package/dist/components/editor-placeholder.d.ts +4 -2
  14. package/dist/components/editor-tidy.d.ts +24 -12
  15. package/dist/components/editor-tidy.js +8 -4
  16. package/dist/components/index.d.ts +1 -0
  17. package/dist/components/index.js +1 -0
  18. package/dist/components/link-completion.d.ts +12 -6
  19. package/dist/components/link-completion.js +12 -6
  20. package/dist/components/markdown-directives.d.ts +9 -6
  21. package/dist/components/markdown-directives.js +9 -6
  22. package/dist/components/markdown-format.d.ts +7 -2
  23. package/dist/components/markdown-format.js +59 -28
  24. package/dist/components/markdown-reference.d.ts +8 -0
  25. package/dist/components/markdown-reference.js +22 -0
  26. package/dist/components/media-upload-outcome.d.ts +12 -6
  27. package/dist/components/objective-errors.d.ts +8 -4
  28. package/dist/components/objective-errors.js +8 -4
  29. package/dist/components/preview-doc.d.ts +4 -2
  30. package/dist/components/preview-doc.js +4 -2
  31. package/dist/components/spellcheck.d.ts +55 -29
  32. package/dist/components/spellcheck.js +39 -21
  33. package/dist/components/tidy-categorize.d.ts +20 -10
  34. package/dist/components/tidy-categorize.js +16 -8
  35. package/dist/components/tidy-validate.d.ts +12 -6
  36. package/dist/components/tidy-validate.js +20 -10
  37. package/dist/components/topbar-context.d.ts +4 -2
  38. package/dist/content/advisories.d.ts +51 -0
  39. package/dist/content/advisories.js +79 -0
  40. package/dist/content/compose.d.ts +4 -2
  41. package/dist/content/compose.js +1 -0
  42. package/dist/content/excerpt.js +4 -2
  43. package/dist/content/getting-started.d.ts +18 -0
  44. package/dist/content/getting-started.js +12 -0
  45. package/dist/content/links.d.ts +16 -8
  46. package/dist/content/links.js +12 -6
  47. package/dist/content/manifest.d.ts +36 -18
  48. package/dist/content/manifest.js +32 -16
  49. package/dist/content/media-refs.d.ts +4 -2
  50. package/dist/content/media-refs.js +4 -2
  51. package/dist/content/media-rewrite.d.ts +8 -4
  52. package/dist/content/media-rewrite.js +76 -38
  53. package/dist/content/schema.d.ts +20 -10
  54. package/dist/content/site-dictionary.d.ts +4 -2
  55. package/dist/content/site-dictionary.js +8 -4
  56. package/dist/content/types.d.ts +97 -42
  57. package/dist/delivery/content-index.d.ts +16 -8
  58. package/dist/delivery/feeds.js +4 -2
  59. package/dist/delivery/json-ld.d.ts +3 -0
  60. package/dist/delivery/json-ld.js +3 -0
  61. package/dist/delivery/manifest.d.ts +4 -2
  62. package/dist/delivery/manifest.js +4 -2
  63. package/dist/delivery/public-routes.d.ts +12 -6
  64. package/dist/delivery/public-routes.js +4 -2
  65. package/dist/delivery/seo-fields.d.ts +12 -6
  66. package/dist/delivery/seo-fields.js +8 -4
  67. package/dist/delivery/site-indexes.d.ts +4 -2
  68. package/dist/delivery/site-resolver.d.ts +4 -2
  69. package/dist/delivery/site-resolver.js +4 -2
  70. package/dist/doctor/cloudflare-api.d.ts +6 -0
  71. package/dist/doctor/cloudflare-api.js +6 -0
  72. package/dist/doctor/index.d.ts +12 -6
  73. package/dist/doctor/report.d.ts +3 -0
  74. package/dist/doctor/report.js +3 -0
  75. package/dist/doctor/run.d.ts +3 -0
  76. package/dist/doctor/run.js +3 -0
  77. package/dist/doctor/types.d.ts +10 -2
  78. package/dist/doctor/types.js +6 -0
  79. package/dist/doctor/wrangler-config.d.ts +7 -2
  80. package/dist/doctor/wrangler-config.js +3 -0
  81. package/dist/email.d.ts +4 -2
  82. package/dist/env.d.ts +0 -3
  83. package/dist/env.js +0 -3
  84. package/dist/github/branches.d.ts +4 -2
  85. package/dist/github/branches.js +4 -2
  86. package/dist/github/signing.d.ts +1 -1
  87. package/dist/github/signing.js +2 -2
  88. package/dist/log/events.d.ts +1 -1
  89. package/dist/media/bulk-delete-plan.d.ts +8 -4
  90. package/dist/media/config.d.ts +12 -6
  91. package/dist/media/config.js +16 -8
  92. package/dist/media/delivery-bucket.d.ts +4 -2
  93. package/dist/media/library-entry.d.ts +4 -2
  94. package/dist/media/library-entry.js +4 -2
  95. package/dist/media/manifest.d.ts +29 -15
  96. package/dist/media/manifest.js +29 -16
  97. package/dist/media/naming.d.ts +12 -6
  98. package/dist/media/naming.js +24 -12
  99. package/dist/media/orphan-scan.d.ts +4 -2
  100. package/dist/media/reconcile.d.ts +21 -11
  101. package/dist/media/reconcile.js +12 -6
  102. package/dist/media/reference.d.ts +8 -4
  103. package/dist/media/reference.js +12 -6
  104. package/dist/media/rewrite-plan.d.ts +12 -6
  105. package/dist/media/sniff.d.ts +4 -2
  106. package/dist/media/sniff.js +28 -14
  107. package/dist/media/store.d.ts +16 -8
  108. package/dist/media/store.js +4 -2
  109. package/dist/media/transform-url.d.ts +12 -6
  110. package/dist/media/transform-url.js +8 -4
  111. package/dist/media/usage.d.ts +8 -4
  112. package/dist/nav/site-config.d.ts +16 -8
  113. package/dist/render/component-grammar.d.ts +23 -10
  114. package/dist/render/component-grammar.js +19 -8
  115. package/dist/render/component-insert.d.ts +8 -4
  116. package/dist/render/component-insert.js +4 -2
  117. package/dist/render/component-reference.d.ts +4 -2
  118. package/dist/render/component-reference.js +4 -2
  119. package/dist/render/component-validate.d.ts +3 -0
  120. package/dist/render/component-validate.js +3 -0
  121. package/dist/render/glyph.d.ts +4 -2
  122. package/dist/render/glyph.js +4 -2
  123. package/dist/render/pipeline.d.ts +20 -10
  124. package/dist/render/pipeline.js +4 -2
  125. package/dist/render/registry.d.ts +40 -20
  126. package/dist/render/registry.js +16 -8
  127. package/dist/render/rehype-dispatch.d.ts +22 -8
  128. package/dist/render/rehype-dispatch.js +22 -8
  129. package/dist/render/remark-directives.d.ts +3 -0
  130. package/dist/render/remark-directives.js +3 -0
  131. package/dist/render/remark-figure.d.ts +4 -2
  132. package/dist/render/remark-figure.js +4 -2
  133. package/dist/render/resolve-links.d.ts +4 -2
  134. package/dist/render/resolve-links.js +4 -2
  135. package/dist/render/resolve-media.d.ts +16 -8
  136. package/dist/render/resolve-media.js +12 -6
  137. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  138. package/dist/sveltekit/admin-dispatch.js +9 -3
  139. package/dist/sveltekit/auth-routes.d.ts +3 -0
  140. package/dist/sveltekit/auth-routes.js +3 -0
  141. package/dist/sveltekit/cairn-admin.d.ts +16 -5
  142. package/dist/sveltekit/cairn-admin.js +26 -10
  143. package/dist/sveltekit/content-routes.d.ts +191 -86
  144. package/dist/sveltekit/content-routes.js +295 -107
  145. package/dist/sveltekit/editors-routes.d.ts +3 -0
  146. package/dist/sveltekit/editors-routes.js +3 -0
  147. package/dist/sveltekit/guard.d.ts +4 -2
  148. package/dist/sveltekit/guard.js +4 -2
  149. package/dist/sveltekit/https-required-page.d.ts +1 -1
  150. package/dist/sveltekit/https-required-page.js +1 -1
  151. package/dist/sveltekit/index.d.ts +1 -1
  152. package/dist/sveltekit/media-route.d.ts +1 -2
  153. package/dist/sveltekit/media-route.js +13 -8
  154. package/dist/sveltekit/nav-routes.d.ts +7 -2
  155. package/dist/sveltekit/nav-routes.js +3 -0
  156. package/dist/sveltekit/types.d.ts +4 -2
  157. package/dist/vite/index.d.ts +32 -16
  158. package/dist/vite/index.js +52 -26
  159. package/dist/vite/resolve-root.d.ts +8 -4
  160. package/dist/vite/resolve-root.js +4 -2
  161. package/package.json +7 -1
  162. package/src/lib/components/AdminLayout.svelte +22 -0
  163. package/src/lib/components/CairnAdmin.svelte +3 -0
  164. package/src/lib/components/CairnTidySettings.svelte +2 -2
  165. package/src/lib/components/ComponentForm.svelte +0 -1
  166. package/src/lib/components/EditPage.svelte +133 -41
  167. package/src/lib/components/HelpHome.svelte +850 -0
  168. package/src/lib/components/MarkdownHelpDialog.svelte +4 -15
  169. package/src/lib/components/client-ingest.ts +20 -10
  170. package/src/lib/components/editor-media.ts +20 -10
  171. package/src/lib/components/editor-placeholder.ts +12 -6
  172. package/src/lib/components/editor-tidy.ts +28 -14
  173. package/src/lib/components/index.ts +1 -0
  174. package/src/lib/components/link-completion.ts +12 -6
  175. package/src/lib/components/markdown-directives.ts +13 -8
  176. package/src/lib/components/markdown-format.ts +63 -30
  177. package/src/lib/components/markdown-reference.ts +30 -0
  178. package/src/lib/components/media-upload-outcome.ts +12 -6
  179. package/src/lib/components/objective-errors.ts +16 -8
  180. package/src/lib/components/preview-doc.ts +4 -2
  181. package/src/lib/components/spellcheck.ts +79 -41
  182. package/src/lib/components/tidy-categorize.ts +28 -14
  183. package/src/lib/components/tidy-validate.ts +28 -14
  184. package/src/lib/components/topbar-context.ts +4 -2
  185. package/src/lib/content/advisories.ts +141 -0
  186. package/src/lib/content/compose.ts +5 -2
  187. package/src/lib/content/excerpt.ts +4 -2
  188. package/src/lib/content/getting-started.ts +31 -0
  189. package/src/lib/content/links.ts +16 -8
  190. package/src/lib/content/manifest.ts +36 -18
  191. package/src/lib/content/media-refs.ts +4 -2
  192. package/src/lib/content/media-rewrite.ts +100 -50
  193. package/src/lib/content/schema.ts +20 -10
  194. package/src/lib/content/site-dictionary.ts +8 -4
  195. package/src/lib/content/types.ts +97 -42
  196. package/src/lib/delivery/content-index.ts +16 -8
  197. package/src/lib/delivery/feeds.ts +4 -2
  198. package/src/lib/delivery/json-ld.ts +3 -0
  199. package/src/lib/delivery/manifest.ts +4 -2
  200. package/src/lib/delivery/public-routes.ts +16 -8
  201. package/src/lib/delivery/seo-fields.ts +12 -6
  202. package/src/lib/delivery/site-indexes.ts +4 -2
  203. package/src/lib/delivery/site-resolver.ts +4 -2
  204. package/src/lib/doctor/cloudflare-api.ts +6 -0
  205. package/src/lib/doctor/index.ts +12 -6
  206. package/src/lib/doctor/report.ts +3 -0
  207. package/src/lib/doctor/run.ts +3 -0
  208. package/src/lib/doctor/types.ts +10 -2
  209. package/src/lib/doctor/wrangler-config.ts +7 -2
  210. package/src/lib/email.ts +4 -2
  211. package/src/lib/env.ts +0 -3
  212. package/src/lib/github/branches.ts +4 -2
  213. package/src/lib/github/signing.ts +2 -2
  214. package/src/lib/log/events.ts +1 -0
  215. package/src/lib/media/bulk-delete-plan.ts +8 -4
  216. package/src/lib/media/config.ts +24 -12
  217. package/src/lib/media/delivery-bucket.ts +4 -2
  218. package/src/lib/media/library-entry.ts +4 -2
  219. package/src/lib/media/manifest.ts +33 -18
  220. package/src/lib/media/naming.ts +24 -12
  221. package/src/lib/media/orphan-scan.ts +4 -2
  222. package/src/lib/media/reconcile.ts +21 -11
  223. package/src/lib/media/reference.ts +12 -6
  224. package/src/lib/media/rewrite-plan.ts +12 -6
  225. package/src/lib/media/sniff.ts +28 -14
  226. package/src/lib/media/store.ts +16 -8
  227. package/src/lib/media/transform-url.ts +12 -6
  228. package/src/lib/media/usage.ts +8 -4
  229. package/src/lib/nav/site-config.ts +16 -8
  230. package/src/lib/render/component-grammar.ts +23 -10
  231. package/src/lib/render/component-insert.ts +8 -4
  232. package/src/lib/render/component-reference.ts +4 -2
  233. package/src/lib/render/component-validate.ts +3 -0
  234. package/src/lib/render/glyph.ts +4 -2
  235. package/src/lib/render/pipeline.ts +20 -10
  236. package/src/lib/render/registry.ts +44 -22
  237. package/src/lib/render/rehype-dispatch.ts +22 -8
  238. package/src/lib/render/remark-directives.ts +3 -0
  239. package/src/lib/render/remark-figure.ts +4 -2
  240. package/src/lib/render/resolve-links.ts +4 -2
  241. package/src/lib/render/resolve-media.ts +16 -8
  242. package/src/lib/sveltekit/admin-dispatch.ts +10 -4
  243. package/src/lib/sveltekit/auth-routes.ts +3 -0
  244. package/src/lib/sveltekit/cairn-admin.ts +37 -15
  245. package/src/lib/sveltekit/content-routes.ts +492 -197
  246. package/src/lib/sveltekit/editors-routes.ts +3 -0
  247. package/src/lib/sveltekit/guard.ts +4 -2
  248. package/src/lib/sveltekit/https-required-page.ts +1 -1
  249. package/src/lib/sveltekit/index.ts +3 -0
  250. package/src/lib/sveltekit/media-route.ts +13 -8
  251. package/src/lib/sveltekit/nav-routes.ts +7 -2
  252. package/src/lib/sveltekit/types.ts +4 -2
  253. package/src/lib/vite/index.ts +60 -30
  254. package/src/lib/vite/resolve-root.ts +8 -4
@@ -7,7 +7,8 @@ import { findConcept } from '../content/concepts.js';
7
7
  import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
8
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
9
9
  import { deriveExcerpt } from '../content/excerpt.js';
10
- import { asString } from '../content/identity.js';
10
+ import { asString, entryIdentity } from '../content/identity.js';
11
+ import { buildAddressIndex, addressCollision } from '../content/advisories.js';
11
12
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
12
13
  import { appCredentials } from '../github/credentials.js';
13
14
  import { listMarkdown, readRaw, commitFile, commitFiles } from '../github/repo.js';
@@ -15,6 +16,8 @@ import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../gith
15
16
  import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
16
17
  import { cachedInstallationToken } from '../github/signing.js';
17
18
  import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
19
+ import { deriveGettingStarted } from '../content/getting-started.js';
20
+ import { markdownReference } from '../components/markdown-reference.js';
18
21
  import { isConflict } from '../github/types.js';
19
22
  import { log } from '../log/index.js';
20
23
  import { dictionaryFileForDialect, DEFAULT_TIDY_MODEL, resolveTidyConventions, parseSiteConfig, setTidy, validateTidyConventions, TidyConventionsError } from '../nav/site-config.js';
@@ -40,19 +43,25 @@ import { buildOrphanScan } from '../media/orphan-scan.js';
40
43
  import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
41
44
  import { planMediaRewrite } from '../media/rewrite-plan.js';
42
45
  import { planBulkDelete } from '../media/bulk-delete-plan.js';
43
- /** The Worker-side request deadline for the tidy model call: 30 seconds. A tidy call to Sonnet on a
46
+ /**
47
+ * The Worker-side request deadline for the tidy model call: 30 seconds. A tidy call to Sonnet on a
44
48
  * full entry can run many seconds, so the action bounds it with an AbortSignal and maps the overrun to
45
49
  * a retryable fail(502). This sits well under Cloudflare's per-request wall-clock ceiling (a Worker
46
50
  * invocation can run far longer, but a single subrequest left open near that ceiling would surface as a
47
51
  * platform timeout the action could not shape into a clean retry). 30s comfortably covers a proofread
48
- * of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit. */
52
+ * of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit.
53
+ */
49
54
  const DEFAULT_TIDY_TIMEOUT_MS = 30_000;
50
- /** The fallback site-config path when no nav menu names one: the convention every scaffolded site
55
+ /**
56
+ * The fallback site-config path when no nav menu names one: the convention every scaffolded site
51
57
  * uses. The settings save edits the same committed YAML the nav editor does, so it resolves the path
52
- * from the configured nav menu first and falls back to this default. */
58
+ * from the configured nav menu first and falls back to this default.
59
+ */
53
60
  const DEFAULT_SITE_CONFIG_PATH = 'src/lib/site.config.yaml';
54
- /** Plain-language labels for the known tidy models, so the read-only model fact reads as a name rather
55
- * than a bare id. An unknown id falls back to itself. */
61
+ /**
62
+ * Plain-language labels for the known tidy models, so the read-only model fact reads as a name rather
63
+ * than a bare id. An unknown id falls back to itself.
64
+ */
56
65
  const TIDY_MODEL_LABELS = {
57
66
  'claude-sonnet-4-6': 'Claude Sonnet',
58
67
  'claude-haiku-4-5': 'Claude Haiku',
@@ -61,14 +70,18 @@ const TIDY_MODEL_LABELS = {
61
70
  function tidyModelLabel(model) {
62
71
  return TIDY_MODEL_LABELS[model] ?? model;
63
72
  }
64
- /** The input cap for a single tidy request: 24000 characters (~6k input tokens). A proofread runs at
73
+ /**
74
+ * The input cap for a single tidy request: 24000 characters (~6k input tokens). A proofread runs at
65
75
  * roughly input length, so this stays comfortably inside the 30s deadline; a longer entry refuses with
66
76
  * fail(413) and the author tidies a selection instead. The cap is enforced BEFORE the model call, so an
67
- * over-long body never spends a token or risks the deadline. */
77
+ * over-long body never spends a token or risks the deadline.
78
+ */
68
79
  const MAX_TIDY_CHARS = 24_000;
69
- /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
80
+ /**
81
+ * Resolve the effective preview for one concept: its `byConcept` override wins per key, with
70
82
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
71
- * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
83
+ * Stylesheets are always shared, and the `byConcept` map never reaches the client.
84
+ */
72
85
  function resolvePreview(preview, conceptId) {
73
86
  if (!preview)
74
87
  return null;
@@ -86,6 +99,9 @@ function conceptOf(runtime, params) {
86
99
  throw error(404, `Unknown content type: ${params.concept ?? ''}`);
87
100
  return concept;
88
101
  }
102
+ /**
103
+ *
104
+ */
89
105
  export function createContentRoutes(runtime, deps = {}) {
90
106
  const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
91
107
  // The default Anthropic factory builds the real SDK client from the resolved key. Tests inject a fake
@@ -93,15 +109,19 @@ export function createContentRoutes(runtime, deps = {}) {
93
109
  // SDK client satisfies TidyClient structurally; the cast names that to the compiler.
94
110
  const anthropicClient = deps.anthropic ?? ((opts) => new Anthropic({ apiKey: opts.apiKey }));
95
111
  const tidyTimeoutMs = deps.tidyTimeoutMs ?? DEFAULT_TIDY_TIMEOUT_MS;
96
- /** Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
97
- * Always read from main: pending branches carry no manifest copy. */
112
+ /**
113
+ * Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
114
+ * Always read from main: pending branches carry no manifest copy.
115
+ */
98
116
  async function readManifest(token) {
99
117
  const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
100
118
  return raw === null ? emptyManifest() : parseManifest(raw);
101
119
  }
102
- /** Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
120
+ /**
121
+ * Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
103
122
  * or corrupt file to null (an empty manifest). The committed file is always our own serialization,
104
- * so the catch only guards a hand-edited or truncated file rather than a normal path. */
123
+ * so the catch only guards a hand-edited or truncated file rather than a normal path.
124
+ */
105
125
  function parseMediaJson(raw) {
106
126
  if (raw === null)
107
127
  return null;
@@ -112,11 +132,13 @@ export function createContentRoutes(runtime, deps = {}) {
112
132
  return null;
113
133
  }
114
134
  }
115
- /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
135
+ /**
136
+ * The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
116
137
  * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
117
138
  * the path confinement), or a concept this site does not configure. Every ref consumer
118
139
  * (the layout count, the list view, publish-all) applies this one predicate, so a stray
119
- * hand-pushed ref cannot inflate a count it can never clear or reach a contents read. */
140
+ * hand-pushed ref cannot inflate a count it can never clear or reach a contents read.
141
+ */
120
142
  function pendingEntryOf(name) {
121
143
  const ref = parsePendingBranch(name);
122
144
  if (!ref || !isValidId(ref.id))
@@ -124,8 +146,10 @@ export function createContentRoutes(runtime, deps = {}) {
124
146
  const concept = findConcept(runtime.concepts, ref.concept);
125
147
  return concept ? { concept, id: ref.id } : null;
126
148
  }
127
- /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
128
- * and the pending entries behind the topbar's publish-all action. */
149
+ /**
150
+ * Layout load for every admin page: the nav, the user, the active path, the resolved theme,
151
+ * and the pending entries behind the topbar's publish-all action.
152
+ */
129
153
  async function layoutLoad(event) {
130
154
  const editor = requireSession(event);
131
155
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
@@ -162,6 +186,33 @@ export function createContentRoutes(runtime, deps = {}) {
162
186
  pendingEntries,
163
187
  };
164
188
  }
189
+ /**
190
+ * Load the Help home: the getting-started progress derived from the committed manifest and the open
191
+ * pending branches, the markdown reference, and the runtime's support contact. A GitHub failure
192
+ * degrades to an empty corpus (0 of 3) rather than failing the screen, the same fail-safe layoutLoad uses.
193
+ */
194
+ async function helpLoad(event) {
195
+ requireSession(event);
196
+ let manifest = emptyManifest();
197
+ let pending = [];
198
+ try {
199
+ const token = await mintToken(event.platform?.env ?? {});
200
+ manifest = await readManifest(token);
201
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
202
+ pending = names.flatMap((name) => {
203
+ const entry = pendingEntryOf(name);
204
+ return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
205
+ });
206
+ }
207
+ catch (err) {
208
+ log.warn('github.unreachable', { scope: 'help', error: String(err) });
209
+ }
210
+ return {
211
+ gettingStarted: deriveGettingStarted(manifest, pending),
212
+ reference: markdownReference,
213
+ supportContact: runtime.supportContact,
214
+ };
215
+ }
165
216
  /** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
166
217
  function indexRedirect() {
167
218
  const first = runtime.concepts[0];
@@ -169,8 +220,10 @@ export function createContentRoutes(runtime, deps = {}) {
169
220
  throw error(404, 'No content types configured');
170
221
  throw redirect(307, `/admin/${first.id}`);
171
222
  }
172
- /** Read a file's frontmatter for its list row, degrading to the id on any read failure. The
173
- * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch. */
223
+ /**
224
+ * Read a file's frontmatter for its list row, degrading to the id on any read failure. The
225
+ * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch.
226
+ */
174
227
  async function summarize(file, token, status, repo = runtime.backend) {
175
228
  try {
176
229
  const raw = await readRaw(repo, file.path, token);
@@ -188,17 +241,21 @@ export function createContentRoutes(runtime, deps = {}) {
188
241
  return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
189
242
  }
190
243
  }
191
- /** Read an entry's list row from its pending branch, so a pending title or draft change shows
244
+ /**
245
+ * Read an entry's list row from its pending branch, so a pending title or draft change shows
192
246
  * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
193
- * an id-only row, so a ghost ref still lists. */
247
+ * an id-only row, so a ghost ref still lists.
248
+ */
194
249
  function pendingRow(concept, id, status, token) {
195
250
  return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
196
251
  ...runtime.backend,
197
252
  branch: pendingBranch(concept.id, id),
198
253
  });
199
254
  }
200
- /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
201
- * and read each one for its row, with edited and new rows reading branch-first. */
255
+ /**
256
+ * The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
257
+ * and read each one for its row, with edited and new rows reading branch-first.
258
+ */
202
259
  async function crawlEntries(concept, pendingIds, token) {
203
260
  const files = await listMarkdown(runtime.backend, concept.dir, token);
204
261
  const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
@@ -207,12 +264,14 @@ export function createContentRoutes(runtime, deps = {}) {
207
264
  const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
208
265
  return [...entries, ...newRows];
209
266
  }
210
- /** List a concept's entries with their publish status. Published rows project straight from
267
+ /**
268
+ * List a concept's entries with their publish status. Published rows project straight from
211
269
  * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
212
270
  * the listing costs one manifest read plus one branch read per pending entry rather than one
213
271
  * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
214
272
  * with no manifest row appends a `new` row read from its branch. A listing failure degrades
215
- * to an inline error, not a thrown 500. */
273
+ * to an inline error, not a thrown 500.
274
+ */
216
275
  async function listLoad(event) {
217
276
  requireSession(event);
218
277
  const concept = conceptOf(runtime, event.params);
@@ -256,12 +315,14 @@ export function createContentRoutes(runtime, deps = {}) {
256
315
  return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
257
316
  }
258
317
  }
259
- /** The admin Media Library load: union the media manifest across main and every open cairn/*
318
+ /**
319
+ * The admin Media Library load: union the media manifest across main and every open cairn/*
260
320
  * branch (so a not-yet-published asset shows), project each row through the shared
261
321
  * mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
262
322
  * hash. The assets union and the usage overlay degrade independently: a usage-build failure
263
323
  * still lists the assets with an empty overlay, and a wholesale read failure degrades to the
264
- * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
324
+ * assets gathered so far rather than a thrown 500, mirroring listLoad's posture.
325
+ */
265
326
  async function mediaLibraryLoad(event) {
266
327
  requireSession(event);
267
328
  // Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
@@ -427,10 +488,10 @@ export function createContentRoutes(runtime, deps = {}) {
427
488
  const published = mainRaw !== null;
428
489
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
429
490
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
491
+ const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
430
492
  let linkTargets = [];
431
493
  let inbound = [];
432
- if (manifestRaw !== null) {
433
- const manifest = parseManifest(manifestRaw);
494
+ if (manifest !== null) {
434
495
  linkTargets = manifest.entries.map((e) => ({
435
496
  concept: e.concept,
436
497
  id: e.id,
@@ -441,6 +502,32 @@ export function createContentRoutes(runtime, deps = {}) {
441
502
  }));
442
503
  inbound = inboundLinks(manifest, concept.id, id);
443
504
  }
505
+ // The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
506
+ // same manifest read above (no second read) and degrade to no notice on any read failure, so a
507
+ // transient GitHub error never blocks the editor. Skip the build with no manifest to index.
508
+ let advisories = [];
509
+ if (manifest !== null) {
510
+ try {
511
+ const identity = entryIdentity(concept, path, parsed.frontmatter);
512
+ const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
513
+ const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
514
+ if (other) {
515
+ const otherConcept = findConcept(runtime.concepts, other.concept);
516
+ const label = otherConcept ? otherConcept.label : other.concept;
517
+ advisories = [
518
+ {
519
+ kind: 'address-collision',
520
+ severity: 'warn',
521
+ message: `Another ${label} already uses the address ${identity.permalink}. Publish this one and it replaces the other at that address.`,
522
+ actions: [{ label: `Open ${other.title}`, href: `/admin/${other.concept}/${other.id}` }],
523
+ },
524
+ ];
525
+ }
526
+ }
527
+ catch (err) {
528
+ log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
529
+ }
530
+ }
444
531
  // Project the one committed media manifest read two ways: the minimal resolver triple the preview
445
532
  // needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
446
533
  // A corrupt committed file degrades both to empty, not a throw.
@@ -487,16 +574,21 @@ export function createContentRoutes(runtime, deps = {}) {
487
574
  model: runtime.tidy?.model || DEFAULT_TIDY_MODEL,
488
575
  conventions: resolveTidyConventions(runtime.tidy?.conventions),
489
576
  },
577
+ advisories,
490
578
  };
491
579
  }
492
- /** The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
493
- * same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`. */
580
+ /**
581
+ * The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
582
+ * same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`.
583
+ */
494
584
  function dictionaryFilePath() {
495
585
  return runtime.dictionaryPath ?? 'src/content/.cairn/dictionary.txt';
496
586
  }
497
- /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
587
+ /**
588
+ * Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
498
589
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
499
- * failures carry the same shape under their own event name. */
590
+ * failures carry the same shape under their own event name.
591
+ */
500
592
  function logCommitFailed(fields, err, event = 'commit.failed') {
501
593
  if (isConflict(err)) {
502
594
  log.warn(event, { ...fields, reason: 'conflict' });
@@ -505,9 +597,11 @@ export function createContentRoutes(runtime, deps = {}) {
505
597
  log.error(event, { ...fields, error: String(err) });
506
598
  }
507
599
  }
508
- /** The shared commit catch for the entry actions: log the failure, bounce a conflict back to
600
+ /**
601
+ * The shared commit catch for the entry actions: log the failure, bounce a conflict back to
509
602
  * `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
510
- * params the bounce must carry (saveAction's `&new=1`). */
603
+ * params the bounce must carry (saveAction's `&new=1`).
604
+ */
511
605
  function commitFailure(fields, err, page, message, opts = {}) {
512
606
  logCommitFailed(fields, err, opts.event);
513
607
  if (isConflict(err)) {
@@ -515,11 +609,13 @@ export function createContentRoutes(runtime, deps = {}) {
515
609
  }
516
610
  throw err;
517
611
  }
518
- /** The shared core of save and publish: parse the posted form, validate the frontmatter,
612
+ /**
613
+ * The shared core of save and publish: parse the posted form, validate the frontmatter,
519
614
  * guard the body's cairn links, ensure the pending branch, and commit the entry file there
520
615
  * with the session editor as author. Returns the broken-link fail for the page to render,
521
616
  * or the held state; throws the redirect bounces save has always thrown (invalid
522
- * frontmatter, a branch-commit conflict). Main stays untouched. */
617
+ * frontmatter, a branch-commit conflict). Main stays untouched.
618
+ */
523
619
  async function saveToBranch(event, editor, concept, id) {
524
620
  const path = `${concept.dir}/${filenameFromId(id)}`;
525
621
  const form = await event.request.formData();
@@ -604,8 +700,10 @@ export function createContentRoutes(runtime, deps = {}) {
604
700
  }
605
701
  return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
606
702
  }
607
- /** Save an edit: validate, then commit to the entry's pending branch with the session editor
608
- * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
703
+ /**
704
+ * Save an edit: validate, then commit to the entry's pending branch with the session editor
705
+ * as author. Main and its manifest stay untouched until publish. Fails safe on 409.
706
+ */
609
707
  async function saveAction(event) {
610
708
  const editor = requireSession(event);
611
709
  const concept = conceptOf(runtime, event.params);
@@ -622,12 +720,14 @@ export function createContentRoutes(runtime, deps = {}) {
622
720
  : 'saved=1';
623
721
  throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
624
722
  }
625
- /** Publish an entry: validate and hold the posted form exactly like save (the branch gets the
723
+ /**
724
+ * Publish an entry: validate and hold the posted form exactly like save (the branch gets the
626
725
  * same commit), then copy that markdown to main with the manifest row upserted in one atomic
627
726
  * commit. Publish-what-you-see: the posted form is the published content, so text typed
628
727
  * after the last save goes live too, and publish works regardless of prior branch state.
629
728
  * The branch is deleted only when its head still matches the commit this action made; a
630
- * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
729
+ * concurrent save moved it, so the entry stays pending and the next publish picks it up.
730
+ */
631
731
  async function publishAction(event) {
632
732
  const editor = requireSession(event);
633
733
  const concept = conceptOf(runtime, event.params);
@@ -647,10 +747,37 @@ export function createContentRoutes(runtime, deps = {}) {
647
747
  ];
648
748
  if (mediaChange)
649
749
  changes.push(mediaChange);
750
+ // The cross-branch address-collision re-check: warn-and-allow, last-write-wins, never a gate.
751
+ // Resolve this entry's own address the way editLoad does and look it up in the index built from
752
+ // the same manifest the publish carries. The read fails open: a thrown index build degrades to
753
+ // no event and the publish proceeds, so a transient GitHub error never blocks a publish.
754
+ let address = '';
755
+ let collision = null;
756
+ try {
757
+ const { frontmatter } = parseMarkdown(markdown);
758
+ address = entryIdentity(concept, path, frontmatter).permalink;
759
+ const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
760
+ collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
761
+ }
762
+ catch (err) {
763
+ // Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
764
+ // proceeds. Log it so a persistently failing advisory build is diagnosable, not invisible.
765
+ collision = null;
766
+ log.warn('github.unreachable', { scope: 'publish-advisories', error: String(err) });
767
+ }
650
768
  const commitFields = { concept: concept.id, id, editor: editor.email };
651
769
  try {
652
770
  await commitFiles(runtime.backend, changes, { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
653
771
  log.info('entry.published', { ...commitFields, batch: false });
772
+ // Only after the publish lands: a diagnostic that a live address now has a new owner.
773
+ if (collision) {
774
+ log.warn('publish.address_collision', {
775
+ editor: editor.email,
776
+ address,
777
+ displacedConcept: collision.concept,
778
+ displacedId: collision.id,
779
+ });
780
+ }
654
781
  }
655
782
  catch (err) {
656
783
  // The branch already holds the just-committed edits, so a conflict here loses nothing.
@@ -664,10 +791,12 @@ export function createContentRoutes(runtime, deps = {}) {
664
791
  }
665
792
  throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
666
793
  }
667
- /** Publish every pending entry site-wide: one atomic commit on main carrying each branch's
794
+ /**
795
+ * Publish every pending entry site-wide: one atomic commit on main carrying each branch's
668
796
  * entry file plus the manifest with every row upserted, then delete the consumed branches.
669
797
  * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
670
- * concept param is ignored and the redirect lands on the first configured concept. */
798
+ * concept param is ignored and the redirect lands on the first configured concept.
799
+ */
671
800
  async function publishAllAction(event) {
672
801
  const editor = requireSession(event);
673
802
  const first = runtime.concepts[0];
@@ -745,8 +874,10 @@ export function createContentRoutes(runtime, deps = {}) {
745
874
  }
746
875
  throw redirect(303, `${listPage}?publishedAll=${published.length}`);
747
876
  }
748
- /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
749
- * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
877
+ /**
878
+ * Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
879
+ * the edit page when the entry lives on main, else to the list (the entry is gone entirely).
880
+ */
750
881
  async function discardAction(event) {
751
882
  const editor = requireSession(event);
752
883
  const concept = conceptOf(runtime, event.params);
@@ -761,11 +892,13 @@ export function createContentRoutes(runtime, deps = {}) {
761
892
  throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
762
893
  throw redirect(303, `/admin/${concept.id}`);
763
894
  }
764
- /** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
895
+ /**
896
+ * The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
765
897
  * commit the file removal and the manifest patch in one commit. The inbound recheck here is the
766
898
  * authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
767
899
  * the list delete (id from the form body) call this with an already-validated id, so the guard is
768
- * enforced once. */
900
+ * enforced once.
901
+ */
769
902
  async function deleteEntry(event, concept, id, editor) {
770
903
  const path = `${concept.dir}/${filenameFromId(id)}`;
771
904
  const token = await mintToken(event.platform?.env ?? {});
@@ -832,10 +965,12 @@ export function createContentRoutes(runtime, deps = {}) {
832
965
  throw error(400, 'Invalid entry id');
833
966
  return deleteEntry(event, concept, id, editor);
834
967
  }
835
- /** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
968
+ /**
969
+ * Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
836
970
  * atomic commit, so no internal link breaks. The collision check and the inbound recompute here
837
971
  * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
838
- * caught by the build's fail-closed backstop. */
972
+ * caught by the build's fail-closed backstop.
973
+ */
839
974
  async function renameAction(event) {
840
975
  const editor = requireSession(event);
841
976
  const concept = conceptOf(runtime, event.params);
@@ -1042,7 +1177,8 @@ export function createContentRoutes(runtime, deps = {}) {
1042
1177
  const MEDIA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1043
1178
  /** A 16-hex content-hash prefix, the immutable asset key. */
1044
1179
  const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
1045
- /** Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1180
+ /**
1181
+ * Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1046
1182
  * read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
1047
1183
  * recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
1048
1184
  * alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
@@ -1059,7 +1195,8 @@ export function createContentRoutes(runtime, deps = {}) {
1059
1195
  * a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
1060
1196
  * the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
1061
1197
  * hash, so a reference added in that window still resolves to bytes that may be gone, the same
1062
- * delete-races-an-edit window every safe delete carries. */
1198
+ * delete-races-an-edit window every safe delete carries.
1199
+ */
1063
1200
  async function mediaDeleteAction(event) {
1064
1201
  const editor = requireSession(event);
1065
1202
  const token = await mintToken(event.platform?.env ?? {});
@@ -1144,7 +1281,8 @@ export function createContentRoutes(runtime, deps = {}) {
1144
1281
  log.info('media.deleted', { editor: editor.email, hash });
1145
1282
  throw redirect(303, '/admin/media?deleted=1');
1146
1283
  }
1147
- /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1284
+ /**
1285
+ * Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1148
1286
  * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1149
1287
  * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1150
1288
  * would blow the workerd connection budget at many open branches). The fail-closed posture is for
@@ -1162,7 +1300,8 @@ export function createContentRoutes(runtime, deps = {}) {
1162
1300
  * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1163
1301
  * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1164
1302
  * aborts the rest of the batch. The result is an itemized 207-style summary the component renders
1165
- * (deleted / skipped with reasons / failed); there is no success redirect. */
1303
+ * (deleted / skipped with reasons / failed); there is no success redirect.
1304
+ */
1166
1305
  async function mediaBulkDelete(event) {
1167
1306
  const editor = requireSession(event);
1168
1307
  const token = await mintToken(event.platform?.env ?? {});
@@ -1245,7 +1384,8 @@ export function createContentRoutes(runtime, deps = {}) {
1245
1384
  log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
1246
1385
  return { deleted, skipped: plan.skipped, failed };
1247
1386
  }
1248
- /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1387
+ /**
1388
+ * The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1249
1389
  * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1250
1390
  * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1251
1391
  * plus a reconcile pass on top of the strict usage build.
@@ -1259,7 +1399,8 @@ export function createContentRoutes(runtime, deps = {}) {
1259
1399
  *
1260
1400
  * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1261
1401
  * purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
1262
- * where-used so an operator can re-ingest rather than purge a still-referenced record). */
1402
+ * where-used so an operator can re-ingest rather than purge a still-referenced record).
1403
+ */
1263
1404
  async function mediaOrphanScan(event) {
1264
1405
  requireSession(event);
1265
1406
  const token = await mintToken(event.platform?.env ?? {});
@@ -1291,7 +1432,8 @@ export function createContentRoutes(runtime, deps = {}) {
1291
1432
  }
1292
1433
  return buildOrphanScan(reconcile, manifest, index);
1293
1434
  }
1294
- /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1435
+ /**
1436
+ * Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1295
1437
  * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1296
1438
  * history. The whole action is built around that fact.
1297
1439
  *
@@ -1315,7 +1457,8 @@ export function createContentRoutes(runtime, deps = {}) {
1315
1457
  *
1316
1458
  * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1317
1459
  * the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
1318
- * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
1460
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
1461
+ */
1319
1462
  async function mediaPurgeOrphans(event) {
1320
1463
  const editor = requireSession(event);
1321
1464
  const token = await mintToken(event.platform?.env ?? {});
@@ -1383,10 +1526,12 @@ export function createContentRoutes(runtime, deps = {}) {
1383
1526
  log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
1384
1527
  return { purged, skippedClaimed, failed };
1385
1528
  }
1386
- /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1529
+ /**
1530
+ * Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1387
1531
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1388
1532
  * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
1389
- * next placement, never a propagating edit of the alt already committed in existing placements. */
1533
+ * next placement, never a propagating edit of the alt already committed in existing placements.
1534
+ */
1390
1535
  async function mediaUpdateAction(event) {
1391
1536
  const editor = requireSession(event);
1392
1537
  const token = await mintToken(event.platform?.env ?? {});
@@ -1416,13 +1561,16 @@ export function createContentRoutes(runtime, deps = {}) {
1416
1561
  }
1417
1562
  throw redirect(303, '/admin/media?updated=1');
1418
1563
  }
1419
- /** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
1564
+ /**
1565
+ * Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
1420
1566
  * an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
1421
- * the hash, so a missing slug still resolves. Shared by the preview and apply token construction. */
1567
+ * the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
1568
+ */
1422
1569
  function replacementToken(slug, hash) {
1423
1570
  return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
1424
1571
  }
1425
- /** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
1572
+ /**
1573
+ * Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
1426
1574
  * of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
1427
1575
  * each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
1428
1576
  * It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
@@ -1432,7 +1580,8 @@ export function createContentRoutes(runtime, deps = {}) {
1432
1580
  * the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
1433
1581
  * returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
1434
1582
  * with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
1435
- * `type`/`status` from the body, never the HTTP status. */
1583
+ * `type`/`status` from the body, never the HTTP status.
1584
+ */
1436
1585
  async function mediaReplacePreview(event) {
1437
1586
  // CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
1438
1587
  // upload action. A failed check refuses before the session read or any GitHub call.
@@ -1498,7 +1647,8 @@ export function createContentRoutes(runtime, deps = {}) {
1498
1647
  });
1499
1648
  return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
1500
1649
  }
1501
- /** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
1650
+ /**
1651
+ * Apply a replace-in-place: rewrite every published main entry that references the old asset to the
1502
1652
  * new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
1503
1653
  * re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
1504
1654
  * preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
@@ -1509,7 +1659,8 @@ export function createContentRoutes(runtime, deps = {}) {
1509
1659
  *
1510
1660
  * No R2 operation: the new bytes were already stored put-first by the upload action, and the old
1511
1661
  * bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
1512
- * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only. */
1662
+ * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
1663
+ */
1513
1664
  async function mediaReplaceApply(event) {
1514
1665
  const editor = requireSession(event);
1515
1666
  const token = await mintToken(event.platform?.env ?? {});
@@ -1598,7 +1749,8 @@ export function createContentRoutes(runtime, deps = {}) {
1598
1749
  }
1599
1750
  throw redirect(303, '/admin/media?replaced=1');
1600
1751
  }
1601
- /** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
1752
+ /**
1753
+ * Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
1602
1754
  * asset's default alt across every published main entry that references it, bucketing each placement
1603
1755
  * (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
1604
1756
  * enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
@@ -1608,7 +1760,8 @@ export function createContentRoutes(runtime, deps = {}) {
1608
1760
  * Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
1609
1761
  * header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
1610
1762
  * ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
1611
- * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body. */
1763
+ * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
1764
+ */
1612
1765
  async function mediaAltPreview(event) {
1613
1766
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
1614
1767
  // replace-preview actions. A failed check refuses before the session read or any GitHub call.
@@ -1676,14 +1829,16 @@ export function createContentRoutes(runtime, deps = {}) {
1676
1829
  });
1677
1830
  return { entries, branchDelta: plan.branchDelta, counts };
1678
1831
  }
1679
- /** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
1832
+ /**
1833
+ * Apply an alt-propagation: fill the asset's default alt into every empty placement across the
1680
1834
  * published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
1681
1835
  * commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
1682
1836
  * differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
1683
1837
  * is NO media.json change (the default alt is READ from the row, never rewritten there), and a
1684
1838
  * decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
1685
1839
  * that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
1686
- * closed on an unverifiable usage read, and writes only entry files in git (no R2 op). */
1840
+ * closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
1841
+ */
1687
1842
  async function mediaAltApply(event) {
1688
1843
  const editor = requireSession(event);
1689
1844
  const token = await mintToken(event.platform?.env ?? {});
@@ -1735,19 +1890,25 @@ export function createContentRoutes(runtime, deps = {}) {
1735
1890
  }
1736
1891
  throw redirect(303, '/admin/media?altPropagated=1');
1737
1892
  }
1738
- /** The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
1893
+ /**
1894
+ * The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
1739
1895
  * this bounds an abusive input; the real authority is the per-character validation, which rejects
1740
- * whitespace and control bytes so a body can never inject an extra line into the committed file. */
1896
+ * whitespace and control bytes so a body can never inject an extra line into the committed file.
1897
+ */
1741
1898
  const MAX_DICTIONARY_WORD = 64;
1742
- /** The cap on the words a single add request carries: an editor adds a handful at save time, never
1743
- * a flood. Past this the body is treated as abusive and the surplus is dropped. */
1899
+ /**
1900
+ * The cap on the words a single add request carries: an editor adds a handful at save time, never
1901
+ * a flood. Past this the body is treated as abusive and the surplus is dropped.
1902
+ */
1744
1903
  const MAX_DICTIONARY_BATCH = 100;
1745
- /** Read the committed personal dictionary, merge the validated additions in sorted order, and commit
1904
+ /**
1905
+ * Read the committed personal dictionary, merge the validated additions in sorted order, and commit
1746
1906
  * the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
1747
1907
  * the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
1748
1908
  * word that already landed is preserved and the result is the same sorted set regardless of order.
1749
1909
  * Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
1750
- * under the commit, which the caller catches to retry once. */
1910
+ * under the commit, which the caller catches to retry once.
1911
+ */
1751
1912
  async function mergeAndCommitDictionary(token, additions, editor) {
1752
1913
  const path = dictionaryFilePath();
1753
1914
  // The existing file as its canonical sorted set, so a no-op add is detected against the same
@@ -1762,25 +1923,31 @@ export function createContentRoutes(runtime, deps = {}) {
1762
1923
  await commitFiles(runtime.backend, [{ path, content: serializeDictionary(merged) }], { message: `Add to dictionary: ${additions.join(', ')}`, author: { name: editor.displayName, email: editor.email } }, token);
1763
1924
  return merged;
1764
1925
  }
1765
- /** The repo-relative site-config path the settings save reads and commits. It is the same committed
1926
+ /**
1927
+ * The repo-relative site-config path the settings save reads and commits. It is the same committed
1766
1928
  * YAML the nav editor edits, so it comes from the configured nav menu first and falls back to the
1767
- * scaffold default when no menu is configured. */
1929
+ * scaffold default when no menu is configured.
1930
+ */
1768
1931
  function siteConfigPath() {
1769
1932
  return runtime.navMenu?.configPath ?? DEFAULT_SITE_CONFIG_PATH;
1770
1933
  }
1771
- /** Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
1934
+ /**
1935
+ * Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
1772
1936
  * truthful visibility gate, never the key itself: the key is a Worker secret, so this only reports
1773
- * that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server. */
1937
+ * that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
1938
+ */
1774
1939
  function keyConfigured(event) {
1775
1940
  const env = (event.platform?.env ?? {});
1776
1941
  return typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.length > 0;
1777
1942
  }
1778
- /** Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
1943
+ /**
1944
+ * Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
1779
1945
  * read-only; the editor tier is the resolved conventions block. The visibility gate is truthful: the
1780
1946
  * `enabled` flag is true only when `tidy.enabled` is set AND the key is present, so the screen renders
1781
1947
  * the convention list only then and the honest gate note otherwise. No secret is returned: only a
1782
1948
  * presence flag for the key. The conventions come straight from the runtime config (the same source
1783
- * the tidy action's prompt reads), so the screen and the prompt can never diverge. */
1949
+ * the tidy action's prompt reads), so the screen and the prompt can never diverge.
1950
+ */
1784
1951
  function settingsLoad(event) {
1785
1952
  requireSession(event);
1786
1953
  const tidy = runtime.tidy;
@@ -1798,13 +1965,15 @@ export function createContentRoutes(runtime, deps = {}) {
1798
1965
  error: event.url.searchParams.get('error'),
1799
1966
  };
1800
1967
  }
1801
- /** Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
1968
+ /**
1969
+ * Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
1802
1970
  * the same committed YAML the nav editor writes, with the session editor as author. The transport is
1803
1971
  * the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
1804
1972
  * `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
1805
1973
  * block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
1806
1974
  * never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
1807
- * enabled, so the gate state's absent editor tier can never be saved past. */
1975
+ * enabled, so the gate state's absent editor tier can never be saved past.
1976
+ */
1808
1977
  async function settingsSave(event) {
1809
1978
  const editor = requireSession(event);
1810
1979
  // The editor tier does not exist when tidy is off, so a save in that state is a 404 (no editable
@@ -1843,7 +2012,8 @@ export function createContentRoutes(runtime, deps = {}) {
1843
2012
  }
1844
2013
  throw redirect(303, '/admin/settings?saved=1');
1845
2014
  }
1846
- /** Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
2015
+ /**
2016
+ * Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
1847
2017
  * the media raw-body actions exactly: a `text/plain` POST, the CSRF token in `X-Cairn-CSRF` validated
1848
2018
  * by validateCsrfHeader (CSRF first, then the session), and a small JSON body `{ word }` or
1849
2019
  * `{ words }`. It reads the current file from the default branch, inserts the validated words in
@@ -1858,7 +2028,8 @@ export function createContentRoutes(runtime, deps = {}) {
1858
2028
  * Input validation is load-bearing here: this commits to the repo from request input, so every word
1859
2029
  * is length-bounded and rejected if it carries whitespace or control characters (a word is one
1860
2030
  * line), and the batch is capped. A body that yields no valid word refuses with a 400 and commits
1861
- * nothing, so the committed file can never gain an injected or empty line. */
2031
+ * nothing, so the committed file can never gain an injected or empty line.
2032
+ */
1862
2033
  async function addDictionaryWord(event) {
1863
2034
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
1864
2035
  // media actions. A failed check refuses before the session read or any GitHub call.
@@ -1911,7 +2082,8 @@ export function createContentRoutes(runtime, deps = {}) {
1911
2082
  }
1912
2083
  }
1913
2084
  }
1914
- /** Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
2085
+ /**
2086
+ * Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
1915
2087
  * the library, so this is the highest-blast-radius server action: untrusted content and the Anthropic
1916
2088
  * API key. The transport mirrors the media raw-body actions (a `text/plain` POST carrying JSON
1917
2089
  * `{ text, scope }`, the CSRF token in `X-Cairn-CSRF`, the response deserialized by the client), with
@@ -1929,7 +2101,8 @@ export function createContentRoutes(runtime, deps = {}) {
1929
2101
  * prompt's injection framing (Task 10) treats it as data. The API key never leaves the action: it is
1930
2102
  * not returned and not logged, and the log line carries no content. The action commits NOTHING, so a
1931
2103
  * failed, aborted, or refused tidy can never corrupt the entry; the diff is computed on the client
1932
- * (Task 12), so the server stays a thin model-call boundary. */
2104
+ * (Task 12), so the server stays a thin model-call boundary.
2105
+ */
1933
2106
  async function tidyAction(event) {
1934
2107
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority. A failed check refuses
1935
2108
  // before the session read and before any model call.
@@ -2019,10 +2192,12 @@ export function createContentRoutes(runtime, deps = {}) {
2019
2192
  log.info('tidy.done', { editor: editor.email, model: message.model, usage: message.usage });
2020
2193
  return { corrected, model: message.model, usage: message.usage };
2021
2194
  }
2022
- return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction, mintToken };
2195
+ return { layoutLoad, helpLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction, mintToken };
2023
2196
  }
2024
- /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
2025
- * so a generous cap rejects only abuse-scale input. */
2197
+ /**
2198
+ * The cap, in characters, on the stored alt text. The human fields are display copy, not content,
2199
+ * so a generous cap rejects only abuse-scale input.
2200
+ */
2026
2201
  const MAX_ALT = 160;
2027
2202
  /** The cap, in characters, on the stored display name. */
2028
2203
  const MAX_DISPLAY_NAME = 120;
@@ -2030,8 +2205,10 @@ const MAX_DISPLAY_NAME = 120;
2030
2205
  const MAX_ORIGINAL_FILENAME = 120;
2031
2206
  /** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
2032
2207
  const MAX_DIMENSION = 60000;
2033
- /** Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
2034
- * so a hostile `X-Cairn-*` value cannot throw past the gate. */
2208
+ /**
2209
+ * Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
2210
+ * so a hostile `X-Cairn-*` value cannot throw past the gate.
2211
+ */
2035
2212
  function safeDecode(value) {
2036
2213
  if (value === null)
2037
2214
  return '';
@@ -2042,35 +2219,46 @@ function safeDecode(value) {
2042
2219
  return '';
2043
2220
  }
2044
2221
  }
2045
- /** The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
2046
- * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record. */
2222
+ /**
2223
+ * The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
2224
+ * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record.
2225
+ */
2047
2226
  function basename(name) {
2048
2227
  const parts = name.split(/[/\\]/);
2049
2228
  return parts[parts.length - 1];
2050
2229
  }
2051
- /** Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
2052
- * refusal lists "Published on the site" first, then the edit-branch references. */
2230
+ /**
2231
+ * Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
2232
+ * refusal lists "Published on the site" first, then the edit-branch references.
2233
+ */
2053
2234
  function originRank(entry) {
2054
2235
  return entry.origin.kind === 'published' ? 0 : 1;
2055
2236
  }
2056
- /** A where-used row's branch name for the secondary sort (the empty string for a published row,
2057
- * which sorts ahead of any branch by `originRank` already). */
2237
+ /**
2238
+ * A where-used row's branch name for the secondary sort (the empty string for a published row,
2239
+ * which sorts ahead of any branch by `originRank` already).
2240
+ */
2058
2241
  function branchKey(entry) {
2059
2242
  return entry.origin.kind === 'branch' ? entry.origin.branch : '';
2060
2243
  }
2061
- /** The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
2062
- * same entry are two rows but one distinct entry, so count by concept/id. */
2244
+ /**
2245
+ * The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
2246
+ * same entry are two rows but one distinct entry, so count by concept/id.
2247
+ */
2063
2248
  function distinctEntryCount(rows) {
2064
2249
  return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
2065
2250
  }
2066
- /** Strip control characters from a human field and cap it at `max` characters. Control characters
2067
- * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON. */
2251
+ /**
2252
+ * Strip control characters from a human field and cap it at `max` characters. Control characters
2253
+ * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON.
2254
+ */
2068
2255
  function sanitizeField(value, max) {
2069
- // eslint-disable-next-line no-control-regex
2070
2256
  return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
2071
2257
  }
2072
- /** Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
2073
- * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`). */
2258
+ /**
2259
+ * Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
2260
+ * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`).
2261
+ */
2074
2262
  function clampDimension(value) {
2075
2263
  if (value === null)
2076
2264
  return null;