@cogenta/cli 0.2.2 → 0.4.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 (71) hide show
  1. package/dist/admin-assets/assets/index-C9a7O_Xs.css +1 -0
  2. package/dist/admin-assets/assets/index-DXvMgWvn.js +74 -0
  3. package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
  4. package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
  5. package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
  6. package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
  7. package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
  8. package/dist/admin-assets/index.html +2 -2
  9. package/dist/commands/assistant.d.ts +65 -0
  10. package/dist/commands/assistant.d.ts.map +1 -0
  11. package/dist/commands/assistant.js +207 -0
  12. package/dist/commands/assistant.js.map +1 -0
  13. package/dist/commands/content-webhooks.d.ts +41 -0
  14. package/dist/commands/content-webhooks.d.ts.map +1 -0
  15. package/dist/commands/content-webhooks.js +61 -0
  16. package/dist/commands/content-webhooks.js.map +1 -0
  17. package/dist/commands/http-security.d.ts +39 -0
  18. package/dist/commands/http-security.d.ts.map +1 -0
  19. package/dist/commands/http-security.js +153 -0
  20. package/dist/commands/http-security.js.map +1 -0
  21. package/dist/commands/links.d.ts +24 -0
  22. package/dist/commands/links.d.ts.map +1 -0
  23. package/dist/commands/links.js +109 -0
  24. package/dist/commands/links.js.map +1 -0
  25. package/dist/commands/media-images.d.ts +67 -0
  26. package/dist/commands/media-images.d.ts.map +1 -0
  27. package/dist/commands/media-images.js +107 -0
  28. package/dist/commands/media-images.js.map +1 -0
  29. package/dist/commands/search-page.d.ts +40 -0
  30. package/dist/commands/search-page.d.ts.map +1 -0
  31. package/dist/commands/search-page.js +104 -0
  32. package/dist/commands/search-page.js.map +1 -0
  33. package/dist/commands/security-alerts.d.ts +24 -0
  34. package/dist/commands/security-alerts.d.ts.map +1 -0
  35. package/dist/commands/security-alerts.js +82 -0
  36. package/dist/commands/security-alerts.js.map +1 -0
  37. package/dist/commands/seo.d.ts +88 -0
  38. package/dist/commands/seo.d.ts.map +1 -0
  39. package/dist/commands/seo.js +155 -0
  40. package/dist/commands/seo.js.map +1 -0
  41. package/dist/commands/serve.d.ts +138 -4
  42. package/dist/commands/serve.d.ts.map +1 -1
  43. package/dist/commands/serve.js +1145 -43
  44. package/dist/commands/serve.js.map +1 -1
  45. package/dist/commands/site-plan.d.ts +86 -0
  46. package/dist/commands/site-plan.d.ts.map +1 -0
  47. package/dist/commands/site-plan.js +235 -0
  48. package/dist/commands/site-plan.js.map +1 -0
  49. package/dist/commands/theme-css.d.ts +54 -0
  50. package/dist/commands/theme-css.d.ts.map +1 -0
  51. package/dist/commands/theme-css.js +121 -0
  52. package/dist/commands/theme-css.js.map +1 -0
  53. package/dist/commands/theme-render.d.ts +115 -4
  54. package/dist/commands/theme-render.d.ts.map +1 -1
  55. package/dist/commands/theme-render.js +260 -17
  56. package/dist/commands/theme-render.js.map +1 -1
  57. package/dist/commands/users.d.ts +7 -1
  58. package/dist/commands/users.d.ts.map +1 -1
  59. package/dist/commands/users.js +129 -15
  60. package/dist/commands/users.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +31 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/reset-mail.d.ts +39 -0
  66. package/dist/reset-mail.d.ts.map +1 -0
  67. package/dist/reset-mail.js +45 -0
  68. package/dist/reset-mail.js.map +1 -0
  69. package/package.json +15 -10
  70. package/dist/admin-assets/assets/index-21ZcDkDC.css +0 -1
  71. package/dist/admin-assets/assets/index-BXVsXHD2.js +0 -23
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The theme's stylesheet, flattened and minified for `cogenta serve`.
3
+ *
4
+ * `@cogenta/theme-canonical` ships `src/styles/theme.css` as three `@import`ed
5
+ * layers — tokens, base, blocks. A real Astro build flattens those; this
6
+ * fallback has no build, so it does the same one thing a bundler would and
7
+ * nothing more: resolve the relative imports, drop the comments, squeeze the
8
+ * whitespace. No rewriting of selectors, no reordering, no autoprefixing —
9
+ * anything cleverer than that would make the served CSS differ from the CSS the
10
+ * theme's own tests assert on.
11
+ *
12
+ * Until this existed, `cogenta serve` sent only the generated `--cogenta-*`
13
+ * custom properties and never the sheet that *uses* them, so every page was
14
+ * rendered by the browser's default stylesheet with a skin defined and unused.
15
+ */
16
+ function squeeze(code) {
17
+ return (code
18
+ .replace(/\s+/g, ' ')
19
+ // Only around the three separators that can never be part of a value or a
20
+ // combinator. `:` is left alone: removing the space in `@media
21
+ // (min-width: 60rem)` is safe but removing it in a selector is not, and
22
+ // telling the two apart needs a parser this deliberately is not.
23
+ .replace(/\s*([{};,])\s*/g, '$1')
24
+ .replace(/;}/g, '}'));
25
+ }
26
+ /**
27
+ * One pass over the sheet, in which a comment, a string and code are three
28
+ * different things and none of them is found with a regular expression.
29
+ *
30
+ * Both halves of that matter and each was a real bug first: an apostrophe in a
31
+ * prose comment ("the editor's intent") opens a string for any scanner that
32
+ * looks at quotes before comments, and swallows the rest of the file; and a
33
+ * `content: " — "` whose spaces are squeezed loses the separator it draws.
34
+ */
35
+ export function minifyCss(css) {
36
+ let out = '';
37
+ let code = '';
38
+ let index = 0;
39
+ while (index < css.length) {
40
+ if (css.startsWith('/*', index)) {
41
+ const end = css.indexOf('*/', index + 2);
42
+ index = end === -1 ? css.length : end + 2;
43
+ // A comment is whitespace, not nothing: `.a/* x */.b` must not become
44
+ // `.a.b`, which selects something else entirely.
45
+ code += ' ';
46
+ continue;
47
+ }
48
+ const character = css[index];
49
+ if (character !== '"' && character !== "'") {
50
+ code += character;
51
+ index += 1;
52
+ continue;
53
+ }
54
+ out += squeeze(code);
55
+ code = '';
56
+ let end = index + 1;
57
+ while (end < css.length && css[end] !== character) {
58
+ end += css[end] === '\\' ? 2 : 1;
59
+ }
60
+ out += css.slice(index, Math.min(end + 1, css.length));
61
+ index = end + 1;
62
+ }
63
+ return (out + squeeze(code)).trim();
64
+ }
65
+ const IMPORT = /@import\s+(?:url\()?["']([^"']+)["']\)?\s*;/g;
66
+ /**
67
+ * Replaces every `@import "./x.css";` with the file's contents, recursively.
68
+ *
69
+ * Relative only, on purpose: a bare-specifier or `http(s)` import would let a
70
+ * theme's stylesheet pull in a third party at render time, which is exactly the
71
+ * kind of outside reach contract D refuses a theme elsewhere. Those are left
72
+ * untouched for the browser to refuse or fetch as it sees fit.
73
+ */
74
+ export async function inlineImports(entry, options) {
75
+ const depth = options.depth ?? 8;
76
+ const css = await options.read(entry);
77
+ if (depth <= 0)
78
+ return css;
79
+ const parts = [];
80
+ let cursor = 0;
81
+ for (const match of css.matchAll(IMPORT)) {
82
+ const specifier = match[1];
83
+ if (!specifier.startsWith('.'))
84
+ continue;
85
+ const index = match.index ?? 0;
86
+ parts.push(css.slice(cursor, index));
87
+ parts.push(await inlineImports(new URL(specifier, entry), { read: options.read, depth: depth - 1 }));
88
+ cursor = index + match[0].length;
89
+ }
90
+ parts.push(css.slice(cursor));
91
+ return parts.join('\n');
92
+ }
93
+ /**
94
+ * The whole theme stylesheet as one minified string, or `null` when the theme
95
+ * package cannot be resolved — a site then renders with the skin's custom
96
+ * properties alone rather than refusing to serve, the same degradation
97
+ * `loadSkinCss` already chose for a missing `theme.tokens.json`.
98
+ */
99
+ /**
100
+ * FNV-1a, 32 bits. A cache key, never a security boundary — and deliberately
101
+ * not `node:crypto`, to match the hash `@cogenta/render` already uses for the
102
+ * skin sheet's ETag rather than introduce a second convention beside it.
103
+ */
104
+ export function cssEtag(css) {
105
+ let value = 0x811c9dc5;
106
+ for (let index = 0; index < css.length; index++) {
107
+ value ^= css.charCodeAt(index);
108
+ value = Math.imul(value, 0x01000193) >>> 0;
109
+ }
110
+ return `"${value.toString(16).padStart(8, '0')}"`;
111
+ }
112
+ export async function loadThemeCss(options) {
113
+ try {
114
+ const entry = new URL(import.meta.resolve('@cogenta/theme-canonical/styles/theme.css'));
115
+ return minifyCss(await inlineImports(entry, options));
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ //# sourceMappingURL=theme-css.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-css.js","sourceRoot":"","sources":["../../src/commands/theme-css.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
- import type { AccessContext, ContentGateway } from '@cogenta/api';
2
- import { type CollectionDefinition } from '@cogenta/schema';
1
+ import { type AccessContext, type ContentGateway, type MenuRouter } from '@cogenta/api';
2
+ import { type MediaAsset as RenderMediaAsset } from '@cogenta/render';
3
+ import { type BlockZones, type CollectionDefinition } from '@cogenta/schema';
3
4
  /**
4
5
  * Real HTML for a matched route — the piece the lot's own gap notes named
5
6
  * honestly ("`cogenta build`/`theme` not built yet"): until a real Astro site
@@ -12,6 +13,8 @@ import { type CollectionDefinition } from '@cogenta/schema';
12
13
  * in-process. This is a deliberately scoped stand-in, not the Astro pipeline:
13
14
  * one theme, no build step, no static generation.
14
15
  */
16
+ /** Where `cogenta serve` publishes image variants. Public: a visitor's browser fetches them. */
17
+ export declare const DEFAULT_IMAGE_ENDPOINT = "/_image";
15
18
  export interface ThemeRenderOptions {
16
19
  readonly collections: readonly CollectionDefinition[];
17
20
  readonly gateway: ContentGateway;
@@ -21,9 +24,76 @@ export interface ThemeRenderOptions {
21
24
  readonly locales: readonly string[];
22
25
  readonly defaultLocale: string;
23
26
  };
24
- /** `null` when the project has no `theme.tokens.json` — served unstyled rather than refused. */
25
- readonly skinCss: string | null;
27
+ /**
28
+ * The whole stylesheet: the skin's generated `--cogenta-*` custom properties
29
+ * followed by the theme's own sheet, already flattened and minified (see
30
+ * `theme-css.ts`). `null` when neither could be loaded — served unstyled
31
+ * rather than refused.
32
+ *
33
+ * The sheet is *linked*, not inlined: only the presence of one is needed
34
+ * here, so that a site with no stylesheet emits no dead `<link>`.
35
+ */
36
+ readonly styles: string | null;
37
+ /**
38
+ * Loads the media a render references, in one batch, before rendering.
39
+ *
40
+ * It has to be a batch: `renderBlock` is pure and synchronous (contract D),
41
+ * and `@cogenta/seo`'s `SeoResolvers.media` is synchronous too, so neither
42
+ * `ctx.image()` nor an `og:image` can await a lookup. Which ids a page
43
+ * needs is answered by `collectDependencies` — the same walk `/api/content`
44
+ * already uses to declare a response's media dependencies — rather than by
45
+ * a fresh heuristic over block JSON.
46
+ *
47
+ * Absent means no images: `ctx.image()` refuses clearly, exactly as it did
48
+ * before the pipeline existed.
49
+ */
50
+ readonly loadMedia?: (ids: readonly string[]) => Promise<ReadonlyMap<string, RenderMediaAsset>>;
51
+ /** Where image variants are served from. Defaults to `/_image`. */
52
+ readonly imageEndpoint?: string;
53
+ /**
54
+ * Self-hosted, cookie-free page-view analytics (`@cogenta/analytics`, L10
55
+ * analytics gap). The `Referer` header of *this* request — the theme's own
56
+ * `<script>`-free policy (see `serve.test.ts`, "no executable client
57
+ * JavaScript anywhere on the page") rules out reading `document.referrer`
58
+ * from an inline script, so the referrer this page was reached from is
59
+ * read server-side, from the request that is rendering it, and baked
60
+ * straight into a collection `<img>` pixel — no client code needed to
61
+ * capture it (see `analyticsBeaconTag` below).
62
+ *
63
+ * Absent entirely means the beacon is left out of the page altogether.
64
+ * Callers pass `{}` (no `referrer`) rather than leaving this out when there
65
+ * is simply no `Referer` header to report — the page builder's draft
66
+ * preview does that, since its request is a `POST` with no navigation
67
+ * referrer of its own, and doing so keeps its `<body>` byte-identical to
68
+ * the published page's (`serve-builder.test.ts`'s fidelity test) rather
69
+ * than carving out an exception that would itself become a body difference.
70
+ */
71
+ readonly analyticsBeacon?: {
72
+ readonly referrer?: string | undefined;
73
+ };
74
+ /**
75
+ * Wires `GET /api/menus/by-name/{name}` into the render pipeline (audit
76
+ * follow-up to L13's menu system: the backend, API and admin were complete
77
+ * and tested, but no menu ever reached the public theme). Called
78
+ * in-process, through the very same `MenuRouter` `/api/menus/*` is mounted
79
+ * with — never a second lookup path.
80
+ *
81
+ * Convention (undeclared by contract A or D, since navigation is not
82
+ * content): a menu named `main` renders in the header, one named `footer`
83
+ * renders in the footer. A site with neither keeps today's empty slots;
84
+ * one with only `main` gets no footer navigation, and so on.
85
+ *
86
+ * Absent means no menu lookup at all — the same empty slots as before this
87
+ * was wired.
88
+ */
89
+ readonly menuRouter?: MenuRouter;
26
90
  }
91
+ /**
92
+ * Where the served stylesheet lives. Under `/_cogenta/` for the same reason
93
+ * `Base.astro` puts the skin there: it is a namespace no collection route can
94
+ * ever claim, since every route pattern starts from a collection's own path.
95
+ */
96
+ export declare const STYLESHEET_PATH = "/_cogenta/styles.css";
27
97
  /**
28
98
  * Resolves `pathname` against the site's real routes and renders the real
29
99
  * page, or returns `null` — never for "matched but nothing found", only for
@@ -32,6 +102,38 @@ export interface ThemeRenderOptions {
32
102
  * gets.
33
103
  */
34
104
  export declare function renderRequestedPage(pathname: string, options: ThemeRenderOptions, context: AccessContext): Promise<string | null>;
105
+ /**
106
+ * An entry that is not what the database holds — the block list an editor has
107
+ * on screen and has not saved yet.
108
+ *
109
+ * `blocks` is the whole zone map, exactly the shape the admin already keeps
110
+ * and already sends to `PUT /api/content/:collection/:id`; nothing here is a
111
+ * second, builder-only serialisation of a page.
112
+ */
113
+ export interface DraftPage {
114
+ readonly collection: string;
115
+ readonly entryId: string;
116
+ readonly blocks: BlockZones;
117
+ /** Only the typed fields that changed. Absent fields keep the stored value. */
118
+ readonly values?: Readonly<Record<string, unknown>>;
119
+ }
120
+ /**
121
+ * Renders an in-progress draft through the *same* function that renders the
122
+ * published page — the single reason this export exists.
123
+ *
124
+ * The visual page builder (L16 task 1) shows this HTML in an iframe rather
125
+ * than re-implementing the twelve blocks in React. The whole point of that
126
+ * choice is that there is no second renderer to drift, so this must not
127
+ * become one: it reads the stored entry through the same permission-checked
128
+ * gateway, overlays the unsaved blocks and values on it, and then hands the
129
+ * result to `renderEntryPage` unchanged. Handed a draft that equals what is
130
+ * stored, it returns the published page byte for byte — which is exactly what
131
+ * `theme-render-fidelity.test.ts` asserts.
132
+ *
133
+ * `null` means the entry does not exist or this actor may not read it. The
134
+ * caller turns both into the same 404, and neither says which.
135
+ */
136
+ export declare function renderDraftPage(draft: DraftPage, options: ThemeRenderOptions, context: AccessContext): Promise<string | null>;
35
137
  /**
36
138
  * Reads `theme.tokens.json` next to the config and renders it through the
37
139
  * real, already-tested `renderSkin` (contract D validation + `--cogenta-*`
@@ -39,4 +141,13 @@ export declare function renderRequestedPage(pathname: string, options: ThemeRend
39
141
  * invalid tokens degrade to unstyled HTML rather than refusing to serve.
40
142
  */
41
143
  export declare function loadSkinCss(readFile: (path: string) => Promise<string>, tokensPath: string): Promise<string | null>;
144
+ /**
145
+ * The two sheets a page needs, in the only order that works: the skin's
146
+ * generated custom properties first, then the theme's stylesheet that reads
147
+ * them. Either half may be missing — a project with no `theme.tokens.json`
148
+ * still gets the theme's layout, and a theme package that cannot be resolved
149
+ * still gets the skin's properties — and a page with neither is served
150
+ * unstyled rather than refused.
151
+ */
152
+ export declare function joinStyles(skinCss: string | null, themeCss: string | null): string | null;
42
153
  //# sourceMappingURL=theme-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"theme-render.d.ts","sourceRoot":"","sources":["../../src/commands/theme-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAwB,MAAM,cAAc,CAAA;AAIvF,OAAO,EAAa,KAAK,oBAAoB,EAAgC,MAAM,iBAAiB,CAAA;AAgEpG;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAA;IACrD,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAA;IAChC,QAAQ,CAAC,IAAI,EAAE;QACb,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;QACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;QACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;QACnC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;KAC/B,CAAA;IACD,gGAAgG;IAChG,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AA0GD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAmHxB;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,EAC3C,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB"}
1
+ {"version":3,"file":"theme-render.d.ts","sourceRoot":"","sources":["../../src/commands/theme-render.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,cAAc,EAGnB,KAAK,UAAU,EAEhB,MAAM,cAAc,CAAA;AAGrB,OAAO,EAAiB,KAAK,UAAU,IAAI,gBAAgB,EAAc,MAAM,iBAAiB,CAAA;AAChG,OAAO,EACL,KAAK,UAAU,EAEf,KAAK,oBAAoB,EAG1B,MAAM,iBAAiB,CAAA;AAqExB;;;;;;;;;;;GAWG;AAEH,gGAAgG;AAChG,eAAO,MAAM,sBAAsB,YAAY,CAAA;AAE/C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAA;IACrD,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAA;IAChC,QAAQ,CAAC,IAAI,EAAE;QACb,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;QACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;QACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;QACnC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;KAC/B,CAAA;IACD;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,KAAK,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAA;IAC/F,mEAAmE;IACnE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;IACrE;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAA;CACjC;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,yBAAyB,CAAA;AA2LrD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAIxB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CACpD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,SAAS,EAChB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA6BxB;AAqQD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,EAC3C,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAMzF"}
@@ -1,7 +1,10 @@
1
+ import { collectDependencies, } from '@cogenta/api';
1
2
  import { CogentaError } from '@cogenta/core';
2
- import { renderSkin } from '@cogenta/render';
3
- import { buildPath, matchPath } from '@cogenta/schema';
4
- import { query as collectionListQuery, renderPage, serialize, } from '@cogenta/theme-canonical';
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';
7
+ import { minifyCss } from './theme-css.js';
5
8
  /**
6
9
  * The theme's own `ContentEntry`/`QueryRequest` (`theme-contract.ts`) are a
7
10
  * deliberately separate, minimal public contract — a theme never imports
@@ -46,6 +49,26 @@ async function listAsTheme(gateway, request, context) {
46
49
  nextCursor: page.nextCursor,
47
50
  };
48
51
  }
52
+ /**
53
+ * Real HTML for a matched route — the piece the lot's own gap notes named
54
+ * honestly ("`cogenta build`/`theme` not built yet"): until a real Astro site
55
+ * exists (ADR-0008, contract D's actual intended delivery plane), `cogenta
56
+ * serve` renders the one theme this codebase has, `@cogenta/theme-canonical`,
57
+ * directly against the same permission-checked `ContentGateway` every REST
58
+ * and GraphQL request already goes through. No secret, config or DB handle
59
+ * ever reaches theme code — only the same `ContentEntry` shape a real HTTP
60
+ * client would receive, so the data boundary R5 cares about holds even
61
+ * in-process. This is a deliberately scoped stand-in, not the Astro pipeline:
62
+ * one theme, no build step, no static generation.
63
+ */
64
+ /** Where `cogenta serve` publishes image variants. Public: a visitor's browser fetches them. */
65
+ export const DEFAULT_IMAGE_ENDPOINT = '/_image';
66
+ /**
67
+ * Where the served stylesheet lives. Under `/_cogenta/` for the same reason
68
+ * `Base.astro` puts the skin there: it is a namespace no collection route can
69
+ * ever claim, since every route pattern starts from a collection's own path.
70
+ */
71
+ export const STYLESHEET_PATH = '/_cogenta/styles.css';
49
72
  function fieldOfKind(collection, kind) {
50
73
  return Object.entries(collection.fields).find(([, field]) => field.kind === kind)?.[0];
51
74
  }
@@ -77,9 +100,58 @@ function toVocabularyBlocks(entry, collection) {
77
100
  ];
78
101
  }
79
102
  function entryTitle(entry) {
80
- const value = entry.values['title'];
103
+ const value = entry.values.title;
81
104
  return typeof value === 'string' && value.trim() !== '' ? value : entry.id;
82
105
  }
106
+ /**
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`
109
+ * out, never a second lookup path or a real HTTP round trip to itself.
110
+ *
111
+ * `null` for "no menu router wired" and "no menu by that name" alike: both
112
+ * mean the slot renders empty, exactly as it always has.
113
+ */
114
+ async function fetchMenuLinks(name, locale, options, context) {
115
+ if (options.menuRouter === undefined)
116
+ return null;
117
+ const response = await options.menuRouter.handle({ method: 'GET', path: `/api/menus/by-name/${encodeURIComponent(name)}`, query: { locale } }, context);
118
+ if (response.status !== 200)
119
+ return null;
120
+ const body = response.body;
121
+ const items = body?.data?.items;
122
+ if (!Array.isArray(items))
123
+ return null;
124
+ return items.map((item) => ({
125
+ label: item.resolvedLabel ?? item.label,
126
+ href: item.kind === 'url' ? item.url : item.kind === 'entry' ? (item.resolvedRoute ?? null) : null,
127
+ openInNewTab: item.openInNewTab,
128
+ }));
129
+ }
130
+ /**
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.
136
+ *
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.
139
+ */
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>`;
154
+ }
83
155
  async function fetchOne(gateway, request, context) {
84
156
  const page = await gateway.list({ ...request, limit: 1 }, context);
85
157
  return page.items[0] ?? null;
@@ -136,7 +208,50 @@ export async function renderRequestedPage(pathname, options, context) {
136
208
  const resolved = await resolveEntry(pathname, options, context);
137
209
  if (resolved === null)
138
210
  return null;
139
- const { collection, entry } = resolved;
211
+ return renderEntryPage(pathname, resolved.collection, resolved.entry, options, context);
212
+ }
213
+ /**
214
+ * Renders an in-progress draft through the *same* function that renders the
215
+ * published page — the single reason this export exists.
216
+ *
217
+ * The visual page builder (L16 task 1) shows this HTML in an iframe rather
218
+ * than re-implementing the twelve blocks in React. The whole point of that
219
+ * choice is that there is no second renderer to drift, so this must not
220
+ * become one: it reads the stored entry through the same permission-checked
221
+ * gateway, overlays the unsaved blocks and values on it, and then hands the
222
+ * result to `renderEntryPage` unchanged. Handed a draft that equals what is
223
+ * stored, it returns the published page byte for byte — which is exactly what
224
+ * `theme-render-fidelity.test.ts` asserts.
225
+ *
226
+ * `null` means the entry does not exist or this actor may not read it. The
227
+ * caller turns both into the same 404, and neither says which.
228
+ */
229
+ export async function renderDraftPage(draft, options, context) {
230
+ const collection = options.collections.find((entry) => entry.name === draft.collection);
231
+ if (collection === undefined)
232
+ return null;
233
+ const stored = await options.gateway.read(draft.collection, draft.entryId, context);
234
+ if (stored === null)
235
+ return null;
236
+ const entry = {
237
+ ...stored,
238
+ values: { ...stored.values, ...(draft.values ?? {}) },
239
+ blocks: draft.blocks,
240
+ };
241
+ // The path the entry really lives at, built from the same `buildPath` the
242
+ // public route uses — never a synthetic `/preview/...` URL. A canonical
243
+ // link, an `og:url` and a `collectionList` link all have to come out of
244
+ // this render identical to the published page's, and they are all derived
245
+ // from this one string.
246
+ const pathname = buildPath(collection, Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string')), entry.locale ?? undefined);
247
+ return renderEntryPage(pathname, collection, entry, options, context);
248
+ }
249
+ /**
250
+ * The one page renderer. Both `renderRequestedPage` (published) and
251
+ * `renderDraftPage` (unsaved) end here, having only differed in how they got
252
+ * hold of an entry.
253
+ */
254
+ async function renderEntryPage(pathname, collection, entry, options, context) {
140
255
  const blocks = toVocabularyBlocks(entry, collection);
141
256
  // Every entry a `collectionList` block needs is fetched up front — the
142
257
  // theme's own contract (`FetchedEntries`, `render-block.ts`) requires it:
@@ -145,16 +260,39 @@ export async function renderRequestedPage(pathname, options, context) {
145
260
  // either.
146
261
  const fetchedEntries = {};
147
262
  const knownEntries = new Map([[entry.id, entry]]);
263
+ /** Which collection each known entry came from — a `ContentEntry` does not say. */
264
+ const entryCollections = new Map([[entry.id, collection.name]]);
148
265
  for (const block of blocks) {
149
266
  if (block._type !== 'collectionList')
150
267
  continue;
151
268
  const themeQuery = collectionListQuery(block);
152
269
  const results = await options.gateway.list(toApiQueryRequest(themeQuery), context);
153
270
  fetchedEntries[block._key] = results.items.map((found) => toThemeEntry(found, themeQuery.collection));
154
- for (const found of results.items)
271
+ for (const found of results.items) {
155
272
  knownEntries.set(found.id, found);
273
+ entryCollections.set(found.id, themeQuery.collection);
274
+ }
156
275
  }
157
276
  const collectionsByName = new Map(options.collections.map((entry) => [entry.name, entry]));
277
+ // Which media this page references, from the same walk `/api/content` uses
278
+ // to declare a response's dependencies (`collectDependencies`): declared
279
+ // `media` fields *and* the media inside every block, resolved through the
280
+ // block registry rather than guessed at from the JSON. A `ContentEntry`
281
+ // plus its collection name is exactly a `SerialisedEntry`, which is why
282
+ // this reuse costs nothing.
283
+ const mediaAssets = new Map();
284
+ if (options.loadMedia !== undefined) {
285
+ const dependencies = collectDependencies([...knownEntries].map(([id, found]) => ({
286
+ ...found,
287
+ collection: entryCollections.get(id) ?? collection.name,
288
+ })), { collection: (name) => collectionsByName.get(name) });
289
+ if (dependencies.media.length > 0) {
290
+ for (const [id, asset] of await options.loadMedia(dependencies.media)) {
291
+ mediaAssets.set(id, asset);
292
+ }
293
+ }
294
+ }
295
+ const imageEndpoint = options.imageEndpoint ?? DEFAULT_IMAGE_ENDPOINT;
158
296
  const link = (target) => {
159
297
  if (typeof target === 'string')
160
298
  return target;
@@ -176,14 +314,23 @@ export async function renderRequestedPage(pathname, options, context) {
176
314
  locale: entry.locale,
177
315
  url: new URL(pathname, options.site.url),
178
316
  t: (key) => key,
179
- // No image pipeline exists yet (`@cogenta/render`'s `images/`) wired to
180
- // this in-process path a theme that asks for one gets a clear refusal,
181
- // not a broken <img>.
182
- image: () => {
183
- throw new CogentaError({
184
- code: 'THEME_IMAGE_UNSUPPORTED',
185
- message: 'cogenta serve cannot render images yet.',
186
- hint: 'No image pipeline is wired into this in-process rendering fallback (no real Astro build exists yet). Avoid mediaFigure/gallery blocks until it is.',
317
+ // The real `srcset`, from `@cogenta/render`'s own `describeMedia` (L10
318
+ // task 5). Pure and synchronous, as contract D requires: the asset was
319
+ // loaded before this render started, and this only builds URLs against
320
+ // the variants the upload already wrote.
321
+ image: (media, imageOptions) => {
322
+ const asset = mediaAssets.get(media);
323
+ if (asset === undefined) {
324
+ throw new CogentaError({
325
+ code: 'THEME_IMAGE_UNSUPPORTED',
326
+ message: `No media asset "${media}" is available to this render.`,
327
+ hint: 'The image must be referenced by a media field or a block of this page — those are the ones loaded before rendering. Check that the asset still exists in the media library.',
328
+ details: { media },
329
+ });
330
+ }
331
+ return describeMedia(asset, imageOptions ?? {}, {
332
+ endpoint: imageEndpoint,
333
+ mediaEndpoint: imageEndpoint,
187
334
  });
188
335
  },
189
336
  link,
@@ -219,20 +366,101 @@ export async function renderRequestedPage(pathname, options, context) {
219
366
  const pageContent = { title: entryTitle(entry), blocks };
220
367
  const node = renderPage(pageContent, themeContext, fetchedEntries);
221
368
  const bodyHtml = serialize(node);
369
+ // The head is `@cogenta/seo`'s, not this file's: title, description,
370
+ // canonical, hreflang, Open Graph, Twitter Card and JSON-LD, all derived
371
+ // from the real entry and the real collection (L10 task 1). Nothing here
372
+ // decides what is indexable — `buildMetaTags` asks `isPublished` itself, so
373
+ // a preview render carries `noindex` without this caller remembering to.
374
+ const seoSite = seoSiteFor(options.site);
375
+ const resource = { collection, entry };
376
+ const alternates = await alternatesForEntry(seoSite, collection, entry, options.gateway, context, options.site.locales);
377
+ // `og:image` and JSON-LD's `image` come from the same assets the page just
378
+ // rendered, resolved to an absolute URL — a social crawler never sends a
379
+ // session and never follows a relative path.
380
+ const seoMedia = (id) => {
381
+ const asset = mediaAssets.get(id);
382
+ if (asset === undefined || asset.kind !== 'image')
383
+ return null;
384
+ if (asset.width === undefined || asset.height === undefined)
385
+ return null;
386
+ const source = describeMedia(asset, {}, { endpoint: imageEndpoint, mediaEndpoint: imageEndpoint });
387
+ return {
388
+ url: new URL(source.src, options.site.url).toString(),
389
+ width: source.width,
390
+ height: source.height,
391
+ ...(source.alt === '' ? {} : { alt: source.alt }),
392
+ };
393
+ };
394
+ // `head` already carries a real `<title>` (`renderSeoHead`, above) — no
395
+ // 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 footer — see `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.
406
+ const [headerMenu, footerMenu] = await Promise.all([
407
+ fetchMenuLinks('main', themeContext.locale, options, context),
408
+ fetchMenuLinks('footer', themeContext.locale, options, context),
409
+ ]);
410
+ const headerNav = renderMenuLinks(headerMenu);
411
+ const footerNav = renderMenuLinks(footerMenu);
412
+ // 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
414
+ // `<main>` alone — which this did until the theme's own stylesheet started
415
+ // being served — left every page with no landmark to skip to and no way back
416
+ // to the home page.
222
417
  return `<!doctype html>
223
- <html lang="${themeContext.locale}">
418
+ <html lang="${themeContext.locale}" dir="auto">
224
419
  <head>
225
420
  <meta charset="utf-8">
226
421
  <meta name="viewport" content="width=device-width, initial-scale=1">
227
- <title>${pageContent.title} ${options.site.name}</title>
228
- ${options.skinCss === null ? '' : `<style>${options.skinCss}</style>`}
422
+ <meta name="color-scheme" content="light dark">
423
+ ${head}
424
+ ${options.styles === null ? '' : `<link rel="stylesheet" href="${STYLESHEET_PATH}">`}
229
425
  </head>
230
426
  <body>
427
+ <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>
231
429
  ${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>
431
+ ${analyticsBeaconTag(pathname, options.analyticsBeacon)}
232
432
  </body>
233
433
  </html>
234
434
  `;
235
435
  }
436
+ /**
437
+ * Self-hosted, cookie-free page-view analytics (`@cogenta/analytics`), L10
438
+ * analytics gap. An invisible `<img>` pixel rather than any inline
439
+ * `<script>`: the theme's own policy is **zero executable client
440
+ * JavaScript** on a rendered page (enforced by `serve.test.ts`), so a script
441
+ * reading `document.referrer` is not an option here. Everything the pixel's
442
+ * URL needs — the path being viewed, and the `Referer` header of the request
443
+ * that is rendering this very page — is already known server-side, so
444
+ * nothing needs to run in the browser to capture it.
445
+ *
446
+ * `undefined` (no `analyticsBeacon` at all) omits the tag entirely — used
447
+ * for the page builder's draft preview, which must not be counted as a real
448
+ * visit. `alt=""` and the visually-hidden inline style keep it out of a
449
+ * screen reader and off the visible page without `display:none`, which some
450
+ * older ad-blocking heuristics treat as a signal to strip the element (and
451
+ * losing the pixel loses nothing here — it fails silently either way, R1/R2
452
+ * spirit: analytics is additive, never load-bearing).
453
+ */
454
+ function analyticsBeaconTag(pathname, beacon) {
455
+ if (beacon === undefined)
456
+ return '';
457
+ const params = new URLSearchParams({ p: pathname });
458
+ if (beacon.referrer !== undefined && beacon.referrer !== '') {
459
+ params.set('r', beacon.referrer);
460
+ }
461
+ const src = escapeAttribute(`/api/analytics/beacon?${params.toString()}`);
462
+ return `<img src="${src}" alt="" width="1" height="1" loading="eager" decoding="async" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">`;
463
+ }
236
464
  /**
237
465
  * Reads `theme.tokens.json` next to the config and renders it through the
238
466
  * real, already-tested `renderSkin` (contract D validation + `--cogenta-*`
@@ -248,4 +476,19 @@ export async function loadSkinCss(readFile, tokensPath) {
248
476
  return null;
249
477
  }
250
478
  }
479
+ /**
480
+ * The two sheets a page needs, in the only order that works: the skin's
481
+ * generated custom properties first, then the theme's stylesheet that reads
482
+ * them. Either half may be missing — a project with no `theme.tokens.json`
483
+ * still gets the theme's layout, and a theme package that cannot be resolved
484
+ * still gets the skin's properties — and a page with neither is served
485
+ * unstyled rather than refused.
486
+ */
487
+ export function joinStyles(skinCss, themeCss) {
488
+ const sheets = [
489
+ skinCss === null ? null : minifyCss(skinCss),
490
+ themeCss === null ? null : minifyCss(themeCss),
491
+ ].filter((sheet) => sheet !== null);
492
+ return sheets.length === 0 ? null : sheets.join('\n');
493
+ }
251
494
  //# sourceMappingURL=theme-render.js.map