@cogenta/cli 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CHANGELOG.md +3856 -0
  2. package/dist/admin-assets/assets/index-CxZHGwVA.css +1 -0
  3. package/dist/admin-assets/assets/index-fYXyHpn8.js +1090 -0
  4. package/dist/admin-assets/assets/vips-_dmTUXFO.wasm +0 -0
  5. package/dist/admin-assets/assets/vips-es6-DrQTTS8Q.js +7 -0
  6. package/dist/admin-assets/branding/logo-cogenta-dark.png +0 -0
  7. package/dist/admin-assets/branding/logo-cogenta-icon.png +0 -0
  8. package/dist/admin-assets/branding/logo-cogenta-light.png +0 -0
  9. package/dist/admin-assets/branding/logo-cogenta-small.png +0 -0
  10. package/dist/admin-assets/branding/logo-cogenta-transparent.png +0 -0
  11. package/dist/admin-assets/branding/logo-cogenta.png +0 -0
  12. package/dist/admin-assets/favicon.png +0 -0
  13. package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
  14. package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
  15. package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
  16. package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
  17. package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
  18. package/dist/admin-assets/fonts/jetbrains-mono-var.woff2 +0 -0
  19. package/dist/admin-assets/fonts/space-grotesk-var.woff2 +0 -0
  20. package/dist/admin-assets/index.html +3 -2
  21. package/dist/bin.js +6 -0
  22. package/dist/bin.js.map +1 -1
  23. package/dist/commands/admin-assets.d.ts +1 -0
  24. package/dist/commands/admin-assets.d.ts.map +1 -1
  25. package/dist/commands/admin-assets.js +19 -1
  26. package/dist/commands/admin-assets.js.map +1 -1
  27. package/dist/commands/agent-runtime.d.ts +151 -0
  28. package/dist/commands/agent-runtime.d.ts.map +1 -0
  29. package/dist/commands/agent-runtime.js +519 -0
  30. package/dist/commands/agent-runtime.js.map +1 -0
  31. package/dist/commands/assistant.d.ts +89 -3
  32. package/dist/commands/assistant.d.ts.map +1 -1
  33. package/dist/commands/assistant.js +206 -39
  34. package/dist/commands/assistant.js.map +1 -1
  35. package/dist/commands/audit-integrity-alert.d.ts +23 -0
  36. package/dist/commands/audit-integrity-alert.d.ts.map +1 -0
  37. package/dist/commands/audit-integrity-alert.js +33 -0
  38. package/dist/commands/audit-integrity-alert.js.map +1 -0
  39. package/dist/commands/backup.d.ts +78 -0
  40. package/dist/commands/backup.d.ts.map +1 -0
  41. package/dist/commands/backup.js +238 -0
  42. package/dist/commands/backup.js.map +1 -0
  43. package/dist/commands/channels.d.ts +47 -0
  44. package/dist/commands/channels.d.ts.map +1 -0
  45. package/dist/commands/channels.js +277 -0
  46. package/dist/commands/channels.js.map +1 -0
  47. package/dist/commands/default-logo.d.ts +27 -0
  48. package/dist/commands/default-logo.d.ts.map +1 -0
  49. package/dist/commands/default-logo.js +30 -0
  50. package/dist/commands/default-logo.js.map +1 -0
  51. package/dist/commands/doctor.d.ts +5 -1
  52. package/dist/commands/doctor.d.ts.map +1 -1
  53. package/dist/commands/doctor.js +74 -2
  54. package/dist/commands/doctor.js.map +1 -1
  55. package/dist/commands/export.d.ts +22 -0
  56. package/dist/commands/export.d.ts.map +1 -0
  57. package/dist/commands/export.js +182 -0
  58. package/dist/commands/export.js.map +1 -0
  59. package/dist/commands/forms-page.d.ts +75 -0
  60. package/dist/commands/forms-page.d.ts.map +1 -0
  61. package/dist/commands/forms-page.js +191 -0
  62. package/dist/commands/forms-page.js.map +1 -0
  63. package/dist/commands/import.d.ts.map +1 -1
  64. package/dist/commands/import.js +13 -1
  65. package/dist/commands/import.js.map +1 -1
  66. package/dist/commands/mcp.d.ts +35 -0
  67. package/dist/commands/mcp.d.ts.map +1 -0
  68. package/dist/commands/mcp.js +277 -0
  69. package/dist/commands/mcp.js.map +1 -0
  70. package/dist/commands/roles.d.ts +22 -0
  71. package/dist/commands/roles.d.ts.map +1 -0
  72. package/dist/commands/roles.js +79 -0
  73. package/dist/commands/roles.js.map +1 -0
  74. package/dist/commands/search-page.d.ts +15 -0
  75. package/dist/commands/search-page.d.ts.map +1 -1
  76. package/dist/commands/search-page.js +22 -16
  77. package/dist/commands/search-page.js.map +1 -1
  78. package/dist/commands/seo.d.ts +92 -4
  79. package/dist/commands/seo.d.ts.map +1 -1
  80. package/dist/commands/seo.js +168 -4
  81. package/dist/commands/seo.js.map +1 -1
  82. package/dist/commands/serve.d.ts +489 -6
  83. package/dist/commands/serve.d.ts.map +1 -1
  84. package/dist/commands/serve.js +3883 -164
  85. package/dist/commands/serve.js.map +1 -1
  86. package/dist/commands/site-plan.d.ts +27 -1
  87. package/dist/commands/site-plan.d.ts.map +1 -1
  88. package/dist/commands/site-plan.js +71 -4
  89. package/dist/commands/site-plan.js.map +1 -1
  90. package/dist/commands/term-archive-page.d.ts +94 -0
  91. package/dist/commands/term-archive-page.d.ts.map +1 -0
  92. package/dist/commands/term-archive-page.js +195 -0
  93. package/dist/commands/term-archive-page.js.map +1 -0
  94. package/dist/commands/theme-css.d.ts +22 -7
  95. package/dist/commands/theme-css.d.ts.map +1 -1
  96. package/dist/commands/theme-css.js +33 -8
  97. package/dist/commands/theme-css.js.map +1 -1
  98. package/dist/commands/theme-registry.d.ts +94 -0
  99. package/dist/commands/theme-registry.d.ts.map +1 -0
  100. package/dist/commands/theme-registry.js +130 -0
  101. package/dist/commands/theme-registry.js.map +1 -0
  102. package/dist/commands/theme-render.d.ts +360 -2
  103. package/dist/commands/theme-render.d.ts.map +1 -1
  104. package/dist/commands/theme-render.js +984 -29
  105. package/dist/commands/theme-render.js.map +1 -1
  106. package/dist/commands/theme-wiring.d.ts +107 -0
  107. package/dist/commands/theme-wiring.d.ts.map +1 -0
  108. package/dist/commands/theme-wiring.js +295 -0
  109. package/dist/commands/theme-wiring.js.map +1 -0
  110. package/dist/commands/tools.d.ts +71 -0
  111. package/dist/commands/tools.d.ts.map +1 -0
  112. package/dist/commands/tools.js +330 -0
  113. package/dist/commands/tools.js.map +1 -0
  114. package/dist/commands/update.d.ts +25 -0
  115. package/dist/commands/update.d.ts.map +1 -0
  116. package/dist/commands/update.js +193 -0
  117. package/dist/commands/update.js.map +1 -0
  118. package/dist/commands/users.d.ts.map +1 -1
  119. package/dist/commands/users.js +6 -36
  120. package/dist/commands/users.js.map +1 -1
  121. package/dist/index.d.ts +15 -0
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +146 -4
  124. package/dist/index.js.map +1 -1
  125. package/dist/invite-mail.d.ts +39 -0
  126. package/dist/invite-mail.d.ts.map +1 -0
  127. package/dist/invite-mail.js +39 -0
  128. package/dist/invite-mail.js.map +1 -0
  129. package/dist/reset-mail.d.ts +39 -0
  130. package/dist/reset-mail.d.ts.map +1 -0
  131. package/dist/reset-mail.js +45 -0
  132. package/dist/reset-mail.js.map +1 -0
  133. package/dist/update/apply.d.ts +54 -0
  134. package/dist/update/apply.d.ts.map +1 -0
  135. package/dist/update/apply.js +77 -0
  136. package/dist/update/apply.js.map +1 -0
  137. package/dist/update/changelog-risk.d.ts +20 -0
  138. package/dist/update/changelog-risk.d.ts.map +1 -0
  139. package/dist/update/changelog-risk.js +55 -0
  140. package/dist/update/changelog-risk.js.map +1 -0
  141. package/dist/update/check.d.ts +60 -0
  142. package/dist/update/check.d.ts.map +1 -0
  143. package/dist/update/check.js +56 -0
  144. package/dist/update/check.js.map +1 -0
  145. package/dist/update/contract-risk.d.ts +55 -0
  146. package/dist/update/contract-risk.d.ts.map +1 -0
  147. package/dist/update/contract-risk.js +75 -0
  148. package/dist/update/contract-risk.js.map +1 -0
  149. package/dist/update/history.d.ts +30 -0
  150. package/dist/update/history.d.ts.map +1 -0
  151. package/dist/update/history.js +38 -0
  152. package/dist/update/history.js.map +1 -0
  153. package/dist/update/index.d.ts +28 -0
  154. package/dist/update/index.d.ts.map +1 -0
  155. package/dist/update/index.js +11 -0
  156. package/dist/update/index.js.map +1 -0
  157. package/dist/update/list-restore-points.d.ts +20 -0
  158. package/dist/update/list-restore-points.d.ts.map +1 -0
  159. package/dist/update/list-restore-points.js +35 -0
  160. package/dist/update/list-restore-points.js.map +1 -0
  161. package/dist/update/npm-registry.d.ts +11 -0
  162. package/dist/update/npm-registry.d.ts.map +1 -0
  163. package/dist/update/npm-registry.js +74 -0
  164. package/dist/update/npm-registry.js.map +1 -0
  165. package/dist/update/restore-point.d.ts +26 -0
  166. package/dist/update/restore-point.d.ts.map +1 -0
  167. package/dist/update/restore-point.js +23 -0
  168. package/dist/update/restore-point.js.map +1 -0
  169. package/dist/update/tar.d.ts +27 -0
  170. package/dist/update/tar.d.ts.map +1 -0
  171. package/dist/update/tar.js +56 -0
  172. package/dist/update/tar.js.map +1 -0
  173. package/dist/update/version-compare.d.ts +17 -0
  174. package/dist/update/version-compare.d.ts.map +1 -0
  175. package/dist/update/version-compare.js +42 -0
  176. package/dist/update/version-compare.js.map +1 -0
  177. package/dist/version.d.ts +13 -0
  178. package/dist/version.d.ts.map +1 -0
  179. package/dist/version.js +18 -0
  180. package/dist/version.js.map +1 -0
  181. package/package.json +33 -13
  182. package/dist/admin-assets/assets/index-Buwdj1V2.js +0 -71
  183. package/dist/admin-assets/assets/index-Csef0SiA.css +0 -1
