@glw907/cairn-cms 0.60.1 → 0.62.2

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 +78 -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 +56 -0
  39. package/dist/content/advisories.js +87 -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 +297 -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 +150 -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 +494 -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, mainAddressIndex, 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,34 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
909
1072
  inbound = inboundLinks(manifest, concept.id, id);
910
1073
  }
911
1074
 
1075
+ // The address-collision advisory: warn-and-allow, never a gate. At edit-load it checks the
1076
+ // published corpus only, built synchronously from the same manifest read above (no extra GitHub
1077
+ // read per editor open); publishAction re-checks the full cross-branch index before it lands. The
1078
+ // try/catch degrades to no notice if entryIdentity throws on a malformed-date entry. Skip the build
1079
+ // with no manifest to index.
1080
+ let advisories: AdvisoryNotice[] = [];
1081
+ if (manifest !== null) {
1082
+ try {
1083
+ const identity = entryIdentity(concept, path, parsed.frontmatter);
1084
+ const addressIndex = mainAddressIndex(manifest);
1085
+ const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
1086
+ if (other) {
1087
+ const otherConcept = findConcept(runtime.concepts, other.concept);
1088
+ const label = otherConcept ? otherConcept.label : other.concept;
1089
+ advisories = [
1090
+ {
1091
+ kind: 'address-collision',
1092
+ severity: 'warn',
1093
+ message: `Another ${label} already uses the address ${identity.permalink}. Publish this one and it replaces the other at that address.`,
1094
+ actions: [{ label: `Open ${other.title}`, href: `/admin/${other.concept}/${other.id}` }],
1095
+ },
1096
+ ];
1097
+ }
1098
+ } catch {
1099
+ // A malformed-date entry that cannot resolve its permalink degrades to no advisory, fail open.
1100
+ }
1101
+ }
1102
+
912
1103
  // Project the one committed media manifest read two ways: the minimal resolver triple the preview
913
1104
  // needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
914
1105
  // A corrupt committed file degrades both to empty, not a throw.
@@ -956,18 +1147,23 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
956
1147
  model: runtime.tidy?.model || DEFAULT_TIDY_MODEL,
957
1148
  conventions: resolveTidyConventions(runtime.tidy?.conventions),
958
1149
  },
1150
+ advisories,
959
1151
  };
960
1152
  }
961
1153
 
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`. */
1154
+ /**
1155
+ * The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
1156
+ * same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`.
1157
+ */
964
1158
  function dictionaryFilePath(): string {
965
1159
  return runtime.dictionaryPath ?? 'src/content/.cairn/dictionary.txt';
966
1160
  }
967
1161
 
968
- /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
1162
+ /**
1163
+ * Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
969
1164
  * 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. */
1165
+ * failures carry the same shape under their own event name.
1166
+ */
971
1167
  function logCommitFailed(
972
1168
  fields: { concept: string; id: string; editor: string },
973
1169
  err: unknown,
@@ -980,9 +1176,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
980
1176
  }
981
1177
  }
982
1178
 
983
- /** The shared commit catch for the entry actions: log the failure, bounce a conflict back to
1179
+ /**
1180
+ * The shared commit catch for the entry actions: log the failure, bounce a conflict back to
984
1181
  * `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`). */
1182
+ * params the bounce must carry (saveAction's `&new=1`).
1183
+ */
986
1184
  function commitFailure(
987
1185
  fields: { concept: string; id: string; editor: string },
988
1186
  err: unknown,
@@ -997,11 +1195,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
997
1195
  throw err;
998
1196
  }
999
1197
 
1000
- /** The held outcome of a validated save: everything publish needs to copy the same markdown
1198
+ /**
1199
+ * The held outcome of a validated save: everything publish needs to copy the same markdown
1001
1200
  * to main without re-reading the branch. `branchSha` is the branch commit saveToBranch just
1002
1201
  * made, the guard for the post-publish branch delete; `manifest` is main's manifest with
1003
1202
  * 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). */
1203
+ * as delete and rename applies, caught by the build's fail-closed backstop).
1204
+ */
1005
1205
  interface SaveHold {
1006
1206
  path: string;
1007
1207
  markdown: string;
@@ -1011,18 +1211,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1011
1211
  /** The draft-target tokens the body links to, for save's warning query. */
1012
1212
  draftLinks: string[];
1013
1213
  token: string;
1014
- /** The merged media.json change this save committed to the branch, when media is on and the
1214
+ /**
1215
+ * The merged media.json change this save committed to the branch, when media is on and the
1015
1216
  * post carried records. Publish reuses it verbatim so the main commit promotes the exact same
1016
1217
  * 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. */
1218
+ * publish). Absent when media is off or no records were posted.
1219
+ */
1018
1220
  mediaChange?: FileChange;
1019
1221
  }
