@caelo-cms/shared 0.10.23 → 0.10.26

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 (137) hide show
  1. package/dist/ai-tools.d.ts +15 -70
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +14 -74
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/base-css.d.ts +11 -0
  6. package/dist/base-css.d.ts.map +1 -1
  7. package/dist/base-css.js +13 -1
  8. package/dist/base-css.js.map +1 -1
  9. package/dist/build-page.d.ts +0 -2
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +2 -3
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +0 -3
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +91 -7
  16. package/dist/content.js.map +1 -1
  17. package/dist/context.d.ts +6 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/design-draft-shell.d.ts +21 -0
  20. package/dist/design-draft-shell.d.ts.map +1 -0
  21. package/dist/design-draft-shell.js +81 -0
  22. package/dist/design-draft-shell.js.map +1 -0
  23. package/dist/font-assets.d.ts +89 -0
  24. package/dist/font-assets.d.ts.map +1 -0
  25. package/dist/font-assets.js +61 -0
  26. package/dist/font-assets.js.map +1 -0
  27. package/dist/genesis.d.ts +43 -3
  28. package/dist/genesis.d.ts.map +1 -1
  29. package/dist/genesis.js +72 -5
  30. package/dist/genesis.js.map +1 -1
  31. package/dist/google-models.d.ts +35 -0
  32. package/dist/google-models.d.ts.map +1 -0
  33. package/dist/google-models.js +20 -0
  34. package/dist/google-models.js.map +1 -0
  35. package/dist/index.d.ts +5 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/media.d.ts +0 -3
  40. package/dist/media.d.ts.map +1 -1
  41. package/dist/media.js +0 -5
  42. package/dist/media.js.map +1 -1
  43. package/dist/plugin-preview.d.ts +44 -0
  44. package/dist/plugin-preview.d.ts.map +1 -0
  45. package/dist/plugin-preview.js +69 -0
  46. package/dist/plugin-preview.js.map +1 -0
  47. package/dist/preview-compose.d.ts +35 -15
  48. package/dist/preview-compose.d.ts.map +1 -1
  49. package/dist/preview-compose.js +95 -89
  50. package/dist/preview-compose.js.map +1 -1
  51. package/dist/responsive-images.d.ts +6 -5
  52. package/dist/responsive-images.d.ts.map +1 -1
  53. package/dist/responsive-images.js +6 -5
  54. package/dist/responsive-images.js.map +1 -1
  55. package/dist/seo.d.ts +11 -28
  56. package/dist/seo.d.ts.map +1 -1
  57. package/dist/seo.js +12 -31
  58. package/dist/seo.js.map +1 -1
  59. package/dist/structured-sets.d.ts +0 -36
  60. package/dist/structured-sets.d.ts.map +1 -1
  61. package/dist/structured-sets.js +1 -52
  62. package/dist/structured-sets.js.map +1 -1
  63. package/dist/template-engine.d.ts +18 -0
  64. package/dist/template-engine.d.ts.map +1 -1
  65. package/dist/template-engine.js +39 -2
  66. package/dist/template-engine.js.map +1 -1
  67. package/dist/theme-importers/loose.js +1 -1
  68. package/dist/theme-importers/loose.js.map +1 -1
  69. package/dist/theme-normalize.d.ts +9 -0
  70. package/dist/theme-normalize.d.ts.map +1 -1
  71. package/dist/theme-normalize.js +65 -6
  72. package/dist/theme-normalize.js.map +1 -1
  73. package/dist/theme-render.d.ts +15 -0
  74. package/dist/theme-render.d.ts.map +1 -1
  75. package/dist/theme-render.js +39 -0
  76. package/dist/theme-render.js.map +1 -1
  77. package/dist/themes.d.ts +15 -1
  78. package/dist/themes.d.ts.map +1 -1
  79. package/dist/themes.js +48 -1
  80. package/dist/themes.js.map +1 -1
  81. package/dist/url.d.ts +45 -0
  82. package/dist/url.d.ts.map +1 -0
  83. package/dist/url.js +64 -0
  84. package/dist/url.js.map +1 -0
  85. package/dist/version.d.ts +2 -2
  86. package/dist/version.js +1 -1
  87. package/package.json +2 -2
  88. package/src/__tests__/redos-hardening.test.ts +1 -1
  89. package/src/ai-tools.ts +14 -90
  90. package/src/base-css.ts +13 -1
  91. package/src/build-page.ts +3 -5
  92. package/src/content.test.ts +99 -17
  93. package/src/content.ts +95 -8
  94. package/src/context.ts +6 -0
  95. package/src/design-draft-shell.test.ts +85 -0
  96. package/src/design-draft-shell.ts +109 -0
  97. package/src/font-assets.ts +63 -0
  98. package/src/genesis.ts +76 -5
  99. package/src/google-models.ts +20 -0
  100. package/src/index.ts +5 -3
  101. package/src/media.ts +0 -6
  102. package/src/plugin-preview.test.ts +79 -0
  103. package/src/plugin-preview.ts +81 -0
  104. package/src/preview-compose-deferrals.test.ts +144 -0
  105. package/src/preview-compose.test.ts +51 -0
  106. package/src/preview-compose.ts +128 -93
  107. package/src/responsive-images.ts +6 -5
  108. package/src/seo.test.ts +8 -48
  109. package/src/seo.ts +16 -52
  110. package/src/structured-sets.ts +1 -67
  111. package/src/template-engine-data-lists.test.ts +90 -0
  112. package/src/template-engine.ts +72 -1
  113. package/src/theme-importers/loose.ts +1 -1
  114. package/src/theme-normalize.ts +70 -8
  115. package/src/theme-render.ts +36 -0
  116. package/src/theme-token-roles.test.ts +182 -0
  117. package/src/themes.ts +53 -0
  118. package/src/url.test.ts +40 -0
  119. package/src/url.ts +69 -0
  120. package/src/version.ts +1 -1
  121. package/dist/design-manifest.d.ts +0 -36
  122. package/dist/design-manifest.d.ts.map +0 -1
  123. package/dist/design-manifest.js +0 -90
  124. package/dist/design-manifest.js.map +0 -1
  125. package/dist/i18n.d.ts +0 -92
  126. package/dist/i18n.d.ts.map +0 -1
  127. package/dist/i18n.js +0 -226
  128. package/dist/i18n.js.map +0 -1
  129. package/dist/translation.d.ts +0 -127
  130. package/dist/translation.d.ts.map +0 -1
  131. package/dist/translation.js +0 -208
  132. package/dist/translation.js.map +0 -1
  133. package/src/design-manifest.ts +0 -93
  134. package/src/i18n.test.ts +0 -274
  135. package/src/i18n.ts +0 -269
  136. package/src/translation.test.ts +0 -160
  137. package/src/translation.ts +0 -295
