@caelo-cms/shared 0.11.1 → 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 (68) hide show
  1. package/dist/ai-tools.d.ts +2 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +7 -0
  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.map +1 -1
  10. package/dist/build-page.js +5 -0
  11. package/dist/build-page.js.map +1 -1
  12. package/dist/content.d.ts +1 -0
  13. package/dist/content.d.ts.map +1 -1
  14. package/dist/content.js +28 -3
  15. package/dist/content.js.map +1 -1
  16. package/dist/css-var-scan.js +3 -3
  17. package/dist/css-var-scan.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/live-events.d.ts +44 -0
  23. package/dist/live-events.d.ts.map +1 -0
  24. package/dist/live-events.js +128 -0
  25. package/dist/live-events.js.map +1 -0
  26. package/dist/markup-structure.d.ts +20 -2
  27. package/dist/markup-structure.d.ts.map +1 -1
  28. package/dist/markup-structure.js +69 -3
  29. package/dist/markup-structure.js.map +1 -1
  30. package/dist/media.d.ts +15 -0
  31. package/dist/media.d.ts.map +1 -1
  32. package/dist/media.js.map +1 -1
  33. package/dist/module-id-tag.d.ts +4 -1
  34. package/dist/module-id-tag.d.ts.map +1 -1
  35. package/dist/module-id-tag.js +11 -2
  36. package/dist/module-id-tag.js.map +1 -1
  37. package/dist/preview-compose.d.ts +18 -1
  38. package/dist/preview-compose.d.ts.map +1 -1
  39. package/dist/preview-compose.js +26 -7
  40. package/dist/preview-compose.js.map +1 -1
  41. package/dist/theme-render.js +11 -26
  42. package/dist/theme-render.js.map +1 -1
  43. package/dist/themes.d.ts.map +1 -1
  44. package/dist/themes.js +12 -9
  45. package/dist/themes.js.map +1 -1
  46. package/dist/version.d.ts +2 -2
  47. package/dist/version.js +1 -1
  48. package/package.json +1 -1
  49. package/src/ai-tools.ts +10 -0
  50. package/src/bug-intake.test.ts +47 -0
  51. package/src/bug-intake.ts +58 -3
  52. package/src/build-page.ts +5 -0
  53. package/src/content.ts +37 -3
  54. package/src/css-var-scan.ts +3 -3
  55. package/src/index.ts +1 -0
  56. package/src/live-events.ts +138 -0
  57. package/src/markup-structure.test.ts +47 -0
  58. package/src/markup-structure.ts +74 -3
  59. package/src/media.ts +12 -0
  60. package/src/module-id-tag.test.ts +38 -0
  61. package/src/module-id-tag.ts +11 -2
  62. package/src/preview-compose-deferrals.test.ts +44 -0
  63. package/src/preview-compose.ts +47 -10
  64. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  65. package/src/theme-render-sanitize.test.ts +8 -0
  66. package/src/theme-render.ts +11 -26
  67. package/src/themes.ts +12 -7
  68. package/src/version.ts +1 -1
package/src/bug-intake.ts CHANGED
@@ -60,7 +60,41 @@ export const bugIntakeRegisterRequest = z
60
60
  .strict();
61
61
  export type BugIntakeRegisterRequest = z.infer<typeof bugIntakeRegisterRequest>;
62
62
 
