@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.
Files changed (50) hide show
  1. package/dist/ai-tools.d.ts +30 -14
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +33 -14
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +7 -6
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +17 -13
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/cloud-shell-install.d.ts +4 -7
  10. package/dist/cloud-shell-install.d.ts.map +1 -1
  11. package/dist/cloud-shell-install.js +14 -19
  12. package/dist/cloud-shell-install.js.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/media.d.ts +2 -2
  18. package/dist/phase-timer.d.ts +36 -0
  19. package/dist/phase-timer.d.ts.map +1 -0
  20. package/dist/phase-timer.js +54 -0
  21. package/dist/phase-timer.js.map +1 -0
  22. package/dist/run-batched.d.ts +12 -0
  23. package/dist/run-batched.d.ts.map +1 -0
  24. package/dist/run-batched.js +37 -0
  25. package/dist/run-batched.js.map +1 -0
  26. package/dist/strip-cdata.d.ts +23 -0
  27. package/dist/strip-cdata.d.ts.map +1 -1
  28. package/dist/strip-cdata.js +31 -0
  29. package/dist/strip-cdata.js.map +1 -1
  30. package/dist/themes.d.ts.map +1 -1
  31. package/dist/themes.js +29 -4
  32. package/dist/themes.js.map +1 -1
  33. package/dist/version.d.ts +2 -2
  34. package/dist/version.js +1 -1
  35. package/package.json +1 -1
  36. package/src/ai-tools.ts +34 -14
  37. package/src/build-page.test.ts +5 -4
  38. package/src/build-page.ts +17 -14
  39. package/src/cloud-shell-install.test.ts +5 -10
  40. package/src/cloud-shell-install.ts +14 -21
  41. package/src/index.ts +2 -0
  42. package/src/phase-timer.test.ts +48 -0
  43. package/src/phase-timer.ts +62 -0
  44. package/src/run-batched.test.ts +40 -0
  45. package/src/run-batched.ts +39 -0
  46. package/src/strip-cdata.test.ts +30 -1
  47. package/src/strip-cdata.ts +31 -0
  48. package/src/theme-dimension.test.ts +61 -0
  49. package/src/themes.ts +30 -5
  50. package/src/version.ts +1 -1
@@ -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 and one given with pageId (fill-once is autofill_page_seo's job there)", () => {
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(false);
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. Create mode (or an
243
- * importPageId rebuild) only — an existing page gets its first
244
- * description from `autofill_page_seo`.
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("pins the release branch of the install repo and the provider's tutorial", () => {
13
- expect(cloudShellInstallUrl("0.10.47", "gcp")).toBe(
14
- "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",
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("v1.2.3", "gcp-firebase")).toContain(
17
- "cloudshell_git_branch=release-v1.2.3&cloudshell_tutorial=gcp-firebase.md",
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" install link (epic #696, Phase 3).
4
+ * The "Open in Google Cloud Shell" links (epic #696, Phase 3).
5
5
  *
6
- * The link opens github.com/caelo-cms/install on the `release-v<version>`
7
- * branch that repository's track-release workflow creates for every stable
8
- * release (and never moves), with the provider's walkthrough tutorial. Its
9
- * `run.sh` pins and verifies exactly that release of the provisioner. One
10
- * builder for every place that shows the link — README, docs, and the
11
- * admin's "run this release in Cloud Shell" hint — so they can't drift.
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 for a stable release.
30
+ * The Cloud Shell install link: always the newest stable release.
29
31
  *
30
- * @throws for a pre-release or non-SemVer version — only stable releases
31
- * get a `release-v<version>` branch in the install repository
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(version: string, provider: CloudShellProvider): string {
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
- `&cloudshell_git_branch=release-v${m[1]}`,
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
+ }
@@ -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
+ });
@@ -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
- /** Dimension: a CSS length / percentage / numeric value. */
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(80)
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.$value)})`,
519
+ message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(valueAtPath(obj, issue.path))})`,
495
520
  });
496
521
  }
497
522
  }
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.66";
25
+ export const CAELO_VERSION = "0.10.68";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this