@@ -1,10 +1,12 @@
1
1
  import { collectDependencies, } from '@cogenta/api';
2
- import { CogentaError } from '@cogenta/core';
2
+ import { CogentaError, isCogentaError } from '@cogenta/core';
3
3
  import { describeMedia, renderSkin } from '@cogenta/render';
4
- import { buildPath, matchPath, } from '@cogenta/schema';
5
- import { query as collectionListQuery, escapeAttribute, renderPage, serialize, } from '@cogenta/theme-canonical';
6
- import { alternatesForEntry, renderSeoHead, seoSiteFor } from './seo.js';
4
+ import { buildPath, matchPath, relationsOf, } from '@cogenta/schema';
5
+ import { buildCollectionListQuery as collectionListQuery, createThemeTranslator, entryExcerpt, entryImage, escapeAttribute, renderCommentsSection, serialize, THEME_TOGGLE_SCRIPT, } from '@cogenta/theme-kit';
6
+ import { DEFAULT_LOGO_PATH } from './default-logo.js';
7
+ import { alternatesForEntry, renderSeoHead, seoSiteFor, siteVerificationMetaTags } from './seo.js';
7
8
  import { minifyCss } from './theme-css.js';
9
+ import { DEFAULT_THEME_NAME, resolveTheme } from './theme-registry.js';
8
10
  /**
9
11
  * The theme's own `ContentEntry`/`QueryRequest` (`theme-contract.ts`) are a
10
12
  * deliberately separate, minimal public contract — a theme never imports
@@ -63,6 +65,233 @@ async function listAsTheme(gateway, request, context) {
63
65
  */
64
66
  /** Where `cogenta serve` publishes image variants. Public: a visitor's browser fetches them. */
65
67
  export const DEFAULT_IMAGE_ENDPOINT = '/_image';