package/src/base-css.ts CHANGED
@@ -19,6 +19,17 @@
19
19
  * drafts assume it);
20
20
  * - form controls inherit the page's font instead of UA chrome.
21
21
  *
22
+ * Amendment (issue #410): one rule consumes a design token — the body
23
+ * font-family reads `var(--font-body)`. This does NOT reintroduce a
24
+ * global look: the token is declared per site by the theme renderer
25
+ * (#164), and applying the site's OWN chosen token is the mechanism
26
+ * that makes that choice take effect at all. Without a consumer the
27
+ * variable is declared but dead, and every fresh site renders the UA
28
+ * serif despite its theme's stack. The `system-ui,sans-serif` fallback
29
+ * is a declared default in the CSS cascade for themeless installs, not
30
+ * a hidden recovery path (CLAUDE.md §2 — nothing is silently
31
+ * substituted at read time; the cascade default is stated here, once).
32
+ *
22
33
  * Injected as `<style data-source="base">` between the theme vars and
23
34
  * the aggregated module CSS, so any module rule overrides it trivially.
24
35
  */
@@ -27,4 +38,5 @@ export const BASE_TECHNICAL_CSS =
27
38
  "*,*::before,*::after{box-sizing:border-box}" +
28
39
  "body{margin:0}" +
29
40
  "img,picture,video,canvas,svg{display:block;max-width:100%}" +
30
- "input,button,textarea,select{font:inherit}";
41
+ "input,button,textarea,select{font:inherit}" +
42
+ "body{font-family:var(--font-body,system-ui,sans-serif)}";
package/src/build-page.ts CHANGED
@@ -22,7 +22,6 @@
22
22
  import { z } from "zod";
23
23
  import {
24
24
  contentInstanceCreateSchema,
25
- localeSchema,
26
25
  MODULE_CSS_MAX,
27
26
  MODULE_HTML_MAX,
28
27
  MODULE_JS_MAX,
@@ -194,7 +193,6 @@ export const buildPageTargetSchema = z
194
193
  slug: slugSchema.optional(),
195
194
  title: z.string().min(1).max(256).optional(),
196
195
  name: z.string().min(1).max(256).optional(),
197
- locale: localeSchema.optional(),
198
196
  templateId: z.string().uuid().optional(),
199
197
  status: pageStatusSchema.optional(),
200
198
  /**
@@ -210,9 +208,9 @@ export const buildPageTargetSchema = z
210
208
  })
211
209
  .strict()
212
210
  .superRefine((page, ctx) => {
213
- const createKeys = (
214
- ["slug", "title", "name", "locale", "templateId", "status"] as const
215
- ).filter((k) => page[k] !== undefined);
211
+ const createKeys = (["slug", "title", "name", "templateId", "status"] as const).filter(
212
+ (k) => page[k] !== undefined,
213
+ );
216
214
  if (page.pageId !== undefined) {
217
215
  if (createKeys.length > 0) {
218
216
  ctx.addIssue({
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
- localeSchema,
6
5
  MODULE_HTML_MAX,
7
6
  moduleCreateSchema,
7
+ moduleUpdateSchema,
8
8
  pageCreateSchema,
9
9
  pageSetModulesSchema,
10
10
  pageUpdateSchema,
@@ -29,21 +29,6 @@ describe("slugSchema", () => {
29
29
  });
30
30
  });
31
31
 
32
- describe("localeSchema", () => {
33
- it("accepts language and language-region", () => {
34
- expect(localeSchema.safeParse("en").success).toBe(true);
35
- expect(localeSchema.safeParse("de").success).toBe(true);
36
- expect(localeSchema.safeParse("de-AT").success).toBe(true);
37
- });
38
-
39
- it("rejects malformed locales", () => {
40
- expect(localeSchema.safeParse("EN").success).toBe(false);
41
- expect(localeSchema.safeParse("en-us").success).toBe(false);
42
- expect(localeSchema.safeParse("eng").success).toBe(false);
43
- expect(localeSchema.safeParse("").success).toBe(false);
44
- });
45
- });
46
-
47
32
  describe("moduleCreateSchema", () => {
48
33
  it("accepts a minimal module", () => {
49
34
  const r = moduleCreateSchema.safeParse({
@@ -73,6 +58,104 @@ describe("moduleCreateSchema", () => {
73
58
  });
74
59
  expect(r.success).toBe(false);
75
60
  });
61
+
62
+ // issue #432 — CDATA wrappers stored verbatim broke rendering and sent the
63
+ // AI into a ~20-loop visual repair spiral; the write boundary now rejects
64
+ // them with an actionable message.
65
+ it("rejects XML CDATA wrappers in css and js, with an actionable message", () => {
66
+ const base = { slug: "site-header", displayName: "Site header", html: "<header></header>" };
67
+ const css = moduleCreateSchema.safeParse({
68
+ ...base,
69
+ css: "/*<![CDATA[*/ .x { color: red } /*]]>*/",
70
+ });
71
+ expect(css.success).toBe(false);
72
+ if (!css.success) {
73
+ expect(css.error.issues.map((i) => i.message).join(" ")).toContain("CDATA");
74
+ }
75
+ const js = moduleCreateSchema.safeParse({
76
+ ...base,
77
+ js: "//<![CDATA[\nconsole.log('hi');\n//]]>",
78
+ });
79
+ expect(js.success).toBe(false);
80
+ });
81
+
82
+ it("accepts plain css/js (and js merely mentioning ']]>' in a string)", () => {
83
+ const base = { slug: "site-header", displayName: "Site header", html: "<header></header>" };
84
+ expect(
85
+ moduleCreateSchema.safeParse({ ...base, css: ".x { color: red }", js: "console.log(1);" })
86
+ .success,
87
+ ).toBe(true);
88
+ // Only the OPENING marker is the corruption signature; a bare "]]>"
89
+ // inside a string literal is legitimate (e.g. code emitting XML).
90
+ expect(moduleCreateSchema.safeParse({ ...base, js: 'xml += "]]>";' }).success).toBe(true);
91
+ });
92
+
93
+ it("rejects CDATA on the update path too (edit_module / edit_content funnel)", () => {
94
+ const r = moduleUpdateSchema.safeParse({
95
+ moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
96
+ css: "/*<![CDATA[*/ .x{} /*]]>*/",
97
+ });
98
+ expect(r.success).toBe(false);
99
+ });
100
+ });
101
+
102
+ // CLAUDE.md §1A / issue #417 — repeating content is a list field, never
103
+ // numbered scalars. Threshold is 3: a pair (col1/col2) is a legitimate
104
+ // layout split; three-or-more numbered siblings is a list.
105
+ describe("moduleFields numbered-scalar fanout guard (§1A)", () => {
106
+ const mod = (fields: unknown) =>
107
+ moduleCreateSchema.safeParse({
108
+ slug: "cards",
109
+ displayName: "Cards",
110
+ html: "<div></div>",
111
+ fields,
112
+ });
113
+ const text = (name: string) => ({ name, kind: "text", label: name });
114
+
115
+ it("rejects a bare base + numbered siblings (label, label2, label3)", () => {
116
+ const r = mod([text("label"), text("label2"), text("label3")]);
117
+ expect(r.success).toBe(false);
118
+ if (!r.success) {
119
+ const msg = r.error.issues[0]?.message ?? "";
120
+ expect(msg).toContain("numbered-scalar fanout");
121
+ expect(msg).toContain('"label", "label2", "label3"');
122
+ // AI-actionable: the error names every list-kind fix.
123
+ expect(msg).toContain("text-list");
124
+ expect(msg).toContain("link-list");
125
+ expect(msg).toContain("module-list");
126
+ expect(msg).toContain("§1A");
127
+ }
128
+ });
129
+
130
+ it("rejects underscore-separated runs (quote_1..quote_3) and mixed separators", () => {
131
+ expect(mod([text("quote_1"), text("quote_2"), text("quote_3")]).success).toBe(false);
132
+ expect(mod([text("logo1"), text("logo_2"), text("logo_3")]).success).toBe(false);
133
+ });
134
+
135
+ it("rejects fanout on moduleUpdateSchema too (same shared fields array)", () => {
136
+ const r = moduleUpdateSchema.safeParse({
137
+ moduleId: "6f9619ff-8b86-4d01-b42d-00cf4fc964ff",
138
+ fields: [text("card1"), text("card2"), text("card3")],
139
+ });
140
+ expect(r.success).toBe(false);
141
+ });
142
+
143
+ it("allows a two-field split (col1/col2) — threshold is 3", () => {
144
+ expect(mod([text("col1"), text("col2")]).success).toBe(true);
145
+ expect(mod([text("label"), text("label2")]).success).toBe(true);
146
+ expect(mod([text("address_line1"), text("address_line2")]).success).toBe(true);
147
+ });
148
+
149
+ it("allows unrelated numbered fields that never form a run of 3", () => {
150
+ expect(mod([text("hero_title"), text("cta_2024"), text("step_1")]).success).toBe(true);
151
+ // Same suffix, different bases — no shared run.
152
+ expect(mod([text("left_col1"), text("right_col1"), text("mid_col1")]).success).toBe(true);
153
+ });
154
+
155
+ it("scopes to scalar kinds — numbered list fields are a modelling question, not fanout", () => {
156
+ const linkList = (name: string) => ({ name, kind: "link-list", label: name });
157
+ expect(mod([linkList("links1"), linkList("links2"), linkList("links3")]).success).toBe(true);
158
+ });
76
159
  });
