@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, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
11
12
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
12
13
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
13
14
  import { listMarkdown, readRaw, commitFile, commitFiles, type FileChange } 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, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
19
+ import { deriveGettingStarted, type GettingStarted } from '../content/getting-started.js';
20
+ import { markdownReference, type MarkdownReferenceRow } 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';
@@ -54,6 +57,10 @@ import type { Editor, Role } from '../auth/types.js';
54
57
  // import that never appears in an exported signature, so it does not reach the public `.d.ts`.
55
58
  import type { R2Bucket } from '@cloudflare/workers-types';
56
59
 
60
+ // The advisory notice types are defined alongside the cross-branch address index in the content
61
+ // layer; re-export them here so EditData's advisories and the /sveltekit subpath carry one shape.
62
+ export type { AdvisoryNotice, AdvisoryAction } from '../content/advisories.js';
63
+
57
64
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
58
65
  export interface NavConcept {
59
66
  id: string;
@@ -71,13 +78,17 @@ export interface LayoutData {
71
78
  navLabel: string | null;
72
79
  /** The admin theme resolved for SSR: the persisted cookie choice, or the light default. */
73
80
  theme: 'cairn-admin' | 'cairn-admin-dark';
74
- /** The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
75
- * collapsed group renders collapsed with no flash. Empty when none are collapsed. */
81
+ /**
82
+ * The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
83
+ * collapsed group renders collapsed with no flash. Empty when none are collapsed.
84
+ */
76
85
  collapsedNav: string[];
77
86
  /** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
78
87
  csrf: string;
79
- /** Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
80
- * Null when GitHub is unreachable, so the topbar hides the action rather than lying. */
88
+ /**
89
+ * Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
90
+ * Null when GitHub is unreachable, so the topbar hides the action rather than lying.
91
+ */
81
92
  pendingEntries: { concept: string; id: string }[] | null;
82
93
  }
83
94
 
@@ -89,8 +100,10 @@ export interface EntrySummary {
89
100
  draft: boolean;
90
101
  /** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
91
102
  status: 'published' | 'edited' | 'new';
92
- /** The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
93
- * frontmatter/body excerpt for a pending one, and null when neither yields text. */
103
+ /**
104
+ * The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
105
+ * frontmatter/body excerpt for a pending one, and null when neither yields text.
106
+ */
94
107
  summary: string | null;
95
108
  }
96
109
 
@@ -98,8 +111,10 @@ export interface EntrySummary {
98
111
  export interface ListData {
99
112
  conceptId: string;
100
113
  label: string;
101
- /** The singular noun for the create affordances ("New post"); from the descriptor, which defaults
102
- * it to `label`. */
114
+ /**
115
+ * The singular noun for the create affordances ("New post"); from the descriptor, which defaults
116
+ * it to `label`.
117
+ */
103
118
  singular: string;
104
119
  /** Posts carry a date in the new-entry form; pages do not (concept routing, spec §7.2). */
105
120
  dated: boolean;
@@ -130,13 +145,17 @@ export interface EditData {
130
145
  slug: string;
131
146
  /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
132
147
  linkTargets: LinkTarget[];
133
- /** The minimal media-resolver input the edit page builds its preview `resolveMedia` from, keyed by
134
- * the 16-hex content hash and parallel to `linkTargets`. Empty when media is off or the read fails. */
148
+ /**
149
+ * The minimal media-resolver input the edit page builds its preview `resolveMedia` from, keyed by
150
+ * the 16-hex content hash and parallel to `linkTargets`. Empty when media is off or the read fails.
151
+ */
135
152
  mediaTargets: Record<string, { slug: string; ext: string; contentType: string }>;
136
- /** The picker's human layer for each stored asset, keyed by the 16-hex content hash and projected
153
+ /**
154
+ * The picker's human layer for each stored asset, keyed by the 16-hex content hash and projected
137
155
  * from the same committed media manifest read that populates `mediaTargets`. The `hash` field
138
156
  * duplicates the key, so the picker can iterate `Object.values`. Empty when media is off or the
139
- * read fails (the same degradation path as `mediaTargets`). */
157
+ * read fails (the same degradation path as `mediaTargets`).
158
+ */
140
159
  mediaLibrary: MediaLibrary;
141
160
  /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
142
161
  inboundLinks: InboundLink[];
@@ -148,30 +167,42 @@ export interface EditData {
148
167
  publishedFlash: boolean;
149
168
  /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
150
169
  discardedFlash: boolean;
151
- /** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
170
+ /**
171
+ * The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
152
172
  * when one exists, applied over the top-level values); null when the site sets none, which
153
- * leaves the frame rendering unstyled markup behind a hint. */
173
+ * leaves the frame rendering unstyled markup behind a hint.
174
+ */
154
175
  preview: ResolvedPreview | null;
155
- /** The spellcheck dictionary file for the site's configured dialect (default US English), resolved
176
+ /**
177
+ * The spellcheck dictionary file for the site's configured dialect (default US English), resolved
156
178
  * once at compose. The editor resolves it to a real asset URL on the main thread and hands that URL
157
179
  * to the spellcheck Worker's `init`, the same way `mediaLibrary` is threaded in. Just the filename,
158
- * e.g. "dictionary-en-us.txt". */
180
+ * e.g. "dictionary-en-us.txt".
181
+ */
159
182
  spellcheckDictionary: string;
160
- /** The committed personal-dictionary words for the site (spec 1.6): the durable, shared, reviewable
183
+ /**
184
+ * The committed personal-dictionary words for the site (spec 1.6): the durable, shared, reviewable
161
185
  * layer the editor seeds the spellcheck Worker's personal set from, the way `mediaLibrary` is handed
162
186
  * in. Read from the git-committed `dictionary.txt` at editor load; empty when the file is absent or
163
187
  * unreadable (the editor degrades to dialect-only). The dialect dictionary and the session ignore
164
- * list are the other two layers; only this one is committed. */
188
+ * list are the other two layers; only this one is committed.
189
+ */
165
190
  siteDictionary: string[];
166
- /** The editor-tier tidy facts the review surface needs (spec 2.5): whether tidy is enabled, the model
191
+ /**
192
+ * The editor-tier tidy facts the review surface needs (spec 2.5): whether tidy is enabled, the model
167
193
  * that runs (for the head pill), and the RESOLVED conventions (the only data source for a
168
194
  * normalization's because-line and the local category inference). The API key never appears here, it
169
- * is a Worker secret. `enabled` false hides the Tidy control. */
195
+ * is a Worker secret. `enabled` false hides the Tidy control.
196
+ */
170
197
  tidy: { enabled: boolean; model: string; conventions: TidyConventions };
198
+ /** Non-blocking editor advisories built server-side; today the cross-branch address collision. */
199
+ advisories: AdvisoryNotice[];
171
200
  }
172
201
 
173
- /** One asset's where-used overlay, kept separate from MediaLibraryEntry so the picker's shared
174
- * projection stays decoupled from the Library-only usage facts. */
202
+ /**
203
+ * One asset's where-used overlay, kept separate from MediaLibraryEntry so the picker's shared
204
+ * projection stays decoupled from the Library-only usage facts.
205
+ */
175
206
  export interface MediaUsageInfo {
176
207
  /** Distinct content entries that reference the asset (count by distinct concept+id). */
177
208
  count: number;
@@ -179,51 +210,69 @@ export interface MediaUsageInfo {
179
210
  entries: UsageEntry[];
180
211
  }
181
212
 
182
- /** The Media Library screen's data: the unioned assets, the per-hash usage overlay, and the
213
+ /**
214
+ * The Media Library screen's data: the unioned assets, the per-hash usage overlay, and the
183
215
  * degraded-load error. The usage overlay is keyed by content hash; an asset with no references
184
- * simply has no key, which the screen renders as "no references found". */
216
+ * simply has no key, which the screen renders as "no references found".
217
+ */
185
218
  export interface MediaLibraryData {
186
219
  assets: MediaLibraryEntry[];
187
220
  /** Per-hash usage overlay, kept separate from MediaLibraryEntry so the popover stays decoupled. */
188
221
  usage: Record<string, MediaUsageInfo>;
189
- /** The degraded-load error: a failed token mint or media read. This slot is the failure of THIS
222
+ /**
223
+ * The degraded-load error: a failed token mint or media read. This slot is the failure of THIS
190
224
  * load, distinct from a prior action's conflict error (see `flashError`), so a read failure and a
191
- * redirected commit conflict never overwrite each other. */
225
+ * redirected commit conflict never overwrite each other.
226
+ */
192
227
  error: string | null;
193
- /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
228
+ /**
229
+ * The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
194
230
  * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
195
231
  * `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
196
- * The component renders a polite success strip for each. */
232
+ * The component renders a polite success strip for each.
233
+ */
197
234
  flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
198
- /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
199
- * its own slot rather than the degraded-load `error` above, so the two never collide. */
235
+ /**
236
+ * A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
237
+ * its own slot rather than the degraded-load `error` above, so the two never collide.
238
+ */
200
239
  flashError: string | null;
201
240
  }
202
241
 
203
- /** The two-tier tidy settings load (spec 2.8, Task 15). The developer tier is read-only: `enabled`,
242
+ /**
243
+ * The two-tier tidy settings load (spec 2.8, Task 15). The developer tier is read-only: `enabled`,
204
244
  * `keyConfigured`, and `model`/`modelLabel` are deploy-time facts the editor sees but cannot change.
205
245
  * The editor tier is the resolved `conventions` block, written back through the save. The visibility
206
246
  * gate is truthful: `enabled` is true only when `tidy.enabled` is set AND the API key is present, so
207
247
  * the screen renders the convention list only then and the honest gate note otherwise. The key is a
208
248
  * Worker secret, so `keyConfigured` is the presence of `ANTHROPIC_API_KEY` in the load's env, never
209
- * the key itself; nothing here returns or logs the secret. */
249
+ * the key itself; nothing here returns or logs the secret.
250
+ */
210
251
  export interface SettingsData {
211
- /** The truthful gate: tidy is enabled AND the API key is present. The screen renders the editor
252
+ /**
253
+ * The truthful gate: tidy is enabled AND the API key is present. The screen renders the editor
212
254
  * tier only when this is true, and the honest gate note (a labelled region, no disabled controls)
213
- * otherwise. */
255
+ * otherwise.
256
+ */
214
257
  enabled: boolean;
215
- /** Whether `tidy.enabled` is set in the site config, independent of the key. The gate note's
216
- * checklist reads this to show which deploy-time step is still open. */
258
+ /**
259
+ * Whether `tidy.enabled` is set in the site config, independent of the key. The gate note's
260
+ * checklist reads this to show which deploy-time step is still open.
261
+ */
217
262
  tidyEnabled: boolean;
218
263
  /** Whether the API key secret is present in the Worker env. A presence flag, never the key. */
219
264
  keyConfigured: boolean;
220
265
  /** The model id (a developer-tier fact, read-only on the screen). */
221
266
  model: string;
222
- /** A plain-language label for the model id ("Claude Sonnet"), so the read-only fact is not a bare
223
- * jargon token. Falls back to the raw id for an unknown model. */
267
+ /**
268
+ * A plain-language label for the model id ("Claude Sonnet"), so the read-only fact is not a bare
269
+ * jargon token. Falls back to the raw id for an unknown model.
270
+ */
224
271
  modelLabel: string;
225
- /** The resolved editor-tier conventions: every field concrete, the screen's initial control state.
226
- * Present only when the gate is open; the gate state needs no conventions. */
272
+ /**
273
+ * The resolved editor-tier conventions: every field concrete, the screen's initial control state.
274
+ * Present only when the gate is open; the gate state needs no conventions.
275
+ */
227
276
  conventions: TidyConventions;
228
277
  /** The success flash a redirected save carries (`?saved=1`). */
229
278
  saved: boolean;
@@ -231,26 +280,42 @@ export interface SettingsData {
231
280
  error: string | null;
232
281
  }
233
282
 
234
- /** A refused settings save: a conflict bounce or a malformed conventions payload. Just the one-line
235
- * summary; the save commits nothing on a refusal. */
283
+ /**
284
+ * A refused settings save: a conflict bounce or a malformed conventions payload. Just the one-line
285
+ * summary; the save commits nothing on a refusal.
286
+ */
236
287
  export interface SettingsSaveFailure {
237
288
  error: string;
238
289
  }
239
290
 
291
+ /**
292
+ * The Help home's data: the derived getting-started progress, the full markdown reference (the
293
+ * component curates by group), and the optional support hand-off (rendered only when set).
294
+ */
295
+ export interface HelpData {
296
+ gettingStarted: GettingStarted;
297
+ reference: MarkdownReferenceRow[];
298
+ supportContact?: string;
299
+ }
300
+
240
301
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
241
302
  export interface ContentEvent extends EventBase<GithubKeyEnv> {
242
303
  params: Record<string, string>;
243
- /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
244
- * token. Optional for non-route callers. */
304
+ /**
305
+ * SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
306
+ * token. Optional for non-route callers.
307
+ */
245
308
  cookies?: CookieJar;
246
309
  }
247
310
 
248
311
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
249
- /** The minimal Anthropic client surface the tidy action uses, typed structurally so the SDK's deep
312
+ /**
313
+ * The minimal Anthropic client surface the tidy action uses, typed structurally so the SDK's deep
250
314
  * generics never reach a public signature and so the integration test can inject a fake whose
251
315
  * `messages.create` it stubs. The real factory builds `new Anthropic({ apiKey })`, which satisfies
252
316
  * this shape. The success path reads only the text blocks, the model, the stop reason, and the usage
253
- * counts. */
317
+ * counts.
318
+ */
254
319
  export interface TidyClient {
255
320
  messages: {
256
321
  create(
@@ -273,53 +338,69 @@ export interface TidyClient {
273
338
  }
274
339
 
275
340
  export interface ContentRoutesDeps {
276
- /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
277
- * A bare string works too; the routes await whatever comes back. */
341
+ /**
342
+ * Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
343
+ * A bare string works too; the routes await whatever comes back.
344
+ */
278
345
  mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
279
- /** Build the Anthropic client for the tidy action from the resolved API key. Defaults to the real
346
+ /**
347
+ * Build the Anthropic client for the tidy action from the resolved API key. Defaults to the real
280
348
  * SDK client. Injected in tests so `messages.create` is stubbed and no network call (or real key)
281
349
  * is ever needed. The factory runs only after the key is read from the env, so a disabled or
282
- * unconfigured site never constructs a client. */
350
+ * unconfigured site never constructs a client.
351
+ */
283
352
  anthropic?: (opts: { apiKey: string }) => TidyClient;
284
- /** The tidy action's own request deadline in milliseconds, set shorter than the platform limit so a
353
+ /**
354
+ * The tidy action's own request deadline in milliseconds, set shorter than the platform limit so a
285
355
  * slow model call becomes a clean retryable fail(502) rather than a platform timeout. Defaults to
286
- * {@link DEFAULT_TIDY_TIMEOUT_MS}. Overridable in tests to assert the deadline path without waiting. */
356
+ * {@link DEFAULT_TIDY_TIMEOUT_MS}. Overridable in tests to assert the deadline path without waiting.
357
+ */
287
358
  tidyTimeoutMs?: number;
288
359
  }
289
360
 
290
- /** The successful tidy outcome (spec 2.1): the corrected markdown, the model that produced it, and the
361
+ /**
362
+ * The successful tidy outcome (spec 2.1): the corrected markdown, the model that produced it, and the
291
363
  * token usage. The diff is computed on the client (Task 12), so the server returns the plain text and
292
364
  * commits nothing. Admin-internal: consumed by the editor's review surface, not on the package's
293
- * sveltekit subpath, so it carries no reference page. */
365
+ * sveltekit subpath, so it carries no reference page.
366
+ */
294
367
  export interface TidyResult {
295
368
  corrected: string;
296
369
  model: string;
297
370
  usage: { input_tokens: number; output_tokens: number };
298
371
  }
299
372
 
300
- /** A refused tidy: `fail(403)` on a failed CSRF check, `fail(503)` when tidy is disabled or the API
373
+ /**
374
+ * A refused tidy: `fail(403)` on a failed CSRF check, `fail(503)` when tidy is disabled or the API
301
375
  * key is missing, `fail(413)` for an over-long body, `fail(502)` for a deadline overrun, abort, or
302
376
  * model error (all retryable), `fail(422)` for a model refusal, `fail(400)` for a malformed body. Just
303
- * the one-line summary; the action commits nothing, so a refusal can never corrupt the entry. */
377
+ * the one-line summary; the action commits nothing, so a refusal can never corrupt the entry.
378
+ */
304
379
  export interface TidyFailure {
305
380
  error: string;
306
381
  }
307
382
 
308
- /** The Worker-side request deadline for the tidy model call: 30 seconds. A tidy call to Sonnet on a
383
+ /**
384
+ * The Worker-side request deadline for the tidy model call: 30 seconds. A tidy call to Sonnet on a
309
385
  * full entry can run many seconds, so the action bounds it with an AbortSignal and maps the overrun to
310
386
  * a retryable fail(502). This sits well under Cloudflare's per-request wall-clock ceiling (a Worker
311
387
  * invocation can run far longer, but a single subrequest left open near that ceiling would surface as a
312
388
  * platform timeout the action could not shape into a clean retry). 30s comfortably covers a proofread
313
- * of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit. */
389
+ * of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit.
390
+ */
314
391
  const DEFAULT_TIDY_TIMEOUT_MS = 30_000;
315
392
 
316
- /** The fallback site-config path when no nav menu names one: the convention every scaffolded site
393
+ /**
394
+ * The fallback site-config path when no nav menu names one: the convention every scaffolded site
317
395
  * uses. The settings save edits the same committed YAML the nav editor does, so it resolves the path
318
- * from the configured nav menu first and falls back to this default. */
396
+ * from the configured nav menu first and falls back to this default.
397
+ */
319
398
  const DEFAULT_SITE_CONFIG_PATH = 'src/lib/site.config.yaml';
320
399
 
321
- /** Plain-language labels for the known tidy models, so the read-only model fact reads as a name rather
322
- * than a bare id. An unknown id falls back to itself. */
400
+ /**
401
+ * Plain-language labels for the known tidy models, so the read-only model fact reads as a name rather
402
+ * than a bare id. An unknown id falls back to itself.
403
+ */
323
404
  const TIDY_MODEL_LABELS: Record<string, string> = {
324
405
  'claude-sonnet-4-6': 'Claude Sonnet',
325
406
  'claude-haiku-4-5': 'Claude Haiku',
@@ -330,10 +411,12 @@ function tidyModelLabel(model: string): string {
330
411
  return TIDY_MODEL_LABELS[model] ?? model;
331
412
  }
332
413
 
333
- /** The input cap for a single tidy request: 24000 characters (~6k input tokens). A proofread runs at
414
+ /**
415
+ * The input cap for a single tidy request: 24000 characters (~6k input tokens). A proofread runs at
334
416
  * roughly input length, so this stays comfortably inside the 30s deadline; a longer entry refuses with
335
417
  * fail(413) and the author tidies a selection instead. The cap is enforced BEFORE the model call, so an
336
- * over-long body never spends a token or risks the deadline. */
418
+ * over-long body never spends a token or risks the deadline.
419
+ */
337
420
  const MAX_TIDY_CHARS = 24_000;
338
421
 
339
422
  /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
@@ -362,9 +445,11 @@ export interface RenameFailure {
362
445
  error: string;
363
446
  }
364
447
 
365
- /** A refused media delete: `fail(404)` for an asset not committed on the default branch, or
448
+ /**
449
+ * A refused media delete: `fail(404)` for an asset not committed on the default branch, or
366
450
  * `fail(409)` when a fresh usage read finds the asset still in use and the typed-slug override
367
- * was not given. `fail(503)` covers media-off or a missing bucket binding. */
451
+ * was not given. `fail(503)` covers media-off or a missing bucket binding.
452
+ */
368
453
  export interface MediaDeleteRefusal {
369
454
  /** The one-line human summary every action failure carries. */
370
455
  error: string;
@@ -376,16 +461,20 @@ export interface MediaDeleteRefusal {
376
461
  foundIn: number;
377
462
  }
378
463
 
379
- /** A refused media metadata edit: `fail(404)` for an asset not committed on the default branch, or
380
- * `fail(400)` for an invalid slug. */
464
+ /**
465
+ * A refused media metadata edit: `fail(404)` for an asset not committed on the default branch, or
466
+ * `fail(400)` for an invalid slug.
467
+ */
381
468
  export interface MediaUpdateFailure {
382
469
  /** The one-line human summary every action failure carries. */
383
470
  error: string;
384
471
  }
385
472
 
386
- /** A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
473
+ /**
474
+ * A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
387
475
  * typed-slug override was not given, or `fail(503)` when usage cannot be verified (fail closed) or the
388
- * bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count. */
476
+ * bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count.
477
+ */
389
478
  export interface MediaReplaceFailure {
390
479
  error: string;
391
480
  hash: string;
@@ -393,57 +482,71 @@ export interface MediaReplaceFailure {
393
482
  foundIn: number;
394
483
  }
395
484
 
396
- /** A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
485
+ /**
486
+ * A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
397
487
  * open branch (fail closed), or the bucket is unbound. Just the one-line summary; alt fill has no
398
- * typed-slug gate. */
488
+ * typed-slug gate.
489
+ */
399
490
  export interface MediaAltPropagateFailure {
400
491
  error: string;
401
492
  }
402
493
 
403
- /** The personal-dictionary add outcome (spec 1.6): the merged, canonical sorted word list after the
494
+ /**
495
+ * The personal-dictionary add outcome (spec 1.6): the merged, canonical sorted word list after the
404
496
  * add landed. The client reconciles its pending-additions set against this (a word now in the list is
405
497
  * committed and dropped from pending). Admin-internal: exported for the editor host's reconcile, not
406
- * on the package's sveltekit subpath, so it carries no reference page. */
498
+ * on the package's sveltekit subpath, so it carries no reference page.
499
+ */
407
500
  export interface DictionaryAddResult {
408
501
  words: string[];
409
502
  }
410
503
 
411
- /** A refused personal-dictionary add: `fail(403)` on a failed CSRF check, `fail(400)` on a body that
504
+ /**
505
+ * A refused personal-dictionary add: `fail(403)` on a failed CSRF check, `fail(400)` on a body that
412
506
  * carries no valid word. The client keeps its pending additions for the session and re-attempts on
413
- * the next save, so the word is never silently dropped. Just the one-line summary. */
507
+ * the next save, so the word is never silently dropped. Just the one-line summary.
508
+ */
414
509
  export interface DictionaryAddFailure {
415
510
  error: string;
416
511
  }
417
512
 
418
- /** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
513
+ /**
514
+ * A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
419
515
  * (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
420
- * returned summary, not a fail. */
516
+ * returned summary, not a fail.
517
+ */
421
518
  export interface MediaBulkFailure {
422
519
  error: string;
423
520
  }
424
521
 
425
- /** The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
522
+ /**
523
+ * The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
426
524
  * partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
427
- * not on the package subpath, so no reference page. */
525
+ * not on the package subpath, so no reference page.
526
+ */
428
527
  export interface MediaBulkDeleteResult {
429
528
  deleted: string[];
430
529
  skipped: BulkDeleteSkip[];
431
530
  failed: { hash: string; error: string }[];
432
531
  }
433
532
 
434
- /** The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
435
- * manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page. */
533
+ /**
534
+ * The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
535
+ * manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page.
536
+ */
436
537
  export interface MediaOrphanPurgeResult {
437
538
  purged: string[];
438
539
  skippedClaimed: string[];
439
540
  failed: { key: string; error: string }[];
440
541
  }
441
542
 
442
- /** One entry the replace preview will rewrite, enriched with its display title and permalink from the
543
+ /**
544
+ * One entry the replace preview will rewrite, enriched with its display title and permalink from the
443
545
  * content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
444
546
  * confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
445
547
  * this. Admin-internal: exported from content-routes for the bundled Media Library component, not
446
- * added to the package's sveltekit subpath, so it carries no reference page. */
548
+ * added to the package's sveltekit subpath, so it carries no reference page.
549
+ */
447
550
  export interface MediaReplacePreviewEntry {
448
551
  /** The concept id, e.g. "posts". */
449
552
  concept: string;
@@ -457,21 +560,25 @@ export interface MediaReplacePreviewEntry {
457
560
  placements: RepointPlacement[];
458
561
  }
459
562
 
460
- /** The replace preview plan: the affected main entries (enriched), the distinct affected count, and
563
+ /**
564
+ * The replace preview plan: the affected main entries (enriched), the distinct affected count, and
461
565
  * the report-only cross-branch delta (open cairn/* branches that reference the same bytes; an apply
462
- * rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this. */
566
+ * rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this.
567
+ */
463
568
  export interface MediaReplacePreviewPlan {
464
569
  affectedCount: number;
465
570
  entries: MediaReplacePreviewEntry[];
466
571
  branchDelta: BranchRef[];
467
572
  }
468
573
 
469
- /** One entry the alt-propagation preview reports, enriched with its display title and permalink from
574
+ /**
575
+ * One entry the alt-propagation preview reports, enriched with its display title and permalink from
470
576
  * the content manifest. Its placements carry every reference of the asset on this entry, each tagged
471
577
  * with the bucket it falls in (a will-fill, a customized alt left as-is, or a decorative hero), so
472
578
  * the screen can show what would change. Admin-internal: exported from content-routes for the bundled
473
579
  * Media Library component, not added to the package's sveltekit subpath, so it carries no reference
474
- * page. */
580
+ * page.
581
+ */
475
582
  export interface MediaAltPreviewEntry {
476
583
  /** The concept id, e.g. "posts". */
477
584
  concept: string;
@@ -485,11 +592,13 @@ export interface MediaAltPreviewEntry {
485
592
  placements: AltPlacement[];
486
593
  }
487
594
 
488
- /** The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
595
+ /**
596
+ * The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
489
597
  * cross-branch delta, and the bucket counts aggregated across every placement. Display-only: the
490
598
  * apply re-derives a fresh plan and never trusts this. The preview reports an entry even when its
491
599
  * only placements are reported-but-unchanged (a kept custom alt, a decorative hero), so the screen
492
- * can show every bucket; the apply commits only the entries it actually changes. */
600
+ * can show every bucket; the apply commits only the entries it actually changes.
601
+ */
493
602
  export interface MediaAltPreviewPlan {
494
603
  entries: MediaAltPreviewEntry[];
495
604
  branchDelta: BranchRef[];
@@ -497,19 +606,23 @@ export interface MediaAltPreviewPlan {
497
606
  counts: { willFill: number; customized: number; decorativeSkipped: number };
498
607
  }
499
608
 
500
- /** What a route's single `form` export presents to a view component: whichever content action
609
+ /**
610
+ * What a route's single `form` export presents to a view component: whichever content action
501
611
  * last failed, merged with every field optional. `error` is always set on a failure; the richer
502
612
  * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
503
613
  * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
504
- * refusal without a second type. */
614
+ * refusal without a second type.
615
+ */
505
616
  export type ContentFormFailure = Partial<
506
617
  SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure & TidyFailure
507
618
  >;
508
619
 
509
- /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
620
+ /**
621
+ * The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
510
622
  * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
511
623
  * `reused` is true when identical bytes were already stored, so the second upload did no second put;
512
- * `mismatch` flags an existing object whose stored content type differs from this sniff. */
624
+ * `mismatch` flags an existing object whose stored content type differs from this sniff.
625
+ */
513
626
  export interface UploadResult {
514
627
  reference: string;
515
628
  record: MediaEntry;
@@ -517,9 +630,11 @@ export interface UploadResult {
517
630
  mismatch: boolean;
518
631
  }
519
632
 
520
- /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
633
+ /**
634
+ * Resolve the effective preview for one concept: its `byConcept` override wins per key, with
521
635
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
522
- * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
636
+ * Stylesheets are always shared, and the `byConcept` map never reaches the client.
637
+ */
523
638
  function resolvePreview(preview: PreviewConfig | undefined, conceptId: string): ResolvedPreview | null {
524
639
  if (!preview) return null;
525
640
  const override = preview.byConcept?.[conceptId];
@@ -537,6 +652,9 @@ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): Conce
537
652
  return concept;
538
653
  }
539
654
 
655
+ /**
656
+ *
657
+ */
540
658
  export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
541
659
  const mintToken =
542
660
  deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
@@ -548,16 +666,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
548
666
  deps.anthropic ?? ((opts: { apiKey: string }) => new Anthropic({ apiKey: opts.apiKey }) as unknown as TidyClient);
549
667
  const tidyTimeoutMs = deps.tidyTimeoutMs ?? DEFAULT_TIDY_TIMEOUT_MS;
550
668
 
551
- /** Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
552
- * Always read from main: pending branches carry no manifest copy. */
669
+ /**
670
+ * Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
671
+ * Always read from main: pending branches carry no manifest copy.
672
+ */
553
673
  async function readManifest(token: string): Promise<Manifest> {
554
674
  const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
555
675
  return raw === null ? emptyManifest() : parseManifest(raw);
556
676
  }
557
677
 
558
- /** Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
678
+ /**
679
+ * Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
559
680
  * or corrupt file to null (an empty manifest). The committed file is always our own serialization,
560
- * so the catch only guards a hand-edited or truncated file rather than a normal path. */
681
+ * so the catch only guards a hand-edited or truncated file rather than a normal path.
682
+ */
561
683
  function parseMediaJson(raw: string | null): unknown {
562
684
  if (raw === null) return null;
563
685
  try {
@@ -567,11 +689,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
567
689
  }
568
690
  }
569
691
 
570
- /** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
692
+ /**
693
+ * The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
571
694
  * malformed name, an id that fails the slug rule (entry paths are built from it, so this is
572
695
  * the path confinement), or a concept this site does not configure. Every ref consumer
573
696
  * (the layout count, the list view, publish-all) applies this one predicate, so a stray
574
- * hand-pushed ref cannot inflate a count it can never clear or reach a contents read. */
697
+ * hand-pushed ref cannot inflate a count it can never clear or reach a contents read.
698
+ */
575
699
  function pendingEntryOf(name: string): { concept: ConceptDescriptor; id: string } | null {
576
700
  const ref = parsePendingBranch(name);
577
701
  if (!ref || !isValidId(ref.id)) return null;
@@ -579,8 +703,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
579
703
  return concept ? { concept, id: ref.id } : null;
580
704
  }
581
705
 
582
- /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
583
- * and the pending entries behind the topbar's publish-all action. */
706
+ /**
707
+ * Layout load for every admin page: the nav, the user, the active path, the resolved theme,
708
+ * and the pending entries behind the topbar's publish-all action.
709
+ */
584
710
  async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
585
711
  const editor = requireSession(event);
586
712
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
@@ -617,6 +743,33 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
617
743
  };
618
744
  }
619
745
 
746
+ /**
747
+ * Load the Help home: the getting-started progress derived from the committed manifest and the open
748
+ * pending branches, the markdown reference, and the runtime's support contact. A GitHub failure
749
+ * degrades to an empty corpus (0 of 3) rather than failing the screen, the same fail-safe layoutLoad uses.
750
+ */
751
+ async function helpLoad(event: ContentEvent): Promise<HelpData> {
752
+ requireSession(event);
753
+ let manifest = emptyManifest();
754
+ let pending: { concept: string; id: string }[] = [];
755
+ try {
756
+ const token = await mintToken(event.platform?.env ?? {});
757
+ manifest = await readManifest(token);
758
+ const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
759
+ pending = names.flatMap((name) => {
760
+ const entry = pendingEntryOf(name);
761
+ return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
762
+ });
763
+ } catch (err) {
764
+ log.warn('github.unreachable', { scope: 'help', error: String(err) });
765
+ }
766
+ return {
767
+ gettingStarted: deriveGettingStarted(manifest, pending),
768
+ reference: markdownReference,
769
+ supportContact: runtime.supportContact,
770
+ };
771
+ }
772
+
620
773
  /** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
621
774
  function indexRedirect(): never {
622
775
  const first = runtime.concepts[0];
@@ -624,8 +777,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
624
777
  throw redirect(307, `/admin/${first.id}`);
625
778
  }
626
779
 
627
- /** Read a file's frontmatter for its list row, degrading to the id on any read failure. The
628
- * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch. */
780
+ /**
781
+ * Read a file's frontmatter for its list row, degrading to the id on any read failure. The
782
+ * repo defaults to main; a pending entry (edited or branch-only) passes its pending branch.
783
+ */
629
784
  async function summarize(
630
785
  file: { id: string; path: string },
631
786
  token: string,
@@ -647,9 +802,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
647
802
  }
648
803
  }
649
804
 
650
- /** Read an entry's list row from its pending branch, so a pending title or draft change shows
805
+ /**
806
+ * Read an entry's list row from its pending branch, so a pending title or draft change shows
651
807
  * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
652
- * an id-only row, so a ghost ref still lists. */
808
+ * an id-only row, so a ghost ref still lists.
809
+ */
653
810
  function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
654
811
  return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
655
812
  ...runtime.backend,
@@ -657,8 +814,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
657
814
  });
658
815
  }
659
816
 
660
- /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
661
- * and read each one for its row, with edited and new rows reading branch-first. */
817
+ /**
818
+ * The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
819
+ * and read each one for its row, with edited and new rows reading branch-first.
820
+ */
662
821
  async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
663
822
  const files = await listMarkdown(runtime.backend, concept.dir, token);
664
823
  const entries = await Promise.all(
@@ -672,12 +831,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
672
831
  return [...entries, ...newRows];
673
832
  }
674
833
 
675
- /** List a concept's entries with their publish status. Published rows project straight from
834
+ /**
835
+ * List a concept's entries with their publish status. Published rows project straight from
676
836
  * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
677
837
  * the listing costs one manifest read plus one branch read per pending entry rather than one
678
838
  * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
679
839
  * with no manifest row appends a `new` row read from its branch. A listing failure degrades
680
- * to an inline error, not a thrown 500. */
840
+ * to an inline error, not a thrown 500.
841
+ */
681
842
  async function listLoad(event: ContentEvent): Promise<ListData> {
682
843
  requireSession(event);
683
844
  const concept = conceptOf(runtime, event.params);
@@ -728,12 +889,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
728
889
  }
729
890
  }
730
891
 
731
- /** The admin Media Library load: union the media manifest across main and every open cairn/*
892
+ /**
893
+ * The admin Media Library load: union the media manifest across main and every open cairn/*
732
894
  * branch (so a not-yet-published asset shows), project each row through the shared
733
895
  * mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
734
896
  * hash. The assets union and the usage overlay degrade independently: a usage-build failure
735
897
  * still lists the assets with an empty overlay, and a wholesale read failure degrades to the
736
- * assets gathered so far rather than a thrown 500, mirroring listLoad's posture. */
898
+ * assets gathered so far rather than a thrown 500, mirroring listLoad's posture.
899
+ */
737
900
  async function mediaLibraryLoad(event: ContentEvent): Promise<MediaLibraryData> {
738
901
  requireSession(event);
739
902
  // Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
@@ -894,10 +1057,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
894
1057
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
895
1058
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
896
1059
 
1060
+ const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
897
1061
  let linkTargets: LinkTarget[] = [];
898
1062
  let inbound: InboundLink[] = [];
899
- if (manifestRaw !== null) {
900
- const manifest = parseManifest(manifestRaw);
1063
+ if (manifest !== null) {
901
1064
  linkTargets = manifest.entries.map((e) => ({
902
1065
  concept: e.concept,
903
1066
  id: e.id,
@@ -909,6 +1072,32 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
909
1072
  inbound = inboundLinks(manifest, concept.id, id);
910
1073
  }
911
1074
 
1075
+ // The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
1076
+ // same manifest read above (no second read) and degrade to no notice on any read failure, so a
1077
+ // transient GitHub error never blocks the editor. Skip the build with no manifest to index.
1078
+ let advisories: AdvisoryNotice[] = [];
1079
+ if (manifest !== null) {
1080
+ try {
1081
+ const identity = entryIdentity(concept, path, parsed.frontmatter);
1082
+ const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
1083
+ const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
1084
+ if (other) {
1085
+ const otherConcept = findConcept(runtime.concepts, other.concept);
1086
+ const label = otherConcept ? otherConcept.label : other.concept;
1087
+ advisories = [
1088
+ {
1089
+ kind: 'address-collision',
1090
+ severity: 'warn',
1091
+ message: `Another ${label} already uses the address ${identity.permalink}. Publish this one and it replaces the other at that address.`,
1092
+ actions: [{ label: `Open ${other.title}`, href: `/admin/${other.concept}/${other.id}` }],
1093
+ },
1094
+ ];
1095
+ }
1096
+ } catch (err) {
1097
+ log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
1098
+ }
1099
+ }
1100
+
912
1101
  // Project the one committed media manifest read two ways: the minimal resolver triple the preview
913
1102
  // needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
914
1103
  // A corrupt committed file degrades both to empty, not a throw.
@@ -956,18 +1145,23 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
956
1145
  model: runtime.tidy?.model || DEFAULT_TIDY_MODEL,
957
1146
  conventions: resolveTidyConventions(runtime.tidy?.conventions),
958
1147
  },
1148
+ advisories,
959
1149
  };
960
1150
  }
961
1151
 
962
- /** The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
963
- * same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`. */
1152
+ /**
1153
+ * The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
1154
+ * same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`.
1155
+ */
964
1156
  function dictionaryFilePath(): string {
965
1157
  return runtime.dictionaryPath ?? 'src/content/.cairn/dictionary.txt';
966
1158
  }
967
1159
 
968
- /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
1160
+ /**
1161
+ * Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
969
1162
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
970
- * failures carry the same shape under their own event name. */
1163
+ * failures carry the same shape under their own event name.
1164
+ */
971
1165
  function logCommitFailed(
972
1166
  fields: { concept: string; id: string; editor: string },
973
1167
  err: unknown,
@@ -980,9 +1174,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
980
1174
  }
981
1175
  }
982
1176
 
983
- /** The shared commit catch for the entry actions: log the failure, bounce a conflict back to
1177
+ /**
1178
+ * The shared commit catch for the entry actions: log the failure, bounce a conflict back to
984
1179
  * `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
985
- * params the bounce must carry (saveAction's `&new=1`). */
1180
+ * params the bounce must carry (saveAction's `&new=1`).
1181
+ */
986
1182
  function commitFailure(
987
1183
  fields: { concept: string; id: string; editor: string },
988
1184
  err: unknown,
@@ -997,11 +1193,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
997
1193
  throw err;
998
1194
  }
999
1195
 
1000
- /** The held outcome of a validated save: everything publish needs to copy the same markdown
1196
+ /**
1197
+ * The held outcome of a validated save: everything publish needs to copy the same markdown
1001
1198
  * to main without re-reading the branch. `branchSha` is the branch commit saveToBranch just
1002
1199
  * made, the guard for the post-publish branch delete; `manifest` is main's manifest with
1003
1200
  * this entry's row upserted from the new markdown (the same last-writer-wins manifest race
1004
- * as delete and rename applies, caught by the build's fail-closed backstop). */
1201
+ * as delete and rename applies, caught by the build's fail-closed backstop).
1202
+ */
1005
1203
  interface SaveHold {
1006
1204
  path: string;
1007
1205
  markdown: string;
@@ -1011,18 +1209,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1011
1209
  /** The draft-target tokens the body links to, for save's warning query. */
1012
1210
  draftLinks: string[];
1013
1211
  token: string;
1014
- /** The merged media.json change this save committed to the branch, when media is on and the
1212
+ /**
1213
+ * The merged media.json change this save committed to the branch, when media is on and the
1015
1214
  * post carried records. Publish reuses it verbatim so the main commit promotes the exact same
1016
1215
  * merged content (decision 1: the default-branch base is read once, here, not re-merged at
1017
- * publish). Absent when media is off or no records were posted. */
1216
+ * publish). Absent when media is off or no records were posted.
1217
+ */
1018
1218
  mediaChange?: FileChange;
1019
1219
  }
1020
1220
 
1021
- /** The shared core of save and publish: parse the posted form, validate the frontmatter,
1221
+ /**
1222
+ * The shared core of save and publish: parse the posted form, validate the frontmatter,
1022
1223
  * guard the body's cairn links, ensure the pending branch, and commit the entry file there
1023
1224
  * with the session editor as author. Returns the broken-link fail for the page to render,
1024
1225
  * or the held state; throws the redirect bounces save has always thrown (invalid
1025
- * frontmatter, a branch-commit conflict). Main stays untouched. */
1226
+ * frontmatter, a branch-commit conflict). Main stays untouched.
1227
+ */
1026
1228
  async function saveToBranch(
1027
1229
  event: ContentEvent,
1028
1230
  editor: Editor,
@@ -1121,8 +1323,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1121
1323
  return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
1122
1324
  }
1123
1325
 
1124
- /** Save an edit: validate, then commit to the entry's pending branch with the session editor
1125
- * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
1326
+ /**
1327
+ * Save an edit: validate, then commit to the entry's pending branch with the session editor
1328
+ * as author. Main and its manifest stay untouched until publish. Fails safe on 409.
1329
+ */
1126
1330
  async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1127
1331
  const editor = requireSession(event);
1128
1332
  const concept = conceptOf(runtime, event.params);
@@ -1138,12 +1342,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1138
1342
  throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
1139
1343
  }
1140
1344
 
1141
- /** Publish an entry: validate and hold the posted form exactly like save (the branch gets the
1345
+ /**
1346
+ * Publish an entry: validate and hold the posted form exactly like save (the branch gets the
1142
1347
  * same commit), then copy that markdown to main with the manifest row upserted in one atomic
1143
1348
  * commit. Publish-what-you-see: the posted form is the published content, so text typed
1144
1349
  * after the last save goes live too, and publish works regardless of prior branch state.
1145
1350
  * The branch is deleted only when its head still matches the commit this action made; a
1146
- * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
1351
+ * concurrent save moved it, so the entry stays pending and the next publish picks it up.
1352
+ */
1147
1353
  async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1148
1354
  const editor = requireSession(event);
1149
1355
  const concept = conceptOf(runtime, event.params);
@@ -1162,6 +1368,24 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1162
1368
  ];
1163
1369
  if (mediaChange) changes.push(mediaChange);
1164
1370
 
1371
+ // The cross-branch address-collision re-check: warn-and-allow, last-write-wins, never a gate.
1372
+ // Resolve this entry's own address the way editLoad does and look it up in the index built from
1373
+ // the same manifest the publish carries. The read fails open: a thrown index build degrades to
1374
+ // no event and the publish proceeds, so a transient GitHub error never blocks a publish.
1375
+ let address = '';
1376
+ let collision: AddressEntry | null = null;
1377
+ try {
1378
+ const { frontmatter } = parseMarkdown(markdown);
1379
+ address = entryIdentity(concept, path, frontmatter).permalink;
1380
+ const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
1381
+ collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
1382
+ } catch (err) {
1383
+ // Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
1384
+ // proceeds. Log it so a persistently failing advisory build is diagnosable, not invisible.
1385
+ collision = null;
1386
+ log.warn('github.unreachable', { scope: 'publish-advisories', error: String(err) });
1387
+ }
1388
+
1165
1389
  const commitFields = { concept: concept.id, id, editor: editor.email };
1166
1390
  try {
1167
1391
  await commitFiles(
@@ -1171,6 +1395,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1171
1395
  token,
1172
1396
  );
1173
1397
  log.info('entry.published', { ...commitFields, batch: false });
1398
+ // Only after the publish lands: a diagnostic that a live address now has a new owner.
1399
+ if (collision) {
1400
+ log.warn('publish.address_collision', {
1401
+ editor: editor.email,
1402
+ address,
1403
+ displacedConcept: collision.concept,
1404
+ displacedId: collision.id,
1405
+ });
1406
+ }
1174
1407
  } catch (err) {
1175
1408
  // The branch already holds the just-committed edits, so a conflict here loses nothing.
1176
1409
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
@@ -1185,10 +1418,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1185
1418
  throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
1186
1419
  }
1187
1420
 
1188
- /** Publish every pending entry site-wide: one atomic commit on main carrying each branch's
1421
+ /**
1422
+ * Publish every pending entry site-wide: one atomic commit on main carrying each branch's
1189
1423
  * entry file plus the manifest with every row upserted, then delete the consumed branches.
1190
1424
  * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
1191
- * concept param is ignored and the redirect lands on the first configured concept. */
1425
+ * concept param is ignored and the redirect lands on the first configured concept.
1426
+ */
1192
1427
  async function publishAllAction(event: ContentEvent): Promise<never> {
1193
1428
  const editor = requireSession(event);
1194
1429
  const first = runtime.concepts[0];
@@ -1274,8 +1509,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1274
1509
  throw redirect(303, `${listPage}?publishedAll=${published.length}`);
1275
1510
  }
1276
1511
 
1277
- /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
1278
- * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
1512
+ /**
1513
+ * Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
1514
+ * the edit page when the entry lives on main, else to the list (the entry is gone entirely).
1515
+ */
1279
1516
  async function discardAction(event: ContentEvent): Promise<never> {
1280
1517
  const editor = requireSession(event);
1281
1518
  const concept = conceptOf(runtime, event.params);
@@ -1291,11 +1528,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1291
1528
  throw redirect(303, `/admin/${concept.id}`);
1292
1529
  }
1293
1530
 
1294
- /** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
1531
+ /**
1532
+ * The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
1295
1533
  * commit the file removal and the manifest patch in one commit. The inbound recheck here is the
1296
1534
  * authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
1297
1535
  * the list delete (id from the form body) call this with an already-validated id, so the guard is
1298
- * enforced once. */
1536
+ * enforced once.
1537
+ */
1299
1538
  async function deleteEntry(
1300
1539
  event: ContentEvent,
1301
1540
  concept: ConceptDescriptor,
@@ -1375,10 +1614,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1375
1614
  return deleteEntry(event, concept, id, editor);
1376
1615
  }
1377
1616
 
1378
- /** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
1617
+ /**
1618
+ * Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
1379
1619
  * atomic commit, so no internal link breaks. The collision check and the inbound recompute here
1380
1620
  * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
1381
- * caught by the build's fail-closed backstop. */
1621
+ * caught by the build's fail-closed backstop.
1622
+ */
1382
1623
  async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1383
1624
  const editor = requireSession(event);
1384
1625
  const concept = conceptOf(runtime, event.params);
@@ -1606,7 +1847,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1606
1847
  /** A 16-hex content-hash prefix, the immutable asset key. */
1607
1848
  const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
1608
1849
 
1609
- /** Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1850
+ /**
1851
+ * Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1610
1852
  * read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
1611
1853
  * recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
1612
1854
  * alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
@@ -1623,7 +1865,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1623
1865
  * a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
1624
1866
  * the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
1625
1867
  * hash, so a reference added in that window still resolves to bytes that may be gone, the same
1626
- * delete-races-an-edit window every safe delete carries. */
1868
+ * delete-races-an-edit window every safe delete carries.
1869
+ */
1627
1870
  async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1628
1871
  const editor = requireSession(event);
1629
1872
  const token = await mintToken(event.platform?.env ?? {});
@@ -1718,7 +1961,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1718
1961
  throw redirect(303, '/admin/media?deleted=1');
1719
1962
  }
1720
1963
 
1721
- /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1964
+ /**
1965
+ * Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1722
1966
  * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1723
1967
  * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1724
1968
  * would blow the workerd connection budget at many open branches). The fail-closed posture is for
@@ -1736,7 +1980,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1736
1980
  * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1737
1981
  * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1738
1982
  * aborts the rest of the batch. The result is an itemized 207-style summary the component renders
1739
- * (deleted / skipped with reasons / failed); there is no success redirect. */
1983
+ * (deleted / skipped with reasons / failed); there is no success redirect.
1984
+ */
1740
1985
  async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
1741
1986
  const editor = requireSession(event);
1742
1987
  const token = await mintToken(event.platform?.env ?? {});
@@ -1828,7 +2073,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1828
2073
  return { deleted, skipped: plan.skipped, failed } satisfies MediaBulkDeleteResult;
1829
2074
  }
1830
2075
 
1831
- /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
2076
+ /**
2077
+ * The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1832
2078
  * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1833
2079
  * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1834
2080
  * plus a reconcile pass on top of the strict usage build.
@@ -1842,7 +2088,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1842
2088
  *
1843
2089
  * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1844
2090
  * purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
1845
- * where-used so an operator can re-ingest rather than purge a still-referenced record). */
2091
+ * where-used so an operator can re-ingest rather than purge a still-referenced record).
2092
+ */
1846
2093
  async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
1847
2094
  requireSession(event);
1848
2095
  const token = await mintToken(event.platform?.env ?? {});
@@ -1878,7 +2125,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1878
2125
  return buildOrphanScan(reconcile, manifest, index);
1879
2126
  }
1880
2127
 
1881
- /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
2128
+ /**
2129
+ * Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1882
2130
  * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1883
2131
  * history. The whole action is built around that fact.
1884
2132
  *
@@ -1902,7 +2150,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1902
2150
  *
1903
2151
  * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1904
2152
  * the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
1905
- * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
2153
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
2154
+ */
1906
2155
  async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
1907
2156
  const editor = requireSession(event);
1908
2157
  const token = await mintToken(event.platform?.env ?? {});
@@ -1975,10 +2224,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1975
2224
  return { purged, skippedClaimed, failed } satisfies MediaOrphanPurgeResult;
1976
2225
  }
1977
2226
 
1978
- /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
2227
+ /**
2228
+ * Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1979
2229
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1980
2230
  * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
1981
- * next placement, never a propagating edit of the alt already committed in existing placements. */
2231
+ * next placement, never a propagating edit of the alt already committed in existing placements.
2232
+ */
1982
2233
  async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1983
2234
  const editor = requireSession(event);
1984
2235
  const token = await mintToken(event.platform?.env ?? {});
@@ -2017,14 +2268,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2017
2268
  throw redirect(303, '/admin/media?updated=1');
2018
2269
  }