1020
1222
 
1021
- /** The shared core of save and publish: parse the posted form, validate the frontmatter,
1223
+ /**
1224
+ * The shared core of save and publish: parse the posted form, validate the frontmatter,
1022
1225
  * guard the body's cairn links, ensure the pending branch, and commit the entry file there
1023
1226
  * with the session editor as author. Returns the broken-link fail for the page to render,
1024
1227
  * or the held state; throws the redirect bounces save has always thrown (invalid
1025
- * frontmatter, a branch-commit conflict). Main stays untouched. */
1228
+ * frontmatter, a branch-commit conflict). Main stays untouched.
1229
+ */
1026
1230
  async function saveToBranch(
1027
1231
  event: ContentEvent,
1028
1232
  editor: Editor,
@@ -1121,8 +1325,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1121
1325
  return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
1122
1326
  }
1123
1327
 
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. */
1328
+ /**
1329
+ * Save an edit: validate, then commit to the entry's pending branch with the session editor
1330
+ * as author. Main and its manifest stay untouched until publish. Fails safe on 409.
1331
+ */
1126
1332
  async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1127
1333
  const editor = requireSession(event);
1128
1334
  const concept = conceptOf(runtime, event.params);
@@ -1138,12 +1344,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1138
1344
  throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
1139
1345
  }
1140
1346
 
1141
- /** Publish an entry: validate and hold the posted form exactly like save (the branch gets the
1347
+ /**
1348
+ * Publish an entry: validate and hold the posted form exactly like save (the branch gets the
1142
1349
  * same commit), then copy that markdown to main with the manifest row upserted in one atomic
1143
1350
  * commit. Publish-what-you-see: the posted form is the published content, so text typed
1144
1351
  * after the last save goes live too, and publish works regardless of prior branch state.
1145
1352
  * 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. */
1353
+ * concurrent save moved it, so the entry stays pending and the next publish picks it up.
1354
+ */
1147
1355
  async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1148
1356
  const editor = requireSession(event);
1149
1357
  const concept = conceptOf(runtime, event.params);
@@ -1162,6 +1370,24 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1162
1370
  ];
1163
1371
  if (mediaChange) changes.push(mediaChange);
1164
1372
 
1373
+ // The cross-branch address-collision re-check: warn-and-allow, last-write-wins, never a gate.
1374
+ // Resolve this entry's own address the way editLoad does and look it up in the index built from
1375
+ // the same manifest the publish carries. The read fails open: a thrown index build degrades to
1376
+ // no event and the publish proceeds, so a transient GitHub error never blocks a publish.
1377
+ let address = '';
1378
+ let collision: AddressEntry | null = null;
1379
+ try {
1380
+ const { frontmatter } = parseMarkdown(markdown);
1381
+ address = entryIdentity(concept, path, frontmatter).permalink;
1382
+ const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
1383
+ collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
1384
+ } catch (err) {
1385
+ // Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
1386
+ // proceeds. Log it so a persistently failing advisory build is diagnosable, not invisible.
1387
+ collision = null;
1388
+ log.warn('github.unreachable', { scope: 'publish-advisories', error: String(err) });
1389
+ }
1390
+
1165
1391
  const commitFields = { concept: concept.id, id, editor: editor.email };
1166
1392
  try {
1167
1393
  await commitFiles(
@@ -1171,6 +1397,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1171
1397
  token,
1172
1398
  );
1173
1399
  log.info('entry.published', { ...commitFields, batch: false });
1400
+ // Only after the publish lands: a diagnostic that a live address now has a new owner.
1401
+ if (collision) {
1402
+ log.warn('publish.address_collision', {
1403
+ editor: editor.email,
1404
+ address,
1405
+ displacedConcept: collision.concept,
1406
+ displacedId: collision.id,
1407
+ });
1408
+ }
1174
1409
  } catch (err) {
1175
1410
  // The branch already holds the just-committed edits, so a conflict here loses nothing.
1176
1411
  commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
@@ -1185,10 +1420,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1185
1420
  throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
1186
1421
  }
1187
1422
 
