@caelo-cms/shared 0.10.62 → 0.10.65

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 (59) hide show
  1. package/dist/build-page.d.ts +10 -0
  2. package/dist/build-page.d.ts.map +1 -1
  3. package/dist/build-page.js +43 -5
  4. package/dist/build-page.js.map +1 -1
  5. package/dist/google-oauth.d.ts +7 -0
  6. package/dist/google-oauth.d.ts.map +1 -1
  7. package/dist/google-oauth.js +9 -0
  8. package/dist/google-oauth.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/link-canonicalise.d.ts.map +1 -1
  14. package/dist/link-canonicalise.js +20 -2
  15. package/dist/link-canonicalise.js.map +1 -1
  16. package/dist/mcp-access.d.ts +34 -0
  17. package/dist/mcp-access.d.ts.map +1 -0
  18. package/dist/mcp-access.js +58 -0
  19. package/dist/mcp-access.js.map +1 -0
  20. package/dist/preview-compose.d.ts.map +1 -1
  21. package/dist/preview-compose.js +9 -2
  22. package/dist/preview-compose.js.map +1 -1
  23. package/dist/screenshot-capture.d.ts +32 -0
  24. package/dist/screenshot-capture.d.ts.map +1 -0
  25. package/dist/screenshot-capture.js +36 -0
  26. package/dist/screenshot-capture.js.map +1 -0
  27. package/dist/seo.d.ts.map +1 -1
  28. package/dist/seo.js +9 -5
  29. package/dist/seo.js.map +1 -1
  30. package/dist/theme-literal-binding.d.ts.map +1 -1
  31. package/dist/theme-literal-binding.js +15 -4
  32. package/dist/theme-literal-binding.js.map +1 -1
  33. package/dist/theme-render.js +2 -1
  34. package/dist/theme-render.js.map +1 -1
  35. package/dist/themes.d.ts.map +1 -1
  36. package/dist/themes.js +6 -2
  37. package/dist/themes.js.map +1 -1
  38. package/dist/version.d.ts +2 -2
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
  41. package/src/build-page.test.ts +55 -0
  42. package/src/build-page.ts +45 -5
  43. package/src/google-oauth.ts +10 -0
  44. package/src/index.ts +2 -0
  45. package/src/link-canonicalise.test.ts +29 -0
  46. package/src/link-canonicalise.ts +19 -2
  47. package/src/mcp-access.ts +73 -0
  48. package/src/preview-compose.test.ts +27 -0
  49. package/src/preview-compose.ts +9 -6
  50. package/src/screenshot-capture.ts +46 -0
  51. package/src/seo.test.ts +9 -0
  52. package/src/seo.ts +9 -5
  53. package/src/theme-importers/__tests__/proto-pollution.test.ts +9 -1
  54. package/src/theme-literal-binding.test.ts +20 -0
  55. package/src/theme-literal-binding.ts +16 -4
  56. package/src/theme-render-sanitize.test.ts +9 -0
  57. package/src/theme-render.ts +2 -1
  58. package/src/themes.ts +4 -2
  59. package/src/version.ts +1 -1
@@ -60,6 +60,31 @@ describe("buildPageInputSchema — page target modes", () => {
60
60
  expect(issue.message).toContain("title");
61
61
  });
62
62
 