2019
2270
 
2020
- /** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
2271
+ /**
2272
+ * Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
2021
2273
  * an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
2022
- * the hash, so a missing slug still resolves. Shared by the preview and apply token construction. */
2274
+ * the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
2275
+ */
2023
2276
  function replacementToken(slug: string, hash: string): string {
2024
2277
  return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
2025
2278
  }
2026
2279
 
2027
- /** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
2280
+ /**
2281
+ * Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
2028
2282
  * of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
2029
2283
  * each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
2030
2284
  * It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
@@ -2034,7 +2288,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2034
2288
  * the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
2035
2289
  * returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
2036
2290
  * with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
2037
- * `type`/`status` from the body, never the HTTP status. */
2291
+ * `type`/`status` from the body, never the HTTP status.
2292
+ */
2038
2293
  async function mediaReplacePreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan> {
2039
2294
  // CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
2040
2295
  // upload action. A failed check refuses before the session read or any GitHub call.
@@ -2104,7 +2359,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2104
2359
  return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
2105
2360
  }
2106
2361
 
2107
- /** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
2362
+ /**
2363
+ * Apply a replace-in-place: rewrite every published main entry that references the old asset to the
2108
2364
  * new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
2109
2365
  * re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
2110
2366
  * preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
@@ -2115,7 +2371,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2115
2371
  *
2116
2372
  * No R2 operation: the new bytes were already stored put-first by the upload action, and the old
2117
2373
  * bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
2118
- * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only. */
2374
+ * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
2375
+ */
2119
2376
  async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2120
