@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.
Files changed (241) hide show
  1. package/dist/ai-tools.d.ts +290 -214
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +341 -264
  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,602 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ ComposeError,
6
+ composePagePreview,
7
+ composePageWithLayout,
8
+ tagModuleId,
9
+ } from "./preview-compose.js";
10
+
11
+ const blankModule = {
12
+ moduleId: "00000000-0000-0000-0000-000000000000",
13
+ slug: "blank",
14
+ displayName: "Blank",
15
+ html: "",
16
+ css: "",
17
+ js: "",
18
+ };
19
+
20
+ describe("composePagePreview", () => {
21
+ it("inlines module HTML into named slots and stamps CSS/JS into head/body", () => {
22
+ const out = composePagePreview({
23
+ templateHtml: `<!doctype html><html><head><title>T</title></head><body><caelo-slot name="content">x</caelo-slot></body></html>`,
24
+ templateCss: "",
25
+ blocks: [
26
+ {
27
+ blockName: "content",
28
+ modules: [
29
+ {
30
+ ...blankModule,
31
+ html: "<p>HELLO</p>",
32
+ css: "p { color: red; }",
33
+ js: "console.log('hi');",
34
+ },
35
+ ],
36
+ },
37
+ ],
38
+ });
39
+ expect(out.html).toContain(
40
+ `<caelo-slot name="content"><p data-caelo-module-id="00000000-0000-0000-0000-000000000000">HELLO</p></caelo-slot>`,
41
+ );
42
+ expect(out.html).toContain(`<style data-source="modules">`);
43
+ expect(out.html).toContain(`p { color: red; }`);
44
+ expect(out.html).toContain(`<script defer data-source="modules">`);
45
+ expect(out.html).toContain(`console.log('hi');`);
46
+ // CSS appears before </head>; JS before </body>.
47
+ expect(out.html.indexOf(`<style data-source="modules">`)).toBeLessThan(
48
+ out.html.indexOf("</head>"),
49
+ );
50
+ expect(out.html.indexOf(`<script defer data-source="modules">`)).toBeLessThan(
51
+ out.html.indexOf("</body>"),
52
+ );
53
+ });
54
+
55
+ it("orders modules within a block by array order", () => {
56
+ const out = composePagePreview({
57
+ templateHtml: `<body><caelo-slot name="x">_</caelo-slot></body>`,
58
+ templateCss: "",
59
+ blocks: [
60
+ {
61
+ blockName: "x",
62
+ modules: [
63
+ { ...blankModule, html: "<p>A</p>" },
64
+ { ...blankModule, html: "<p>B</p>" },
65
+ ],
66
+ },
67
+ ],
68
+ });
69
+ // Both modules use the same blank moduleId so the data-attr collides;
70
+ // ordering is what matters here. Just assert both are present in
71
+ // sequence with the expected separator.
72
+ expect(out.html).toMatch(
73
+ /<p data-caelo-module-id="[^"]+">A<\/p>\n<p data-caelo-module-id="[^"]+">B<\/p>/,
74
+ );
75
+ });
76
+
77
+ it("emits template CSS before module CSS so module rules win on tie specificity", () => {
78
+ const out = composePagePreview({
79
+ templateHtml: `<head></head><body></body>`,
80
+ templateCss: "body { color: blue; }",
81
+ blocks: [
82
+ {
83
+ blockName: "x",
84
+ modules: [{ ...blankModule, css: "body { color: red; }" }],
85
+ },
86
+ ],
87
+ });
88
+ const cssBlock = out.html.match(/<style data-source="modules">[\s\S]*?<\/style>/)?.[0] ?? "";
89
+ expect(cssBlock.indexOf("color: blue")).toBeLessThan(cssBlock.indexOf("color: red"));
90
+ });
91
+
92
+ it("omits the style tag entirely when no module/template CSS exists", () => {
93
+ const out = composePagePreview({
94
+ templateHtml: `<head></head><body><caelo-slot name="x">_</caelo-slot></body>`,
95
+ templateCss: "",
96
+ blocks: [{ blockName: "x", modules: [{ ...blankModule, html: "<p>x</p>" }] }],
97
+ });
98
+ expect(out.html).not.toContain(`<style data-source="modules">`);
99
+ expect(out.html).not.toContain(`<script defer data-source="modules">`);
100
+ });
101
+
102
+ it("records missing slot names when the template has unfilled slots", () => {
103
+ const out = composePagePreview({
104
+ templateHtml: `<body><caelo-slot name="missing">_</caelo-slot></body>`,
105
+ templateCss: "",
106
+ blocks: [],
107
+ });
108
+ expect(out.missingSlots).toEqual(["missing"]);
109
+ });
110
+ });
111
+
112
+ describe("tagModuleId (P6.7 live-edit overlay support)", () => {
113
+ it("adds data-caelo-module-id to the first opening tag", () => {
114
+ expect(tagModuleId("<p>hello</p>", "abc-123")).toBe(
115
+ `<p data-caelo-module-id="abc-123">hello</p>`,
116
+ );
117
+ });
118
+
119
+ it("is idempotent — re-running on tagged HTML is a no-op", () => {
120
+ const once = tagModuleId("<p>x</p>", "id-1");
121
+ expect(tagModuleId(once, "id-1")).toBe(once);
122
+ });
123
+
124
+ it("only tags the FIRST opening tag — siblings stay untouched", () => {
125
+ const out = tagModuleId("<p>a</p><p>b</p>", "id-1");
126
+ expect(out).toBe(`<p data-caelo-module-id="id-1">a</p><p>b</p>`);
127
+ });
128
+
129
+ it("tolerates leading whitespace + comments before the first tag", () => {
130
+ const out = tagModuleId(" <!-- hi -->\n<div>x</div>", "id-9");
131
+ expect(out).toContain(`<div data-caelo-module-id="id-9">x</div>`);
132
+ });
133
+
134
+ it("returns unchanged when there is no opening tag (pure text)", () => {
135
+ expect(tagModuleId("plain text", "id-1")).toBe("plain text");
136
+ });
137
+
138
+ it("preserves existing attributes on the first tag", () => {
139
+ expect(tagModuleId(`<h1 class="hero" id="x">A</h1>`, "id-7")).toBe(
140
+ `<h1 class="hero" id="x" data-caelo-module-id="id-7">A</h1>`,
141
+ );
142
+ });
143
+ });
144
+
145
+ describe("composePagePreview substitutes {{name}} placeholders with field defaults", () => {
146
+ // PR #61 follow-up: before this, modules with `{{name}}` placeholders
147
+ // and a declared `fields[]` shipped raw to the browser via the
148
+ // static-generator path (visitors saw literal `{{spantext}}` /
149
+ // `{{ctahref}}` text). The substitution lives in compose so both the
150
+ // preview-iframe path and the static-gen path agree on the default
151
+ // floor. Per-placement overrides (content_instances.values) still
152
+ // happen in preview-render, not here.
153
+ const slot = `<body><caelo-slot name="content">_</caelo-slot></body>`;
154
+ it("fills declared {{name}} placeholders with each field's default", () => {
155
+ const out = composePagePreview({
156
+ templateHtml: slot,
157
+ templateCss: "",
158
+ blocks: [
159
+ {
160
+ blockName: "content",
161
+ modules: [
162
+ {
163
+ moduleId: "mod-x",
164
+ slug: "hero",
165
+ displayName: "Hero",
166
+ html: '<h1>{{heading}}</h1><a href="{{ctahref}}">{{ctalabel}}</a>',
167
+ css: "",
168
+ js: "",
169
+ fields: [
170
+ { name: "heading", default: "Welcome to Caelo" },
171
+ { name: "ctahref", default: "/get-started" },
172
+ { name: "ctalabel", default: "Get started" },
173
+ ],
174
+ },
175
+ ],
176
+ },
177
+ ],
178
+ });
179
+ expect(out.html).toContain(">Welcome to Caelo</h1>");
180
+ expect(out.html).toContain('<a href="/get-started">Get started</a>');
181
+ // No raw `{{name}}` residue in the body.
182
+ expect(out.html).not.toMatch(/\{\{[a-z]/i);
183
+ });
184
+ it("contentValues override field defaults (per-placement wins)", () => {
185
+ const out = composePagePreview({
186
+ templateHtml: slot,
187
+ templateCss: "",
188
+ blocks: [
189
+ {
190
+ blockName: "content",
191
+ modules: [
192
+ {
193
+ moduleId: "mod-z",
194
+ slug: "hero",
195
+ displayName: "Hero",
196
+ html: "<h1>{{heading}}</h1>",
197
+ css: "",
198
+ js: "",
199
+ fields: [{ name: "heading", default: "Default headline" }],
200
+ contentValues: { heading: "Per-placement headline" },
201
+ },
202
+ ],
203
+ },
204
+ ],
205
+ });
206
+ expect(out.html).toContain(">Per-placement headline</h1>");
207
+ expect(out.html).not.toContain("Default headline");
208
+ });
209
+ it("contentValues without a matching field still substitute (values are authoritative)", () => {
210
+ // AI-authored explicit fields without defaults rely on
211
+ // contentValues being the source of truth — the bug e2e-livedit's
212
+ // second Stage hit before this commit (issue #70).
213
+ const out = composePagePreview({
214
+ templateHtml: slot,
215
+ templateCss: "",
216
+ blocks: [
217
+ {
218
+ blockName: "content",
219
+ modules: [
220
+ {
221
+ moduleId: "mod-w",
222
+ slug: "brand",
223
+ displayName: "Brand",
224
+ html: '<a href="{{brand_href}}">{{brand_name}}</a>',
225
+ css: "",
226
+ js: "",
227
+ fields: [{ name: "brand_href" }, { name: "brand_name" }],
228
+ contentValues: { brand_href: "/", brand_name: "Caelo" },
229
+ },
230
+ ],
231
+ },
232
+ ],
233
+ });
234
+ expect(out.html).toContain('<a href="/" data-caelo-module-id="mod-w">Caelo</a>');
235
+ expect(out.html).not.toMatch(/\{\{brand/);
236
+ });
237
+ it("leaves unknown {{name}} as raw placeholder (no-fallbacks per CLAUDE.md §2)", () => {
238
+ const out = composePagePreview({
239
+ templateHtml: slot,
240
+ templateCss: "",
241
+ blocks: [
242
+ {
243
+ blockName: "content",
244
+ modules: [
245
+ {
246
+ moduleId: "mod-y",
247
+ slug: "broken",
248
+ displayName: "Broken",
249
+ html: "<p>{{declared}} and {{undeclared}}</p>",
250
+ css: "",
251
+ js: "",
252
+ fields: [{ name: "declared", default: "OK" }],
253
+ },
254
+ ],
255
+ },
256
+ ],
257
+ });
258
+ expect(out.html).toContain(">OK and {{undeclared}}</p>");
259
+ });
260
+ });
261
+
262
+ describe("composePagePreview list-iteration via shared template engine (#71)", () => {
263
+ // PR #71 / Plan B — the compose path now routes through the shared
264
+ // template engine and gains text-list / link-list / module-list
265
+ // section support. The fixtures below are the AC verbatim shapes;
266
+ // failures here imply the static-generator is leaking raw mustache
267
+ // markers to visitors again (the bug #71 closes).
268
+ const slot = `<body><caelo-slot name="content">_</caelo-slot></body>`;
269
+
270
+ it("AC #1 — link-list iterates per element inside composed page", () => {
271
+ const out = composePagePreview({
272
+ templateHtml: slot,
273
+ templateCss: "",
274
+ blocks: [
275
+ {
276
+ blockName: "content",
277
+ modules: [
278
+ {
279
+ moduleId: "mod-nav",
280
+ slug: "header",
281
+ displayName: "Header",
282
+ html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
283
+ css: "",
284
+ js: "",
285
+ fields: [{ name: "nav_items", kind: "link-list" }],
286
+ contentValues: {
287
+ nav_items: [
288
+ { label: "Docs", href: "/docs" },
289
+ { label: "Blog", href: "/blog" },
290
+ ],
291
+ },
292
+ },
293
+ ],
294
+ },
295
+ ],
296
+ });
297
+ // Outermost element is tagged with data-caelo-module-id; assert
298
+ // the section iterated and that no raw markers leaked into the
299
+ // composed HTML.
300
+ expect(out.html).toContain('<a href="/docs">Docs</a>');
301
+ expect(out.html).toContain('<a href="/blog">Blog</a>');
302
+ expect(out.html).not.toMatch(/\{\{[#/]/);
303
+ expect(out.html).not.toMatch(/\{\{label/);
304
+ expect(out.html).not.toMatch(/\{\{href/);
305
+ expect(out.html).toContain('data-caelo-module-id="mod-nav"');
306
+ });
307
+
308
+ it("AC #2 — text-list iterates {{.}} per element inside composed page", () => {
309
+ const out = composePagePreview({
310
+ templateHtml: slot,
311
+ templateCss: "",
312
+ blocks: [
313
+ {
314
+ blockName: "content",
315
+ modules: [
316
+ {
317
+ moduleId: "mod-tags",
318
+ slug: "tags",
319
+ displayName: "Tags",
320
+ html: "<ul>{{#tags}}<li>{{.}}</li>{{/tags}}</ul>",
321
+ css: "",
322
+ js: "",
323
+ fields: [{ name: "tags", kind: "text-list" }],
324
+ contentValues: { tags: ["a", "b", "c"] },
325
+ },
326
+ ],
327
+ },
328
+ ],
329
+ });
330
+ expect(out.html).toContain("<li>a</li><li>b</li><li>c</li>");
331
+ expect(out.html).not.toMatch(/\{\{[#/]/);
332
+ expect(out.html).toContain('data-caelo-module-id="mod-tags"');
333
+ });
334
+
335
+ it("AC #3 — unknown {{#unknown}}…{{/unknown}} stays as raw markers in composed output", () => {
336
+ const out = composePagePreview({
337
+ templateHtml: slot,
338
+ templateCss: "",
339
+ blocks: [
340
+ {
341
+ blockName: "content",
342
+ modules: [
343
+ {
344
+ moduleId: "mod-broken",
345
+ slug: "broken",
346
+ displayName: "Broken",
347
+ html: "<p>{{#unknown}}x{{/unknown}}</p>",
348
+ css: "",
349
+ js: "",
350
+ fields: [],
351
+ },
352
+ ],
353
+ },
354
+ ],
355
+ });
356
+ // Raw markers preserved (no silent swallow) so the operator sees
357
+ // the broken template in DevTools, per CLAUDE.md §2.
358
+ expect(out.html).toContain("{{#unknown}}x{{/unknown}}");
359
+ });
360
+
361
+ it("AC #4 (compose-path posture) — module-list emits loud HTML comment, never raw markers", () => {
362
+ const out = composePagePreview({
363
+ templateHtml: slot,
364
+ templateCss: "",
365
+ blocks: [
366
+ {
367
+ blockName: "content",
368
+ modules: [
369
+ {
370
+ moduleId: "mod-cards",
371
+ slug: "cards",
372
+ displayName: "Cards",
373
+ html: "<ul>{{#cards}}<li>x</li>{{/cards}}</ul>",
374
+ css: "",
375
+ js: "",
376
+ fields: [{ name: "cards", kind: "module-list" }],
377
+ contentValues: {
378
+ cards: [{ moduleId: "child-mod", contentInstanceId: "child-ci" }],
379
+ },
380
+ },
381
+ ],
382
+ },
383
+ ],
384
+ });
385
+ expect(out.html).toContain(
386
+ "<!-- caelo:module-list cards needs recursive renderer (compose path) -->",
387
+ );
388
+ expect(out.html).not.toMatch(/\{\{[#/]/);
389
+ });
390
+ });
391
+
392
+ describe("composePagePreview tags every module's outermost element", () => {
393
+ const slot = `<body><caelo-slot name="content">_</caelo-slot></body>`;
394
+ it("threads the moduleId through the composed output", () => {
395
+ const out = composePagePreview({
396
+ templateHtml: slot,
397
+ templateCss: "",
398
+ blocks: [
399
+ {
400
+ blockName: "content",
401
+ modules: [
402
+ { moduleId: "mod-a", slug: "a", displayName: "A", html: "<p>1</p>", css: "", js: "" },
403
+ { moduleId: "mod-b", slug: "b", displayName: "B", html: "<p>2</p>", css: "", js: "" },
404
+ ],
405
+ },
406
+ ],
407
+ });
408
+ expect(out.html).toContain(`<p data-caelo-module-id="mod-a">1</p>`);
409
+ expect(out.html).toContain(`<p data-caelo-module-id="mod-b">2</p>`);
410
+ });
411
+ });
412
+
413
+ describe("composePageWithLayout", () => {
414
+ const layoutHtml = `<!doctype html><html><head></head><body><header><caelo-slot name="header">_</caelo-slot></header><main><caelo-slot name="content">_</caelo-slot></main></body></html>`;
415
+ const templateHtml = `<body><caelo-slot name="content">_</caelo-slot></body>`;
416
+
417
+ it("aggregates CSS in layout → template → modules order", () => {
418
+ const out = composePageWithLayout({
419
+ templateHtml,
420
+ templateCss: "/* TPL */",
421
+ blocks: [
422
+ {
423
+ blockName: "content",
424
+ modules: [
425
+ {
426
+ ...blankModule,
427
+ moduleId: "11111111-1111-1111-1111-111111111111",
428
+ html: "<p>x</p>",
429
+ css: "/* MOD */",
430
+ },
431
+ ],
432
+ },
433
+ ],
434
+ layoutHtml,
435
+ layoutCss: "/* LAYOUT */",
436
+ layoutBlocks: [],
437
+ layoutSlug: "test",
438
+ });
439
+ const css = out.html.slice(
440
+ out.html.indexOf(`<style data-source="modules">`),
441
+ out.html.indexOf("</style>", out.html.indexOf(`<style data-source="modules">`)),
442
+ );
443
+ const layoutIdx = css.indexOf("/* LAYOUT */");
444
+ const tplIdx = css.indexOf("/* TPL */");
445
+ const modIdx = css.indexOf("/* MOD */");
446
+ expect(layoutIdx).toBeGreaterThanOrEqual(0);
447
+ expect(tplIdx).toBeGreaterThan(layoutIdx);
448
+ expect(modIdx).toBeGreaterThan(tplIdx);
449
+ });
450
+
451
+ it("injects fonts (preloads + @font-face) before the theme style (issue #150)", () => {
452
+ const out = composePageWithLayout({
453
+ templateHtml,
454
+ templateCss: "",
455
+ blocks: [],
456
+ layoutHtml,
457
+ layoutCss: "",
458
+ layoutBlocks: [],
459
+ layoutSlug: "test",
460
+ fonts: {
461
+ css: "@font-face{font-family:\"Poppins\";src:url(/_assets/fonts/poppins/aa.woff2) format('woff2');}",
462
+ preloads: ["/_assets/fonts/poppins/aa.woff2"],
463
+ },
464
+ });
465
+ expect(out.html).toContain('<style data-source="fonts">');
466
+ // Font preloads MUST carry crossorigin even same-origin (fetch spec
467
+ // font-destination CORS rule) or the browser double-downloads.
468
+ expect(out.html).toContain(
469
+ '<link rel="preload" as="font" type="font/woff2" crossorigin href="/_assets/fonts/poppins/aa.woff2">',
470
+ );
471
+ const headEnd = out.html.indexOf("</head>");
472
+ expect(out.html.indexOf('<style data-source="fonts">')).toBeLessThan(headEnd);
473
+ });
474
+
475
+ it("omits the fonts fragment entirely for system-stack-only themes (issue #150)", () => {
476
+ const out = composePageWithLayout({
477
+ templateHtml,
478
+ templateCss: "",
479
+ blocks: [],
480
+ layoutHtml,
481
+ layoutCss: "",
482
+ layoutBlocks: [],
483
+ layoutSlug: "test",
484
+ fonts: { css: "", preloads: [] },
485
+ });
486
+ expect(out.html).not.toContain('data-source="fonts"');
487
+ expect(out.html).not.toContain('rel="preload"');
488
+ });
489
+
490
+ it("renders a responsive nav with once-per-page functional assets (issue #160)", () => {
491
+ const navModule = {
492
+ ...blankModule,
493
+ moduleId: "33333333-3333-4333-8333-333333333303",
494
+ slug: "nav-menu-header",
495
+ html: "<nav>replaced</nav>",
496
+ };
497
+ const out = composePageWithLayout({
498
+ templateHtml,
499
+ templateCss: "",
500
+ blocks: [{ blockName: "content", modules: [navModule, navModule] }],
501
+ layoutHtml,
502
+ layoutCss: "",
503
+ layoutBlocks: [],
504
+ layoutSlug: "test",
505
+ structuredSets: {
506
+ byKindSlug: { "nav-menu/header": [{ label: "Docs", href: "/docs" }] },
507
+ },
508
+ });
509
+ expect(out.html).toContain('class="caelo-nav-toggle"');
510
+ expect(out.html).toContain('aria-expanded="false"');
511
+ expect(out.html).toContain('data-nav-open="false"');
512
+ // Functional assets exactly once, even with two nav placements.
513
+ expect(out.html.split("caelo-nav-toggle{display:none").length - 1).toBe(1);
514
+ expect(out.html.split("data-nav-open','false'").length - 1).toBe(1);
515
+ // Zero aesthetics in the functional css: no colors beyond currentColor.
516
+ expect(out.html).not.toMatch(/caelo-nav-toggle\{[^}]*#[0-9a-f]{3}/i);
517
+ });
518
+
519
+ it("injects the invisible technical baseline between theme and modules (issue #151)", () => {
520
+ const out = composePageWithLayout({
521
+ templateHtml,
522
+ templateCss: "/* TPL */",
523
+ blocks: [],
524
+ layoutHtml,
525
+ layoutCss: "",
526
+ layoutBlocks: [],
527
+ layoutSlug: "test",
528
+ });
529
+ const base = out.html.indexOf('<style data-source="base">');
530
+ const modules = out.html.indexOf('<style data-source="modules">');
531
+ expect(base).toBeGreaterThan(-1);
532
+ expect(out.html).toContain("box-sizing:border-box");
533
+ expect(base).toBeLessThan(modules); // module CSS overrides the floor
534
+ // Zero design opinion: no colors, no font sizes, no type scale.
535
+ const baseBlock = out.html.slice(base, out.html.indexOf("</style>", base));
536
+ expect(baseBlock).not.toMatch(/#[0-9a-f]{3}|font-size|color:|rem\b/i);
537
+ });
538
+
539
+ it("emits a twice-placed module's CSS/JS exactly once (issue #158)", () => {
540
+ const shared = {
541
+ ...blankModule,
542
+ moduleId: "22222222-2222-4222-8222-222222222202",
543
+ html: "<p>x</p>",
544
+ css: "/* SHARED-CSS */",
545
+ js: "/* SHARED-JS */",
546
+ };
547
+ const out = composePageWithLayout({
548
+ templateHtml,
549
+ templateCss: "",
550
+ blocks: [
551
+ { blockName: "content", modules: [shared, shared] },
552
+ { blockName: "header", modules: [shared] },
553
+ ],
554
+ layoutHtml,
555
+ layoutCss: "",
556
+ layoutBlocks: [],
557
+ layoutSlug: "test",
558
+ });
559
+ expect(out.html.split("/* SHARED-CSS */").length - 1).toBe(1);
560
+ expect(out.html.split("/* SHARED-JS */").length - 1).toBe(1);
561
+ });
562
+
563
+ it("throws ComposeError when the layout lacks a content slot", () => {
564
+ expect(() =>
565
+ composePageWithLayout({
566
+ templateHtml,
567
+ templateCss: "",
568
+ blocks: [],
569
+ layoutHtml: `<!doctype html><html><body><header>chrome</header></body></html>`,
570
+ layoutCss: "",
571
+ layoutBlocks: [],
572
+ layoutSlug: "broken",
573
+ }),
574
+ ).toThrow(ComposeError);
575
+ });
576
+
577
+ it("peels a body-wrapping <caelo-slot name='content'> with single-quote attrs", () => {
578
+ // Single-quote attribute would have failed the previous regex peel
579
+ // and produced a nested <caelo-slot> in the output. With the
580
+ // parser-based peel, the wrapping slot is removed and the inner
581
+ // <p>BODY</p> lands directly in the layout's content slot.
582
+ const out = composePageWithLayout({
583
+ templateHtml: `<body><caelo-slot name='content'>_</caelo-slot></body>`,
584
+ templateCss: "",
585
+ blocks: [
586
+ {
587
+ blockName: "content",
588
+ modules: [{ ...blankModule, html: "<p>BODY</p>" }],
589
+ },
590
+ ],
591
+ layoutHtml,
592
+ layoutCss: "",
593
+ layoutBlocks: [],
594
+ layoutSlug: "test",
595
+ });
596
+ expect(out.html).toContain("<p");
597
+ expect(out.html).toContain(">BODY</p>");
598
+ // Exactly one caelo-slot[name=content] in output (the layout's own).
599
+ const matches = out.html.match(/caelo-slot[^>]*name=["']content["']/g) ?? [];
600
+ expect(matches.length).toBe(1);
601
+ });
602
+ });