@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.
- package/dist/build-page.d.ts +10 -0
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +43 -5
- package/dist/build-page.js.map +1 -1
- package/dist/google-oauth.d.ts +7 -0
- package/dist/google-oauth.d.ts.map +1 -1
- package/dist/google-oauth.js +9 -0
- package/dist/google-oauth.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +20 -2
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/mcp-access.d.ts +34 -0
- package/dist/mcp-access.d.ts.map +1 -0
- package/dist/mcp-access.js +58 -0
- package/dist/mcp-access.js.map +1 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +9 -2
- package/dist/preview-compose.js.map +1 -1
- package/dist/screenshot-capture.d.ts +32 -0
- package/dist/screenshot-capture.d.ts.map +1 -0
- package/dist/screenshot-capture.js +36 -0
- package/dist/screenshot-capture.js.map +1 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +9 -5
- package/dist/seo.js.map +1 -1
- package/dist/theme-literal-binding.d.ts.map +1 -1
- package/dist/theme-literal-binding.js +15 -4
- package/dist/theme-literal-binding.js.map +1 -1
- package/dist/theme-render.js +2 -1
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +6 -2
- 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/build-page.test.ts +55 -0
- package/src/build-page.ts +45 -5
- package/src/google-oauth.ts +10 -0
- package/src/index.ts +2 -0
- package/src/link-canonicalise.test.ts +29 -0
- package/src/link-canonicalise.ts +19 -2
- package/src/mcp-access.ts +73 -0
- package/src/preview-compose.test.ts +27 -0
- package/src/preview-compose.ts +9 -6
- package/src/screenshot-capture.ts +46 -0
- package/src/seo.test.ts +9 -0
- package/src/seo.ts +9 -5
- package/src/theme-importers/__tests__/proto-pollution.test.ts +9 -1
- package/src/theme-literal-binding.test.ts +20 -0
- package/src/theme-literal-binding.ts +16 -4
- package/src/theme-render-sanitize.test.ts +9 -0
- package/src/theme-render.ts +2 -1
- package/src/themes.ts +4 -2
- package/src/version.ts +1 -1
package/src/build-page.test.ts
CHANGED
|
@@ -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>"}`
|
|
112
|
-
* this entry's `{moduleId,
|
|
113
|
-
*
|
|
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
|
|
254
|
-
//
|
|
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;
|
package/src/google-oauth.ts
CHANGED
|
@@ -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 = [
|
package/src/link-canonicalise.ts
CHANGED
|
@@ -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 (
|
|
596
|
-
"
|
|
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"><script>"');
|
|
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
|
});
|
package/src/preview-compose.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
`<script defer data-source="modules">\n${
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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
|
});
|
package/src/theme-render.ts
CHANGED
|
@@ -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
|
|
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
|
}
|