2377
  const editor = requireSession(event);
2121
2378
  const token = await mintToken(event.platform?.env ?? {});
@@ -2216,7 +2473,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2216
2473
  throw redirect(303, '/admin/media?replaced=1');
2217
2474
  }
2218
2475
 
2219
- /** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
2476
+ /**
2477
+ * Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
2220
2478
  * asset's default alt across every published main entry that references it, bucketing each placement
2221
2479
  * (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
2222
2480
  * enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
@@ -2226,7 +2484,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2226
2484
  * Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
2227
2485
  * header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
2228
2486
  * ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
2229
- * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body. */
2487
+ * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
2488
+ */
2230
2489
  async function mediaAltPreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaAltPreviewPlan> {
2231
2490
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
2232
2491
  // replace-preview actions. A failed check refuses before the session read or any GitHub call.
@@ -2295,14 +2554,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2295
2554
  return { entries, branchDelta: plan.branchDelta, counts };
2296
2555
  }
2297
2556
 
2298
- /** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
2557
+ /**
2558
+ * Apply an alt-propagation: fill the asset's default alt into every empty placement across the
2299
2559
  * published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
2300
2560
  * commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
2301
2561
  * differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
2302
2562
  * is NO media.json change (the default alt is READ from the row, never rewritten there), and a
2303
2563
  * decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
2304
2564
  * that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
2305
- * closed on an unverifiable usage read, and writes only entry files in git (no R2 op). */
2565
+ * closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
2566
+ */
2306
2567
  async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2307