63
- /** `POST /v1/reports` — one redacted bug report (signed). */
63
+ /**
64
+ * What a report is about (#1192).
65
+ * defect — something in Caelo misbehaved: a tool contradicted its
66
+ * description, a render contradicted stored state, a call
67
+ * failed. Auto captures and provider errors are always this.
68
+ * suggestion — nothing misbehaved, but Caelo lacks something the AI needed:
69
+ * a capability, a tool, a docs page. Filed by the AI only.
70
+ * The intake keeps the two apart end to end: own title prefix and label, own
71
+ * daily issue cap, and matching never puts one kind into the other's cluster.
72
+ */
73
+ export const bugReportKind = z.enum(["defect", "suggestion"]);
74
+ export type BugReportKind = z.infer<typeof bugReportKind>;
75
+
76
+ /**
77
+ * The kind of a report that names none. Contract rule (#1192): installs
78
+ * released before `kind` existed send no kind, and everything they could
79
+ * file was a defect, so a missing kind IS `defect`. This is a protocol rule,
80
+ * not a guess: it holds for every protocol-1 report, and the intake applies
81
+ * the same rule to the records it stored before #1192.
82
+ */
83
+ export const BUG_REPORT_KIND_BEFORE_1192: BugReportKind = "defect";
84
+
85
+ /** How badly a defect hurt the task. Defects only: a suggestion has no severity. */
86
+ export const bugReportSeverity = z.enum(["blocking", "degraded", "cosmetic"]);
87
+ export type BugReportSeverity = z.infer<typeof bugReportSeverity>;
88
+
89
+ /**
90
+ * `POST /v1/reports` — one redacted bug report (signed).
91
+ *
92
+ * `kind` is optional on the wire only for the contract rule above; parsing
93
+ * always yields one. `severity` is set for a defect and `null` for a
94
+ * suggestion (nothing broke, so there is nothing to grade), and an
95
+ * auto-captured report is always a defect. A report that breaks either is
96
+ * refused, not repaired: it came from a broken client.
97
+ */
64
98
  export const bugIntakeReport = z
65
99
  .object({
66
100
  protocolVersion: z.literal(BUG_INTAKE_PROTOCOL_VERSION),
@@ -69,7 +103,8 @@ export const bugIntakeReport = z
69
103
  caeloVersion: z.string().min(1).max(64),
70
104
  fingerprint: fingerprintSchema,
71
105
  source: z.enum(["ai", "auto"]),
72
- severity: z.enum(["blocking", "degraded", "cosmetic"]),
106
+ kind: bugReportKind.default(BUG_REPORT_KIND_BEFORE_1192),
107
+ severity: bugReportSeverity.nullable(),
73
108
  blockedTask: z.boolean(),
74
109
  suspectedTool: z.string().max(200).nullable(),
75
110
  title: z.string().min(1).max(BUG_INTAKE_LIMITS.title),
@@ -77,7 +112,27 @@ export const bugIntakeReport = z
77
112
  expected: z.string().min(1).max(BUG_INTAKE_LIMITS.expected),
78
113
  evidence: z.string().max(BUG_INTAKE_LIMITS.evidence).nullable(),
79
114
  })
80
- .strict();
115
+ .strict()
116
+ .superRefine((r, ctx) => {
117
+ if (r.kind === "defect" && r.severity === null) {
118
+ ctx.addIssue({ code: "custom", path: ["severity"], message: "a defect needs a severity" });
119
+ }
120
+ if (r.kind === "suggestion" && r.severity !== null) {
121
+ ctx.addIssue({
122
+ code: "custom",
123
+ path: ["severity"],
124
+ message: "a suggestion has no severity; send null",
125
+ });
126
+ }
127
+ if (r.kind === "suggestion" && r.source === "auto") {
128
+ ctx.addIssue({
129
+ code: "custom",
130
+ path: ["kind"],
131
+ message: "an auto-captured report is always a defect",
132
+ });
133
+ }
134
+ });
135
+ /** A parsed report: `kind` is always present. */
81
136
  export type BugIntakeReport = z.infer<typeof bugIntakeReport>;
82
137
 