77
160
 
78
161
  describe("pageCreateSchema (no raw HTML invariant)", () => {
@@ -84,7 +167,6 @@ describe("pageCreateSchema (no raw HTML invariant)", () => {
84
167
  });
85
168
  expect(r.success).toBe(true);
86
169
  if (!r.success) return;
87
- expect(r.data.locale).toBe("en");
88
170
  // status is intentionally OPTIONAL (no schema default): pages.create
89
171
  // resolves it at write time — published on a bootstrap site, else draft.
90
172
  // Omitting it here leaves it undefined for the handler to resolve.
package/src/content.ts CHANGED
@@ -84,11 +84,6 @@ export function slugifyModuleSection(
84
84
  return `${deriveModuleType(displayName)}-${idx}-${suffix}`;
85
85
  }
86
86
 
87
- /** BCP-47 shape: 2-letter language with optional 2-letter region. P9 widens this. */
88
- export const localeSchema = z
89
- .string()
90
- .regex(/^[a-z]{2}(-[A-Z]{2})?$/, "locale must be 'xx' or 'xx-YY' (BCP-47 shape)");
91
-
92
87
  const displayNameSchema = z.string().min(1).max(128);
93
88
 
94
89
  /** Caps are arbitrary but cheap — paste-of-binary mistakes get caught. */
@@ -99,8 +94,39 @@ export const TEMPLATE_HTML_MAX = 512 * 1024;
99
94
  export const TEMPLATE_CSS_MAX = 128 * 1024;
100
95
 
101
96
  const moduleHtml = z.string().max(MODULE_HTML_MAX, `html exceeds ${MODULE_HTML_MAX} bytes`);
102
- const moduleCss = z.string().max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`);
103
- const moduleJs = z.string().max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`);
97
+
98
+ /**
99
+ * issue #432 root-cause guard — reject XML CDATA wrappers in module css/js.
100
+ *
101
+ * Module css/js is stored and served VERBATIM, so a `<![CDATA[ … ]]>`
102
+ * wrapper (an XHTML-era idiom the AI occasionally reaches for when
103
+ * "protecting" inline code) reaches the browser unparsed: the stylesheet
104
+ * fails to apply and the script throws `SyntaxError: Unexpected token '<'`.
105
+ * On e2e runs 31000584991 / 31000699422 (2026-08-05) exactly this landed
106
+ * silently, the page rendered broken, and the model burned ~15–20 extra
107
+ * loops (~+1.2M input tokens) discovering and repairing it via
108
+ * screenshot/inspect/edit cycles. Rejecting at the write boundary with an
109
+ * actionable message turns that repair spiral into a single corrected call
110
+ * (CLAUDE.md §11 — failure surfaces are AI-actionable; §2 — fail loudly).
111
+ *
112
+ * css/js only: CDATA is never valid CSS or JS, while an html body may
113
+ * legitimately carry CDATA inside inline `<svg>`/`<math>` foreign content.
114
+ */
115
+ const CDATA_OPEN = "<![CDATA[";
116
+ const noCdata = (value: string): boolean => !value.includes(CDATA_OPEN);
117
+ const cdataMessage = (field: "css" | "js"): string =>
118
+ `${field} contains an XML CDATA marker (${CDATA_OPEN}) — module ${field} is stored and served ` +
119
+ `verbatim, so CDATA wrappers reach the browser unparsed and break rendering. ` +
120
+ `Remove the wrapper and resend plain ${field === "css" ? "CSS" : "JavaScript"}.`;
121
+
122
+ const moduleCss = z
123
+ .string()
124
+ .max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
125
+ .refine(noCdata, cdataMessage("css"));
126
+ const moduleJs = z
127
+ .string()
128
+ .max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
129
+ .refine(noCdata, cdataMessage("js"));
104
130
 
105
131
  /**
106
132
  * v0.4.0 — module field schema. Each field declares one substitution slot in
@@ -283,6 +309,54 @@ export const moduleRefSchema = z
283
309
  .strict();
284
310
  export type ModuleRef = z.infer<typeof moduleRefSchema>;
285
311
 
312
+ /**
313
+ * CLAUDE.md §1A — "repeating content is a list field, never numbered
314
+ * scalars". This guard rejects the mechanically detectable half of that
315
+ * rule: a RUN of 3+ sibling scalar fields sharing a base name plus a
316
+ * numeric suffix (`label`, `label2`, `label3` / `quote_1`…`quote_3`).
317
+ *
318
+ * Threshold is 3 (issue #417): a two-field split (`col1`/`col2` for a
319
+ * two-column layout) is routinely a genuine layout role, so a pair stays
320
+ * legal; three-or-more numbered siblings is repeating content that must
321
+ * be ONE list field. The bare base name counts as an implicit member of
322
+ * its run — `label` next to `label2`/`label3` is item 1 in disguise.
323
+ *
324
+ * Deliberately out of scope (guidance-only, per the issue): content-
325
+ * derived names (`quote_justamazing`) and mid-name numbering
326
+ * (`step1_title`) — neither is detectable without false positives.
327
+ * Nested/list kinds are excluded too: a numbered run of lists is
328
+ * already a modelling question, not scalar fanout. The server-side
329
+ * extractor fallback (`extract-module-structure.ts`) is also
330
+ * unguarded on purpose: it numbers repeated tags in messy HUMAN-
331
+ * authored HTML, runs only when the caller declared no fields, and its
332
+ * output never re-enters this schema — the guard polices the boundary
333
+ * where the AI (or a human) AUTHORS `fields[]` explicitly.
334
+ */
335
+ const NUMBERED_SCALAR_FANOUT_THRESHOLD = 3;
336
+ const numberedFieldName = /^([a-z][a-z0-9_]*?)_?\d+$/;
337
+
338
+ function findNumberedScalarFanoutRuns(
339
+ fields: readonly { name: string; kind: string }[],
340
+ ): string[][] {
341
+ const scalarKinds = new Set<string>(MODULE_FIELD_PRIMITIVE_KINDS);
342
+ const scalarNames = fields.filter((f) => scalarKinds.has(f.kind)).map((f) => f.name);
343
+ const byBase = new Map<string, string[]>();
344
+ for (const name of scalarNames) {
345
+ const m = numberedFieldName.exec(name);
346
+ const base = m?.[1];
347
+ if (base === undefined) continue;
348
+ const members = byBase.get(base);
349
+ if (members) members.push(name);
350
+ else byBase.set(base, [name]);
351
+ }
352
+ const runs: string[][] = [];
353
+ for (const [base, members] of byBase) {
354
+ const run = scalarNames.includes(base) ? [base, ...members] : members;
355
+ if (run.length >= NUMBERED_SCALAR_FANOUT_THRESHOLD) runs.push(run);
356
+ }
357
+ return runs;
358
+ }
359
+
286
360
  const moduleFieldsArray = z
287
361
  .array(moduleFieldSchema)
288
362
  .max(64, "modules may declare at most 64 fields")
@@ -295,6 +369,20 @@ const moduleFieldsArray = z
295
369
  }