63
+ it("create mode takes the new page's meta description (trimmed), so it is Stage-ready from this call", () => {
64
+ const r = buildPageInputSchema.safeParse({
65
+ page: { slug: "pricing", title: "Pricing", metaDescription: " Plans and prices. " },
66
+ modules: [mintModule],
67
+ });
68
+ expect(r.success).toBe(true);
69
+ if (!r.success) return;
70
+ expect(r.data.page.metaDescription).toBe("Plans and prices.");
71
+ });
72
+
73
+ it("rejects a blank meta description and one given with pageId (fill-once is autofill_page_seo's job there)", () => {
74
+ const blank = buildPageInputSchema.safeParse({
75
+ page: { slug: "pricing", title: "Pricing", metaDescription: " " },
76
+ modules: [],
77
+ });
78
+ expect(blank.success).toBe(false);
79
+ const existing = buildPageInputSchema.safeParse({
80
+ page: { pageId: UUID, metaDescription: "Plans and prices." },
81
+ modules: [],
82
+ });
83
+ expect(existing.success).toBe(false);
84
+ if (existing.success) return;
85
+ expect(existing.error.issues[0]!.message).toContain("autofill_page_seo");
86
+ });
87
+
63
88
  it("rejects a page target with neither pageId nor slug+title", () => {
64
89
  const r = buildPageInputSchema.safeParse({
65
90
  page: { slug: "pricing" },
@@ -132,6 +157,36 @@ describe("buildPageInputSchema — module entry modes name the failing index", (
132
157
  });
133
158
  expect(r.success).toBe(false);
134
159
  });
160
+
161
+ // e2e-livedit detected bug: the AI carried add_module's `position` into
162
+ // build_page entries and the whole call was refused as an unknown key.
163
+ it("accepts add_module's `position` grammar per placed entry", () => {
164
+ for (const position of ["top", "bottom", 0, 3, "2"]) {
165
+ const r = buildPageInputSchema.safeParse({
166
+ page: { pageId: UUID },
167
+ modules: [{ ...mintModule, position }],
168
+ });
169
+ expect(r.success).toBe(true);
170
+ if (r.success) {
171
+ expect(r.data.modules[0]?.position).toEqual(position === "2" ? 2 : position);
172
+ }
173
+ }
174
+ const bad = buildPageInputSchema.safeParse({
175
+ page: { pageId: UUID },
176
+ modules: [{ ...mintModule, position: "middle" }],
177
+ });
178
+ expect(bad.success).toBe(false);
179
+ });
180
+
181
+ it("rejects `position` on a detached (unplaced) entry", () => {
182
+ const { blockName: _b, ...detached } = mintModule;
183
+ const r = buildPageInputSchema.safeParse({
184
+ page: { pageId: UUID },
185
+ modules: [{ ...detached, ref: "card", position: "top" }],
186
+ });
187
+ expect(r.success).toBe(false);
188
+ if (!r.success) expect(r.error.issues[0]?.message).toContain("DETACHED");
189
+ });
135
190
  });
136
191
 
