@cogenta/schema 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/dist/define-collection.d.ts.map +1 -1
  2. package/dist/define-collection.js +53 -3
  3. package/dist/define-collection.js.map +1 -1
  4. package/dist/generate-types.d.ts.map +1 -1
  5. package/dist/generate-types.js +6 -1
  6. package/dist/generate-types.js.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/media-usage.d.ts +56 -0
  12. package/dist/media-usage.d.ts.map +1 -0
  13. package/dist/media-usage.js +89 -0
  14. package/dist/media-usage.js.map +1 -0
  15. package/dist/rich-text.d.ts +28 -2
  16. package/dist/rich-text.d.ts.map +1 -1
  17. package/dist/rich-text.js +26 -3
  18. package/dist/rich-text.js.map +1 -1
  19. package/dist/routing/index.d.ts +5 -1
  20. package/dist/routing/index.d.ts.map +1 -1
  21. package/dist/routing/index.js +2 -0
  22. package/dist/routing/index.js.map +1 -1
  23. package/dist/routing/not-found-log.d.ts +78 -0
  24. package/dist/routing/not-found-log.d.ts.map +1 -0
  25. package/dist/routing/not-found-log.js +153 -0
  26. package/dist/routing/not-found-log.js.map +1 -0
  27. package/dist/routing/redirect-patterns.d.ts +61 -0
  28. package/dist/routing/redirect-patterns.d.ts.map +1 -0
  29. package/dist/routing/redirect-patterns.js +173 -0
  30. package/dist/routing/redirect-patterns.js.map +1 -0
  31. package/dist/routing/redirects.d.ts +27 -4
  32. package/dist/routing/redirects.d.ts.map +1 -1
  33. package/dist/routing/redirects.js +160 -48
  34. package/dist/routing/redirects.js.map +1 -1
  35. package/dist/scheduling/index.d.ts +2 -0
  36. package/dist/scheduling/index.d.ts.map +1 -1
  37. package/dist/scheduling/index.js +1 -0
  38. package/dist/scheduling/index.js.map +1 -1
  39. package/dist/scheduling/registry.d.ts +147 -0
  40. package/dist/scheduling/registry.d.ts.map +1 -0
  41. package/dist/scheduling/registry.js +318 -0
  42. package/dist/scheduling/registry.js.map +1 -0
  43. package/dist/search/extract.d.ts +24 -0
  44. package/dist/search/extract.d.ts.map +1 -1
  45. package/dist/search/extract.js +63 -8
  46. package/dist/search/extract.js.map +1 -1
  47. package/dist/search/index.d.ts +2 -1
  48. package/dist/search/index.d.ts.map +1 -1
  49. package/dist/search/index.js +1 -9
  50. package/dist/search/index.js.map +1 -1
  51. package/dist/store/admin-theme-store.d.ts +26 -0
  52. package/dist/store/admin-theme-store.d.ts.map +1 -0
  53. package/dist/store/admin-theme-store.js +81 -0
  54. package/dist/store/admin-theme-store.js.map +1 -0
  55. package/dist/store/admin-theme-tables.d.ts +24 -0
  56. package/dist/store/admin-theme-tables.d.ts.map +1 -0
  57. package/dist/store/admin-theme-tables.js +41 -0
  58. package/dist/store/admin-theme-tables.js.map +1 -0
  59. package/dist/store/admin-theme-templates.d.ts +135 -0
  60. package/dist/store/admin-theme-templates.d.ts.map +1 -0
  61. package/dist/store/admin-theme-templates.js +217 -0
  62. package/dist/store/admin-theme-templates.js.map +1 -0
  63. package/dist/store/columns.d.ts.map +1 -1
  64. package/dist/store/columns.js +8 -2
  65. package/dist/store/columns.js.map +1 -1
  66. package/dist/store/diff.d.ts +40 -0
  67. package/dist/store/diff.d.ts.map +1 -1
  68. package/dist/store/diff.js +148 -0
  69. package/dist/store/diff.js.map +1 -1
  70. package/dist/store/index.d.ts +47 -4
  71. package/dist/store/index.d.ts.map +1 -1
  72. package/dist/store/index.js +26 -2
  73. package/dist/store/index.js.map +1 -1
  74. package/dist/store/maintenance-store.d.ts +34 -0
  75. package/dist/store/maintenance-store.d.ts.map +1 -0
  76. package/dist/store/maintenance-store.js +92 -0
  77. package/dist/store/maintenance-store.js.map +1 -0
  78. package/dist/store/menu-store.d.ts +146 -0
  79. package/dist/store/menu-store.d.ts.map +1 -0
  80. package/dist/store/menu-store.js +565 -0
  81. package/dist/store/menu-store.js.map +1 -0
  82. package/dist/store/menu-tables.d.ts +38 -0
  83. package/dist/store/menu-tables.d.ts.map +1 -0
  84. package/dist/store/menu-tables.js +117 -0
  85. package/dist/store/menu-tables.js.map +1 -0
  86. package/dist/store/naming.d.ts +1 -1
  87. package/dist/store/naming.d.ts.map +1 -1
  88. package/dist/store/naming.js +2 -0
  89. package/dist/store/naming.js.map +1 -1
  90. package/dist/store/pattern-store.d.ts +75 -0
  91. package/dist/store/pattern-store.d.ts.map +1 -0
  92. package/dist/store/pattern-store.js +118 -0
  93. package/dist/store/pattern-store.js.map +1 -0
  94. package/dist/store/pattern-tables.d.ts +29 -0
  95. package/dist/store/pattern-tables.d.ts.map +1 -0
  96. package/dist/store/pattern-tables.js +53 -0
  97. package/dist/store/pattern-tables.js.map +1 -0
  98. package/dist/store/read-only.d.ts +3 -3
  99. package/dist/store/read-only.d.ts.map +1 -1
  100. package/dist/store/read-only.js +4 -3
  101. package/dist/store/read-only.js.map +1 -1
  102. package/dist/store/redirect-tracking.d.ts +50 -0
  103. package/dist/store/redirect-tracking.d.ts.map +1 -0
  104. package/dist/store/redirect-tracking.js +91 -0
  105. package/dist/store/redirect-tracking.js.map +1 -0
  106. package/dist/store/review-transitions.d.ts +34 -0
  107. package/dist/store/review-transitions.d.ts.map +1 -0
  108. package/dist/store/review-transitions.js +51 -0
  109. package/dist/store/review-transitions.js.map +1 -0
  110. package/dist/store/role-permission-export.d.ts +22 -0
  111. package/dist/store/role-permission-export.d.ts.map +1 -0
  112. package/dist/store/role-permission-export.js +0 -0
  113. package/dist/store/role-permission-export.js.map +1 -0
  114. package/dist/store/role-permission-overlay.d.ts +27 -0
  115. package/dist/store/role-permission-overlay.d.ts.map +1 -0
  116. package/dist/store/role-permission-overlay.js +0 -0
  117. package/dist/store/role-permission-overlay.js.map +1 -0
  118. package/dist/store/role-permission-store.d.ts +72 -0
  119. package/dist/store/role-permission-store.d.ts.map +1 -0
  120. package/dist/store/role-permission-store.js +148 -0
  121. package/dist/store/role-permission-store.js.map +1 -0
  122. package/dist/store/role-permission-tables.d.ts +27 -0
  123. package/dist/store/role-permission-tables.d.ts.map +1 -0
  124. package/dist/store/role-permission-tables.js +52 -0
  125. package/dist/store/role-permission-tables.js.map +1 -0
  126. package/dist/store/scheduled-publish-enqueue.d.ts +37 -0
  127. package/dist/store/scheduled-publish-enqueue.d.ts.map +1 -0
  128. package/dist/store/scheduled-publish-enqueue.js +51 -0
  129. package/dist/store/scheduled-publish-enqueue.js.map +1 -0
  130. package/dist/store/scheduled-publish-failures.d.ts +36 -0
  131. package/dist/store/scheduled-publish-failures.d.ts.map +1 -0
  132. package/dist/store/scheduled-publish-failures.js +67 -0
  133. package/dist/store/scheduled-publish-failures.js.map +1 -0
  134. package/dist/store/schema-2-1-migration.d.ts +29 -0
  135. package/dist/store/schema-2-1-migration.d.ts.map +1 -0
  136. package/dist/store/schema-2-1-migration.js +71 -0
  137. package/dist/store/schema-2-1-migration.js.map +1 -0
  138. package/dist/store/search-console-store.d.ts +44 -0
  139. package/dist/store/search-console-store.d.ts.map +1 -0
  140. package/dist/store/search-console-store.js +90 -0
  141. package/dist/store/search-console-store.js.map +1 -0
  142. package/dist/store/search-console-tables.d.ts +14 -0
  143. package/dist/store/search-console-tables.d.ts.map +1 -0
  144. package/dist/store/search-console-tables.js +29 -0
  145. package/dist/store/search-console-tables.js.map +1 -0
  146. package/dist/store/search-indexing.d.ts +36 -0
  147. package/dist/store/search-indexing.d.ts.map +1 -1
  148. package/dist/store/search-indexing.js +33 -0
  149. package/dist/store/search-indexing.js.map +1 -1
  150. package/dist/store/site-settings-registry.d.ts +164 -0
  151. package/dist/store/site-settings-registry.d.ts.map +1 -0
  152. package/dist/store/site-settings-registry.js +1160 -0
  153. package/dist/store/site-settings-registry.js.map +1 -0
  154. package/dist/store/site-settings-store.d.ts +42 -0
  155. package/dist/store/site-settings-store.d.ts.map +1 -0
  156. package/dist/store/site-settings-store.js +97 -0
  157. package/dist/store/site-settings-store.js.map +1 -0
  158. package/dist/store/site-settings-tables.d.ts +29 -0
  159. package/dist/store/site-settings-tables.d.ts.map +1 -0
  160. package/dist/store/site-settings-tables.js +48 -0
  161. package/dist/store/site-settings-tables.js.map +1 -0
  162. package/dist/store/store.d.ts +57 -2
  163. package/dist/store/store.d.ts.map +1 -1
  164. package/dist/store/store.js +278 -37
  165. package/dist/store/store.js.map +1 -1
  166. package/dist/store/tables.d.ts.map +1 -1
  167. package/dist/store/tables.js +11 -0
  168. package/dist/store/tables.js.map +1 -1
  169. package/dist/store/taxonomy-store.d.ts.map +1 -1
  170. package/dist/store/taxonomy-store.js +90 -39
  171. package/dist/store/taxonomy-store.js.map +1 -1
  172. package/dist/store/taxonomy-usage.d.ts +47 -0
  173. package/dist/store/taxonomy-usage.d.ts.map +1 -0
  174. package/dist/store/taxonomy-usage.js +101 -0
  175. package/dist/store/taxonomy-usage.js.map +1 -0
  176. package/dist/store/theme-store.d.ts +73 -0
  177. package/dist/store/theme-store.d.ts.map +1 -0
  178. package/dist/store/theme-store.js +193 -0
  179. package/dist/store/theme-store.js.map +1 -0
  180. package/dist/store/types.d.ts +36 -1
  181. package/dist/store/types.d.ts.map +1 -1
  182. package/dist/store/values.d.ts.map +1 -1
  183. package/dist/store/values.js +28 -1
  184. package/dist/store/values.js.map +1 -1
  185. package/dist/system-fields.d.ts +8 -1
  186. package/dist/system-fields.d.ts.map +1 -1
  187. package/dist/system-fields.js +14 -1
  188. package/dist/system-fields.js.map +1 -1
  189. package/dist/types.d.ts +54 -1
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/types.js +21 -0
  192. package/dist/types.js.map +1 -1
  193. package/package.json +2 -2