2568
  const editor = requireSession(event);
2308
2569
  const token = await mintToken(event.platform?.env ?? {});
@@ -2363,20 +2624,26 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2363
2624
  throw redirect(303, '/admin/media?altPropagated=1');
2364
2625
  }
2365
2626
 
2366
- /** The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
2627
+ /**
2628
+ * The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
2367
2629
  * this bounds an abusive input; the real authority is the per-character validation, which rejects
2368
- * whitespace and control bytes so a body can never inject an extra line into the committed file. */
2630
+ * whitespace and control bytes so a body can never inject an extra line into the committed file.
2631
+ */
2369
2632
  const MAX_DICTIONARY_WORD = 64;
2370
- /** The cap on the words a single add request carries: an editor adds a handful at save time, never
2371
- * a flood. Past this the body is treated as abusive and the surplus is dropped. */
2633
+ /**
2634
+ * The cap on the words a single add request carries: an editor adds a handful at save time, never
2635
+ * a flood. Past this the body is treated as abusive and the surplus is dropped.
2636
+ */
2372
2637
  const MAX_DICTIONARY_BATCH = 100;
2373
2638
 
2374
- /** Read the committed personal dictionary, merge the validated additions in sorted order, and commit
2639
+ /**
2640
+ * Read the committed personal dictionary, merge the validated additions in sorted order, and commit
2375
2641
  * the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
2376
2642
  * the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
2377
2643
  * word that already landed is preserved and the result is the same sorted set regardless of order.
2378
2644
  * Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
2379
- * under the commit, which the caller catches to retry once. */
2645
+ * under the commit, which the caller catches to retry once.
2646
+ */
2380
2647
  async function mergeAndCommitDictionary(token: string, additions: string[], editor: Editor): Promise<string[]> {
2381
2648
  const path = dictionaryFilePath();
2382
2649
  // The existing file as its canonical sorted set, so a no-op add is detected against the same
@@ -2396,27 +2663,33 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2396
2663
  return merged;
2397
2664
  }