1188
- /** Publish every pending entry site-wide: one atomic commit on main carrying each branch's
1423
+ /**
1424
+ * Publish every pending entry site-wide: one atomic commit on main carrying each branch's
1189
1425
  * entry file plus the manifest with every row upserted, then delete the consumed branches.
1190
1426
  * 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. */
1427
+ * concept param is ignored and the redirect lands on the first configured concept.
1428
+ */
1192
1429
  async function publishAllAction(event: ContentEvent): Promise<never> {
1193
1430
  const editor = requireSession(event);
1194
1431
  const first = runtime.concepts[0];
@@ -1274,8 +1511,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1274
1511
  throw redirect(303, `${listPage}?publishedAll=${published.length}`);
1275
1512
  }
1276
1513
 
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). */
1514
+ /**
1515
+ * Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
1516
+ * the edit page when the entry lives on main, else to the list (the entry is gone entirely).
1517
+ */
1279
1518
  async function discardAction(event: ContentEvent): Promise<never> {
1280
1519
  const editor = requireSession(event);
1281
1520
  const concept = conceptOf(runtime, event.params);
@@ -1291,11 +1530,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1291
1530
  throw redirect(303, `/admin/${concept.id}`);
1292
1531
  }
1293
1532
 
1294
- /** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
1533
+ /**
1534
+ * The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
1295
1535
  * commit the file removal and the manifest patch in one commit. The inbound recheck here is the
1296
1536
  * authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
1297
1537
  * the list delete (id from the form body) call this with an already-validated id, so the guard is
1298
- * enforced once. */
1538
+ * enforced once.
1539
+ */
1299
1540
  async function deleteEntry(
1300
1541
  event: ContentEvent,
1301
1542
  concept: ConceptDescriptor,
@@ -1375,10 +1616,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1375
1616
  return deleteEntry(event, concept, id, editor);
1376
1617
  }
1377
1618
 
1378
- /** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
1619
+ /**
1620
+ * Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
1379
1621
  * atomic commit, so no internal link breaks. The collision check and the inbound recompute here
1380
1622
  * 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. */
1623
+ * caught by the build's fail-closed backstop.
1624
+ */
1382
1625
  async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1383
1626
  const editor = requireSession(event);
1384
1627
  const concept = conceptOf(runtime, event.params);
@@ -1606,7 +1849,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1606
1849
  /** A 16-hex content-hash prefix, the immutable asset key. */
1607
1850
  const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
1608
1851
 
1609
- /** Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1852
+ /**
1853
+ * Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
1610
1854
  * read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
1611
1855
  * recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
1612
1856
  * alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
@@ -1623,7 +1867,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1623
1867
  * a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
1624
1868
  * the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
1625
1869
  * 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. */
1870
+ * delete-races-an-edit window every safe delete carries.
1871
+ */
1627
1872
  async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1628
1873
  const editor = requireSession(event);
1629
1874
  const token = await mintToken(event.platform?.env ?? {});
@@ -1718,7 +1963,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1718
1963
  throw redirect(303, '/admin/media?deleted=1');
1719
1964
  }
1720
1965
 
1721
- /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1966
+ /**
1967
+ * Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1722
1968
  * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1723
1969
  * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1724
1970
  * would blow the workerd connection budget at many open branches). The fail-closed posture is for
@@ -1736,7 +1982,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1736
1982
  * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1737
1983
  * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1738
1984
  * 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. */
1985
+ * (deleted / skipped with reasons / failed); there is no success redirect.
1986
+ */
1740
1987
  async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
1741
1988
  const editor = requireSession(event);
1742
1989
  const token = await mintToken(event.platform?.env ?? {});
@@ -1828,7 +2075,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1828
2075
  return { deleted, skipped: plan.skipped, failed } satisfies MediaBulkDeleteResult;
1829
2076
  }
1830
2077
 
1831
- /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
2078
+ /**
2079
+ * The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1832
2080
  * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1833
2081
  * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1834
2082
  * plus a reconcile pass on top of the strict usage build.
@@ -1842,7 +2090,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1842
2090
  *
1843
2091
  * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1844
2092
  * 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). */
2093
+ * where-used so an operator can re-ingest rather than purge a still-referenced record).
2094
+ */
1846
2095
  async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
1847
2096
  requireSession(event);
1848
2097
  const token = await mintToken(event.platform?.env ?? {});
@@ -1878,7 +2127,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1878
2127
  return buildOrphanScan(reconcile, manifest, index);
