@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.
- package/dist/ai-tools.d.ts +2 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +7 -0
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +46 -3
- package/dist/bug-intake.d.ts.map +1 -1
- package/dist/bug-intake.js +52 -3
- package/dist/bug-intake.js.map +1 -1
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +5 -0
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +1 -0
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +28 -3
- package/dist/content.js.map +1 -1
- package/dist/css-var-scan.js +3 -3
- package/dist/css-var-scan.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/live-events.d.ts +44 -0
- package/dist/live-events.d.ts.map +1 -0
- package/dist/live-events.js +128 -0
- package/dist/live-events.js.map +1 -0
- package/dist/markup-structure.d.ts +20 -2
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +69 -3
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +15 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js.map +1 -1
- package/dist/module-id-tag.d.ts +4 -1
- package/dist/module-id-tag.d.ts.map +1 -1
- package/dist/module-id-tag.js +11 -2
- package/dist/module-id-tag.js.map +1 -1
- package/dist/preview-compose.d.ts +18 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +26 -7
- package/dist/preview-compose.js.map +1 -1
- package/dist/theme-render.js +11 -26
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +12 -9
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +10 -0
- package/src/bug-intake.test.ts +47 -0
- package/src/bug-intake.ts +58 -3
- package/src/build-page.ts +5 -0
- package/src/content.ts +37 -3
- package/src/css-var-scan.ts +3 -3
- package/src/index.ts +1 -0
- package/src/live-events.ts +138 -0
- package/src/markup-structure.test.ts +47 -0
- package/src/markup-structure.ts +74 -3
- package/src/media.ts +12 -0
- package/src/module-id-tag.test.ts +38 -0
- package/src/module-id-tag.ts +11 -2
- package/src/preview-compose-deferrals.test.ts +44 -0
- package/src/preview-compose.ts +47 -10
- package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
- package/src/theme-render-sanitize.test.ts +8 -0
- package/src/theme-render.ts +11 -26
- package/src/themes.ts +12 -7
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
339
|
-
|
|
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();
|
package/src/css-var-scan.ts
CHANGED
|
@@ -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
|
-
: " —
|
|
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
|
|
151
|
-
"Use the exact names
|
|
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
|
@@ -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>',
|
package/src/markup-structure.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
+
});
|
package/src/module-id-tag.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|