@caelo-cms/shared 0.11.1 → 0.11.2
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 +2 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +7 -0
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +46 -3
- package/dist/bug-intake.d.ts.map +1 -1
- package/dist/bug-intake.js +52 -3
- package/dist/bug-intake.js.map +1 -1
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +5 -0
- package/dist/build-page.js.map +1 -1
- package/dist/content.d.ts +1 -0
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +28 -3
- package/dist/content.js.map +1 -1
- package/dist/css-var-scan.js +3 -3
- package/dist/css-var-scan.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/live-events.d.ts +44 -0
- package/dist/live-events.d.ts.map +1 -0
- package/dist/live-events.js +128 -0
- package/dist/live-events.js.map +1 -0
- package/dist/markup-structure.d.ts +20 -2
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +69 -3
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +15 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js.map +1 -1
- package/dist/module-id-tag.d.ts +4 -1
- package/dist/module-id-tag.d.ts.map +1 -1
- package/dist/module-id-tag.js +11 -2
- package/dist/module-id-tag.js.map +1 -1
- package/dist/preview-compose.d.ts +18 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +26 -7
- package/dist/preview-compose.js.map +1 -1
- package/dist/theme-render.js +11 -26
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +12 -9
- 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 +10 -0
- package/src/bug-intake.test.ts +47 -0
- package/src/bug-intake.ts +58 -3
- package/src/build-page.ts +5 -0
- package/src/content.ts +37 -3
- package/src/css-var-scan.ts +3 -3
- package/src/index.ts +1 -0
- package/src/live-events.ts +138 -0
- package/src/markup-structure.test.ts +47 -0
- package/src/markup-structure.ts +74 -3
- package/src/media.ts +12 -0
- package/src/module-id-tag.test.ts +38 -0
- package/src/module-id-tag.ts +11 -2
- package/src/preview-compose-deferrals.test.ts +44 -0
- package/src/preview-compose.ts +47 -10
- package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
- package/src/theme-render-sanitize.test.ts +8 -0
- package/src/theme-render.ts +11 -26
- package/src/themes.ts +12 -7
- package/src/version.ts +1 -1
package/src/preview-compose.ts
CHANGED
|
@@ -30,7 +30,7 @@ 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
32
|
import type { ListPageContext } from "./lists/resolve.js";
|
|
33
|
-
import { tagModuleId } from "./module-id-tag.js";
|
|
33
|
+
import { MODULE_ID_ATTRIBUTE, tagModuleId } from "./module-id-tag.js";
|
|
34
34
|
import {
|
|
35
35
|
type NestedModuleResource,
|
|
36
36
|
type NestedRenderFailure,
|
|
@@ -530,12 +530,28 @@ function recordFailures(
|
|
|
530
530
|
* A module withheld from this render, as resolved by the plugin host
|
|
531
531
|
* (#450). The composer never decides this — it only knows the verdict.
|
|
532
532
|
*/
|
|
533
|
-
export
|
|
533
|
+
export type ComposeDeferral = ComposeGateDeferral | ComposeOmitDeferral;
|
|
534
|
+
|
|
535
|
+
/** Withheld behind a placeholder until the plugin's runtime releases it. */
|
|
536
|
+
export interface ComposeGateDeferral {
|
|
534
537
|
readonly pluginSlug: string;
|
|
535
538
|
readonly reason: string;
|
|
536
539
|
readonly placeholderModuleSlug: string;
|
|
537
540
|
readonly placeholderHtml: string;
|
|
538
541
|
readonly placeholderCss: string;
|
|
542
|
+
readonly omit?: undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Left out entirely: nothing on the visitor's side can release it. The
|
|
547
|
+
* plugin host passes the editor notice only to the editor preview; a
|
|
548
|
+
* build gets `null` and the module's place stays empty.
|
|
549
|
+
*/
|
|
550
|
+
export interface ComposeOmitDeferral {
|
|
551
|
+
readonly pluginSlug: string;
|
|
552
|
+
readonly reason: string;
|
|
553
|
+
readonly omit: true;
|
|
554
|
+
readonly editorNotice: { readonly html: string; readonly css: string } | null;
|
|
539
555
|
}
|
|
540
556
|
|
|
541
557
|
/**
|
|
@@ -566,10 +582,8 @@ function wrapDeferredModule(
|
|
|
566
582
|
moduleHtml: string,
|
|
567
583
|
module: Pick<ComposeModule, "moduleId" | "slug" | "css" | "js">,
|
|
568
584
|
nestedModules: readonly Pick<NestedModuleResource, "moduleId" | "css" | "js">[],
|
|
569
|
-
deferral:
|
|
585
|
+
deferral: ComposeGateDeferral,
|
|
570
586
|
): string {
|
|
571
|
-
const attr = (v: string): string =>
|
|
572
|
-
v.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
573
587
|
// Inside the inert <template>, only an element's own end tag ends a
|
|
574
588
|
// <style> / <script>; a `</template` there is text. So CSS needs just
|
|
575
589
|
// its own close escaped, JS that could end its <script> ships base64
|
|
@@ -598,6 +612,25 @@ function wrapDeferredModule(
|
|
|
598
612
|
].join("");
|
|
599
613
|
}
|
|
600
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Emit an omitted module: nothing in a build, the editor notice in the
|
|
617
|
+
* preview. The module's markup, CSS and JS never reach the page — not
|
|
618
|
+
* even in a `<template>`, since nothing would ever clone it out. The
|
|
619
|
+
* notice carries the module id (and its slug as the label) so the
|
|
620
|
+
* editor's chat affordances reference the module it stands in for.
|
|
621
|
+
*/
|
|
622
|
+
function omittedModuleNotice(
|
|
623
|
+
module: Pick<ComposeModule, "moduleId" | "slug">,
|
|
624
|
+
deferral: ComposeOmitDeferral,
|
|
625
|
+
): string {
|
|
626
|
+
if (deferral.editorNotice === null) return "";
|
|
627
|
+
return `<div data-caelo-deferred="${attr(deferral.pluginSlug)}" data-reason="${attr(deferral.reason)}" data-module="${attr(module.slug)}" data-caelo-deferred-omitted ${MODULE_ID_ATTRIBUTE}="${attr(module.moduleId)}" aria-label="${attr(module.slug)}">${deferral.editorNotice.html}</div>`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function attr(v: string): string {
|
|
631
|
+
return v.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
632
|
+
}
|
|
633
|
+
|
|
601
634
|
export interface ComposeLayoutBlock {
|
|
602
635
|
readonly blockName: string;
|
|
603
636
|
readonly modules: readonly ComposeModule[];
|
|
@@ -693,9 +726,9 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
693
726
|
// Duplicate rule blocks made the cascade order-dependent and bloated
|
|
694
727
|
// every page the same module appeared on twice.
|
|
695
728
|
const seenAssetModules = new Set<string>();
|
|
696
|
-
// Placeholder
|
|
697
|
-
//
|
|
698
|
-
const deferredCss = new
|
|
729
|
+
// Placeholder and notice CSS: one withheld module's placeholder used
|
|
730
|
+
// on five placements is emitted once.
|
|
731
|
+
const deferredCss = new Set<string>();
|
|
699
732
|
const moduleFailures: ComposeModuleFailure[] = [];
|
|
700
733
|
// Nested modules rendered since the last asset flush (see flushNestedAssets).
|
|
701
734
|
const nestedAssetIds: string[] = [];
|
|
@@ -734,7 +767,11 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
734
767
|
}
|
|
735
768
|
const tagged = tagModuleId(baseHtml, m.moduleId);
|
|
736
769
|
if (!deferral) return tagged;
|
|
737
|
-
|
|
770
|
+
if (deferral.omit) {
|
|
771
|
+
if (deferral.editorNotice) deferredCss.add(deferral.editorNotice.css);
|
|
772
|
+
return omittedModuleNotice(m, deferral);
|
|
773
|
+
}
|
|
774
|
+
deferredCss.add(deferral.placeholderCss);
|
|
738
775
|
const gatedNested = [...gatedNestedIds].map((id) => {
|
|
739
776
|
const nested = input.nestedModules?.getModule(id);
|
|
740
777
|
// renderModuleFields resolved every id it reports through this
|
|
@@ -791,7 +828,7 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
791
828
|
flushNestedAssets();
|
|
792
829
|
}
|
|
793
830
|
|
|
794
|
-
for (const css of deferredCss
|
|
831
|
+
for (const css of deferredCss) {
|
|
795
832
|
if (css.trim().length > 0) cssParts.push(css);
|
|
796
833
|
}
|
|
797
834
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
11
|
import { isUnsafeKey } from "../../safe-keys.js";
|
|
12
12
|
import { applyDtcgWrites, removeDtcgPath } from "../../themes.js";
|
|
13
|
+
import { UnknownTokenName } from "../../themes-errors.js";
|
|
13
14
|
import { importTailwind } from "../tailwind.js";
|
|
14
15
|
|
|
15
16
|
describe("isUnsafeKey", () => {
|
|
@@ -41,10 +42,19 @@ describe("importTailwind prototype-pollution guard (S5)", () => {
|
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
describe("applyDtcgWrites / setLeafAtPath path guard (S5)", () => {
|
|
44
|
-
it("
|
|
45
|
-
|
|
45
|
+
it("refuses a write whose path contains __proto__, naming the path (no pollution)", () => {
|
|
46
|
+
expect(() => applyDtcgWrites({}, { "a.__proto__.polluted": "x" }, {})).toThrow(
|
|
47
|
+
/UnknownTokenName: cannot infer canonical path for 'a\.__proto__\.polluted'/,
|
|
48
|
+
);
|
|
46
49
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
47
|
-
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("refuses constructor/prototype segments, including as the leaf", () => {
|
|
53
|
+
expect(() => applyDtcgWrites({}, { "color.constructor": "#fff" }, {})).toThrow(
|
|
54
|
+
UnknownTokenName,
|
|
55
|
+
);
|
|
56
|
+
expect(() => applyDtcgWrites({}, { "prototype.x": "#fff" }, {})).toThrow(UnknownTokenName);
|
|
57
|
+
expect(({} as Record<string, unknown>).x).toBeUndefined();
|
|
48
58
|
});
|
|
49
59
|
|
|
50
60
|
it("still applies a legitimate dotted write", () => {
|
|
@@ -38,6 +38,14 @@ describe("renderThemeCss CSS-token sanitization (S6)", () => {
|
|
|
38
38
|
expect(css).not.toContain("-->");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("drops every < and > in one pass, so no nesting re-creates a breakout", () => {
|
|
42
|
+
for (const hostile of ["<</style>/style>", "<<!--!---->>", "</sty</style>le>", "a--!>b"]) {
|
|
43
|
+
const css = renderThemeCss(fontDoc(hostile));
|
|
44
|
+
expect(css).not.toMatch(/[<>]/);
|
|
45
|
+
expect(css.toLowerCase()).not.toContain("</style");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
41
49
|
it("leaves a normal fontFamily untouched", () => {
|
|
42
50
|
const css = renderThemeCss(fontDoc("Inter, sans-serif"));
|
|
43
51
|
expect(css).toContain("Inter, sans-serif");
|
package/src/theme-render.ts
CHANGED
|
@@ -519,16 +519,17 @@ function asString(value: unknown): string {
|
|
|
519
519
|
* CSS values are otherwise unrestricted in character set (font-family
|
|
520
520
|
* names legitimately contain quotes, commas, parens, hyphens), so we
|
|
521
521
|
* don't HTML-encode broadly — that would break perfectly-valid
|
|
522
|
-
* `font-family: "Segoe UI", sans-serif` declarations. Instead,
|
|
523
|
-
*
|
|
524
|
-
* `<style>…</style>` context:
|
|
522
|
+
* `font-family: "Segoe UI", sans-serif` declarations. Instead, drop the
|
|
523
|
+
* single characters that let a value leave its context:
|
|
525
524
|
*
|
|
526
|
-
* -
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
525
|
+
* - `<` and `>` — every way out of the surrounding `<style>…</style>`
|
|
526
|
+
* starts with one (`</style`, `<!--`, `-->`, `--!>`), and no color,
|
|
527
|
+
* dimension, shadow or font name contains them. Removing single
|
|
528
|
+
* characters cannot re-create a removed sequence, so one pass is
|
|
529
|
+
* complete (a sequence-based scrub needs a fixed-point loop, which
|
|
530
|
+
* CodeQL's js/incomplete-multi-character-sanitization cannot see).
|
|
531
|
+
* - `;`, `{`, `}` — they would end the declaration or the rule and
|
|
532
|
+
* inject CSS of the value's choosing.
|
|
532
533
|
*
|
|
533
534
|
* The themeColorToken / themeDimensionToken Zod regexes already
|
|
534
535
|
* constrain those token types to character sets that exclude `<`, so
|
|
@@ -536,21 +537,5 @@ function asString(value: unknown): string {
|
|
|
536
537
|
* (`typography.X.fontFamily`, `$description` if it ever lands in CSS).
|
|
537
538
|
*/
|
|
538
539
|
function sanitizeCssTokenValue(value: string): string {
|
|
539
|
-
|
|
540
|
-
// (e.g. `<!--</style>-->` → `</style` only appears after the comment
|
|
541
|
-
// tokens go), which a single pass would leave behind (CodeQL
|
|
542
|
-
// js/incomplete-multi-character-sanitization). `--!>` is the HTML5
|
|
543
|
-
// alternative comment-close and must be scrubbed alongside `-->`
|
|
544
|
-
// (CodeQL js/bad-tag-filter).
|
|
545
|
-
let out = value;
|
|
546
|
-
let prev: string;
|
|
547
|
-
do {
|
|
548
|
-
prev = out;
|
|
549
|
-
out = out
|
|
550
|
-
.replace(/<\/style/gi, "")
|
|
551
|
-
.replace(/<!--/g, "")
|
|
552
|
-
.replace(/--!?>/g, "")
|
|
553
|
-
.replace(/[;{}]/g, "");
|
|
554
|
-
} while (out !== prev);
|
|
555
|
-
return out;
|
|
540
|
+
return value.replace(/[<>;{}]/g, "");
|
|
556
541
|
}
|
package/src/themes.ts
CHANGED
|
@@ -880,21 +880,26 @@ function setLeafAtPath(doc: Record<string, unknown>, path: string, leaf: unknown
|
|
|
880
880
|
const parts = path.split(".");
|
|
881
881
|
// The dotted path comes from the caller's `writes` map (form / AI input),
|
|
882
882
|
// so a segment like `__proto__` or `constructor` would let the walk below
|
|
883
|
-
// (`cur[k] = {}`, `cur[last] = leaf`) pollute the prototype chain
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
883
|
+
// (`cur[k] = {}`, `cur[last] = leaf`) pollute the prototype chain. No
|
|
884
|
+
// token is named that, so the write fails loudly instead of being dropped
|
|
885
|
+
// (CLAUDE.md §2). The comparison sits inline at the assignment so CodeQL's
|
|
886
|
+
// js/prototype-pollution-utility sees the guard.
|
|
887
887
|
let cur: Record<string, unknown> = doc;
|
|
888
|
-
for (let i = 0; i < parts.length
|
|
888
|
+
for (let i = 0; i < parts.length; i++) {
|
|
889
889
|
const k = parts[i];
|
|
890
890
|
if (!k) continue;
|
|
891
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") {
|
|
892
|
+
throw new UnknownTokenName(path, []);
|
|
893
|
+
}
|
|
894
|
+
if (i === parts.length - 1) {
|
|
895
|
+
cur[k] = leaf;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
891
898
|
if (cur[k] === undefined || cur[k] === null || typeof cur[k] !== "object") {
|
|
892
899
|
cur[k] = {};
|
|
893
900
|
}
|
|
894
901
|
cur = cur[k] as Record<string, unknown>;
|
|
895
902
|
}
|
|
896
|
-
const last = parts[parts.length - 1];
|
|
897
|
-
if (last) cur[last] = leaf;
|
|
898
903
|
}
|
|
899
904
|
|
|
900
905
|
/**
|