@cogenta/cli 0.4.0 → 0.7.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 (174) hide show
  1. package/CHANGELOG.md +3951 -0
  2. package/dist/admin-assets/assets/index-Bc_X94A_.js +1090 -0
  3. package/dist/admin-assets/assets/index-CxZHGwVA.css +1 -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/jetbrains-mono-var.woff2 +0 -0
  14. package/dist/admin-assets/fonts/space-grotesk-var.woff2 +0 -0
  15. package/dist/admin-assets/index.html +3 -2
  16. package/dist/bin.js +6 -0
  17. package/dist/bin.js.map +1 -1
  18. package/dist/commands/admin-assets.d.ts +1 -0
  19. package/dist/commands/admin-assets.d.ts.map +1 -1
  20. package/dist/commands/admin-assets.js +19 -1
  21. package/dist/commands/admin-assets.js.map +1 -1
  22. package/dist/commands/agent-runtime.d.ts +160 -0
  23. package/dist/commands/agent-runtime.d.ts.map +1 -0
  24. package/dist/commands/agent-runtime.js +524 -0
  25. package/dist/commands/agent-runtime.js.map +1 -0
  26. package/dist/commands/assistant.d.ts +89 -3
  27. package/dist/commands/assistant.d.ts.map +1 -1
  28. package/dist/commands/assistant.js +209 -41
  29. package/dist/commands/assistant.js.map +1 -1
  30. package/dist/commands/audit-integrity-alert.d.ts +23 -0
  31. package/dist/commands/audit-integrity-alert.d.ts.map +1 -0
  32. package/dist/commands/audit-integrity-alert.js +33 -0
  33. package/dist/commands/audit-integrity-alert.js.map +1 -0
  34. package/dist/commands/backup.d.ts +78 -0
  35. package/dist/commands/backup.d.ts.map +1 -0
  36. package/dist/commands/backup.js +238 -0
  37. package/dist/commands/backup.js.map +1 -0
  38. package/dist/commands/channels.d.ts +47 -0
  39. package/dist/commands/channels.d.ts.map +1 -0
  40. package/dist/commands/channels.js +284 -0
  41. package/dist/commands/channels.js.map +1 -0
  42. package/dist/commands/default-logo.d.ts +27 -0
  43. package/dist/commands/default-logo.d.ts.map +1 -0
  44. package/dist/commands/default-logo.js +30 -0
  45. package/dist/commands/default-logo.js.map +1 -0
  46. package/dist/commands/doctor.d.ts +5 -1
  47. package/dist/commands/doctor.d.ts.map +1 -1
  48. package/dist/commands/doctor.js +74 -2
  49. package/dist/commands/doctor.js.map +1 -1
  50. package/dist/commands/export.d.ts +22 -0
  51. package/dist/commands/export.d.ts.map +1 -0
  52. package/dist/commands/export.js +182 -0
  53. package/dist/commands/export.js.map +1 -0
  54. package/dist/commands/forms-page.d.ts +75 -0
  55. package/dist/commands/forms-page.d.ts.map +1 -0
  56. package/dist/commands/forms-page.js +191 -0
  57. package/dist/commands/forms-page.js.map +1 -0
  58. package/dist/commands/import.d.ts.map +1 -1
  59. package/dist/commands/import.js +13 -1
  60. package/dist/commands/import.js.map +1 -1
  61. package/dist/commands/mcp.d.ts +35 -0
  62. package/dist/commands/mcp.d.ts.map +1 -0
  63. package/dist/commands/mcp.js +277 -0
  64. package/dist/commands/mcp.js.map +1 -0
  65. package/dist/commands/roles.d.ts +22 -0
  66. package/dist/commands/roles.d.ts.map +1 -0
  67. package/dist/commands/roles.js +79 -0
  68. package/dist/commands/roles.js.map +1 -0
  69. package/dist/commands/search-page.d.ts +15 -0
  70. package/dist/commands/search-page.d.ts.map +1 -1
  71. package/dist/commands/search-page.js +22 -16
  72. package/dist/commands/search-page.js.map +1 -1
  73. package/dist/commands/seo.d.ts +92 -4
  74. package/dist/commands/seo.d.ts.map +1 -1
  75. package/dist/commands/seo.js +168 -4
  76. package/dist/commands/seo.js.map +1 -1
  77. package/dist/commands/serve.d.ts +439 -7
  78. package/dist/commands/serve.d.ts.map +1 -1
  79. package/dist/commands/serve.js +3515 -181
  80. package/dist/commands/serve.js.map +1 -1
  81. package/dist/commands/site-plan.d.ts +27 -1
  82. package/dist/commands/site-plan.d.ts.map +1 -1
  83. package/dist/commands/site-plan.js +79 -8
  84. package/dist/commands/site-plan.js.map +1 -1
  85. package/dist/commands/skin.d.ts.map +1 -1
  86. package/dist/commands/skin.js +7 -2
  87. package/dist/commands/skin.js.map +1 -1
  88. package/dist/commands/term-archive-page.d.ts +94 -0
  89. package/dist/commands/term-archive-page.d.ts.map +1 -0
  90. package/dist/commands/term-archive-page.js +195 -0
  91. package/dist/commands/term-archive-page.js.map +1 -0
  92. package/dist/commands/theme-css.d.ts +22 -7
  93. package/dist/commands/theme-css.d.ts.map +1 -1
  94. package/dist/commands/theme-css.js +33 -8
  95. package/dist/commands/theme-css.js.map +1 -1
  96. package/dist/commands/theme-registry.d.ts +94 -0
  97. package/dist/commands/theme-registry.d.ts.map +1 -0
  98. package/dist/commands/theme-registry.js +130 -0
  99. package/dist/commands/theme-registry.js.map +1 -0
  100. package/dist/commands/theme-render.d.ts +330 -9
  101. package/dist/commands/theme-render.d.ts.map +1 -1
  102. package/dist/commands/theme-render.js +932 -66
  103. package/dist/commands/theme-render.js.map +1 -1
  104. package/dist/commands/theme-wiring.d.ts +107 -0
  105. package/dist/commands/theme-wiring.d.ts.map +1 -0
  106. package/dist/commands/theme-wiring.js +308 -0
  107. package/dist/commands/theme-wiring.js.map +1 -0
  108. package/dist/commands/tools.d.ts +71 -0
  109. package/dist/commands/tools.d.ts.map +1 -0
  110. package/dist/commands/tools.js +330 -0
  111. package/dist/commands/tools.js.map +1 -0
  112. package/dist/commands/update.d.ts +25 -0
  113. package/dist/commands/update.d.ts.map +1 -0
  114. package/dist/commands/update.js +193 -0
  115. package/dist/commands/update.js.map +1 -0
  116. package/dist/index.d.ts +15 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +146 -4
  119. package/dist/index.js.map +1 -1
  120. package/dist/invite-mail.d.ts +39 -0
  121. package/dist/invite-mail.d.ts.map +1 -0
  122. package/dist/invite-mail.js +39 -0
  123. package/dist/invite-mail.js.map +1 -0
  124. package/dist/update/apply.d.ts +54 -0
  125. package/dist/update/apply.d.ts.map +1 -0
  126. package/dist/update/apply.js +77 -0
  127. package/dist/update/apply.js.map +1 -0
  128. package/dist/update/changelog-risk.d.ts +20 -0
  129. package/dist/update/changelog-risk.d.ts.map +1 -0
  130. package/dist/update/changelog-risk.js +55 -0
  131. package/dist/update/changelog-risk.js.map +1 -0
  132. package/dist/update/check.d.ts +60 -0
  133. package/dist/update/check.d.ts.map +1 -0
  134. package/dist/update/check.js +56 -0
  135. package/dist/update/check.js.map +1 -0
  136. package/dist/update/contract-risk.d.ts +55 -0
  137. package/dist/update/contract-risk.d.ts.map +1 -0
  138. package/dist/update/contract-risk.js +75 -0
  139. package/dist/update/contract-risk.js.map +1 -0
  140. package/dist/update/history.d.ts +30 -0
  141. package/dist/update/history.d.ts.map +1 -0
  142. package/dist/update/history.js +38 -0
  143. package/dist/update/history.js.map +1 -0
  144. package/dist/update/index.d.ts +28 -0
  145. package/dist/update/index.d.ts.map +1 -0
  146. package/dist/update/index.js +11 -0
  147. package/dist/update/index.js.map +1 -0
  148. package/dist/update/list-restore-points.d.ts +20 -0
  149. package/dist/update/list-restore-points.d.ts.map +1 -0
  150. package/dist/update/list-restore-points.js +35 -0
  151. package/dist/update/list-restore-points.js.map +1 -0
  152. package/dist/update/npm-registry.d.ts +11 -0
  153. package/dist/update/npm-registry.d.ts.map +1 -0
  154. package/dist/update/npm-registry.js +74 -0
  155. package/dist/update/npm-registry.js.map +1 -0
  156. package/dist/update/restore-point.d.ts +26 -0
  157. package/dist/update/restore-point.d.ts.map +1 -0
  158. package/dist/update/restore-point.js +23 -0
  159. package/dist/update/restore-point.js.map +1 -0
  160. package/dist/update/tar.d.ts +27 -0
  161. package/dist/update/tar.d.ts.map +1 -0
  162. package/dist/update/tar.js +56 -0
  163. package/dist/update/tar.js.map +1 -0
  164. package/dist/update/version-compare.d.ts +17 -0
  165. package/dist/update/version-compare.d.ts.map +1 -0
  166. package/dist/update/version-compare.js +42 -0
  167. package/dist/update/version-compare.js.map +1 -0
  168. package/dist/version.d.ts +13 -0
  169. package/dist/version.d.ts.map +1 -0
  170. package/dist/version.js +18 -0
  171. package/dist/version.js.map +1 -0
  172. package/package.json +33 -16
  173. package/dist/admin-assets/assets/index-C9a7O_Xs.css +0 -1
  174. package/dist/admin-assets/assets/index-DXvMgWvn.js +0 -74
