@caelo-cms/shared 0.10.60 → 0.10.62

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/auto-update.d.ts +15 -0
  2. package/dist/auto-update.d.ts.map +1 -1
  3. package/dist/auto-update.js +27 -0
  4. package/dist/auto-update.js.map +1 -1
  5. package/dist/cloud-shell-install.d.ts +42 -10
  6. package/dist/cloud-shell-install.d.ts.map +1 -1
  7. package/dist/cloud-shell-install.js +59 -0
  8. package/dist/cloud-shell-install.js.map +1 -1
  9. package/dist/css-scope-scan.d.ts +29 -0
  10. package/dist/css-scope-scan.d.ts.map +1 -0
  11. package/dist/css-scope-scan.js +251 -0
  12. package/dist/css-scope-scan.js.map +1 -0
  13. package/dist/font-assets.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/interactions.d.ts +65 -13
  19. package/dist/interactions.d.ts.map +1 -1
  20. package/dist/interactions.js +132 -33
  21. package/dist/interactions.js.map +1 -1
  22. package/dist/preview-compose.d.ts.map +1 -1
  23. package/dist/preview-compose.js +15 -19
  24. package/dist/preview-compose.js.map +1 -1
  25. package/dist/release-manifest.d.ts +3 -0
  26. package/dist/release-manifest.d.ts.map +1 -1
  27. package/dist/release-manifest.js +11 -0
  28. package/dist/release-manifest.js.map +1 -1
  29. package/dist/static-security-headers.d.ts +50 -0
  30. package/dist/static-security-headers.d.ts.map +1 -0
  31. package/dist/static-security-headers.js +58 -0
  32. package/dist/static-security-headers.js.map +1 -0
  33. package/dist/version.d.ts +2 -2
  34. package/dist/version.js +1 -1
  35. package/package.json +1 -1
  36. package/src/auto-update.test.ts +25 -0
  37. package/src/auto-update.ts +36 -0
  38. package/src/cloud-shell-install.test.ts +36 -1
  39. package/src/cloud-shell-install.ts +64 -0
  40. package/src/css-scope-scan.test.ts +117 -0
  41. package/src/css-scope-scan.ts +263 -0
  42. package/src/index.ts +2 -0
  43. package/src/interactions.test.ts +61 -0
  44. package/src/interactions.ts +181 -26
  45. package/src/preview-compose.test.ts +87 -1
  46. package/src/preview-compose.ts +16 -21
  47. package/src/release-manifest.ts +12 -0
  48. package/src/static-security-headers.test.ts +60 -0
  49. package/src/static-security-headers.ts +64 -0
  50. package/src/version.ts +1 -1
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
+ import { INTERACTIONS } from "./interactions.js";
4
5
  import {
5
6
  ComposeError,
6
7
  composePagePreview,
@@ -528,11 +529,96 @@ describe("composePageWithLayout", () => {
528
529
  expect(out.html).toContain('data-nav-open="false"');
529
530
  // Functional assets exactly once, even with two nav placements.
530
531
  expect(out.html.split("caelo-nav-toggle{display:none").length - 1).toBe(1);
531
- expect(out.html.split("data-nav-open','false'").length - 1).toBe(1);
532
+ expect(out.html.split(INTERACTIONS["mobile-nav"].js).length - 1).toBe(1);
533
+ expect(out.html).toContain('data-caelo-ui="mobile-nav"');
532
534
  // Zero aesthetics in the functional css: no colors beyond currentColor.
533
535
  expect(out.html).not.toMatch(/caelo-nav-toggle\{[^}]*#[0-9a-f]{3}/i);
534
536
  });
535
537
 
538
+ it("ships each vetted interaction once per page, whoever asks for it (issue #160)", () => {
539
+ const tabsModule = {
540
+ ...blankModule,
541
+ moduleId: "33333333-3333-4333-8333-333333333304",
542
+ slug: "pricing-tabs",
543
+ html: '<div class="pricing" data-caelo-ui="tabs"><div role="tablist"></div></div>',
544
+ css: ".pricing{display:grid}",
545
+ };
546
+ const faqModule = {
547
+ ...blankModule,
548
+ moduleId: "33333333-3333-4333-8333-333333333305",
549
+ slug: "faq",
550
+ html: '<div class="faq" data-caelo-ui="accordion"><details><summary>Q</summary>A</details></div>',
551
+ };
552
+ const out = composePageWithLayout({
553
+ templateHtml,
554
+ templateCss: "",
555
+ blocks: [{ blockName: "content", modules: [tabsModule, faqModule, tabsModule] }],
556
+ // The layout's own chrome may ask too.
557
+ layoutHtml: layoutHtml.replace("<body>", "<body><div data-caelo-ui='tabs'></div>"),
558
+ layoutCss: "",
559
+ layoutBlocks: [],
560
+ layoutSlug: "test",
561
+ });
562
+ expect(out.html.split(INTERACTIONS.tabs.js).length - 1).toBe(1);
563
+ expect(out.html.split(INTERACTIONS.tabs.css).length - 1).toBe(1);
564
+ expect(out.html.split(INTERACTIONS.accordion.js).length - 1).toBe(1);
565
+ // Registry order, independent of which module asked first.
566
+ expect(out.html.indexOf(INTERACTIONS.accordion.js)).toBeLessThan(
567
+ out.html.indexOf(INTERACTIONS.tabs.js),
568
+ );
569
+ // Unused ones stay out.
570
+ expect(out.html).not.toContain(INTERACTIONS.carousel.js);
571
+ expect(out.html).not.toContain(INTERACTIONS["mobile-nav"].js);
572
+ // Functional rules follow module CSS so a module display rule cannot unhide a panel.
573
+ expect(out.html.indexOf(".pricing{display:grid}")).toBeLessThan(
574
+ out.html.indexOf(INTERACTIONS.tabs.css),
575
+ );
576
+ });
577
+
578
+ it("ships the interaction a withheld module needs (issue #160)", () => {
579
+ const gated = {
580
+ ...blankModule,
581
+ moduleId: "33333333-3333-4333-8333-333333333306",
582
+ slug: "gallery",
583
+ html: '<section class="gallery" data-caelo-ui="carousel"></section>',
584
+ };
585
+ const out = composePageWithLayout({
586
+ templateHtml,
587
+ templateCss: "",
588
+ blocks: [{ blockName: "content", modules: [gated] }],
589
+ layoutHtml,
590
+ layoutCss: "",
591
+ layoutBlocks: [],
592
+ layoutSlug: "test",
593
+ deferredModules: {
594
+ [gated.moduleId]: {
595
+ pluginSlug: "consent-manager",
596
+ reason: "marketing",
597
+ placeholderModuleSlug: "consent-placeholder",
598
+ placeholderHtml: "<p>consent</p>",
599
+ placeholderCss: "",
600
+ },
601
+ },
602
+ });
603
+ // The snippet binds by delegation, so it works once the plugin clones
604
+ // the module out of its <template> after consent.
605
+ expect(out.html).toContain(INTERACTIONS.carousel.js);
606
+ });
607
+
608
+ it("does not ship anything for a data-caelo-ui value outside the kit (issue #160)", () => {
609
+ const out = composePagePreview({
610
+ templateHtml: `<!doctype html><html><head></head><body><caelo-slot name="content"></caelo-slot></body></html>`,
611
+ templateCss: "",
612
+ blocks: [
613
+ {
614
+ blockName: "content",
615
+ modules: [{ ...blankModule, html: '<div data-caelo-ui="lightbox"></div>' }],
616
+ },
617
+ ],
618
+ });
619
+ expect(out.html).not.toContain('data-source="modules"');
620
+ });
621
+
536
622
  it("injects the invisible technical baseline between theme and modules (issue #151)", () => {
537
623
  const out = composePageWithLayout({
538
624
  templateHtml,
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { BASE_TECHNICAL_CSS } from "./base-css.js";
29
29
  import type { ModuleFieldKind } from "./content.js";
30
- import { NAV_FUNCTIONAL_CSS, NAV_TOGGLE_JS } from "./interactions.js";
30
+ import { findInteractionIds, interactionAssets } from "./interactions.js";
31
31
  import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
32
32
  import {
33
33
  type NestedModuleResource,
@@ -276,8 +276,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
276
276
  const contentByName = new Map<string, string>();
277
277
  const allCss: string[] = [];
278
278
  const allJs: string[] = [];
279
- // issue #160 — see composePageWithLayout.
280
- let navRendered = false;
281
279
  // issue #158 — same per-module dedup as composePageWithLayout.
282
280
  const seenAssetModules = new Set<string>();
283
281
  const moduleFailures: ComposeModuleFailure[] = [];
@@ -298,7 +296,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
298
296
  const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
299
297
  let baseHtml: string;
300
298
  if (navMenuItems !== null) {
301
- navRendered = true;
302
299
  const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
303
300
  baseHtml = nav.html;
304
301
  recordFailures(moduleFailures, block.blockName, m, nav.failures);
@@ -328,13 +325,12 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
328
325
  }
329
326
  }
330
327
 
331
- if (navRendered) {
332
- allCss.push(NAV_FUNCTIONAL_CSS);
333
- allJs.push(NAV_TOGGLE_JS);
334
- }
335
-
336
328
  const replaced = applySlotReplacements(input.templateHtml, { contentByName });
337
329
  let html = replaced.html;
330
+ // issue #160 — see composePageWithLayout.
331
+ const interactions = interactionAssets(new Set(findInteractionIds(html).known));
332
+ allCss.push(...interactions.css);
333
+ allJs.push(...interactions.js);
338
334
 
339
335
  // Theme brand metadata (favicon) leads the injected head block — it
340
336
  // is document metadata, not styling, and is independent of the
@@ -462,13 +458,13 @@ function renderNavMenuHtml(
462
458
  return typeof o.label === "string" && typeof o.href === "string";
463
459
  });
464
460
  // issue #160 — mobile-ready markup: toggle button (three functional
465
- // bars via currentColor) + collapsible list. The functional CSS/JS
466
- // ships once per page from ./interactions.ts when a nav rendered.
461
+ // bars via currentColor) + collapsible list. `data-caelo-ui` pulls the
462
+ // functional CSS/JS from ./interactions.ts in once per page.
467
463
  const toggle =
468
464
  '<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
469
465
  '<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
470
466
  "</button>";
471
- const html = `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
467
+ const html = `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
472
468
  return { html, failures: missing.map((reason) => ({ field: "", reason })) };
473
469
  }
474
470
 
@@ -794,9 +790,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
794
790
  // brittle and reads as a bug).
795
791
  const cssParts: string[] = [];
796
792
  const jsParts: string[] = [];
797
- // issue #160 — set when any nav-menu module rendered; pulls the
798
- // functional nav CSS/JS in exactly once per page.
799
- let navRendered = false;
800
793
  // issue #158 — a module placed N times contributes its CSS/JS ONCE
801
794
  // (first occurrence wins; source order is otherwise preserved).
802
795
  // Duplicate rule blocks made the cascade order-dependent and bloated
@@ -821,7 +814,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
821
814
  const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
822
815
  let baseHtml: string;
823
816
  if (navMenuItems !== null) {
824
- navRendered = true;
825
817
  const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
826
818
  baseHtml = nav.html;
827
819
  recordFailures(moduleFailures, blockName, m, nav.failures);
@@ -913,11 +905,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
913
905
  if (css.trim().length > 0) cssParts.push(css);
914
906
  }
915
907
 
916
- if (navRendered) {
917
- cssParts.push(NAV_FUNCTIONAL_CSS);
918
- jsParts.push(NAV_TOGGLE_JS);
919
- }
920
-
921
908
  // 3. Render the layout HTML, substituting all named slots. The
922
909
  // layout's own markup (the page frame around the blocks) stores
923
910
  // internal links as references too (#724 rollout C canonicalises
@@ -932,6 +919,14 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
932
919
  });
933
920
  let html = replaced.html;
934
921
 
922
+ // issue #160 — every vetted interaction the page's markup asks for
923
+ // (`data-caelo-ui="…"`, from modules, the nav renderer, the layout or
924
+ // the template, withheld modules included) ships once, after module
925
+ // CSS/JS so its functional rules win over a module's display rules.
926
+ const interactions = interactionAssets(new Set(findInteractionIds(html).known));
927
+ cssParts.push(...interactions.css);
928
+ jsParts.push(...interactions.js);
929
+
935
930
  // Theme brand metadata (favicon) — see composePagePreview.
936
931
  const themeHeadLinks = themeHeadFragment(input.theme);
937
932
  if (themeHeadLinks !== null) {
@@ -31,6 +31,9 @@ const GHSA_ID = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/;
31
31
 
32
32
  const digest = z.string().regex(IMAGE_DIGEST, "expected sha256:<64 hex>");
33
33
 
34
+ /** The longest `ownerAction` a release may declare: one sentence. */
35
+ export const OWNER_ACTION_MAX_CHARS = 300;
36
+
34
37
  /** Zod schema of `release-manifest.json` (manifestVersion 1). */
35
38
  export const ReleaseManifestSchema = z.object({
36
39
  manifestVersion: z.literal(1),
@@ -55,6 +58,15 @@ export const ReleaseManifestSchema = z.object({
55
58
  /** Set only via the release-cut input, backed by published GitHub Security Advisories. */
56
59
  security: z.boolean(),
57
60
  advisories: z.array(z.string().regex(GHSA_ID, "expected a GHSA id")),
61
+ /**
62
+ * #868 — what the Owner has to do for this release's breaking change, in
63
+ * one sentence (Settings → Updates shows it under "Before you update").
64
+ * Set from the release-cut input; the release checks refuse a release
65
+ * with a breaking commit without it. Absent on releases from before the
66
+ * field and on releases without a breaking change: optional, so
67
+ * manifestVersion stays 1 and older readers ignore it.
68
+ */
69
+ ownerAction: z.string().min(1).max(OWNER_ACTION_MAX_CHARS).optional(),
58
70
  });
59
71
  export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
60
72
 
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #863 / #891 — the media security headers. The CSP must reach every SVG
5
+ * (the second line behind `sanitizeSvg`) and must NOT reach a PDF:
6
+ * `sandbox` stops Chrome's built-in viewer from opening it.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ isSvgContentType,
12
+ mediaSecurityHeaders,
13
+ SVG_DOCUMENT_CSP,
14
+ SVG_PATH_PATTERN,
15
+ } from "./static-security-headers.js";
16
+
17
+ describe("mediaSecurityHeaders", () => {
18
+ it("sandboxes SVG documents without script, network or plugins", () => {
19
+ const h = mediaSecurityHeaders("image/svg+xml");
20
+ expect(h["content-security-policy"]).toBe(SVG_DOCUMENT_CSP);
21
+ expect(SVG_DOCUMENT_CSP).toContain("default-src 'none'");
22
+ expect(SVG_DOCUMENT_CSP).toContain("sandbox");
23
+ expect(SVG_DOCUMENT_CSP).not.toContain("script-src");
24
+ expect(h["x-content-type-options"]).toBe("nosniff");
25
+ });
26
+
27
+ it("matches the SVG type regardless of case or parameters", () => {
28
+ expect(mediaSecurityHeaders("Image/SVG+XML; charset=utf-8")["content-security-policy"]).toBe(
29
+ SVG_DOCUMENT_CSP,
30
+ );
31
+ expect(isSvgContentType(" image/svg+xml ")).toBe(true);
32
+ expect(isSvgContentType("image/png")).toBe(false);
33
+ });
34
+
35
+ it("keeps embedded rasters and inline styles rendering", () => {
36
+ expect(SVG_DOCUMENT_CSP).toContain("img-src data:");
37
+ expect(SVG_DOCUMENT_CSP).toContain("style-src 'unsafe-inline'");
38
+ });
39
+
40
+ it("leaves PDFs and rasters openable (nosniff only)", () => {
41
+ for (const type of ["application/pdf", "image/png", "image/webp", "video/mp4", "text/html"]) {
42
+ expect(mediaSecurityHeaders(type)).toEqual({ "x-content-type-options": "nosniff" });
43
+ }
44
+ });
45
+ });
46
+
47
+ describe("SVG_PATH_PATTERN", () => {
48
+ it("matches .svg paths in any case and nothing else", () => {
49
+ for (const p of ["/_assets/logo.0123456789abcdef.svg", "/_assets/LOGO.SVG", "icon.Svg"]) {
50
+ expect(new RegExp(SVG_PATH_PATTERN).test(p)).toBe(true);
51
+ }
52
+ for (const p of ["/_assets/logo.svgz", "/_assets/svg/logo.png", "/about/", "/x.svg/"]) {
53
+ expect(new RegExp(SVG_PATH_PATTERN).test(p)).toBe(false);
54
+ }
55
+ });
56
+
57
+ it("uses no inline flags or lookaround, so RE2 (Firebase Hosting) accepts it", () => {
58
+ expect(SVG_PATH_PATTERN).not.toMatch(/\(\?/);
59
+ });
60
+ });
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Security response headers for uploaded media and the published static
5
+ * site — the one definition the admin's media routes, the staging preview,
6
+ * every static publisher and every provider stack's edge config derive
7
+ * from (#863, #891).
8
+ *
9
+ * An SVG opened directly (`https://<site>/_assets/logo.<hash>.svg`) is a
10
+ * document, not an image: it runs script in the origin it is served from.
11
+ * On the public site that origin also serves `/api/*` (the gateway behind
12
+ * the same load balancer, CLAUDE.md §11.B), on the admin it holds the
13
+ * operator's session. `sanitizeSvg` strips script at upload; this CSP is
14
+ * the line behind it should a construct ever get past the sanitizer: no
15
+ * script, no plugins, no network, and `sandbox` gives the document an
16
+ * opaque origin. Inline styles and `data:` rasters / fonts stay allowed so
17
+ * an SVG renders exactly as before — as a document and as an `<img>`.
18
+ * Browsers apply a CSP only to documents (and workers), so the header
19
+ * changes nothing for an SVG loaded through `<img>` or CSS.
20
+ *
21
+ * Only SVG gets the CSP: raster images, video and fonts cannot run script,
22
+ * and `sandbox` would stop Chrome's built-in viewer from opening a PDF.
23
+ * `nosniff` goes on everything so a browser never reinterprets bytes as a
24
+ * scriptable type the content-type did not declare.
25
+ */
26
+
27
+ /** The CSP an SVG document is served with (see the file comment). */
28
+ export const SVG_DOCUMENT_CSP =
29
+ "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; sandbox";
30
+
31
+ /** `X-Content-Type-Options` value every media / static response carries. */
32
+ export const NOSNIFF = "nosniff";
33
+
34
+ /** The MIME type the CSP is keyed on. */
35
+ export const SVG_CONTENT_TYPE = "image/svg+xml";
36
+
37
+ /**
38
+ * Regex (source string, unanchored at the start) matching a URL path that
39
+ * ends in `.svg`, any case. Only syntax shared by ECMAScript and RE2 (no
40
+ * inline flags), so the same string serves Firebase Hosting's `regex`
41
+ * header matcher and `RegExp` here. The static generator names every file
42
+ * by extension (`contentTypeFor` in admin-core), so the extension is what
43
+ * decides the served `image/svg+xml`.
44
+ */
45
+ export const SVG_PATH_PATTERN = "\\.[sS][vV][gG]$";
46
+
47
+ /** True when `contentType` is SVG (case and parameters such as `; charset=` ignored). */
48
+ export function isSvgContentType(contentType: string): boolean {
49
+ return (contentType.split(";")[0] ?? "").trim().toLowerCase() === SVG_CONTENT_TYPE;
50
+ }
51
+
52
+ /**
53
+ * Security headers for one media / static-file response.
54
+ *
55
+ * @param contentType - the `content-type` the response is served with.
56
+ * @returns lower-case header entries to spread into the response's headers.
57
+ * @example
58
+ * new Response(body, { headers: { "Content-Type": ct, ...mediaSecurityHeaders(ct) } });
59
+ */
60
+ export function mediaSecurityHeaders(contentType: string): Record<string, string> {
61
+ const headers: Record<string, string> = { "x-content-type-options": NOSNIFF };
62
+ if (isSvgContentType(contentType)) headers["content-security-policy"] = SVG_DOCUMENT_CSP;
63
+ return headers;
64
+ }
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.60";
25
+ export const CAELO_VERSION = "0.10.62";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this