@@ -0,0 +1,1160 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The declarative registry fiche 23 task 1 asks for: "un magasin clé/valeur
4
+ * typé, avec un schéma Zod déclarant chaque réglage : clé, type, valeur par
5
+ * défaut, portée (site ou locale), permission requise. Un réglage inconnu est
6
+ * refusé — pas un dépotoir de clés libres."
7
+ *
8
+ * Adding a setting is one entry here — `SiteSettingsStore` validates,
9
+ * defaults and scopes every key generically from this list (never a special
10
+ * case per key), and the admin's settings screen renders a field from each
11
+ * entry's `group`/`order`/`uiType` the same way (`site-settings-field.tsx`).
12
+ * That is what makes the fiche's own acceptance test true: "ajouter un
13
+ * réglage nouveau = une ligne de déclaration, et il apparaît à l'écran sans
14
+ * code d'interface supplémentaire."
15
+ *
16
+ * Every key here is **editorial** (ADR-0025's third category) — a rédacteur
17
+ * changes it from the admin, and it never duplicates a field
18
+ * `cogenta.config.mjs` already owns. `general.defaultLocale` and
19
+ * `reading.notFoundPath`, for instance, stay in the config file on purpose
20
+ * (section 8 of the fiche recommends migrating nothing that already exists);
21
+ * the settings screen shows them too, but read-only, mirrored from
22
+ * `SchemaDocument`/`Site.site` rather than declared here.
23
+ */
24
+ /** How the admin renders a value — never a free-form widget choice per key. */
25
+ export const SITE_SETTING_UI_TYPES = [
26
+ 'string',
27
+ 'text',
28
+ 'email',
29
+ 'boolean',
30
+ 'path',
31
+ 'number',
32
+ 'timeZone',
33
+ 'dateStyle',
34
+ 'timeStyle',
35
+ /**
36
+ * A closed choice, rendered as a `<select>`. Fiche 34 task 4's first user:
37
+ * `commerce.priceDisplay` (`ttc`/`ht`) — free text would let an operator
38
+ * type a third value the totals engine does not know what to do with.
39
+ */
40
+ 'select',
41
+ /**
42
+ * A single optional media reference, rendered as `MediaPicker` (fiche L21
43
+ * task 8's first and only user: `branding.customLogoMediaId`). The value
44
+ * stored is a media id, or `''` for "unset" — a plain string, like every
45
+ * other value this registry holds; nothing here talks to `MediaStore`
46
+ * directly, exactly the way a `path` setting names a route without
47
+ * checking the route exists.
48
+ */
49
+ 'media',
50
+ /**
51
+ * A short ordered list of `{label, url}` pairs (L25 D2's first and only
52
+ * user: `general.socialLinks`), rendered as a textarea of one
53
+ * `Label | https://url` line per entry, converted to/from JSON by the
54
+ * field component itself — the same "closest shape in the closed set"
55
+ * reasoning `seo.collectionTitleTemplates` already documents for a `text`
56
+ * bypass, except this one *is* a real, generic editor (a JSON object map
57
+ * has no natural line-per-entry form; an ordered list of two short strings
58
+ * does) rather than a placeholder for a bespoke screen.
59
+ */
60
+ 'linkList',
61
+ ];
62
+ export const SITE_SETTING_GROUPS = [
63
+ 'general',
64
+ 'reading',
65
+ 'discussion',
66
+ 'media',
67
+ 'privacy',
68
+ /**
69
+ * The shop-wide settings of fiche 34 task 4 — currency, tax-inclusive vs.
70
+ * exclusive display, countries served, minimum order, default backorder
71
+ * policy, and the invoice mentions of task 5. `commerce.tosPagePath` and
72
+ * `commerce.returnPolicyPagePath` are, deliberately, **paths** here —
73
+ * exactly the way `reading.homePath`/`privacy.policyPath` already point at
74
+ * a real content entry rather than storing legal text as a settings field
75
+ * (fiche 34 § pièges: "les CGV doivent être une page publique").
76
+ */
77
+ 'commerce',
78
+ /**
79
+ * Cogenta's own credit and white-label override (fiche L21 task 8) —
80
+ * whether the public footer and the admin shell name Cogenta at all, and
81
+ * what replaces it when they don't. Editorial on purpose: a reseller
82
+ * turns this off from the admin the same way any other setting here
83
+ * changes, with no redeploy and no code touched.
84
+ */
85
+ 'branding',
86
+ /**
87
+ * Search-engine visibility (fiche 21 task 3): title/description templates,
88
+ * per-collection sitemap hints, and social-card defaults. Previously
89
+ * `seo.tsx` was read-only "by design" — that was a scope choice of a
90
+ * previous lot, never an ADR, and this group is what makes it editable
91
+ * without inventing a second settings store: exactly the same registry
92
+ * that already backs `SettingsRoute`, just with its own screen
93
+ * (`SeoRoute`) rather than a tab there, the same way `commerce` gets its
94
+ * own screen instead of a `SettingsRoute` tab.
95
+ */
96
+ 'seo',
97
+ /**
98
+ * OpenTelemetry tracing and structured-log collection (fiche L22 task 5).
99
+ * Editorial on purpose, unlike the OTLP export destination (which stays in
100
+ * `cogenta.config.mjs`'s `observability` section, because it can carry a
101
+ * bearer-token header — rule R7): whether the local recent-events buffer
102
+ * runs at all, and how verbose it is, are exactly the kind of switch an
103
+ * admin should be able to flip from the "Exploitation" screen with no
104
+ * redeploy, the same way `discussion.enabled` already works.
105
+ */
106
+ 'observability',
107
+ /**
108
+ * L22 task 4 — which collections the writing assistant's vector index
109
+ * actually contains. Its own screen (the redesigned `AssistantRoute`),
110
+ * not a `SettingsRoute` tab, for the same reason `commerce`/`seo` get
111
+ * their own: the one key this group holds (`assistant.indexedCollections`)
112
+ * is a per-collection map, not a single scalar a generic form field can
113
+ * render — exactly `seo.sitemapCollectionSettings`'s own shape.
114
+ */
115
+ 'assistant',
116
+ /**
117
+ * The update system (L22 task 9): whether `cogenta serve`'s own
118
+ * `updates-auto-check` scheduled task may apply an npm update on its own,
119
+ * and how far it may go. Lives here, not in `cogenta.config.mjs`, for the
120
+ * same reason `discussion.moderationRequired` does — it is a live
121
+ * operational choice an admin flips from the screen at 3am because a
122
+ * package just misbehaved, not something that should need a git commit
123
+ * and a redeploy to change. Read by the scheduled task on every tick
124
+ * (`updates.ts` in `cogenta serve`'s wiring), never cached at startup.
125
+ */
126
+ 'updates',
127
+ /**
128
+ * Admin nav reordering/hiding (fiche 22 tâche 8, part 3) — which sidebar
129
+ * sections and entries a signed-in actor sees, and in what order.
130
+ * Deliberately **site-wide**, not per-browser: `sidebarCollapsed` and
131
+ * `groupOpen` (`app-shell.tsx`) stay in `localStorage` on purpose (each
132
+ * person's own "collapsed today" is not something anyone else needs to
133
+ * see), but "this site sells nothing, so hide Boutique for everyone" is an
134
+ * editorial decision about the site, no different from every other setting
135
+ * in this registry — it has to survive a different browser, a different
136
+ * person signing in, and a fresh install pulling the same database.
137
+ */
138
+ 'navigation',
139
+ /**
140
+ * Fiche 59 — the one non-secret, operator-set fact a channel's step-by-step
141
+ * guide needs to name the bot by handle instead of "the Cogenta bot": each
142
+ * channel's bot display name/handle, free text, never a credential. Bot
143
+ * *tokens* stay environment-only (R7) and never enter this table — this
144
+ * group exists specifically so a guide can say "open a chat with
145
+ * @my_cogenta_bot" without ever storing anything a leaked table could turn
146
+ * into a working credential.
147
+ */
148
+ 'channels',
149
+ ];
150
+ export const SITE_SETTING_SCOPES = ['site', 'locale'];
151
+ const nonEmpty200 = z.string().max(200);
152
+ const freeText = z.string().max(4000);
153
+ const emailOrEmpty = z.union([z.literal(''), z.email()]);
154
+ const pathOrEmpty = z
155
+ .string()
156
+ .max(200)
157
+ .refine((value) => value === '' || value.startsWith('/'), {
158
+ error: 'A path must be empty (unset) or start with "/".',
159
+ });
160
+ /** A path or an absolute URL, or empty — `seo.defaultSocialImageUrl`'s own shape (fiche 21 task 3). */
161
+ const urlOrPathOrEmpty = z
162
+ .string()
163
+ .max(500)
164
+ .refine((value) => value === '' || value.startsWith('/') || /^https?:\/\//u.test(value), {
165
+ error: 'Must be empty, a site-relative path starting with "/", or an absolute http(s) URL.',
166
+ });
167
+ /**
168
+ * `%title%`/`%site%` composition (`@cogenta/seo`'s `applyTitleTemplate`).
169
+ * Required to actually contain `%title%` when set — a template that drops it
170
+ * silently replaces every title on the site with the same static string,
171
+ * which is never what "add a suffix" meant.
172
+ */
173
+ const titleTemplateOrEmpty = z
174
+ .string()
175
+ .max(200)
176
+ .refine((value) => value === '' || value.includes('%title%'), {
177
+ error: 'A title template must include the %title% token, or be left empty.',
178
+ });
179
+ /** `seo.collectionTitleTemplates` — `titleTemplateOrEmpty`, one per collection name (fiche 21 task 3). */
180
+ const collectionTitleTemplates = z.record(z.string(), titleTemplateOrEmpty);
181
+ const metaDescriptionOrEmpty = z.string().max(500);
182
+ /**
183
+ * A Google/Bing site-verification token, or empty (fiche 50 task 2). Neither
184
+ * provider documents a fixed format — Google's runs to roughly 44 base64-ish
185
+ * characters, Bing's is a GUID — so this only rules out whitespace and quote
186
+ * characters rather than pinning a shape that would break the moment either
187
+ * provider changes its own generator. The value is rendered into a
188
+ * `<meta content="…">` attribute, HTML-escaped there (`escapeHtmlAttribute`)
189
+ * exactly like every other user-supplied attribute this codebase emits, so
190
+ * this schema's job is plausibility, not the actual injection guard.
191
+ */
192
+ const siteVerificationTokenOrEmpty = z
193
+ .string()
194
+ .max(255)
195
+ .regex(/^[^\s<>"']*$/u, {
196
+ error: 'Must be empty, or the verification token as the provider gave it — no spaces or quotes.',
197
+ });
198
+ /**
199
+ * IndexNow's own key format (fiche 50 task 3) — mirrors `@cogenta/seo`'s
200
+ * `KEY_PATTERN` (`indexnow.ts`) verbatim. Duplicated rather than imported:
201
+ * `@cogenta/schema` cannot depend on `@cogenta/seo`, which depends on it the
202
+ * other way round.
203
+ */
204
+ const indexNowKeyOrEmpty = z
205
+ .string()
206
+ .max(128)
207
+ .regex(/^$|^[a-fA-F0-9]{8,128}$/u, {
208
+ error: 'Must be empty, or 8 to 128 hexadecimal characters — generate one with crypto.randomUUID().replaceAll("-", "").',
209
+ });
210
+ /**
211
+ * A channel bot's display name/handle (fiche 59) — free text, deliberately
212
+ * unconstrained in shape: `@my_bot` (Telegram), an app name (Slack), or
213
+ * `MyBot#1234` (Discord) are all valid, and this field's only job is
214
+ * plausibility (no newline, a sane length), never a format check tied to one
215
+ * platform. Never a credential — see this key's own registry entry.
216
+ */
217
+ const botNameOrEmpty = z
218
+ .string()
219
+ .max(80)
220
+ .refine((value) => !/[\r\n]/u.test(value), {
221
+ error: 'A bot name is a single line — remove any line break.',
222
+ });
223
+ /** A Twitter/X `@handle` for `twitter:site`, or empty. */
224
+ const twitterHandleOrEmpty = z
225
+ .string()
226
+ .max(16)
227
+ .refine((value) => value === '' || /^@[A-Za-z0-9_]{1,15}$/u.test(value), {
228
+ error: 'Must be empty, or a handle starting with "@" (letters, digits, underscore, max 15 characters).',
229
+ });
230
+ const changeFrequencyOrEmpty = z.enum([
231
+ '',
232
+ 'always',
233
+ 'hourly',
234
+ 'daily',
235
+ 'weekly',
236
+ 'monthly',
237
+ 'yearly',
238
+ 'never',
239
+ ]);
240
+ /**
241
+ * A comma-separated list of bare tokens — a nav group id (`content`,
242
+ * `commerce`, …) or the `to` path of a nav item (`/collections`,
243
+ * `/commerce/products`, …). The same shape `content.newEntryDefaultBlocks`
244
+ * already uses for "an ordered subset of a vocabulary this package cannot
245
+ * import" (fiche 22 tâche 8, part 3): `@cogenta/schema` has no dependency on
246
+ * `@cogenta/admin`'s `nav-items.ts` any more than it does on
247
+ * `@cogenta/blocks`'s block vocabulary, so this only shapes the string —
248
+ * `@cogenta/admin`'s own `nav-layout.ts` is what filters out a token that
249
+ * does not name a real group or item. Empty means "no override": the
250
+ * admin's own shipped order and visibility apply untouched.
251
+ */
252
+ const navTokenList = z
253
+ .string()
254
+ .max(4000)
255
+ .regex(/^$|^[a-zA-Z0-9/_-]+(\s*,\s*[a-zA-Z0-9/_-]+)*$/u, {
256
+ error: 'A comma-separated list of navigation section or entry ids.',
257
+ });
258
+ /** `seo.sitemapCollectionSettings` — one override per collection name (fiche 21 task 3, mirrors `@cogenta/seo`'s `SitemapCollectionOverride`). */
259
+ const sitemapCollectionSettings = z.record(z.string(), z.object({
260
+ included: z.boolean(),
261
+ changefreq: changeFrequencyOrEmpty,
262
+ // '' means "no hint" — kept as a string in storage so an empty text
263
+ // input round-trips without becoming `NaN` on the way back in.
264
+ priority: z.union([z.literal(''), z.number().min(0).max(1)]),
265
+ }));
266
+ /**
267
+ * `general.socialLinks` (L25 D2) — the site's own social profile links, in
268
+ * the order a footer shows them. Twelve is the footer's own layout budget
269
+ * (`renderSocialLinks`, `@cogenta/theme-kit`): past a dozen icons a footer
270
+ * stops reading as a footer, and there is no legitimate reason a real site
271
+ * needs more distinct platforms than that. `url` must be `http(s)`: a footer
272
+ * `<a>` is not a place for a `javascript:`/`mailto:` surprise, and a real
273
+ * social profile is never anything else.
274
+ */
275
+ const socialLinkEntry = z.object({
276
+ label: z.string().min(1).max(40),
277
+ url: z
278
+ .string()
279
+ .max(500)
280
+ .refine((value) => /^https?:\/\//u.test(value), {
281
+ error: 'Must be an http(s) URL.',
282
+ }),
283
+ });
284
+ const socialLinksSetting = z.array(socialLinkEntry).max(12);
285
+ /**
286
+ * `assistant.indexedCollections` (L22 task 4) — one boolean per collection
287
+ * name, `false` meaning "excluded from the vector index". A collection
288
+ * absent from the map is included: the opt-out shape is what keeps a site
289
+ * that has never touched this setting indexing exactly as it always has.
290
+ */
291
+ const indexedCollectionsSettings = z.record(z.string(), z.boolean());
292
+ /**
293
+ * A real IANA time zone name, or empty for "not set". `Intl.DateTimeFormat`
294
+ * throws `RangeError` on anything it does not recognise — the same check
295
+ * every JS runtime already performs, reused rather than a hand-rolled list of
296
+ * zone names to keep in sync with the platform's own tzdata.
297
+ */
298
+ function isValidTimeZone(value) {
299
+ if (value === '')
300
+ return true;
301
+ try {
302
+ new Intl.DateTimeFormat(undefined, { timeZone: value });
303
+ return true;
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ }
309
+ const timeZoneOrEmpty = z
310
+ .string()
311
+ .max(64)
312
+ .refine(isValidTimeZone, { error: 'Not a recognised IANA time zone name (e.g. "Europe/Paris").' });
313
+ const dateTimeStyle = z.enum(['full', 'long', 'medium', 'short']);
314
+ const ADMIN_ONLY = ['admin'];
315
+ export const SITE_SETTINGS_REGISTRY = [
316
+ // General
317
+ {
318
+ key: 'general.title',
319
+ group: 'general',
320
+ order: 0,
321
+ uiType: 'string',
322
+ scope: 'site',
323
+ schema: nonEmpty200,
324
+ defaultValue: '',
325
+ writeRoles: ADMIN_ONLY,
326
+ },
327
+ {
328
+ key: 'general.tagline',
329
+ group: 'general',
330
+ order: 1,
331
+ uiType: 'string',
332
+ scope: 'locale',
333
+ schema: nonEmpty200,
334
+ defaultValue: '',
335
+ writeRoles: ADMIN_ONLY,
336
+ },
337
+ {
338
+ key: 'general.adminEmail',
339
+ group: 'general',
340
+ order: 2,
341
+ uiType: 'email',
342
+ scope: 'site',
343
+ schema: emailOrEmpty,
344
+ defaultValue: '',
345
+ writeRoles: ADMIN_ONLY,
346
+ },
347
+ {
348
+ key: 'general.timeZone',
349
+ group: 'general',
350
+ order: 3,
351
+ uiType: 'timeZone',
352
+ scope: 'site',
353
+ schema: timeZoneOrEmpty,
354
+ defaultValue: '',
355
+ writeRoles: ADMIN_ONLY,
356
+ },
357
+ {
358
+ key: 'general.dateStyle',
359
+ group: 'general',
360
+ order: 4,
361
+ uiType: 'dateStyle',
362
+ scope: 'site',
363
+ schema: dateTimeStyle,
364
+ defaultValue: 'medium',
365
+ writeRoles: ADMIN_ONLY,
366
+ },
367
+ {
368
+ key: 'general.timeStyle',
369
+ group: 'general',
370
+ order: 5,
371
+ uiType: 'timeStyle',
372
+ scope: 'site',
373
+ schema: dateTimeStyle,
374
+ defaultValue: 'short',
375
+ writeRoles: ADMIN_ONLY,
376
+ },
377
+ /**
378
+ * L21 task 5's "blocs de départ" — the admin's new-entry flow prefills a
379
+ * fresh `blocks` field with these, instead of the empty array it wrote
380
+ * before this setting existed, so an MCP call or a person opening a new
381
+ * page never has to know every block type just to get something sane on
382
+ * the page. Kept out of contract A on purpose (`defineCollection` gains no
383
+ * `defaultBlocks` option — that would be a frozen-schema change, and the
384
+ * lot's own note says to avoid it): this is a pure admin default, not
385
+ * something a headless client's own `POST` ever sees or is bound by.
386
+ *
387
+ * A comma-separated list of contract B block type names rather than a new
388
+ * `uiType` — the same shape `commerce.countriesServed` already uses for a
389
+ * short closed-ish list — because `@cogenta/schema` cannot import
390
+ * `@cogenta/blocks` (the dependency runs the other way) to validate
391
+ * against the real vocabulary here; the admin's own copy
392
+ * (`blocks/vocabulary.ts`) does that filtering when it reads this value,
393
+ * silently dropping a name it does not recognise rather than crashing the
394
+ * new-entry screen over a typo in a setting.
395
+ */
396
+ {
397
+ key: 'content.newEntryDefaultBlocks',
398
+ group: 'general',
399
+ order: 6,
400
+ uiType: 'string',
401
+ scope: 'site',
402
+ schema: z
403
+ .string()
404
+ .max(500)
405
+ .regex(/^$|^[a-zA-Z][a-zA-Z0-9]*(\s*,\s*[a-zA-Z][a-zA-Z0-9]*)*$/u, {
406
+ error: 'A comma-separated list of block type names, e.g. "prose". Empty means no starting blocks.',
407
+ }),
408
+ defaultValue: 'prose',
409
+ writeRoles: ADMIN_ONLY,
410
+ },
411
+ /**
412
+ * L25 D2 — the site's own social profiles, read by `resolveChromeExtras`
413
+ * (`@cogenta/cli`) and rendered by `renderSocialLinks`
414
+ * (`@cogenta/theme-kit`) in every built-in theme's footer. Site-scoped,
415
+ * unlike `general.tagline`: a Mastodon handle does not change per locale
416
+ * the way an editorial tagline does.
417
+ */
418
+ {
419
+ key: 'general.socialLinks',
420
+ group: 'general',
421
+ order: 7,
422
+ uiType: 'linkList',
423
+ scope: 'site',
424
+ schema: socialLinksSetting,
425
+ defaultValue: [],
426
+ writeRoles: ADMIN_ONLY,
427
+ },
428
+ /**
429
+ * L25 D2 — a short line an editor writes once (a legal mention, an
430
+ * "about" sentence, an address) that a theme's footer shows as its own
431
+ * short "about" column (`ChromeInput.footerNote`, contract D `theme@1.4`).
432
+ * Locale-scoped like `general.tagline` right above it: this is prose an
433
+ * editor writes, not a fixed fact like a social handle.
434
+ */
435
+ {
436
+ key: 'general.footerNote',
437
+ group: 'general',
438
+ order: 8,
439
+ uiType: 'text',
440
+ scope: 'locale',
441
+ schema: z.string().max(400),
442
+ defaultValue: '',
443
+ writeRoles: ADMIN_ONLY,
444
+ },
445
+ // Reading
446
+ {
447
+ key: 'reading.homePath',
448
+ group: 'reading',
449
+ order: 0,
450
+ uiType: 'path',
451
+ scope: 'site',
452
+ schema: pathOrEmpty,
453
+ defaultValue: '',
454
+ writeRoles: ADMIN_ONLY,
455
+ },
456
+ {
457
+ key: 'reading.postsPerPage',
458
+ group: 'reading',
459
+ order: 1,
460
+ uiType: 'number',
461
+ scope: 'site',
462
+ schema: z.number().int().min(1).max(100),
463
+ defaultValue: 10,
464
+ writeRoles: ADMIN_ONLY,
465
+ },
466
+ // Discussion (fiche 15 task 5, ADR-0025) — the site-wide defaults every
467
+ // collection and every entry inherit from unless it has its own override.
468
+ // Per-collection and per-entry overrides do NOT live here: this registry
469
+ // is site/locale scoped only, with no notion of "per collection" — they
470
+ // live in `@cogenta/comments`'s own `CommentSettingsStore` instead (see
471
+ // its module comment for why), which reads `discussion.enabled` and
472
+ // `discussion.moderationRequired` as the bottom of its own inheritance
473
+ // chain (`effectiveEnabled`/`effectiveModerationRequired`).
474
+ {
475
+ key: 'discussion.enabled',
476
+ group: 'discussion',
477
+ order: 0,
478
+ uiType: 'boolean',
479
+ scope: 'site',
480
+ schema: z.boolean(),
481
+ defaultValue: true,
482
+ writeRoles: ADMIN_ONLY,
483
+ },
484
+ {
485
+ key: 'discussion.moderationRequired',
486
+ group: 'discussion',
487
+ order: 1,
488
+ uiType: 'boolean',
489
+ scope: 'site',
490
+ schema: z.boolean(),
491
+ // On by default: a comment nobody moderated appearing on a live page is
492
+ // the surprise a new site owner least wants (fiche 15's own comparison
493
+ // to WordPress, which ships the same default).
494
+ defaultValue: true,
495
+ writeRoles: ADMIN_ONLY,
496
+ },
497
+ {
498
+ key: 'discussion.allowAnonymous',
499
+ group: 'discussion',
500
+ order: 2,
501
+ uiType: 'boolean',
502
+ scope: 'site',
503
+ schema: z.boolean(),
504
+ defaultValue: true,
505
+ writeRoles: ADMIN_ONLY,
506
+ },
507
+ {
508
+ key: 'discussion.autoCloseDays',
509
+ group: 'discussion',
510
+ order: 3,
511
+ uiType: 'number',
512
+ scope: 'site',
513
+ // 0 means "never auto-close" — not every site wants an expiry, and 0 is
514
+ // a clearer way to say that than a second boolean that could disagree
515
+ // with this number.
516
+ schema: z.number().int().min(0).max(3650),
517
+ defaultValue: 0,
518
+ writeRoles: ADMIN_ONLY,
519
+ },
520
+ {
521
+ key: 'discussion.maxNestingDepth',
522
+ group: 'discussion',
523
+ order: 4,
524
+ uiType: 'number',
525
+ scope: 'site',
526
+ schema: z.number().int().min(1).max(10),
527
+ defaultValue: 5,
528
+ writeRoles: ADMIN_ONLY,
529
+ },
530
+ {
531
+ key: 'discussion.notifyEmail',
532
+ group: 'discussion',
533
+ order: 5,
534
+ uiType: 'email',
535
+ scope: 'site',
536
+ schema: emailOrEmpty,
537
+ defaultValue: '',
538
+ writeRoles: ADMIN_ONLY,
539
+ },
540
+ // Media
541
+ {
542
+ key: 'media.maxUploadSizeMb',
543
+ group: 'media',
544
+ order: 0,
545
+ uiType: 'number',
546
+ scope: 'site',
547
+ schema: z.number().int().min(1).max(200),
548
+ defaultValue: 15,
549
+ writeRoles: ADMIN_ONLY,
550
+ },
551
+ // Privacy
552
+ {
553
+ key: 'privacy.policyPath',
554
+ group: 'privacy',
555
+ order: 0,
556
+ uiType: 'path',
557
+ scope: 'site',
558
+ schema: pathOrEmpty,
559
+ defaultValue: '',
560
+ writeRoles: ADMIN_ONLY,
561
+ },
562
+ {
563
+ key: 'privacy.cookieBannerEnabled',
564
+ group: 'privacy',
565
+ order: 1,
566
+ uiType: 'boolean',
567
+ scope: 'site',
568
+ schema: z.boolean(),
569
+ // Off by default and on purpose (fiche 23 § pièges): Cogenta sets no
570
+ // cookie of its own, so a banner nobody asked for would be pure noise —
571
+ // this exists for the site that adds a tracker of its own, not to
572
+ // impose a fixture on every site that does not.
573
+ defaultValue: false,
574
+ writeRoles: ADMIN_ONLY,
575
+ },
576
+ {
577
+ key: 'privacy.cookieBannerMessage',
578
+ group: 'privacy',
579
+ order: 2,
580
+ uiType: 'text',
581
+ scope: 'site',
582
+ schema: freeText,
583
+ defaultValue: '',
584
+ writeRoles: ADMIN_ONLY,
585
+ },
586
+ {
587
+ key: 'privacy.dataRetentionNote',
588
+ group: 'privacy',
589
+ order: 3,
590
+ uiType: 'text',
591
+ scope: 'site',
592
+ schema: freeText,
593
+ defaultValue: '',
594
+ writeRoles: ADMIN_ONLY,
595
+ },
596
+ // Commerce (fiche 34 task 4) — general store settings. `commerce.currency`
597
+ // and `commerce.priceDisplay` feed the checkout's own totals engine
598
+ // (@cogenta/commerce) as the *default* when an order does not say
599
+ // otherwise; they never duplicate a value the totals engine itself owns.
600
+ {
601
+ key: 'commerce.currency',
602
+ group: 'commerce',
603
+ order: 0,
604
+ uiType: 'string',
605
+ scope: 'site',
606
+ schema: z
607
+ .string()
608
+ .regex(/^[A-Z]{3}$/u, { error: 'A currency is a three-letter ISO 4217 code, e.g. "EUR".' }),
609
+ defaultValue: 'EUR',
610
+ writeRoles: ADMIN_ONLY,
611
+ },
612
+ {
613
+ key: 'commerce.priceDisplay',
614
+ group: 'commerce',
615
+ order: 1,
616
+ uiType: 'select',
617
+ scope: 'site',
618
+ schema: z.enum(['ttc', 'ht']),
619
+ // Tax-inclusive by default — the European convention this codebase's own
620
+ // tax engine already documents (`TaxRule.includedInPrice`).
621
+ defaultValue: 'ttc',
622
+ options: [
623
+ { value: 'ttc', label: 'Tax-inclusive (TTC)' },
624
+ { value: 'ht', label: 'Tax-exclusive (HT)' },
625
+ ],
626
+ writeRoles: ADMIN_ONLY,
627
+ },
628
+ {
629
+ key: 'commerce.countriesServed',
630
+ group: 'commerce',
631
+ order: 2,
632
+ uiType: 'text',
633
+ scope: 'site',
634
+ // Comma-separated ISO 3166-1 alpha-2 codes. Empty means "every country" —
635
+ // the same "unset means no restriction" convention `path` settings use.
636
+ schema: z
637
+ .string()
638
+ .max(2000)
639
+ .refine((value) => value
640
+ .split(',')
641
+ .map((code) => code.trim())
642
+ .filter((code) => code !== '')
643
+ .every((code) => /^[A-Z]{2}$/u.test(code)), {
644
+ error: 'Each country must be a two-letter ISO 3166-1 code, comma-separated (e.g. "FR, BE, LU").',
645
+ }),
646
+ defaultValue: '',
647
+ writeRoles: ADMIN_ONLY,
648
+ },
649
+ {
650
+ key: 'commerce.minOrderSubtotalMinor',
651
+ group: 'commerce',
652
+ order: 3,
653
+ uiType: 'number',
654
+ scope: 'site',
655
+ // Minor units, like every other amount in @cogenta/commerce (ADR-0006) —
656
+ // 0 means no minimum.
657
+ schema: z.number().int().nonnegative().max(1_000_000_000),
658
+ defaultValue: 0,
659
+ writeRoles: ADMIN_ONLY,
660
+ },
661
+ {
662
+ key: 'commerce.allowBackorderDefault',
663
+ group: 'commerce',
664
+ order: 4,
665
+ uiType: 'boolean',
666
+ scope: 'site',
667
+ schema: z.boolean(),
668
+ defaultValue: false,
669
+ writeRoles: ADMIN_ONLY,
670
+ },
671
+ {
672
+ key: 'commerce.tosPagePath',
673
+ group: 'commerce',
674
+ order: 5,
675
+ uiType: 'path',
676
+ scope: 'site',
677
+ schema: pathOrEmpty,
678
+ defaultValue: '',
679
+ writeRoles: ADMIN_ONLY,
680
+ },
681
+ {
682
+ key: 'commerce.returnPolicyPagePath',
683
+ group: 'commerce',
684
+ order: 6,
685
+ uiType: 'path',
686
+ scope: 'site',
687
+ schema: pathOrEmpty,
688
+ defaultValue: '',
689
+ writeRoles: ADMIN_ONLY,
690
+ },
691
+ // Commerce — invoice template (fiche 34 task 5). `billing` (legal name,
692
+ // address, tax id, footer) stays in `cogenta.config.mjs` — ADR-0024's
693
+ // seller identity, and infrastructure per ADR-0025's classification. These
694
+ // four are editorial on top of it: how numbering and wording read, which a
695
+ // shop owner changes without redeploying.
696
+ {
697
+ key: 'commerce.invoiceSeriesPrefix',
698
+ group: 'commerce',
699
+ order: 7,
700
+ uiType: 'string',
701
+ scope: 'site',
702
+ // Prepended to the year-based series (`formatInvoiceNumber`), e.g.
703
+ // "AC" + "2026" => series "AC2026". Empty keeps today's plain year series.
704
+ schema: z
705
+ .string()
706
+ .max(20)
707
+ .regex(/^[A-Za-z0-9-]*$/u, {
708
+ error: 'A series prefix may only contain letters, digits and hyphens.',
709
+ }),
710
+ defaultValue: '',
711
+ writeRoles: ADMIN_ONLY,
712
+ },
713
+ {
714
+ key: 'commerce.invoicePaymentTerms',
715
+ group: 'commerce',
716
+ order: 8,
717
+ uiType: 'text',
718
+ scope: 'site',
719
+ schema: freeText,
720
+ defaultValue: '',
721
+ writeRoles: ADMIN_ONLY,
722
+ },
723
+ {
724
+ key: 'commerce.invoiceLanguage',
725
+ group: 'commerce',
726
+ order: 9,
727
+ uiType: 'string',
728
+ scope: 'site',
729
+ schema: z.string().max(10),
730
+ defaultValue: 'en',
731
+ writeRoles: ADMIN_ONLY,
732
+ },
733
+ // Branding (fiche L21 task 8) — Cogenta's own credit, and its white-label
734
+ // override. Read publicly (GET /api/settings is unauthenticated, exactly
735
+ // like every other setting here): the public footer needs both values on
736
+ // every anonymous request, the same way it already reads `general.title`.
737
+ {
738
+ key: 'branding.showCogentaBranding',
739
+ group: 'branding',
740
+ order: 0,
741
+ uiType: 'boolean',
742
+ scope: 'site',
743
+ schema: z.boolean(),
744
+ // On by default: an install says "built with Cogenta" until someone
745
+ // deliberately turns it off, never the other way round.
746
+ defaultValue: true,
747
+ writeRoles: ADMIN_ONLY,
748
+ },
749
+ {
750
+ key: 'branding.customLogoMediaId',
751
+ group: 'branding',
752
+ order: 1,
753
+ uiType: 'media',
754
+ scope: 'site',
755
+ // A media id, or '' for "unset" — only meaningful once
756
+ // `showCogentaBranding` is off; kept independent of it so turning
757
+ // Cogenta's credit back on never throws away a logo already uploaded.
758
+ schema: z.string().max(200),
759
+ defaultValue: '',
760
+ writeRoles: ADMIN_ONLY,
761
+ },
762
+ // SEO (fiche 21 task 3) — overrides `seo.tsx`'s previous "read-only by
763
+ // design" scope choice (never an ADR): title/description templates,
764
+ // per-collection sitemap hints and social-card defaults, all live-read by
765
+ // `@cogenta/cli`'s render path and by `@cogenta/api`'s `SeoRouter`
766
+ // (`titleDefaults`), never cached at server startup.
767
+ {
768
+ key: 'seo.titleTemplate',
769
+ group: 'seo',
770
+ order: 0,
771
+ uiType: 'string',
772
+ scope: 'site',
773
+ // `%title% — %site%`-style, applied to every page with no per-collection
774
+ // override below and no `seoTitle` field value of its own.
775
+ schema: titleTemplateOrEmpty,
776
+ defaultValue: '',
777
+ writeRoles: ADMIN_ONLY,
778
+ },
779
+ {
780
+ key: 'seo.collectionTitleTemplates',
781
+ group: 'seo',
782
+ order: 1,
783
+ // Bypasses the generic per-`uiType` renderer (`SiteSettingsField`) —
784
+ // `SeoRoute` renders this one key with a bespoke per-collection table
785
+ // instead, the same way `ReadingTab` special-cases `notFoundPath`. `text`
786
+ // is the closest shape in the closed `uiType` set; nothing reads it for
787
+ // this key.
788
+ uiType: 'text',
789
+ scope: 'site',
790
+ schema: collectionTitleTemplates,
791
+ defaultValue: {},
792
+ writeRoles: ADMIN_ONLY,
793
+ },
794
+ {
795
+ key: 'seo.defaultMetaDescription',
796
+ group: 'seo',
797
+ order: 2,
798
+ uiType: 'text',
799
+ scope: 'site',
800
+ // Fed into `@cogenta/seo`'s `SeoSite.description`, the fallback every
801
+ // page's own `excerpt`/`seoDescription` already takes priority over.
802
+ schema: metaDescriptionOrEmpty,
803
+ defaultValue: '',
804
+ writeRoles: ADMIN_ONLY,
805
+ },
806
+ {
807
+ key: 'seo.sitemapCollectionSettings',
808
+ group: 'seo',
809
+ order: 3,
810
+ // Bypassed the same way `collectionTitleTemplates` is — see that entry's
811
+ // own comment.
812
+ uiType: 'text',
813
+ scope: 'site',
814
+ schema: sitemapCollectionSettings,
815
+ defaultValue: {},
816
+ writeRoles: ADMIN_ONLY,
817
+ },
818
+ {
819
+ key: 'seo.twitterHandle',
820
+ group: 'seo',
821
+ order: 4,
822
+ uiType: 'string',
823
+ scope: 'site',
824
+ // Fed into `@cogenta/seo`'s `SeoSite.twitterSite`, rendered as `twitter:site`.
825
+ schema: twitterHandleOrEmpty,
826
+ defaultValue: '',
827
+ writeRoles: ADMIN_ONLY,
828
+ },
829
+ {
830
+ key: 'seo.defaultSocialImageUrl',
831
+ group: 'seo',
832
+ order: 5,
833
+ uiType: 'path',
834
+ scope: 'site',
835
+ // A plain URL/path setting, deliberately not a media reference: resolving
836
+ // an arbitrary media id at request time would need the render pipeline's
837
+ // media store reachable from a settings read, for one field whose value
838
+ // rarely changes. A site that wants its own asset pastes the same URL
839
+ // `/api/media/{id}/file` or `/_image?id=…` would answer.
840
+ schema: urlOrPathOrEmpty,
841
+ defaultValue: '',
842
+ writeRoles: ADMIN_ONLY,
843
+ },
844
+ // Fiche 50 tasks 2-5 — search-engine verification, a hand-written
845
+ // robots.txt addendum, and the two off-by-default indexing extras
846
+ // (`indexnow.ts`/`llms-txt.ts`) that were written and unit-tested back in
847
+ // L3/L9 but never wired to a route or a setting until now.
848
+ {
849
+ key: 'seo.googleSiteVerification',
850
+ group: 'seo',
851
+ order: 6,
852
+ uiType: 'string',
853
+ scope: 'site',
854
+ // Rendered verbatim into `<meta name="google-site-verification" content="…">`
855
+ // — no OAuth, no Search Console API call (R1/R7): this is the same
856
+ // "paste the token Google's own verification page shows you" flow every
857
+ // other CMS offers next to domain/DNS verification.
858
+ schema: siteVerificationTokenOrEmpty,
859
+ defaultValue: '',
860
+ writeRoles: ADMIN_ONLY,
861
+ },
862
+ {
863
+ key: 'seo.bingSiteVerification',
864
+ group: 'seo',
865
+ order: 7,
866
+ uiType: 'string',
867
+ scope: 'site',
868
+ // Rendered into `<meta name="msvalidate.01" content="…">` — Bing
869
+ // Webmaster Tools' own meta-tag verification method, same reasoning as
870
+ // `googleSiteVerification` above.
871
+ schema: siteVerificationTokenOrEmpty,
872
+ defaultValue: '',
873
+ writeRoles: ADMIN_ONLY,
874
+ },
875
+ {
876
+ key: 'seo.robotsCustomRules',
877
+ group: 'seo',
878
+ order: 8,
879
+ // Bypassed the same way `collectionTitleTemplates` is — `SeoRoute`'s
880
+ // Diagnostics tab renders this one key with a bespoke textarea that
881
+ // confirms before saving a rule that would block every crawler
882
+ // (`Disallow: /`), a piège the fiche names explicitly.
883
+ uiType: 'text',
884
+ scope: 'site',
885
+ // Free-form robots.txt lines, merged after the derived group and before
886
+ // the `Sitemap:` directive (`renderRobotsTxt`'s own `customRules`
887
+ // option). Not `assertSingleLine`-checked like a single directive value:
888
+ // this field is deliberately multi-line.
889
+ schema: freeText,
890
+ defaultValue: '',
891
+ writeRoles: ADMIN_ONLY,
892
+ },
893
+ {
894
+ key: 'seo.indexNowEnabled',
895
+ group: 'seo',
896
+ order: 9,
897
+ uiType: 'boolean',
898
+ scope: 'site',
899
+ schema: z.boolean(),
900
+ // Off by default: pinging a third-party endpoint on every publish is
901
+ // real, deliberate behaviour an admin opts into, the same reasoning
902
+ // `updates.autoUpdatePolicy` gives for defaulting off.
903
+ defaultValue: false,
904
+ writeRoles: ADMIN_ONLY,
905
+ },
906
+ {
907
+ key: 'seo.indexNowKey',
908
+ group: 'seo',
909
+ order: 10,
910
+ uiType: 'string',
911
+ scope: 'site',
912
+ schema: indexNowKeyOrEmpty,
913
+ defaultValue: '',
914
+ writeRoles: ADMIN_ONLY,
915
+ },
916
+ {
917
+ key: 'seo.llmsTxtEnabled',
918
+ group: 'seo',
919
+ order: 11,
920
+ uiType: 'boolean',
921
+ scope: 'site',
922
+ schema: z.boolean(),
923
+ // Off by default, same reasoning as `indexNowEnabled`: `llms.txt` lists
924
+ // every published title site-wide, which an admin should choose to
925
+ // expose rather than find already on by an install nobody configured.
926
+ defaultValue: false,
927
+ writeRoles: ADMIN_ONLY,
928
+ },
929
+ // Fiche 70 task 3 — the feature-activation grid's own four gates. Every
930
+ // one of these defaults to `true`: unlike `indexNowEnabled`/`llmsTxtEnabled`
931
+ // (which switch on a real effect an install nobody configured should not
932
+ // get for free), these four gate a feature that is *already* opt-in by
933
+ // construction — a score/link report nobody asked to see is simply not
934
+ // rendered, and a verification token/custom robots rule nobody typed has
935
+ // no content to gate in the first place. Defaulting to `true` is what
936
+ // keeps an upgrading site's behaviour byte-identical the day this fiche
937
+ // ships: every admin who had already typed a verification token or a
938
+ // custom robots.txt rule keeps seeing it applied, with no silent regression.
939
+ {
940
+ key: 'seo.contentScoreEnabled',
941
+ group: 'seo',
942
+ order: 12,
943
+ uiType: 'boolean',
944
+ scope: 'site',
945
+ // Read only by the admin (`content-analysis.ts`'s `analyseContent` runs
946
+ // entirely client-side, R2/R9) — this setting merely decides whether the
947
+ // SEO panel renders the score at all, never a server-side gate.
948
+ schema: z.boolean(),
949
+ defaultValue: true,
950
+ writeRoles: ADMIN_ONLY,
951
+ },
952
+ {
953
+ key: 'seo.linkAssistantEnabled',
954
+ group: 'seo',
955
+ order: 13,
956
+ uiType: 'boolean',
957
+ scope: 'site',
958
+ // Gates `GET /api/seo/link-suggestions` itself (`seo-router.ts`) —
959
+ // unlike `contentScoreEnabled`, this one *does* reach the server, since
960
+ // the underlying scan reads every routed collection's published
961
+ // entries and is not free to run.
962
+ schema: z.boolean(),
963
+ defaultValue: true,
964
+ writeRoles: ADMIN_ONLY,
965
+ },
966
+ {
967
+ key: 'seo.searchVerificationEnabled',
968
+ group: 'seo',
969
+ order: 14,
970
+ uiType: 'boolean',
971
+ scope: 'site',
972
+ // Gates whether `seo.googleSiteVerification`/`seo.bingSiteVerification`
973
+ // are actually rendered into `<meta>` tags (`readSeoRenderDefaults`,
974
+ // `@cogenta/cli`) — the saved tokens are never erased by turning this
975
+ // off, exactly the same "data persists, activation gates whether it
976
+ // applies" shape `robotsCustomRulesEnabled` below uses.
977
+ schema: z.boolean(),
978
+ defaultValue: true,
979
+ writeRoles: ADMIN_ONLY,
980
+ },
981
+ {
982
+ key: 'seo.robotsCustomRulesEnabled',
983
+ group: 'seo',
984
+ order: 15,
985
+ uiType: 'boolean',
986
+ scope: 'site',
987
+ // Gates whether `seo.robotsCustomRules` is actually merged into the
988
+ // served `robots.txt` (`readSeoRenderDefaults`) — the saved text stays
989
+ // in the textarea either way; only whether a crawler ever sees it changes.
990
+ schema: z.boolean(),
991
+ defaultValue: true,
992
+ writeRoles: ADMIN_ONLY,
993
+ },
994
+ // Observability (fiche L22 task 5) — whether the local trace/log
995
+ // collection behind the admin's "Exploitation" screen runs, and how
996
+ // verbose it is. The OTLP export destination itself is not here (see the
997
+ // `observability` group's own doc comment) — this registry only ever
998
+ // holds editorial state, never infra wiring that can carry a secret.
999
+ {
1000
+ key: 'observability.enabled',
1001
+ group: 'observability',
1002
+ order: 0,
1003
+ uiType: 'boolean',
1004
+ scope: 'site',
1005
+ schema: z.boolean(),
1006
+ // On by default — fiche L22 task 5's own "actif par défaut pour la
1007
+ // collecte et l'affichage local (R1)": a fresh site shows something on
1008
+ // its Exploitation screen without anyone having to opt in first.
1009
+ defaultValue: true,
1010
+ writeRoles: ADMIN_ONLY,
1011
+ },
1012
+ {
1013
+ key: 'observability.logLevel',
1014
+ group: 'observability',
1015
+ order: 1,
1016
+ uiType: 'select',
1017
+ scope: 'site',
1018
+ schema: z.enum(['error', 'warn', 'info', 'debug']),
1019
+ defaultValue: 'info',
1020
+ options: [
1021
+ { value: 'error', label: 'Error' },
1022
+ { value: 'warn', label: 'Warn' },
1023
+ { value: 'info', label: 'Info' },
1024
+ { value: 'debug', label: 'Debug' },
1025
+ ],
1026
+ writeRoles: ADMIN_ONLY,
1027
+ },
1028
+ // Assistant (L22 task 4) — which collections the writing assistant's
1029
+ // vector index actually contains. Read live by `cogenta serve` on every
1030
+ // content write and by the "Reindex vectors" tool (`@cogenta/cli`'s
1031
+ // `isAssistantCollectionEnabled`) — never cached, so a toggle here takes
1032
+ // effect on the next save with no restart.
1033
+ {
1034
+ key: 'assistant.indexedCollections',
1035
+ group: 'assistant',
1036
+ order: 0,
1037
+ // Bypasses the generic per-`uiType` renderer, the same way
1038
+ // `seo.sitemapCollectionSettings` does — the redesigned assistant
1039
+ // screen renders this one key as a per-collection toggle list instead.
1040
+ uiType: 'text',
1041
+ scope: 'site',
1042
+ schema: indexedCollectionsSettings,
1043
+ defaultValue: {},
1044
+ writeRoles: ADMIN_ONLY,
1045
+ },
1046
+ // Update system (L22 task 9) — auto-update policy. A closed choice, never
1047
+ // free text: the scheduled task that reads this only ever compares it
1048
+ // against `UpdateBump` (`@cogenta/cli`'s `policyAllows`), so a typo here
1049
+ // must be impossible, the same reasoning `commerce.priceDisplay` already
1050
+ // gave for using `select` over a free string.
1051
+ {
1052
+ key: 'updates.autoUpdatePolicy',
1053
+ group: 'updates',
1054
+ order: 0,
1055
+ uiType: 'select',
1056
+ scope: 'site',
1057
+ schema: z.enum(['off', 'patch', 'patch-minor', 'patch-minor-major']),
1058
+ // Off by default: an unattended `npm install` against a running site is
1059
+ // real, deliberate behaviour that has to be opted into — never a
1060
+ // surprise sprung on whoever's default install this is (same reasoning
1061
+ // `privacy.cookieBannerEnabled` gives for defaulting off).
1062
+ defaultValue: 'off',
1063
+ options: [
1064
+ { value: 'off', label: 'Off — check only, never apply automatically' },
1065
+ { value: 'patch', label: 'Patch releases only' },
1066
+ { value: 'patch-minor', label: 'Patch and minor releases' },
1067
+ { value: 'patch-minor-major', label: 'Patch, minor and major releases' },
1068
+ ],
1069
+ writeRoles: ADMIN_ONLY,
1070
+ },
1071
+ // Navigation (fiche 22 tâche 8, part 3) — admin sidebar reordering and
1072
+ // hiding, site-wide. Four independent lists rather than one nested
1073
+ // structure: each is a plain `navTokenList`, the same "comma-separated
1074
+ // subset of a vocabulary this package cannot import" shape every other
1075
+ // string-shaped setting here already uses, so this registry stays generic
1076
+ // (no key here gets a bespoke value shape the store has to special-case).
1077
+ {
1078
+ key: 'navigation.sectionOrder',
1079
+ group: 'navigation',
1080
+ order: 0,
1081
+ uiType: 'string',
1082
+ scope: 'site',
1083
+ schema: navTokenList,
1084
+ defaultValue: '',
1085
+ writeRoles: ADMIN_ONLY,
1086
+ },
1087
+ {
1088
+ key: 'navigation.hiddenSections',
1089
+ group: 'navigation',
1090
+ order: 1,
1091
+ uiType: 'string',
1092
+ scope: 'site',
1093
+ schema: navTokenList,
1094
+ defaultValue: '',
1095
+ writeRoles: ADMIN_ONLY,
1096
+ },
1097
+ {
1098
+ key: 'navigation.itemOrder',
1099
+ group: 'navigation',
1100
+ order: 2,
1101
+ uiType: 'string',
1102
+ scope: 'site',
1103
+ schema: navTokenList,
1104
+ defaultValue: '',
1105
+ writeRoles: ADMIN_ONLY,
1106
+ },
1107
+ {
1108
+ key: 'navigation.hiddenItems',
1109
+ group: 'navigation',
1110
+ order: 3,
1111
+ uiType: 'string',
1112
+ scope: 'site',
1113
+ schema: navTokenList,
1114
+ defaultValue: '',
1115
+ writeRoles: ADMIN_ONLY,
1116
+ },
1117
+ // Channels (fiche 59) — the operator-entered, non-secret bot handle each
1118
+ // channel's in-admin step-by-step guide names, so a person linking their
1119
+ // account reads "open a chat with @my_cogenta_bot" instead of a generic
1120
+ // placeholder. Free text on purpose (`botNameOrEmpty`, not a `path`/`email`
1121
+ // shape): a Telegram handle, a Slack app name and a Discord tag
1122
+ // (`MyBot#1234`) have three different formats, and this field never
1123
+ // validates against any of them — the real bot credential (the token) is
1124
+ // environment-only (R7) and has no row anywhere in this table.
1125
+ {
1126
+ key: 'channels.telegramBotName',
1127
+ group: 'channels',
1128
+ order: 0,
1129
+ uiType: 'string',
1130
+ scope: 'site',
1131
+ schema: botNameOrEmpty,
1132
+ defaultValue: '',
1133
+ writeRoles: ADMIN_ONLY,
1134
+ },
1135
+ {
1136
+ key: 'channels.slackBotName',
1137
+ group: 'channels',
1138
+ order: 1,
1139
+ uiType: 'string',
1140
+ scope: 'site',
1141
+ schema: botNameOrEmpty,
1142
+ defaultValue: '',
1143
+ writeRoles: ADMIN_ONLY,
1144
+ },
1145
+ {
1146
+ key: 'channels.discordBotName',
1147
+ group: 'channels',
1148
+ order: 2,
1149
+ uiType: 'string',
1150
+ scope: 'site',
1151
+ schema: botNameOrEmpty,
1152
+ defaultValue: '',
1153
+ writeRoles: ADMIN_ONLY,
1154
+ },
1155
+ ];
1156
+ const BY_KEY = new Map(SITE_SETTINGS_REGISTRY.map((entry) => [entry.key, entry]));
1157
+ export function siteSettingByKey(key) {
1158
+ return BY_KEY.get(key);
1159
+ }
1160
+ //# sourceMappingURL=site-settings-registry.js.map