2398
2665
 
2399
- /** The repo-relative site-config path the settings save reads and commits. It is the same committed
2666
+ /**
2667
+ * The repo-relative site-config path the settings save reads and commits. It is the same committed
2400
2668
  * YAML the nav editor edits, so it comes from the configured nav menu first and falls back to the
2401
- * scaffold default when no menu is configured. */
2669
+ * scaffold default when no menu is configured.
2670
+ */
2402
2671
  function siteConfigPath(): string {
2403
2672
  return runtime.navMenu?.configPath ?? DEFAULT_SITE_CONFIG_PATH;
2404
2673
  }
2405
2674
 
2406
- /** Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
2675
+ /**
2676
+ * Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
2407
2677
  * truthful visibility gate, never the key itself: the key is a Worker secret, so this only reports
2408
- * that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server. */
2678
+ * that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
2679
+ */
2409
2680
  function keyConfigured(event: ContentEvent): boolean {
2410
2681
  const env = (event.platform?.env ?? {}) as Record<string, unknown>;
2411
2682
  return typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.length > 0;
2412
2683
  }
2413
2684
 
2414
- /** Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
2685
+ /**
2686
+ * Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
2415
2687
  * read-only; the editor tier is the resolved conventions block. The visibility gate is truthful: the
2416
2688
  * `enabled` flag is true only when `tidy.enabled` is set AND the key is present, so the screen renders
2417
2689
  * the convention list only then and the honest gate note otherwise. No secret is returned: only a
2418
2690
  * presence flag for the key. The conventions come straight from the runtime config (the same source
2419
- * the tidy action's prompt reads), so the screen and the prompt can never diverge. */
2691
+ * the tidy action's prompt reads), so the screen and the prompt can never diverge.
2692
+ */
2420
2693
  function settingsLoad(event: ContentEvent): SettingsData {
2421
2694
  requireSession(event);
2422
2695
  const tidy = runtime.tidy;
@@ -2435,13 +2708,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2435
2708
  };