68
+ /** "Nothing has ever been chosen" — the pre-T01 rendering, exactly. */
69
+ export const EMPTY_SITE_IDENTITY = {
70
+ logoMediaId: null,
71
+ logoDarkMediaId: null,
72
+ faviconMediaId: null,
73
+ shareImageMediaId: null,
74
+ };
75
+ /**
76
+ * Resolves the four identity media ids into what a page actually needs.
77
+ *
78
+ * One batch through the very same `loadMedia` every other image on the page
79
+ * goes through — never a second lookup path, and never a URL built from an
80
+ * id this renderer has not confirmed is a live image: a `kind !== 'image'`
81
+ * asset (a PDF someone picked in the media browser) resolves to `null` and
82
+ * the site falls back, rather than emitting a `<link rel="icon">` pointing
83
+ * at a document.
84
+ *
85
+ * **The share-image decision (audit T01, left open as "à trancher").**
86
+ * `seo.defaultSocialImageUrl` stays the one field the SEO pipeline reads;
87
+ * `shareImageMediaId` becomes a *source* for it, not a rival — when the
88
+ * appearance screen names a media, its `/_image` URL is what
89
+ * `fallbackImageFor` sees, and otherwise the SEO screen's URL is used
90
+ * unchanged. Two screens, one effective value, and neither field is
91
+ * silently dead: the appearance one wins because it is the more specific
92
+ * choice (a picked asset beats a typed URL), and because dropping it would
93
+ * throw away a setting sites have already saved.
94
+ */
95
+ async function resolveIdentity(siteName, imageEndpoint, options) {
96
+ const identity = options.identity === undefined ? EMPTY_SITE_IDENTITY : await options.identity();
97
+ const ids = [
98
+ identity.logoMediaId,
99
+ identity.logoDarkMediaId,
100
+ identity.faviconMediaId,
101
+ identity.shareImageMediaId,
102
+ ].filter((id) => id !== null && id !== '');
103
+ const assets = new Map();
104
+ if (ids.length > 0 && options.loadMedia !== undefined) {
105
+ for (const [id, asset] of await options.loadMedia([...new Set(ids)]))
106
+ assets.set(id, asset);
107
+ }
108
+ const sourceFor = (id) => {
109
+ if (id === null || id === '')
110
+ return null;
111
+ const asset = assets.get(id);
112
+ if (asset === undefined || asset.kind !== 'image')
113
+ return null;
114
+ return describeMedia(asset, {}, { endpoint: imageEndpoint, mediaEndpoint: imageEndpoint });
115
+ };
116
+ const favicon = sourceFor(identity.faviconMediaId);
117
+ const share = sourceFor(identity.shareImageMediaId);
118
+ return {
119
+ brand: {
120
+ name: siteName,
121
+ logo: sourceFor(identity.logoMediaId),
122
+ logoDark: sourceFor(identity.logoDarkMediaId),
123
+ faviconUrl: favicon === null ? null : favicon.src,
124
+ },
125
+ faviconHref: favicon === null ? null : favicon.src,
126
+ shareImageUrl: share === null ? null : share.src,
127
+ };
128
+ }
129
+ /**
130
+ * `<link rel="icon">`, and what it falls back to.
131
+ *
132
+ * The fallback is **branding-aware**, and that is not a detail: Cogenta's
133
+ * default icon is Cogenta's own logo, so a site that turned the credit off
134
+ * (fiche L21 task 8) and then got that logo back in its browser tab would
135
+ * have its white-labelling undone by the very change that started serving a
136
+ * favicon at all. A white-labelled site therefore falls back to its own
137
+ * uploaded replacement logo, and to *no icon tag whatsoever* when it has
138
+ * none — a browser's own blank default, which is the honest answer, not
139
+ * somebody else's mark.
140
+ *
141
+ * The `type` is only ever declared for the default, which really is the PNG
142
+ * this package ships; a media-library asset goes through `/_image`, which
143
+ * answers WebP or PNG depending on what the upload pipeline wrote, so
144
+ * declaring `image/png` there would be a claim this file cannot make. A
145
+ * browser sniffs the bytes either way.
146
+ */
147
+ function faviconLinkTag(chosen, branding, imageEndpoint) {
148
+ const href = chosen ?? defaultFaviconFor(branding, imageEndpoint);
149
+ if (href === null)
150
+ return '';
151
+ const typeAttr = href === DEFAULT_LOGO_PATH ? ' type="image/png"' : '';
152
+ return `<link rel="icon"${typeAttr} href="${escapeAttribute(href)}">`;
153
+ }
154
+ function defaultFaviconFor(branding, imageEndpoint) {
155
+ if (branding.showCogentaBranding)
156
+ return DEFAULT_LOGO_PATH;
157
+ if (branding.customLogoMediaId !== null && branding.customLogoMediaId !== '') {
158
+ return `${imageEndpoint}?id=${encodeURIComponent(branding.customLogoMediaId)}&w=64`;
159
+ }
160
+ return null;
161
+ }
162
+ /**
163
+ * Four of the five built-in themes pull their typefaces from Google Fonts
164
+ * with a CSS `@import`, which the browser only discovers *after* the
165
+ * stylesheet has downloaded and parsed — two extra round trips before the
166
+ * first glyph is requested. `preconnect` collapses the DNS/TLS half of that
167
+ * wait, and it is emitted unconditionally on purpose: the cost of two unused
168
+ * hints on a system-font theme is a few dozen bytes, while making it
169
+ * conditional would mean this file keeping a list of which themes use web
170
+ * fonts — exactly the per-theme knowledge the chrome extension point exists
171
+ * to keep out of here.
172
+ *
173
+ * `font-display: swap` itself is already declared by every one of those four
174
+ * `@import` URLs (`&display=swap`), so no font blocks first paint today;
175
+ * these hints are the remaining half of that fix.
176
+ */
177
+ /**
178
+ * The site's own feeds, discoverable (audit T03). A reader pasting the site's
179
+ * URL into a feed reader finds them through these two tags and nothing else —
180
+ * the routes existed before this and were invisible.
181
+ *
182
+ * Both formats are advertised because readers disagree about which they
183
+ * prefer, and both are served from the same content, so offering one would
184
+ * only make the other undiscoverable for no gain.
185
+ */
186
+ function feedLinkTags(siteName) {
187
+ const title = escapeAttribute(siteName);
188
+ return (`<link rel="alternate" type="application/rss+xml" title="${title}" href="/feed.xml">` +
189
+ `<link rel="alternate" type="application/atom+xml" title="${title}" href="/atom.xml">`);
190
+ }
191
+ function fontPreconnectTags() {
192
+ return (`<link rel="preconnect" href="https://fonts.googleapis.com">` +
193
+ `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`);
194
+ }
195
+ const DEFAULT_BRANDING = { showCogentaBranding: true, customLogoMediaId: null };
196
+ async function brandingFor(get) {
197
+ if (get === undefined)
198
+ return DEFAULT_BRANDING;
199
+ return get();
200
+ }
201
+ /** Resolves the active theme module — `theme-registry.ts`'s own fallback covers an absent or unrecognised name. */
202
+ async function themeFor(get) {
203
+ const name = get === undefined ? DEFAULT_THEME_NAME : await get();
204
+ return resolveTheme(name);
205
+ }
206
+ const EMPTY_CHROME_EXTRAS = { tagline: '', social: [], footerNote: '' };
207
+ async function chromeExtrasFor(get, locale) {
208
+ if (get === undefined)
209
+ return EMPTY_CHROME_EXTRAS;
210
+ return get(locale);
211
+ }
212
+ /**
213
+ * The `location` a menu is assigned to for it to become `ChromeInput.headerAction`
214
+ * (contract D `theme@1.4`) — a site names its own menu at this location from
215
+ * the admin's menu screen, exactly the way `header-nav`/`footer-nav` already
216
+ * work for `headerNav`/`footerNav`.
217
+ */
218
+ export const HEADER_ACTION_MENU_LOCATION = 'header-action';
219
+ /**
220
+ * The first link of the menu assigned to `HEADER_ACTION_MENU_LOCATION`, or
221
+ * `undefined` when no menu is assigned there (or it has no menu router
222
+ * wired at all) — never a legacy-name fallback, unlike
223
+ * `fetchMenuLinksForSlot`'s header/footer nav: this location is new in L25,
224
+ * so there is no pre-existing convention it needs to keep working.
225
+ */
226
+ async function resolveHeaderAction(locale, menus, context) {
227
+ if (menus?.menuRouter === undefined)
228
+ return undefined;
229
+ const links = await fetchMenuLinksByLocation(HEADER_ACTION_MENU_LOCATION, locale, menus, context);
230
+ const first = links?.[0];
231
+ if (first === undefined || first.href === null)
232
+ return undefined;
233
+ return { label: first.label, href: first.href };
234
+ }
235
+ /**
236
+ * The one place every `renderChrome` call resolves the four `theme@1.4`
237
+ * fields (D2) from — `general.tagline`/`general.socialLinks`/
238
+ * `general.footerNote` through whichever `chromeExtras` reader the caller
239
+ * wired (`chromeExtrasForSite`, `@cogenta/cli`'s `serve.ts`), and
240
+ * `headerAction` from the live menu router right here, since it needs no
241
+ * database read of its own. Every field is omitted, never emitted as `''`/
242
+ * `[]`, when it has nothing to say — a theme's own "is this set" check
243
+ * (`input.tagline !== undefined`, say) stays a plain presence check.
244
+ */
245
+ async function resolveChromeExtras(getExtras, locale, menus, context) {
246
+ const [extras, headerAction] = await Promise.all([
247
+ chromeExtrasFor(getExtras, locale),
248
+ resolveHeaderAction(locale, menus, context),
249
+ ]);
250
+ return {
251
+ ...(extras.tagline === '' ? {} : { tagline: extras.tagline }),
252
+ ...(extras.social.length === 0 ? {} : { social: extras.social }),
253
+ ...(extras.footerNote === '' ? {} : { footerNote: extras.footerNote }),
254
+ ...(headerAction === undefined ? {} : { headerAction }),
255
+ };
256
+ }
257
+ /**
258
+ * The footer's own branding block — Cogenta's real logo and a link back to
259
+ * the project by default, the site's uploaded replacement once Cogenta's
260
+ * credit is turned off, or nothing at all once it is off with no
261
+ * replacement. `imageEndpoint` is the same `/_image` (or override) every
262
+ * other image on the page already resolves through — no second delivery
263
+ * path for this one image.
264
+ */
265
+ function renderFooterBranding(branding, imageEndpoint) {
266
+ if (branding.showCogentaBranding) {
267
+ const versionSuffix = branding.cogentaVersion === undefined || branding.cogentaVersion === ''
268
+ ? ''
269
+ : ` <span class="cg-site-footer__version">v${escapeHtml(branding.cogentaVersion)}</span>`;
270
+ return (`<div class="cg-site-footer__branding">` +
271
+ `<a href="https://github.com/cogenta-cms/cogenta" rel="noopener" target="_blank">` +
272
+ `<img src="${DEFAULT_LOGO_PATH}" width="32" height="32" alt="Cogenta" ` +
273
+ `class="cg-site-footer__brand-logo" loading="lazy">${versionSuffix}</a></div>`);
274
+ }
275
+ if (branding.customLogoMediaId !== null && branding.customLogoMediaId !== '') {
276
+ const src = `${imageEndpoint}?id=${encodeURIComponent(branding.customLogoMediaId)}&w=64`;
277
+ return (`<div class="cg-site-footer__branding">` +
278
+ `<img src="${escapeAttribute(src)}" width="32" height="32" alt="" ` +
279
+ `class="cg-site-footer__brand-logo" loading="lazy"></div>`);
280
+ }
281
+ return '';
282
+ }
283
+ /** The hardcoded fallback every blueprint's home page uses when no `reading.homePath` setting is stored. */
284
+ const DEFAULT_HOME_PATH = '/home';
285
+ async function homePathFor(options) {
286
+ if (options.homePath === undefined)
287
+ return DEFAULT_HOME_PATH;
288
+ const configured = await options.homePath();
289
+ return configured === null || configured === '' ? DEFAULT_HOME_PATH : configured;
290
+ }
291
+ /** This stand-in theme's own default header slot — see `ThemeRenderOptions.headerMenuLocation`. */
292
+ export const DEFAULT_HEADER_MENU_LOCATION = 'primary';
293
+ /** This stand-in theme's own default footer slot — see `ThemeRenderOptions.footerMenuLocation`. */
294
+ export const DEFAULT_FOOTER_MENU_LOCATION = 'footer';
66
295
  /**
67
296
  * Where the served stylesheet lives. Under `/_cogenta/` for the same reason
68
297
  * `Base.astro` puts the skin there: it is a namespace no collection route can
@@ -99,9 +328,249 @@ function toVocabularyBlocks(entry, collection) {
99
328
  },
100
329
  ];
101
330
  }
102
- function entryTitle(entry) {
103
- const value = entry.values['title'];
104
- return typeof value === 'string' && value.trim() !== '' ? value : entry.id;
331
+ function richTextRefs(document) {
332
+ const media = new Set();
333
+ const links = [];
334
+ for (const node of document) {
335
+ if (node._type === 'media') {
336
+ media.add(node.id);
337
+ continue;
338
+ }
339
+ // A thematic break (fiche 42 task 2) carries no data beyond its key —
340
+ // no `markDefs` to walk, unlike a text block.
341
+ if (node._type === 'hr')
342
+ continue;
343
+ for (const definition of node.markDefs) {
344
+ if (definition._type === 'internalLink') {
345
+ links.push({ collection: definition.collection, id: definition.id });
346
+ }
347
+ }
348
+ }
349
+ return { media: [...media], links };
350
+ }
351
+ /**
352
+ * The `media` nodes and `internalLink` marks a page's rich text carries
353
+ * (ADR-0013), gathered up front for the same reason `collectionList`'s
354
+ * entries are: `renderBlock` is pure and synchronous (contract D), so
355
+ * `ctx.image()` and `link()` cannot await a lookup once rendering starts.
356
+ *
357
+ * `@cogenta/api`'s `collectDependencies` does not reach inside a `richText`
358
+ * value — it only walks a collection's own declared `media`/`relation`
359
+ * fields — so a media node or an internal link living *inside* prose would
360
+ * otherwise never be fetched, and `ctx.image()` would throw on a perfectly
361
+ * valid page. Only `prose` carries a top-level `richText` field in contract
362
+ * B's vocabulary (`body`); a `faq` answer is also rich text, but nested
363
+ * inside a repeated item the same way gallery/logo media is, which
364
+ * `collectDependencies` already treats as a known, separate limitation.
365
+ */
366
+ function collectRichTextAssets(blocks) {
367
+ const media = new Set();
368
+ const links = [];
369
+ for (const block of blocks) {
370
+ if (block._type !== 'prose')
371
+ continue;
372
+ const refs = richTextRefs(block.body);
373
+ for (const id of refs.media)
374
+ media.add(id);
375
+ links.push(...refs.links);
376
+ }
377
+ return { media: [...media], links };
378
+ }
379
+ // A collection is free to call its title field anything (`vitrine`'s
380
+ // `service`, `restaurant`'s `menu_item`, `store`'s `product` and `saas`'s
381
+ // `feature` all use `name`, never `title`) — the same fallback chain
382
+ // `@cogenta/theme-kit`'s own `entryTitle` already follows for a theme's
383
+ // cards and lists, so a page's `<title>`/`<h1>` reads the same name rather
384
+ // than falling back to the entry's raw id.
385
+ const TITLE_FIELDS = ['title', 'name', 'label'];
386
+ export function entryTitle(entry) {
387
+ for (const field of TITLE_FIELDS) {
388
+ const value = entry.values[field];
389
+ if (typeof value === 'string' && value.trim() !== '')
390
+ return value;
391
+ }
392
+ return entry.id;
393
+ }
394
+ /**
395
+ * Looks a menu up through the exact same `MenuRouter` `/api/menus/*` is
396
+ * mounted with — an in-process call, `RestRequest` in and `RestResponse`
397
+ * out, never a second lookup path or a real HTTP round trip to itself.
398
+ * Shared by the by-location and by-name lookups below; only the path
399
+ * differs.
400
+ *
401
+ * `null` for "no menu router wired" and "no menu found at all" alike: both
402
+ * mean the slot renders empty, exactly as it always has.
403
+ */
404
+ async function fetchMenuLinksFromPath(path, locale, options, context) {
405
+ if (options.menuRouter === undefined)
406
+ return null;
407
+ const response = await options.menuRouter.handle({ method: 'GET', path, query: { locale } }, context);
408
+ if (response.status !== 200)
409
+ return null;
410
+ const body = response.body;
411
+ const items = body?.data?.items;
412
+ if (!Array.isArray(items))
413
+ return null;
414
+ return items.map((item) => ({
415
+ label: item.resolvedLabel ?? item.label,
416
+ // `url` carries its own stored destination; every other resolvable kind
417
+ // (`entry`, `taxonomy`, `home`) is only ever linked through the
418
+ // resolver's answer — never a second, ad hoc way to derive a route. A
419
+ // `submenu-placeholder` has no resolver call at all, so `resolvedRoute`
420
+ // is `undefined` for it and this falls through to `null`, exactly the
421
+ // "no link, just a heading" case `renderMenuLinks` keeps.
422
+ href: item.kind === 'url' ? item.url : (item.resolvedRoute ?? null),
423
+ openInNewTab: item.openInNewTab,
424
+ kind: item.kind,
425
+ title: item.title,
426
+ }));
427
+ }
428
+ /** Looks a menu up by its `location` (fiche 09, task 3) — the generic, theme-agnostic resolution. */
429
+ function fetchMenuLinksByLocation(location, locale, options, context) {
430
+ return fetchMenuLinksFromPath(`/api/menus/by-location/${encodeURIComponent(location)}`, locale, options, context);
431
+ }
432
+ /** Looks a menu up by its machine `name` — the legacy resolution `fetchMenuLinksForSlot` falls back to. */
433
+ function fetchMenuLinksByName(name, locale, options, context) {
434
+ return fetchMenuLinksFromPath(`/api/menus/by-name/${encodeURIComponent(name)}`, locale, options, context);
435
+ }
436
+ /**
437
+ * Resolves the menu for one render slot (header or footer): by `location`
438
+ * first — the only mechanism a future second theme needs, since it is a
439
+ * property of the *menu*, never a name this file hardcodes — and, only when
440
+ * nothing is assigned there, by the legacy `name` convention (`main`,
441
+ * `footer`) this stand-in theme shipped with before locations existed. That
442
+ * fallback is what lets a site created before task 3 keep its navigation
443
+ * showing up unchanged: nothing about its data has to move for `/` to keep
444
+ * rendering the menu it already had.
445
+ */
446
+ async function fetchMenuLinksForSlot(location, legacyName, locale, options, context) {
447
+ const byLocation = await fetchMenuLinksByLocation(location, locale, options, context);
448
+ if (byLocation !== null)
449
+ return byLocation;
450
+ return fetchMenuLinksByName(legacyName, locale, options, context);
451
+ }
452
+ /**
453
+ * The one page frame every public page shares: skip link, `color-scheme`
454
+ * meta, site header with the primary navigation, the page's own content, the
455
+ * footer with its own navigation.
456
+ *
457
+ * Extracted from `renderEntryPage` (below) so `/search` and `/forms/{name}`
458
+ * (L20 audit, points 8-9) stop hand-rolling a second, thinner `<html>` shell
459
+ * that carried the stylesheet link but none of the site's chrome — the two
460
+ * pages looked unstyled not because the stylesheet failed to load, but
461
+ * because the markup the stylesheet's selectors target (`.cg-site-header`,
462
+ * `.cg-site-footer`, the skip link) was never there. `renderEntryPage` itself
463
+ * is not routed through this helper: its admin bar, comments section and
464
+ * analytics beacon are specific to a real content entry, and duplicating
465
+ * `renderPageChrome`'s call there would only move the divergence risk rather
466
+ * than remove it.
467
+ */
468
+ export async function renderPageChrome(options, context) {
469
+ let headerNav = [];
470
+ let footerNav = [];
471
+ if (options.menus?.menuRouter !== undefined) {
472
+ const [headerMenu, footerMenu] = await Promise.all([
473
+ fetchMenuLinksForSlot(options.menus.headerMenuLocation ?? DEFAULT_HEADER_MENU_LOCATION, 'main', options.locale, options.menus, context),
474
+ fetchMenuLinksForSlot(options.menus.footerMenuLocation ?? DEFAULT_FOOTER_MENU_LOCATION, 'footer', options.locale, options.menus, context),
475
+ ]);
476
+ headerNav = headerMenu ?? [];
477
+ footerNav = footerMenu ?? [];
478
+ }
479
+ const branding = await brandingFor(options.branding);
480
+ const brandingHtml = renderFooterBranding(branding, DEFAULT_IMAGE_ENDPOINT);
481
+ const theme = await themeFor(options.activeTheme);
482
+ const identity = await resolveIdentity(options.site.name, DEFAULT_IMAGE_ENDPOINT, options);
483
+ const chromeExtras = await resolveChromeExtras(options.chromeExtras, options.locale, options.menus, context);
484
+ const chrome = theme.renderChrome({
485
+ site: options.site,
486
+ locale: options.locale,
487
+ // Not locale-prefixed on purpose, matching this route's pre-existing
488
+ // behaviour — a genuine, pre-existing gap in a multi-locale deployment,
489
+ // tracked separately rather than folded into this change.
490
+ homeHref: '/',
491
+ headerNav,
492
+ footerNav,
493
+ brandingHtml,
494
+ brand: identity.brand,
495
+ ...chromeExtras,
496
+ });
497
+ const verificationTags = siteVerificationMetaTags(options.seo === undefined ? null : await options.seo());
498
+ return `<!doctype html>
499
+ <html lang="${escapeAttribute(options.locale)}" dir="auto">
500
+ <head>
501
+ <meta charset="utf-8">
502
+ <meta name="viewport" content="width=device-width, initial-scale=1">
503
+ <meta name="color-scheme" content="light dark">
504
+ <script>${THEME_TOGGLE_SCRIPT}</script>
505
+ ${faviconLinkTag(identity.faviconHref, branding, DEFAULT_IMAGE_ENDPOINT)}
506
+ ${feedLinkTags(options.site.name)}
507
+ ${fontPreconnectTags()}
508
+ ${options.headHtml}
509
+ ${verificationTags === '' ? '' : `${verificationTags}\n`}${options.styles === null ? '' : `<link rel="stylesheet" href="${STYLESHEET_PATH}">`}
510
+ </head>
511
+ <body>
512
+ <a class="cg-skip-link" href="#cg-main">Skip to content</a>
513
+ ${chrome.header}
514
+ ${options.bodyHtml}
515
+ ${chrome.footer}
516
+ </body>
517
+ </html>
518
+ `;
519
+ }
520
+ /**
521
+ * The public-site admin bar (fiche 35 task 6) — WordPress's most-used
522
+ * shortcut, in the three links this codebase can actually back today:
523
+ * straight into the admin shell, to editing this exact entry, and to
524
+ * starting a new one in the same collection.
525
+ *
526
+ * Plain `<a href>`s and one scoped `<style>` block, nothing else: the
527
+ * theme's own zero-executable-client-JavaScript policy (`serve.test.ts`,
528
+ * "no executable client JavaScript anywhere on the page") applies to this
529
+ * markup exactly as it does to the rest of the page — there is no `onclick`,
530
+ * no toggle, nothing that needs a script to work.
531
+ *
532
+ * Its three labels used to be hardcoded English, and its first one hardcoded
533
+ * the word "Cogenta" — visible to every signed-in editor of a French, or
534
+ * white-labelled, site (audit 2026-09-01, 10-coquille-reglages-dashboard.md
535
+ * T02). Both are now resolved: the name follows the same
536
+ * `showCogentaBranding` switch the footer credit already follows, and the
537
+ * labels come from the two-language table below.
538
+ *
539
+ * A table rather than `react-i18next` (ADR-0019's library) because this is
540
+ * pure server-rendered HTML with no React runtime anywhere near it; three
541
+ * strings do not justify pulling an i18n runtime into the render path. The
542
+ * language is the page's own — a visitor reading the French edition of a
543
+ * page gets French chrome, which is also the only locale signal this render
544
+ * has (an `Actor` carries `id` and `roles`, and no locale).
545
+ */
546
+ const ADMIN_BAR_LABELS = {
547
+ en: { edit: 'Edit this page', create: 'New' },
548
+ fr: { edit: 'Modifier cette page', create: 'Nouveau' },
549
+ };
550
+ function adminBarLabels(locale) {
551
+ // `fr-CA` and `fr` get the same table entry; anything with no entry gets
552
+ // English rather than a key or an empty string.
553
+ const base = locale.split('-')[0]?.toLowerCase() ?? 'en';
554
+ return ADMIN_BAR_LABELS[base] ?? ADMIN_BAR_LABELS.en;
555
+ }
556
+ function renderAdminBar(collectionName, entryId, options) {
557
+ const collection = encodeURIComponent(collectionName);
558
+ const entry = encodeURIComponent(entryId);
559
+ const labels = adminBarLabels(options.locale);
560
+ // A white-labelled site names itself, never the CMS behind it — the same
561
+ // rule `renderFooterBranding` already applies to the credit below.
562
+ const adminName = options.showCogentaBranding ? 'Cogenta' : options.siteName;
563
+ const home = escapeHtml(adminName);
564
+ return `<div class="cg-admin-bar" role="navigation" aria-label="${escapeAttribute(adminName)}">
565
+ <style>
566
+ .cg-admin-bar{display:flex;gap:1rem;align-items:center;padding:0.4rem 1rem;background:#1a1a1a;color:#fff;font:500 0.8125rem/1.4 system-ui,sans-serif;position:sticky;top:0;z-index:1000}
567
+ .cg-admin-bar a{color:#fff;text-decoration:none;opacity:0.85}
568
+ .cg-admin-bar a:hover,.cg-admin-bar a:focus-visible{opacity:1;text-decoration:underline}
569
+ </style>
570
+ <a href="/admin">${home}</a>
571
+ <a href="/admin/collections/${collection}/${entry}">${escapeHtml(labels.edit)}</a>
572
+ <a href="/admin/collections/${collection}/new">${escapeHtml(labels.create)}</a>
573
+ </div>`;
105
574
  }