296
370
  seen.add(f.name);
297
371
  }
372
+ for (const run of findNumberedScalarFanoutRuns(arr)) {
373
+ ctx.addIssue({
374
+ code: z.ZodIssueCode.custom,
375
+ message:
376
+ `numbered-scalar fanout: ${run.map((n) => `"${n}"`).join(", ")} repeat one role as ` +
377
+ "numbered scalar fields — repeating content is ONE list field, never numbered scalars " +
378
+ '(CLAUDE.md §1A). Replace the run with a single field: kind "text-list" (list of ' +
379
+ 'strings, inner template iterates {{.}}), "link-list" (list of {label, href}), or ' +
380
+ '"module-list" (rich per-item structure: mint a card sub-module and reference it per ' +
381
+ "item — in build_page, author the card detached with `ref` and pass " +
382
+ '[{"$ref": "<ref>"}, …] as the field value). A two-field split like col1/col2 is fine; ' +
383
+ `${NUMBERED_SCALAR_FANOUT_THRESHOLD}+ numbered siblings is a list.`,
384
+ });
385
+ }
298
386
  });
299
387
 
300
388
  /**
@@ -457,7 +545,6 @@ export const pageStatusSchema = z.enum(["draft", "published"]);
457
545
  export const pageCreateSchema = z
458
546
  .object({
459
547
  slug: slugSchema,
460
- locale: localeSchema.default("en"),
461
548
  /** P6.7.5 — internal editor label. Defaults to title if omitted. */