2436
2709
  }
2437
2710
 
2438
- /** Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
2711
+ /**
2712
+ * Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
2439
2713
  * the same committed YAML the nav editor writes, with the session editor as author. The transport is
2440
2714
  * the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
2441
2715
  * `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
2442
2716
  * block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
2443
2717
  * never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
2444
- * enabled, so the gate state's absent editor tier can never be saved past. */
2718
+ * enabled, so the gate state's absent editor tier can never be saved past.
2719
+ */
2445
2720
  async function settingsSave(event: ContentEvent): Promise<never> {
2446
2721
  const editor = requireSession(event);
2447
2722
  // The editor tier does not exist when tidy is off, so a save in that state is a 404 (no editable
@@ -2487,7 +2762,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2487
2762
  throw redirect(303, '/admin/settings?saved=1');
2488
2763
  }
2489
2764
 
2490
- /** Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
2765
+ /**
2766
+ * Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
2491
2767
  * the media raw-body actions exactly: a `text/plain` POST, the CSRF token in `X-Cairn-CSRF` validated
2492
2768
  * by validateCsrfHeader (CSRF first, then the session), and a small JSON body `{ word }` or
2493
2769
  * `{ words }`. It reads the current file from the default branch, inserts the validated words in
@@ -2502,7 +2778,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2502
2778
  * Input validation is load-bearing here: this commits to the repo from request input, so every word
2503
2779
  * is length-bounded and rejected if it carries whitespace or control characters (a word is one
2504
2780
  * line), and the batch is capped. A body that yields no valid word refuses with a 400 and commits
2505
- * nothing, so the committed file can never gain an injected or empty line. */
2781
+ * nothing, so the committed file can never gain an injected or empty line.
2782
+ */
2506
2783
  async function addDictionaryWord(event: ContentEvent): Promise<ReturnType<typeof fail> | DictionaryAddResult> {
2507
2784
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
2508
2785
  // media actions. A failed check refuses before the session read or any GitHub call.
@@ -2554,7 +2831,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2554
2831
  }