106
575
  async function fetchOne(gateway, request, context) {
107
576
  const page = await gateway.list({ ...request, limit: 1 }, context);
@@ -114,14 +583,15 @@ async function fetchOne(gateway, request, context) {
114
583
  * this rather than each re-deriving the same filter from `match.params`.
115
584
  *
116
585
  * `/` itself matches no collection's route (every `page` pattern is
117
- * `/:slug`, which needs a real segment) every `create-cogenta` blueprint
118
- * seeds its home page at the real, consistent slug `home`, so `/` retries
119
- * once as `/home` rather than 404ing on the one URL a real visitor always
120
- * tries first. Not a magic redirect: a site with no page at that slug still
121
- * 404s honestly, exactly like every other unmatched path.
586
+ * `/:slug`, which needs a real segment), so `/` retries once as
587
+ * `homePathFor(options)` the `reading.homePath` site setting when one is
588
+ * stored (fiche 23 task 4), `/home` otherwise, the slug every
589
+ * `create-cogenta` blueprint seeds its home page at. Not a magic redirect: a
590
+ * site with no page at that path still 404s honestly, exactly like every
591
+ * other unmatched path.
122
592
  */
123
- async function resolveEntry(pathname, options, context) {
124
- const effectivePath = pathname === '/' ? '/home' : pathname;
593
+ export async function resolveEntry(pathname, options, context) {
594
+ const effectivePath = pathname === '/' ? await homePathFor(options) : pathname;
125
595
  const match = matchPath(options.collections, effectivePath, {
126
596
  locales: options.site.locales,
127
597
  defaultLocale: options.site.defaultLocale,
@@ -197,6 +667,78 @@ export async function renderDraftPage(draft, options, context) {
197
667
  const pathname = buildPath(collection, Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string')), entry.locale ?? undefined);
198
668
  return renderEntryPage(pathname, collection, entry, options, context);
199
669
  }
670
+ /** ~200 words/minute, rounded up — `PageContent.entry.readingMinutes` (contract D `theme@1.4`). `0` (no text at all) is not a reading time, so it is left unset instead. */
671
+ function richTextWordCount(document) {
672
+ let words = 0;
673
+ for (const node of document) {
674
+ if (node._type !== 'block')
675
+ continue;
676
+ const text = node.children.map((span) => span.text).join(' ');
677
+ words += text.split(/\s+/u).filter((word) => word.length > 0).length;
678
+ }
679
+ return words;
680
+ }
681
+ /**
682
+ * Every taxonomy term this entry is classified under, resolved to a label
683
+ * and a route through `options.resolveTerm` — one taxonomy field per
684
+ * `relationsOf(collection)` entry of `kind: 'taxonomy'`, a to-many field's
685
+ * value being an array of ids and a to-one field's a single id.
686
+ *
687
+ * `undefined` when `options.resolveTerm` was never wired — the pre-1.4
688
+ * behaviour — never a half-resolved list with some terms silently dropped.
689
+ */
690
+ async function entryTerms(entry, collection, resolveTerm) {
691
+ if (resolveTerm === undefined)
692
+ return undefined;
693
+ const taxonomyFields = relationsOf(collection).filter((relation) => relation.kind === 'taxonomy');
694
+ if (taxonomyFields.length === 0)
695
+ return undefined;
696
+ const terms = [];
697
+ for (const field of taxonomyFields) {
698
+ const raw = entry.values[field.field];
699
+ const ids = Array.isArray(raw) ? raw : typeof raw === 'string' && raw !== '' ? [raw] : [];
700
+ for (const id of ids) {
701
+ if (typeof id !== 'string' || id === '')
702
+ continue;
703
+ const resolved = await resolveTerm(field.to, id);
704
+ if (resolved === null)
705
+ continue;
706
+ terms.push({ taxonomy: field.to, label: resolved.label, href: resolved.route });
707
+ }
708
+ }
709
+ return terms.length === 0 ? undefined : terms;
710
+ }
711
+ /**
712
+ * `PageContent.entry` (contract D `theme@1.4`) — every field a theme's
713
+ * `renderEntryHeader` needs, beyond the blocks a page already carries.
714
+ * `themeEntry` is the already-flattened theme-shaped entry (`toThemeEntry`),
715
+ * which is what `entryImage`/`entryExcerpt` read; `entry`/`collection` are
716
+ * the raw schema shapes, which is what system fields and taxonomy field
717
+ * declarations live on.
718
+ */
719
+ async function buildEntryMeta(entry, collection, themeEntry, themeContext, options) {
720
+ const author = options.authorFor === undefined || entry.createdBy === null
721
+ ? undefined
722
+ : ((await options.authorFor(entry.createdBy)) ?? undefined);
723
+ const richTextField = Object.entries(collection.fields).find(([, field]) => field.kind === 'richText')?.[0];
724
+ const richTextValue = richTextField === undefined ? undefined : entry.values[richTextField];
725
+ const words = Array.isArray(richTextValue)
726
+ ? richTextWordCount(richTextValue)
727
+ : 0;
728
+ const image = entryImage(themeEntry, themeContext);
729
+ const excerpt = entryExcerpt(themeEntry);
730
+ const terms = await entryTerms(entry, collection, options.resolveTerm);
731
+ return {
732
+ collection: collection.name,
733
+ ...(entry.publishedAt === null ? {} : { publishedAt: entry.publishedAt }),
734
+ updatedAt: entry.updatedAt,
735
+ ...(image === undefined ? {} : { image }),
736
+ ...(excerpt === undefined ? {} : { excerpt }),
737
+ ...(author === undefined ? {} : { author }),
738
+ ...(terms === undefined ? {} : { terms }),
739
+ ...(words === 0 ? {} : { readingMinutes: Math.ceil(words / 200) }),
740
+ };
741
+ }
200
742
  /**
201
743
  * The one page renderer. Both `renderRequestedPage` (published) and
202
744
  * `renderDraftPage` (unsaved) end here, having only differed in how they got
@@ -225,20 +767,41 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
225
767
  }
226
768
  }
227
769
  const collectionsByName = new Map(options.collections.map((entry) => [entry.name, entry]));
770
+ // Rich text (ADR-0013) can carry its own `media` nodes and `internalLink`
771
+ // marks, invisible to `collectDependencies` (see `collectRichTextAssets`).
772
+ // Every linked entry is fetched through the same permission-checked
773
+ // `gateway.read` a preview already uses: a target that is trashed, still a
774
+ // draft, or simply gone comes back `null` and is left out of `knownEntries`
775
+ // — `link()` below then resolves it to `'#'`, and `renderRichText` turns
776
+ // that into plain text rather than a dead anchor, never a 404.
777
+ const richTextAssets = collectRichTextAssets(blocks);
778
+ for (const target of richTextAssets.links) {
779
+ if (knownEntries.has(target.id))
780
+ continue;
781
+ if (!collectionsByName.has(target.collection))
782
+ continue;
783
+ const found = await options.gateway.read(target.collection, target.id, context);
784
+ if (found === null)
785
+ continue;
786
+ knownEntries.set(found.id, found);
787
+ entryCollections.set(found.id, target.collection);
788
+ }
228
789
  // Which media this page references, from the same walk `/api/content` uses
229
790
  // to declare a response's dependencies (`collectDependencies`): declared
230
791
  // `media` fields *and* the media inside every block, resolved through the
231
792
  // block registry rather than guessed at from the JSON. A `ContentEntry`
232
793
  // plus its collection name is exactly a `SerialisedEntry`, which is why
233
- // this reuse costs nothing.
794
+ // this reuse costs nothing. Rich text's own media nodes are merged in
795
+ // separately, above.
234
796
  const mediaAssets = new Map();
235
797
  if (options.loadMedia !== undefined) {
236
798
  const dependencies = collectDependencies([...knownEntries].map(([id, found]) => ({
237
799
  ...found,
238
800
  collection: entryCollections.get(id) ?? collection.name,
239
801
  })), { collection: (name) => collectionsByName.get(name) });
240
- if (dependencies.media.length > 0) {
241
- for (const [id, asset] of await options.loadMedia(dependencies.media)) {
802
+ const mediaIds = new Set([...dependencies.media, ...richTextAssets.media]);
803
+ if (mediaIds.size > 0) {
804
+ for (const [id, asset] of await options.loadMedia([...mediaIds])) {
242
805
  mediaAssets.set(id, asset);
243
806
  }
244
807
  }
@@ -258,13 +821,26 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
258
821
  // doesn't build one.
259
822
  return '#';
260
823
  }
261
- return buildPath(targetCollection, Object.fromEntries(Object.entries(found.values).filter((pair) => typeof pair[1] === 'string')), found.locale ?? undefined);
824
+ try {
825
+ return buildPath(targetCollection, Object.fromEntries(Object.entries(found.values).filter((pair) => typeof pair[1] === 'string')), found.locale ?? undefined);
826
+ }
827
+ catch (error) {
828
+ // A route param can be missing on real content — a routed collection's
829
+ // slug-kind field is not `required`, so a saved-but-incomplete entry
830
+ // (a draft published without a slug, for instance) is reachable through
831
+ // a `collectionList` or a rich-text link long before anyone fixes it.
832
+ // One unresolvable target must not fail the whole page: the same "the
833
+ // honest answer is unresolvable" rule above applies here too.
834
+ if (isCogentaError(error) && error.code === 'CONTENT_ROUTE_INVALID')
835
+ return '#';
836
+ throw error;
837
+ }
262
838
  };
263
839
  const themeContext = {
264
840
  site: options.site,
265
841
  locale: entry.locale,
266
842
  url: new URL(pathname, options.site.url),
267
- t: (key) => key,
843
+ t: createThemeTranslator(entry.locale),
268
844
  // The real `srcset`, from `@cogenta/render`'s own `describeMedia` (L10
269
845
  // task 5). Pure and synchronous, as contract D requires: the asset was
270
846
  // loaded before this render started, and this only builds URLs against
@@ -314,15 +890,66 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
314
890
  list: (request) => listAsTheme(options.gateway, request, context),
315
891
  },
316
892
  };
317
- const pageContent = { title: entryTitle(entry), blocks };
318
- const node = renderPage(pageContent, themeContext, fetchedEntries);
893
+ const themeEntry = toThemeEntry(entry, collection.name);
894
+ const entryMeta = await buildEntryMeta(entry, collection, themeEntry, themeContext, options);
895
+ const pageContent = { title: entryTitle(entry), blocks, entry: entryMeta };
896
+ const theme = await themeFor(options.activeTheme);
897
+ const node = theme.renderPage(pageContent, themeContext, fetchedEntries, options.blocks);
319
898
  const bodyHtml = serialize(node);
899
+ // The comment thread and form (fiche 15 task 6) — a property of the route,
900
+ // not of the page's own blocks, so it is appended after `<main>` rather
901
+ // than folded into `renderPage`'s tree (see `ThemeRenderOptions.comments`'s
902
+ // own comment for why that also keeps the L16 fidelity test true).
903
+ const commentsOptions = options.comments;
904
+ let commentsHtml = '';
905
+ if (commentsOptions !== undefined) {
906
+ const data = await commentsOptions.forEntry(collection.name, entry.id, entry.locale);
907
+ // Closed and empty means "this page has no discussion", not "here is a
908
+ // discussion you may not join": a home page whose collection opted out
909
+ // of comments (every blueprint's `page` collection, L25) used to end on
910
+ // a "Comments (0) — closed" section anyway. A closed thread that already
911
+ // holds approved comments still shows them, read-only.
912
+ const hasThread = data.open || data.items.length > 0;
913
+ if (hasThread)
914
+ commentsHtml = serialize(renderCommentsSection({
915
+ comments: data.items,
916
+ open: data.open,
917
+ action: commentsOptions.action,
918
+ collection: collection.name,
919
+ entryId: entry.id,
920
+ locale: entry.locale,
921
+ pagePath: pathname,
922
+ ...(commentsOptions.honeypotField === undefined
923
+ ? {}
924
+ : { honeypotField: commentsOptions.honeypotField }),
925
+ renderedAt: Date.now(),
926
+ }));
927
+ }
320
928
  // The head is `@cogenta/seo`'s, not this file's: title, description,
321
929
  // canonical, hreflang, Open Graph, Twitter Card and JSON-LD, all derived
322
930
  // from the real entry and the real collection (L10 task 1). Nothing here
323
931
  // decides what is indexable — `buildMetaTags` asks `isPublished` itself, so
324
932
  // a preview render carries `noindex` without this caller remembering to.
325
- const seoSite = seoSiteFor(options.site);
933
+ //
934
+ // `seoSettings` (fiche 21 task 3) is fetched once here, read fresh on every
935
+ // render rather than cached at server startup — see `ThemeRenderOptions.seo`'s
936
+ // own doc comment. It feeds both the site-wide fields below (`description`,
937
+ // `twitterSite`) and the per-page title template/fallback image.
938
+ const storedSeoSettings = options.seo === undefined ? null : await options.seo();
939
+ // The site's own identity (audit T01). Resolved here rather than beside
940
+ // the chrome below because the share image it carries has to reach the SEO
941
+ // pipeline: `shareImageMediaId` is a *source* for
942
+ // `seo.defaultSocialImageUrl`, not a second social-image mechanism — see
943
+ // `resolveIdentity`.
944
+ const identity = await resolveIdentity(options.site.name, imageEndpoint, options);
945
+ // Resolved here, above both its consumers: the admin bar's own name follows
946
+ // the same white-label switch as the footer credit, and the favicon's
947
+ // fallback follows it too (see `faviconLinkTag`).
948
+ const branding = await brandingFor(options.branding);
949
+ const seoSettings = identity.shareImageUrl === null || storedSeoSettings === null
950
+ ? storedSeoSettings
951
+ : { ...storedSeoSettings, defaultSocialImageUrl: identity.shareImageUrl };
952
+ const seoSite = seoSiteFor(options.site, seoSettings);
326
953
  const resource = { collection, entry };
327
954
  const alternates = await alternatesForEntry(seoSite, collection, entry, options.gateway, context, options.site.locales);
328
955
  // `og:image` and JSON-LD's `image` come from the same assets the page just
@@ -344,13 +971,60 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
344
971
  };
345
972
  // `head` already carries a real `<title>` (`renderSeoHead`, above) — no
346
973
  // second one is written into the template below.
347
- const head = renderSeoHead(seoSite, resource, {
348
- ...(alternates.length === 0 ? {} : { alternates }),
349
- ...(mediaAssets.size === 0 ? {} : { media: seoMedia }),
974
+ const head = [
975
+ renderSeoHead(seoSite, resource, {
976
+ ...(alternates.length === 0 ? {} : { alternates }),
977
+ ...(mediaAssets.size === 0 ? {} : { media: seoMedia }),
978
+ ...(seoSettings === null ? {} : { seo: seoSettings }),
979
+ }),
980
+ // Search Console/Webmaster Tools verification (fiche 50 task 2) — the
981
+ // same site-wide tags on every page, not just the home page: neither
982
+ // provider documents which page it fetches to check ownership.
983
+ siteVerificationMetaTags(seoSettings),
984
+ ]
985
+ .filter((part) => part !== '')
986
+ .join('\n');
987
+ // Precaution 1 of 3 (fiche 35 task 6): only ever rendered for an actor
988
+ // this request's own `resolveActor` actually authenticated — the flag
989
+ // alone (set for every request on this path, anonymous included) is not
990
+ // the gate, this check is.
991
+ const adminBar = options.adminBar === true && context.actor.id !== null
992
+ ? renderAdminBar(collection.name, entry.id, {
993
+ siteName: options.site.name,
994
+ showCogentaBranding: branding.showCogentaBranding,
995
+ locale: themeContext.locale,
996
+ })
997
+ : '';
998
+ // The navigation menus (audit follow-up to L13's menu system, generalised
999
+ // to real `location`s by fiche 09 task 3): the menu assigned to the header
1000
+ // location, the one assigned to the footer location — see
1001
+ // `ThemeRenderOptions.headerMenuLocation`/`footerMenuLocation` for why the
1002
+ // location key is a per-render option rather than a name this file
1003
+ // hardcodes, and `fetchMenuLinksForSlot` for the legacy-name fallback that
1004
+ // keeps a pre-task-3 site's navigation rendering unchanged. Both are
1005
+ // `null`, rendering nothing, on a site with no menu router wired or no
1006
+ // menu in that slot at all — the same empty slots as before this was
1007
+ // wired.
1008
+ const [headerMenu, footerMenu] = await Promise.all([
1009
+ fetchMenuLinksForSlot(options.headerMenuLocation ?? DEFAULT_HEADER_MENU_LOCATION, 'main', themeContext.locale, options, context),
1010
+ fetchMenuLinksForSlot(options.footerMenuLocation ?? DEFAULT_FOOTER_MENU_LOCATION, 'footer', themeContext.locale, options, context),
1011
+ ]);
1012
+ const brandingHtml = renderFooterBranding(branding, imageEndpoint);
1013
+ const chromeExtras = await resolveChromeExtras(options.chromeExtras, themeContext.locale, options, context);
1014
+ const chrome = theme.renderChrome({
1015
+ site: options.site,
1016
+ locale: themeContext.locale,
1017
+ // Not locale-prefixed on purpose, matching this route's pre-existing
1018
+ // behaviour — see `renderPageChrome`'s own identical comment.
1019
+ homeHref: '/',
1020
+ headerNav: headerMenu ?? [],
1021
+ footerNav: footerMenu ?? [],
1022
+ brandingHtml,
1023
+ brand: identity.brand,
1024
+ ...chromeExtras,
350
1025
  });
351
- const siteName = escapeAttribute(options.site.name);
352
1026
  // The same frame `Base.astro` builds for a real Astro build: a skip link
353
- // first, the site name as a header, the content, a footer. Rendering the
1027
+ // first, the site's own chrome, the content, a footer. Rendering the
354
1028
  // `<main>` alone — which this did until the theme's own stylesheet started
355
1029
  // being served — left every page with no landmark to skip to and no way back
356
1030
  // to the home page.
@@ -360,18 +1034,53 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
360
1034
  <meta charset="utf-8">
361
1035
  <meta name="viewport" content="width=device-width, initial-scale=1">
362
1036
  <meta name="color-scheme" content="light dark">
1037
+ <script>${THEME_TOGGLE_SCRIPT}</script>
1038
+ ${faviconLinkTag(identity.faviconHref, branding, imageEndpoint)}
1039
+ ${feedLinkTags(options.site.name)}
1040
+ ${fontPreconnectTags()}
363
1041
  ${head}
364
1042
  ${options.styles === null ? '' : `<link rel="stylesheet" href="${STYLESHEET_PATH}">`}
365
1043
  </head>
366
1044
  <body>
367
1045
  <a class="cg-skip-link" href="#cg-main">Skip to content</a>
368
- <header class="cg-site-header"><div class="cg-site-header__inner"><a class="cg-site-header__home" href="/">${siteName}</a></div></header>
1046
+ ${adminBar}
1047
+ ${chrome.header}
369
1048
  ${bodyHtml}
370
- <footer class="cg-site-footer"><div class="cg-site-footer__inner">${siteName}</div></footer>
1049
+ ${commentsHtml}
1050
+ ${chrome.footer}
1051
+ ${analyticsBeaconTag(pathname, options.analyticsBeacon)}
371
1052
  </body>
372
1053
  </html>
373
1054
  `;
374
1055
  }
1056
+ /**
1057
+ * Self-hosted, cookie-free page-view analytics (`@cogenta/analytics`), L10
1058
+ * analytics gap. An invisible `<img>` pixel rather than any inline
1059
+ * `<script>`: the theme's own policy is **zero executable client
1060
+ * JavaScript** on a rendered page (enforced by `serve.test.ts`), so a script
1061
+ * reading `document.referrer` is not an option here. Everything the pixel's
1062
+ * URL needs — the path being viewed, and the `Referer` header of the request
1063
+ * that is rendering this very page — is already known server-side, so
1064
+ * nothing needs to run in the browser to capture it.
1065
+ *
1066
+ * `undefined` (no `analyticsBeacon` at all) omits the tag entirely — used
1067
+ * for the page builder's draft preview, which must not be counted as a real
1068
+ * visit. `alt=""` and the visually-hidden inline style keep it out of a
1069
+ * screen reader and off the visible page without `display:none`, which some
1070
+ * older ad-blocking heuristics treat as a signal to strip the element (and
1071
+ * losing the pixel loses nothing here — it fails silently either way, R1/R2
1072
+ * spirit: analytics is additive, never load-bearing).
1073
+ */
1074
+ function analyticsBeaconTag(pathname, beacon) {
1075
+ if (beacon === undefined)
1076
+ return '';
1077
+ const params = new URLSearchParams({ p: pathname });
1078
+ if (beacon.referrer !== undefined && beacon.referrer !== '') {
1079
+ params.set('r', beacon.referrer);
1080
+ }
1081
+ const src = escapeAttribute(`/api/analytics/beacon?${params.toString()}`);
1082
+ return `<img src="${src}" alt="" width="1" height="1" loading="eager" decoding="async" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">`;
1083
+ }
375
1084
  /**
376
1085
  * Reads `theme.tokens.json` next to the config and renders it through the
377
1086
  * real, already-tested `renderSkin` (contract D validation + `--cogenta-*`
@@ -402,4 +1111,250 @@ export function joinStyles(skinCss, themeCss) {
402
1111
  ].filter((sheet) => sheet !== null);
403
1112
  return sheets.length === 0 ? null : sheets.join('\n');
404
1113
  }
1114
+ /**
1115
+ * The appearance screen's theme gallery preview (fiche L24 task 5) — one
1116
+ * fixed, database-free demo page, rendered through whichever theme package
1117
+ * is asked for by name.
1118
+ *
1119
+ * **Option (a) of the task's own note, chosen over (b) ("preview the site's
1120
+ * real home page")**, for two reasons:
1121
+ *
1122
+ * 1. an admin comparing themes is often doing it *before* the site has any
1123
+ * content at all — the moment a real-home-page preview would have
1124
+ * nothing to show, or would 404 the whole comparison;
1125
+ * 2. every candidate theme has to render the *same* page for the
1126
+ * comparison to mean anything. A real home page would let one theme's
1127
+ * card look richer than another's purely because that site's current
1128
+ * home happens to use more blocks — an artifact of this site's content,
1129
+ * not of the theme.
1130
+ *
1131
+ * The three blocks below (hero, collectionList, featureGrid) are the same
1132
+ * shape `create-cogenta`'s "blog" blueprint seeds a real home page with
1133
+ * (`packages/create-cogenta/src/blueprints/blog.ts`) — realistic content a
1134
+ * theme actually has to lay out, not lorem ipsum — but the `collectionList`
1135
+ * entries are fabricated in-process rather than queried, and nothing here
1136
+ * ever touches `ContentGateway`/the database: a card in this gallery cannot
1137
+ * leak draft or private content because it never reads content of any kind.
1138
+ * `link()` and `image()` below reflect that — an entry link resolves to
1139
+ * `'#'` rather than a real route, and `image()` refuses, because the demo
1140
+ * page never references a real media asset in the first place.
1141
+ */
1142
+ const GALLERY_PREVIEW_BLOCK_VERSION = '1.0.0';
1143
+ const GALLERY_PREVIEW_COLLECTION_LIST_KEY = 'gallery-preview-posts';
1144
+ function galleryPreviewPage() {
1145
+ return {
1146
+ title: 'A site that looks like yours',
1147
+ blocks: [
1148
+ {
1149
+ _key: 'gallery-preview-hero',
1150
+ _type: 'hero',
1151
+ _version: GALLERY_PREVIEW_BLOCK_VERSION,
1152
+ eyebrow: 'Preview',
1153
+ title: 'A site that looks like yours',
1154
+ subtitle: 'This is fixed demo content, shown identically across every theme, so you can compare layouts on equal footing.',
1155
+ actions: [
1156
+ { label: 'Get started', target: { href: '#' }, emphasis: 'primary' },
1157
+ { label: 'Learn more', target: { href: '#' }, emphasis: 'secondary' },
1158
+ ],
1159
+ },
1160
+ {
1161
+ _key: GALLERY_PREVIEW_COLLECTION_LIST_KEY,
1162
+ _type: 'collectionList',
1163
+ _version: GALLERY_PREVIEW_BLOCK_VERSION,
1164
+ title: 'Latest posts',
1165
+ collection: 'post',
1166
+ sort: { field: 'createdAt', direction: 'desc' },
1167
+ limit: 3,
1168
+ layout: 'list',
1169
+ },
1170
+ {
1171
+ _key: 'gallery-preview-features',
1172
+ _type: 'featureGrid',
1173
+ _version: GALLERY_PREVIEW_BLOCK_VERSION,
1174
+ title: 'What you get',
1175
+ items: [
1176
+ {
1177
+ _key: 'gallery-preview-feature-1',
1178
+ icon: 'blocks',
1179
+ title: 'Blocks, not HTML',
1180
+ text: 'Every section of a page is structured data. The theme decides what it looks like.',
1181
+ },
1182
+ {
1183
+ _key: 'gallery-preview-feature-2',
1184
+ icon: 'content',
1185
+ title: 'Your real content',
1186
+ text: 'Switching themes never touches your entries — only how they are laid out.',
1187
+ },
1188
+ {
1189
+ _key: 'gallery-preview-feature-3',
1190
+ icon: 'zero-js',
1191
+ title: 'No client JavaScript',
1192
+ text: 'Every theme in this gallery ships zero executable client JavaScript.',
1193
+ },
1194
+ ],
1195
+ },
1196
+ ],
1197
+ };
1198
+ }
1199
+ /** Three fabricated posts for the demo `collectionList` above — never a real query result. */
1200
+ function galleryPreviewEntries(locale) {
1201
+ const posts = [
1202
+ {
1203
+ title: 'Welcome to your new site',
1204
+ excerpt: 'A short introduction to what you can do here, once real content replaces this demo.',
1205
+ },
1206
+ {
1207
+ title: 'How themes work',
1208
+ excerpt: 'A theme lays out your content; it never stores any of it. Switch freely.',
1209
+ },
1210
+ {
1211
+ title: 'Zero client JavaScript, by policy',
1212
+ excerpt: 'Every theme in the gallery renders without shipping a single script to the browser.',
1213
+ },
1214
+ ];
1215
+ return posts.map((post, index) => ({
1216
+ id: `gallery-preview-${index}`,
1217
+ collection: 'post',
1218
+ locale,
1219
+ status: 'published',
1220
+ title: post.title,
1221
+ excerpt: post.excerpt,
1222
+ createdAt: new Date(Date.now() - index * 86_400_000).toISOString(),
1223
+ }));
1224
+ }
1225
+ /** Demo navigation for the gallery preview's header/footer — never a real menu lookup. */
1226
+ const GALLERY_PREVIEW_HEADER_NAV = [
1227
+ { label: 'Home', href: '/', openInNewTab: false, kind: 'url', title: null },
1228
+ { label: 'Blog', href: '#', openInNewTab: false, kind: 'url', title: null },
1229
+ { label: 'About', href: '#', openInNewTab: false, kind: 'url', title: null },
1230
+ ];
1231
+ const GALLERY_PREVIEW_FOOTER_NAV = [
1232
+ { label: 'Privacy', href: '#', openInNewTab: false, kind: 'url', title: null },
1233
+ ];
1234
+ /**
1235
+ * Synthetic `theme@1.4` chrome (contract D, L25 D2) — this route reads no
1236
+ * database (see `renderThemeGalleryPreview`'s own comment), so a real
1237
+ * `general.socialLinks`/`general.tagline`/`general.footerNote` never reaches
1238
+ * it; fixed demo values instead, the same way the nav above is fixed rather
1239
+ * than empty, so the gallery actually shows what a candidate theme does with
1240
+ * these fields rather than leaving them permanently blank.
1241
+ */
1242
+ const GALLERY_PREVIEW_TAGLINE = 'A site that looks like yours';
1243
+ const GALLERY_PREVIEW_SOCIAL = [
1244
+ { label: 'X', href: 'https://x.com/cogenta' },
1245
+ { label: 'GitHub', href: 'https://github.com/cogenta-cms' },
1246
+ { label: 'Mastodon', href: 'https://mastodon.social/@cogenta' },
1247
+ ];
1248
+ const GALLERY_PREVIEW_FOOTER_NOTE = 'Built with Cogenta — a CMS that runs itself.';
1249
+ const GALLERY_PREVIEW_HEADER_ACTION = { label: 'Get started', href: '#' };
1250
+ /**
1251
+ * Renders the fixed demo page above through `themeName`, resolved through
1252
+ * the same `theme-registry.ts` every other theme lookup on this server uses.
1253
+ * An unrecognised name is the caller's responsibility to refuse before
1254
+ * calling this — `resolveTheme` itself falls back to the built-in default
1255
+ * rather than throwing (see its own comment), which is the right behaviour
1256
+ * for a live page render but not for a gallery card that claims to preview
1257
+ * one specific theme.
1258
+ */
1259
+ export async function renderThemeGalleryPreview(themeName, options) {
1260
+ const theme = await resolveTheme(themeName);
1261
+ const locale = options.site.defaultLocale;
1262
+ const link = (target) => {
1263
+ if (typeof target === 'string')
1264
+ return target;
1265
+ if ('path' in target)
1266
+ return target.path;
1267
+ // A demo entry has no real route — same "honest unresolvable answer" as
1268
+ // `renderEntryPage`'s own `link`, just never populated here.
1269
+ return '#';
1270
+ };
1271
+ const image = (media, _imageOptions) => {
1272
+ throw new CogentaError({
1273
+ code: 'THEME_IMAGE_UNSUPPORTED',
1274
+ message: `No media asset "${media}" is available to the theme gallery preview.`,
1275
+ hint: 'This preview renders fixed, image-free demo content by design — see renderThemeGalleryPreview.',
1276
+ details: { media },
1277
+ });
1278
+ };
1279
+ const themeContext = {
1280
+ site: options.site,
1281
+ locale,
1282
+ url: new URL('/', options.site.url),
1283
+ t: createThemeTranslator(locale),
1284
+ image,
1285
+ link,
1286
+ content: {
1287
+ entry: async () => null,
1288
+ byPath: async () => null,
1289
+ list: async () => ({ items: [], nextCursor: null }),
1290
+ },
1291
+ };
1292
+ const pageContent = galleryPreviewPage();
1293
+ const fetchedEntries = {
1294
+ [GALLERY_PREVIEW_COLLECTION_LIST_KEY]: galleryPreviewEntries(locale),
1295
+ };
1296
+ const bodyHtml = serialize(theme.renderPage(pageContent, themeContext, fetchedEntries));
1297
+ const brandingHtml = renderFooterBranding(await brandingFor(options.branding), DEFAULT_IMAGE_ENDPOINT);
1298
+ const chrome = theme.renderChrome({
1299
+ site: options.site,
1300
+ locale,
1301
+ homeHref: '/',
1302
+ headerNav: GALLERY_PREVIEW_HEADER_NAV,
1303
+ footerNav: GALLERY_PREVIEW_FOOTER_NAV,
1304
+ brandingHtml,
1305
+ tagline: GALLERY_PREVIEW_TAGLINE,
1306
+ social: GALLERY_PREVIEW_SOCIAL,
1307
+ footerNote: GALLERY_PREVIEW_FOOTER_NOTE,
1308
+ headerAction: GALLERY_PREVIEW_HEADER_ACTION,
1309
+ });
1310
+ return `<!doctype html>
1311
+ <html lang="${escapeAttribute(locale)}" dir="auto">
1312
+ <head>
1313
+ <meta charset="utf-8">
1314
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1315
+ <meta name="color-scheme" content="light dark">
1316
+ <script>${THEME_TOGGLE_SCRIPT}</script>
1317
+ <meta name="robots" content="noindex, nofollow">
1318
+ <title>${escapeHtml(pageContent.title)}</title>
1319
+ ${options.styles === null ? '' : `<style>${options.styles}</style>`}
1320
+ </head>
1321
+ <body>
1322
+ <a class="cg-skip-link" href="#cg-main">Skip to content</a>
1323
+ ${chrome.header}
1324
+ ${bodyHtml}
1325
+ ${chrome.footer}
1326
+ </body>
1327
+ </html>
1328
+ `;
1329
+ }
1330
+ function escapeHtml(value) {
1331
+ return value
1332
+ .replaceAll('&', '&amp;')
1333
+ .replaceAll('<', '&lt;')
1334
+ .replaceAll('>', '&gt;')
1335
+ .replaceAll('"', '&quot;');
1336
+ }
1337
+ /**
1338
+ * The public wait page for maintenance mode (fiche 24 task 5) — deliberately
1339
+ * theme-agnostic (no skin, no block, no `RenderContext`): a site down for
1340
+ * maintenance may be down for exactly the reason its theme cannot render, so
1341
+ * this page must stand on its own, unstyled beyond a few inline rules.
1342
+ */
1343
+ export function renderMaintenancePage(siteName, message) {
1344
+ const body = message === null ? 'We are performing scheduled maintenance. Please check back soon.' : message;
1345
+ return `<!doctype html>
1346
+ <html lang="en">
1347
+ <head>
1348
+ <meta charset="utf-8">
1349
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1350
+ <title>${escapeHtml(siteName)} — Maintenance</title>
1351
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#1a1a1a}h1{font-size:1.25rem}</style>
1352
+ </head>
1353
+ <body>
1354
+ <h1>${escapeHtml(siteName)}</h1>
1355
+ <p>${escapeHtml(body)}</p>
1356
+ </body>
1357
+ </html>
1358
+ `;
1359
+ }
405
1360
  //# sourceMappingURL=theme-render.js.map