@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,96 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { applySlotReplacements } from "./preview-scanner.js";
5
+
6
+ const map = (entries: [string, string][]) => new Map(entries);
7
+
8
+ describe("applySlotReplacements", () => {
9
+ it("returns input unchanged when there are no slots", () => {
10
+ const html = "<html><body><h1>Static</h1></body></html>";
11
+ const result = applySlotReplacements(html, { contentByName: map([]) });
12
+ expect(result.html).toBe(html);
13
+ expect(result.replacedSlots).toEqual([]);
14
+ expect(result.missingSlots).toEqual([]);
15
+ });
16
+
17
+ it("replaces a single named slot's inner HTML", () => {
18
+ const html = `<body><caelo-slot name="content">placeholder</caelo-slot></body>`;
19
+ const result = applySlotReplacements(html, {
20
+ contentByName: map([["content", "<p>HELLO</p>"]]),
21
+ });
22
+ expect(result.html).toBe(`<body><caelo-slot name="content"><p>HELLO</p></caelo-slot></body>`);
23
+ expect(result.replacedSlots).toEqual(["content"]);
24
+ });
25
+
26
+ it("preserves the slot wrapper so CSS selectors still match", () => {
27
+ const html = `<caelo-slot name="x"></caelo-slot>`;
28
+ const result = applySlotReplacements(html, { contentByName: map([["x", "FILLED"]]) });
29
+ expect(result.html).toBe(`<caelo-slot name="x">FILLED</caelo-slot>`);
30
+ });
31
+
32
+ it("replaces multiple slots in order", () => {
33
+ const html = `<caelo-slot name="header">_</caelo-slot><caelo-slot name="footer">_</caelo-slot>`;
34
+ const result = applySlotReplacements(html, {
35
+ contentByName: map([
36
+ ["header", "TOP"],
37
+ ["footer", "BOT"],
38
+ ]),
39
+ });
40
+ expect(result.html).toBe(
41
+ `<caelo-slot name="header">TOP</caelo-slot><caelo-slot name="footer">BOT</caelo-slot>`,
42
+ );
43
+ expect(result.replacedSlots).toEqual(["header", "footer"]);
44
+ });
45
+
46
+ it("records slots without a replacement and preserves their original inner HTML", () => {
47
+ const html = `<caelo-slot name="missing">DEFAULT</caelo-slot>`;
48
+ const result = applySlotReplacements(html, { contentByName: map([]) });
49
+ expect(result.html).toBe(`<caelo-slot name="missing">DEFAULT</caelo-slot>`);
50
+ expect(result.missingSlots).toEqual(["missing"]);
51
+ });
52
+
53
+ it("handles single-quoted name attributes", () => {
54
+ const html = `<caelo-slot name='content'>x</caelo-slot>`;
55
+ const result = applySlotReplacements(html, { contentByName: map([["content", "Y"]]) });
56
+ expect(result.html).toBe(`<caelo-slot name='content'>Y</caelo-slot>`);
57
+ });
58
+
59
+ it("throws on an unterminated slot opener", () => {
60
+ expect(() =>
61
+ applySlotReplacements(`<caelo-slot name="x">no close`, { contentByName: map([]) }),
62
+ ).toThrow(/unterminated/);
63
+ });
64
+
65
+ it("throws on a nested slot", () => {
66
+ const html = `<caelo-slot name="a"><caelo-slot name="b">y</caelo-slot></caelo-slot>`;
67
+ expect(() => applySlotReplacements(html, { contentByName: map([]) })).toThrow(/nested/);
68
+ });
69
+
70
+ it("leaves bytes outside slots unchanged", () => {
71
+ const before = `<!doctype html><html lang="en"><head><title>T</title></head><body>`;
72
+ const after = `</body></html>`;
73
+ const html = `${before}<caelo-slot name="x">_</caelo-slot>${after}`;
74
+ const result = applySlotReplacements(html, { contentByName: map([["x", "Y"]]) });
75
+ expect(result.html).toBe(`${before}<caelo-slot name="x">Y</caelo-slot>${after}`);
76
+ });
77
+
78
+ it("ignores caelo-slot inside an HTML comment", () => {
79
+ // The regex-based version mistakenly treated this as a real slot. The
80
+ // parser-based version ignores everything inside <!-- … -->.
81
+ const html = `<!-- <caelo-slot name="ghost">x</caelo-slot> --><caelo-slot name="real">_</caelo-slot>`;
82
+ const result = applySlotReplacements(html, { contentByName: map([["real", "Y"]]) });
83
+ expect(result.html).toBe(
84
+ `<!-- <caelo-slot name="ghost">x</caelo-slot> --><caelo-slot name="real">Y</caelo-slot>`,
85
+ );
86
+ expect(result.replacedSlots).toEqual(["real"]);
87
+ expect(result.missingSlots).toEqual([]);
88
+ });
89
+
90
+ it("ignores caelo-slot mention inside an attribute value", () => {
91
+ const html = `<div data-doc="example: <caelo-slot name='fake'>"></div><caelo-slot name="real">_</caelo-slot>`;
92
+ const result = applySlotReplacements(html, { contentByName: map([["real", "Y"]]) });
93
+ expect(result.html).toContain(`<caelo-slot name="real">Y</caelo-slot>`);
94
+ expect(result.replacedSlots).toEqual(["real"]);
95
+ });
96
+ });
@@ -0,0 +1,214 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * `<caelo-slot name="…">…</caelo-slot>` scanner backed by `htmlparser2`'s
5
+ * Parser. Replaces the inner HTML of each named slot with caller-supplied
6
+ * content, leaving every byte outside the slots unchanged.
7
+ *
8
+ * Why a real parser: the previous regex-based version mishandled comments
9
+ * (`<!-- <caelo-slot> -->`), CDATA, and attributes containing `<`, and
10
+ * required hand-rolled "no nested slots" detection. htmlparser2 is MIT,
11
+ * tiny, and dependency-free — same parser the larger Cheerio/Jsdom-style
12
+ * tools use under the hood.
13
+ *
14
+ * Invariants preserved from the regex version:
15
+ * - Bytes outside `<caelo-slot>` ranges are byte-stable in the output.
16
+ * - The `<caelo-slot ...>` opening + closing tags themselves are
17
+ * preserved (only inner HTML is replaced) so CSS selectors keep matching.
18
+ * - Nested `<caelo-slot>` inside another `<caelo-slot>` is rejected.
19
+ * - Unterminated openers are rejected.
20
+ */
21
+
22
+ import { Parser } from "htmlparser2";
23
+
24
+ const SLOT_TAG = "caelo-slot";
25
+
26
+ /**
27
+ * P6.7.6 review pass — return the list of `<caelo-slot name="…">` slot
28
+ * names declared in `html`, in source order. Used by the layout-aware
29
+ * composer to validate that a layout declares the required `content`
30
+ * slot before rendering, so misconfigurations error loudly per the
31
+ * no-fallbacks invariant. Walks the same htmlparser2 Parser as
32
+ * `applySlotReplacements` so attribute quoting / ordering / whitespace
33
+ * are handled uniformly.
34
+ */
35
+ export function listSlotNames(html: string): readonly string[] {
36
+ const names: string[] = [];
37
+ const parser = new Parser(
38
+ {
39
+ onopentag(name, attrs) {
40
+ if (name !== SLOT_TAG) return;
41
+ names.push(attrs.name ?? "");
42
+ },
43
+ },
44
+ { lowerCaseTags: true, recognizeSelfClosing: false },
45
+ );
46
+ parser.write(html);
47
+ parser.end();
48
+ return names;
49
+ }
50
+
51
+ /**
52
+ * P6.7.6 review pass — when a rendered template body is exactly one
53
+ * top-level `<caelo-slot name="content">…</caelo-slot>` element
54
+ * (typical of legacy templates whose body wraps the slot directly),
55
+ * peel the wrapper and return just its inner HTML. Returns `null` when
56
+ * the body has multiple top-level elements, no top-level content slot,
57
+ * or any other shape — so the caller can fall back to using the body
58
+ * as-is.
59
+ *
60
+ * Replaces the previous regex-based peel which silently failed on
61
+ * single-quote attributes, attribute reordering, or whitespace
62
+ * variations.
63
+ */
64
+ export function extractInnerOfTopLevelContentSlot(html: string): string | null {
65
+ let depth = 0;
66
+ let topLevelOpens = 0;
67
+ let topLevelTextBetween = false;
68
+ let contentSlotInnerStart = -1;
69
+ let contentSlotInnerEnd = -1;
70
+ let foundContentSlot = false;
71
+ const parser = new Parser(
72
+ {
73
+ onopentag(name, attrs) {
74
+ if (depth === 0) {
75
+ topLevelOpens += 1;
76
+ if (name === SLOT_TAG && attrs.name === "content" && topLevelOpens === 1) {
77
+ foundContentSlot = true;
78
+ contentSlotInnerStart = parser.endIndex + 1;
79
+ }
80
+ }
81
+ depth += 1;
82
+ },
83
+ onclosetag(name) {
84
+ depth -= 1;
85
+ if (depth === 0 && name === SLOT_TAG && foundContentSlot && contentSlotInnerEnd === -1) {
86
+ contentSlotInnerEnd = parser.startIndex;
87
+ }
88
+ },
89
+ ontext(text) {
90
+ if (depth === 0 && text.trim().length > 0) topLevelTextBetween = true;
91
+ },
92
+ },
93
+ { lowerCaseTags: true, recognizeSelfClosing: false },
94
+ );
95
+ parser.write(html);
96
+ parser.end();
97
+ if (
98
+ !foundContentSlot ||
99
+ topLevelOpens !== 1 ||
100
+ topLevelTextBetween ||
101
+ contentSlotInnerStart === -1 ||
102
+ contentSlotInnerEnd === -1
103
+ ) {
104
+ return null;
105
+ }
106
+ return html.slice(contentSlotInnerStart, contentSlotInnerEnd);
107
+ }
108
+
109
+ export interface SlotReplacement {
110
+ /** Inner HTML for each `<caelo-slot name="…">`. */
111
+ readonly contentByName: ReadonlyMap<string, string>;
112
+ }
113
+
114
+ export interface ScanResult {
115
+ readonly html: string;
116
+ readonly replacedSlots: readonly string[];
117
+ readonly missingSlots: readonly string[];
118
+ }
119
+
120
+ interface SlotRange {
121
+ readonly name: string;
122
+ /** Byte offset just after the opening `>`. */
123
+ readonly innerStart: number;
124
+ /** Byte offset of the opening `<` of the closing tag. */
125
+ readonly innerEnd: number;
126
+ }
127
+
128
+ /**
129
+ * Returns a copy of `templateHtml` where each `<caelo-slot name="X">…</caelo-slot>`
130
+ * has its inner HTML replaced by `replacement.contentByName.get(X)`. Slots
131
+ * with no entry render with their original inner HTML preserved (so a
132
+ * template-author placeholder stays visible in the editor preview).
133
+ *
134
+ * Throws on nested or unbalanced slot markers — those are template-author
135
+ * bugs and silently smoothing them over would just defer the discovery.
136
+ */
137
+ export function applySlotReplacements(
138
+ templateHtml: string,
139
+ replacement: SlotReplacement,
140
+ ): ScanResult {
141
+ const ranges: SlotRange[] = [];
142
+ let openName: string | null = null;
143
+ let openInnerStart = 0;
144
+ let openOpenStart = 0;
145
+
146
+ const parser = new Parser(
147
+ {
148
+ onopentag(name, attrs) {
149
+ if (name !== SLOT_TAG) return;
150
+ if (openName !== null) {
151
+ throw new Error(`nested <caelo-slot> not allowed (at offset ${parser.startIndex})`);
152
+ }
153
+ openName = attrs.name ?? "";
154
+ // `endIndex` of the opening tag points at the closing `>`; the inner
155
+ // HTML therefore starts one byte later.
156
+ openOpenStart = parser.startIndex;
157
+ openInnerStart = parser.endIndex + 1;
158
+ },
159
+ onclosetag(name) {
160
+ if (name !== SLOT_TAG) return;
161
+ if (openName === null) {
162
+ throw new Error(`closing </caelo-slot> with no opener (at offset ${parser.startIndex})`);
163
+ }
164
+ // htmlparser2 auto-closes unterminated tags at EOF — detect that case
165
+ // by verifying the source actually contains `</caelo-slot>` at the
166
+ // reported offset. Auto-closed tags fire onclosetag at EOF without a
167
+ // matching `</` in the input.
168
+ const hasExplicitClose = templateHtml
169
+ .slice(parser.startIndex, parser.startIndex + SLOT_TAG.length + 3)
170
+ .toLowerCase()
171
+ .startsWith(`</${SLOT_TAG}`);
172
+ if (!hasExplicitClose) {
173
+ throw new Error(
174
+ `unterminated <caelo-slot name="${openName}"> at offset ${openOpenStart}`,
175
+ );
176
+ }
177
+ ranges.push({
178
+ name: openName,
179
+ innerStart: openInnerStart,
180
+ innerEnd: parser.startIndex,
181
+ });
182
+ openName = null;
183
+ },
184
+ },
185
+ // recognizeSelfClosing handles `<caelo-slot name="x" />` style; we still
186
+ // require an explicit close tag so that case skips replacement (matches
187
+ // the documented "self-close passes through" limitation).
188
+ { lowerCaseTags: true, recognizeSelfClosing: false },
189
+ );
190
+ parser.write(templateHtml);
191
+ parser.end();
192
+
193
+ if (openName !== null) {
194
+ throw new Error(`unterminated <caelo-slot name="${openName}"> at offset ${openOpenStart}`);
195
+ }
196
+
197
+ const parts: string[] = [];
198
+ const replaced: string[] = [];
199
+ const missing: string[] = [];
200
+ let cursor = 0;
201
+ for (const range of ranges) {
202
+ parts.push(templateHtml.slice(cursor, range.innerStart));
203
+ if (replacement.contentByName.has(range.name)) {
204
+ parts.push(replacement.contentByName.get(range.name) ?? "");
205
+ replaced.push(range.name);
206
+ } else {
207
+ missing.push(range.name);
208
+ parts.push(templateHtml.slice(range.innerStart, range.innerEnd));
209
+ }
210
+ cursor = range.innerEnd;
211
+ }
212
+ parts.push(templateHtml.slice(cursor));
213
+ return { html: parts.join(""), replacedSlots: replaced, missingSlots: missing };
214
+ }
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Unit guards for the canonical propose/execute status enum. The
5
+ * load-bearing invariants: the four states never silently change order or
6
+ * membership when ~14 pending-op schemas migrate to this shared source, and
7
+ * the *proposal* enum never absorbs the semantically-different `"accepted"`
8
+ * review-status family (CLAUDE.md §11.A, plan Out-of-scope §3).
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { z } from "zod";
13
+ import { PROPOSAL_STATUSES, type ProposalStatus, proposalStatus } from "./proposal-status.js";
14
+
15
+ describe("proposalStatus", () => {
16
+ it("has exactly the four states, in canonical order", () => {
17
+ expect(PROPOSAL_STATUSES).toEqual(["pending", "applied", "rejected", "superseded"]);
18
+ expect(proposalStatus.options).toEqual(["pending", "applied", "rejected", "superseded"]);
19
+ });
20
+
21
+ it("parses every member back to itself", () => {
22
+ for (const s of PROPOSAL_STATUSES) {
23
+ expect(proposalStatus.parse(s)).toBe(s);
24
+ }
25
+ });
26
+
27
+ it("rejects values outside the vocabulary (incl. the review-status family)", () => {
28
+ // "accepted" is the skill/ai-memory review vocabulary, NOT a proposal
29
+ // state — this guard is why the two enums must stay separate.
30
+ expect(proposalStatus.safeParse("accepted").success).toBe(false);
31
+ expect(proposalStatus.safeParse("draft").success).toBe(false);
32
+ expect(proposalStatus.safeParse("all").success).toBe(false);
33
+ expect(proposalStatus.safeParse("").success).toBe(false);
34
+ });
35
+
36
+ it("derives the documented domain variants without re-typing the 4-tuple", () => {
37
+ // locales list-filter: + "all"
38
+ expect(z.enum([...PROPOSAL_STATUSES, "all"] as const).options).toEqual([
39
+ "pending",
40
+ "applied",
41
+ "rejected",
42
+ "superseded",
43
+ "all",
44
+ ]);
45
+ // themes: + "cancelled"
46
+ expect(z.enum([...PROPOSAL_STATUSES, "cancelled"] as const).options).toEqual([
47
+ "pending",
48
+ "applied",
49
+ "rejected",
50
+ "superseded",
51
+ "cancelled",
52
+ ]);
53
+ // deploy proposals: no "superseded"
54
+ expect(proposalStatus.exclude(["superseded"]).options).toEqual([
55
+ "pending",
56
+ "applied",
57
+ "rejected",
58
+ ]);
59
+ });
60
+
61
+ it("exports a ProposalStatus type that is the union and nothing wider", () => {
62
+ const ok: ProposalStatus = "pending";
63
+ expect(ok).toBe("pending");
64
+ // @ts-expect-error "accepted" is not a ProposalStatus
65
+ const bad: ProposalStatus = "accepted";
66
+ // reference `bad` so the binding isn't elided before the ts-expect-error applies
67
+ expect(typeof bad).toBe("string");
68
+ });
69
+ });
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Canonical status vocabulary for the propose/execute pattern (CLAUDE.md
5
+ * §11.A, `docs/propose-execute-pattern.md`). Every gated domain's
6
+ * `<domain>_pending_actions` row moves through these four states:
7
+ *
8
+ * pending — AI/Owner queued the proposal; awaiting a human click.
9
+ * applied — Owner approved; `execute_proposal` ran the real op.
10
+ * rejected — Owner declined (with an optional reason).
11
+ * superseded — a newer proposal for the same target replaced this one
12
+ * (dedup/GC), so it can never be approved.
13
+ *
14
+ * Lives in `@caelo-cms/shared` so the ~14 pending-op Zod schemas import one
15
+ * source instead of re-typing the literal — which is how the four states
16
+ * stay value-identical across every domain that adopted the pattern.
17
+ *
18
+ * Domains that need extra states extend this rather than re-typing it:
19
+ * - list-filter ops add `"all"`: `z.enum([...PROPOSAL_STATUSES, "all"] as const)`
20
+ * - themes adds `"cancelled"`: `z.enum([...PROPOSAL_STATUSES, "cancelled"] as const)`
21
+ * - deploy proposals never get `"superseded"`: `proposalStatus.exclude(["superseded"])`
22
+ * Keep the `as const` on any spread so `z.enum` infers the literal union,
23
+ * not `string[]`.
24
+ *
25
+ * NOTE: this is NOT the vocabulary for skill/AI-memory *review* status
26
+ * (`accepted`/`rejected`) nor for deploy/translation *run* status
27
+ * (`running`/`succeeded`/…) — those are different value sets and must not
28
+ * be collapsed into this enum.
29
+ */
30
+
31
+ import { z } from "zod";
32
+
33
+ /** The four propose/execute states, in canonical order. */
34
+ export const PROPOSAL_STATUSES = ["pending", "applied", "rejected", "superseded"] as const;
35
+
36
+ /** Zod enum over {@link PROPOSAL_STATUSES} — the single source for every gated-domain `status` schema. */
37
+ export const proposalStatus = z.enum(PROPOSAL_STATUSES);
38
+
39
+ /** Union of the four propose/execute states. */
40
+ export type ProposalStatus = z.infer<typeof proposalStatus>;
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #162 — shared responsive-image enrichment: production shape
5
+ * (src rewritten to _assets) vs preview shape (src kept on the admin
6
+ * media route), author precedence, and the two-entry ladder rule.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ enrichResponsiveImages,
12
+ type ImageVariantInfo,
13
+ pickAiImageVariant,
14
+ } from "./responsive-images.js";
15
+
16
+ const ASSET = "12345678-1234-4123-8123-123456789abc";
17
+ const VARIANTS: ImageVariantInfo[] = [
18
+ { variant: "webp-400", format: "webp" },
19
+ { variant: "webp-800", format: "webp" },
20
+ { variant: "webp-1200", format: "webp" },
21
+ { variant: "orig", format: "jpeg" },
22
+ ];
23
+ const byAsset = new Map([[ASSET, VARIANTS]]);
24
+ const IMG = `<img src="/_caelo/media/${ASSET}/webp-800" alt="Team">`;
25
+
26
+ describe("enrichResponsiveImages (issue #162)", () => {
27
+ it("production mode rewrites src and builds the _assets ladder", () => {
28
+ const out = enrichResponsiveImages(IMG, byAsset, {
29
+ rewriteSrc: true,
30
+ urlFor: (id, v, f) => `/_assets/${id}/${v}.${f === "webp" ? "webp" : "jpg"}`,
31
+ });
32
+ expect(out).toContain(`src="/_assets/${ASSET}/webp-800.webp"`);
33
+ expect(out).toContain(`/_assets/${ASSET}/webp-400.webp 400w`);
34
+ expect(out).toContain('loading="lazy"');
35
+ expect(out).toContain('decoding="async"');
36
+ expect(out).toContain('alt="Team"');
37
+ });
38
+
39
+ it("preview mode keeps the admin src but emits the SAME attribute shape", () => {
40
+ const out = enrichResponsiveImages(IMG, byAsset, {
41
+ rewriteSrc: false,
42
+ urlFor: (id, v) => `/_caelo/media/${id}/${v}`,
43
+ });
44
+ expect(out).toContain(`src="/_caelo/media/${ASSET}/webp-800"`);
45
+ expect(out).toContain(`/_caelo/media/${ASSET}/webp-400 400w`);
46
+ expect(out).toContain(`sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 800px"`);
47
+ expect(out).toContain('loading="lazy"');
48
+ });
49
+
50
+ it("author-supplied attributes keep precedence", () => {
51
+ const authored = `<img loading="eager" srcset="x 1w" src="/_caelo/media/${ASSET}/webp-800">`;
52
+ const out = enrichResponsiveImages(authored, byAsset, {
53
+ rewriteSrc: false,
54
+ urlFor: (id, v) => `/_caelo/media/${id}/${v}`,
55
+ });
56
+ expect(out).toContain('loading="eager"');
57
+ expect(out).toContain('srcset="x 1w"');
58
+ expect(out.match(/srcset=/g)).toHaveLength(1);
59
+ });
60
+
61
+ it("skips srcset for sub-two-entry ladders and non-media images", () => {
62
+ const single = new Map([[ASSET, [{ variant: "webp-800", format: "webp" }]]]);
63
+ const out = enrichResponsiveImages(IMG, single, {
64
+ rewriteSrc: false,
65
+ urlFor: (id, v) => `/_caelo/media/${id}/${v}`,
66
+ });
67
+ expect(out).not.toContain("srcset=");
68
+ const external = '<img src="https://example.com/x.png">';
69
+ expect(
70
+ enrichResponsiveImages(external, byAsset, {
71
+ rewriteSrc: false,
72
+ urlFor: (id, v) => `/${id}/${v}`,
73
+ }),
74
+ ).toBe(external);
75
+ });
76
+ });
77
+
78
+ /**
79
+ * Run #10 D4 — AI-facing surfaces must advertise a variant that EXISTS.
80
+ * The pipeline never emits webp-800 for sub-800px sources or animated
81
+ * GIFs; advertising it anyway blocked the whole staging build.
82
+ */
83
+ describe("pickAiImageVariant", () => {
84
+ it("prefers webp-800 when present", () => {
85
+ expect(pickAiImageVariant(["orig", "webp-400", "webp-800", "webp-1200"])).toBe("webp-800");
86
+ });
87
+
88
+ it("falls back to the largest webp below 800 (small source, no upscaling)", () => {
89
+ expect(pickAiImageVariant(["orig", "webp-400"])).toBe("webp-400");
90
+ });
91
+
92
+ it("falls back to the smallest webp above 800 when nothing smaller exists", () => {
93
+ expect(pickAiImageVariant(["orig", "webp-1200", "webp-1600"])).toBe("webp-1200");
94
+ });
95
+
96
+ it("returns orig when no webp exists (animated GIF, SVG, PDF, pipeline gap)", () => {
97
+ expect(pickAiImageVariant(["orig"])).toBe("orig");
98
+ expect(pickAiImageVariant([])).toBe("orig");
99
+ });
100
+
101
+ it("ignores crop-family variants when picking", () => {
102
+ expect(pickAiImageVariant(["orig", "square-800", "webp-400"])).toBe("webp-400");
103
+ });
104
+ });
@@ -0,0 +1,151 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #162 — responsive-image markup, ONE implementation for both
5
+ * render surfaces. Production (the static generator's media pass) and
6
+ * the editor preview must emit the same `srcset`/`sizes`/`loading`/
7
+ * `decoding` shape — the operator tunes visuals against the preview,
8
+ * and the #155 self-review loop makes design decisions from preview
9
+ * screenshots, so markup drift there means optimizing the wrong
10
+ * artifact. Only the URL form differs (parameterized `urlFor`):
11
+ * `/_assets/<id>/<variant>.<ext>` in builds, `/_caelo/media/<id>/<variant>`
12
+ * in the admin preview.
13
+ *
14
+ * Extracted from apps/static-generator/src/media-pass.ts verbatim in
15
+ * behaviour: author-supplied srcset/sizes/loading/decoding always keep
16
+ * precedence; ladders under two entries add no srcset.
17
+ */
18
+
19
+ export interface ImageVariantInfo {
20
+ readonly variant: string;
21
+ readonly format: string;
22
+ }
23
+
24
+ /** Extract `webp-800` → 800; `square-400` → 400; `orig` → null. */
25
+ export function parseVariantWidth(variant: string): number | null {
26
+ const m = variant.match(/-(\d+)$/);
27
+ return m?.[1] ? Number.parseInt(m[1], 10) : null;
28
+ }
29
+
30
+ /** `webp-800` → 'webp'; `square-800` → 'square'; `orig` → 'orig'. */
31
+ export function variantFamily(variant: string): string {
32
+ const m = variant.match(/^([a-z][a-z0-9-]*?)-\d+$/);
33
+ return m?.[1] ? m[1] : variant;
34
+ }
35
+
36
+ /**
37
+ * Pick the variant tag AI-facing surfaces should hand out for an
38
+ * asset, given the variant tags that ACTUALLY exist on it.
39
+ *
40
+ * Run #10 D4: `find_media` and the `## Media` system-prompt block
41
+ * advertised `webp-800` for every raster mime, but the pipeline never
42
+ * emits `webp-800` for sources narrower than 800px (no upscaling) or
43
+ * for animated GIFs — the AI wrote those URLs into module HTML and the
44
+ * static generator's media pass failed the whole staging build on
45
+ * "asset/variant pairs missing". Advertising must be grounded in the
46
+ * `media_variants` rows, not in the mime.
47
+ *
48
+ * Preference order: `webp-800` when present; else the LARGEST webp at
49
+ * or below 800 (best quality that exists without shipping a hero-sized
50
+ * file); else the smallest webp above 800; else `orig` (always exists).
51
+ *
52
+ * @param existingVariants variant tags present in `media_variants` for the asset.
53
+ */
54
+ export function pickAiImageVariant(existingVariants: readonly string[]): string {
55
+ const webps = existingVariants
56
+ .map((v) => ({ variant: v, width: parseVariantWidth(v) }))
57
+ .filter(
58
+ (v): v is { variant: string; width: number } =>
59
+ variantFamily(v.variant) === "webp" && v.width !== null,
60
+ );
61
+ const exact = webps.find((v) => v.width === 800);
62
+ if (exact) return exact.variant;
63
+ const below = webps.filter((v) => v.width < 800).sort((a, b) => b.width - a.width)[0];
64
+ if (below) return below.variant;
65
+ const above = webps.filter((v) => v.width > 800).sort((a, b) => a.width - b.width)[0];
66
+ if (above) return above.variant;
67
+ return "orig";
68
+ }
69
+
70
+ export interface EnrichResponsiveImagesOptions {
71
+ /**
72
+ * Build the URL an enriched attribute should reference for
73
+ * (ref, variant, format), where `ref` is the asset SLUG (current form)
74
+ * or a legacy UUID id. Return null to leave `src` untouched (the preview
75
+ * keeps its admin-served URLs).
76
+ */
77
+ readonly urlFor: (ref: string, variant: string, format: string) => string;
78
+ /** Rewrite the `src` attribute itself (production) or keep it (preview). */
79
+ readonly rewriteSrc: boolean;
80
+ /** Format lookup per (ref, variant); defaults to webp. */
81
+ readonly formatFor?: (ref: string, variant: string) => string;
82
+ }
83
+
84
+ const ENRICH_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
85
+
86
+ /**
87
+ * Walk every `<img …>` whose src points at `/_caelo/media/<slug>` (or the
88
+ * legacy `/_caelo/media/<uuid>/<variant>`) and append the responsive
89
+ * attributes. `variantsByAsset` is keyed by the same `ref` the caller uses
90
+ * in `urlFor` (slug for current embeds, id for legacy). String-level
91
+ * rewrite; existing attributes always win.
92
+ */
93
+ export function enrichResponsiveImages(
94
+ html: string,
95
+ variantsByAsset: ReadonlyMap<string, readonly ImageVariantInfo[]>,
96
+ options: EnrichResponsiveImagesOptions,
97
+ ): string {
98
+ return html.replace(/<img\b[^>]*>/g, (tag) => {
99
+ const srcMatch = tag.match(
100
+ /\bsrc=("|')\/_caelo\/media\/([a-z0-9][a-z0-9-]{0,63})(?:\/([a-z][a-z0-9-]{0,63}))?\1/,
101
+ );
102
+ if (!srcMatch) return tag;
103
+ const seg1 = srcMatch[2] as string;
104
+ const seg2 = srcMatch[3];
105
+ // Legacy id form only when a full uuid is followed by an explicit
106
+ // variant; otherwise `ref` is the slug and orig is implied.
107
+ const isLegacyId = ENRICH_UUID_RE.test(seg1) && seg2 !== undefined;
108
+ const ref = seg1;
109
+ const variant = isLegacyId ? (seg2 as string) : (seg2 ?? "orig");
110
+ const assetId = ref;
111
+ const variants = variantsByAsset.get(ref) ?? [];
112
+
113
+ const family = variantFamily(variant);
114
+ const familyVariants = variants
115
+ .filter((v) => variantFamily(v.variant) === family && v.format === "webp")
116
+ .map((v) => ({ variant: v.variant, width: parseVariantWidth(v.variant) }))
117
+ .filter((v): v is { variant: string; width: number } => v.width !== null)
118
+ .sort((a, b) => a.width - b.width);
119
+
120
+ const requestedWidth = parseVariantWidth(variant);
121
+ const requestedFormat = options.formatFor?.(assetId, variant) ?? "webp";
122
+
123
+ let out = tag;
124
+ if (options.rewriteSrc) {
125
+ out = out.replace(srcMatch[0], `src="${options.urlFor(assetId, variant, requestedFormat)}"`);
126
+ }
127
+
128
+ if (familyVariants.length >= 2 && !/\bsrcset=/i.test(out)) {
129
+ const srcset = familyVariants
130
+ .map((v) => `${options.urlFor(assetId, v.variant, "webp")} ${v.width}w`)
131
+ .join(", ");
132
+ const sizes =
133
+ requestedWidth !== null
134
+ ? `(max-width: 600px) 400px, (max-width: 1200px) 800px, ${requestedWidth}px`
135
+ : "100vw";
136
+ out = out.replace(
137
+ /<img\b/,
138
+ `<img srcset="${srcset}" sizes="${/\bsizes=/i.test(out) ? "" : sizes}"`,
139
+ );
140
+ out = out.replace(/\bsizes=""\s*/g, "");
141
+ }
142
+
143
+ if (!/\bloading=/i.test(out)) {
144
+ out = out.replace(/<img\b/, '<img loading="lazy"');
145
+ }
146
+ if (!/\bdecoding=/i.test(out)) {
147
+ out = out.replace(/<img\b/, '<img decoding="async"');
148
+ }
149
+ return out;
150
+ });
151
+ }