@@ -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, escapeText, 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,22 +328,83 @@ 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 };
105
350
  }
106
351
  /**
107
- * Looks a menu up by name, through the exact same `MenuRouter` `/api/menus/*`
108
- * is mounted with an in-process call, `RestRequest` in and `RestResponse`
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`
109
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.
110
400
  *
111
- * `null` for "no menu router wired" and "no menu by that name" alike: both
401
+ * `null` for "no menu router wired" and "no menu found at all" alike: both
112
402
  * mean the slot renders empty, exactly as it always has.
113
403
  */
114
- async function fetchMenuLinks(name, locale, options, context) {
404
+ async function fetchMenuLinksFromPath(path, locale, options, context) {
115
405
  if (options.menuRouter === undefined)
116
406
  return null;
117
- const response = await options.menuRouter.handle({ method: 'GET', path: `/api/menus/by-name/${encodeURIComponent(name)}`, query: { locale } }, context);
407
+ const response = await options.menuRouter.handle({ method: 'GET', path, query: { locale } }, context);
118
408
  if (response.status !== 200)
119
409
  return null;
120
410
  const body = response.body;
@@ -123,34 +413,164 @@ async function fetchMenuLinks(name, locale, options, context) {
123
413
  return null;
124
414
  return items.map((item) => ({
125
415
  label: item.resolvedLabel ?? item.label,
126
- href: item.kind === 'url' ? item.url : item.kind === 'entry' ? (item.resolvedRoute ?? null) : null,
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),
127
423
  openInNewTab: item.openInNewTab,
424
+ kind: item.kind,
425
+ title: item.title,
128
426
  }));
129
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
+ }
130
452
  /**
131
- * A flat list of links (task 2's documented MVP): every item of the menu, in
132
- * the order the store returns them, regardless of `parent`/`depth`. A real
133
- * sub-menu render is left for later — the hierarchy is already in the data
134
- * (`parent`, `depth`), so nothing here would need to change to add it, only
135
- * this function's markup.
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.
136
456
  *
137
- * `null`/empty renders nothing: the caller's slot stays exactly as empty as
138
- * it was before this was wired, for a site with no menu by that name.
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.
139
467
  */
140
- function renderMenuLinks(links) {
141
- if (links === null || links.length === 0)
142
- return '';
143
- const items = links
144
- .map((link) => {
145
- const label = escapeText(link.label);
146
- if (link.href === null)
147
- return `<li><span>${label}</span></li>`;
148
- const href = escapeAttribute(link.href);
149
- const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : '';
150
- return `<li><a href="${href}"${target}>${label}</a></li>`;
151
- })
152
- .join('');
153
- return `<ul class="cg-menu">${items}</ul>`;
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>`;
154
574
  }
155
575
  async function fetchOne(gateway, request, context) {
156
576
  const page = await gateway.list({ ...request, limit: 1 }, context);
@@ -163,14 +583,15 @@ async function fetchOne(gateway, request, context) {
163
583
  * this rather than each re-deriving the same filter from `match.params`.
164
584
  *
165
585
  * `/` itself matches no collection's route (every `page` pattern is
166
- * `/:slug`, which needs a real segment) every `create-cogenta` blueprint
167
- * seeds its home page at the real, consistent slug `home`, so `/` retries
168
- * once as `/home` rather than 404ing on the one URL a real visitor always
169
- * tries first. Not a magic redirect: a site with no page at that slug still
170
- * 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.
171
592
  */
172
- async function resolveEntry(pathname, options, context) {
173
- const effectivePath = pathname === '/' ? '/home' : pathname;
593
+ export async function resolveEntry(pathname, options, context) {
594
+ const effectivePath = pathname === '/' ? await homePathFor(options) : pathname;
174
595
  const match = matchPath(options.collections, effectivePath, {
175
596
  locales: options.site.locales,
176
597
  defaultLocale: options.site.defaultLocale,
@@ -246,6 +667,78 @@ export async function renderDraftPage(draft, options, context) {
246
667
  const pathname = buildPath(collection, Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string')), entry.locale ?? undefined);
247
668
  return renderEntryPage(pathname, collection, entry, options, context);
248
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
+ }
249
742
  /**
250
743
  * The one page renderer. Both `renderRequestedPage` (published) and
251
744
  * `renderDraftPage` (unsaved) end here, having only differed in how they got
@@ -274,20 +767,41 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
274
767
  }
275
768
  }
276
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
+ }
277
789
  // Which media this page references, from the same walk `/api/content` uses
278
790
  // to declare a response's dependencies (`collectDependencies`): declared
279
791
  // `media` fields *and* the media inside every block, resolved through the
280
792
  // block registry rather than guessed at from the JSON. A `ContentEntry`
281
793
  // plus its collection name is exactly a `SerialisedEntry`, which is why
282
- // this reuse costs nothing.
794
+ // this reuse costs nothing. Rich text's own media nodes are merged in
795
+ // separately, above.
283
796
  const mediaAssets = new Map();
284
797
  if (options.loadMedia !== undefined) {
285
798
  const dependencies = collectDependencies([...knownEntries].map(([id, found]) => ({
286
799
  ...found,
287
800
  collection: entryCollections.get(id) ?? collection.name,
288
801
  })), { collection: (name) => collectionsByName.get(name) });
289
- if (dependencies.media.length > 0) {
290
- 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])) {
291
805
  mediaAssets.set(id, asset);
292
806
  }
293
807
  }
@@ -307,13 +821,26 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
307
821
  // doesn't build one.
308
822
  return '#';
309
823
  }
310
- 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
+ }
311
838
  };
312
839
  const themeContext = {
313
840
  site: options.site,
314
841
  locale: entry.locale,
315
842
  url: new URL(pathname, options.site.url),
316
- t: (key) => key,
843
+ t: createThemeTranslator(entry.locale),
317
844
  // The real `srcset`, from `@cogenta/render`'s own `describeMedia` (L10
318
845
  // task 5). Pure and synchronous, as contract D requires: the asset was
319
846
  // loaded before this render started, and this only builds URLs against
@@ -363,15 +890,66 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
363
890
  list: (request) => listAsTheme(options.gateway, request, context),
364
891
  },
365
892
  };
366
- const pageContent = { title: entryTitle(entry), blocks };
367
- 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);
368
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
+ }
369
928
  // The head is `@cogenta/seo`'s, not this file's: title, description,
370
929
  // canonical, hreflang, Open Graph, Twitter Card and JSON-LD, all derived
371
930
  // from the real entry and the real collection (L10 task 1). Nothing here
372
931
  // decides what is indexable — `buildMetaTags` asks `isPublished` itself, so
373
932
  // a preview render carries `noindex` without this caller remembering to.
374
- 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);
375
953
  const resource = { collection, entry };
376
954
  const alternates = await alternatesForEntry(seoSite, collection, entry, options.gateway, context, options.site.locales);
377
955
  // `og:image` and JSON-LD's `image` come from the same assets the page just
@@ -393,24 +971,60 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
393
971
  };
394
972
  // `head` already carries a real `<title>` (`renderSeoHead`, above) — no
395
973
  // second one is written into the template below.
396
- const head = renderSeoHead(seoSite, resource, {
397
- ...(alternates.length === 0 ? {} : { alternates }),
398
- ...(mediaAssets.size === 0 ? {} : { media: seoMedia }),
399
- });
400
- const siteName = escapeAttribute(options.site.name);
401
- // The navigation menus (audit follow-up to L13's menu system): `main` in
402
- // the header, `footer` in the footersee `ThemeRenderOptions.menuRouter`
403
- // for the convention and why it lives here rather than in contract A/D.
404
- // Both are `null`, rendering nothing, on a site with no menu router wired
405
- // or no menu by that name — the same empty slots as before this was wired.
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.
406
1008
  const [headerMenu, footerMenu] = await Promise.all([
407
- fetchMenuLinks('main', themeContext.locale, options, context),
408
- fetchMenuLinks('footer', themeContext.locale, options, context),
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),
409
1011
  ]);
410
- const headerNav = renderMenuLinks(headerMenu);
411
- const footerNav = renderMenuLinks(footerMenu);
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,
1025
+ });
412
1026
  // The same frame `Base.astro` builds for a real Astro build: a skip link
413
- // 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
414
1028
  // `<main>` alone — which this did until the theme's own stylesheet started
415
1029
  // being served — left every page with no landmark to skip to and no way back
416
1030
  // to the home page.
@@ -420,14 +1034,20 @@ async function renderEntryPage(pathname, collection, entry, options, context) {
420
1034
  <meta charset="utf-8">
421
1035
  <meta name="viewport" content="width=device-width, initial-scale=1">
422
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()}
423
1041
  ${head}
424
1042
  ${options.styles === null ? '' : `<link rel="stylesheet" href="${STYLESHEET_PATH}">`}
425
1043
  </head>
426
1044
  <body>
427
1045
  <a class="cg-skip-link" href="#cg-main">Skip to content</a>
428
- <header class="cg-site-header"><div class="cg-site-header__inner"><a class="cg-site-header__home" href="/">${siteName}</a>${headerNav === '' ? '' : `<nav class="cg-site-header__nav" aria-label="Primary">${headerNav}</nav>`}</div></header>
1046
+ ${adminBar}
1047
+ ${chrome.header}
429
1048
  ${bodyHtml}
430
- <footer class="cg-site-footer"><div class="cg-site-footer__inner"><span>${siteName}</span>${footerNav === '' ? '' : `<nav class="cg-site-footer__nav" aria-label="Footer">${footerNav}</nav>`}</div></footer>
1049
+ ${commentsHtml}
1050
+ ${chrome.footer}
431
1051
  ${analyticsBeaconTag(pathname, options.analyticsBeacon)}
432
1052
  </body>
433
1053
  </html>
@@ -491,4 +1111,250 @@ export function joinStyles(skinCss, themeCss) {
491
1111
  ].filter((sheet) => sheet !== null);
492
1112
  return sheets.length === 0 ? null : sheets.join('\n');
493
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
+ }
494
1360
  //# sourceMappingURL=theme-render.js.map