1879
2128
  }
1880
2129
 
1881
- /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
2130
+ /**
2131
+ * Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1882
2132
  * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1883
2133
  * history. The whole action is built around that fact.
1884
2134
  *
@@ -1902,7 +2152,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1902
2152
  *
1903
2153
  * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1904
2154
  * 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). */
2155
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
2156
+ */
1906
2157
  async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
1907
2158
  const editor = requireSession(event);
1908
2159
  const token = await mintToken(event.platform?.env ?? {});
@@ -1975,10 +2226,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1975
2226
  return { purged, skippedClaimed, failed } satisfies MediaOrphanPurgeResult;
1976
2227
  }
1977
2228
 
1978
- /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
2229
+ /**
2230
+ * Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1979
2231
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1980
2232
  * 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. */
2233
+ * next placement, never a propagating edit of the alt already committed in existing placements.
2234
+ */
1982
2235
  async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
1983
2236
  const editor = requireSession(event);
1984
2237
  const token = await mintToken(event.platform?.env ?? {});
@@ -2017,14 +2270,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2017
2270
  throw redirect(303, '/admin/media?updated=1');
2018
2271
  }
2019
2272
 
2020
- /** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
2273
+ /**
2274
+ * Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
2021
2275
  * 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. */
2276
+ * the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
2277
+ */
2023
2278
  function replacementToken(slug: string, hash: string): string {
2024
2279
  return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
2025
2280
  }
2026
2281
 
2027
- /** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
2282
+ /**
2283
+ * Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
2028
2284
  * of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
2029
2285
  * each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
2030
2286
  * It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
@@ -2034,7 +2290,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2034
2290
  * the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
2035
2291
  * returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
2036
2292
  * 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. */
2293
+ * `type`/`status` from the body, never the HTTP status.
2294
+ */
2038
2295
  async function mediaReplacePreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan> {
2039
2296
  // CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
2040
2297
  // upload action. A failed check refuses before the session read or any GitHub call.
@@ -2104,7 +2361,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2104
2361
  return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
2105
2362
  }
2106
2363
 
2107
- /** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
2364
+ /**
2365
+ * Apply a replace-in-place: rewrite every published main entry that references the old asset to the
2108
2366
  * new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
2109
2367
  * re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
2110
2368
  * preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
@@ -2115,7 +2373,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2115
2373
  *
2116
2374
  * No R2 operation: the new bytes were already stored put-first by the upload action, and the old
2117
2375
  * 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. */
2376
+ * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
2377
+ */
2119
2378
  async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2120
2379
  const editor = requireSession(event);
2121
2380
  const token = await mintToken(event.platform?.env ?? {});
@@ -2216,7 +2475,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2216
2475
  throw redirect(303, '/admin/media?replaced=1');
2217
2476
  }
2218
2477
 
2219
- /** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
2478
+ /**
2479
+ * Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
2220
2480
  * asset's default alt across every published main entry that references it, bucketing each placement
2221
2481
  * (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
2222
2482
  * enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
@@ -2226,7 +2486,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2226
2486
  * Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
2227
2487
  * header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
2228
2488
  * 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. */
2489
+ * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
2490
+ */
2230
2491
  async function mediaAltPreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaAltPreviewPlan> {
2231
2492
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
2232
2493
  // replace-preview actions. A failed check refuses before the session read or any GitHub call.
@@ -2295,14 +2556,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2295
2556
  return { entries, branchDelta: plan.branchDelta, counts };
2296
2557
  }
2297
2558
 
2298
- /** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
2559
+ /**
2560
+ * Apply an alt-propagation: fill the asset's default alt into every empty placement across the
2299
2561
  * published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
2300
2562
  * commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
2301
2563
  * differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
2302
2564
  * is NO media.json change (the default alt is READ from the row, never rewritten there), and a
2303
2565
  * decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
2304
2566
  * 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). */
2567
+ * closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
2568
+ */
2306
2569
  async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
2307
2570
  const editor = requireSession(event);
2308
2571
  const token = await mintToken(event.platform?.env ?? {});
@@ -2363,20 +2626,26 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2363
2626
  throw redirect(303, '/admin/media?altPropagated=1');
2364
2627
  }
2365
2628
 
2366
- /** The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
2629
+ /**
2630
+ * The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
2367
2631
  * 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. */
2632
+ * whitespace and control bytes so a body can never inject an extra line into the committed file.
2633
+ */
2369
2634
  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. */