2555
2832
  }
2556
2833
 
2557
- /** Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
2834
+ /**
2835
+ * Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
2558
2836
  * the library, so this is the highest-blast-radius server action: untrusted content and the Anthropic
2559
2837
  * API key. The transport mirrors the media raw-body actions (a `text/plain` POST carrying JSON
2560
2838
  * `{ text, scope }`, the CSRF token in `X-Cairn-CSRF`, the response deserialized by the client), with
@@ -2572,7 +2850,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2572
2850
  * prompt's injection framing (Task 10) treats it as data. The API key never leaves the action: it is
2573
2851
  * not returned and not logged, and the log line carries no content. The action commits NOTHING, so a
2574
2852
  * failed, aborted, or refused tidy can never corrupt the entry; the diff is computed on the client
2575
- * (Task 12), so the server stays a thin model-call boundary. */
2853
+ * (Task 12), so the server stays a thin model-call boundary.
2854
+ */
2576
2855
  async function tidyAction(event: ContentEvent): Promise<ReturnType<typeof fail> | TidyResult> {
2577
2856
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority. A failed check refuses
2578
2857
  // before the session read and before any model call.
@@ -2669,11 +2948,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2669
2948
  return { corrected, model: message.model, usage: message.usage };
2670
2949
  }
2671
2950
 
