@caelo-cms/shared 0.10.22 → 0.10.24

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