2635
+ /**
2636
+ * The cap on the words a single add request carries: an editor adds a handful at save time, never
2637
+ * a flood. Past this the body is treated as abusive and the surplus is dropped.
2638
+ */
2372
2639
  const MAX_DICTIONARY_BATCH = 100;
2373
2640
 
2374
- /** Read the committed personal dictionary, merge the validated additions in sorted order, and commit
2641
+ /**
2642
+ * Read the committed personal dictionary, merge the validated additions in sorted order, and commit
2375
2643
  * the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
2376
2644
  * the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
2377
2645
  * word that already landed is preserved and the result is the same sorted set regardless of order.
2378
2646
  * Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
2379
- * under the commit, which the caller catches to retry once. */
2647
+ * under the commit, which the caller catches to retry once.
2648
+ */
2380
2649
  async function mergeAndCommitDictionary(token: string, additions: string[], editor: Editor): Promise<string[]> {
2381
2650
  const path = dictionaryFilePath();
2382
2651
  // The existing file as its canonical sorted set, so a no-op add is detected against the same
@@ -2396,27 +2665,33 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2396
2665
  return merged;
2397
2666
  }
2398
2667
 
2399
- /** The repo-relative site-config path the settings save reads and commits. It is the same committed
2668
+ /**
2669
+ * The repo-relative site-config path the settings save reads and commits. It is the same committed
2400
2670
  * 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. */
2671
+ * scaffold default when no menu is configured.
2672
+ */
2402
2673
  function siteConfigPath(): string {
2403
2674
  return runtime.navMenu?.configPath ?? DEFAULT_SITE_CONFIG_PATH;
2404
2675
  }
2405
2676
 
2406
- /** Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
2677
+ /**
2678
+ * Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
2407
2679
  * 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. */
2680
+ * that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
2681
+ */
2409
2682
  function keyConfigured(event: ContentEvent): boolean {
2410
2683
  const env = (event.platform?.env ?? {}) as Record<string, unknown>;
2411
2684
  return typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.length > 0;
2412
2685
  }
2413
2686
 
2414
- /** Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
2687
+ /**
2688
+ * Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
2415
2689
  * read-only; the editor tier is the resolved conventions block. The visibility gate is truthful: the
2416
2690
  * `enabled` flag is true only when `tidy.enabled` is set AND the key is present, so the screen renders
2417
2691
  * the convention list only then and the honest gate note otherwise. No secret is returned: only a
2418
2692
  * 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. */
2693
+ * the tidy action's prompt reads), so the screen and the prompt can never diverge.
2694
+ */
2420
2695
  function settingsLoad(event: ContentEvent): SettingsData {
2421
2696
  requireSession(event);
2422
2697
  const tidy = runtime.tidy;
@@ -2435,13 +2710,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2435
2710
  };
2436
2711
  }
2437
2712
 
2438
- /** Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
2713
+ /**
2714
+ * Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
2439
2715
  * the same committed YAML the nav editor writes, with the session editor as author. The transport is
2440
2716
  * the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
2441
2717
  * `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
2442
2718
  * block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
2443
2719
  * 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. */
2720
+ * enabled, so the gate state's absent editor tier can never be saved past.
2721
+ */
2445
2722
  async function settingsSave(event: ContentEvent): Promise<never> {
2446
2723
  const editor = requireSession(event);
2447
2724
  // The editor tier does not exist when tidy is off, so a save in that state is a 404 (no editable
@@ -2487,7 +2764,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2487
2764
  throw redirect(303, '/admin/settings?saved=1');
2488
2765
  }
2489
2766
 
2490
- /** Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
2767
+ /**
2768
+ * Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
2491
2769
  * the media raw-body actions exactly: a `text/plain` POST, the CSRF token in `X-Cairn-CSRF` validated
2492
2770
  * by validateCsrfHeader (CSRF first, then the session), and a small JSON body `{ word }` or
2493
2771
  * `{ words }`. It reads the current file from the default branch, inserts the validated words in
@@ -2502,7 +2780,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2502
2780
  * Input validation is load-bearing here: this commits to the repo from request input, so every word
2503
2781
  * is length-bounded and rejected if it carries whitespace or control characters (a word is one
2504
2782
  * 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. */
2783
+ * nothing, so the committed file can never gain an injected or empty line.
2784
+ */
2506
2785
  async function addDictionaryWord(event: ContentEvent): Promise<ReturnType<typeof fail> | DictionaryAddResult> {
2507
2786
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
2508
2787
  // media actions. A failed check refuses before the session read or any GitHub call.
@@ -2554,7 +2833,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2554
2833
  }
2555
2834
  }
2556
2835
 
2557
- /** Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
2836
+ /**
2837
+ * Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
2558
2838
  * the library, so this is the highest-blast-radius server action: untrusted content and the Anthropic
2559
2839
  * API key. The transport mirrors the media raw-body actions (a `text/plain` POST carrying JSON
2560
2840
  * `{ text, scope }`, the CSRF token in `X-Cairn-CSRF`, the response deserialized by the client), with
@@ -2572,7 +2852,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2572
2852
  * prompt's injection framing (Task 10) treats it as data. The API key never leaves the action: it is
2573
2853
  * not returned and not logged, and the log line carries no content. The action commits NOTHING, so a
2574
2854
  * 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. */
2855
+ * (Task 12), so the server stays a thin model-call boundary.
2856
+ */
2576
2857
  async function tidyAction(event: ContentEvent): Promise<ReturnType<typeof fail> | TidyResult> {
2577
2858
  // CSRF first: a raw-body (JSON) POST, so the header witness is the authority. A failed check refuses
2578
2859
  // before the session read and before any model call.
@@ -2669,11 +2950,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
2669
2950
  return { corrected, model: message.model, usage: message.usage };
2670
2951
  }
2671
2952
 
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 };
2953
+ 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
2954
  }
2674
2955
 
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. */
2956
+ /**
2957
+ * The cap, in characters, on the stored alt text. The human fields are display copy, not content,
2958
+ * so a generous cap rejects only abuse-scale input.
2959
+ */
2677
2960
  const MAX_ALT = 160;
2678
2961
  /** The cap, in characters, on the stored display name. */
2679
2962
  const MAX_DISPLAY_NAME = 120;
@@ -2682,8 +2965,10 @@ const MAX_ORIGINAL_FILENAME = 120;
2682
2965
  /** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
2683
2966
  const MAX_DIMENSION = 60000;
2684
2967
 
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. */
2968
+ /**
2969
+ * Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
2970
+ * so a hostile `X-Cairn-*` value cannot throw past the gate.
2971
+ */
2687
2972
  function safeDecode(value: string | null): string {
2688
2973
  if (value === null) return '';
2689
2974
  try {
@@ -2693,40 +2978,52 @@ function safeDecode(value: string | null): string {
2693
2978
  }
2694
2979
  }
2695
2980
 
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. */
2981
+ /**
2982
+ * The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
2983
+ * of `../../evil.png` yields `evil.png`, so no path component reaches the stored record.
2984
+ */
2698
2985
  function basename(name: string): string {
2699
2986
  const parts = name.split(/[/\\]/);
2700
2987
  return parts[parts.length - 1];
2701
2988
  }
2702
2989
 
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. */
2990
+ /**
2991
+ * Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
2992
+ * refusal lists "Published on the site" first, then the edit-branch references.
2993
+ */
2705
2994
  function originRank(entry: UsageEntry): number {
2706
2995
  return entry.origin.kind === 'published' ? 0 : 1;
2707
2996
  }
2708
2997
 
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). */
2998
+ /**
2999
+ * A where-used row's branch name for the secondary sort (the empty string for a published row,
3000
+ * which sorts ahead of any branch by `originRank` already).
3001
+ */
2711
3002
  function branchKey(entry: UsageEntry): string {
2712
3003
  return entry.origin.kind === 'branch' ? entry.origin.branch : '';
2713
3004
  }
2714
3005
 
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. */
3006
+ /**
3007
+ * The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
3008
+ * same entry are two rows but one distinct entry, so count by concept/id.
3009
+ */
2717
3010
  function distinctEntryCount(rows: UsageEntry[]): number {
2718
3011
  return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
2719
3012
  }
2720
3013
 
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. */
3014
+ /**
3015
+ * Strip control characters from a human field and cap it at `max` characters. Control characters
3016
+ * (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON.
3017
+ */
2723
3018
  function sanitizeField(value: string, max: number): string {
2724
- // eslint-disable-next-line no-control-regex
3019
+
2725
3020
  return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
2726
3021
  }
2727
3022
 
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`). */
3023
+ /**
3024
+ * Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
3025
+ * absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`).
3026
+ */
2730
3027
  function clampDimension(value: string | null): number | null {
2731
3028
  if (value === null) return null;
2732
3029
  const n = Number(value);