2672
- 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 };
2951
+ 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 };
2673
2952
  }
2674
2953
 
2675
- /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
2676
- * so a generous cap rejects only abuse-scale input. */
2954
+ /**
2955
+ * The cap, in characters, on the stored alt text. The human fields are display copy, not content,
2956
+ * so a generous cap rejects only abuse-scale input.
2957
+ */
2677
2958
  const MAX_ALT = 160;
2678
2959
  /** The cap, in characters, on the stored display name. */
2679
2960
  const MAX_DISPLAY_NAME = 120;
@@ -2682,8 +2963,10 @@ const MAX_ORIGINAL_FILENAME = 120;
2682
2963
  /** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
2683
2964
  const MAX_DIMENSION = 60000;
2684
2965
 
2685
- /** Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
2686
- * so a hostile `X-Cairn-*` value cannot throw past the gate. */
2966
+ /**
2967
+ * Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
2968
+ * so a hostile `X-Cairn-*` value cannot throw past the gate.
2969
+ */
2687
2970
  function safeDecode(value: string | null): string {
2688
2971
  if (value === null) return '';
2689
2972
  try {
@@ -2693,40 +2976,52 @@ function safeDecode(value: string | null): string {
2693
2976
  }
2694
2977
  }
2695
2978
 
2696
- /** The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
2697
- * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record. */
2979
+ /**
2980
+ * The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
2981
+ * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record.
2982
+ */
2698
2983
  function basename(name: string): string {
2699
2984
  const parts = name.split(/[/\\]/);
2700
2985
  return parts[parts.length - 1];
2701
2986
  }
2702
2987
 
2703
- /** Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
2704
- * refusal lists "Published on the site" first, then the edit-branch references. */
2988
+ /**
2989
+ * Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
2990
+ * refusal lists "Published on the site" first, then the edit-branch references.
2991
+ */
2705
2992
  function originRank(entry: UsageEntry): number {
2706
2993
  return entry.origin.kind === 'published' ? 0 : 1;
2707
2994
  }
2708
2995
 
2709
- /** A where-used row's branch name for the secondary sort (the empty string for a published row,
2710
- * which sorts ahead of any branch by `originRank` already). */
2996
+ /**
2997
+ * A where-used row's branch name for the secondary sort (the empty string for a published row,
2998
+ * which sorts ahead of any branch by `originRank` already).
2999
+ */
2711
3000
  function branchKey(entry: UsageEntry): string {
2712
3001
  return entry.origin.kind === 'branch' ? entry.origin.branch : '';
2713
3002
  }
2714
3003
 
2715
- /** The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
2716
- * same entry are two rows but one distinct entry, so count by concept/id. */
3004
+ /**
3005
+ * The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
3006
+ * same entry are two rows but one distinct entry, so count by concept/id.
3007
+ */
2717
3008
  function distinctEntryCount(rows: UsageEntry[]): number {
2718
3009
  return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
2719
3010
  }
2720
3011
 
2721
- /** Strip control characters from a human field and cap it at `max` characters. Control characters
2722
- * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON. */
3012
+ /**
3013
+ * Strip control characters from a human field and cap it at `max` characters. Control characters
3014
+ * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON.
3015
+ */
2723
3016
  function sanitizeField(value: string, max: number): string {
2724
- // eslint-disable-next-line no-control-regex
3017
+
2725
3018
  return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
2726
3019
  }
2727
3020
 
2728
- /** Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
2729
- * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`). */
3021
+ /**
3022
+ * Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
3023
+ * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`).
3024
+ */
2730
3025
  function clampDimension(value: string | null): number | null {
2731
3026
  if (value === null) return null;
2732
3027
  const n = Number(value);