@caelo-cms/shared 0.10.64 → 0.10.66
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/markup-structure.d.ts +9 -1
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +90 -13
- package/dist/markup-structure.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/module-id-tag.d.ts +26 -0
- package/dist/module-id-tag.d.ts.map +1 -0
- package/dist/module-id-tag.js +39 -0
- package/dist/module-id-tag.js.map +1 -0
- package/dist/nested-module-render.d.ts +4 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +7 -1
- package/dist/nested-module-render.js.map +1 -1
- package/dist/preview-compose.d.ts +2 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +12 -26
- package/dist/preview-compose.js.map +1 -1
- package/dist/release-manifest.d.ts +5 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +13 -0
- package/dist/release-manifest.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/markup-structure.test.ts +85 -0
- package/src/markup-structure.ts +104 -10
- package/src/mcp-access.ts +73 -0
- package/src/module-id-tag.ts +38 -0
- package/src/nested-module-render.test.ts +98 -5
- package/src/nested-module-render.ts +14 -1
- package/src/preview-compose-nested.test.ts +7 -2
- package/src/preview-compose.test.ts +27 -0
- package/src/preview-compose.ts +13 -28
- package/src/release-manifest.ts +13 -0
- 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
|
@@ -53,6 +53,9 @@ const PARENT_CI_ID = "00000000-0000-0000-0000-00000000bbb1";
|
|
|
53
53
|
const CHILD_CI_ID = "00000000-0000-0000-0000-00000000bbb2";
|
|
54
54
|
const GRAND_CI_ID = "00000000-0000-0000-0000-00000000bbb3";
|
|
55
55
|
|
|
56
|
+
/** The id stamp every nested module root carries (#1027). */
|
|
57
|
+
const tag = (moduleId: string) => `data-caelo-module-id="${moduleId}"`;
|
|
58
|
+
|
|
56
59
|
describe("renderModuleWithContent — primitive substitution", () => {
|
|
57
60
|
it("replaces {{name}} with values[name] and tracks the touched module", () => {
|
|
58
61
|
const resolver = buildResolver(
|
|
@@ -154,7 +157,9 @@ describe("renderModuleWithContent — nested module slots", () => {
|
|
|
154
157
|
],
|
|
155
158
|
);
|
|
156
159
|
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
157
|
-
expect(r.html).toBe(
|
|
160
|
+
expect(r.html).toBe(
|
|
161
|
+
`<section><div ${tag(CHILD_MOD_ID)}><span ${tag(GRAND_MOD_ID)}>deep</span></div></section>`,
|
|
162
|
+
);
|
|
158
163
|
expect(r.missingSlots).toEqual([]);
|
|
159
164
|
// All three modules touched — caller uses this set for CSS/JS dedup.
|
|
160
165
|
expect(r.touchedModuleIds.has(PARENT_MOD_ID)).toBe(true);
|
|
@@ -201,7 +206,7 @@ describe("renderModuleWithContent — nested module slots", () => {
|
|
|
201
206
|
],
|
|
202
207
|
);
|
|
203
208
|
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
204
|
-
expect(r.html).toBe(
|
|
209
|
+
expect(r.html).toBe(`<ul><li ${tag(CHILD_MOD_ID)}>A</li><li ${tag(CHILD_MOD_ID)}>B</li></ul>`);
|
|
205
210
|
expect(r.missingSlots).toEqual([]);
|
|
206
211
|
});
|
|
207
212
|
});
|
|
@@ -697,7 +702,7 @@ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
|
|
|
697
702
|
],
|
|
698
703
|
);
|
|
699
704
|
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
700
|
-
expect(r.html).toContain(
|
|
705
|
+
expect(r.html).toContain(`<article ${tag(CHILD_MOD_ID)}>OK</article>`);
|
|
701
706
|
expect(r.html).toContain("<!-- caelo:missing reason=module-list-malformed cards[1] -->");
|
|
702
707
|
expect(r.missingSlots).toContain("module-list-malformed:cards[1]");
|
|
703
708
|
});
|
|
@@ -781,7 +786,9 @@ describe("renderPlacedModule — the composer's entry point", () => {
|
|
|
781
786
|
resolver,
|
|
782
787
|
{},
|
|
783
788
|
);
|
|
784
|
-
expect(r.html).toBe(
|
|
789
|
+
expect(r.html).toBe(
|
|
790
|
+
`<section><div ${tag(CHILD_MOD_ID)}>Pro<a ${tag(GRAND_MOD_ID)}>Buy</a></div></section>`,
|
|
791
|
+
);
|
|
785
792
|
expect(r.failures).toEqual([]);
|
|
786
793
|
expect([...r.touchedModuleIds].sort()).toEqual([CHILD_MOD_ID, GRAND_MOD_ID].sort());
|
|
787
794
|
});
|
|
@@ -848,7 +855,7 @@ describe("renderPlacedModule — only template-referenced nested fields render",
|
|
|
848
855
|
resolver,
|
|
849
856
|
{},
|
|
850
857
|
);
|
|
851
|
-
expect(r.html).toBe(
|
|
858
|
+
expect(r.html).toBe(`<section><article ${tag(CHILD_MOD_ID)}>Pro</article></section>`);
|
|
852
859
|
expect(r.failures).toEqual([]);
|
|
853
860
|
expect(r.missingSlots).toEqual([]);
|
|
854
861
|
expect([...r.touchedModuleIds]).toEqual([CHILD_MOD_ID]);
|
|
@@ -885,3 +892,89 @@ describe("renderPlacedModule — only template-referenced nested fields render",
|
|
|
885
892
|
expect(r.failures).toEqual([{ field: "card", reason: `content-instance-missing:${GONE_CI}` }]);
|
|
886
893
|
});
|
|
887
894
|
});
|
|
895
|
+
|
|
896
|
+
describe("#1027 — nested module roots carry data-caelo-module-id", () => {
|
|
897
|
+
// The FAQ case from the searchVIU install: a module-list of FAQ items.
|
|
898
|
+
const faq: NestedModuleResource = {
|
|
899
|
+
moduleId: PARENT_MOD_ID,
|
|
900
|
+
slug: "faq",
|
|
901
|
+
html: '<div class="faq">{{#items}}{{/items}}{{>featured}}</div>',
|
|
902
|
+
css: "",
|
|
903
|
+
js: "",
|
|
904
|
+
fields: [
|
|
905
|
+
{ name: "items", kind: "module-list" },
|
|
906
|
+
{ name: "featured", kind: "module" },
|
|
907
|
+
],
|
|
908
|
+
};
|
|
909
|
+
const item: NestedModuleResource = {
|
|
910
|
+
moduleId: CHILD_MOD_ID,
|
|
911
|
+
slug: "faq-entry",
|
|
912
|
+
html: "<details><summary>{{question}}</summary><p>{{answer}}</p></details>",
|
|
913
|
+
css: "",
|
|
914
|
+
js: "",
|
|
915
|
+
fields: [
|
|
916
|
+
{ name: "question", kind: "text" },
|
|
917
|
+
{ name: "answer", kind: "text" },
|
|
918
|
+
],
|
|
919
|
+
};
|
|
920
|
+
const values = {
|
|
921
|
+
items: [
|
|
922
|
+
{ moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID },
|
|
923
|
+
{ moduleId: CHILD_MOD_ID, contentInstanceId: GRAND_CI_ID },
|
|
924
|
+
],
|
|
925
|
+
featured: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID },
|
|
926
|
+
};
|
|
927
|
+
const resolver = buildResolver(
|
|
928
|
+
[faq, item],
|
|
929
|
+
[
|
|
930
|
+
{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values, deletedAt: null },
|
|
931
|
+
{
|
|
932
|
+
id: CHILD_CI_ID,
|
|
933
|
+
moduleId: CHILD_MOD_ID,
|
|
934
|
+
values: { question: "Q1", answer: "A1" },
|
|
935
|
+
deletedAt: null,
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
id: GRAND_CI_ID,
|
|
939
|
+
moduleId: CHILD_MOD_ID,
|
|
940
|
+
values: { question: "Q2", answer: "A2" },
|
|
941
|
+
deletedAt: null,
|
|
942
|
+
},
|
|
943
|
+
],
|
|
944
|
+
);
|
|
945
|
+
const expected =
|
|
946
|
+
`<div class="faq"><details ${tag(CHILD_MOD_ID)}><summary>Q1</summary><p>A1</p></details>` +
|
|
947
|
+
`<details ${tag(CHILD_MOD_ID)}><summary>Q2</summary><p>A2</p></details>` +
|
|
948
|
+
`<details ${tag(CHILD_MOD_ID)}><summary>Q1</summary><p>A1</p></details></div>`;
|
|
949
|
+
|
|
950
|
+
it("tags module and module-list roots in the editor preview path", () => {
|
|
951
|
+
const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
|
|
952
|
+
// The placed module's own root is left to the composer (tagModuleId).
|
|
953
|
+
expect(r.html).toBe(expected);
|
|
954
|
+
expect(r.failures).toEqual([]);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it("tags them identically in the composer / static build path", () => {
|
|
958
|
+
const r = renderPlacedModule({ html: faq.html, fields: faq.fields, values }, resolver, {});
|
|
959
|
+
expect(r.html).toBe(expected);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("leaves a failure comment untagged (no element to carry the id)", () => {
|
|
963
|
+
const r = renderPlacedModule(
|
|
964
|
+
{
|
|
965
|
+
html: faq.html,
|
|
966
|
+
fields: faq.fields,
|
|
967
|
+
values: {
|
|
968
|
+
items: [{ moduleId: CHILD_MOD_ID, contentInstanceId: "missing" }],
|
|
969
|
+
featured: values.featured,
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
resolver,
|
|
973
|
+
{},
|
|
974
|
+
);
|
|
975
|
+
expect(r.html).toBe(
|
|
976
|
+
'<div class="faq"><!-- caelo:missing reason=content-instance-missing missing -->' +
|
|
977
|
+
`<details ${tag(CHILD_MOD_ID)}><summary>Q1</summary><p>A1</p></details></div>`,
|
|
978
|
+
);
|
|
979
|
+
});
|
|
980
|
+
});
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
* comment + missingSlots entry. Same channel; the operator sees the
|
|
52
52
|
* gap in the preview, and the static generator refuses the build.
|
|
53
53
|
*
|
|
54
|
+
* Module ids: the root element of every nested module render carries
|
|
55
|
+
* `data-caelo-module-id` (#1027), the same stamp the composer puts on a
|
|
56
|
+
* page placement's root, in the preview and the static build alike.
|
|
57
|
+
*
|
|
54
58
|
* CSS + JS dedup: every unique module touched during recursion
|
|
55
59
|
* contributes its CSS + JS once. The caller collects the seen-set out
|
|
56
60
|
* of band so the page's <head>/<style> + footer scripts are stable.
|
|
@@ -58,6 +62,7 @@
|
|
|
58
62
|
|
|
59
63
|
import type { ModuleFieldKind } from "./content.js";
|
|
60
64
|
import { isLinkFailureMarker, type LinkResolver } from "./link-refs.js";
|
|
65
|
+
import { tagModuleId } from "./module-id-tag.js";
|
|
61
66
|
import { caeloMissingComment, renderTemplate, type TemplateField } from "./template-engine.js";
|
|
62
67
|
|
|
63
68
|
const MAX_RECURSION_DEPTH = 8;
|
|
@@ -343,7 +348,15 @@ function renderPartial(
|
|
|
343
348
|
): string | undefined {
|
|
344
349
|
const hit = nestedRefForPartial(key, fieldByName, values);
|
|
345
350
|
if (!hit) return undefined;
|
|
346
|
-
|
|
351
|
+
// #1027 — a nested module's root carries its own id, exactly like a
|
|
352
|
+
// page placement's (the composer tags those), so the live-edit overlay
|
|
353
|
+
// and the quality checks attribute its markup to the nested module
|
|
354
|
+
// instead of reading it as free HTML of the parent. A failure comment
|
|
355
|
+
// has no element and stays as it is.
|
|
356
|
+
return tagModuleId(
|
|
357
|
+
renderInner(hit.ref.moduleId, hit.ref.contentInstanceId, descend(ctx, hit.step)),
|
|
358
|
+
hit.ref.moduleId,
|
|
359
|
+
);
|
|
347
360
|
}
|
|
348
361
|
|
|
349
362
|
/**
|
|
@@ -30,6 +30,9 @@ const CARD_A = "00000000-0000-0000-0000-0000000ca001";
|
|
|
30
30
|
const CARD_B = "00000000-0000-0000-0000-0000000ca002";
|
|
31
31
|
const GONE = "00000000-0000-0000-0000-00000000dead";
|
|
32
32
|
|
|
33
|
+
/** The id stamp every nested module root carries (#1027). */
|
|
34
|
+
const tag = (moduleId: string) => `data-caelo-module-id="${moduleId}"`;
|
|
35
|
+
|
|
33
36
|
const card: NestedModuleResource = {
|
|
34
37
|
moduleId: CARD_MOD,
|
|
35
38
|
slug: "card",
|
|
@@ -89,7 +92,9 @@ describe("composePageWithLayout — nested modules", () => {
|
|
|
89
92
|
|
|
90
93
|
it("renders every module-list element with the nested resolver", () => {
|
|
91
94
|
const out = compose(grid(refs), resolver([card], instances));
|
|
92
|
-
expect(out.html).toContain(
|
|
95
|
+
expect(out.html).toContain(
|
|
96
|
+
`<article ${tag(CARD_MOD)}>Starter</article><article ${tag(CARD_MOD)}>Pro</article>`,
|
|
97
|
+
);
|
|
93
98
|
expect(out.html).not.toContain("needs recursive renderer");
|
|
94
99
|
expect(out.moduleFailures).toEqual([]);
|
|
95
100
|
});
|
|
@@ -104,7 +109,7 @@ describe("composePageWithLayout — nested modules", () => {
|
|
|
104
109
|
grid([refs[0], { moduleId: CARD_MOD, contentInstanceId: GONE }, "not-a-ref"]),
|
|
105
110
|
resolver([card], instances),
|
|
106
111
|
);
|
|
107
|
-
expect(out.html).toContain(
|
|
112
|
+
expect(out.html).toContain(`<article ${tag(CARD_MOD)}>Starter</article>`);
|
|
108
113
|
expect(out.moduleFailures).toEqual([
|
|
109
114
|
{
|
|
110
115
|
blockName: "content",
|
|
@@ -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
|
@@ -29,6 +29,7 @@ import { BASE_TECHNICAL_CSS } from "./base-css.js";
|
|
|
29
29
|
import type { ModuleFieldKind } from "./content.js";
|
|
30
30
|
import { findInteractionIds, interactionAssets } from "./interactions.js";
|
|
31
31
|
import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
|
|
32
|
+
import { tagModuleId } from "./module-id-tag.js";
|
|
32
33
|
import {
|
|
33
34
|
type NestedModuleResource,
|
|
34
35
|
type NestedRenderFailure,
|
|
@@ -47,6 +48,9 @@ import { renderTemplate, type TemplateField } from "./template-engine.js";
|
|
|
47
48
|
import { renderThemeCss as renderThemeCssFromTokens } from "./theme-render.js";
|
|
48
49
|
import type { ThemeDocument } from "./themes.js";
|
|
49
50
|
|
|
51
|
+
// Re-exported so existing callers keep importing it from the composer.
|
|
52
|
+
export { tagModuleId };
|
|
53
|
+
|
|
50
54
|
export interface ComposeModule {
|
|
51
55
|
readonly moduleId: string;
|
|
52
56
|
readonly slug: string;
|
|
@@ -369,7 +373,10 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
369
373
|
html = injectBefore(html, HEAD_CLOSE_RE, styleTag);
|
|
370
374
|
}
|
|
371
375
|
if (allJs.length > 0) {
|
|
372
|
-
const
|
|
376
|
+
const joinedJs = allJs.join("\n");
|
|
377
|
+
const scriptTag = scriptNeedsEncoding(joinedJs)
|
|
378
|
+
? `<script defer data-source="modules" src="data:text/javascript;base64,${utf8ToBase64(joinedJs)}"></script>`
|
|
379
|
+
: `<script defer data-source="modules">\n${joinedJs}\n</script>`;
|
|
373
380
|
html = injectBefore(html, BODY_CLOSE_RE, scriptTag);
|
|
374
381
|
}
|
|
375
382
|
|
|
@@ -381,16 +388,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
381
388
|
};
|
|
382
389
|
}
|
|
383
390
|
|
|
384
|
-
/**
|
|
385
|
-
* Insert `data-caelo-module-id="<id>"` into the first opening tag of
|
|
386
|
-
* the module's HTML. Idempotent — re-tagging an already-tagged module
|
|
387
|
-
* is a no-op. Comments / DOCTYPE / leading whitespace before the first
|
|
388
|
-
* tag are tolerated. Modules that have no opening tag (pure text)
|
|
389
|
-
* return unchanged because there's nothing to attach to.
|
|
390
|
-
*
|
|
391
|
-
* Exported so callers (admin preview endpoint, static generator,
|
|
392
|
-
* tests) can reuse the same logic.
|
|
393
|
-
*/
|
|
394
391
|
/**
|
|
395
392
|
* P6.7.5 — return the items for a `nav-menu/<slug>` set when a module's
|
|
396
393
|
* slug starts with `nav-menu-`. Returns null when the module is not a
|
|
@@ -602,18 +599,6 @@ function recordFailures(
|
|
|
602
599
|
}
|
|
603
600
|
}
|
|
604
601
|
|
|
605
|
-
export function tagModuleId(html: string, moduleId: string): string {
|
|
606
|
-
if (!html) return html;
|
|
607
|
-
const firstOpen = /<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/;
|
|
608
|
-
const m = firstOpen.exec(html);
|
|
609
|
-
if (!m) return html;
|
|
610
|
-
// Already tagged?
|
|
611
|
-
const tagAttrs = m[2] ?? "";
|
|
612
|
-
if (/\sdata-caelo-module-id\s*=/.test(tagAttrs)) return html;
|
|
613
|
-
const replaced = `<${m[1]}${tagAttrs} data-caelo-module-id="${moduleId}">`;
|
|
614
|
-
return html.slice(0, m.index) + replaced + html.slice(m.index + m[0].length);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
602
|
/**
|
|
618
603
|
* P6.7.6 — layout-aware composer. Runs the template composer first,
|
|
619
604
|
* extracts the resulting body content, then renders the layout HTML
|
|
@@ -958,11 +943,11 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
958
943
|
);
|
|
959
944
|
}
|
|
960
945
|
if (jsParts.length > 0) {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
`<script defer data-source="modules">\n${
|
|
965
|
-
);
|
|
946
|
+
const joinedJs = jsParts.join("\n");
|
|
947
|
+
const scriptTag = scriptNeedsEncoding(joinedJs)
|
|
948
|
+
? `<script defer data-source="modules" src="data:text/javascript;base64,${utf8ToBase64(joinedJs)}"></script>`
|
|
949
|
+
: `<script defer data-source="modules">\n${joinedJs}\n</script>`;
|
|
950
|
+
html = injectBefore(html, BODY_CLOSE_RE, scriptTag);
|
|
966
951
|
}
|
|
967
952
|
|
|
968
953
|
// De-duplicate slot accounting across both passes — the template's
|
package/src/release-manifest.ts
CHANGED
|
@@ -40,6 +40,19 @@ export const ReleaseManifestSchema = z.object({
|
|
|
40
40
|
version: z.string().regex(SEMVER, "expected a SemVer version without a leading v"),
|
|
41
41
|
channel: z.enum(RELEASE_CHANNELS),
|
|
42
42
|
digests: z.object({ admin: digest, gateway: digest, ops: digest }),
|
|
43
|
+
/**
|
|
44
|
+
* #1025 — the linux/amd64 platform manifest inside each image index of
|
|
45
|
+
* `digests`. The release images are OCI indexes (buildx adds an
|
|
46
|
+
* attestation manifest even to a single-platform build), and Cloud Run
|
|
47
|
+
* records the platform manifest it resolved — not the index a deploy
|
|
48
|
+
* pinned — in a revision's `spec.containers[0].image` and
|
|
49
|
+
* `status.imageDigest`. Signing this form too lets an upgrader tell
|
|
50
|
+
* which release serves without a registry round trip. Absent on
|
|
51
|
+
* releases from before the field: optional, so manifestVersion stays 1
|
|
52
|
+
* and older readers ignore it; readers then list the index itself
|
|
53
|
+
* (content-addressed, so it cannot be forged either).
|
|
54
|
+
*/
|
|
55
|
+
platformDigests: z.object({ admin: digest, gateway: digest, ops: digest }).optional(),
|
|
43
56
|
minFromVersion: z.string().regex(SEMVER),
|
|
44
57
|
infraContractVersion: z.number().int().positive(),
|
|
45
58
|
migrations: z.object({
|
|
@@ -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
|
}
|