@cogenta/cli 0.4.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.
- package/CHANGELOG.md +3856 -0
- package/dist/admin-assets/assets/index-CxZHGwVA.css +1 -0
- package/dist/admin-assets/assets/index-fYXyHpn8.js +1090 -0
- package/dist/admin-assets/assets/vips-_dmTUXFO.wasm +0 -0
- package/dist/admin-assets/assets/vips-es6-DrQTTS8Q.js +7 -0
- package/dist/admin-assets/branding/logo-cogenta-dark.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-icon.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-light.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-small.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-transparent.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta.png +0 -0
- package/dist/admin-assets/favicon.png +0 -0
- package/dist/admin-assets/fonts/jetbrains-mono-var.woff2 +0 -0
- package/dist/admin-assets/fonts/space-grotesk-var.woff2 +0 -0
- package/dist/admin-assets/index.html +3 -2
- package/dist/bin.js +6 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/admin-assets.d.ts +1 -0
- package/dist/commands/admin-assets.d.ts.map +1 -1
- package/dist/commands/admin-assets.js +19 -1
- package/dist/commands/admin-assets.js.map +1 -1
- package/dist/commands/agent-runtime.d.ts +151 -0
- package/dist/commands/agent-runtime.d.ts.map +1 -0
- package/dist/commands/agent-runtime.js +519 -0
- package/dist/commands/agent-runtime.js.map +1 -0
- package/dist/commands/assistant.d.ts +89 -3
- package/dist/commands/assistant.d.ts.map +1 -1
- package/dist/commands/assistant.js +206 -39
- package/dist/commands/assistant.js.map +1 -1
- package/dist/commands/audit-integrity-alert.d.ts +23 -0
- package/dist/commands/audit-integrity-alert.d.ts.map +1 -0
- package/dist/commands/audit-integrity-alert.js +33 -0
- package/dist/commands/audit-integrity-alert.js.map +1 -0
- package/dist/commands/backup.d.ts +78 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +238 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/channels.d.ts +47 -0
- package/dist/commands/channels.d.ts.map +1 -0
- package/dist/commands/channels.js +277 -0
- package/dist/commands/channels.js.map +1 -0
- package/dist/commands/default-logo.d.ts +27 -0
- package/dist/commands/default-logo.d.ts.map +1 -0
- package/dist/commands/default-logo.js +30 -0
- package/dist/commands/default-logo.js.map +1 -0
- package/dist/commands/doctor.d.ts +5 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +74 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/export.d.ts +22 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +182 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/forms-page.d.ts +75 -0
- package/dist/commands/forms-page.d.ts.map +1 -0
- package/dist/commands/forms-page.js +191 -0
- package/dist/commands/forms-page.js.map +1 -0
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +13 -1
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/mcp.d.ts +35 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +277 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/roles.d.ts +22 -0
- package/dist/commands/roles.d.ts.map +1 -0
- package/dist/commands/roles.js +79 -0
- package/dist/commands/roles.js.map +1 -0
- package/dist/commands/search-page.d.ts +15 -0
- package/dist/commands/search-page.d.ts.map +1 -1
- package/dist/commands/search-page.js +22 -16
- package/dist/commands/search-page.js.map +1 -1
- package/dist/commands/seo.d.ts +92 -4
- package/dist/commands/seo.d.ts.map +1 -1
- package/dist/commands/seo.js +168 -4
- package/dist/commands/seo.js.map +1 -1
- package/dist/commands/serve.d.ts +439 -7
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +3498 -181
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/site-plan.d.ts +27 -1
- package/dist/commands/site-plan.d.ts.map +1 -1
- package/dist/commands/site-plan.js +71 -4
- package/dist/commands/site-plan.js.map +1 -1
- package/dist/commands/term-archive-page.d.ts +94 -0
- package/dist/commands/term-archive-page.d.ts.map +1 -0
- package/dist/commands/term-archive-page.js +195 -0
- package/dist/commands/term-archive-page.js.map +1 -0
- package/dist/commands/theme-css.d.ts +22 -7
- package/dist/commands/theme-css.d.ts.map +1 -1
- package/dist/commands/theme-css.js +33 -8
- package/dist/commands/theme-css.js.map +1 -1
- package/dist/commands/theme-registry.d.ts +94 -0
- package/dist/commands/theme-registry.d.ts.map +1 -0
- package/dist/commands/theme-registry.js +130 -0
- package/dist/commands/theme-registry.js.map +1 -0
- package/dist/commands/theme-render.d.ts +330 -9
- package/dist/commands/theme-render.d.ts.map +1 -1
- package/dist/commands/theme-render.js +932 -66
- package/dist/commands/theme-render.js.map +1 -1
- package/dist/commands/theme-wiring.d.ts +107 -0
- package/dist/commands/theme-wiring.d.ts.map +1 -0
- package/dist/commands/theme-wiring.js +295 -0
- package/dist/commands/theme-wiring.js.map +1 -0
- package/dist/commands/tools.d.ts +71 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +330 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/update.d.ts +25 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +193 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +146 -4
- package/dist/index.js.map +1 -1
- package/dist/invite-mail.d.ts +39 -0
- package/dist/invite-mail.d.ts.map +1 -0
- package/dist/invite-mail.js +39 -0
- package/dist/invite-mail.js.map +1 -0
- package/dist/update/apply.d.ts +54 -0
- package/dist/update/apply.d.ts.map +1 -0
- package/dist/update/apply.js +77 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/changelog-risk.d.ts +20 -0
- package/dist/update/changelog-risk.d.ts.map +1 -0
- package/dist/update/changelog-risk.js +55 -0
- package/dist/update/changelog-risk.js.map +1 -0
- package/dist/update/check.d.ts +60 -0
- package/dist/update/check.d.ts.map +1 -0
- package/dist/update/check.js +56 -0
- package/dist/update/check.js.map +1 -0
- package/dist/update/contract-risk.d.ts +55 -0
- package/dist/update/contract-risk.d.ts.map +1 -0
- package/dist/update/contract-risk.js +75 -0
- package/dist/update/contract-risk.js.map +1 -0
- package/dist/update/history.d.ts +30 -0
- package/dist/update/history.d.ts.map +1 -0
- package/dist/update/history.js +38 -0
- package/dist/update/history.js.map +1 -0
- package/dist/update/index.d.ts +28 -0
- package/dist/update/index.d.ts.map +1 -0
- package/dist/update/index.js +11 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/list-restore-points.d.ts +20 -0
- package/dist/update/list-restore-points.d.ts.map +1 -0
- package/dist/update/list-restore-points.js +35 -0
- package/dist/update/list-restore-points.js.map +1 -0
- package/dist/update/npm-registry.d.ts +11 -0
- package/dist/update/npm-registry.d.ts.map +1 -0
- package/dist/update/npm-registry.js +74 -0
- package/dist/update/npm-registry.js.map +1 -0
- package/dist/update/restore-point.d.ts +26 -0
- package/dist/update/restore-point.d.ts.map +1 -0
- package/dist/update/restore-point.js +23 -0
- package/dist/update/restore-point.js.map +1 -0
- package/dist/update/tar.d.ts +27 -0
- package/dist/update/tar.d.ts.map +1 -0
- package/dist/update/tar.js +56 -0
- package/dist/update/tar.js.map +1 -0
- package/dist/update/version-compare.d.ts +17 -0
- package/dist/update/version-compare.d.ts.map +1 -0
- package/dist/update/version-compare.js +42 -0
- package/dist/update/version-compare.js.map +1 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/package.json +33 -16
- package/dist/admin-assets/assets/index-C9a7O_Xs.css +0 -1
- 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 {
|
|
6
|
-
import {
|
|
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
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
-
*
|
|
138
|
-
*
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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)
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* 404s honestly, exactly like every
|
|
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 === '/' ?
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
|
367
|
-
const
|
|
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
|
-
|
|
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 =
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
|
411
|
-
const
|
|
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
|
|
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
|
-
|
|
1046
|
+
${adminBar}
|
|
1047
|
+
${chrome.header}
|
|
429
1048
|
${bodyHtml}
|
|
430
|
-
|
|
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('&', '&')
|
|
1333
|
+
.replaceAll('<', '<')
|
|
1334
|
+
.replaceAll('>', '>')
|
|
1335
|
+
.replaceAll('"', '"');
|
|
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
|