@caelo-cms/shared 0.10.66 → 0.10.68
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 +30 -14
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +33 -14
- package/dist/ai-tools.js.map +1 -1
- package/dist/build-page.d.ts +7 -6
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +17 -13
- package/dist/build-page.js.map +1 -1
- package/dist/cloud-shell-install.d.ts +4 -7
- package/dist/cloud-shell-install.d.ts.map +1 -1
- package/dist/cloud-shell-install.js +14 -19
- package/dist/cloud-shell-install.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/media.d.ts +2 -2
- package/dist/phase-timer.d.ts +36 -0
- package/dist/phase-timer.d.ts.map +1 -0
- package/dist/phase-timer.js +54 -0
- package/dist/phase-timer.js.map +1 -0
- package/dist/run-batched.d.ts +12 -0
- package/dist/run-batched.d.ts.map +1 -0
- package/dist/run-batched.js +37 -0
- package/dist/run-batched.js.map +1 -0
- package/dist/strip-cdata.d.ts +23 -0
- package/dist/strip-cdata.d.ts.map +1 -1
- package/dist/strip-cdata.js +31 -0
- package/dist/strip-cdata.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +29 -4
- 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 +34 -14
- package/src/build-page.test.ts +5 -4
- package/src/build-page.ts +17 -14
- package/src/cloud-shell-install.test.ts +5 -10
- package/src/cloud-shell-install.ts +14 -21
- package/src/index.ts +2 -0
- package/src/phase-timer.test.ts +48 -0
- package/src/phase-timer.ts +62 -0
- package/src/run-batched.test.ts +40 -0
- package/src/run-batched.ts +39 -0
- package/src/strip-cdata.test.ts +30 -1
- package/src/strip-cdata.ts +31 -0
- package/src/theme-dimension.test.ts +61 -0
- package/src/themes.ts +30 -5
- package/src/version.ts +1 -1
package/src/build-page.test.ts
CHANGED
|
@@ -70,19 +70,20 @@ describe("buildPageInputSchema — page target modes", () => {
|
|
|
70
70
|
expect(r.data.page.metaDescription).toBe("Plans and prices.");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it("rejects a blank meta description
|
|
73
|
+
it("rejects a blank meta description", () => {
|
|
74
74
|
const blank = buildPageInputSchema.safeParse({
|
|
75
75
|
page: { slug: "pricing", title: "Pricing", metaDescription: " " },
|
|
76
76
|
modules: [],
|
|
77
77
|
});
|
|
78
78
|
expect(blank.success).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("takes a meta description with pageId (the existing page's first fill; run 40018361466)", () => {
|
|
79
82
|
const existing = buildPageInputSchema.safeParse({
|
|
80
83
|
page: { pageId: UUID, metaDescription: "Plans and prices." },
|
|
81
84
|
modules: [],
|
|
82
85
|
});
|
|
83
|
-
expect(existing.success).toBe(
|
|
84
|
-
if (existing.success) return;
|
|
85
|
-
expect(existing.error.issues[0]!.message).toContain("autofill_page_seo");
|
|
86
|
+
expect(existing.success).toBe(true);
|
|
86
87
|
});
|
|
87
88
|
|
|
88
89
|
it("rejects a page target with neither pageId nor slug+title", () => {
|
package/src/build-page.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { z } from "zod";
|
|
23
|
-
import { positionInputSchema } from "./ai-tools.js";
|
|
23
|
+
import { positionInputSchema, sourceTextArg } from "./ai-tools.js";
|
|
24
24
|
import {
|
|
25
25
|
contentInstanceCreateSchema,
|
|
26
26
|
MODULE_CSS_MAX,
|
|
@@ -155,9 +155,9 @@ export const buildPageModuleSchema = z
|
|
|
155
155
|
description: z.string().max(1000).optional(),
|
|
156
156
|
kind: moduleKindSchema.optional(),
|
|
157
157
|
type: slugSchema.optional(),
|
|
158
|
-
html: z.string().min(1).max(MODULE_HTML_MAX).optional(),
|
|
159
|
-
css: z.string().max(MODULE_CSS_MAX).optional(),
|
|
160
|
-
js: z.string().max(MODULE_JS_MAX).optional(),
|
|
158
|
+
html: sourceTextArg(z.string().min(1).max(MODULE_HTML_MAX)).optional(),
|
|
159
|
+
css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
|
|
160
|
+
js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
|
|
161
161
|
fields: z.array(moduleFieldSchema).max(64).optional(),
|
|
162
162
|
/** issue #164 slice 2 — opt-in mechanical token binding (tool layer). */
|
|
163
163
|
bindThemeLiterals: z.boolean().optional(),
|
|
@@ -239,9 +239,13 @@ export const buildPageTargetSchema = z
|
|
|
239
239
|
* The new page's meta description, written in the same transaction
|
|
240
240
|
* with the fill-once semantics of `pages_seo.autofill` (CLAUDE.md §2).
|
|
241
241
|
* A staging build refuses a published page without one, so the page
|
|
242
|
-
* is Stage-ready from the call that creates it.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* is Stage-ready from the call that creates it. With `pageId` it is the
|
|
243
|
+
* existing page's FIRST description, same fill-once rule: a page that
|
|
244
|
+
* already has a different one keeps it (`kept-existing`, never an
|
|
245
|
+
* overwrite). Refusing it there (as #993 did) failed the natural "build
|
|
246
|
+
* the page's content and describe it in one pass" call (e2e-livedit run
|
|
247
|
+
* 40018361466) although the op applied it safely for importPageId
|
|
248
|
+
* rebuilds of existing pages all along.
|
|
245
249
|
*/
|
|
246
250
|
metaDescription: z.string().trim().min(1).max(SEO_DESCRIPTION_HARD_MAX).optional(),
|
|
247
251
|
})
|
|
@@ -251,13 +255,6 @@ export const buildPageTargetSchema = z
|
|
|
251
255
|
["slug", "title", "name", "templateId", "status", "parentPageId"] as const
|
|
252
256
|
).filter((k) => page[k] !== undefined);
|
|
253
257
|
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
|
-
}
|
|
261
258
|
if (createKeys.length > 0) {
|
|
262
259
|
ctx.addIssue({
|
|
263
260
|
code: z.ZodIssueCode.custom,
|
|
@@ -309,6 +306,12 @@ export type BuildPageInput = z.infer<typeof buildPageInputSchema>;
|
|
|
309
306
|
|
|
310
307
|
/** `pages.build_page` op output — one row per placed module, in input order. */
|
|
311
308
|
export const buildPagePlacementResultSchema = z.object({
|
|
309
|
+
/**
|
|
310
|
+
* The entry's index in the call's `modules` array. Detached (nested-only)
|
|
311
|
+
* entries are not placed, so the row's position in `placements` is NOT
|
|
312
|
+
* the input index once one precedes it — label results with this.
|
|
313
|
+
*/
|
|
314
|
+
index: z.number().int().nonnegative(),
|
|
312
315
|
blockName: z.string(),
|
|
313
316
|
position: z.number().int().nonnegative(),
|
|
314
317
|
moduleId: z.string(),
|
|
@@ -9,19 +9,14 @@ import {
|
|
|
9
9
|
} from "./cloud-shell-install.js";
|
|
10
10
|
|
|
11
11
|
describe("cloudShellInstallUrl", () => {
|
|
12
|
-
it("
|
|
13
|
-
expect(cloudShellInstallUrl("
|
|
14
|
-
"https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=
|
|
12
|
+
it("names no version: the install repo's main installs the newest release", () => {
|
|
13
|
+
expect(cloudShellInstallUrl("gcp")).toBe(
|
|
14
|
+
"https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=main&cloudshell_tutorial=gcp.md",
|
|
15
15
|
);
|
|
16
|
-
expect(cloudShellInstallUrl("
|
|
17
|
-
"cloudshell_git_branch=
|
|
16
|
+
expect(cloudShellInstallUrl("gcp-firebase")).toContain(
|
|
17
|
+
"cloudshell_git_branch=main&cloudshell_tutorial=gcp-firebase.md",
|
|
18
18
|
);
|
|
19
19
|
});
|
|
20
|
-
|
|
21
|
-
it("refuses pre-releases and garbage: they have no pinned branch", () => {
|
|
22
|
-
expect(() => cloudShellInstallUrl("0.11.0-rc.1", "gcp")).toThrow(/only stable releases/);
|
|
23
|
-
expect(() => cloudShellInstallUrl("latest", "gcp")).toThrow();
|
|
24
|
-
});
|
|
25
20
|
});
|
|
26
21
|
|
|
27
22
|
describe("cloudShellUpgradeUrl (#816)", () => {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The "Open in Google Cloud Shell"
|
|
4
|
+
* The "Open in Google Cloud Shell" links (epic #696, Phase 3).
|
|
5
5
|
*
|
|
6
|
-
* The link opens github.com/caelo-cms/install
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* The INSTALL link names no version: it opens github.com/caelo-cms/install
|
|
7
|
+
* on `main`, whose `run.sh` resolves the newest stable release itself (npm
|
|
8
|
+
* `latest`) and verifies it before anything runs, so the link is right for
|
|
9
|
+
* every release and never moves. The UPGRADE link names one target release:
|
|
10
|
+
* it opens that release's `release-v<version>` branch (created by the
|
|
11
|
+
* install repository's track-release workflow, never moved), and `run.sh`
|
|
12
|
+
* there takes the version from the branch name. One builder per link for
|
|
13
|
+
* every place that shows it, so they can't drift.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { isNewerVersion } from "./version.js";
|
|
@@ -25,25 +27,16 @@ export type CloudShellProvider = "gcp" | "gcp-firebase";
|
|
|
25
27
|
const STABLE = /^v?(\d+\.\d+\.\d+)$/;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
|
-
* The Cloud Shell install link
|
|
30
|
+
* The Cloud Shell install link: always the newest stable release.
|
|
29
31
|
*
|
|
30
|
-
* @
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @example cloudShellInstallUrl("0.10.47", "gcp")
|
|
34
|
-
* // → "https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=release-v0.10.47&cloudshell_tutorial=gcp.md"
|
|
32
|
+
* @example cloudShellInstallUrl("gcp")
|
|
33
|
+
* // → "https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=main&cloudshell_tutorial=gcp.md"
|
|
35
34
|
*/
|
|
36
|
-
export function cloudShellInstallUrl(
|
|
37
|
-
const m = STABLE.exec(version);
|
|
38
|
-
if (!m) {
|
|
39
|
-
throw new Error(
|
|
40
|
-
`no Cloud Shell install link for "${version}": only stable releases are pinned in ${CLOUD_SHELL_INSTALL_REPO}`,
|
|
41
|
-
);
|
|
42
|
-
}
|
|
35
|
+
export function cloudShellInstallUrl(provider: CloudShellProvider): string {
|
|
43
36
|
return [
|
|
44
37
|
"https://shell.cloud.google.com/cloudshell/editor",
|
|
45
38
|
`?cloudshell_git_repo=${CLOUD_SHELL_INSTALL_REPO}`,
|
|
46
|
-
|
|
39
|
+
"&cloudshell_git_branch=main",
|
|
47
40
|
`&cloudshell_tutorial=${provider}.md`,
|
|
48
41
|
].join("");
|
|
49
42
|
}
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export * from "./mcp-access.js";
|
|
|
47
47
|
export * from "./media.js";
|
|
48
48
|
export * from "./nested-module-render.js";
|
|
49
49
|
export * from "./page-log.js";
|
|
50
|
+
export * from "./phase-timer.js";
|
|
50
51
|
export * from "./platform-updates.js";
|
|
51
52
|
export * from "./plugin-preview.js";
|
|
52
53
|
export * from "./preview-compose.js";
|
|
@@ -64,6 +65,7 @@ export * from "./redirect-target.js";
|
|
|
64
65
|
export * from "./release-manifest.js";
|
|
65
66
|
export * from "./responsive-images.js";
|
|
66
67
|
export * from "./result.js";
|
|
68
|
+
export * from "./run-batched.js";
|
|
67
69
|
export * from "./safe-keys.js";
|
|
68
70
|
export * from "./screenshot-capture.js";
|
|
69
71
|
export * from "./seo.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { PhaseTimer, phaseTimingSchema } from "./phase-timer.js";
|
|
5
|
+
|
|
6
|
+
function clock(...ticks: number[]): () => number {
|
|
7
|
+
let i = 0;
|
|
8
|
+
return () => {
|
|
9
|
+
const t = ticks[Math.min(i, ticks.length - 1)];
|
|
10
|
+
i += 1;
|
|
11
|
+
return t ?? 0;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("PhaseTimer", () => {
|
|
16
|
+
it("each lap measures the time since the previous one", () => {
|
|
17
|
+
const t = new PhaseTimer(clock(0, 10.4, 35.6, 40));
|
|
18
|
+
t.lap("load");
|
|
19
|
+
t.lap("render");
|
|
20
|
+
expect(t.phases()).toEqual([
|
|
21
|
+
{ phase: "load", ms: 10 },
|
|
22
|
+
{ phase: "render", ms: 25 },
|
|
23
|
+
]);
|
|
24
|
+
expect(t.totalMs()).toBe(40);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("includes steps timed elsewhere under a prefix, before the lap that spans them", () => {
|
|
28
|
+
const t = new PhaseTimer(clock(0, 120));
|
|
29
|
+
t.include("generator", [
|
|
30
|
+
{ phase: "compose", ms: 80 },
|
|
31
|
+
{ phase: "media", ms: 30 },
|
|
32
|
+
]);
|
|
33
|
+
t.lap("generator");
|
|
34
|
+
expect(t.phases()).toEqual([
|
|
35
|
+
{ phase: "generator.compose", ms: 80 },
|
|
36
|
+
{ phase: "generator.media", ms: 30 },
|
|
37
|
+
{ phase: "generator", ms: 120 },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("phases() is a copy and every entry passes the wire schema", () => {
|
|
42
|
+
const t = new PhaseTimer(clock(0, 5));
|
|
43
|
+
t.lap("x");
|
|
44
|
+
t.phases().pop();
|
|
45
|
+
expect(t.phases()).toHaveLength(1);
|
|
46
|
+
for (const p of t.phases()) expect(phaseTimingSchema.parse(p)).toEqual(p);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wall-clock timings of the steps of one long operation (a Stage: chat
|
|
5
|
+
* merge, generator passes, upload, parity fingerprint, preview selection).
|
|
6
|
+
* The generator subprocess and the admin share this shape, so the
|
|
7
|
+
* generator's phases arrive in the admin's list prefixed and in order.
|
|
8
|
+
*
|
|
9
|
+
* Laps instead of wrapped callbacks: `lap("x")` closes the step that ran
|
|
10
|
+
* since the previous lap, so a long function gets one line per boundary
|
|
11
|
+
* and keeps its control flow (early throws, shared locals) as it is.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
/** One finished step: its name and how long it took, in whole milliseconds. */
|
|
17
|
+
export const phaseTimingSchema = z.object({
|
|
18
|
+
phase: z.string().min(1),
|
|
19
|
+
ms: z.number().int().nonnegative(),
|
|
20
|
+
});
|
|
21
|
+
export type PhaseTiming = z.infer<typeof phaseTimingSchema>;
|
|
22
|
+
|
|
23
|
+
export class PhaseTimer {
|
|
24
|
+
readonly #now: () => number;
|
|
25
|
+
readonly #phases: PhaseTiming[] = [];
|
|
26
|
+
readonly #start: number;
|
|
27
|
+
#last: number;
|
|
28
|
+
|
|
29
|
+
/** @param now clock in milliseconds; injectable for tests. */
|
|
30
|
+
constructor(now: () => number = () => performance.now()) {
|
|
31
|
+
this.#now = now;
|
|
32
|
+
this.#start = now();
|
|
33
|
+
this.#last = this.#start;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Close the step that ran since the previous lap (or construction) as `phase`. */
|
|
37
|
+
lap(phase: string): void {
|
|
38
|
+
const t = this.#now();
|
|
39
|
+
this.#phases.push({ phase, ms: Math.round(t - this.#last) });
|
|
40
|
+
this.#last = t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Append steps timed elsewhere (the generator subprocess, a publisher)
|
|
45
|
+
* as `<prefix>.<phase>`. They ran inside the current lap, so the lap that
|
|
46
|
+
* closes it still measures the whole span (the remainder is the overhead
|
|
47
|
+
* around them, e.g. spawning the subprocess).
|
|
48
|
+
*/
|
|
49
|
+
include(prefix: string, phases: readonly PhaseTiming[]): void {
|
|
50
|
+
for (const p of phases) this.#phases.push({ phase: `${prefix}.${p.phase}`, ms: p.ms });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Milliseconds since construction. */
|
|
54
|
+
totalMs(): number {
|
|
55
|
+
return Math.round(this.#now() - this.#start);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The steps so far, in the order they finished. */
|
|
59
|
+
phases(): PhaseTiming[] {
|
|
60
|
+
return [...this.#phases];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { runBatched } from "./run-batched.js";
|
|
5
|
+
|
|
6
|
+
describe("runBatched", () => {
|
|
7
|
+
it("runs every item with at most `parallelism` in flight", async () => {
|
|
8
|
+
let inFlight = 0;
|
|
9
|
+
let peak = 0;
|
|
10
|
+
const done: number[] = [];
|
|
11
|
+
await runBatched([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
|
|
12
|
+
inFlight += 1;
|
|
13
|
+
peak = Math.max(peak, inFlight);
|
|
14
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
15
|
+
done.push(n);
|
|
16
|
+
inFlight -= 1;
|
|
17
|
+
});
|
|
18
|
+
expect(done.sort()).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
|
19
|
+
expect(peak).toBe(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("stops starting new items after the first failure and rejects with it", async () => {
|
|
23
|
+
const started: number[] = [];
|
|
24
|
+
await expect(
|
|
25
|
+
runBatched([1, 2, 3, 4, 5, 6], 2, async (n) => {
|
|
26
|
+
started.push(n);
|
|
27
|
+
if (n === 2) throw new Error("boom 2");
|
|
28
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
29
|
+
}),
|
|
30
|
+
).rejects.toThrow("boom 2");
|
|
31
|
+
expect(started.length).toBeLessThan(6);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("handles an empty list and refuses a non-positive parallelism", async () => {
|
|
35
|
+
await runBatched([], 4, async () => {
|
|
36
|
+
throw new Error("never");
|
|
37
|
+
});
|
|
38
|
+
await expect(runBatched([1], 0, async () => {})).rejects.toThrow("positive integer");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run `worker` over `items` with at most `parallelism` in flight, in item
|
|
5
|
+
* order. File and network I/O of a build (page writes, the `current/`
|
|
6
|
+
* mirror, uploads) goes through this: one-at-a-time costs a round trip per
|
|
7
|
+
* file, unbounded `Promise.all` exhausts file descriptors and memory on
|
|
8
|
+
* large sites.
|
|
9
|
+
*
|
|
10
|
+
* The first rejection rejects the whole call; workers already running
|
|
11
|
+
* finish, no new item starts.
|
|
12
|
+
*/
|
|
13
|
+
export async function runBatched<T>(
|
|
14
|
+
items: ReadonlyArray<T>,
|
|
15
|
+
parallelism: number,
|
|
16
|
+
worker: (item: T) => Promise<void>,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
if (!Number.isInteger(parallelism) || parallelism < 1) {
|
|
19
|
+
throw new Error(`runBatched: parallelism must be a positive integer, got ${parallelism}`);
|
|
20
|
+
}
|
|
21
|
+
let cursor = 0;
|
|
22
|
+
let failed = false;
|
|
23
|
+
const next = async (): Promise<void> => {
|
|
24
|
+
while (!failed && cursor < items.length) {
|
|
25
|
+
const i = cursor++;
|
|
26
|
+
try {
|
|
27
|
+
// biome-ignore lint/style/noNonNullAssertion: cursor < length checked
|
|
28
|
+
await worker(items[i]!);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
failed = true;
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const workers: Promise<void>[] = [];
|
|
36
|
+
const n = Math.min(parallelism, items.length);
|
|
37
|
+
for (let i = 0; i < n; i++) workers.push(next());
|
|
38
|
+
await Promise.all(workers);
|
|
39
|
+
}
|
package/src/strip-cdata.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
-
import { stripCdataGuards } from "./strip-cdata.js";
|
|
4
|
+
import { stripCdataGuards, unwrapCdataEnvelope } from "./strip-cdata.js";
|
|
5
5
|
|
|
6
6
|
describe("stripCdataGuards", () => {
|
|
7
7
|
it("leaves clean HTML untouched (fast path)", () => {
|
|
@@ -39,3 +39,32 @@ describe("stripCdataGuards", () => {
|
|
|
39
39
|
expect(stripCdataGuards(html)).toBe(html);
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
describe("unwrapCdataEnvelope", () => {
|
|
44
|
+
// Shape of the add_module css the real-AI suite filed (run 39936064709).
|
|
45
|
+
const css =
|
|
46
|
+
".consent-banner {\n position: fixed;\n}\n@media (max-width: 640px) {\n .x { width: 100%; }\n}";
|
|
47
|
+
|
|
48
|
+
it("decodes a whole-value envelope to its exact body", () => {
|
|
49
|
+
expect(unwrapCdataEnvelope(`<![CDATA[${css}]]>`)).toBe(css);
|
|
50
|
+
expect(unwrapCdataEnvelope(`\n <![CDATA[${css}]]>\n`)).toBe(css);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves values without an enclosing envelope untouched", () => {
|
|
54
|
+
expect(unwrapCdataEnvelope(css)).toBe(css);
|
|
55
|
+
const guarded = `/*<![CDATA[*/ ${css} /*]]>*/`;
|
|
56
|
+
expect(unwrapCdataEnvelope(guarded)).toBe(guarded);
|
|
57
|
+
const midValue = `.a{} <![CDATA[ .b{} ]]>`;
|
|
58
|
+
expect(unwrapCdataEnvelope(midValue)).toBe(midValue);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not decode two CDATA sections as one envelope", () => {
|
|
62
|
+
const two = "<![CDATA[.a{}]]> <![CDATA[.b{}]]>";
|
|
63
|
+
expect(unwrapCdataEnvelope(two)).toBe(two);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("passes non-strings through for the schema to judge", () => {
|
|
67
|
+
expect(unwrapCdataEnvelope(undefined)).toBeUndefined();
|
|
68
|
+
expect(unwrapCdataEnvelope(42)).toBe(42);
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/strip-cdata.ts
CHANGED
|
@@ -48,3 +48,34 @@ export function stripCdataGuards(html: string): string {
|
|
|
48
48
|
if (!html.includes("CDATA") && !html.includes("]]>")) return html;
|
|
49
49
|
return html.replace(CDATA_GUARDS, "");
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
const CDATA_ENVELOPE = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode a whole-value `<![CDATA[ … ]]>` envelope around an AI tool
|
|
56
|
+
* argument that carries source text (module/template/layout html, css, js).
|
|
57
|
+
*
|
|
58
|
+
* Why at the tool boundary: tool parameters reach the model through an
|
|
59
|
+
* XML-shaped encoding, and for long markup strings the model sometimes
|
|
60
|
+
* escapes the WHOLE value as a CDATA section — a transport envelope, not
|
|
61
|
+
* content. The parameter descriptions say not to (issue #993's note), yet
|
|
62
|
+
* the real-AI suite still filed it on add_module css in roughly one run in
|
|
63
|
+
* seven (e.g. 39654716622 on main, 39936064709, 39971619728). Refusing it
|
|
64
|
+
* cost a tool round-trip every time and, for template/layout css (which has
|
|
65
|
+
* no CDATA refusal), stored a stylesheet the browser cannot parse.
|
|
66
|
+
*
|
|
67
|
+
* Only an envelope spanning the entire value is decoded, and only when its
|
|
68
|
+
* body contains no further `]]>` (a single CDATA section cannot). Anything
|
|
69
|
+
* else — `/*<![CDATA[*\/` guards inside the CSS, a marker mid-value — is
|
|
70
|
+
* left untouched so the op validator still refuses it loudly (CLAUDE.md §2):
|
|
71
|
+
* this decodes a malformed encoding of a value the caller did supply; it is
|
|
72
|
+
* not a fallback for missing data.
|
|
73
|
+
*
|
|
74
|
+
* @returns the envelope's body, or the input unchanged.
|
|
75
|
+
*/
|
|
76
|
+
export function unwrapCdataEnvelope(value: unknown): unknown {
|
|
77
|
+
if (typeof value !== "string") return value;
|
|
78
|
+
const m = CDATA_ENVELOPE.exec(value);
|
|
79
|
+
if (!m || m[1] === undefined || m[1].includes("]]>")) return value;
|
|
80
|
+
return m[1];
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dimension values accept CSS math functions (e2e-livedit run 40018361466:
|
|
5
|
+
* set_theme_tokens refused `typography.display` with a fluid
|
|
6
|
+
* `clamp(2.5rem, 2rem + 2.5vw, 4rem)` font size, and the error printed the
|
|
7
|
+
* truncated composite value, which hid the field it was about).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import { themeDocument } from "./themes.js";
|
|
12
|
+
|
|
13
|
+
const display = (fontSize: string) => ({
|
|
14
|
+
typography: {
|
|
15
|
+
display: {
|
|
16
|
+
$type: "typography",
|
|
17
|
+
$value: {
|
|
18
|
+
fontFamily: "Inter, system-ui, -apple-system, 'Segoe UI', sans-serif",
|
|
19
|
+
fontSize,
|
|
20
|
+
fontWeight: 800,
|
|
21
|
+
lineHeight: 1.05,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("theme dimension values", () => {
|
|
28
|
+
it("accept clamp() / calc() / min() / max() font sizes and spacing", () => {
|
|
29
|
+
for (const v of [
|
|
30
|
+
"clamp(2.5rem, 2rem + 2.5vw, 4rem)",
|
|
31
|
+
"calc(1rem + 0.5vw)",
|
|
32
|
+
"min(4rem, 10vw)",
|
|
33
|
+
"max(1rem, calc(0.75rem + 1vw))",
|
|
34
|
+
]) {
|
|
35
|
+
expect(themeDocument.safeParse(display(v)).success).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
const spacing = {
|
|
38
|
+
spacing: { section: { $type: "dimension", $value: "clamp(2rem, 5vw, 5rem)" } },
|
|
39
|
+
};
|
|
40
|
+
expect(themeDocument.safeParse(spacing).success).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("still refuse values that are not a length, and math functions that could close the declaration", () => {
|
|
44
|
+
for (const v of [
|
|
45
|
+
"large",
|
|
46
|
+
"clamp(1rem; } body { display:none }",
|
|
47
|
+
"calc(1rem)</style>",
|
|
48
|
+
"url(x)",
|
|
49
|
+
]) {
|
|
50
|
+
expect(themeDocument.safeParse(display(v)).success).toBe(false);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("name the offending field's value, not the truncated composite", () => {
|
|
55
|
+
const r = themeDocument.safeParse(display("large"));
|
|
56
|
+
expect(r.success).toBe(false);
|
|
57
|
+
if (r.success) return;
|
|
58
|
+
const message = r.error.issues.map((i) => i.message).join(" ");
|
|
59
|
+
expect(message).toContain('(got "large")');
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/themes.ts
CHANGED
|
@@ -51,14 +51,26 @@ const colorValueString = z
|
|
|
51
51
|
"color value must be #hex, oklch(...), rgb(...), or a CSS named color",
|
|
52
52
|
);
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Dimension: a CSS length / percentage / numeric value, or a CSS math
|
|
56
|
+
* function over them (`clamp()`, `calc()`, `min()`, `max()`).
|
|
57
|
+
*
|
|
58
|
+
* The math functions are accepted because fluid type is the idiomatic way to
|
|
59
|
+
* size a display heading — `clamp(2.5rem, 2rem + 2.5vw, 4rem)` — and token
|
|
60
|
+
* values are emitted verbatim as CSS custom properties, where a browser
|
|
61
|
+
* resolves them like any length. Refusing them (e2e-livedit run 40018361466,
|
|
62
|
+
* set_theme_tokens `typography.display`) cost a retry and the AI downgraded to
|
|
63
|
+
* a fixed size it had not chosen. The arguments are limited to numbers,
|
|
64
|
+
* units, operators, commas and nested parentheses, so a value cannot close
|
|
65
|
+
* the declaration (`;`, `{`, `}`) or carry markup.
|
|
66
|
+
*/
|
|
55
67
|
const dimensionValueString = z
|
|
56
68
|
.string()
|
|
57
69
|
.min(1)
|
|
58
|
-
.max(
|
|
70
|
+
.max(120)
|
|
59
71
|
.regex(
|
|
60
|
-
/^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex|fr|deg|rad|turn|s|ms)?$|^auto$|^0$/,
|
|
61
|
-
"dimension must be a CSS length / number / percentage",
|
|
72
|
+
/^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex|fr|deg|rad|turn|s|ms)?$|^auto$|^0$|^(calc|clamp|min|max)\([0-9a-zA-Z.%+\-*/,\s()]+\)$/,
|
|
73
|
+
"dimension must be a CSS length / number / percentage, or clamp()/calc()/min()/max() over them",
|
|
62
74
|
);
|
|
63
75
|
|
|
64
76
|
/** Font-weight: keyword or 1-1000 number. */
|
|
@@ -422,6 +434,16 @@ const CATEGORY_EXPECTATIONS: Record<string, string> = {
|
|
|
422
434
|
};
|
|
423
435
|
|
|
424
436
|
/** Compact, human-readable render of a rejected `$value` for the error tail. */
|
|
437
|
+
/** The value `path` points at inside `root`; `root` itself for an empty path. */
|
|
438
|
+
function valueAtPath(root: unknown, path: readonly PropertyKey[]): unknown {
|
|
439
|
+
let node = root;
|
|
440
|
+
for (const key of path) {
|
|
441
|
+
if (node === null || typeof node !== "object") return node;
|
|
442
|
+
node = (node as Record<PropertyKey, unknown>)[key];
|
|
443
|
+
}
|
|
444
|
+
return node;
|
|
445
|
+
}
|
|
446
|
+
|
|
425
447
|
function describeValue(value: unknown): string {
|
|
426
448
|
if (typeof value === "string") return JSON.stringify(value);
|
|
427
449
|
if (value === undefined) return "undefined";
|
|
@@ -488,10 +510,13 @@ function refineKnownCategoryLeaves(
|
|
|
488
510
|
});
|
|
489
511
|
} else {
|
|
490
512
|
for (const issue of sub.error.issues) {
|
|
513
|
+
// Name the offending LEAF, not the whole composite `$value`: a
|
|
514
|
+
// typography value prints `fontFamily` first, so the truncated
|
|
515
|
+
// object hid the `fontSize` the issue was about.
|
|
491
516
|
ctx.addIssue({
|
|
492
517
|
code: "custom",
|
|
493
518
|
path: [...path, ...issue.path],
|
|
494
|
-
message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(obj
|
|
519
|
+
message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(valueAtPath(obj, issue.path))})`,
|
|
495
520
|
});
|
|
496
521
|
}
|
|
497
522
|
}
|