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