83
138
  /**
package/src/build-page.ts CHANGED
@@ -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
  }
package/src/content.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { z } from "zod";
14
14
  import { hasEscapedLineBreaks } from "./escaped-line-breaks.js";
15
+ import { DEFAULT_LIST_TYPES, SITE_TEXTS_LIST_TYPE } from "./lists/default-types.js";
15
16
  import { listItemInputSchema } from "./lists/items.js";
16
17
  import { LIST_MAX_ITEMS, sourceDefSchema } from "./lists/list-type.js";
17
18
  import { LIST_REF_RE } from "./lists/refs.js";
@@ -330,13 +331,31 @@ const moduleFieldModuleListSchema = z
330
331
  * written, and the renderer reports `list-type-missing:<slug>` for a type
331
332
  * that is gone.
332
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
+
333
339
  const moduleFieldListSchema = z
334
340
  .object({
335
341
  name: moduleFieldName,
336
342
  kind: z.literal("list"),
337
343
  label: moduleFieldLabel,
338
- /** Slug of the list type that defines the entries. */
339
- 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
+ ),
340
359
  bind: z.enum(["local", "shared", "source"]).optional(),
341
360
  /** `bind: "shared"` — fix the list for every placement (`caelo:list/<id>`). */
342
361
  list: z.string().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
@@ -777,8 +796,23 @@ export const pageSetModulesSchema = z
777
796
  .object({
778
797
  blockName: slugSchema,
779
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(),
780
808
  })
781
- .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
+ ),
782
816
  ),
783
817
  })
784
818
  .strict();
@@ -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
  }
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from "./interactions.js";
35
35
  export * from "./link-canonicalise.js";
36
36
  export * from "./link-refs.js";
37
37
  export * from "./lists/index.js";
