@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,379 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Tier-1 unit tests for the shared template engine (Plan B per #71).
5
+ *
6
+ * Each test pins one of the §8.1 cases in the implementation plan and
7
+ * states the exact expected output so a future regression points
8
+ * directly at the affected branch.
9
+ *
10
+ * Failure-marker tests (kind-mismatch, *-malformed, module-ref-malformed,
11
+ * field-not-declared) assert the literal `missingSlots` string the
12
+ * chat-runner diag pass + editor missing-content surface read today —
13
+ * any rename to the marker shape silently breaks those callers, so the
14
+ * tests intentionally match strings, not regexes.
15
+ */
16
+
17
+ import { describe, expect, it } from "bun:test";
18
+ import { renderTemplate, type TemplateField } from "./template-engine.js";
19
+
20
+ describe("renderTemplate — primitive substitution", () => {
21
+ it("substitutes {{name}} with contentValues[name] (happy path)", () => {
22
+ const r = renderTemplate({
23
+ html: "<h1>{{title}}</h1>",
24
+ fields: [{ name: "title", kind: "text" }],
25
+ contentValues: { title: "Hello" },
26
+ });
27
+ expect(r.html).toBe("<h1>Hello</h1>");
28
+ expect(r.missingSlots).toEqual([]);
29
+ });
30
+
31
+ it("contentValues beats field.default (per-placement wins)", () => {
32
+ const r = renderTemplate({
33
+ html: "<h1>{{title}}</h1>",
34
+ fields: [{ name: "title", kind: "text", default: "D" }],
35
+ contentValues: { title: "Override" },
36
+ });
37
+ expect(r.html).toBe("<h1>Override</h1>");
38
+ });
39
+
40
+ it("falls back to field.default when contentValues is absent", () => {
41
+ const r = renderTemplate({
42
+ html: "<h1>{{title}}</h1>",
43
+ fields: [{ name: "title", kind: "text", default: "D" }],
44
+ contentValues: {},
45
+ });
46
+ expect(r.html).toBe("<h1>D</h1>");
47
+ });
48
+
49
+ it("leaves unknown {{name}} as raw markers (loud-raw) and tracks field-not-declared", () => {
50
+ const r = renderTemplate({
51
+ html: "<h1>{{ghost}}</h1>",
52
+ fields: [],
53
+ contentValues: {},
54
+ });
55
+ expect(r.html).toBe("<h1>{{ghost}}</h1>");
56
+ expect(r.missingSlots).toContain("field-not-declared:ghost");
57
+ });
58
+
59
+ it("leaves declared-but-empty {{name}} as raw markers without tracking missingSlots", () => {
60
+ // A declared field with no contentValue and no default is the
61
+ // operator's responsibility (still authoring), not a system-side
62
+ // gap callers should warn about. Loud-raw in HTML, silent in the
63
+ // structured channel.
64
+ const r = renderTemplate({
65
+ html: "<h1>{{title}}</h1>",
66
+ fields: [{ name: "title", kind: "text" }],
67
+ contentValues: {},
68
+ });
69
+ expect(r.html).toBe("<h1>{{title}}</h1>");
70
+ expect(r.missingSlots).toEqual([]);
71
+ });
72
+
73
+ it("case-insensitive name match — {{ctaHref}} finds contentValues.ctahref", () => {
74
+ const r = renderTemplate({
75
+ html: "<h1>{{ctaHref}}</h1>",
76
+ fields: [{ name: "ctahref", kind: "url" }],
77
+ contentValues: { ctahref: "/x" },
78
+ });
79
+ expect(r.html).toBe("<h1>/x</h1>");
80
+ });
81
+
82
+ it("does NOT HTML-escape values (modules ARE the place raw HTML lives, §3.1)", () => {
83
+ const r = renderTemplate({
84
+ html: '<a href="{{u}}">x</a>',
85
+ fields: [{ name: "u", kind: "url" }],
86
+ contentValues: { u: "/docs?a=1&b=2" },
87
+ });
88
+ expect(r.html).toBe('<a href="/docs?a=1&b=2">x</a>');
89
+ });
90
+ });
91
+
92
+ describe("renderTemplate — text-list section", () => {
93
+ const textListField: TemplateField = { name: "items", kind: "text-list" };
94
+
95
+ it("iterates with {{.}} per element", () => {
96
+ const r = renderTemplate({
97
+ html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
98
+ fields: [textListField],
99
+ contentValues: { items: ["a", "b", "c"] },
100
+ });
101
+ expect(r.html).toBe("<ul><li>a</li><li>b</li><li>c</li></ul>");
102
+ expect(r.missingSlots).toEqual([]);
103
+ });
104
+
105
+ it("iterates with {{item}} alias per element", () => {
106
+ const r = renderTemplate({
107
+ html: "<ul>{{#items}}<li>{{item}}</li>{{/items}}</ul>",
108
+ fields: [textListField],
109
+ contentValues: { items: ["a", "b", "c"] },
110
+ });
111
+ expect(r.html).toBe("<ul><li>a</li><li>b</li><li>c</li></ul>");
112
+ });
113
+
114
+ it("coerces numeric + boolean elements to strings", () => {
115
+ const r = renderTemplate({
116
+ html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
117
+ fields: [textListField],
118
+ contentValues: { items: [1, true, "x"] },
119
+ });
120
+ expect(r.html).toBe("<ul><li>1</li><li>true</li><li>x</li></ul>");
121
+ });
122
+
123
+ it("empty array renders as empty section, no missingSlots", () => {
124
+ const r = renderTemplate({
125
+ html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
126
+ fields: [textListField],
127
+ contentValues: { items: [] },
128
+ });
129
+ expect(r.html).toBe("<ul></ul>");
130
+ expect(r.missingSlots).toEqual([]);
131
+ });
132
+
133
+ it("per-element malformed object becomes loud comment + tracks text-list-malformed", () => {
134
+ const r = renderTemplate({
135
+ html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
136
+ fields: [textListField],
137
+ contentValues: { items: ["a", { bogus: true }, "c"] },
138
+ });
139
+ expect(r.html).toContain("<li>a</li>");
140
+ expect(r.html).toContain("<li>c</li>");
141
+ expect(r.html).toContain("<!-- caelo:missing reason=text-list-malformed items[1] -->");
142
+ expect(r.missingSlots).toContain("text-list-malformed:items[1]");
143
+ });
144
+ });
145
+
146
+ describe("renderTemplate — link-list section (AC #1)", () => {
147
+ const linkListField: TemplateField = { name: "nav_items", kind: "link-list" };
148
+
149
+ it("AC #1 verbatim fixture", () => {
150
+ const r = renderTemplate({
151
+ html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
152
+ fields: [linkListField],
153
+ contentValues: {
154
+ nav_items: [
155
+ { label: "Docs", href: "/docs" },
156
+ { label: "Blog", href: "/blog" },
157
+ ],
158
+ },
159
+ });
160
+ expect(r.html).toBe('<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>');
161
+ expect(r.html).not.toMatch(/\{\{[#/]/);
162
+ expect(r.html).not.toMatch(/\{\{label/);
163
+ expect(r.html).not.toMatch(/\{\{href/);
164
+ expect(r.missingSlots).toEqual([]);
165
+ });
166
+
167
+ it("per-element malformed (missing href) becomes loud comment + tracks link-list-malformed", () => {
168
+ const r = renderTemplate({
169
+ html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
170
+ fields: [linkListField],
171
+ contentValues: {
172
+ nav_items: [
173
+ { label: "OK", href: "/a" },
174
+ { label: "OK only" }, // missing href
175
+ ],
176
+ },
177
+ });
178
+ expect(r.html).toContain('<a href="/a">OK</a>');
179
+ expect(r.html).toContain("<!-- caelo:missing reason=link-list-malformed nav_items[1] -->");
180
+ expect(r.missingSlots).toContain("link-list-malformed:nav_items[1]");
181
+ });
182
+ });
183
+
184
+ describe("renderTemplate — module-list section", () => {
185
+ const moduleListField: TemplateField = { name: "cards", kind: "module-list" };
186
+
187
+ it("renders each element from partials[<name>__<index>] in order", () => {
188
+ const r = renderTemplate({
189
+ html: "<ul>{{#cards}}<li>ignored</li>{{/cards}}</ul>",
190
+ fields: [moduleListField],
191
+ contentValues: {
192
+ cards: [
193
+ { moduleId: "m1", contentInstanceId: "c1" },
194
+ { moduleId: "m2", contentInstanceId: "c2" },
195
+ ],
196
+ },
197
+ partials: {
198
+ cards__0: "<article>A</article>",
199
+ cards__1: "<article>B</article>",
200
+ },
201
+ });
202
+ expect(r.html).toBe("<ul><article>A</article><article>B</article></ul>");
203
+ expect(r.html).not.toMatch(/\{\{[#/]/);
204
+ });
205
+
206
+ it("compose-path posture — no partials supplied → loud comment per element", () => {
207
+ const r = renderTemplate({
208
+ html: "<ul>{{#cards}}<li>ignored</li>{{/cards}}</ul>",
209
+ fields: [moduleListField],
210
+ contentValues: {
211
+ cards: [{ moduleId: "m1", contentInstanceId: "c1" }],
212
+ },
213
+ });
214
+ expect(r.html).toContain(
215
+ "<!-- caelo:module-list cards needs recursive renderer (compose path) -->",
216
+ );
217
+ expect(r.html).not.toMatch(/\{\{[#/]/);
218
+ });
219
+
220
+ it("per-element malformed ref becomes loud comment + tracks module-list-malformed", () => {
221
+ const r = renderTemplate({
222
+ html: "<ul>{{#cards}}<li>x</li>{{/cards}}</ul>",
223
+ fields: [moduleListField],
224
+ contentValues: {
225
+ cards: [{ moduleId: "m1", contentInstanceId: "c1" }, "not-a-ref"],
226
+ },
227
+ partials: { cards__0: "<article>A</article>" },
228
+ });
229
+ expect(r.html).toContain("<article>A</article>");
230
+ expect(r.html).toContain("<!-- caelo:missing reason=module-list-malformed cards[1] -->");
231
+ expect(r.missingSlots).toContain("module-list-malformed:cards[1]");
232
+ });
233
+ });
234
+
235
+ describe("renderTemplate — single nested {{>name}}", () => {
236
+ const moduleField: TemplateField = { name: "hero", kind: "module" };
237
+
238
+ it("renders partials[<name>] inline", () => {
239
+ const r = renderTemplate({
240
+ html: "<aside>{{>hero}}</aside>",
241
+ fields: [moduleField],
242
+ contentValues: { hero: { moduleId: "m", contentInstanceId: "c" } },
243
+ partials: { hero: "<h1>X</h1>" },
244
+ });
245
+ expect(r.html).toBe("<aside><h1>X</h1></aside>");
246
+ expect(r.missingSlots).toEqual([]);
247
+ });
248
+
249
+ it("compose-path posture — no partial supplied → loud comment", () => {
250
+ const r = renderTemplate({
251
+ html: "<aside>{{>hero}}</aside>",
252
+ fields: [moduleField],
253
+ contentValues: { hero: { moduleId: "m", contentInstanceId: "c" } },
254
+ });
255
+ expect(r.html).toContain("<!-- caelo:module hero needs recursive renderer (compose path) -->");
256
+ });
257
+
258
+ it("malformed ref value becomes module-ref-malformed comment + tracks marker", () => {
259
+ const r = renderTemplate({
260
+ html: "<aside>{{>hero}}</aside>",
261
+ fields: [moduleField],
262
+ contentValues: { hero: "not-a-ref" },
263
+ });
264
+ expect(r.html).toContain("<!-- caelo:missing reason=module-ref-malformed hero -->");
265
+ expect(r.missingSlots).toContain("module-ref-malformed:hero");
266
+ });
267
+ });
268
+
269
+ describe("renderTemplate — loud-raw on unknown sections (AC #3)", () => {
270
+ it("unknown {{#unknown}}…{{/unknown}} leaves raw markers + tracks field-not-declared", () => {
271
+ const r = renderTemplate({
272
+ html: "<p>{{#unknown}}x{{/unknown}}</p>",
273
+ fields: [],
274
+ contentValues: {},
275
+ });
276
+ expect(r.html).toBe("<p>{{#unknown}}x{{/unknown}}</p>");
277
+ expect(r.missingSlots).toContain("field-not-declared:unknown");
278
+ });
279
+
280
+ it("unknown {{>partial}} leaves raw markers + tracks field-not-declared", () => {
281
+ const r = renderTemplate({
282
+ html: "<aside>{{>ghost}}</aside>",
283
+ fields: [],
284
+ contentValues: {},
285
+ });
286
+ expect(r.html).toBe("<aside>{{>ghost}}</aside>");
287
+ expect(r.missingSlots).toContain("field-not-declared:ghost");
288
+ });
289
+ });
290
+
291
+ describe("renderTemplate — whitespace tolerance in markers", () => {
292
+ // The regexes allow surrounding whitespace inside the curly braces
293
+ // (e.g. `{{ name }}`, `{{ #items }}`, `{{ > hero }}`). AI-authored
294
+ // HTML usually emits tight markers, but a future extractor that
295
+ // pretty-prints could regress silently — these tests pin behaviour
296
+ // the regexes already support so any future tightening fails loudly.
297
+
298
+ it("primitive {{ name }} (whitespace inside braces) substitutes normally", () => {
299
+ const r = renderTemplate({
300
+ html: "<h1>{{ title }}</h1>",
301
+ fields: [{ name: "title", kind: "text" }],
302
+ contentValues: { title: "Hello" },
303
+ });
304
+ expect(r.html).toBe("<h1>Hello</h1>");
305
+ });
306
+
307
+ it("section {{ #items }}…{{ /items }} (whitespace inside braces) iterates normally", () => {
308
+ const r = renderTemplate({
309
+ html: "<ul>{{ #items }}<li>{{ . }}</li>{{ /items }}</ul>",
310
+ fields: [{ name: "items", kind: "text-list" }],
311
+ contentValues: { items: ["a", "b"] },
312
+ });
313
+ expect(r.html).toBe("<ul><li>a</li><li>b</li></ul>");
314
+ });
315
+
316
+ it("partial {{ > hero }} (whitespace inside braces) resolves normally", () => {
317
+ const r = renderTemplate({
318
+ html: "<aside>{{ > hero }}</aside>",
319
+ fields: [{ name: "hero", kind: "module" }],
320
+ contentValues: { hero: { moduleId: "m", contentInstanceId: "c" } },
321
+ partials: { hero: "<h1>X</h1>" },
322
+ });
323
+ expect(r.html).toBe("<aside><h1>X</h1></aside>");
324
+ });
325
+ });
326
+
327
+ describe("renderTemplate — failure-marker parity", () => {
328
+ // A NON-list field used as a `{{#name}}…{{/name}}` section is a Mustache
329
+ // CONDITIONAL, not a kind-mismatch — the old behaviour silently DROPPED
330
+ // real scalar content (headings, body copy, an image, a nested module).
331
+ it("renders a {{#scalar}} block when the text field has a value", () => {
332
+ const r = renderTemplate({
333
+ html: "<h1>{{#title}}{{title}}{{/title}}</h1>",
334
+ fields: [{ name: "title", kind: "text" }],
335
+ contentValues: { title: "Willkommen" },
336
+ });
337
+ expect(r.html).toBe("<h1>Willkommen</h1>");
338
+ expect(r.missingSlots.some((s) => s.includes("kind-mismatch"))).toBe(false);
339
+ });
340
+
341
+ it("supports {{.}} inside a scalar conditional", () => {
342
+ const r = renderTemplate({
343
+ html: "<h2>{{#title}}{{.}}{{/title}}</h2>",
344
+ fields: [{ name: "title", kind: "text" }],
345
+ contentValues: { title: "Hallo" },
346
+ });
347
+ expect(r.html).toBe("<h2>Hallo</h2>");
348
+ });
349
+
350
+ it("drops a {{#scalar}} block only when the field is empty (conditional, not missing)", () => {
351
+ const r = renderTemplate({
352
+ html: "<p>{{#subtitle}}<span>{{subtitle}}</span>{{/subtitle}}</p>",
353
+ fields: [{ name: "subtitle", kind: "text" }],
354
+ contentValues: {},
355
+ });
356
+ expect(r.html).toBe("<p></p>");
357
+ expect(r.missingSlots).toEqual([]);
358
+ });
359
+
360
+ it("wraps a scalar image field in a {{#image}} conditional", () => {
361
+ const r = renderTemplate({
362
+ html: '{{#photo}}<img src="{{photo}}" alt="rep">{{/photo}}',
363
+ fields: [{ name: "photo", kind: "image" }],
364
+ contentValues: { photo: "/_caelo/media/rep-photo" },
365
+ });
366
+ expect(r.html).toBe('<img src="/_caelo/media/rep-photo" alt="rep">');
367
+ });
368
+
369
+ it("kind-mismatch on {{>name}} against a list field", () => {
370
+ const r = renderTemplate({
371
+ html: "<aside>{{>items}}</aside>",
372
+ fields: [{ name: "items", kind: "text-list" }],
373
+ contentValues: {},
374
+ });
375
+ const expected = "kind-mismatch:items expected=module actual=text-list";
376
+ expect(r.html).toBe(`<aside><!-- caelo:missing reason=${expected} --></aside>`);
377
+ expect(r.missingSlots).toContain(expected);
378
+ });
379
+ });