@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.
- package/dist/admin-assets/assets/index-C9a7O_Xs.css +1 -0
- package/dist/admin-assets/assets/index-DXvMgWvn.js +74 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
- package/dist/admin-assets/index.html +2 -2
- package/dist/commands/assistant.d.ts +65 -0
- package/dist/commands/assistant.d.ts.map +1 -0
- package/dist/commands/assistant.js +207 -0
- package/dist/commands/assistant.js.map +1 -0
- package/dist/commands/content-webhooks.d.ts +41 -0
- package/dist/commands/content-webhooks.d.ts.map +1 -0
- package/dist/commands/content-webhooks.js +61 -0
- package/dist/commands/content-webhooks.js.map +1 -0
- package/dist/commands/http-security.d.ts +39 -0
- package/dist/commands/http-security.d.ts.map +1 -0
- package/dist/commands/http-security.js +153 -0
- package/dist/commands/http-security.js.map +1 -0
- package/dist/commands/links.d.ts +24 -0
- package/dist/commands/links.d.ts.map +1 -0
- package/dist/commands/links.js +109 -0
- package/dist/commands/links.js.map +1 -0
- package/dist/commands/media-images.d.ts +67 -0
- package/dist/commands/media-images.d.ts.map +1 -0
- package/dist/commands/media-images.js +107 -0
- package/dist/commands/media-images.js.map +1 -0
- package/dist/commands/search-page.d.ts +40 -0
- package/dist/commands/search-page.d.ts.map +1 -0
- package/dist/commands/search-page.js +104 -0
- package/dist/commands/search-page.js.map +1 -0
- package/dist/commands/security-alerts.d.ts +24 -0
- package/dist/commands/security-alerts.d.ts.map +1 -0
- package/dist/commands/security-alerts.js +82 -0
- package/dist/commands/security-alerts.js.map +1 -0
- package/dist/commands/seo.d.ts +88 -0
- package/dist/commands/seo.d.ts.map +1 -0
- package/dist/commands/seo.js +155 -0
- package/dist/commands/seo.js.map +1 -0
- package/dist/commands/serve.d.ts +138 -4
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +1145 -43
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/site-plan.d.ts +86 -0
- package/dist/commands/site-plan.d.ts.map +1 -0
- package/dist/commands/site-plan.js +235 -0
- package/dist/commands/site-plan.js.map +1 -0
- package/dist/commands/theme-css.d.ts +54 -0
- package/dist/commands/theme-css.d.ts.map +1 -0
- package/dist/commands/theme-css.js +121 -0
- package/dist/commands/theme-css.js.map +1 -0
- package/dist/commands/theme-render.d.ts +115 -4
- package/dist/commands/theme-render.d.ts.map +1 -1
- package/dist/commands/theme-render.js +260 -17
- package/dist/commands/theme-render.js.map +1 -1
- package/dist/commands/users.d.ts +7 -1
- package/dist/commands/users.d.ts.map +1 -1
- package/dist/commands/users.js +129 -15
- package/dist/commands/users.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/dist/reset-mail.d.ts +39 -0
- package/dist/reset-mail.d.ts.map +1 -0
- package/dist/reset-mail.js +45 -0
- package/dist/reset-mail.js.map +1 -0
- package/package.json +15 -10
- package/dist/admin-assets/assets/index-21ZcDkDC.css +0 -1
- 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
|
|
2
|
-
import { type
|
|
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
|
-
/**
|
|
25
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
<
|
|
228
|
-
${
|
|
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
|