137
192
  describe("buildPageContentSchema — the three sources", () => {
package/src/build-page.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { z } from "zod";
23
+ import { positionInputSchema } from "./ai-tools.js";
23
24
  import {
24
25
  contentInstanceCreateSchema,
25
26
  MODULE_CSS_MAX,
@@ -31,6 +32,7 @@ import {
31
32
  slugSchema,
32
33
  syncModeSchema,
33
34
  } from "./content.js";
35
+ import { SEO_DESCRIPTION_HARD_MAX } from "./seo.js";
34
36
 
35
37
  /** Content values keyed by the module's declared field names. */
36
38
  const contentValuesSchema = z.record(z.string(), z.unknown());
@@ -106,11 +108,25 @@ export const buildPageModuleSchema = z
106
108
  * module / module-list field value. A detached entry requires `ref`.
107
109
  */
108
110
  blockName: slugSchema.optional(),
111
+ /**
112
+ * Where in its block the entry lands — the same grammar as
113
+ * add_module's `position` ("top", "bottom", or a 0-based index).
114
+ * Omitted = "bottom": entries append in array order, so a new page
115
+ * needs none. Entries are inserted in array order, each into the
116
+ * block as it stands after the entries before it; an index past the
117
+ * end appends. Meaningless (and rejected) on a detached entry.
118
+ *
119
+ * Accepted because the AI carries `position` over from add_module,
120
+ * and on an existing page "put this at the top" is a real intent a
121
+ * pure append cannot express (e2e-livedit detected bugs).
122
+ */
123
+ position: positionInputSchema.optional(),
109
124
  /**
110
125
  * Local handle other entries in the SAME call can reference: a
111
- * module/module-list field value of `{"$ref": "<ref>"}` resolves to
112
- * this entry's `{moduleId, contentInstanceId}`. Entries resolve in
113
- * array order, so referenced entries must come FIRST.
126
+ * module/module-list field value of `{"$ref": "<ref>"}` (or a
127
+ * `moduleId: {"$ref"}`) resolves to this entry's `{moduleId,
128
+ * contentInstanceId}`. Array position does not matter: the op mints
129
+ * referenced entries before the entries that embed them.
114
130
  */
115
131
  ref: z
116
132
  .string()
@@ -149,6 +165,14 @@ export const buildPageModuleSchema = z
149
165
  })
150
166
  .strict()
151
167
  .superRefine((entry, ctx) => {
168
+ if (entry.blockName === undefined && entry.position !== undefined) {
169
+ ctx.addIssue({
170
+ code: z.ZodIssueCode.custom,
171
+ message:
172
+ "`position` places an entry inside its block, but this entry has no `blockName` (a DETACHED, " +
173
+ "nested-only module is not placed). Drop `position`, or pass `blockName` to place it.",
174
+ });
175
+ }
152
176
  if (entry.blockName === undefined && entry.ref === undefined) {
153
177
  ctx.addIssue({
154
178
  code: z.ZodIssueCode.custom,
@@ -211,6 +235,15 @@ export const buildPageTargetSchema = z
211
235
  * Compatible with either target shape (create OR existing pageId).
212
236
  */
213
237
  importPageId: z.string().uuid().optional(),
238
+ /**
239
+ * The new page's meta description, written in the same transaction
240
+ * with the fill-once semantics of `pages_seo.autofill` (CLAUDE.md §2).
241
+ * A staging build refuses a published page without one, so the page
242
+ * is Stage-ready from the call that creates it. Create mode (or an
243
+ * importPageId rebuild) only — an existing page gets its first
244
+ * description from `autofill_page_seo`.
245
+ */
246
+ metaDescription: z.string().trim().min(1).max(SEO_DESCRIPTION_HARD_MAX).optional(),
214
247
  })
215
248
  .strict()
216
249
  .superRefine((page, ctx) => {
@@ -218,6 +251,13 @@ export const buildPageTargetSchema = z
218
251
  ["slug", "title", "name", "templateId", "status", "parentPageId"] as const
219
252
  ).filter((k) => page[k] !== undefined);
220
253
  if (page.pageId !== undefined) {
254
+ if (page.metaDescription !== undefined) {
255
+ ctx.addIssue({
256
+ code: z.ZodIssueCode.custom,
257
+ message:
258
+ "page.metaDescription is written only for a page this call CREATES — drop it with pageId; an existing page without a description gets one via autofill_page_seo.",
259
+ });
260
+ }
221
261
  if (createKeys.length > 0) {
222
262
  ctx.addIssue({
223
263
  code: z.ZodIssueCode.custom,
@@ -250,8 +290,8 @@ export const buildPageInputSchema = z
250
290
  })
251
291
  .strict()
252
292
  .superRefine((input, ctx) => {
253
- // `ref` handles must be unique — a duplicate would silently shadow
254
- // the earlier entry when a later {"$ref"} resolves.
293
+ // `ref` handles must be unique — a duplicate would make a {"$ref"}
294
+ // ambiguous.
255
295
  const seen = new Set<string>();
256
296
  for (const [i, entry] of input.modules.entries()) {
257
297
  if (entry.ref === undefined) continue;
@@ -25,3 +25,13 @@ export const GOOGLE_OAUTH_CLIENT_ID = /^[0-9]+-[a-z0-9]+\.apps\.googleuserconten
25
25
  export function iapRedirectUri(clientId: string): string {
26
26
  return `https://iap.googleapis.com/v1/oauth/clientIds/${clientId}:handleRedirect`;
27
27
  }
28
+
29
+ /**
30
+ * #967 — the Google console page of one OAuth client, where its authorized
31
+ * redirect URIs and client secrets are edited. Shared so the setup page can
32
+ * link it as the Owner types the client ID, and the preflight can name it
33
+ * in every fix.
34
+ */
35
+ export function oauthClientConsoleUrl(projectId: string, clientId: string): string {
36
+ return `https://console.cloud.google.com/auth/clients/${encodeURIComponent(clientId)}?project=${encodeURIComponent(projectId)}`;
37
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  type StructuredLogEntry,
44
44
  } from "./logger.js";
45
45
  export * from "./markup-structure.js";
46
+ export * from "./mcp-access.js";
46
47
  export * from "./media.js";
47
48
  export * from "./nested-module-render.js";
48
49
  export * from "./page-log.js";
@@ -64,6 +65,7 @@ export * from "./release-manifest.js";
64
65
  export * from "./responsive-images.js";
65
66
  export * from "./result.js";
66
67
  export * from "./safe-keys.js";
68
+ export * from "./screenshot-capture.js";
67
69
  export * from "./seo.js";
68
70
  export * from "./skills.js";
69
71
  export * from "./snapshots.js";
@@ -180,6 +180,35 @@ describe("canonicaliseUrl", () => {
180
180
  expect(strict.issues.map((i) => i.kind)).toEqual(["unknown-page-path"]);
181
181
  });
182
182
 
183
+ // e2e-livedit detected bugs: the site header (brand link "/", nav
184
+ // "/#features") is built before the homepage exists and was refused.
185
+ it("keeps the site root literal while no page serves it, never an issue", () => {
186
+ const noRoot: LinkTargetLookup = {
187
+ ...lookup,
188
+ pageIdForPath: (p) => (p === "/" ? null : lookup.pageIdForPath(p)),
189
+ };
190
+ const r = linkCanonicaliseRun(noRoot);
191
+ expect(canonicaliseUrl("/", r, "html")).toBe("/");
192
+ expect(canonicaliseUrl("/#features", r, "fields.nav.default[1].href")).toBe("/#features");
193
+ expect(canonicaliseUrl("https://example.com/?ref=nav", r, "x")).toBe(
194
+ "https://example.com/?ref=nav",
195
+ );
196
+ expect(canonicaliseHtmlLinks('<a href="/">Home</a><a href="/#features">F</a>', r, "html")).toBe(
197
+ '<a href="/">Home</a><a href="/#features">F</a>',
198
+ );
199
+ expect(r.issues).toEqual([]);
200
+ // A suffix the stored grammar cannot carry is still refused, and any
201
+ // other unknown path still is an issue: only the root is guaranteed.
202
+ canonicaliseUrl('/#bad"anchor', r, "y");
203
+ canonicaliseUrl("/get-started", r, "z");
204
+ expect(r.issues.map((i) => [i.kind, i.value])).toEqual([
205
+ ["unsupported-link-form", '/#bad"anchor'],
206
+ ["unknown-page-path", "/get-started"],
207
+ ]);
208
+ // Once the homepage exists the root becomes its reference as usual.
209
+ expect(canonicaliseUrl("/#features", run(), "x")).toBe(`caelo:page/${HOME}#features`);
210
+ });
211
+
183
212
  it("reports a malformed reference instead of passing it through", () => {
184
213
  const r = run();
185
214
  const bad = [
@@ -39,6 +39,12 @@
39
39
  * that run not built yet is kept and listed in `run.pending`; the
40
40
  * caller records it and the run's finalisation converts it (or
41
41
  * reports it) once the run's pages exist.
42
+ * - the site root: `/` (with an optional `#anchor` / `?query`) while no
43
+ * page serves it yet is kept literal, not refused. Every site has a
44
+ * root page by the time it ships — the staged build refuses one
45
+ * without — so the link cannot dangle, and site chrome (the brand
46
+ * link, `/#features` nav items) is routinely written before the
47
+ * homepage. Once the homepage exists, `/` canonicalises as usual.
42
48
  */
43
49
 
44
50
  import {
@@ -322,6 +328,16 @@ export function canonicaliseUrl(
322
328
  run.pending.push({ value: v, path: normalised, where });
323
329
  return value;
324
330
  }
331
+ if (pageId === null && normalised === "/") {
332
+ // The site root before its page exists (a header built ahead of the
333
+ // homepage). It cannot dangle: the staged build refuses a site with
334
+ // no page at "/" (CLAUDE.md §2: loud there, not here). Kept literal;
335
+ // the next save after the homepage exists stores its reference.
336
+ if ((query !== null && !QUERY_OK.test(query)) || (anchor !== null && !ANCHOR_OK.test(anchor))) {
337
+ if (!run.grandfathered.has(v)) issue(run, "unsupported-link-form", v, where);
338
+ }
339
+ return value;
340
+ }
325
341
  if (pageId === null) {
326
342
  const lastSegment = path.slice(path.lastIndexOf("/") + 1);
327
343
  const isFile = FILE_EXT_RE.test(lastSegment);
@@ -592,7 +608,8 @@ export function describeLinkIssues(issues: readonly LinkCanonicaliseIssue[]): st
592
608
  `Internal link check failed — ${lines.join("; ")}${more}. ` +
593
609
  "Internal links must point at an existing page or media asset. Next step: use the exact path " +
594
610
  "of an existing page (list_pages shows every page's path) or an existing media URL (find_media); " +
595
- "if the page does not exist yet, create it first (or leave the link out and add it once the page " +
596
- "exists). For a link to another site, write the full https:// URL."
611
+ "if the page does not exist yet, create it first (build_page with `modules: []` makes an empty page " +
612
+ "shell) or leave the link out and add it once the page exists. The site root `/` is always valid. " +
613
+ "For a link to another site, write the full https:// URL."
597
614
  );
598
615
  }
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #968 — the facts about Caelo's MCP access paths that the admin UI,
5
+ * the remote resolver's error messages and the docs must agree on: which
6
+ * surface needs which permission, what each surface is for in plain words,
7
+ * and how a client app's key reads to a person. Client-safe (no server
8
+ * imports): Settings → MCP renders it, admin-core words refusals with it.
9
+ * The concept is docs/dev/mcp-access.md.
10
+ */
11
+
12
+ /** A remote MCP surface: talk to the site's AI, or drive its admin tools. */
13
+ export type McpSurfaceName = "chat" | "admin";
14
+
15
+ /**
16
+ * The permission each remote surface needs. Chat writes content through
17
+ * Caelo's own AI; admin exposes every tool the AI has, so it needs what an
18
+ * admin-scoped MCP token needs. Owners have both.
19
+ */
20
+ export const MCP_SURFACE_PERMISSION: Readonly<
21
+ Record<McpSurfaceName, "content.write" | "settings.write">
22
+ > = {
23
+ chat: "content.write",
24
+ admin: "settings.write",
25
+ };
26
+
27
+ /** What a surface does and who may use it, in the words the UI and errors use. */
28
+ export const MCP_SURFACE_COPY: Readonly<
29
+ Record<McpSurfaceName, { readonly title: string; readonly does: string; readonly who: string }>
30
+ > = {
31
+ chat: {
32
+ title: "Chat with this site's AI",
33
+ does: "The AI tool sends requests to Caelo's own AI, which builds and edits the site like the Live Edit chat does.",
34
+ who: "Everyone who may edit content (content.write).",
35
+ },
36
+ admin: {
37
+ title: "Full admin access",
38
+ does: "The AI tool gets every tool Caelo's AI has and does the work itself, on a preview branch. Approval-gated actions still wait for an Owner's click.",
39
+ who: "Owners and roles that may change settings (settings.write).",
40
+ },
41
+ };
42
+
43
+ /**
44
+ * A suggested connector name, so two connectors to one site in the same AI
45
+ * tool are told apart: "Caelo viu.one (Chat)", "Caelo viu.one (Admin)".
46
+ */
47
+ export function mcpConnectorName(siteHost: string, surface: McpSurfaceName): string {
48
+ return `Caelo ${siteHost} (${surface === "admin" ? "Admin" : "Chat"})`;
49
+ }
50
+
51
+ /**
52
+ * How a client app's key (the gateway's `mcpClientKey`) reads to a person.
53
+ * Known CIMD documents get their product name; a DCR key shows the name the
54
+ * app registered with (unverified — anyone can register any name).
55
+ */
56
+ export function describeMcpClient(clientKey: string | null): string {
57
+ if (clientKey === null) return "An app from before per-app connections";
58
+ if (clientKey.startsWith("dcr:sha256:") || clientKey.startsWith("sha256:")) {
59
+ return "An app that did not give its name";
60
+ }
61
+ if (clientKey.startsWith("dcr:")) return clientKey.slice(4);
62
+ let url: URL;
63
+ try {
64
+ url = new URL(clientKey);
65
+ } catch {
66
+ return clientKey;
67
+ }
68
+ if (url.hostname === "chatgpt.com") return "ChatGPT";
69
+ if (url.hostname === "claude.ai") {
70
+ return url.pathname.includes("claude-code") ? "Claude Code" : "Claude";
71
+ }
72
+ return url.hostname;
73
+ }
@@ -818,4 +818,31 @@ describe("theme favicon in <head>", () => {
818
818
  expect(out.html).toContain('href="/x&quot;&gt;&lt;script&gt;"');
819
819
  expect(out.html).not.toContain('/x"><script>');
820
820
  });
821
+
822
+ it("base64-encodes the page-wide module script bundle when JS contains </script or <!--", () => {
823
+ const mod = {
824
+ ...blankModule,
825
+ html: "<p>x</p>",
826
+ js: 'const tpl = "</script><img src=x onerror=alert(1)>";',
827
+ };
828
+ const preview = composePagePreview({
829
+ templateHtml: `<html><head></head><body><caelo-slot name="content">_</caelo-slot></body></html>`,
830
+ templateCss: "",
831
+ blocks: [{ blockName: "content", modules: [mod] }],
832
+ });
833
+ expect(preview.html).not.toContain("<img src=x onerror=alert(1)>");
834
+ expect(preview.html).toContain('src="data:text/javascript;base64,');
835
+
836
+ const withLayout = composePageWithLayout({
837
+ templateHtml,
838
+ templateCss: "",
839
+ blocks: [{ blockName: "content", modules: [mod] }],
840
+ layoutHtml,
841
+ layoutCss: "",
842
+ layoutBlocks: [],
843
+ layoutSlug: "test",
844
+ });
845
+ expect(withLayout.html).not.toContain("<img src=x onerror=alert(1)>");
846
+ expect(withLayout.html).toContain('src="data:text/javascript;base64,');
847
+ });
821
848
  });
@@ -369,7 +369,10 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
369
369
  html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
370
370
  }
371
371
  if (allJs.length > 0) {
372
- const scriptTag = `<script defer data-source="modules">\n${allJs.join("\n")}\n</script>`;
372
+ const joinedJs = allJs.join("\n");
373
+ const scriptTag = scriptNeedsEncoding(joinedJs)
374
+ ? `<script defer data-source="modules" src="data:text/javascript;base64,${utf8ToBase64(joinedJs)}"></script>`
375
+ : `<script defer data-source="modules">\n${joinedJs}\n</script>`;
373
376
  html = injectBefore(html, BODY_CLOSE_RE, scriptTag);
374
377
  }
375
378
 
@@ -958,11 +961,11 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
958
961
  );
959
962
  }
960
963
  if (jsParts.length > 0) {
961
- html = injectBefore(
962
- html,
963
- BODY_CLOSE_RE,
964
- `<script defer data-source="modules">\n${jsParts.join("\n")}\n</script>`,
965
- );
964
+ const joinedJs = jsParts.join("\n");
965
+ const scriptTag = scriptNeedsEncoding(joinedJs)
966
+ ? `<script defer data-source="modules" src="data:text/javascript;base64,${utf8ToBase64(joinedJs)}"></script>`
967
+ : `<script defer data-source="modules">\n${joinedJs}\n</script>`;
968
+ html = injectBefore(html, BODY_CLOSE_RE, scriptTag);
966
969
  }
967
970
 
968
971
  // De-duplicate slot accounting across both passes — the template's
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The operator-browser `screenshot_page` capture (html2canvas in the chat
5
+ * panel) and the tool handler that receives its result agree on one failure
6
+ * kind: the capture target exists but has no rendered size.
7
+ *
8
+ * Why it needs its own kind: the browser capture renders the preview WITHOUT
9
+ * the page's scripts (#946 — the frame shares the admin origin, so scripts
10
+ * must not run with the operator's session). An element a script reveals —
11
+ * the consent banner is `display:none` until the consent runtime adds a class
12
+ * to `<html>` — is therefore 0×0 there, html2canvas paints a 0×0 canvas and
13
+ * `canvas.toBlob` answers null (e2e-livedit 2026-10, "canvas.toBlob returned
14
+ * null" on `[data-consent-banner]`). That is a limit of this backend, not of
15
+ * the page: the server-side renderer runs real Chromium with scripts and can
16
+ * capture the element, so the handler routes such a failure there.
17
+ */
18
+
19
+ /** Prefix of a browser-capture failure whose target has no rendered size. */
20
+ export const SCREENSHOT_TARGET_NOT_RENDERED = "capture target is not rendered";
21
+
22
+ /** A rendered box, as `getBoundingClientRect()` / a canvas reports it. */
23
+ export interface ScreenshotBox {
24
+ readonly width: number;
25
+ readonly height: number;
26
+ }
27
+
28
+ /**
29
+ * The failure message when the capture target (or the canvas painted from
30
+ * it) has no area, else null.
31
+ *
32
+ * @param target - what is being captured: the selector, or "page body".
33
+ */
34
+ export function screenshotTargetNotRendered(target: string, box: ScreenshotBox): string | null {
35
+ if (box.width > 0 && box.height > 0) return null;
36
+ return (
37
+ `${SCREENSHOT_TARGET_NOT_RENDERED}: ${target} is ${Math.round(box.width)}×${Math.round(box.height)}px ` +
38
+ "in the operator-browser capture, which runs without the page's scripts — an element a script " +
39
+ "reveals (display:none until then) has no size there"
40
+ );
41
+ }
42
+
43
+ /** True when a browser-capture failure message is the not-rendered kind. */
44
+ export function isScreenshotTargetNotRendered(message: string): boolean {
45
+ return message.includes(SCREENSHOT_TARGET_NOT_RENDERED);
46
+ }
package/src/seo.test.ts CHANGED
@@ -199,6 +199,15 @@ describe("injectSeoIntoHead", () => {
199
199
  const out = injectSeoIntoHead(html, headBlock);
200
200
  expect(out).toContain(headBlock);
201
201
  });
202
+
203
+ it("matches uppercase </HEAD> and preserves <svg><title> inside <body>", () => {
204
+ const html =
205
+ "<html><HEAD><title>LayoutTitle</title></HEAD><body><svg><title>IconTitle</title></svg></body></html>";
206
+ const out = injectSeoIntoHead(html, "<title>PageTitle</title>");
207
+ expect(out).not.toContain("LayoutTitle");
208
+ expect(out).toContain("<title>PageTitle</title>\n</HEAD>");
209
+ expect(out).toContain("<svg><title>IconTitle</title></svg>");
210
+ });
202
211
  });
203
212
 
204
213
  describe("schemas", () => {
package/src/seo.ts CHANGED
@@ -340,12 +340,16 @@ export function renderSeoHead(input: SeoMetaInput): string {
340
340
  * the layout HTML.
341
341
  */
342
342
  export function injectSeoIntoHead(html: string, headBlock: string): string {
343
- // Strip a layout-supplied <title> if present; we'll re-emit it in
344
- // the headBlock at the right position.
345
- const titleStripped = html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
346
- if (titleStripped.includes("</head>")) {
347
- return titleStripped.replace("</head>", () => `${headBlock}\n</head>`);
343
+ const headCloseMatch = /<\/head\s*>/i.exec(html);
344
+ if (headCloseMatch) {
345
+ const headEnd = headCloseMatch.index;
346
+ // Strip a layout-supplied <title> inside <head> only (never an SVG
347
+ // <title> in <body>); we re-emit the page title in headBlock.
348
+ const beforeClose = html.slice(0, headEnd).replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
349
+ const afterClose = html.slice(headEnd);
350
+ return `${beforeClose}${headBlock}\n${afterClose}`;
348
351
  }
349
352
  // No closing head tag — prepend the block to the document.
353
+ const titleStripped = html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
350
354
  return `${headBlock}\n${titleStripped}`;
351
355
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import { isUnsafeKey } from "../../safe-keys.js";
12
- import { applyDtcgWrites } from "../../themes.js";
12
+ import { applyDtcgWrites, removeDtcgPath } from "../../themes.js";
13
13
  import { importTailwind } from "../tailwind.js";
14
14
 
15
15
  describe("isUnsafeKey", () => {
@@ -51,4 +51,12 @@ describe("applyDtcgWrites / setLeafAtPath path guard (S5)", () => {
51
51
  const out = applyDtcgWrites({}, { "color.primary": "#fff" }, { "color.primary": "color" });
52
52
  expect(JSON.stringify(out)).toContain("primary");
53
53
  });
54
+
55
+ it("removeDtcgPath rejects __proto__/constructor/prototype and inherited keys", () => {
56
+ const doc = applyDtcgWrites({}, { "color.primary": "#fff" }, { "color.primary": "color" });
57
+ expect(removeDtcgPath(doc, "__proto__.toString").removed).toBe(false);
58
+ expect(removeDtcgPath(doc, "color.toString").removed).toBe(false);
59
+ expect(removeDtcgPath(doc, "constructor.prototype").removed).toBe(false);
60
+ expect(removeDtcgPath(doc, "color.primary").removed).toBe(true);
61
+ });
54
62
  });
@@ -62,9 +62,29 @@ describe("applyThemeLiteralBinding", () => {
62
62
  expect(r.suggestions).toEqual([{ literal: "6rem", varName: "--spacing-xl" }]);
63
63
  });
64
64
 
65
+ it("leaves a var() fallback literal as written (e2e-livedit: edit_content anchors missed)", () => {
66
+ // The AI authors `var(--x, #literal)`; rebinding the fallback stored
67
+ // CSS it never wrote and its next edit_content anchor did not match.
68
+ const css =
69
+ ".f{border-top:1px solid var(--color-foreground, #0f172a);background:var(--color-surface, #4f46e5);color:var( --color-x ,#0F172A)}";
70
+ const r = applyThemeLiteralBinding(css, TOKENS);
71
+ expect(r.css).toBe(css);
72
+ expect(r.rewrites).toEqual([]);
73
+ });
74
+
75
+ it("still binds a bare literal next to a var() reference", () => {
76
+ const r = applyThemeLiteralBinding(
77
+ ".f{color:var(--color-foreground);background:#4f46e5}",
78
+ TOKENS,
79
+ );
80
+ expect(r.css).toBe(".f{color:var(--color-foreground);background:var(--color-primary)}");
81
+ });
82
+
65
83
  it("formats a report and stays silent on no-ops", () => {
66
84
  const bound = applyThemeLiteralBinding(".a{color:#0F172A}", TOKENS);
67
85
  expect(formatBindingReport(bound)).toContain("#0F172A→var(--color-foreground)");
86
+ // The AI must know the stored css differs from what it sent.
87
+ expect(formatBindingReport(bound)).toContain("anchor later edit_content calls");
68
88
  const noop = applyThemeLiteralBinding(".a{color:#123456}", TOKENS);
69
89
  expect(formatBindingReport(noop)).toBeNull();
70
90
  });
@@ -26,6 +26,9 @@ import type { ThemeDocument } from "./themes.js";
26
26
 
27
27
  const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:oklch|rgba?|hsla?|lab|lch|hwb)\([^()]*\)/g;
28
28
  const DIMENSION_VALUE_RE = /^-?\d+(\.\d+)?(rem|em|px|%)$/;
29
+ /** The text before a literal ends in an open `var(--name,` — the literal is
30
+ * (the start of) that var's fallback. */
31
+ const VAR_FALLBACK_BEFORE_RE = /var\(\s*--[\w-]+\s*,[^()]*$/i;
29
32
 
30
33
  export interface ThemeValueMap {
31
34
  /** normalized color value → var name (first-declared wins). */
@@ -107,11 +110,18 @@ export function applyThemeLiteralBinding(css: string, tokens: ThemeDocument): Li
107
110
  });
108
111
 
109
112
  out = out.replace(COLOR_LITERAL_RE, (literal, offset: number) => {
110
- // Never rewrite inside an already-produced var(...) fallback or a
111
- // remaining gradient (a gradient that did NOT match stays literal
112
- // by decision — rebinding its stops would half-tokenise it).
113
+ // Never rewrite inside a remaining gradient (a gradient that did NOT
114
+ // match stays literal by decision — rebinding its stops would
115
+ // half-tokenise it) or a var() fallback. The author already referenced
116
+ // a var there; rebinding its fallback produced
117
+ // `var(--color-border, var(--color-border))` and
118
+ // `var(--color-surface, var(--color-primary-foreground))` — CSS the AI
119
+ // never wrote, so its next edit_content anchored on its own copy
120
+ // missed (5 "oldString not found" in e2e-livedit, 2026-10). An unknown
121
+ // var name in front of the fallback is reported by the unknown-var check.
113
122
  const before = out.slice(Math.max(0, offset - 64), offset);
114
123
  if (/gradient\([^()]*$/i.test(before)) return literal;
124
+ if (VAR_FALLBACK_BEFORE_RE.test(before)) return literal;
115
125
  const varName = map.colors.get(normalizeColor(literal));
116
126
  if (varName === undefined) return literal;
117
127
  const entry = counts.get(literal) ?? { to: varName, count: 0 };
@@ -145,7 +155,9 @@ export function formatBindingReport(result: LiteralBindingResult): string | null
145
155
  parts.push(
146
156
  `🔗 bound ${total} literal(s) to theme vars: ${result.rewrites
147
157
  .map((r) => `${r.from}→var(${r.to})${r.count > 1 ? `×${r.count}` : ""}`)
148
- .join(", ")}`,
158
+ .join(
159
+ ", ",
160
+ )} — the STORED css has var(…) in place of those literals, so anchor later edit_content calls on that text, not on the css you sent`,
149
161
  );
150
162
  }
151
163
  if (result.suggestions.length > 0) {
@@ -82,4 +82,13 @@ describe("renderThemeCss CSS-token sanitization (S6)", () => {
82
82
  } as unknown as ThemeDocument;
83
83
  expect(renderThemeCss(doc)).toContain("--color-grün:#0a0;");
84
84
  });
85
+
86
+ it("scrubs CSS declaration/block delimiters (; { }) from fontFamily and shadow strings", () => {
87
+ const css = renderThemeCss(
88
+ fontDoc("Inter; } body { background: url(https://evil.example/x) } :root { --x: 1"),
89
+ );
90
+ expect(css).toBe(
91
+ ":root{--font-body:Inter body background: url(https://evil.example/x) :root --x: 1;}",
92
+ );
93
+ });
85
94
  });
@@ -549,7 +549,8 @@ function sanitizeCssTokenValue(value: string): string {
549
549
  out = out
550
550
  .replace(/<\/style/gi, "")
551
551
  .replace(/<!--/g, "")
552
- .replace(/--!?>/g, "");
552
+ .replace(/--!?>/g, "")
553
+ .replace(/[;{}]/g, "");
553
554
  } while (out !== prev);
554
555
  return out;
555
556
  }
package/src/themes.ts CHANGED
@@ -882,19 +882,21 @@ export function removeDtcgPath(
882
882
  doc: ThemeDocument,
883
883
  path: string,
884
884
  ): { tokens: ThemeDocument; removed: boolean } {
885
- const out: ThemeDocument = JSON.parse(JSON.stringify(doc));
886
885
  const parts = path.split(".");
886
+ if (parts.some(isUnsafeKey)) return { tokens: doc, removed: false };
887
+ const out: ThemeDocument = JSON.parse(JSON.stringify(doc));
887
888
  let cur: Record<string, unknown> = out;
888
889
  for (let i = 0; i < parts.length - 1; i++) {
889
890
  const k = parts[i];
890
891
  if (!k) continue;
892
+ if (!Object.hasOwn(cur, k)) return { tokens: doc, removed: false };
891
893
  const next = cur[k];
892
894
  if (!next || typeof next !== "object") return { tokens: doc, removed: false };
893
895
  cur = next as Record<string, unknown>;
894
896
  }
895
897
  const last = parts[parts.length - 1];
896
898
  if (!last) return { tokens: doc, removed: false };
897
- if (!(last in cur)) return { tokens: doc, removed: false };
899
+ if (!Object.hasOwn(cur, last)) return { tokens: doc, removed: false };
898
900
  delete cur[last];
899
901
  return { tokens: out, removed: true };
900
902
  }