@caelo-cms/shared 0.10.21 → 0.10.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/dist/ai-tools.d.ts +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
@@ -0,0 +1,520 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Shared template engine for AI-authored module HTML. Consolidates the
5
+ * no-DB `applyFieldSubstitution` in `preview-compose.ts` and the
6
+ * DB-aware `substituteWithRecursion` in `preview-render.ts` into a
7
+ * single engine built on `mustache.js` (Plan B per issue #71).
8
+ *
9
+ * Grammar (a Mustache subset — see CMS_REQUIREMENTS §3.1 / §5):
10
+ * {{name}} — primitive substitution
11
+ * {{#name}}…inner…{{/name}} — section: ITERATES over a list field
12
+ * (text-list / link-list / module-list);
13
+ * for a SCALAR (text/image/…) or single
14
+ * `module` field it is a CONDITIONAL —
15
+ * the block renders once when the field
16
+ * has a value, and is dropped when empty
17
+ * (never a kind-mismatch — that used to
18
+ * silently drop real scalar content)
19
+ * {{>name}} — single nested module reference
20
+ * (module field kind)
21
+ *
22
+ * Substitution priority for {{name}}:
23
+ * 1. contentValues[name] — per-placement value
24
+ * 2. fields[name].default — module-level default
25
+ * 3. raw `{{name}}` left in place (CLAUDE.md §2 — no fallbacks pre-1.0,
26
+ * so broken templates stay visible)
27
+ *
28
+ * Loud-raw invariant: an unknown `{{name}}` / `{{#name}}` / `{{>name}}`
29
+ * (no matching declared field) is left as raw text in the output, not
30
+ * silenced. Mustache's "unknown → empty string" default is overridden
31
+ * by a sentinel pre-scan + post-substitute pass that re-injects the
32
+ * original Mustache source after rendering. See plan §2 risk #2.
33
+ *
34
+ * Failure markers preserved verbatim from the legacy preview-render
35
+ * for `missingSlots`: `field-not-declared:<name>`,
36
+ * `kind-mismatch:<name> expected=<…> actual=<kind>`,
37
+ * `text-list-malformed:<name>[<i>]`, `link-list-malformed:<name>[<i>]`,
38
+ * `module-list-malformed:<name>[<i>]`, `module-ref-malformed:<name>`.
39
+ * v0.11.1 (issue #76): `theme-asset-unbound:<slot>` for the four
40
+ * `{{theme_<slot>_url}}` placeholders when the active theme's asset
41
+ * slot isn't bound.
42
+ * The chat-runner diag pass + editor missing-content surface read
43
+ * these literal strings; any rename is a silent regression for them.
44
+ *
45
+ * No HTML escaping. Module HTML substitutes raw — modules are the
46
+ * place raw HTML lives (CMS_REQUIREMENTS §3.1). Auto-escape would
47
+ * silently break every <a href="{{url}}"> in the catalog. The
48
+ * `Mustache.escape` override at module load is the public Mustache
49
+ * configuration knob; the engine module is the sole workspace
50
+ * importer of mustache so the singleton mutation is scoped in
51
+ * effect.
52
+ *
53
+ * Partials are caller-supplied (sync `Record<string, string>`). The
54
+ * compose path passes an empty map (no DB) — module / module-list
55
+ * refs become loud HTML comments so static-gen output is visible-
56
+ * broken instead of silent-empty. The preview-render path
57
+ * pre-resolves each nested ref via its existing RenderResolver walk
58
+ * (depth-limit + cycle-detection live there, untouched) and supplies
59
+ * the rendered HTML as a partial.
60
+ */
61
+
62
+ import Mustache from "mustache";
63
+ import { MODULE_FIELD_SECTION_KINDS, type ModuleFieldKind } from "./content.js";
64
+ import { stripCdataGuards } from "./strip-cdata.js";
65
+
66
+ // Override Mustache's default HTML escape — module HTML substitutes
67
+ // raw. The engine module is the only workspace importer of mustache,
68
+ // so the singleton mutation is scoped in effect. Plan §2 risk #1: the
69
+ // {{href}} test pins this; any leak of the default escape fails.
70
+ Mustache.escape = (s: unknown): string => (s === null || s === undefined ? "" : String(s));
71
+
72
+ /**
73
+ * Subset of `modules.fields[]` the engine cares about. The full
74
+ * schema lives in `content.ts`; the engine only needs `name`, `kind`,
75
+ * and the optional `default`. Importers pass the full row through.
76
+ */
77
+ export interface TemplateField {
78
+ readonly name: string;
79
+ readonly kind: ModuleFieldKind;
80
+ readonly default?: unknown;
81
+ }
82
+
83
+ export interface RenderTemplateInput {
84
+ readonly html: string;
85
+ readonly fields: readonly TemplateField[];
86
+ /** Per-placement values from `content_instances.values`. */
87
+ readonly contentValues?: Readonly<Record<string, unknown>>;
88
+ /**
89
+ * Pre-rendered nested-module HTML keyed by:
90
+ * - `<name>` for single `{{>name}}` (module field kind)
91
+ * - `<name>__<index>` for each `{{#name}}` element (module-list)
92
+ * Compose path: empty map (no DB → loud HTML comments emit).
93
+ * Preview-render path: built from RenderResolver walks.
94
+ */
95
+ readonly partials?: Readonly<Record<string, string>>;
96
+ /**
97
+ * v0.11.1 (issue #76) — active theme's resolved asset URLs. When
98
+ * present, `{{theme_logo_url}}` / `{{theme_logo_dark_url}}` /
99
+ * `{{theme_favicon_url}}` / `{{theme_social_share_url}}` substitute
100
+ * to the asset URL string. Unbound slots (null/absent) follow the
101
+ * existing loud-raw invariant (CLAUDE.md §2): the `{{…}}` stays in
102
+ * the output verbatim AND `theme-asset-unbound:<slot>` lands in
103
+ * `missingSlots`. Per-placement `contentValues` still take
104
+ * precedence so an operator can override on a single module.
105
+ */
106
+ readonly themeAssets?: {
107
+ readonly logo: string | null;
108
+ readonly logoDark: string | null;
109
+ readonly favicon: string | null;
110
+ readonly socialShare: string | null;
111
+ };
112
+ }
113
+
114
+ export interface RenderTemplateOutput {
115
+ readonly html: string;
116
+ /** Structured failure channel — see file-level comment for the markers. */
117
+ readonly missingSlots: readonly string[];
118
+ }
119
+
120
+ interface NestedRef {
121
+ readonly moduleId: string;
122
+ readonly contentInstanceId: string;
123
+ }
124
+
125
+ // Field names per the v0.4.0 + v0.12.0 grammar: lowercase ASCII +
126
+ // digits + underscores. Case-insensitive primitive matching (the AI
127
+ // sometimes camelCases the placeholder when extracting from existing
128
+ // HTML) is handled below via lowercased view lookup. Whitespace
129
+ // between `{{` and the sigil (#, >, /) is permitted to match the
130
+ // Mustache spec — an extractor that pretty-prints AI-authored HTML
131
+ // shouldn't silently break section / partial dispatch.
132
+ // The section body uses a "tempered dot" — `(?:(?!CLOSE)[\s\S])*` — instead
133
+ // of a lazy `[\s\S]*?`. The lazy form makes the engine try, then backtrack,
134
+ // the close-tag match at every interior position, which is O(n²) on
135
+ // unclosed/large input (CodeQL js/polynomial-redos). The tempered form
136
+ // consumes one character only when the close tag does not start there, so
137
+ // there is a single unambiguous path. The capture stops at the first close
138
+ // tag for the captured name, exactly as the lazy form did — same output.
139
+ const SECTION_RE =
140
+ /\{\{\s*#\s*([a-z][a-z0-9_]*)\s*\}\}((?:(?!\{\{\s*\/\s*\1\s*\}\})[\s\S])*)\{\{\s*\/\s*\1\s*\}\}/g;
141
+ const PARTIAL_RE = /\{\{\s*>\s*([a-z][a-z0-9_]*)\s*\}\}/g;
142
+ const PRIMITIVE_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
143
+
144
+ // Section-dispatch kinds (the engine's `{{#name}}` operand). Imported
145
+ // from content.ts so new list-shaped kinds wire through automatically
146
+ // when the canonical declaration is extended.
147
+ const SECTION_KINDS: ReadonlySet<string> = new Set(MODULE_FIELD_SECTION_KINDS);
148
+
149
+ // v0.11.1 (issue #76) — the four theme-asset substitutions the engine
150
+ // resolves from `RenderTemplateInput.themeAssets`. Keys are the
151
+ // `{{name}}` placeholders module authors write; the table maps them
152
+ // back to the asset slot on the ComposeTheme.assets aggregate.
153
+ const THEME_ASSET_KEY_TO_SLOT = {
154
+ theme_logo_url: "logo",
155
+ theme_logo_dark_url: "logoDark",
156
+ theme_favicon_url: "favicon",
157
+ theme_social_share_url: "socialShare",
158
+ } as const;
159
+ const THEME_ASSET_KEYS = Object.keys(THEME_ASSET_KEY_TO_SLOT) as ReadonlyArray<
160
+ keyof typeof THEME_ASSET_KEY_TO_SLOT
161
+ >;
162
+
163
+ function isNestedRef(v: unknown): v is NestedRef {
164
+ return (
165
+ typeof v === "object" &&
166
+ v !== null &&
167
+ typeof (v as { moduleId?: unknown }).moduleId === "string" &&
168
+ typeof (v as { contentInstanceId?: unknown }).contentInstanceId === "string"
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Render a `caelo:missing` HTML comment carrying a failure reason.
174
+ * The shape (`<!-- caelo:missing reason=<…> -->`) is part of the
175
+ * public failure-marker contract — the chat-runner diag pass reads
176
+ * the comment text out of rendered HTML, and the editor's missing-
177
+ * content surface highlights it for the operator. Exported so the
178
+ * DB-aware preview-render path (`packages/admin-core/src/ops/content/
179
+ * preview-render.ts`) emits the exact same shape without redeclaring.
180
+ */
181
+ export function caeloMissingComment(reason: string): string {
182
+ return `<!-- caelo:missing reason=${reason} -->`;
183
+ }
184
+
185
+ const comment = caeloMissingComment;
186
+
187
+ /**
188
+ * Render `html` against `contentValues` + `fields` + `partials`,
189
+ * returning the substituted HTML plus a structured `missingSlots`
190
+ * channel. Pure / sync / no IO.
191
+ *
192
+ * @example
193
+ * // AC #1 fixture (issue #71). Iterates a link-list field per element.
194
+ * renderTemplate({
195
+ * html: '<nav>{{#nav_items}}<a href="{{href}}">{{label}}</a>{{/nav_items}}</nav>',
196
+ * fields: [{ name: 'nav_items', kind: 'link-list' }],
197
+ * contentValues: {
198
+ * nav_items: [
199
+ * { label: 'Docs', href: '/docs' },
200
+ * { label: 'Blog', href: '/blog' },
201
+ * ],
202
+ * },
203
+ * });
204
+ * // → {
205
+ * // html: '<nav><a href="/docs">Docs</a><a href="/blog">Blog</a></nav>',
206
+ * // missingSlots: [],
207
+ * // }
208
+ *
209
+ * @see {@link TemplateField} for the field shape (name + kind + optional default).
210
+ * @see {@link ./content.js#MODULE_FIELD_KINDS} for the canonical kind union.
211
+ * @see {@link ./content.js#MODULE_FIELD_SECTION_KINDS} for the kinds the engine
212
+ * iterates via `{{#name}}` (text-list / link-list / module-list).
213
+ */
214
+ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput {
215
+ const missing: string[] = [];
216
+ const fieldByName = new Map<string, TemplateField>();
217
+ for (const f of input.fields) fieldByName.set(f.name, f);
218
+ const cvs = input.contentValues ?? {};
219
+ const partials = input.partials ?? {};
220
+
221
+ // Sentinels survive Mustache.render untouched (they contain no
222
+ // `{{` `}}`), then get restored to the original Mustache source
223
+ // after render — the loud-raw invariant. The prefix carries a
224
+ // per-call UUID so the sentinel string is unguessable from the
225
+ // outside: a module author who pastes the literal `__CAELO_TPL_…__`
226
+ // pattern into their HTML can't break loud-raw restore by
227
+ // intercepting the sentinel for a known-key sentinel — they would
228
+ // have to guess the UUID minted at render time.
229
+ const sentinels = new Map<string, string>();
230
+ const sentinelPrefix = `__CAELO_TPL_${globalThis.crypto.randomUUID()}_`;
231
+ const mkSentinel = (original: string): string => {
232
+ const key = `${sentinelPrefix}${sentinels.size}__`;
233
+ sentinels.set(key, original);
234
+ return key;
235
+ };
236
+
237
+ // 0. Defensively unwrap XHTML-style CDATA guards the model sometimes
238
+ // emits around inline <style>/<script> — they otherwise survive the
239
+ // byte-preserving compose path and leak a stray `]]>` into the page.
240
+ // Store-time normalization (modules.create/update) cleans new
241
+ // modules; this covers any already-stored HTML + the deploy render.
242
+ const sourceHtml = stripCdataGuards(input.html);
243
+
244
+ // 1. {{#name}}…{{/name}} sections.
245
+ let html = sourceHtml.replace(SECTION_RE, (match, name: string, inner: string) =>
246
+ renderSection(match, name, inner, fieldByName, cvs, partials, missing, mkSentinel),
247
+ );
248
+
249
+ // 2. {{>name}} single partials.
250
+ html = html.replace(PARTIAL_RE, (match, name: string) =>
251
+ renderPartialRef(match, name, fieldByName, cvs, partials, missing, mkSentinel),
252
+ );
253
+
254
+ // 3. {{name}} primitives. Pre-rewrite to canonical lowercase form
255
+ // so the (lowercased) view picks them up regardless of source
256
+ // casing. Unknowns become sentinels for loud-raw.
257
+ const declaredFieldNames = new Set<string>();
258
+ for (const f of input.fields) {
259
+ declaredFieldNames.add(f.name);
260
+ declaredFieldNames.add(f.name.toLowerCase());
261
+ }
262
+ const view: Record<string, unknown> = {};
263
+ for (const [k, v] of Object.entries(cvs)) {
264
+ view[k.toLowerCase()] = v === null || v === undefined ? "" : v;
265
+ }
266
+ for (const f of input.fields) {
267
+ const lower = f.name.toLowerCase();
268
+ if (lower in view) continue;
269
+ if (SECTION_KINDS.has(f.kind) || f.kind === "module") continue;
270
+ if (f.default !== undefined && f.default !== null) view[lower] = f.default;
271
+ }
272
+
273
+ // v0.11.1 (issue #76) — pre-populate four theme-asset substitutions
274
+ // when the caller supplied `themeAssets`. `contentValues` already
275
+ // populated `view` above so any per-placement override wins (per
276
+ // §S19's "contentValues take precedence" invariant). Unbound slots
277
+ // (null) DON'T land in the view — they fall through to the loud-raw
278
+ // path below, but mark themselves with `theme-asset-unbound:<slot>`
279
+ // so the failure marker disambiguates from `field-not-declared`.
280
+ const themeAssetKeys = THEME_ASSET_KEYS;
281
+ const themeAssetSlotByKey = THEME_ASSET_KEY_TO_SLOT;
282
+ const themeAssets = input.themeAssets;
283
+ if (themeAssets) {
284
+ for (const key of themeAssetKeys) {
285
+ const slot = themeAssetSlotByKey[key];
286
+ const url = themeAssets[slot];
287
+ if (typeof url === "string" && url.length > 0 && !(key in view)) {
288
+ view[key] = url;
289
+ }
290
+ }
291
+ }
292
+
293
+ html = html.replace(PRIMITIVE_RE, (match, name: string) => {
294
+ const lower = name.toLowerCase();
295
+ if (lower in view) return `{{${lower}}}`;
296
+ // v0.11.1 (issue #76) — theme-asset placeholder with no bound URL.
297
+ // Use a dedicated marker so the failure surface disambiguates from
298
+ // the generic field-not-declared marker.
299
+ if (lower in themeAssetSlotByKey) {
300
+ const slot = themeAssetSlotByKey[lower as keyof typeof THEME_ASSET_KEY_TO_SLOT];
301
+ missing.push(`theme-asset-unbound:${slot}`);
302
+ return mkSentinel(match);
303
+ }
304
+ // No value + no default: leave raw (CLAUDE.md §2). Track in
305
+ // missingSlots only when the field isn't declared at all —
306
+ // declared-but-empty is the operator's responsibility (still
307
+ // authoring), not a system-side gap callers should warn about.
308
+ if (!declaredFieldNames.has(name) && !declaredFieldNames.has(lower)) {
309
+ missing.push(`field-not-declared:${name}`);
310
+ }
311
+ return mkSentinel(match);
312
+ });
313
+
314
+ // 4. Render. The view holds only lowercase keys; sentinels survive
315
+ // untouched; sections + partials are already pre-substituted.
316
+ const rendered = Mustache.render(html, view);
317
+
318
+ // 5. Restore loud-raw sentinels.
319
+ let final = rendered;
320
+ for (const [sentinel, original] of sentinels) {
321
+ final = final.split(sentinel).join(original);
322
+ }
323
+
324
+ return { html: final, missingSlots: missing };
325
+ }
326
+
327
+ function renderSection(
328
+ match: string,
329
+ name: string,
330
+ inner: string,
331
+ fields: Map<string, TemplateField>,
332
+ cvs: Readonly<Record<string, unknown>>,
333
+ partials: Readonly<Record<string, string>>,
334
+ missing: string[],
335
+ mkSentinel: (original: string) => string,
336
+ ): string {
337
+ const field = fields.get(name);
338
+ if (!field) {
339
+ missing.push(`field-not-declared:${name}`);
340
+ return mkSentinel(match);
341
+ }
342
+ if (field.kind === "module-list") {
343
+ return renderModuleList(name, field, cvs, partials, missing);
344
+ }
345
+ if (field.kind === "text-list") {
346
+ return renderTextList(name, inner, field, cvs, missing);
347
+ }
348
+ if (field.kind === "link-list") {
349
+ return renderLinkList(name, inner, field, cvs, missing);
350
+ }
351
+ // Any NON-list field used as a `{{#name}}…{{/name}}` section is a
352
+ // CONDITIONAL (Mustache semantics): render the inner block once when the
353
+ // field has a present, non-empty value; drop it when empty. This is what
354
+ // makes `{{#subtitle}}<p>{{subtitle}}</p>{{/subtitle}}` wrap a scalar
355
+ // text/image field — previously a scalar in a section fell through to
356
+ // `kind-mismatch` and SILENTLY DROPPED real content (headings, body copy,
357
+ // an image, a single nested module). `{{name}}` inside the block resolves
358
+ // in the later primitive pass (a `module` field's `{{>name}}` in the
359
+ // partial pass); `{{.}}`/`{{item}}` are substituted here for parity with
360
+ // the *-list renderers. An empty conditional is intentional, not
361
+ // "missing" — nothing is pushed to `missing`.
362
+ return renderConditionalSection(name, inner, field, cvs);
363
+ }
364
+
365
+ /**
366
+ * Render a `{{#name}}…{{/name}}` section over a scalar/module field as a
367
+ * Mustache conditional: the block once when the value is present, else "".
368
+ */
369
+ function renderConditionalSection(
370
+ name: string,
371
+ inner: string,
372
+ field: TemplateField,
373
+ cvs: Readonly<Record<string, unknown>>,
374
+ ): string {
375
+ const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
376
+ if (!isPresentSectionValue(raw)) return "";
377
+ const value = scalarSectionString(raw);
378
+ return value.length > 0 ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value) : inner;
379
+ }
380
+
381
+ /**
382
+ * Mustache-style truthiness for a conditional section value. Empty string,
383
+ * null/undefined, `false`, and an empty array/object are absent; everything
384
+ * else (incl. a numeric 0 — a real content value, not a control flag) is
385
+ * present.
386
+ */
387
+ function isPresentSectionValue(raw: unknown): boolean {
388
+ if (raw === undefined || raw === null || raw === false) return false;
389
+ if (typeof raw === "string") return raw.trim().length > 0;
390
+ if (typeof raw === "number") return !Number.isNaN(raw);
391
+ if (Array.isArray(raw)) return raw.length > 0;
392
+ if (typeof raw === "object") return Object.keys(raw as object).length > 0;
393
+ return true;
394
+ }
395
+
396
+ /** String form for `{{.}}`/`{{item}}` inside a scalar conditional. Objects
397
+ * (module refs / image objects) render through `{{name}}`/`{{>name}}`, not
398
+ * `{{.}}`, so they stringify to "". */
399
+ function scalarSectionString(raw: unknown): string {
400
+ if (typeof raw === "string") return raw;
401
+ if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
402
+ return "";
403
+ }
404
+
405
+ function renderTextList(
406
+ name: string,
407
+ inner: string,
408
+ field: TemplateField,
409
+ cvs: Readonly<Record<string, unknown>>,
410
+ missing: string[],
411
+ ): string {
412
+ const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
413
+ if (!Array.isArray(raw)) return "";
414
+ const parts: string[] = [];
415
+ for (let i = 0; i < raw.length; i += 1) {
416
+ const el = raw[i];
417
+ if (typeof el !== "string" && typeof el !== "number" && typeof el !== "boolean") {
418
+ missing.push(`text-list-malformed:${name}[${i}]`);
419
+ parts.push(comment(`text-list-malformed ${name}[${i}]`));
420
+ continue;
421
+ }
422
+ const value = String(el);
423
+ parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value));
424
+ }
425
+ return parts.join("");
426
+ }
427
+
428
+ function renderLinkList(
429
+ name: string,
430
+ inner: string,
431
+ field: TemplateField,
432
+ cvs: Readonly<Record<string, unknown>>,
433
+ missing: string[],
434
+ ): string {
435
+ const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
436
+ if (!Array.isArray(raw)) return "";
437
+ const parts: string[] = [];
438
+ for (let i = 0; i < raw.length; i += 1) {
439
+ const el = raw[i];
440
+ if (
441
+ typeof el !== "object" ||
442
+ el === null ||
443
+ typeof (el as { label?: unknown }).label !== "string" ||
444
+ typeof (el as { href?: unknown }).href !== "string"
445
+ ) {
446
+ missing.push(`link-list-malformed:${name}[${i}]`);
447
+ parts.push(comment(`link-list-malformed ${name}[${i}]`));
448
+ continue;
449
+ }
450
+ const { label, href } = el as { label: string; href: string };
451
+ parts.push(
452
+ inner.replace(/\{\{\s*label\s*\}\}/g, () => label).replace(/\{\{\s*href\s*\}\}/g, () => href),
453
+ );
454
+ }
455
+ return parts.join("");
456
+ }
457
+
458
+ function renderModuleList(
459
+ name: string,
460
+ field: TemplateField,
461
+ cvs: Readonly<Record<string, unknown>>,
462
+ partials: Readonly<Record<string, string>>,
463
+ missing: string[],
464
+ ): string {
465
+ const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
466
+ if (!Array.isArray(raw)) return "";
467
+ const parts: string[] = [];
468
+ for (let i = 0; i < raw.length; i += 1) {
469
+ const el = raw[i];
470
+ if (!isNestedRef(el)) {
471
+ missing.push(`module-list-malformed:${name}[${i}]`);
472
+ parts.push(comment(`module-list-malformed ${name}[${i}]`));
473
+ continue;
474
+ }
475
+ const partialKey = `${name}__${i}`;
476
+ const partialHtml = partials[partialKey];
477
+ if (partialHtml === undefined) {
478
+ // Compose path: no DB → no partials → loud comment so
479
+ // operators see the gap. The preview-render path always
480
+ // supplies a partial (or routes a structured failure marker
481
+ // through it from renderInner), so this branch is the
482
+ // static-gen escape hatch until #70 lands.
483
+ parts.push(`<!-- caelo:module-list ${name} needs recursive renderer (compose path) -->`);
484
+ continue;
485
+ }
486
+ parts.push(partialHtml);
487
+ }
488
+ return parts.join("");
489
+ }
490
+
491
+ function renderPartialRef(
492
+ match: string,
493
+ name: string,
494
+ fields: Map<string, TemplateField>,
495
+ cvs: Readonly<Record<string, unknown>>,
496
+ partials: Readonly<Record<string, string>>,
497
+ missing: string[],
498
+ mkSentinel: (original: string) => string,
499
+ ): string {
500
+ const field = fields.get(name);
501
+ if (!field) {
502
+ missing.push(`field-not-declared:${name}`);
503
+ return mkSentinel(match);
504
+ }
505
+ if (field.kind !== "module") {
506
+ const reason = `kind-mismatch:${name} expected=module actual=${field.kind}`;
507
+ missing.push(reason);
508
+ return comment(reason);
509
+ }
510
+ const ref = cvs[name];
511
+ if (!isNestedRef(ref)) {
512
+ missing.push(`module-ref-malformed:${name}`);
513
+ return comment(`module-ref-malformed ${name}`);
514
+ }
515
+ const partialHtml = partials[name];
516
+ if (partialHtml === undefined) {
517
+ return `<!-- caelo:module ${name} needs recursive renderer (compose path) -->`;
518
+ }
519
+ return partialHtml;
520
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #153 — first-class `gradient` token category: boundary
5
+ * validation (well-formed CSS gradient strings only; no url(), no
6
+ * declaration breakout) and renderer emission as `--gradient-<name>`.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { listThemeCssVarNames, renderThemeCss } from "./theme-render.js";
11
+ import { type ThemeDocument, themeDocument, themeGradientToken } from "./themes.js";
12
+
13
+ function docWithGradient(value: string): unknown {
14
+ return {
15
+ color: { primary: { $type: "color", $value: "#4f46e5" } },
16
+ gradient: { hero: { $type: "gradient", $value: value } },
17
+ };
18
+ }
19
+
20
+ describe("themeGradientToken boundary (issue #153)", () => {
21
+ it("accepts linear / radial / conic / repeating gradients and aliases", () => {
22
+ for (const v of [
23
+ "linear-gradient(135deg, #4f46e5, #7c3aed)",
24
+ "radial-gradient(circle at 30% 20%, #06b6d4, transparent)",
25
+ "conic-gradient(from 90deg, #f59e0b, #ef4444)",
26
+ "repeating-linear-gradient(45deg, #0f172a 0 10px, #1e293b 10px 20px)",
27
+ ]) {
28
+ const r = themeDocument.safeParse(docWithGradient(v));
29
+ expect(r.success).toBe(true);
30
+ }
31
+ expect(
32
+ themeDocument.safeParse({
33
+ gradient: {
34
+ hero: { $value: "linear-gradient(135deg, #4f46e5, #7c3aed)" },
35
+ subtle: { $type: "gradient", $value: "{gradient.hero}" },
36
+ },
37
+ }).success,
38
+ ).toBe(true);
39
+ });
40
+
41
+ it("rejects url()-carrying, declaration-breaking, and non-gradient values", () => {
42
+ for (const v of [
43
+ "linear-gradient(135deg, url(https://evil.example/x), #fff)",
44
+ "linear-gradient(90deg, #fff, #000); background: red",
45
+ "linear-gradient(90deg, #fff, #000)} body{display:none",
46
+ "not-a-gradient(#fff, #000)",
47
+ ]) {
48
+ // Document-level: none of these match ANY token shape.
49
+ expect(themeDocument.safeParse(docWithGradient(v)).success).toBe(false);
50
+ // Token-level: the gradient schema itself names the reason.
51
+ expect(themeGradientToken.safeParse({ $type: "gradient", $value: v }).success).toBe(false);
52
+ }
53
+ // Per-category leaf enforcement (#153): a plain color string under
54
+ // gradient.* fails BOTH the token schema and the document boundary —
55
+ // pre-#153 it silently validated as another category's token.
56
+ expect(themeGradientToken.safeParse({ $type: "gradient", $value: "#4f46e5" }).success).toBe(
57
+ false,
58
+ );
59
+ expect(themeDocument.safeParse(docWithGradient("#4f46e5")).success).toBe(false);
60
+ });
61
+
62
+ it("closes the pre-#153 hole: invalid leaves no longer pass as metadata groups", () => {
63
+ // Before the per-category walker, {$value: <garbage>} fell back to
64
+ // validating as a "group" of $-metadata and STILL got emitted into
65
+ // CSS by the renderer — silent acceptance (CLAUDE.md §2).
66
+ // ("notacolor" would pass — the color schema deliberately admits any
67
+ // single word as a potential CSS named color; the breakout shape
68
+ // below matches no color form at all.)
69
+ expect(
70
+ themeDocument.safeParse({
71
+ color: { primary: { $type: "color", $value: "red; background:url(x)" } },
72
+ }).success,
73
+ ).toBe(false);
74
+ expect(
75
+ themeDocument.safeParse({ spacing: { md: { $value: "not-a-dimension" } } }).success,
76
+ ).toBe(false);
77
+ // Unknown categories keep the DTCG open-vocabulary tolerance.
78
+ expect(
79
+ themeDocument.safeParse({ effect: { blur: { $type: "blur", $value: "8px" } } }).success,
80
+ ).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe("gradient rendering (issue #153)", () => {
85
+ const doc = docWithGradient("linear-gradient(135deg, #4f46e5, #7c3aed)") as ThemeDocument;
86
+
87
+ it("emits --gradient-<name> and lists it in the var inventory", () => {
88
+ const css = renderThemeCss(doc);
89
+ expect(css).toContain("--gradient-hero:linear-gradient(135deg, #4f46e5, #7c3aed);");
90
+ expect(listThemeCssVarNames(doc)).toContain("--gradient-hero");
91
+ });
92
+ });
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Regression tests for the prototype-pollution guards (issue #113, S5 —
5
+ * js/prototype-polluting-assignment + js/prototype-pollution-utility).
6
+ * Hostile keys derived from imported CSS variable names / dotted write
7
+ * paths must never mutate the prototype chain.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { isUnsafeKey } from "../../safe-keys.js";
12
+ import { applyDtcgWrites } from "../../themes.js";
13
+ import { importTailwind } from "../tailwind.js";
14
+
15
+ describe("isUnsafeKey", () => {
16
+ it("flags the prototype-pollution keys and nothing else", () => {
17
+ expect(isUnsafeKey("__proto__")).toBe(true);
18
+ expect(isUnsafeKey("constructor")).toBe(true);
19
+ expect(isUnsafeKey("prototype")).toBe(true);
20
+ expect(isUnsafeKey("primary")).toBe(false);
21
+ expect(isUnsafeKey("color")).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe("importTailwind prototype-pollution guard (S5)", () => {
26
+ it("drops a hostile --color-__proto__ token without polluting", () => {
27
+ const doc = importTailwind("@theme { --color-__proto__: #ff6600; --color-primary: #112233; }");
28
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
29
+ expect(Object.prototype).not.toHaveProperty("__proto__polluted");
30
+ // The legitimate token still imports.
31
+ expect(JSON.stringify(doc)).toContain("primary");
32
+ });
33
+
34
+ it("drops a hostile ramp base name (--color-constructor-500)", () => {
35
+ const doc = importTailwind(
36
+ "@theme { --color-constructor-500: #abcabc; --color-primary: #001122; }",
37
+ );
38
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
39
+ expect(JSON.stringify(doc)).toContain("primary");
40
+ });
41
+ });
42
+
43
+ describe("applyDtcgWrites / setLeafAtPath path guard (S5)", () => {
44
+ it("rejects a write whose path contains __proto__ (no pollution, no write)", () => {
45
+ const out = applyDtcgWrites({}, { "a.__proto__.polluted": "x" }, {});
46
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
47
+ expect(out).not.toHaveProperty("a");
48
+ });
49
+
50
+ it("still applies a legitimate dotted write", () => {
51
+ const out = applyDtcgWrites({}, { "color.primary": "#fff" }, { "color.primary": "color" });
52
+ expect(JSON.stringify(out)).toContain("primary");
53
+ });
54
+ });