462
549
  name: z.string().min(1).max(256).optional(),
463
550
  title: z.string().min(1).max(256),
package/src/context.ts CHANGED
@@ -31,6 +31,12 @@ export interface ExecutionContext {
31
31
  * fold consecutive AI actions into one entry.
32
32
  */
33
33
  readonly chatTaskId?: string;
34
+ /**
35
+ * For a plugin installed as an external artifact: the digest of the
36
+ * artifact this call runs. Private storage operations accept the call
37
+ * only while exactly this artifact is the active, granted one.
38
+ */
39
+ readonly pluginArtifactDigest?: string;
34
40
  /**
35
41
  * P10.5: parent attribution for subagent invocations. When the
36
42
  * `spawn_subagent` tool calls runChatTurn for the child, it carries
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #375 — theme-shell composition for growth-time design drafts:
5
+ * head order (fonts → theme vars → base), theme-asset placeholder
6
+ * resolution incl. the loud unbound path, and the no-theme degradation.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { composeDesignDraftShell } from "./design-draft-shell.js";
11
+ import type { ComposeTheme } from "./preview-compose.js";
12
+ import type { ThemeDocument } from "./themes.js";
13
+
14
+ const TOKENS = {
15
+ color: {
16
+ background: { $type: "color", $value: "#ffffff" },
17
+ primary: { $type: "color", $value: "#4f46e5" },
18
+ },
19
+ } as ThemeDocument;
20
+
21
+ const THEME: ComposeTheme = {
22
+ tokens: TOKENS,
23
+ assets: {
24
+ logo: { mediaId: "11111111-1111-4111-8111-111111111111", url: "/_caelo/media/logo/orig" },
25
+ logoDark: null,
26
+ favicon: null,
27
+ socialShare: null,
28
+ },
29
+ };
30
+
31
+ const FRAGMENT = `<section class="hero"><h1>Real headline</h1><style>.hero{color:var(--color-primary)}</style></section>`;
32
+
33
+ describe("composeDesignDraftShell (issue #375)", () => {
34
+ it("wraps the fragment in fonts → theme vars → base head order", () => {
35
+ const out = composeDesignDraftShell({
36
+ fragmentHtml: FRAGMENT,
37
+ theme: THEME,
38
+ fonts: { css: "@font-face{font-family:Inter}", preloads: ["/_caelo/fonts/inter/aa.woff2"] },
39
+ title: "Draft — bold",
40
+ });
41
+ expect(out.html).toStartWith("<!doctype html>");
42
+ expect(out.html).toContain('<style data-source="fonts">@font-face{font-family:Inter}');
43
+ expect(out.html).toContain('rel="preload" as="font"');
44
+ expect(out.html).toContain('<style data-source="theme">');
45
+ expect(out.html).toContain("--color-primary:#4f46e5");
46
+ expect(out.html).toContain('<style data-source="base">');
47
+ expect(out.html).toContain(FRAGMENT);
48
+ expect(out.missingSlots).toEqual([]);
49
+ const fontsAt = out.html.indexOf('data-source="fonts"');
50
+ const themeAt = out.html.indexOf('data-source="theme"');
51
+ const baseAt = out.html.indexOf('data-source="base"');
52
+ expect(fontsAt).toBeLessThan(themeAt);
53
+ expect(themeAt).toBeLessThan(baseAt);
54
+ });
55
+
56
+ it("substitutes bound theme-asset placeholders and flags unbound ones loudly", () => {
57
+ const out = composeDesignDraftShell({
58
+ fragmentHtml: `<img src="{{theme_logo_url}}"><img src="{{theme_favicon_url}}">`,
59
+ theme: THEME,
60
+ title: "assets",
61
+ });
62
+ expect(out.html).toContain('src="/_caelo/media/logo/orig"');
63
+ // Unbound slot: placeholder survives raw IN PLACE (§2 no-fallbacks;
64
+ // the attribute value must not be corrupted by an inline marker) …
65
+ expect(out.html).toContain('src="{{theme_favicon_url}}"');
66
+ // … and the marker rides as a real comment node at the body's end.
67
+ expect(out.html).toContain("<!-- caelo:missing reason=theme-asset-unbound:favicon --></body>");
68
+ expect(out.missingSlots).toEqual(["theme-asset-unbound:favicon"]);
69
+ });
70
+
71
+ it("renders without theme vars when no theme exists (visibly broken on purpose)", () => {
72
+ const out = composeDesignDraftShell({ fragmentHtml: FRAGMENT, title: "no theme" });
73
+ expect(out.html).not.toContain('data-source="theme"');
74
+ expect(out.html).not.toContain('data-source="fonts"');
75
+ expect(out.html).toContain('data-source="base"');
76
+ });
77
+
78
+ it("escapes the document title", () => {
79
+ const out = composeDesignDraftShell({
80
+ fragmentHtml: FRAGMENT,
81
+ title: `<script>"x"</script>`,
82
+ });
83
+ expect(out.html).toContain("<title>&lt;script&gt;&quot;x&quot;&lt;/script&gt;</title>");
84
+ });
85
+ });
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #375 — theme shell for growth-time design-draft previews.
5
+ *
6
+ * Page/module-scope drafts are stored as FRAGMENTS bound to the site's
7
+ * `var(--…)` tokens; this composer wraps one in the site's real
8
+ * compiled theme at view time: web fonts (#150), theme CSS variables,
9
+ * and the invisible technical baseline (#151) — in the exact head
10
+ * order `composePagePreview` uses, so a draft previews in the same
11
+ * cascade it will materialise into. Pure / sync / no IO: the caller
12
+ * (the `genesis.render_draft` op) loads the theme row and resolves
13
+ * fonts, mirroring `pages.render_preview`.
14
+ */
15
+
16
+ import { BASE_TECHNICAL_CSS } from "./base-css.js";
17
+ import { type ComposeFonts, type ComposeTheme, fontsHeadFragment } from "./preview-compose.js";
18
+ import { caeloMissingComment } from "./template-engine.js";
19
+ import { renderThemeCss } from "./theme-render.js";
20
+
21
+ /** Same four placeholders the template engine resolves (v0.11.1, #76). */
22
+ const THEME_ASSET_KEY_TO_SLOT = {
23
+ theme_logo_url: "logo",
24
+ theme_logo_dark_url: "logoDark",
25
+ theme_favicon_url: "favicon",
26
+ theme_social_share_url: "socialShare",
27
+ } as const;
28
+
29
+ export interface DesignDraftShellInput {
30
+ /** The stored fragment (already script-stripped at the boundary). */
31
+ readonly fragmentHtml: string;
32
+ /** Active theme; undefined renders the shell without theme vars
33
+ * (loud: the draft's `var(--…)` references fall to browser initial
34
+ * values — visibly broken, per CLAUDE.md §2 no-fallbacks). */
35
+ readonly theme?: ComposeTheme;
36
+ /** Resolved web fonts; undefined = system stacks only. */
37
+ readonly fonts?: ComposeFonts;
38
+ /** Document title shown in the iframe (accessibility). */
39
+ readonly title: string;
40
+ }
41
+
42
+ export interface DesignDraftShellOutput {
43
+ readonly html: string;
44
+ /** `theme-asset-unbound:<slot>` markers, mirroring the template
45
+ * engine's failure-marker contract. */
46
+ readonly missingSlots: readonly string[];
47
+ }
48
+
49
+ /**
50
+ * Resolve `{{theme_*_url}}` placeholders in a draft fragment. Bound
51
+ * slots substitute their URL; a placeholder on an unbound slot stays
52
+ * loud-raw IN PLACE (placeholders usually sit inside attribute values,
53
+ * where an appended comment would corrupt the URL string — PR-378
54
+ * review) and lands in `missingSlots`; the caller appends the real
55
+ * `caelo:missing` comment nodes at the end of the body so the marker
56
+ * tooling still sees them. Other `{{…}}` sequences are left untouched:
57
+ * a draft is not a fielded module.
58
+ */
59
+ function substituteThemeAssetUrls(
60
+ html: string,
61
+ theme: ComposeTheme | undefined,
62
+ missing: string[],
63
+ ): string {
64
+ return html.replace(/\{\{\s*(theme_[a-z_]+)\s*\}\}/g, (match, key: string) => {
65
+ const slot = THEME_ASSET_KEY_TO_SLOT[key as keyof typeof THEME_ASSET_KEY_TO_SLOT];
66
+ if (slot === undefined) return match;
67
+ const url = theme?.assets[slot]?.url;
68
+ if (url === undefined) {
69
+ missing.push(`theme-asset-unbound:${slot}`);
70
+ return match;
71
+ }
72
+ return url;
73
+ });
74
+ }
75
+
76
+ export function composeDesignDraftShell(input: DesignDraftShellInput): DesignDraftShellOutput {
77
+ const missing: string[] = [];
78
+ const substituted = substituteThemeAssetUrls(input.fragmentHtml, input.theme, missing);
79
+ // Real comment NODES, after the fragment — never inside an attribute.
80
+ const markers = [...new Set(missing)].map((m) => caeloMissingComment(m)).join("");
81
+ const body = substituted + markers;
82
+
83
+ // Head order mirrors composePagePreview: fonts first (URL discovery),
84
+ // then theme vars (so fragment CSS can `var(--color-primary)`), then
85
+ // the technical baseline.
86
+ const head: string[] = [
87
+ `<meta charset="utf-8">`,
88
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`,
89
+ `<title>${escapeHtml(input.title)}</title>`,
90
+ ];
91
+ const fonts = fontsHeadFragment(input.fonts);
92
+ if (fonts !== null) head.push(fonts);
93
+ const themeCss = input.theme !== undefined ? renderThemeCss(input.theme.tokens) : null;
94
+ if (themeCss !== null) head.push(`<style data-source="theme">${themeCss}</style>`);
95
+ head.push(`<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
96
+
97
+ return {
98
+ html: `<!doctype html>\n<html><head>${head.join("")}</head><body>${body}</body></html>`,
99
+ missingSlots: missing,
100
+ };
101
+ }
102
+
103
+ function escapeHtml(s: string): string {
104
+ return s
105
+ .replaceAll("&", "&amp;")
106
+ .replaceAll("<", "&lt;")
107
+ .replaceAll(">", "&gt;")
108
+ .replaceAll('"', "&quot;");
109
+ }
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { z } from "zod";
3
+
4
+ export const fontRef = z
5
+ .object({ id: z.string().uuid(), sha256: z.string().regex(/^[a-f0-9]{64}$/) })
6
+ .strict();
7
+ export const fontLicense = z
8
+ .object({
9
+ name: z.string().min(1).max(200),
10
+ text: z.string().min(1).max(100_000),
11
+ webEmbedding: z.boolean(),
12
+ documentEmbedding: z.boolean(),
13
+ })
14
+ .strict();
15
+ export const fontMetadata = fontRef.extend({
16
+ family: z.string(),
17
+ subfamily: z.string(),
18
+ postscriptName: z.string(),
19
+ cssFamily: z.string(),
20
+ format: z.enum(["ttf", "otf", "woff", "woff2"]),
21
+ weight: z.number(),
22
+ style: z.enum(["normal", "italic"]),
23
+ axes: z.record(z.string(), z.object({ min: z.number(), max: z.number(), default: z.number() })),
24
+ sizeBytes: z.number(),
25
+ glyphCount: z.number(),
26
+ embedding: z.object({
27
+ web: z.boolean(),
28
+ document: z.boolean(),
29
+ subset: z.boolean(),
30
+ restriction: z.string().nullable(),
31
+ }),
32
+ license: fontLicense,
33
+ source: z.string(),
34
+ createdAt: z.string(),
35
+ });
36
+ export type FontRef = z.infer<typeof fontRef>;
37
+ export type FontMetadata = z.infer<typeof fontMetadata>;
38
+ export const fontImportInput = z
39
+ .object({
40
+ dataBase64: z.string().min(1).max(11_184_812),
41
+ license: fontLicense,
42
+ source: z.string().min(1).max(1000),
43
+ })
44
+ .strict();
45
+ export const fontResolveInput = fontRef
46
+ .extend({
47
+ use: z.enum(["web", "document"]),
48
+ text: z.string().max(200_000).default(""),
49
+ formats: z
50
+ .array(z.enum(["ttf", "otf", "woff", "woff2"]))
51
+ .min(1)
52
+ .max(4),
53
+ })
54
+ .strict();
55
+ export const fontReadInput = fontRef
56
+ .extend({ offset: z.number().int().min(0), length: z.number().int().min(1).max(262144) })
57
+ .strict();
58
+ export const fontFindInput = z
59
+ .object({
60
+ query: z.string().max(200).default(""),
61
+ limit: z.number().int().min(1).max(100).default(50),
62
+ })
63
+ .strict();