38
+ export * from "./live-events.js";
38
39
  export {
39
40
  type LogContext,
40
41
  type Logger,
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #1215 — the editor's live-refresh vocabulary. CLAUDE.md §11.B:
5
+ * clients must not poll the admin on a timer, because every request keeps an
6
+ * instance up. Instead the admin tells an open editor WHAT changed, as a
7
+ * topic, and the editor re-reads that one surface through its usual read
8
+ * endpoint (`/content/chat/<id>/pending`, `/api/agents`, …).
9
+ *
10
+ * A topic is a hint, never data: it carries no ids, no rows and no actor, so
11
+ * it is safe to hand to every signed-in tab. The read that follows goes
12
+ * through the endpoint's own permission checks.
13
+ *
14
+ * Topics come from the names of operations that committed (the Query API's
15
+ * `observeCommittedOperations`). Only WRITE operations are listed: a read
16
+ * classified as a change would make every refresh announce another change.
17
+ */
18
+
19
+ /** One surface an editor tab may need to re-read. */
20
+ export const LIVE_TOPICS = [
21
+ /** Running agents (turns, subagents, external MCP agents). */
22
+ "agents",
23
+ /** Pending approvals: queued proposals and paused in-chat approval cards. */
24
+ "pending",
25
+ /** One-time credentials an approval left for the operator (#971). */
26
+ "credentials",
27
+ /** Quality-audit status and the Publish gate (#553). */
28
+ "quality",
29
+ /** Site-import crawl progress (#228). */
30
+ "crawl",
31
+ /** Deploy runs (Stage, Publish live, rollback). */
32
+ "deploys",
33
+ ] as const;
34
+
35
+ export type LiveTopic = (typeof LIVE_TOPICS)[number];
36
+
37
+ const TOPIC_SET: ReadonlySet<string> = new Set(LIVE_TOPICS);
38
+
39
+ /** Is `value` a known topic? */
40
+ export function isLiveTopic(value: string): value is LiveTopic {
41
+ return TOPIC_SET.has(value);
42
+ }
43
+
44
+ /**
45
+ * Parse a comma-separated topic list (a query parameter). Unknown names are
46
+ * an error, not dropped: a client asking for a topic the server does not
47
+ * know would otherwise wait for an event that can never come.
48
+ */
49
+ export function parseLiveTopics(
50
+ raw: string | null,
51
+ ): { ok: true; topics: LiveTopic[] } | { ok: false; unknown: string[] } {
52
+ const names = (raw ?? "")
53
+ .split(",")
54
+ .map((s) => s.trim())
55
+ .filter((s) => s.length > 0);
56
+ const unknown = names.filter((n) => !isLiveTopic(n));
57
+ if (unknown.length > 0) return { ok: false, unknown };
58
+ return { ok: true, topics: [...new Set(names)] as LiveTopic[] };
59
+ }
60
+
61
+ // Write ops per topic. Heartbeats (`chat.heartbeat_agent_run`,
62
+ // `chat.renew_turn_lease`) and the audit worker's idle `claim_next` are left
63
+ // out on purpose: they commit on a clock, and a hint on a clock is the very
64
+ // polling this replaces.
65
+ const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
66
+ "chat.register_agent_run": ["agents"],
67
+ "chat.finish_agent_run": ["agents"],
68
+ "chat.cancel_agents": ["agents"],
69
+ "chat.allow_external_agent": ["agents"],
70
+ // An external agent's tool call: first sight of it lists it as running,
71
+ // and it may have queued a card.
72
+ "chat.touch_external_agent": ["agents", "pending"],
73
+ "chat.record_turn_approvals": ["pending"],
74
+ "chat.decide_turn_approval": ["pending"],
75
+ "chat.supersede_turn_approvals": ["pending"],
76
+ "chat.claim_turn_approvals": ["pending"],
77
+ // Proposal flows whose op names do not follow propose_* / *_proposal.
78
+ "skills.review_proposal": ["pending"],
79
+ "gateway.execute_rate_limit_proposal": ["pending"],
80
+ "gateway.reject_rate_limit_proposal": ["pending"],
81
+ "plugins.approve_installation": ["pending"],
82
+ "credentials.issue": ["credentials"],
83
+ "chat.stage": ["quality", "deploys"],
84
+ "chat.unstage": ["quality"],
85
+ "chat.finalize_stage": ["quality", "deploys"],
86
+ "chat.finalize_draft_stage": ["quality", "deploys"],
87
+ "chat.publish": ["quality", "deploys"],
88
+ "quality_audits.enqueue": ["quality"],
89
+ "quality_audits.record_result": ["quality"],
90
+ "quality_audits.retry": ["quality"],
91
+ "quality_audits.claim_chat_notification": ["quality"],
92
+ "quality_audits.ack_chat_notification": ["quality"],
93
+ "quality_audits.release_chat_notification": ["quality"],
94
+ "quality_audits.publish_anyway": ["quality"],
95
+ "imports.create_run": ["crawl"],
96
+ "imports.update_run_status": ["crawl"],
97
+ "imports.write_extracted_pages": ["crawl"],
98
+ "deploy.trigger": ["deploys"],
99
+ // #1211 — the Stage's steps: its run appears, ends failed, ends recorded.
100
+ "deploy.start_trigger": ["deploys"],
101
+ "deploy.fail_run": ["deploys"],
102
+ "deploy.finish_trigger": ["deploys"],
103
+ "deploy.begin_run": ["deploys"],
104
+ "deploy.update_progress": ["deploys"],
105
+ "deploy.promote": ["deploys"],
106
+ "deploy.rollback": ["deploys"],
107
+ "deploy.build_plugin_data": ["deploys"],
108
+ };
109
+
110
+ /**
111
+ * The topics a committed operation touches (empty for every other op).
112
+ *
113
+ * Every proposal op (`<domain>.propose_*`, `.execute_proposal`,
114
+ * `.reject_proposal`, `.cancel_proposal`) moves the pending strip; an
115
+ * applied proposal may also leave a one-time credential (#971) and, for
116
+ * the quality and deploy domains, move their state.
117
+ *
118
+ * @example liveTopicsOfOperation("users.execute_proposal") // ["pending", "credentials"]
119
+ */
120
+ export function liveTopicsOfOperation(operationName: string): readonly LiveTopic[] {
121
+ const exact = EXACT[operationName];
122
+ if (exact) return exact;
123
+ const dot = operationName.lastIndexOf(".");
124
+ const domain = operationName.slice(0, dot);
125
+ const action = operationName.slice(dot + 1);
126
+ const extra: LiveTopic[] =
127
+ domain === "quality_audits" ? ["quality"] : domain === "deploy" ? ["deploys"] : [];
128
+ if (action === "execute_proposal") return ["pending", "credentials", ...extra];
129
+ if (
130
+ action === "propose" ||
131
+ action.startsWith("propose_") ||
132
+ action === "reject_proposal" ||
133
+ action === "cancel_proposal"
134
+ ) {
135
+ return ["pending"];
136
+ }
137
+ return [];
138
+ }
@@ -158,6 +158,53 @@ describe("templates and free regions", () => {
158
158
  expect(freeRegions(root, parseMarkup(stripMustache(template)))).toBeNull();
159
159
  });
