@caelo-cms/shared 0.11.0 → 0.11.2

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 +32 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +46 -139
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +46 -3
  6. package/dist/bug-intake.d.ts.map +1 -1
  7. package/dist/bug-intake.js +52 -3
  8. package/dist/bug-intake.js.map +1 -1
  9. package/dist/build-page.d.ts +5 -5
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +14 -9
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +14 -2
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +73 -15
  16. package/dist/content.js.map +1 -1
  17. package/dist/css-scope-scan.js +1 -1
  18. package/dist/css-var-scan.js +3 -3
  19. package/dist/css-var-scan.js.map +1 -1
  20. package/dist/escaped-line-breaks.d.ts +44 -0
  21. package/dist/escaped-line-breaks.d.ts.map +1 -0
  22. package/dist/escaped-line-breaks.js +203 -0
  23. package/dist/escaped-line-breaks.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/interactions.js +5 -5
  29. package/dist/interactions.js.map +1 -1
  30. package/dist/lists/default-types.d.ts +12 -0
  31. package/dist/lists/default-types.d.ts.map +1 -1
  32. package/dist/lists/default-types.js +36 -0
  33. package/dist/lists/default-types.js.map +1 -1
  34. package/dist/lists/fields.d.ts.map +1 -1
  35. package/dist/lists/fields.js +1 -0
  36. package/dist/lists/fields.js.map +1 -1
  37. package/dist/lists/render-data.d.ts +9 -0
  38. package/dist/lists/render-data.d.ts.map +1 -1
  39. package/dist/lists/render-deps.d.ts +4 -3
  40. package/dist/lists/render-deps.d.ts.map +1 -1
  41. package/dist/lists/render-deps.js +5 -3
  42. package/dist/lists/render-deps.js.map +1 -1
  43. package/dist/lists/resolve.d.ts +13 -0
  44. package/dist/lists/resolve.d.ts.map +1 -1
  45. package/dist/lists/resolve.js +95 -3
  46. package/dist/lists/resolve.js.map +1 -1
  47. package/dist/live-events.d.ts +44 -0
  48. package/dist/live-events.d.ts.map +1 -0
  49. package/dist/live-events.js +128 -0
  50. package/dist/live-events.js.map +1 -0
  51. package/dist/markup-structure.d.ts +20 -2
  52. package/dist/markup-structure.d.ts.map +1 -1
  53. package/dist/markup-structure.js +69 -3
  54. package/dist/markup-structure.js.map +1 -1
  55. package/dist/media.d.ts +15 -0
  56. package/dist/media.d.ts.map +1 -1
  57. package/dist/media.js.map +1 -1
  58. package/dist/module-id-tag.d.ts +4 -1
  59. package/dist/module-id-tag.d.ts.map +1 -1
  60. package/dist/module-id-tag.js +11 -2
  61. package/dist/module-id-tag.js.map +1 -1
  62. package/dist/nested-module-render.d.ts +13 -0
  63. package/dist/nested-module-render.d.ts.map +1 -1
  64. package/dist/nested-module-render.js +34 -0
  65. package/dist/nested-module-render.js.map +1 -1
  66. package/dist/preview-compose.d.ts +18 -1
  67. package/dist/preview-compose.d.ts.map +1 -1
  68. package/dist/preview-compose.js +26 -7
  69. package/dist/preview-compose.js.map +1 -1
  70. package/dist/redirect-plan.d.ts +13 -0
  71. package/dist/redirect-plan.d.ts.map +1 -1
  72. package/dist/redirect-plan.js +22 -0
  73. package/dist/redirect-plan.js.map +1 -1
  74. package/dist/seo.d.ts +21 -0
  75. package/dist/seo.d.ts.map +1 -1
  76. package/dist/seo.js +32 -0
  77. package/dist/seo.js.map +1 -1
  78. package/dist/source-text-transport.d.ts +28 -2
  79. package/dist/source-text-transport.d.ts.map +1 -1
  80. package/dist/source-text-transport.js +12 -4
  81. package/dist/source-text-transport.js.map +1 -1
  82. package/dist/template-sections.d.ts.map +1 -1
  83. package/dist/template-sections.js +17 -4
  84. package/dist/template-sections.js.map +1 -1
  85. package/dist/theme-render.js +11 -26
  86. package/dist/theme-render.js.map +1 -1
  87. package/dist/themes.d.ts.map +1 -1
  88. package/dist/themes.js +12 -9
  89. package/dist/themes.js.map +1 -1
  90. package/dist/version.d.ts +2 -2
  91. package/dist/version.js +1 -1
  92. package/package.json +1 -1
  93. package/src/__tests__/redos-hardening.test.ts +1 -0
  94. package/src/ai-tools-add-module-modes.test.ts +1 -1
  95. package/src/ai-tools.test.ts +7 -20
  96. package/src/ai-tools.ts +57 -151
  97. package/src/bug-intake.test.ts +48 -1
  98. package/src/bug-intake.ts +58 -3
  99. package/src/build-page.ts +14 -9
  100. package/src/content.test.ts +26 -1
  101. package/src/content.ts +84 -16
  102. package/src/css-scope-scan.ts +1 -1
  103. package/src/css-var-scan.ts +3 -3
  104. package/src/escaped-line-breaks.ts +221 -0
  105. package/src/index.ts +2 -0
  106. package/src/interactions.ts +5 -5
  107. package/src/link-refs.test.ts +1 -0
  108. package/src/lists/default-types.ts +39 -0
  109. package/src/lists/fields.ts +1 -0
  110. package/src/lists/list-fixtures.test.ts +9 -3
  111. package/src/lists/render-data.ts +9 -0
  112. package/src/lists/render-deps.ts +6 -3
  113. package/src/lists/resolve.test.ts +182 -5
  114. package/src/lists/resolve.ts +101 -3
  115. package/src/live-events.ts +138 -0
  116. package/src/markup-structure.test.ts +47 -0
  117. package/src/markup-structure.ts +74 -3
  118. package/src/media.ts +12 -0
  119. package/src/module-id-tag.test.ts +38 -0
  120. package/src/module-id-tag.ts +11 -2
  121. package/src/nested-module-render.test.ts +39 -0
  122. package/src/nested-module-render.ts +35 -0
  123. package/src/preview-compose-deferrals.test.ts +44 -0
  124. package/src/preview-compose.ts +47 -10
  125. package/src/redirect-plan.test.ts +25 -0
  126. package/src/redirect-plan.ts +24 -0
  127. package/src/seo.test.ts +58 -0
  128. package/src/seo.ts +49 -0
  129. package/src/source-text-transport.test.ts +98 -3
  130. package/src/source-text-transport.ts +15 -4
  131. package/src/template-engine-lists.test.ts +55 -0
  132. package/src/template-sections.ts +18 -4
  133. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  134. package/src/theme-render-sanitize.test.ts +8 -0
  135. package/src/theme-render.ts +11 -26
  136. package/src/themes.ts +12 -7
  137. package/src/version.ts +1 -1
package/src/build-page.ts CHANGED
@@ -43,10 +43,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
43
43
  * existing content paths so build_page adds NO new semantics, only batching:
44
44
  *
45
45
  * - `inline` → mint a private (unsynced) content_instance carrying
46
- * `values`, exactly what `set_page_module_content` produces for a
46
+ * `values`, exactly what `set_page_module_content_many` produces for a
47
47
  * fresh placement.
48
48
  * - `shared` → mint a reusable content_instance (purpose required —
49
- * same decision-support contract as `create_content_instance`) and
49
+ * same decision-support contract as `create_content_instances`) and
50
50
  * bind this placement to it, `synced` by default.
51
51
  * - `existing` → bind an already-minted content_instance (reuse-first
52
52
  * per CLAUDE.md §1A) — the batched form of `set_placement_content`.
@@ -94,7 +94,7 @@ export type BuildPageContent = z.infer<typeof buildPageContentSchema>;
94
94
  * extra authoring keys are TOLERATED (§1A/§11 — a placement that can
95
95
  * succeed must not fail over an extra field) but NOT applied; the op
96
96
  * surfaces an info naming any carried field whose value differs from the
97
- * module's stored one, pointing at edit_module (§2 — no silent drop).
97
+ * module's stored one, pointing at update_modules_many (§2 — no silent drop).
98
98
  *
99
99
  * The element-level superRefine reports issues WITH the element's array
100
100
  * index in the Zod path, so a mode error fails as `modules[3]: …`.
@@ -159,9 +159,9 @@ export const buildPageModuleSchema = z
159
159
  description: z.string().max(1000).optional(),
160
160
  kind: moduleKindSchema.optional(),
161
161
  type: slugSchema.optional(),
162
- html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX)).optional(),
163
- css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
164
- js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
162
+ html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX), "html").optional(),
163
+ css: sourceTextArg(z.string().max(MODULE_CSS_MAX), "css").optional(),
164
+ js: sourceTextArg(z.string().max(MODULE_JS_MAX), "js").optional(),
165
165
  fields: z.array(moduleFieldSchema).max(64).optional(),
166
166
  /** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
167
167
  bindThemeLiterals: z.boolean().optional(),
@@ -192,7 +192,7 @@ export const buildPageModuleSchema = z
192
192
  // (build_page places; it does not re-author a shared module). To avoid a
193
193
  // SILENT dropped change (§2), the HANDLER surfaces an INFO in the result
194
194
  // naming any ignored authoring field whose value DIFFERS from the module's
195
- // stored one, and points at edit_module. The schema can't do that check (no
195
+ // stored one, and points at update_modules_many. The schema can't do that check (no
196
196
  // DB), so it just allows the entry through.
197
197
  if (entry.moduleId !== undefined) return;
198
198
  if (entry.displayName === undefined || entry.html === undefined) {
@@ -220,7 +220,7 @@ export const buildPageTargetSchema = z
220
220
  pageId: z.string().uuid().optional(),
221
221
  slug: slugSchema.optional(),
222
222
  title: z.string().min(1).max(256).optional(),
223
- name: z.string().min(1).max(256).optional(),
223
+ shortTitle: pageCreateSchema.shape.shortTitle,
224
224
  templateId: z.string().uuid().optional(),
225
225
  status: pageStatusSchema.optional(),
226
226
  /**
@@ -268,7 +268,7 @@ export const buildPageTargetSchema = z
268
268
  [
269
269
  "slug",
270
270
  "title",
271
- "name",
271
+ "shortTitle",
272
272
  "templateId",
273
273
  "status",
274
274
  "parentPageId",
@@ -283,6 +283,11 @@ export const buildPageTargetSchema = z
283
283
  code: z.ZodIssueCode.custom,
284
284
  message:
285
285
  `pageId targets an EXISTING page — drop ${createKeys.join(", ")}. ` +
286
+ // #1152 — status is a create-time field; publishing an existing
287
+ // page has its own tool.
288
+ (createKeys.includes("status")
289
+ ? "`status` only applies to a page this call creates; publish an existing page with set_pages_status_many. "
290
+ : "") +
286
291
  "To create a new page instead, omit pageId and pass slug + title.",
287
292
  });
288
293
  }
@@ -91,7 +91,32 @@ describe("moduleCreateSchema", () => {
91
91
  expect(moduleCreateSchema.safeParse({ ...base, js: 'xml += "]]>";' }).success).toBe(true);
92
92
  });
93
93
 
94
- it("rejects CDATA on the update path too (edit_module / edit_content funnel)", () => {
94
+ // e2e-livedit run 40532297620 — html / css whose line breaks arrived as
95
+ // literal `\n` sequences rendered `\n` text and broke the CSS. AI tool
96
+ // arguments are decoded first (`decodeEscapedLineBreaks`); every other
97
+ // writer is refused with the fix.
98
+ it("rejects html and css whose line breaks are escaped, with the fix", () => {
99
+ const base = { slug: "site-footer", displayName: "Site footer", html: "<footer></footer>" };
100
+ const html = moduleCreateSchema.safeParse({
101
+ ...base,
102
+ html: '<footer class="f">\\n <nav>\\n <a href="/">Home</a>\\n </nav>\\n</footer>',
103
+ });
104
+ expect(html.success).toBe(false);
105
+ if (!html.success) {
106
+ expect(html.error.issues.map((i) => i.message).join(" ")).toContain("real line breaks");
107
+ }
108
+ const css = moduleUpdateSchema.safeParse({
109
+ moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
110
+ css: ".f {\\n padding: 2rem;\\n}\\n.f a {\\n color: inherit;\\n}",
111
+ });
112
+ expect(css.success).toBe(false);
113
+ // One escaped newline inside a CSS string on a single line is not the pattern.
114
+ expect(
115
+ moduleCreateSchema.safeParse({ ...base, css: '.f::after { content: "\\n " }' }).success,
116
+ ).toBe(true);
117
+ });
118
+
119
+ it("rejects CDATA on the update path too (update_modules_many / edit_content funnel)", () => {
95
120
  const r = moduleUpdateSchema.safeParse({
96
121
  moduleId: "8c2f2f2a-0000-4000-8000-000000000432",
97
122
  css: "/*<![CDATA[*/ .x{} /*]]>*/",
package/src/content.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import { z } from "zod";
14
+ import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
15
+ import { DEFAULT_LIST_TYPES, SITE_TEXTS_LIST_TYPE } from "./lists/default-types.js";
14
16
  import { listItemInputSchema } from "./lists/items.js";
15
17
  import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
16
18
  import { LIST_REF_RE } from "./lists/refs.js";
@@ -126,14 +128,34 @@ export function htmlEscapedMarkupMessage(field: string, value: string): string |
126
128
  return `${field} is HTML-escaped (&lt;…&gt; instead of <…>) — send the markup itself, unescaped.`;
127
129
  }
128
130
 
129
- const noCorruptMarkup = (field: string) => (value: string, ctx: z.RefinementCtx) => {
130
- const message = jsonEncodedCodeMessage(field, value) ?? htmlEscapedMarkupMessage(field, value);
131
+ /**
132
+ * See {@link jsonEncodedCodeMessage}: source whose line breaks arrived as
133
+ * literal `\n` sequences (`hasEscapedLineBreaks`) — the page shows the
134
+ * `\n` text and the CSS rules after it break. AI tool arguments are decoded
135
+ * first (`decodeEscapedLineBreaks`); this guards every other writer. Module
136
+ * js is checked by parsing it (admin-core `module-js-syntax.ts`), which
137
+ * catches this and every other syntax error.
138
+ */
139
+ export function escapedLineBreaksMessage(field: "html" | "css", value: string): string | null {
140
+ if (!hasEscapedLineBreaks(value, field)) return null;
141
+ return `${field} has its line breaks escaped (literal \\n sequences, no real line break) — send the ${field} with real line breaks.`;
142
+ }
143
+
144
+ const noCorruptMarkup = (field: "html") => (value: string, ctx: z.RefinementCtx) => {
145
+ const message =
146
+ jsonEncodedCodeMessage(field, value) ??
147
+ htmlEscapedMarkupMessage(field, value) ??
148
+ escapedLineBreaksMessage(field, value);
131
149
  if (message) ctx.addIssue({ code: "custom", message });
132
150
  };
133
151
  const noJsonEncodedCode = (field: string) => (value: string, ctx: z.RefinementCtx) => {
134
152
  const message = jsonEncodedCodeMessage(field, value);
135
153
  if (message) ctx.addIssue({ code: "custom", message });
136
154
  };
155
+ const noCorruptCss = (value: string, ctx: z.RefinementCtx) => {
156
+ const message = jsonEncodedCodeMessage("css", value) ?? escapedLineBreaksMessage("css", value);
157
+ if (message) ctx.addIssue({ code: "custom", message });
158
+ };
137
159
 
138
160
  const moduleHtml = z
139
161
  .string()
@@ -168,7 +190,7 @@ const moduleCss = z
168
190
  .string()
169
191
  .max(MODULE_CSS_MAX, `css exceeds ${MODULE_CSS_MAX} bytes`)
170
192
  .refine(noCdata, cdataMessage("css"))
171
- .superRefine(noJsonEncodedCode("css"));
193
+ .superRefine(noCorruptCss);
172
194
  const moduleJs = z
173
195
  .string()
174
196
  .max(MODULE_JS_MAX, `js exceeds ${MODULE_JS_MAX} bytes`)
@@ -276,7 +298,7 @@ const moduleFieldModuleSchema = z
276
298
  * stable `modules.type` class, e.g. `button` — NOT the unique
277
299
  * `modules.slug` like `button-mpqxq3ch`). When absent, any module is
278
300
  * permitted. The op-layer validator enforces the whitelist against
279
- * the referenced module's `type` at `set_content_instance_values`
301
+ * the referenced module's `type` at `content_instances.set_values`
280
302
  * time. v0.12.3 (issue #106) renamed this from `allowedModuleSlugs`:
281
303
  * an exact-slug whitelist could never match an AI-minted module
282
304
  * because every minted slug carries a uniqueness suffix.
@@ -309,13 +331,31 @@ const moduleFieldModuleListSchema = z
309
331
  * written, and the renderer reports `list-type-missing:<slug>` for a type
310
332
  * that is gone.
311
333
  */
334
+ /** The shipped list types, quoted, for the missing-listType message. */
335
+ const SHIPPED_LIST_TYPE_SLUGS = [...DEFAULT_LIST_TYPES.map((t) => t.slug), SITE_TEXTS_LIST_TYPE]
336
+ .map((slug) => `"${slug}"`)
337
+ .join(", ");
338
+
312
339
  const moduleFieldListSchema = z
313
340
  .object({
314
341
  name: moduleFieldName,
315
342
  kind: z.literal("list"),
316
343
  label: moduleFieldLabel,
317
- /** Slug of the list type that defines the entries. */
318
- listType: slugSchema,
344
+ /**
345
+ * Slug of the list type that defines the entries. REQUIRED — a missing
346
+ * one says which types exist (#1190), not just "expected string".
347
+ */
348
+ listType: z
349
+ .string({
350
+ error: (iss) =>
351
+ iss.input === undefined
352
+ ? `a list field needs listType: ${SHIPPED_LIST_TYPE_SLUGS} or a custom type's slug (list_list_types shows them)`
353
+ : undefined,
354
+ })
355
+ .regex(
356
+ /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/,
357
+ "slug must be lowercase letters, digits, or hyphens (1–64 chars, no leading/trailing hyphen)",
358
+ ),
319
359
  bind: z.enum(["local", "shared", "source"]).optional(),
320
360
  /** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
321
361
  list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
@@ -491,7 +531,7 @@ export type ModuleKind = (typeof MODULE_KINDS)[number];
491
531
  /**
492
532
  * v0.12.0 — description is `.default("")` at the Zod boundary so the
493
533
  * 82+ legacy callers in tests/seed scripts keep working unchanged.
494
- * AI tool descriptions (create_module / edit_module) require the AI
534
+ * AI tool descriptions (add_module / build_page / update_modules_many) require the AI
495
535
  * to supply a real description explicitly — see CLAUDE.md §1A.
496
536
  */
497
537
  const moduleDescription = z.string().max(1000);
@@ -627,6 +667,15 @@ export const templateBlocksSetSchema = z
627
667
 
628
668
  export const pageStatusSchema = z.enum(["draft", "published"]);
629
669
 
670
+ /** `pages.short_title` (#1142): a crumb-sized public label, never blank. */
671
+ export const pageShortTitleSchema = z
672
+ .string()
673
+ .trim()
674
+ .min(1, "the short title is the page's breadcrumb label and cannot be blank")
675
+ // Same bound as the title it is backfilled and defaulted from, so every
676
+ // stored page stays editable; keeping it crumb-sized is guidance.
677
+ .max(256);
678
+
630
679
  /**
631
680
  * Page payloads are `.strict()` — passing an `html` field is rejected by Zod
632
681
  * before the handler runs. This is the §3.1 "no raw HTML on pages" invariant
@@ -636,9 +685,13 @@ export const pageStatusSchema = z.enum(["draft", "published"]);
636
685
  export const pageCreateSchema = z
637
686
  .object({
638
687
  slug: slugSchema,
639
- /** P6.7.5 — internal editor label. Defaults to title if omitted. */
640
- name: z.string().min(1).max(256).optional(),
641
688
  title: z.string().min(1).max(256),
689
+ /**
690
+ * #1142 — the page's public label in breadcrumbs, navigation and the
691
+ * page picker ("Pricing" for "Pricing for teams of every size").
692
+ * Omitted = the create-time resolver stores `title` (stored data).
693
+ */
694
+ shortTitle: pageShortTitleSchema.optional(),
642
695
  /**
643
696
  * P6.7.6 — optional. When omitted, the handler resolves to
644
697
  * `site_defaults.default_template_id` at create time. Stored data,
@@ -703,9 +756,9 @@ export const pageUpdateSchema = z
703
756
  * Routes that load the page first should always pass it back.
704
757
  */
705
758
  expectedVersion: z.number().int().nonnegative().optional(),
706
- /** P6.7.5 — three independently-editable identifiers. */
707
- name: z.string().min(1).max(256).optional(),
708
759
  title: z.string().min(1).max(256).optional(),
760
+ /** #1142 — the breadcrumb / navigation label; see {@link pageCreateSchema}. */
761
+ shortTitle: pageShortTitleSchema.optional(),
709
762
  slug: slugSchema.optional(),
710
763
  templateId: z.string().uuid().optional(),
711
764
  status: pageStatusSchema.optional(),
@@ -743,8 +796,23 @@ export const pageSetModulesSchema = z
743
796
  .object({
744
797
  blockName: slugSchema,
745
798
  moduleIds: z.array(z.string().uuid()),
799
+ /**
800
+ * #1195 — the placement each entry of `moduleIds` IS, by its
801
+ * content instance (same index; null = a new placement). Given,
802
+ * a placement keeps its content wherever it moves — across
803
+ * blocks too — and two placements of one module never trade
804
+ * content. Omitted, bindings carry over per (block, module) in
805
+ * order, which cannot tell repeated placements apart.
806
+ */
807
+ contentInstanceIds: z.array(z.string().uuid().nullable()).optional(),
746
808
  })
747
- .strict(),
809
+ .strict()
810
+ .refine(
811
+ (b) =>
812
+ b.contentInstanceIds === undefined ||
813
+ b.contentInstanceIds.length === b.moduleIds.length,
814
+ { message: "contentInstanceIds needs one entry (or null) per moduleIds entry" },
815
+ ),
748
816
  ),
749
817
  })
