@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.
Files changed (68) hide show
  1. package/dist/ai-tools.d.ts +2 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +7 -0
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +46 -3
  6. package/dist/bug-intake.d.ts.map +1 -1
  7. package/dist/bug-intake.js +52 -3
  8. package/dist/bug-intake.js.map +1 -1
  9. package/dist/build-page.d.ts.map +1 -1
  10. package/dist/build-page.js +5 -0
  11. package/dist/build-page.js.map +1 -1
  12. package/dist/content.d.ts +1 -0
  13. package/dist/content.d.ts.map +1 -1
  14. package/dist/content.js +28 -3
  15. package/dist/content.js.map +1 -1
  16. package/dist/css-var-scan.js +3 -3
  17. package/dist/css-var-scan.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/live-events.d.ts +44 -0
  23. package/dist/live-events.d.ts.map +1 -0
  24. package/dist/live-events.js +128 -0
  25. package/dist/live-events.js.map +1 -0
  26. package/dist/markup-structure.d.ts +20 -2
  27. package/dist/markup-structure.d.ts.map +1 -1
  28. package/dist/markup-structure.js +69 -3
  29. package/dist/markup-structure.js.map +1 -1
  30. package/dist/media.d.ts +15 -0
  31. package/dist/media.d.ts.map +1 -1
  32. package/dist/media.js.map +1 -1
  33. package/dist/module-id-tag.d.ts +4 -1
  34. package/dist/module-id-tag.d.ts.map +1 -1
  35. package/dist/module-id-tag.js +11 -2
  36. package/dist/module-id-tag.js.map +1 -1
  37. package/dist/preview-compose.d.ts +18 -1
  38. package/dist/preview-compose.d.ts.map +1 -1
  39. package/dist/preview-compose.js +26 -7
  40. package/dist/preview-compose.js.map +1 -1
  41. package/dist/theme-render.js +11 -26
  42. package/dist/theme-render.js.map +1 -1
  43. package/dist/themes.d.ts.map +1 -1
  44. package/dist/themes.js +12 -9
  45. package/dist/themes.js.map +1 -1
  46. package/dist/version.d.ts +2 -2
  47. package/dist/version.js +1 -1
  48. package/package.json +1 -1
  49. package/src/ai-tools.ts +10 -0
  50. package/src/bug-intake.test.ts +47 -0
  51. package/src/bug-intake.ts +58 -3
  52. package/src/build-page.ts +5 -0
  53. package/src/content.ts +37 -3
  54. package/src/css-var-scan.ts +3 -3
  55. package/src/index.ts +1 -0
  56. package/src/live-events.ts +138 -0
  57. package/src/markup-structure.test.ts +47 -0
  58. package/src/markup-structure.ts +74 -3
  59. package/src/media.ts +12 -0
  60. package/src/module-id-tag.test.ts +38 -0
  61. package/src/module-id-tag.ts +11 -2
  62. package/src/preview-compose-deferrals.test.ts +44 -0
  63. package/src/preview-compose.ts +47 -10
  64. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  65. package/src/theme-render-sanitize.test.ts +8 -0
  66. package/src/theme-render.ts +11 -26
  67. package/src/themes.ts +12 -7
  68. package/src/version.ts +1 -1
@@ -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 interface ComposeDeferral {
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: ComposeDeferral,
585
+ deferral: ComposeGateDeferral,
570
586
  ): string {
571
- const attr = (v: string): string =>
572
- v.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
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 CSS, keyed by placeholder slug: one withheld module's
697
- // placeholder used on five placements is emitted once.
698
- const deferredCss = new Map<string, string>();
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
- deferredCss.set(deferral.placeholderModuleSlug, deferral.placeholderCss);
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.values()) {
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("rejects a write whose path contains __proto__ (no pollution, no write)", () => {
45
- const out = applyDtcgWrites({}, { "a.__proto__.polluted": "x" }, {});
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
- expect(out).not.toHaveProperty("a");
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");
@@ -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, scrub
523
- * the two sequences that let an attacker break out of the surrounding
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
- * - `</style` (case-insensitive) — premature end-tag would close
527
- * the style block and let everything after it parse as HTML
528
- * (`fontFamily: "Inter</style><script>alert(1)</script>"`).
529
- * - `<!--` / `-->` — CDO/CDC tokens; some HTML parsers treat them
530
- * specially inside `<style>` and they're never valid in a real
531
- * CSS value anyway.
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
- // Run to a fixed point: removing one sequence can re-create another
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 (CodeQL
884
- // js/prototype-pollution-utility). Reject the whole write on any unsafe
885
- // segment rather than partially applying it.
886
- if (parts.some(isUnsafeKey)) return;
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 - 1; i++) {
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
  /**
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.11.1";
25
+ export const CAELO_VERSION = "0.11.2";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this