160
160
 
161
+ it("a section inside a tag is optional: dropped by default, kept on request (#1104)", () => {
162
+ const t =
163
+ '<div class="wrapc split {{#reverse}}rev{{/reverse}}" data-x="{{^a}}1{{/a}}"><p>{{t}}</p></div>';
164
+ expect(stripMustache(t)).toBe('<div class="wrapc split " data-x=""><p></p></div>');
165
+ expect(stripMustache(t, { keepOptionalAttributeText: true })).toBe(
166
+ '<div class="wrapc split rev" data-x="1"><p></p></div>',
167
+ );
168
+ // A section between elements still renders its body once (a list).
169
+ expect(stripMustache('<ul>{{#items}}<li class="i">{{.}}</li>{{/items}}</ul>')).toBe(
170
+ '<ul><li class="i"></li></ul>',
171
+ );
172
+ // A `>` inside a quoted value or a partial does not end the tag.
173
+ expect(stripMustache('<a title="x>y" class="{{#on}}on{{/on}}">{{>p}}</a>')).toBe(
174
+ '<a title="x>y" class=""></a>',
175
+ );
176
+ const started = performance.now();
177
+ stripMustache(`<a ${"{{#x}}".repeat(50_000)}>`);
178
+ expect(performance.now() - started).toBeLessThan(500);
179
+ });
180
+
181
+ it("an optional class does not make renders without it free markup (#1104)", () => {
182
+ const tpl =
183
+ '<section class="sec"><div class="wrapc split {{#reverse}}rev{{/reverse}}"><h2>{{t}}</h2><div class="body">{{{body}}}</div></div></section>';
184
+ const template = parseMarkup(stripMustache(tpl));
185
+ for (const cls of ["wrapc split", "wrapc split rev"]) {
186
+ const rendered = parseMarkup(
187
+ `<section class="sec" data-caelo-module-id="m1"><div class="${cls}"><h2>Hi</h2><div class="body"><p>x</p></div></div></section>`,
188
+ );
189
+ const root = moduleRoots(rendered)[0];
190
+ if (!root) throw new Error("no module root");
191
+ expect(freeRegions(root, template)?.map((n) => n.tag)).toEqual(["p"]);
192
+ }
193
+ });
194
+
195
+ it("a root whose classes all sit in sections is still checked (#1104)", () => {
196
+ const tpl =
197
+ '<div class="{{#moment}}moment{{/moment}}{{^moment}}sec{{/moment}}"><h2>{{t}}</h2><div class="body">{{{body}}}</div></div>';
198
+ const rendered = parseMarkup(
199
+ `<div class="sec" data-caelo-module-id="m1"><h2>Hi</h2><div class="body">${chain(["a", "b"])}</div></div>`,
200
+ );
201
+ const root = moduleRoots(rendered)[0];
202
+ if (!root) throw new Error("no module root");
203
+ const free = freeRegions(root, parseMarkup(stripMustache(tpl)));
204
+ expect(free).not.toBeNull();
205
+ expect(outermostPatterns(free ?? []).map((f) => f.node.classes[0])).toEqual(["chain"]);
206
+ });
207
+
161
208
  it("templateShape fingerprints the template's root element", () => {
162
209
  const t = templateShape(
163
210
  '<div class="chain">{{#steps}}<div class="step"><span class="n">{{n}}</span><p>{{t}}</p></div><span class="arrow"></span>{{/steps}}</div>',
@@ -120,13 +120,84 @@ function splitClasses(raw: string | undefined): string[] {
120
120
  return [...new Set(raw.split(/\s+/).filter((c) => c.length > 0))].sort();
121
121
  }
122
122
 
123
+ /** Options of {@link stripMustache}. */
124
+ export interface StripMustacheOptions {
125
+ /**
126
+ * Keep the text of a section inside a tag (`class="a {{#s}}b{{/s}}"` →
127
+ * `class="a b"`): every class the template CAN render. Default false:
128
+ * such a section is dropped, leaving the classes it ALWAYS renders.
129
+ */
130
+ readonly keepOptionalAttributeText?: boolean;
131
+ }
132
+
123
133
  /**
124
134
  * Remove Mustache tags (`{{x}}`, `{{{x}}}`, `{{#s}}`, `{{/s}}`, `{{>p}}`)
125
135
  * from a module template so it parses as the markup it renders: a section
126
- * body stays once, a placeholder becomes empty.
136
+ * body between elements stays once (a list renders it per entry), a
137
+ * placeholder becomes empty.
138
+ *
139
+ * A section INSIDE a tag (`<div class="split {{#reverse}}rev{{/reverse}}">`,
140
+ * a boolean variant) is optional: the rendered element may or may not
141
+ * carry its text. By default it is dropped, so the template node has only
142
+ * the classes every render has (#1104). Keeping it made `rev` a required
143
+ * class — every render without it looked like free markup — and a root
144
+ * whose classes all sit in sections matched no render, so the module's
145
+ * free markup went unchecked.
146
+ */
147
+ export function stripMustache(template: string, opts: StripMustacheOptions = {}): string {
148
+ const base = opts.keepOptionalAttributeText ? template : dropInTagSections(template);
149
+ return stripDelimited(stripDelimited(base, 3), 2);
150
+ }
151
+
152
+ /**
153
+ * Remove every `{{#s}}…{{/s}}` / `{{^s}}…{{/s}}` that opens inside a tag
154
+ * (between `<name` and its `>`), body included. One linear scan; Mustache
155
+ * tags are skipped whole, so a `>` in `{{>partial}}` never ends a tag.
127
156
  */
128
- export function stripMustache(template: string): string {
129
- return stripDelimited(stripDelimited(template, 3), 2);
157
+ function dropInTagSections(text: string): string {
158
+ let out = "";
159
+ let i = 0;
160
+ let inTag = false;
161
+ let quote: string | null = null;
162
+ // Names with no closer after some point have none after any later point
163
+ // either: remembering them keeps the scan linear on unclosed openers.
164
+ const unclosed = new Set<string>();
165
+ while (i < text.length) {
166
+ if (text.startsWith("{{", i)) {
167
+ const end = text.indexOf("}}", i + 2);
168
+ if (end < 0) break;
169
+ const sigil = text[i + 2];
170
+ if (inTag && (sigil === "#" || sigil === "^")) {
171
+ const name = text.slice(i + 3, end).trim();
172
+ const closer = `{{/${name}}}`;
173
+ const close = unclosed.has(name) ? -1 : text.indexOf(closer, end + 2);
174
+ if (close < 0) unclosed.add(name);
175
+ if (name.length > 0 && close >= 0) {
176
+ i = close + closer.length;
177
+ continue;
178
+ }
179
+ }
180
+ out += text.slice(i, end + 2);
181
+ i = end + 2;
182
+ continue;
183
+ }
184
+ const ch = text[i] as string;
185
+ if (inTag) {
186
+ if (quote !== null) {
187
+ if (ch === quote) quote = null;
188
+ } else if (ch === '"' || ch === "'") {
189
+ quote = ch;
190
+ } else if (ch === ">") {
191
+ inTag = false;
192
+ }
193
+ } else if (ch === "<" && /[a-zA-Z]/.test(text[i + 1] ?? "")) {
194
+ inTag = true;
195
+ quote = null;
196
+ }
197
+ out += ch;
198
+ i++;
199
+ }
200
+ return out + text.slice(i);
130
201
  }
131
202
 
132
203
  /**
package/src/media.ts CHANGED
@@ -394,6 +394,18 @@ export type MediaSetCdnInput = z.infer<typeof mediaSetCdnInputSchema>;
394
394
  export interface MediaStorageAdapter {
395
395
  put(key: string, body: Uint8Array, contentType: string): Promise<void>;
396
396
  get(key: string): Promise<Uint8Array>;
397
+ /**
398
+ * #1216 — the object's size in bytes without reading it, so a caller can
399
+ * refuse an oversized object before it is loaded. Throws when the key
400
+ * does not exist.
401
+ */
402
+ size(key: string): Promise<number>;
403
+ /**
404
+ * #1216 — the object as a byte stream plus its size, for serving it
405
+ * without holding the whole file in memory. Throws when the key does not
406
+ * exist.
407
+ */
408
+ openStream(key: string): Promise<{ body: ReadableStream<Uint8Array>; sizeBytes: number }>;
397
409
  delete(key: string): Promise<void>;
398
410
  exists(key: string): Promise<boolean>;
399
411
  /** Bytes-on-disk for capacity reporting. */
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { tagModuleId } from "./module-id-tag.js";
5
+
6
+ describe("tagModuleId", () => {
7
+ it("stamps the first opening tag", () => {
8
+ expect(tagModuleId('<p class="a">hi</p>', "m1")).toBe(
9
+ '<p class="a" data-caelo-module-id="m1">hi</p>',
10
+ );
11
+ });
12
+
13
+ it("keeps a self-closing root self-closed (#1099)", () => {
14
+ expect(tagModuleId('<line x1="0" stroke="#E3E7EE"/>', "m1")).toBe(
15
+ '<line x1="0" stroke="#E3E7EE" data-caelo-module-id="m1"/>',
16
+ );
17
+ expect(tagModuleId('<circle r="2" />', "m1")).toBe('<circle r="2" data-caelo-module-id="m1"/>');
18
+ expect(tagModuleId("<br/>", "m1")).toBe('<br data-caelo-module-id="m1"/>');
19
+ });
20
+
21
+ it("does not mistake a slash inside a quoted value for self-closing", () => {
22
+ expect(tagModuleId('<a href="/">x</a>', "m1")).toBe(
23
+ '<a href="/" data-caelo-module-id="m1">x</a>',
24
+ );
25
+ });
26
+
27
+ it("is idempotent", () => {
28
+ const once = tagModuleId('<polyline points="0,0 1,1"/>', "m1");
29
+ expect(tagModuleId(once, "m2")).toBe(once);
30
+ });
31
+
32
+ it("stays linear on a long whitespace run inside the root tag", () => {
33
+ const html = `<div${"\t".repeat(200_000)}x>`;
34
+ const start = performance.now();
35
+ expect(tagModuleId(html, "m1").endsWith(' data-caelo-module-id="m1">')).toBe(true);
36
+ expect(performance.now() - start).toBeLessThan(500);
37
+ });
38
+ });
@@ -22,9 +22,12 @@ export const MODULE_ID_ATTRIBUTE = "data-caelo-module-id";
22
22
  * stays attributed to itself. Comments / DOCTYPE / leading whitespace
23
23
  * before the first tag are tolerated. HTML without an opening tag (pure
24
24
  * text, a failure comment) returns unchanged because there's nothing to
25
- * attach to.
25
+ * attach to. A self-closing root (`<line … />`, common for SVG parts)
26
+ * gets the attribute before its `/` (#1099) — after it, the tag would no
27
+ * longer self-close and swallow every following sibling.
26
28
  *
27
29
  * @example tagModuleId("<p>hi</p>", "abc") // '<p data-caelo-module-id="abc">hi</p>'
30
+ * @example tagModuleId('<line x1="0"/>', "abc") // '<line x1="0" data-caelo-module-id="abc"/>'
28
31
  */
29
32
  export function tagModuleId(html: string, moduleId: string): string {
30
33
  if (!html) return html;
@@ -33,6 +36,12 @@ export function tagModuleId(html: string, moduleId: string): string {
33
36
  if (!m) return html;
34
37
  const tagAttrs = m[2] ?? "";
35
38
  if (/\sdata-caelo-module-id\s*=/.test(tagAttrs)) return html;
36
- const replaced = `<${m[1]}${tagAttrs} ${MODULE_ID_ATTRIBUTE}="${moduleId}">`;
39
+ // String ops, not a `\s*\/\s*$` regex: that backtracks polynomially on
40
+ // long whitespace runs (CodeQL js/polynomial-redos).
41
+ const trimmed = tagAttrs.trimEnd();
42
+ const selfClosing = trimmed.endsWith("/");
43
+ const attrs = selfClosing ? trimmed.slice(0, -1).trimEnd() : tagAttrs;
44
+ const close = selfClosing ? "/>" : ">";
45
+ const replaced = `<${m[1]}${attrs} ${MODULE_ID_ATTRIBUTE}="${moduleId}"${close}`;
37
46
  return html.slice(0, m.index) + replaced + html.slice(m.index + m[0].length);
38
47
  }
@@ -245,3 +245,47 @@ function liveScripts(html: string): string[] {
245
245
  parser.end();
246
246
  return scripts;
247
247
  }
248
+
249
+ describe("composer — omitted modules", () => {
250
+ const omitted = (editorNotice: { html: string; css: string } | null): ComposeDeferral => ({
251
+ pluginSlug: "consent-manager",
252
+ reason: "unclassified",
253
+ omit: true,
254
+ editorNotice,
255
+ });
256
+ const MODULE_WITH_ASSETS = { ...VIDEO, css: ".video-frame{}", js: "fetch('https://t.example')" };
257
+ const composeOmitted = (d: ComposeDeferral) =>
258
+ composePageWithLayout({
259
+ templateHtml: TEMPLATE,
260
+ templateCss: "",
261
+ blocks: [{ blockName: "main", modules: [MODULE_WITH_ASSETS] }],
262
+ layoutHtml: LAYOUT,
263
+ layoutCss: "",
264
+ layoutBlocks: [],
265
+ deferredModules: { [VIDEO.moduleId]: d },
266
+ }).html;
267
+
268
+ it("leaves a build with nothing in the module's place", () => {
269
+ // A visitor cannot release it, so no placeholder and no parked copy:
270
+ // neither the markup nor the module's CSS/JS reach the page.
271
+ const html = composeOmitted(omitted(null));
272
+ expect(html).not.toContain("youtube.com");
273
+ expect(html).not.toContain(".video-frame");
274
+ expect(html).not.toContain("t.example");
275
+ expect(html).not.toContain("data-caelo-deferred");
276
+ expect(html).not.toContain("<template");
277
+ });
278
+
279
+ it("shows the editor notice in the preview, attributed to the module", () => {
280
+ const html = composeOmitted(
281
+ omitted({ html: '<div class="note">Hidden</div>', css: ".note{color:red}" }),
282
+ );
283
+ expect(html).toContain('<div class="note">Hidden</div>');
284
+ expect(html).toContain(".note{color:red}");
285
+ expect(html).toContain("data-caelo-deferred-omitted");
286
+ // The chat affordances reference the module the notice stands in for.
287
+ expect(html).toContain(`data-caelo-module-id="${VIDEO.moduleId}"`);
288
+ expect(html).not.toContain("youtube.com");
289
+ expect(html).not.toContain("<template");
290
+ });
291
+ });