750
818
  .strict();
@@ -780,10 +848,10 @@ const contentValuesSchema = z.record(z.string(), z.unknown());
780
848
 
781
849
  /**
782
850
  * v0.12.0 — why this row exists as a shared/reusable instance.
783
- * Surfaced in the `## Content Library` system-prompt block so the AI
784
- * can decide *reuse the synced row* vs *fork to unsynced* vs *mint
785
- * new* without a tool round-trip. Required by the AI's
786
- * create_content_instance tool description; legacy callers may pass
851
+ * Shown by the AI's list_content_instances tool so the AI can decide
852
+ * *reuse the synced row* vs *fork to unsynced* vs *mint new* without
853
+ * asking the operator. Required by the AI's
854
+ * create_content_instances tool description; legacy callers may pass
787
855
  * null (the migration-0093 unsynced rows have purpose=NULL).
788
856
  * See CLAUDE.md §1A.
789
857
  */
@@ -7,7 +7,7 @@
7
7
  * that is not tied to the module bleeds into every other module on the
8
8
  * page: an `h1 {}` in the hero restyles the pricing table's heading, a
9
9
  * `.card {}` in one module restyles another module's cards. The
10
- * convention (edit_module description, design-quality skill) is to nest
10
+ * convention (update_modules_many description, design-quality skill) is to nest
11
11
  * module CSS under the module's own root class; this scanner is the
12
12
  * mechanical write-time check that rides the authoring tool's result,
13
13
  * so the AI fixes the selector in the same turn. A warning, not a gate:
@@ -143,11 +143,11 @@ export function formatUnknownCssVarWarning(unknown: readonly UnknownCssVar[]): s
143
143
  const hint = u.suggestion !== null ? ` (did you mean \`${u.suggestion}\`?)` : "";
144
144
  const fallback = u.hasFallback
145
145
  ? " — its hardcoded fallback is what visitors will see, detached from the theme"
146
- : " — the declaration is invalid and the property won't apply";
146
+ : " — nothing defines it, so the property won't apply";
147
147
  return `\`${u.name}\`${hint}${fallback}`;
148
148
  });
149
149
  return (
150
- `⚠️ Unknown CSS vars (the active theme does not emit them): ${parts.join("; ")}. ` +
151
- "Use the exact names from the `## Theme` inventory, or define the custom property in this CSS if it's intentional."
150
+ `⚠️ Unknown CSS vars (neither the active theme nor any layout or template CSS defines them): ${parts.join("; ")}. ` +
151
+ "Use the exact token names (`get_theme`), or define the custom property in this CSS if it's intentional."
152
152
  );
