@caelo-cms/shared 0.10.21 → 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.
Files changed (241) hide show
  1. package/dist/ai-tools.d.ts +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. 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, "&amp;")
356
+ .replace(/"/g, "&quot;")
357
+ .replace(/</g, "&lt;")
358
+ .replace(/>/g, "&gt;");
359
+ }
360
+ function escapeText(s: string): string {
361
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }