@caelo-cms/shared 0.10.22 → 0.10.23
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/ai-tools.d.ts +290 -214
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +341 -264
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compose a page's HTML from its template + module references.
|
|
5
|
+
*
|
|
6
|
+
* The composed output is what the admin preview iframe renders. Production
|
|
7
|
+
* static-gen (P6) will reuse this same composer once Astro is wired up, so the
|
|
8
|
+
* function is pure and dependency-free — no DB calls, no IO. The Query API op
|
|
9
|
+
* does the loads and hands the data here.
|
|
10
|
+
*
|
|
11
|
+
* Output shape:
|
|
12
|
+
* 1. `<caelo-slot name="X">` blocks have their inner HTML replaced by the
|
|
13
|
+
* concatenated module HTML for block X (in `position` order).
|
|
14
|
+
* 2. All module CSS is concatenated into a single
|
|
15
|
+
* `<style data-source="modules">` injected before `</head>` — template
|
|
16
|
+
* stays the source of truth for `<head>`; we just append.
|
|
17
|
+
* 3. All module JS is concatenated into a single
|
|
18
|
+
* `<script defer data-source="modules">` injected before `</body>`.
|
|
19
|
+
* 4. Template CSS is injected ahead of module CSS so module rules can
|
|
20
|
+
* override template defaults via specificity.
|
|
21
|
+
*
|
|
22
|
+
* The composer never escapes module HTML — modules ARE the place where raw
|
|
23
|
+
* HTML lives (CMS_REQUIREMENTS §3.1). Templates ARE the place where the
|
|
24
|
+
* `<head>` skeleton lives. Sandboxing happens one layer up (preview iframe in
|
|
25
|
+
* the admin; in P11, plugin Web Components inside Shadow DOM).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { BASE_TECHNICAL_CSS } from "./base-css.js";
|
|
29
|
+
import type { ModuleFieldKind } from "./content.js";
|
|
30
|
+
import { NAV_FUNCTIONAL_CSS, NAV_TOGGLE_JS } from "./interactions.js";
|
|
31
|
+
import {
|
|
32
|
+
applySlotReplacements,
|
|
33
|
+
extractInnerOfTopLevelContentSlot,
|
|
34
|
+
listSlotNames,
|
|
35
|
+
} from "./preview-scanner.js";
|
|
36
|
+
import { stripCdataGuards } from "./strip-cdata.js";
|
|
37
|
+
import { type LanguageSelectorOverride, renderLanguageSelector } from "./structured-sets.js";
|
|
38
|
+
import { renderTemplate, type TemplateField } from "./template-engine.js";
|
|
39
|
+
import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
|
|
40
|
+
import type { ThemeDocument } from "./themes.js";
|
|
41
|
+
|
|
42
|
+
export interface ComposeModule {
|
|
43
|
+
readonly moduleId: string;
|
|
44
|
+
readonly slug: string;
|
|
45
|
+
readonly displayName: string;
|
|
46
|
+
readonly html: string;
|
|
47
|
+
readonly css: string;
|
|
48
|
+
readonly js: string;
|
|
49
|
+
/**
|
|
50
|
+
* v0.4.0 module-field schema, extended in #71 to carry `kind` so
|
|
51
|
+
* the shared template engine can dispatch text-list / link-list /
|
|
52
|
+
* module-list / module sections. `kind` is optional for back-compat
|
|
53
|
+
* with callers that haven't been updated; the engine treats absent
|
|
54
|
+
* kinds as primitives (the legacy compose-path behaviour).
|
|
55
|
+
*
|
|
56
|
+
* When present, the composer substitutes `{{name}}` placeholders
|
|
57
|
+
* in `html` with each field's `default` value before slot
|
|
58
|
+
* replacement — without this, modules created via the AI-authored
|
|
59
|
+
* extractor path (which mints `{{spantext}}` / `{{ctahref}}` etc.
|
|
60
|
+
* with declared defaults) ship raw placeholders to the browser,
|
|
61
|
+
* visible to visitors as literal `{{name}}` text.
|
|
62
|
+
*
|
|
63
|
+
* Per-placement overrides (content_instances.values) are applied
|
|
64
|
+
* here via the `contentValues` field below. The chat-branched
|
|
65
|
+
* preview path additionally walks nested-module refs in
|
|
66
|
+
* preview-render before reaching here.
|
|
67
|
+
*/
|
|
68
|
+
readonly fields?: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[];
|
|
69
|
+
/**
|
|
70
|
+
* PR #61 follow-up — per-placement content values from the bound
|
|
71
|
+
* `content_instances.values` jsonb. When present, the composer
|
|
72
|
+
* substitutes `{{name}}` placeholders with `contentValues[name]`
|
|
73
|
+
* BEFORE falling back to `fields[].default`. Without this, AI-
|
|
74
|
+
* authored modules that declare explicit fields (without a `default`)
|
|
75
|
+
* but rely on per-placement values shipped raw `{{name}}` text to
|
|
76
|
+
* visitors — the bug e2e-livedit's second Stage caught.
|
|
77
|
+
*/
|
|
78
|
+
readonly contentValues?: Readonly<Record<string, unknown>>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ComposeBlock {
|
|
82
|
+
readonly blockName: string;
|
|
83
|
+
readonly modules: readonly ComposeModule[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** P6.7.5 — structured sets carried into the composer so nav-menu
|
|
87
|
+
* modules render from typed items and theme tokens flow into <head>. */
|
|
88
|
+
export interface ComposeStructuredSets {
|
|
89
|
+
/** Map keyed by `<kind>/<slug>` (e.g. `nav-menu/header-main`). */
|
|
90
|
+
readonly byKindSlug: Readonly<Record<string, readonly unknown[]>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* P9 — language-selector context. The caller (preview op + static
|
|
95
|
+
* generator) resolves the current page's per-locale URLs once and
|
|
96
|
+
* threads them in. The composer renders the selector when a module
|
|
97
|
+
* slug starts with `language-selector-`.
|
|
98
|
+
*/
|
|
99
|
+
export interface ComposeLanguageSelector {
|
|
100
|
+
readonly availableLocales: ReadonlyArray<{
|
|
101
|
+
code: string;
|
|
102
|
+
displayName: string;
|
|
103
|
+
href: string;
|
|
104
|
+
isCurrent: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* v0.11.0 — Theme context resolved by the preview op + static generator
|
|
110
|
+
* (#45 Phase 3). Carries the active theme's DTCG tokens jsonb plus the
|
|
111
|
+
* four asset URL resolutions (logo / logo-dark / favicon / social-share).
|
|
112
|
+
*
|
|
113
|
+
* The composer reads `tokens` to emit `<style data-source="theme">`;
|
|
114
|
+
* `assets` are surfaced for modules that want to reference theme-bound
|
|
115
|
+
* images (the dedicated `{{theme_logo_url}}` template binding lands in
|
|
116
|
+
* v0.11.x — see #45's "Out of scope"; v0.11.0 only surfaces the URLs).
|
|
117
|
+
*/
|
|
118
|
+
export interface ComposeThemeAsset {
|
|
119
|
+
readonly mediaId: string;
|
|
120
|
+
readonly url: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ComposeTheme {
|
|
124
|
+
readonly tokens: ThemeDocument;
|
|
125
|
+
readonly assets: {
|
|
126
|
+
readonly logo: ComposeThemeAsset | null;
|
|
127
|
+
readonly logoDark: ComposeThemeAsset | null;
|
|
128
|
+
readonly favicon: ComposeThemeAsset | null;
|
|
129
|
+
readonly socialShare: ComposeThemeAsset | null;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* issue #150 — self-hosted web fonts resolved by the caller (font
|
|
135
|
+
* resolver in admin-core; static generator + preview op thread the
|
|
136
|
+
* same shape so both surfaces load identical fonts). `css` is the
|
|
137
|
+
* @font-face block; `preloads` are woff2 URLs worth a
|
|
138
|
+
* `<link rel="preload">` (body/heading faces, capped upstream).
|
|
139
|
+
*/
|
|
140
|
+
export interface ComposeFonts {
|
|
141
|
+
readonly css: string;
|
|
142
|
+
readonly preloads: readonly string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ComposeInput {
|
|
146
|
+
readonly templateHtml: string;
|
|
147
|
+
readonly templateCss: string;
|
|
148
|
+
readonly blocks: readonly ComposeBlock[];
|
|
149
|
+
readonly structuredSets?: ComposeStructuredSets;
|
|
150
|
+
readonly languageSelector?: ComposeLanguageSelector;
|
|
151
|
+
/**
|
|
152
|
+
* v0.11.0 — active theme threaded through from the preview op + static
|
|
153
|
+
* generator. Undefined when no theme row exists OR when the caller
|
|
154
|
+
* hasn't been migrated to the new primitive yet (renderer no-ops in
|
|
155
|
+
* both cases, preserving legacy parity).
|
|
156
|
+
*/
|
|
157
|
+
readonly theme?: ComposeTheme;
|
|
158
|
+
/** issue #150 — resolved web fonts; undefined = system stacks only. */
|
|
159
|
+
readonly fonts?: ComposeFonts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ComposeOutput {
|
|
163
|
+
readonly html: string;
|
|
164
|
+
readonly replacedSlots: readonly string[];
|
|
165
|
+
readonly missingSlots: readonly string[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const HEAD_CLOSE_RE = /<\/head\s*>/i;
|
|
169
|
+
const BODY_CLOSE_RE = /<\/body\s*>/i;
|
|
170
|
+
|
|
171
|
+
function injectBefore(source: string, marker: RegExp, fragment: string): string {
|
|
172
|
+
const m = marker.exec(source);
|
|
173
|
+
if (!m) return source + fragment; // template lacks the tag — append as fallback
|
|
174
|
+
const idx = m.index;
|
|
175
|
+
return source.slice(0, idx) + fragment + source.slice(idx);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* issue #150 — head fragment for resolved web fonts: preload links (the
|
|
180
|
+
* `crossorigin` attribute is REQUIRED for font preloads even same-origin,
|
|
181
|
+
* per the fetch spec's font-destination CORS rule) + the @font-face
|
|
182
|
+
* block. Empty css with no preloads → null (system-stack-only theme).
|
|
183
|
+
*/
|
|
184
|
+
function fontsHeadFragment(fonts: ComposeFonts | undefined): string | null {
|
|
185
|
+
if (fonts === undefined) return null;
|
|
186
|
+
const links = fonts.preloads
|
|
187
|
+
.map((href) => `<link rel="preload" as="font" type="font/woff2" crossorigin href="${href}">`)
|
|
188
|
+
.join("");
|
|
189
|
+
const style =
|
|
190
|
+
fonts.css.trim().length > 0 ? `<style data-source="fonts">${fonts.css}</style>` : "";
|
|
191
|
+
const fragment = links + style;
|
|
192
|
+
return fragment.length > 0 ? fragment : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
196
|
+
const contentByName = new Map<string, string>();
|
|
197
|
+
const allCss: string[] = [];
|
|
198
|
+
const allJs: string[] = [];
|
|
199
|
+
// issue #160 — see composePageWithLayout.
|
|
200
|
+
let navRendered = false;
|
|
201
|
+
// issue #158 — same per-module dedup as composePageWithLayout.
|
|
202
|
+
const seenAssetModules = new Set<string>();
|
|
203
|
+
// Template CSS first so module CSS can override it via source-order specificity.
|
|
204
|
+
if (input.templateCss.trim().length > 0) allCss.push(input.templateCss);
|
|
205
|
+
|
|
206
|
+
for (const block of input.blocks) {
|
|
207
|
+
// P6.7 — tag every module's outermost element with
|
|
208
|
+
// `data-caelo-module-id="<uuid>"` so the live-edit overlay's iframe
|
|
209
|
+
// hover affordances can identify the clicked module.
|
|
210
|
+
//
|
|
211
|
+
// P6.7.5 — modules whose slug matches a `nav-menu/<slug>` set get
|
|
212
|
+
// their HTML replaced by a fresh render of the menu items. That's
|
|
213
|
+
// what makes a slug change update every menu without touching
|
|
214
|
+
// module HTML.
|
|
215
|
+
const renderedModuleHtml = block.modules.map((m) => {
|
|
216
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
217
|
+
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
218
|
+
let baseHtml: string;
|
|
219
|
+
if (navMenuItems !== null) {
|
|
220
|
+
navRendered = true;
|
|
221
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
222
|
+
} else if (langSelector !== null) {
|
|
223
|
+
baseHtml = langSelector;
|
|
224
|
+
} else {
|
|
225
|
+
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
|
|
226
|
+
}
|
|
227
|
+
return tagModuleId(baseHtml, m.moduleId);
|
|
228
|
+
});
|
|
229
|
+
const html = renderedModuleHtml.join("\n");
|
|
230
|
+
contentByName.set(block.blockName, html);
|
|
231
|
+
for (const m of block.modules) {
|
|
232
|
+
if (seenAssetModules.has(m.moduleId)) continue;
|
|
233
|
+
seenAssetModules.add(m.moduleId);
|
|
234
|
+
if (m.css.trim().length > 0) allCss.push(m.css);
|
|
235
|
+
if (m.js.trim().length > 0) allJs.push(m.js);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (navRendered) {
|
|
240
|
+
allCss.push(NAV_FUNCTIONAL_CSS);
|
|
241
|
+
allJs.push(NAV_TOGGLE_JS);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const replaced = applySlotReplacements(input.templateHtml, { contentByName });
|
|
245
|
+
let html = replaced.html;
|
|
246
|
+
|
|
247
|
+
// issue #150 — @font-face + preloads before everything else so the
|
|
248
|
+
// browser discovers font URLs as early as possible.
|
|
249
|
+
const fontsFragment = fontsHeadFragment(input.fonts);
|
|
250
|
+
if (fontsFragment !== null) {
|
|
251
|
+
html = injectBefore(html, HEAD_CLOSE_RE, fontsFragment);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// v0.11.0 — theme tokens become CSS custom properties on :root + (when
|
|
255
|
+
// dark variants exist) :root.dark. Goes first so module CSS can
|
|
256
|
+
// `var(--color-primary)` and override. Pre-v0.11 read from
|
|
257
|
+
// structured_sets["theme/site"]; now reads the active themes row
|
|
258
|
+
// threaded through ComposeInput.theme.
|
|
259
|
+
const themeCss = renderThemeCss(input.theme);
|
|
260
|
+
if (themeCss !== null) {
|
|
261
|
+
const styleTag = `<style data-source="theme">${themeCss}</style>`;
|
|
262
|
+
html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
|
|
263
|
+
}
|
|
264
|
+
// issue #151 — invisible technical baseline (see composePageWithLayout).
|
|
265
|
+
html = injectBefore(
|
|
266
|
+
html,
|
|
267
|
+
HEAD_CLOSE_RE,
|
|
268
|
+
`<style data-source="base">${BASE_TECHNICAL_CSS}</style>`,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (allCss.length > 0) {
|
|
272
|
+
const styleTag = `<style data-source="modules">\n${allCss.join("\n")}\n</style>`;
|
|
273
|
+
html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
|
|
274
|
+
}
|
|
275
|
+
if (allJs.length > 0) {
|
|
276
|
+
const scriptTag = `<script defer data-source="modules">\n${allJs.join("\n")}\n</script>`;
|
|
277
|
+
html = injectBefore(html, BODY_CLOSE_RE, scriptTag);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
html,
|
|
282
|
+
replacedSlots: replaced.replacedSlots,
|
|
283
|
+
missingSlots: replaced.missingSlots,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Insert `data-caelo-module-id="<id>"` into the first opening tag of
|
|
289
|
+
* the module's HTML. Idempotent — re-tagging an already-tagged module
|
|
290
|
+
* is a no-op. Comments / DOCTYPE / leading whitespace before the first
|
|
291
|
+
* tag are tolerated. Modules that have no opening tag (pure text)
|
|
292
|
+
* return unchanged because there's nothing to attach to.
|
|
293
|
+
*
|
|
294
|
+
* Exported so callers (admin preview endpoint, static generator,
|
|
295
|
+
* tests) can reuse the same logic.
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* P6.7.5 — return the items for a `nav-menu/<slug>` set when a module's
|
|
299
|
+
* slug starts with `nav-menu-`. Returns null when the module is not a
|
|
300
|
+
* nav menu (so the composer falls back to its stored HTML).
|
|
301
|
+
*
|
|
302
|
+
* Convention: a module slug `nav-menu-header-main` resolves to
|
|
303
|
+
* structuredSets[`nav-menu/header-main`].
|
|
304
|
+
*/
|
|
305
|
+
function lookupNavMenuItems(
|
|
306
|
+
moduleSlug: string,
|
|
307
|
+
sets: ComposeStructuredSets | undefined,
|
|
308
|
+
): readonly unknown[] | null {
|
|
309
|
+
if (!sets) return null;
|
|
310
|
+
const prefix = "nav-menu-";
|
|
311
|
+
if (!moduleSlug.startsWith(prefix)) return null;
|
|
312
|
+
const setSlug = moduleSlug.slice(prefix.length);
|
|
313
|
+
const items = sets.byKindSlug[`nav-menu/${setSlug}`];
|
|
314
|
+
return items ?? null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* P9 — return rendered language-selector HTML when a module's slug
|
|
319
|
+
* starts with `language-selector-`. The set's items act as overrides
|
|
320
|
+
* (relabel a locale, hide one); the available-locale list comes from
|
|
321
|
+
* the caller via `input.languageSelector`. Returns null when the
|
|
322
|
+
* module is not a language selector.
|
|
323
|
+
*
|
|
324
|
+
* Convention: a module slug `language-selector-header` resolves to
|
|
325
|
+
* structuredSets[`language-selector/header`] for overrides, and the
|
|
326
|
+
* rendered HTML lists every locale that has a published variant of
|
|
327
|
+
* the current page.
|
|
328
|
+
*/
|
|
329
|
+
function lookupLanguageSelector(moduleSlug: string, input: ComposeInput): string | null {
|
|
330
|
+
const prefix = "language-selector-";
|
|
331
|
+
if (!moduleSlug.startsWith(prefix)) return null;
|
|
332
|
+
if (!input.languageSelector || input.languageSelector.availableLocales.length === 0) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const setSlug = moduleSlug.slice(prefix.length);
|
|
336
|
+
const overridesUnknown = input.structuredSets?.byKindSlug[`language-selector/${setSlug}`];
|
|
337
|
+
const overrides = Array.isArray(overridesUnknown)
|
|
338
|
+
? (overridesUnknown as LanguageSelectorOverride[])
|
|
339
|
+
: undefined;
|
|
340
|
+
return renderLanguageSelector({
|
|
341
|
+
availableLocales: input.languageSelector.availableLocales,
|
|
342
|
+
overrides,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface NavMenuItem {
|
|
347
|
+
label: string;
|
|
348
|
+
href: string;
|
|
349
|
+
target?: "_self" | "_blank";
|
|
350
|
+
children?: NavMenuItem[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function escapeAttr(s: string): string {
|
|
354
|
+
return s
|
|
355
|
+
.replace(/&/g, "&")
|
|
356
|
+
.replace(/"/g, """)
|
|
357
|
+
.replace(/</g, "<")
|
|
358
|
+
.replace(/>/g, ">");
|
|
359
|
+
}
|
|
360
|
+
function escapeText(s: string): string {
|
|
361
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Render a nav-menu's typed items into HTML. Recursively handles
|
|
366
|
+
* children for submenus. Plain `<nav><ul><li>` so site CSS can theme
|
|
367
|
+
* it via the `caelo-nav-menu` class.
|
|
368
|
+
*/
|
|
369
|
+
function renderNavMenuHtml(items: readonly unknown[]): string {
|
|
370
|
+
const safeItems = items.filter((it): it is NavMenuItem => {
|
|
371
|
+
if (!it || typeof it !== "object") return false;
|
|
372
|
+
const o = it as { label?: unknown; href?: unknown };
|
|
373
|
+
return typeof o.label === "string" && typeof o.href === "string";
|
|
374
|
+
});
|
|
375
|
+
// issue #160 — mobile-ready markup: toggle button (three functional
|
|
376
|
+
// bars via currentColor) + collapsible list. The functional CSS/JS
|
|
377
|
+
// ships once per page from ./interactions.ts when a nav rendered.
|
|
378
|
+
const toggle =
|
|
379
|
+
'<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
|
|
380
|
+
'<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
|
|
381
|
+
"</button>";
|
|
382
|
+
return `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
|
|
383
|
+
}
|
|
384
|
+
function renderNavItem(item: NavMenuItem): string {
|
|
385
|
+
const target = item.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
|
|
386
|
+
const inner =
|
|
387
|
+
item.children && item.children.length > 0
|
|
388
|
+
? `<ul>${item.children.map(renderNavItem).join("")}</ul>`
|
|
389
|
+
: "";
|
|
390
|
+
return `<li><a href="${escapeAttr(item.href)}"${target}>${escapeText(item.label)}</a>${inner}</li>`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* v0.11.0 — Render the active theme's DTCG tokens as `:root { … }` (+
|
|
395
|
+
* optional `:root.dark { … }`) CSS. Returns null when no active theme
|
|
396
|
+
* is threaded through, so the composer skips injecting an empty
|
|
397
|
+
* `<style>` tag. The renderer itself lives in `theme-render.ts`; this
|
|
398
|
+
* wrapper exists so the composer's call site stays readable + the
|
|
399
|
+
* empty-tokens case (active row, `tokens={}`) emits a deterministic
|
|
400
|
+
* empty shell rather than skipping the tag (observable absence in
|
|
401
|
+
* DevTools, per #45's test-strategy "empty-active-theme" assertion).
|
|
402
|
+
*/
|
|
403
|
+
function renderThemeCss(theme: ComposeTheme | undefined): string | null {
|
|
404
|
+
if (!theme) return null;
|
|
405
|
+
// Empty-tokens case: emit the shell so cascade ordering stays
|
|
406
|
+
// consistent (legacy "no theme/site row" case still returns null
|
|
407
|
+
// above so the tag is skipped entirely).
|
|
408
|
+
if (Object.keys(theme.tokens).length === 0) return ":root{}";
|
|
409
|
+
return renderThemeCssFromTokens(theme.tokens);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Substitute `{{name}}` placeholders and `{{#name}}…{{/name}}`
|
|
414
|
+
* sections in module HTML. Thin wrapper around the shared template
|
|
415
|
+
* engine (#71); see `template-engine.ts` for the full substitution
|
|
416
|
+
* grammar and the loud-raw / failure-marker invariants.
|
|
417
|
+
*
|
|
418
|
+
* Compose is the no-DB path: nested-module field kinds (`module`,
|
|
419
|
+
* `module-list`) cannot be resolved here because the partial loader
|
|
420
|
+
* needs a DB walk to fetch nested content_instances. The engine
|
|
421
|
+
* emits a loud HTML comment for those refs so static-gen output is
|
|
422
|
+
* visible-broken (per CLAUDE.md §2) rather than silent-empty. The
|
|
423
|
+
* chat-branched preview path resolves nested refs via
|
|
424
|
+
* `preview-render.ts` BEFORE the substituted HTML reaches the
|
|
425
|
+
* composer, so this branch never trips for that path. Issue #70
|
|
426
|
+
* tracks pre-resolving in static-gen so the loud comment goes away
|
|
427
|
+
* there too.
|
|
428
|
+
*
|
|
429
|
+
* Both `fields` and `contentValues` are optional. Field `kind` is
|
|
430
|
+
* optional for back-compat with callers that haven't been updated;
|
|
431
|
+
* the engine treats absent kinds as primitives (the legacy
|
|
432
|
+
* compose-path behaviour).
|
|
433
|
+
*/
|
|
434
|
+
function applyFieldSubstitution(
|
|
435
|
+
html: string,
|
|
436
|
+
fields: readonly { name: string; kind?: ModuleFieldKind; default?: unknown }[] | undefined,
|
|
437
|
+
contentValues: Readonly<Record<string, unknown>> | undefined,
|
|
438
|
+
theme: ComposeTheme | undefined,
|
|
439
|
+
): string {
|
|
440
|
+
// The substitution engine (renderTemplate) already unwraps CDATA
|
|
441
|
+
// guards; cover the no-op early-return path so a chrome module with no
|
|
442
|
+
// fields/values/theme is cleaned too.
|
|
443
|
+
if (!fields && !contentValues && !theme) return stripCdataGuards(html);
|
|
444
|
+
const engineFields: TemplateField[] = (fields ?? []).map((f) => ({
|
|
445
|
+
name: f.name,
|
|
446
|
+
kind: f.kind ?? "text",
|
|
447
|
+
default: f.default,
|
|
448
|
+
}));
|
|
449
|
+
return renderTemplate({
|
|
450
|
+
html,
|
|
451
|
+
fields: engineFields,
|
|
452
|
+
contentValues,
|
|
453
|
+
// v0.11.1 (issue #76) — thread the active theme's asset URLs so
|
|
454
|
+
// module HTML carrying `{{theme_logo_url}}` etc. resolves. Unbound
|
|
455
|
+
// slots emit loud-raw + `theme-asset-unbound:<slot>` markers.
|
|
456
|
+
themeAssets: theme
|
|
457
|
+
? {
|
|
458
|
+
logo: theme.assets.logo?.url ?? null,
|
|
459
|
+
logoDark: theme.assets.logoDark?.url ?? null,
|
|
460
|
+
favicon: theme.assets.favicon?.url ?? null,
|
|
461
|
+
socialShare: theme.assets.socialShare?.url ?? null,
|
|
462
|
+
}
|
|
463
|
+
: undefined,
|
|
464
|
+
// Compose path has no DB; pass no partials so module/module-list
|
|
465
|
+
// refs emit the loud HTML comment per CLAUDE.md §2.
|
|
466
|
+
}).html;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function tagModuleId(html: string, moduleId: string): string {
|
|
470
|
+
if (!html) return html;
|
|
471
|
+
const firstOpen = /<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/;
|
|
472
|
+
const m = firstOpen.exec(html);
|
|
473
|
+
if (!m) return html;
|
|
474
|
+
// Already tagged?
|
|
475
|
+
const tagAttrs = m[2] ?? "";
|
|
476
|
+
if (/\sdata-caelo-module-id\s*=/.test(tagAttrs)) return html;
|
|
477
|
+
const replaced = `<${m[1]}${tagAttrs} data-caelo-module-id="${moduleId}">`;
|
|
478
|
+
return html.slice(0, m.index) + replaced + html.slice(m.index + m[0].length);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* P6.7.6 — layout-aware composer. Runs the template composer first,
|
|
483
|
+
* extracts the resulting body content, then renders the layout HTML
|
|
484
|
+
* substituting:
|
|
485
|
+
* - `<caelo-slot name="content">` → the body of the rendered template
|
|
486
|
+
* - other layout blocks (header / footer / etc.) → concatenated HTML
|
|
487
|
+
* from `layoutBlocks` (per-block module attachments)
|
|
488
|
+
*
|
|
489
|
+
* Per CLAUDE.md §2 no-fallbacks: validates the layout has the required
|
|
490
|
+
* `<caelo-slot name="content">` slot before rendering. Throws
|
|
491
|
+
* `ComposeError` if the layout is malformed so callers (preview op +
|
|
492
|
+
* static generator) surface it as a structured failure rather than
|
|
493
|
+
* silently emitting broken HTML.
|
|
494
|
+
*/
|
|
495
|
+
export interface ComposeLayoutBlock {
|
|
496
|
+
readonly blockName: string;
|
|
497
|
+
readonly modules: readonly ComposeModule[];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface ComposeWithLayoutInput extends ComposeInput {
|
|
501
|
+
readonly layoutHtml: string;
|
|
502
|
+
readonly layoutCss: string;
|
|
503
|
+
readonly layoutBlocks: readonly ComposeLayoutBlock[];
|
|
504
|
+
/** Optional layout slug carried into ComposeError messages. */
|
|
505
|
+
readonly layoutSlug?: string;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Typed failure for the layout-aware composer. Use `kind` to dispatch:
|
|
510
|
+
* - `layout-missing-content`: the layout HTML lacks
|
|
511
|
+
* `<caelo-slot name="content">…</caelo-slot>` so the page body has
|
|
512
|
+
* nowhere to land.
|
|
513
|
+
*/
|
|
514
|
+
export class ComposeError extends Error {
|
|
515
|
+
readonly kind: "layout-missing-content";
|
|
516
|
+
readonly layoutSlug: string | undefined;
|
|
517
|
+
constructor(kind: "layout-missing-content", message: string, layoutSlug?: string) {
|
|
518
|
+
super(message);
|
|
519
|
+
this.name = "ComposeError";
|
|
520
|
+
this.kind = kind;
|
|
521
|
+
this.layoutSlug = layoutSlug;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const BODY_OPEN_RE = /<body\b[^>]*>/i;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Extract the inner body HTML from a fully rendered template document.
|
|
529
|
+
* If the template HTML has no <body> (legacy fragment templates), the
|
|
530
|
+
* whole composed string is returned as-is — the layout's
|
|
531
|
+
* `<caelo-slot name="content">` becomes a generic mount point and the
|
|
532
|
+
* layout owns <html><head><body>.
|
|
533
|
+
*
|
|
534
|
+
* Legacy templates often wrap their slot in `<body><caelo-slot
|
|
535
|
+
* name="content">…</caelo-slot></body>` — peel off the redundant
|
|
536
|
+
* `<caelo-slot>` so we don't end up with the layout's own slot
|
|
537
|
+
* containing yet another `<caelo-slot>`. The peel uses the same
|
|
538
|
+
* htmlparser2 Parser as `applySlotReplacements` so quoting / attribute
|
|
539
|
+
* ordering / whitespace variations are handled uniformly (the previous
|
|
540
|
+
* regex silently fell through on `name='content'`, attr reordering,
|
|
541
|
+
* etc., producing nested-slot output).
|
|
542
|
+
*/
|
|
543
|
+
function extractBodyInner(composedHtml: string): string {
|
|
544
|
+
const open = BODY_OPEN_RE.exec(composedHtml);
|
|
545
|
+
const close = BODY_CLOSE_RE.exec(composedHtml);
|
|
546
|
+
let inner: string;
|
|
547
|
+
if (!open || !close || close.index < open.index) {
|
|
548
|
+
inner = composedHtml;
|
|
549
|
+
} else {
|
|
550
|
+
const start = open.index + open[0].length;
|
|
551
|
+
inner = composedHtml.slice(start, close.index);
|
|
552
|
+
}
|
|
553
|
+
const peeled = extractInnerOfTopLevelContentSlot(inner);
|
|
554
|
+
return peeled ?? inner;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOutput {
|
|
558
|
+
// No-fallbacks (CLAUDE.md §2): validate the layout declares a
|
|
559
|
+
// `content` slot up-front, before rendering. The htmlparser2-based
|
|
560
|
+
// walk handles attribute quoting / ordering uniformly; a layout
|
|
561
|
+
// without the slot is a misconfiguration that must surface to the
|
|
562
|
+
// caller, not silently emit a body-less page.
|
|
563
|
+
if (!listSlotNames(input.layoutHtml).includes("content")) {
|
|
564
|
+
const slug = input.layoutSlug ?? "(unknown)";
|
|
565
|
+
throw new ComposeError(
|
|
566
|
+
"layout-missing-content",
|
|
567
|
+
`layout "${slug}" is missing the required \`<caelo-slot name="content">\` slot — fix via /security/layouts`,
|
|
568
|
+
input.layoutSlug,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// CSS / JS aggregation order: layout (ground) → template (overrides
|
|
573
|
+
// layout) → modules (highest specificity). The array's source order
|
|
574
|
+
// drives cascade order in the emitted <style> tag, so we push in
|
|
575
|
+
// priority sequence rather than mixing push + unshift (which is
|
|
576
|
+
// brittle and reads as a bug).
|
|
577
|
+
const cssParts: string[] = [];
|
|
578
|
+
const jsParts: string[] = [];
|
|
579
|
+
// issue #160 — set when any nav-menu module rendered; pulls the
|
|
580
|
+
// functional nav CSS/JS in exactly once per page.
|
|
581
|
+
let navRendered = false;
|
|
582
|
+
// issue #158 — a module placed N times contributes its CSS/JS ONCE
|
|
583
|
+
// (first occurrence wins; source order is otherwise preserved).
|
|
584
|
+
// Duplicate rule blocks made the cascade order-dependent and bloated
|
|
585
|
+
// every page the same module appeared on twice.
|
|
586
|
+
const seenAssetModules = new Set<string>();
|
|
587
|
+
if (input.layoutCss.trim().length > 0) cssParts.push(input.layoutCss);
|
|
588
|
+
if (input.templateCss.trim().length > 0) cssParts.push(input.templateCss);
|
|
589
|
+
|
|
590
|
+
// 1. Render the page modules into the template (slot replacement only;
|
|
591
|
+
// no head/body manipulation here — that belongs to the layout).
|
|
592
|
+
const templateContentByName = new Map<string, string>();
|
|
593
|
+
for (const block of input.blocks) {
|
|
594
|
+
const renderedModuleHtml = block.modules.map((m) => {
|
|
595
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
596
|
+
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
597
|
+
let baseHtml: string;
|
|
598
|
+
if (navMenuItems !== null) {
|
|
599
|
+
navRendered = true;
|
|
600
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
601
|
+
} else if (langSelector !== null) {
|
|
602
|
+
baseHtml = langSelector;
|
|
603
|
+
} else {
|
|
604
|
+
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
|
|
605
|
+
}
|
|
606
|
+
return tagModuleId(baseHtml, m.moduleId);
|
|
607
|
+
});
|
|
608
|
+
templateContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
609
|
+
for (const m of block.modules) {
|
|
610
|
+
if (seenAssetModules.has(m.moduleId)) continue;
|
|
611
|
+
seenAssetModules.add(m.moduleId);
|
|
612
|
+
if (m.css.trim().length > 0) cssParts.push(m.css);
|
|
613
|
+
if (m.js.trim().length > 0) jsParts.push(m.js);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const renderedTemplate = applySlotReplacements(input.templateHtml, {
|
|
617
|
+
contentByName: templateContentByName,
|
|
618
|
+
});
|
|
619
|
+
const innerBody = extractBodyInner(renderedTemplate.html);
|
|
620
|
+
|
|
621
|
+
// 2. Build per-layout-block contents (header / footer / etc.) +
|
|
622
|
+
// aggregate their CSS/JS at module specificity (already higher
|
|
623
|
+
// than layout/template because the layout/template parts went
|
|
624
|
+
// in first above).
|
|
625
|
+
const layoutContentByName = new Map<string, string>();
|
|
626
|
+
layoutContentByName.set("content", innerBody);
|
|
627
|
+
for (const block of input.layoutBlocks) {
|
|
628
|
+
if (block.blockName === "content") continue; // reserved for the page body
|
|
629
|
+
const renderedModuleHtml = block.modules.map((m) => {
|
|
630
|
+
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
631
|
+
const langSelector = lookupLanguageSelector(m.slug, input);
|
|
632
|
+
let baseHtml: string;
|
|
633
|
+
if (navMenuItems !== null) {
|
|
634
|
+
navRendered = true;
|
|
635
|
+
baseHtml = renderNavMenuHtml(navMenuItems);
|
|
636
|
+
} else if (langSelector !== null) {
|
|
637
|
+
baseHtml = langSelector;
|
|
638
|
+
} else {
|
|
639
|
+
baseHtml = applyFieldSubstitution(m.html, m.fields, m.contentValues, input.theme);
|
|
640
|
+
}
|
|
641
|
+
return tagModuleId(baseHtml, m.moduleId);
|
|
642
|
+
});
|
|
643
|
+
layoutContentByName.set(block.blockName, renderedModuleHtml.join("\n"));
|
|
644
|
+
for (const m of block.modules) {
|
|
645
|
+
if (seenAssetModules.has(m.moduleId)) continue;
|
|
646
|
+
seenAssetModules.add(m.moduleId);
|
|
647
|
+
if (m.css.trim().length > 0) cssParts.push(m.css);
|
|
648
|
+
if (m.js.trim().length > 0) jsParts.push(m.js);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (navRendered) {
|
|
653
|
+
cssParts.push(NAV_FUNCTIONAL_CSS);
|
|
654
|
+
jsParts.push(NAV_TOGGLE_JS);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// 3. Render the layout HTML, substituting all named slots.
|
|
658
|
+
const replaced = applySlotReplacements(input.layoutHtml, {
|
|
659
|
+
contentByName: layoutContentByName,
|
|
660
|
+
});
|
|
661
|
+
let html = replaced.html;
|
|
662
|
+
|
|
663
|
+
// issue #150 — fonts first (URL discovery), then theme vars, then
|
|
664
|
+
// aggregated CSS; source order in <head> mirrors injection order.
|
|
665
|
+
const fontsFragment = fontsHeadFragment(input.fonts);
|
|
666
|
+
if (fontsFragment !== null) {
|
|
667
|
+
html = injectBefore(html, HEAD_CLOSE_RE, fontsFragment);
|
|
668
|
+
}
|
|
669
|
+
const themeCss = renderThemeCss(input.theme);
|
|
670
|
+
if (themeCss !== null) {
|
|
671
|
+
html = injectBefore(html, HEAD_CLOSE_RE, `<style data-source="theme">${themeCss}</style>`);
|
|
672
|
+
}
|
|
673
|
+
// issue #151 — invisible technical baseline (reset only, zero design
|
|
674
|
+
// opinion); module CSS follows and overrides trivially.
|
|
675
|
+
html = injectBefore(
|
|
676
|
+
html,
|
|
677
|
+
HEAD_CLOSE_RE,
|
|
678
|
+
`<style data-source="base">${BASE_TECHNICAL_CSS}</style>`,
|
|
679
|
+
);
|
|
680
|
+
if (cssParts.length > 0) {
|
|
681
|
+
html = injectBefore(
|
|
682
|
+
html,
|
|
683
|
+
HEAD_CLOSE_RE,
|
|
684
|
+
`<style data-source="modules">\n${cssParts.join("\n")}\n</style>`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
if (jsParts.length > 0) {
|
|
688
|
+
html = injectBefore(
|
|
689
|
+
html,
|
|
690
|
+
BODY_CLOSE_RE,
|
|
691
|
+
`<script defer data-source="modules">\n${jsParts.join("\n")}\n</script>`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// De-duplicate slot accounting across both passes — the template's
|
|
696
|
+
// `content` slot and the layout's `content` slot are conceptually the
|
|
697
|
+
// same surface to a caller asking "did content get filled?".
|
|
698
|
+
const replacedSet = new Set<string>([
|
|
699
|
+
...renderedTemplate.replacedSlots,
|
|
700
|
+
...replaced.replacedSlots,
|
|
701
|
+
]);
|
|
702
|
+
const missingSet = new Set<string>([...renderedTemplate.missingSlots, ...replaced.missingSlots]);
|
|
703
|
+
for (const name of replacedSet) missingSet.delete(name);
|
|
704
|
+
return {
|
|
705
|
+
html,
|
|
706
|
+
replacedSlots: [...replacedSet],
|
|
707
|
+
missingSlots: [...missingSet],
|
|
708
|
+
};
|
|
709
|
+
}
|