153
153
  }
@@ -0,0 +1,221 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Source text whose line breaks arrived string-escaped as a whole.
5
+ *
6
+ * e2e-livedit run 40532297620 (release gate v0.11.1): `add_module`'s html,
7
+ * css and js came in with every line break as the two characters `\` `n`
8
+ * (`(function () {\n document…`). Stored as sent, the footer showed `\n`
9
+ * text, its CSS broke, and the page's shared module `<script>` threw
10
+ * `SyntaxError: Invalid or unexpected token` — a backslash outside a string
11
+ * is not JavaScript. Like the other whole-value transport encodings
12
+ * (`source-text-transport.ts`) the AI tool boundary decodes it, once.
13
+ *
14
+ * The same two characters are legitimate inside a string literal (`"a\nb"`
15
+ * in JS or a CSS `content`), a regex, an HTML attribute value, or the body
16
+ * of a raw-text element. A value is only decoded when EVERY escaped line
17
+ * break sits where a real line break could have stood — between tokens of
18
+ * code, or in markup text between tags. One escape inside a literal makes
19
+ * the value ambiguous and it is left exactly as sent: decoding it would
20
+ * turn valid source into broken source (a raw newline inside a JS string
21
+ * is a syntax error). The op validators still refuse what is left broken
22
+ * (module js is parsed in admin-core `module-js-syntax.ts`).
23
+ */
24
+
25
+ /** Which grammar a source value is written in; decides what counts as a literal. */
26
+ export type SourceLanguage = "html" | "css" | "js";
27
+
28
+ /** `\n` or `\r\n` escape starting at `i` (a single backslash, not an escaped one). */
29
+ function escapedLineBreakLength(value: string, i: number): number {
30
+ if (value[i] !== "\\") return 0;
31
+ if (value[i + 1] === "n") return 2;
32
+ if (value[i + 1] === "r" && value[i + 2] === "\\" && value[i + 3] === "n") return 4;
33
+ return 0;
34
+ }
35
+
36
+ /** Characters after which a `/` in JS starts a regex literal rather than a division. */
37
+ const REGEX_PRECEDERS = new Set([..."(,=:[!&|?{};+-*%<>~^"]);
38
+
39
+ /**
40
+ * Index just past a quoted literal starting at `start` (its quote char), or
41
+ * -1 when an escaped line break sits inside it.
42
+ */
43
+ function skipQuoted(value: string, start: number): number {
44
+ const quote = value[start];
45
+ let j = start + 1;
46
+ while (j < value.length) {
47
+ const ch = value[j];
48
+ if (ch === "\\") {
49
+ if (value[j + 1] === "n" || value[j + 1] === "r") return -1;
50
+ j += 2;
51
+ continue;
52
+ }
53
+ if (ch === quote) return j + 1;
54
+ j += 1;
55
+ }
56
+ return j;
57
+ }
58
+
59
+ /** True when an escaped line break sits inside a JS string, template, or regex literal. */
60
+ function jsLiteralHoldsEscape(value: string): boolean {
61
+ let prev = "";
62
+ let i = 0;
63
+ while (i < value.length) {
64
+ const ch = value[i] as string;
65
+ const lineBreak = escapedLineBreakLength(value, i);
66
+ if (lineBreak > 0) {
67
+ prev = "";
68
+ i += lineBreak;
69
+ continue;
70
+ }
71
+ if (ch === "\\") {
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch === '"' || ch === "'" || ch === "`") {
76
+ const end = skipQuoted(value, i);
77
+ if (end < 0) return true;
78
+ prev = ch;
79
+ i = end;
80
+ continue;
81
+ }
82
+ if (ch === "/" && value[i + 1] === "/") {
83
+ // A line comment runs to the next (escaped) line break.
84
+ i += 2;
85
+ while (i < value.length && escapedLineBreakLength(value, i) === 0) i += 1;
86
+ continue;
87
+ }
88
+ if (ch === "/" && value[i + 1] === "*") {
89
+ const close = value.indexOf("*/", i + 2);
90
+ i = close < 0 ? value.length : close + 2;
91
+ continue;
92
+ }
93
+ if (ch === "/" && (prev === "" || REGEX_PRECEDERS.has(prev))) {
94
+ let j = i + 1;
95
+ let inClass = false;
96
+ while (j < value.length) {
97
+ const r = value[j];
98
+ if (r === "\\") {
99
+ if (value[j + 1] === "n" || value[j + 1] === "r") return true;
100
+ j += 2;
101
+ continue;
102
+ }
103
+ if (r === "[") inClass = true;
104
+ else if (r === "]") inClass = false;
105
+ else if (r === "/" && !inClass) break;
106
+ j += 1;
107
+ }
108
+ prev = "/";
109
+ i = j + 1;
110
+ continue;
111
+ }
112
+ if (ch !== " " && ch !== "\t") prev = ch;
113
+ i += 1;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** True when an escaped line break sits inside a CSS string. */
119
+ function cssLiteralHoldsEscape(value: string): boolean {
120
+ let i = 0;
121
+ while (i < value.length) {
122
+ const ch = value[i];
123
+ if (ch === "/" && value[i + 1] === "*") {
124
+ const close = value.indexOf("*/", i + 2);
125
+ i = close < 0 ? value.length : close + 2;
126
+ continue;
127
+ }
128
+ if (ch === '"' || ch === "'") {
129
+ const end = skipQuoted(value, i);
130
+ if (end < 0) return true;
131
+ i = end;
132
+ continue;
133
+ }
134
+ i += ch === "\\" ? 2 : 1;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /** Elements whose body is not markup text: an escape there belongs to that body. */
140
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "pre", "textarea"]);
141
+
142
+ /** True when an escaped line break sits inside an attribute value, a comment, or a raw-text element body. */
143
+ function htmlLiteralHoldsEscape(value: string): boolean {
144
+ const lower = value.toLowerCase();
145
+ let i = 0;
146
+ while (i < value.length) {
147
+ if (value[i] !== "<") {
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if (value.startsWith("<!--", i)) {
152
+ const close = value.indexOf("-->", i + 4);
153
+ const end = close < 0 ? value.length : close + 3;
154
+ if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
155
+ i = end;
156
+ continue;
157
+ }
158
+ const name = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(value.slice(i, i + 64))?.[1]?.toLowerCase();
159
+ let j = i + 1;
160
+ while (j < value.length && value[j] !== ">") {
161
+ if (value[j] === '"' || value[j] === "'") {
162
+ const end = skipQuoted(value, j);
163
+ if (end < 0) return true;
164
+ j = end;
165
+ continue;
166
+ }
167
+ j += 1;
168
+ }
169
+ i = j + 1;
170
+ if (name && RAW_TEXT_ELEMENTS.has(name)) {
171
+ const close = lower.indexOf(`</${name}`, i);
172
+ const end = close < 0 ? value.length : close;
173
+ if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
174
+ i = end;
175
+ }
176
+ }
177
+ return false;
178
+ }
179
+
180
+ const LITERAL_HOLDS_ESCAPE: Readonly<Record<SourceLanguage, (value: string) => boolean>> = {
181
+ html: htmlLiteralHoldsEscape,
182
+ css: cssLiteralHoldsEscape,
183
+ js: jsLiteralHoldsEscape,
184
+ };
185
+
186
+ /** An escaped line break (`\n`, `\r\n`) not preceded by another backslash. */
187
+ const ESCAPED_LINE_BREAK = /(?<!\\)(?:\\r)?\\n/g;
188
+
189
+ /**
190
+ * True when a source value's line breaks were string-escaped as a whole:
191
+ * no real line break at all, at least two escaped ones, one of them
192
+ * followed by indentation (the signature of pretty-printed source), and
193
+ * none inside a literal of `language` (see the file comment).
194
+ *
195
+ * @param value - the source text as sent.
196
+ * @param language - the grammar the value is written in.
197
+ */
198
+ export function hasEscapedLineBreaks(value: string, language: SourceLanguage): boolean {
199
+ if (/[\r\n]/.test(value)) return false;
200
+ const hits = value.match(ESCAPED_LINE_BREAK)?.length ?? 0;
201
+ if (hits < 2) return false;
202
+ if (!/(?<!\\)\\n(?:[ \t]|\\t)/.test(value)) return false;
203
+ return !LITERAL_HOLDS_ESCAPE[language](value);
204
+ }
205
+
206
+ /**
207
+ * The value with every escaped line break turned back into a real one (and
208
+ * the escaped tabs indenting the next line into real tabs) when
209
+ * `hasEscapedLineBreaks` holds; the input unchanged otherwise. Nothing else
210
+ * is unescaped: quotes and backslashes arrived as they were meant.
211
+ *
212
+ * @param value - the tool argument (non-strings pass through).
213
+ * @param language - the grammar the value is written in.
214
+ */
215
+ export function decodeEscapedLineBreaks(value: unknown, language: SourceLanguage): unknown {
216
+ if (typeof value !== "string" || !hasEscapedLineBreaks(value, language)) return value;
217
+ return value.replace(
218
+ /(?<!\\)(?:\\r)?\\n((?:\\t)*)/g,
219
+ (_m, tabs: string) => `\n${"\t".repeat(tabs.length / 2)}`,
220
+ );
221
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export * from "./database-url.js";
20
20
  export * from "./design-draft-shell.js";
21
21
  export * from "./document-language.js";
22
22
  export * from "./edit-markers.js";
23
+ export * from "./escaped-line-breaks.js";
23
24
  export * from "./font-assets.js";
24
25
  export * from "./font-axis-policy.js";
25
26
  export * from "./font-usage.js";
@@ -34,6 +35,7 @@ export * from "./interactions.js";
34
35
  export * from "./link-canonicalise.js";
35
36
  export * from "./link-refs.js";
36
37
  export * from "./lists/index.js";
38
+ export * from "./live-events.js";
37
39
  export {
38
40
  type LogContext,
39
41
  type Logger,
@@ -195,9 +195,9 @@ export function interactionAssets(used: ReadonlySet<InteractionId>): {
195
195
  * guidance cannot drift from the snippets above.
196
196
  */
197
197
  export const INTERACTION_MARKUP_CONTRACT =
198
- '**Vetted interactions - use these instead of writing JS for them.** Add `data-caelo-ui="<id>"` to the markup and Caelo ships accessible, dependency-free behaviour once per page; style it freely in the module CSS. ' +
199
- '`accordion` (FAQ, collapsible sections): `<div data-caelo-ui="accordion" data-single>` containing `<details id="..."><summary>Question</summary><div>Answer</div></details>` items - `data-single` keeps one open; a link to `#<details id>` opens it. ' +
200
- '`tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div>` - ids must be unique on the page. ' +
201
- '`carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">&lsaquo;</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">&rsaquo;</span></button></section>` (an icon button keeps its visible content aria-hidden; a text button needs no aria-label) - one slide per view by default; set the slides\' `flex-basis` in module CSS to show more. ' +
202
- '`mobile-nav` (menu collapsing behind a toggle on small screens): `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false"><button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span></button><ul>{{#menu}}<li>…</li>{{/menu}}</ul></nav>` around the module\'s menu `list` field. ' +
198
+ "`data-caelo-ui` kit — use it instead of JS for these (accessible, shipped once per page, style freely in module CSS):\n" +
199
+ '- `accordion` (FAQ, collapsible): `<div data-caelo-ui="accordion" data-single>` with `<details id="..."><summary>Question</summary><div>Answer</div></details>` items; `data-single` keeps one open; a link to `#<details id>` opens it.\n' +
200
+ '- `tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div>`; ids unique on the page.\n' +
201
+ '- `carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">&lsaquo;</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">&rsaquo;</span></button></section>`; icon buttons keep visible content aria-hidden, text buttons need no aria-label. One slide per view; set the slides\' `flex-basis` in module CSS to show more.\n' +
202
+ '- `mobile-nav` (menu collapsing behind a toggle on small screens): `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false"><button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu"><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span></button><ul>{{#menu}}<li>…</li>{{/menu}}</ul></nav>` around the module\'s menu `list` field.\n' +
203
203
  "Write module JS only for behaviour the kit does not cover.";
@@ -39,6 +39,7 @@ const lists = createListRenderContext({
39
39
  pages: new Map(),
40
40
  media: new Map(),
41
41
  siteLanguage: "en",
42
+ homePageId: null,
42
43
  }).forPage(null);
43
44
  const uid = (n: number) => `00000000-0000-4000-9000-${String(n).padStart(12, "0")}`;
44
45
  const links = (...ls: [string, string][]) =>
@@ -100,3 +100,42 @@ export const DEFAULT_LIST_TYPES: readonly ListTypeDef[] = [
100
100
  },
101
101
  },
102
102
  ];
103
+
104
+ /** Slug of the list type whose entries are the site texts (`{{site_text.<key>}}`, #1142). */
105
+ export const SITE_TEXTS_LIST_TYPE = "site-texts";
106
+
107
+ /**
108
+ * The site texts type: fixed interface strings of the website that are no
109
+ * page content ("Start" in a trail, the breadcrumb `aria-label`, "Read
110
+ * more"). Module HTML reads one by key with `{{site_text.<key>}}`, so the
111
+ * string is translatable (shared-list overlays) instead of frozen into
112
+ * the module. Seeded by migration 0358 together with the "Site texts"
113
+ * list; installs that ran 0352 never had it, so it is not part of
114
+ * {@link DEFAULT_LIST_TYPES} (whose seed is pinned to 0352).
115
+ */
116
+ export const SITE_TEXTS_TYPE: ListTypeDef = {
117
+ slug: SITE_TEXTS_LIST_TYPE,
118
+ name: "Site texts",
119
+ purpose:
120
+ "Fixed interface texts of the website, read by key in module HTML with {{site_text.<key>}}: the breadcrumb aria-label, button and link texts that repeat across modules. Not page content.",
121
+ role: "content",
122
+ root: { allow: ["text"] },
123
+ nodes: {
124
+ text: {
125
+ label: "Site text",
126
+ fields: [
127
+ // The lookup key is code, not copy: a translated key would stop
128
+ // `{{site_text.<key>}}` from finding the entry in that language.
129
+ {
130
+ name: "key",
131
+ label: "Key",
132
+ type: "text",
133
+ required: true,
134
+ maxLength: 64,
135
+ ext: { "international-site": { translate: false } },
136
+ },
137
+ { name: "text", label: "Text", type: "text", required: true },
138
+ ],
139
+ },
140
+ },
141
+ };
@@ -50,6 +50,7 @@ export const LIST_RESERVED_NAMES: ReadonlySet<string> = new Set([
50
50
  "last",
51
51
  "items",
52
52
  "page",
53
+ "site_text",
53
54
  "recurse",
54
55
  ]);
55
56
  const RESERVED_PREFIXES = ["is_", "has_", "count_"] as const;