@caelo-cms/shared 0.10.27 → 0.10.29

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 (45) hide show
  1. package/dist/ai-tools.d.ts +1 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +1 -0
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/document-language.d.ts +56 -0
  6. package/dist/document-language.d.ts.map +1 -0
  7. package/dist/document-language.js +142 -0
  8. package/dist/document-language.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/media.d.ts +3 -1
  14. package/dist/media.d.ts.map +1 -1
  15. package/dist/media.js +8 -0
  16. package/dist/media.js.map +1 -1
  17. package/dist/preview-compose.d.ts +6 -0
  18. package/dist/preview-compose.d.ts.map +1 -1
  19. package/dist/preview-compose.js +32 -1
  20. package/dist/preview-compose.js.map +1 -1
  21. package/dist/seo.d.ts +11 -1
  22. package/dist/seo.d.ts.map +1 -1
  23. package/dist/seo.js +7 -3
  24. package/dist/seo.js.map +1 -1
  25. package/dist/static-cache-policy.d.ts +50 -0
  26. package/dist/static-cache-policy.d.ts.map +1 -0
  27. package/dist/static-cache-policy.js +54 -0
  28. package/dist/static-cache-policy.js.map +1 -0
  29. package/dist/version.d.ts +2 -2
  30. package/dist/version.js +1 -1
  31. package/package.json +1 -1
  32. package/src/ai-tools.ts +1 -0
  33. package/src/design-draft-shell.test.ts +5 -1
  34. package/src/document-language.test.ts +118 -0
  35. package/src/document-language.ts +146 -0
  36. package/src/index.ts +2 -0
  37. package/src/media.test.ts +8 -0
  38. package/src/media.ts +8 -0
  39. package/src/preview-compose.test.ts +82 -0
  40. package/src/preview-compose.ts +40 -1
  41. package/src/seo.test.ts +8 -0
  42. package/src/seo.ts +18 -4
  43. package/src/static-cache-policy.test.ts +66 -0
  44. package/src/static-cache-policy.ts +60 -0
  45. package/src/version.ts +1 -1
@@ -0,0 +1,146 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The document language — the `lang` attribute on `<html>`.
5
+ *
6
+ * Screen readers pick their pronunciation from it and search engines
7
+ * use it to classify the page, so every page Caelo renders must carry
8
+ * one. Like `<title>` and the rest of the SEO head, it is a structured
9
+ * value owned by core, never something a layout author hand-writes:
10
+ * the composed page always carries the value core resolved, replacing
11
+ * whatever `lang` the layout HTML happened to contain.
12
+ *
13
+ * Where the value comes from (resolved identically by the admin
14
+ * preview and the static generator):
15
+ * 1. the per-page language a plugin contributes through the head
16
+ * contribution point (the `international-site` plugin knows each
17
+ * page's locale — core does not, since epic #380), else
18
+ * 2. the site's stored language (`site_defaults.site_language`, set
19
+ * by the AI via `set_site_identity` or by the Owner at /security/seo).
20
+ * The stored language has no default (migration 0232, CLAUDE.md §2): NULL
21
+ * means nobody chose one yet. Nothing here substitutes a language for it —
22
+ * the preview renders `<html>` without `lang` and flags
23
+ * `site-language-unset`, and the static generator refuses to build.
24
+ */
25
+
26
+ import { z } from "zod";
27
+
28
+ /**
29
+ * A BCP 47 language tag as accepted for `<html lang>`: a 2–8 letter
30
+ * primary subtag followed by optional alphanumeric subtags (`en`,
31
+ * `de-AT`, `zh-Hant-TW`). Deliberately structural rather than a full
32
+ * registry check — the same shape the `site_defaults.site_language`
33
+ * CHECK constraint enforces.
34
+ */
35
+ export const languageTagSchema = z
36
+ .string()
37
+ .max(35)
38
+ .regex(
39
+ /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/,
40
+ "must be a BCP 47 language tag such as `en`, `de` or `pt-BR`",
41
+ );
42
+
43
+ /**
44
+ * Pick the language for one page: a plugin-contributed per-page value
45
+ * wins over the site's stored language. Exported so preview and build
46
+ * resolve through the same expression.
47
+ *
48
+ * @returns `null` when no plugin assigns one and the site language is not
49
+ * configured — the caller surfaces that state, never a guessed tag.
50
+ */
51
+ export function resolveDocumentLanguage(args: {
52
+ readonly contributed: string | undefined;
53
+ readonly siteLanguage: string | null;
54
+ }): string | null {
55
+ return args.contributed ?? args.siteLanguage;
56
+ }
57
+
58
+ // The `<html` start-tag opener; the lookahead keeps look-alikes such as
59
+ // `<html-widget>` out. Fixed-width, so matching is linear.
60
+ const HTML_TAG_OPENER_RE = /<html(?=[\s>/])/i;
61
+ const DOCTYPE_RE = /^\s*<!doctype[^>]*>/i;
62
+ // HTML's ASCII whitespace (the tokenizer's attribute separators).
63
+ const HTML_WS = new Set(["\t", "\n", "\f", "\r", " "]);
64
+
65
+ /**
66
+ * Scan the attributes of the start tag beginning at `from` (just past
67
+ * `<html`) and return them with every `lang` attribute removed, plus the
68
+ * index just past the closing `>`. `null` when the tag never closes.
69
+ *
70
+ * A single forward pass over the tag, character by character: layout
71
+ * HTML is AI- or operator-authored input, and a backtracking regex over
72
+ * it (`\s+lang…` with a global flag) is polynomial on long whitespace
73
+ * runs (CodeQL js/polynomial-redos). Quoted values may contain `>`.
74
+ * `xml:lang` and `data-lang` are different attribute names and survive.
75
+ */
76
+ function stripLangAttributes(
77
+ html: string,
78
+ from: number,
79
+ ): { readonly attrs: string; readonly end: number } | null {
80
+ const n = html.length;
81
+ let i = from;
82
+ let attrs = "";
83
+ while (i < n) {
84
+ const segmentStart = i;
85
+ while (i < n && HTML_WS.has(html[i] as string)) i++;
86
+ if (i >= n) return null;
87
+ if (html[i] === ">") return { attrs: attrs + html.slice(segmentStart, i), end: i + 1 };
88
+ const nameStart = i;
89
+ // An attribute name runs to whitespace, `=`, `>` or `/`; a stray `=`
90
+ // or `/` is consumed as a one-character name so the scan always moves.
91
+ i++;
92
+ while (i < n && !HTML_WS.has(html[i] as string) && !"=>/".includes(html[i] as string)) i++;
93
+ const name = html.slice(nameStart, i).toLowerCase();
94
+ const afterName = i;
95
+ while (i < n && HTML_WS.has(html[i] as string)) i++;
96
+ if (html[i] === "=") {
97
+ i++;
98
+ while (i < n && HTML_WS.has(html[i] as string)) i++;
99
+ const quote = html[i];
100
+ if (quote === '"' || quote === "'") {
101
+ const close = html.indexOf(quote, i + 1);
102
+ if (close === -1) return null;
103
+ i = close + 1;
104
+ } else {
105
+ while (i < n && !HTML_WS.has(html[i] as string) && html[i] !== ">") i++;
106
+ }
107
+ } else {
108
+ // No value: the whitespace belongs to the next attribute.
109
+ i = afterName;
110
+ }
111
+ if (name !== "lang") attrs += html.slice(segmentStart, i);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function escapeAttr(s: string): string {
117
+ return s
118
+ .replaceAll("&", "&amp;")
119
+ .replaceAll('"', "&quot;")
120
+ .replaceAll("<", "&lt;")
121
+ .replaceAll(">", "&gt;");
122
+ }
123
+
124
+ /**
125
+ * Set `lang` on the document's `<html>` start tag, replacing any value
126
+ * the layout carried. A layout with no `<html>` start tag (the tag is
127
+ * optional in HTML) gets one inserted right after the doctype, which
128
+ * parses to the same document with the language attached. An `<html`
129
+ * start tag that never closes is malformed and treated as absent.
130
+ *
131
+ * `lang: null` (no language configured) removes every layout-authored
132
+ * `lang` and adds none: core owns the value, so a hand-written one must
133
+ * not pass for a configured language.
134
+ */
135
+ export function applyDocumentLanguage(html: string, lang: string | null): string {
136
+ const attr = lang === null ? "" : ` lang="${escapeAttr(lang)}"`;
137
+ const open = HTML_TAG_OPENER_RE.exec(html);
138
+ const tag = open ? stripLangAttributes(html, open.index + "<html".length) : null;
139
+ if (open && tag) {
140
+ return `${html.slice(0, open.index)}<html${attr}${tag.attrs}>${html.slice(tag.end)}`;
141
+ }
142
+ if (lang === null) return html;
143
+ const doctype = DOCTYPE_RE.exec(html);
144
+ const at = doctype ? doctype[0].length : 0;
145
+ return `${html.slice(0, at)}<html${attr}>${html.slice(at)}`;
146
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./context.js";
12
12
  export * from "./css-gradient-scan.js";
13
13
  export * from "./css-var-scan.js";
14
14
  export * from "./design-draft-shell.js";
15
+ export * from "./document-language.js";
15
16
  export * from "./font-assets.js";
16
17
  export * from "./fonts.js";
17
18
  export * from "./genesis.js";
@@ -42,6 +43,7 @@ export * from "./safe-keys.js";
42
43
  export * from "./seo.js";
43
44
  export * from "./skills.js";
44
45
  export * from "./snapshots.js";
46
+ export * from "./static-cache-policy.js";
45
47
  export * from "./strip-cdata.js";
46
48
  export * from "./structured-sets.js";
47
49
  export * from "./subagents.js";
package/src/media.test.ts CHANGED
@@ -68,6 +68,14 @@ describe("media size caps + allowlist", () => {
68
68
  }
69
69
  });
70
70
 
71
+ it("allows .ico under the single canonical image/x-icon, capped at 1 MiB", () => {
72
+ const allowed: readonly string[] = MEDIA_ALLOWED_MIMES;
73
+ expect(allowed).toContain("image/x-icon");
74
+ // The IANA alias is normalised on the way in, never stored.
75
+ expect(allowed).not.toContain("image/vnd.microsoft.icon");
76
+ expect(MEDIA_SIZE_CAPS["image/x-icon"]).toBe(1024 * 1024);
77
+ });
78
+
71
79
  it("variant widths cover the non-orig tags only", () => {
72
80
  for (const t of MEDIA_VARIANT_TAGS) {
73
81
  if (t === "orig") continue;
package/src/media.ts CHANGED
@@ -26,6 +26,13 @@ export const MEDIA_ALLOWED_MIMES = [
26
26
  "image/avif",
27
27
  "image/gif",
28
28
  "image/svg+xml",
29
+ // Favicons (.ico). `image/x-icon` is the one canonical stored value:
30
+ // it is what the upload sniffer (file-type) reports, what browsers send
31
+ // and what MDN's `<link rel="icon" type>` examples use. The IANA name
32
+ // `image/vnd.microsoft.icon` is normalised to it on the way in (see
33
+ // `normalizeAssetMime` in admin-core) and never stored. Stored as-is
34
+ // (no derived variants) — the ICO container carries its own sizes.
35
+ "image/x-icon",
29
36
  "application/pdf",
30
37
  "video/mp4",
31
38
  // issue #249 — webfonts. Migrated sites reference their own font
@@ -47,6 +54,7 @@ export const MEDIA_SIZE_CAPS: Record<MediaMime, number> = {
47
54
  "image/avif": 10 * 1024 * 1024,
48
55
  "image/gif": 8 * 1024 * 1024,
49
56
  "image/svg+xml": 1 * 1024 * 1024,
57
+ "image/x-icon": 1 * 1024 * 1024,
50
58
  "application/pdf": 20 * 1024 * 1024,
51
59
  "video/mp4": 50 * 1024 * 1024,
52
60
  "font/woff2": 5 * 1024 * 1024,
@@ -651,3 +651,85 @@ it("preloads pinned TTF/OTF/WOFF files with their actual format", () => {
651
651
  expect(html).toContain('type="font/otf"');
652
652
  expect(html).toContain('type="font/woff"');
653
653
  });
654
+
655
+ // The theme's favicon is document metadata: binding it via
656
+ // `set_theme_asset({slot:"favicon"})` must put a `<link rel="icon">` into
657
+ // <head> on every page, without any module carrying the tag. Before this,
658
+ // nothing was emitted and browsers fell back to a 404ing /favicon.ico.
659
+ describe("theme favicon in <head>", () => {
660
+ const layoutHtml = `<!doctype html><html><head><title>t</title></head><body><caelo-slot name="content">_</caelo-slot></body></html>`;
661
+ const templateHtml = `<body><caelo-slot name="content">_</caelo-slot></body>`;
662
+ const themeWith = (favicon: { url: string; mime: string } | null) => ({
663
+ tokens: {},
664
+ assets: {
665
+ logo: null,
666
+ logoDark: null,
667
+ favicon:
668
+ favicon === null ? null : { mediaId: "44444444-4444-4444-8444-444444444444", ...favicon },
669
+ socialShare: null,
670
+ },
671
+ });
672
+ const headOf = (html: string): string => html.slice(0, html.indexOf("</head>"));
673
+ const bodyOf = (html: string): string => html.slice(html.indexOf("</head>"));
674
+
675
+ it("composePageWithLayout emits <link rel=icon> with the bound media URL + mime", () => {
676
+ const out = composePageWithLayout({
677
+ templateHtml,
678
+ templateCss: "",
679
+ blocks: [],
680
+ layoutHtml,
681
+ layoutCss: "",
682
+ layoutBlocks: [],
683
+ layoutSlug: "test",
684
+ theme: themeWith({ url: "/_caelo/media/viu-one-favicon", mime: "image/svg+xml" }),
685
+ });
686
+ const tag = '<link rel="icon" href="/_caelo/media/viu-one-favicon" type="image/svg+xml">';
687
+ expect(headOf(out.html)).toContain(tag);
688
+ expect(bodyOf(out.html)).not.toContain('rel="icon"');
689
+ // Exactly once per page.
690
+ expect(out.html.split('rel="icon"').length - 1).toBe(1);
691
+ });
692
+
693
+ it("composePagePreview emits the same tag", () => {
694
+ const out = composePagePreview({
695
+ templateHtml: `<html><head></head><body><caelo-slot name="content">_</caelo-slot></body></html>`,
696
+ templateCss: "",
697
+ blocks: [],
698
+ theme: themeWith({ url: "/_caelo/media/fav", mime: "image/png" }),
699
+ });
700
+ expect(headOf(out.html)).toContain(
701
+ '<link rel="icon" href="/_caelo/media/fav" type="image/png">',
702
+ );
703
+ });
704
+
705
+ it("emits no icon link when the theme has no favicon bound, or no theme is threaded", () => {
706
+ const base = {
707
+ templateHtml,
708
+ templateCss: "",
709
+ blocks: [],
710
+ layoutHtml,
711
+ layoutCss: "",
712
+ layoutBlocks: [],
713
+ layoutSlug: "test",
714
+ };
715
+ expect(composePageWithLayout({ ...base, theme: themeWith(null) }).html).not.toContain(
716
+ 'rel="icon"',
717
+ );
718
+ expect(composePageWithLayout(base).html).not.toContain('rel="icon"');
719
+ });
720
+
721
+ it("attribute-escapes the URL and mime", () => {
722
+ const out = composePageWithLayout({
723
+ templateHtml,
724
+ templateCss: "",
725
+ blocks: [],
726
+ layoutHtml,
727
+ layoutCss: "",
728
+ layoutBlocks: [],
729
+ layoutSlug: "test",
730
+ theme: themeWith({ url: '/x"><script>', mime: "image/png" }),
731
+ });
732
+ expect(out.html).toContain('href="/x&quot;&gt;&lt;script&gt;"');
733
+ expect(out.html).not.toContain('/x"><script>');
734
+ });
735
+ });
@@ -102,6 +102,12 @@ export interface ComposeStructuredSets {
102
102
  export interface ComposeThemeAsset {
103
103
  readonly mediaId: string;
104
104
  readonly url: string;
105
+ /**
106
+ * `media_assets.mime` of the bound asset — the content type of the
107
+ * `orig` bytes the URL serves. Carried so `<head>` metadata that
108
+ * declares a type (`<link rel="icon" type=…>`) states the real one.
109
+ */
110
+ readonly mime: string;
105
111
  }
106
112
 
107
113
  export interface ComposeTheme {
@@ -195,6 +201,25 @@ export function fontsHeadFragment(fonts: ComposeFonts | undefined): string | nul
195
201
  return fragment.length > 0 ? fragment : null;
196
202
  }
197
203
 
204
+ /**
205
+ * Head fragment for the active theme's document-level brand metadata:
206
+ * `<link rel="icon">` when a favicon is bound. The favicon is page
207
+ * METADATA, not body content — it must sit in `<head>` on every page
208
+ * regardless of which layout or chrome modules the page uses, so the
209
+ * composer emits it from the theme binding instead of relying on a
210
+ * module to carry the tag. The href is the media URL as composed
211
+ * (`/_caelo/media/<slug>`); the static generator's media pass rewrites
212
+ * it to the published `/_assets/<slug>.<ext>` and copies the bytes, the
213
+ * same as any other media reference. Returns null when no theme is
214
+ * threaded or no favicon is bound (nothing to declare; browsers fall
215
+ * back to their own `/favicon.ico` probe exactly as before).
216
+ */
217
+ function themeHeadFragment(theme: ComposeTheme | undefined): string | null {
218
+ const favicon = theme?.assets.favicon;
219
+ if (!favicon) return null;
220
+ return `<link rel="icon" href="${escapeAttr(favicon.url)}" type="${escapeAttr(favicon.mime)}">`;
221
+ }
222
+
198
223
  export function composePagePreview(input: ComposeInput): ComposeOutput {
199
224
  // No withholding path here; rendering a withheld module would ship it
200
225
  // ungated, so refuse instead of degrading silently (CLAUDE.md §2).
@@ -251,7 +276,15 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
251
276
  const replaced = applySlotReplacements(input.templateHtml, { contentByName });
252
277
  let html = replaced.html;
253
278
 
254
- // issue #150 — @font-face + preloads before everything else so the
279
+ // Theme brand metadata (favicon) leads the injected head block — it
280
+ // is document metadata, not styling, and is independent of the
281
+ // cascade order the style tags below depend on.
282
+ const themeHeadLinks = themeHeadFragment(input.theme);
283
+ if (themeHeadLinks !== null) {
284
+ html = injectBefore(html, HEAD_CLOSE_RE, themeHeadLinks);
285
+ }
286
+
287
+ // issue #150 — @font-face + preloads ahead of the style tags so the
255
288
  // browser discovers font URLs as early as possible.
256
289
  const fontsFragment = fontsHeadFragment(input.fonts);
257
290
  if (fontsFragment !== null) {
@@ -695,6 +728,12 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
695
728
  });
696
729
  let html = replaced.html;
697
730
 
731
+ // Theme brand metadata (favicon) — see composePagePreview.
732
+ const themeHeadLinks = themeHeadFragment(input.theme);
733
+ if (themeHeadLinks !== null) {
734
+ html = injectBefore(html, HEAD_CLOSE_RE, themeHeadLinks);
735
+ }
736
+
698
737
  // issue #150 — fonts first (URL discovery), then theme vars, then
699
738
  // aggregated CSS; source order in <head> mirrors injection order.
700
739
  const fontsFragment = fontsHeadFragment(input.fonts);
package/src/seo.test.ts CHANGED
@@ -87,6 +87,14 @@ describe("renderSeoHead", () => {
87
87
  organization: {},
88
88
  };
89
89
 
90
+ it("#551: omits canonical, og:url and the JSON-LD url when the base URL is unset", () => {
91
+ const head = renderSeoHead({ ...base, canonical: null });
92
+ expect(head).not.toContain('rel="canonical"');
93
+ expect(head).not.toContain("og:url");
94
+ expect(head).not.toContain('"url"');
95
+ expect(head).toContain("<title>Welcome</title>");
96
+ });
97
+
90
98
  it("emits canonical + og:type + og:url for the simplest valid input", () => {
91
99
  const head = renderSeoHead(base);
92
100
  expect(head).toContain("<title>Welcome</title>");
package/src/seo.ts CHANGED
@@ -98,6 +98,10 @@ export interface PageSeoRow {
98
98
  export interface SiteSeoSettings {
99
99
  siteBaseUrl: string;
100
100
  sitemapEnabled: boolean;
101
+ /** `site_defaults.site_language` — the `<html lang>` of every page no
102
+ * plugin assigns its own locale to (see document-language.ts). Always
103
+ * set here: the static generator refuses to build while it is NULL. */
104
+ siteLanguage: string;
101
105
  organization: {
102
106
  name?: string;
103
107
  url?: string;
@@ -138,7 +142,13 @@ export function resolveCanonicalUrl(args: {
138
142
  export interface SeoMetaInput {
139
143
  title: string;
140
144
  metaDescription: string;
141
- canonical: string;
145
+ /**
146
+ * Absolute canonical URL, or null when the site base URL is not
147
+ * configured yet (#551). Only the admin preview renders with null — it
148
+ * omits canonical, og:url and the JSON-LD url and flags
149
+ * `site-base-url-unset`; the static generator refuses to build instead.
150
+ */
151
+ canonical: string | null;
142
152
  noindex: boolean;
143
153
  ogImageUrl: string | null;
144
154
  organization: SiteSeoSettings["organization"];
@@ -162,7 +172,9 @@ export function renderSeoHead(input: SeoMetaInput): string {
162
172
  if (input.metaDescription) {
163
173
  lines.push(`<meta name="description" content="${enc(input.metaDescription)}" />`);
164
174
  }
165
- lines.push(`<link rel="canonical" href="${enc(input.canonical)}" />`);
175
+ if (input.canonical) {
176
+ lines.push(`<link rel="canonical" href="${enc(input.canonical)}" />`);
177
+ }
166
178
  if (input.noindex) {
167
179
  lines.push(`<meta name="robots" content="noindex" />`);
168
180
  }
@@ -172,7 +184,9 @@ export function renderSeoHead(input: SeoMetaInput): string {
172
184
  lines.push(`<meta property="og:description" content="${enc(input.metaDescription)}" />`);
173
185
  }
174
186
  lines.push(`<meta property="og:type" content="website" />`);
175
- lines.push(`<meta property="og:url" content="${enc(input.canonical)}" />`);
187
+ if (input.canonical) {
188
+ lines.push(`<meta property="og:url" content="${enc(input.canonical)}" />`);
189
+ }
176
190
  if (input.ogImageUrl) {
177
191
  lines.push(`<meta property="og:image" content="${enc(input.ogImageUrl)}" />`);
178
192
  }
@@ -185,7 +199,7 @@ export function renderSeoHead(input: SeoMetaInput): string {
185
199
  "@context": "https://schema.org",
186
200
  "@type": "WebPage",
187
201
  name: input.title,
188
- url: input.canonical,
202
+ ...(input.canonical ? { url: input.canonical } : {}),
189
203
  };
190
204
  if (input.metaDescription) ld.description = input.metaDescription;
191
205
  if (input.ogImageUrl) ld.image = input.ogImageUrl;
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { CONTENT_HASHED_PATH_PATTERN, isContentHashedPath } from "./static-cache-policy.js";
5
+
6
+ /** Build outputs whose URL changes whenever their bytes change. */
7
+ const HASHED_PATHS = [
8
+ // Lighthouse report on gcp-firebase staging (v0.10.28).
9
+ "/_assets/fonts/inter/29ede7bd4be32ab0.woff2",
10
+ "/_assets/fonts/manrope/f3a06e9b32049b82.woff2",
11
+ `/_assets/fonts/pinned/${"a".repeat(64)}.woff2`,
12
+ `/_assets/fonts/pinned/${"0".repeat(64)}.ttf`,
13
+ "/_caelo/plugin/consent-manager/runtime.0123456789ab.js",
14
+ "/_caelo/plugin/consent-manager/banner.abcdef012345.css",
15
+ "/_app/immutable/chunks/abc.js",
16
+ ];
17
+
18
+ /** Build outputs served under a stable name — must stay short-lived. */
19
+ const STABLE_PATHS = [
20
+ "/",
21
+ "/index.html",
22
+ "/about/",
23
+ "/about/index.html",
24
+ "/en/about",
25
+ "/robots.txt",
26
+ "/sitemap.xml",
27
+ "/routing-manifest.json",
28
+ "/cdn_manifest.json",
29
+ "/_content-types.json",
30
+ // Media addressed by slug: bytes can change behind the same URL.
31
+ "/_assets/searchviu-logo.png",
32
+ "/_assets/hero/w800.webp",
33
+ "/_assets/0b1f6c2e-9d7a-4c1e-8f3a-2b6d9e0c1a4f/orig.jpg",
34
+ "/_caelo/media/hero",
35
+ // Pinned-font license file is id-named, not hashed.
36
+ "/_assets/fonts/pinned/0b1f6c2e-9d7a-4c1e-8f3a-2b6d9e0c1a4f.license.txt",
37
+ // Look-alikes that must not slip through.
38
+ "/_assets/fonts/inter/not-a-hash.woff2",
39
+ "/_assets/fonts/inter/29ede7bd4be32ab0.woff2.html",
40
+ "/_caelo/plugin/consent-manager/runtime.js",
41
+ "/blog/_assets/fonts/inter/29ede7bd4be32ab0.woff2",
42
+ "/api/forms/submit",
43
+ ];
44
+
45
+ describe("isContentHashedPath", () => {
46
+ it("matches every content-hashed build output", () => {
47
+ for (const p of HASHED_PATHS) expect(isContentHashedPath(p)).toBe(true);
48
+ });
49
+
50
+ it("rejects pages, manifests and slug-addressed media", () => {
51
+ for (const p of STABLE_PATHS) expect(isContentHashedPath(p)).toBe(false);
52
+ });
53
+
54
+ it("accepts build-dir-relative keys (no leading slash) the same way", () => {
55
+ expect(isContentHashedPath("_assets/fonts/inter/29ede7bd4be32ab0.woff2")).toBe(true);
56
+ expect(isContentHashedPath("_assets/searchviu-logo.png")).toBe(false);
57
+ });
58
+
59
+ it("uses only RE2-compatible syntax (Firebase Hosting + Caddy match with RE2)", () => {
60
+ // No lookaround, no backreferences, no named groups.
61
+ expect(CONTENT_HASHED_PATH_PATTERN).not.toMatch(/\(\?[=!<]/);
62
+ expect(CONTENT_HASHED_PATH_PATTERN).not.toMatch(/\\[1-9]/);
63
+ expect(CONTENT_HASHED_PATH_PATTERN.startsWith("^/")).toBe(true);
64
+ expect(CONTENT_HASHED_PATH_PATTERN.endsWith("$")).toBe(true);
65
+ });
66
+ });
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Cache-Control policy for the published static site — the single
5
+ * source of truth every static publisher (GCS object metadata,
6
+ * Firebase Hosting version headers, self-hosted Caddy) derives from.
7
+ *
8
+ * The rule: only a URL whose bytes can never change may be cached
9
+ * forever. That holds exactly when the URL itself changes whenever the
10
+ * content does, i.e. the file name carries a content hash. Everything
11
+ * else (pages, robots.txt, sitemap.xml, manifests, media served by its
12
+ * stable slug under `_assets/<slug>…`) must stay short-lived or
13
+ * revalidating so a publish shows up promptly.
14
+ *
15
+ * Content-hashed paths the static generator emits today:
16
+ *
17
+ * - `_assets/fonts/<family-slug>/<16 hex>.woff2` — self-hosted
18
+ * Google Fonts faces; the name is a hash of the upstream face URL,
19
+ * which Google versions per file (a changed face gets a new URL,
20
+ * hence a new name).
21
+ * - `_assets/fonts/pinned/<sha256>.<ttf|otf|woff|woff2>` — library
22
+ * fonts pinned by the sha256 of their bytes. (The sibling
23
+ * `pinned/<font-id>.license.txt` is id-named, NOT hashed.)
24
+ * - `_caelo/plugin/<slug>/<stem>.<12 hex>.<js|css>` — plugin client
25
+ * assets, sha256 of the content in the name
26
+ * (`plugin-host/src/client-assets.ts`).
27
+ * - `_app/immutable/**` — Vite's hashed-output convention, kept for
28
+ * builds that ship SvelteKit-style bundles.
29
+ *
30
+ * NOT content-hashed (deliberately excluded): media under
31
+ * `_assets/<slug>.<ext>` / `_assets/<slug>/<variant>.<ext>` — the slug is
32
+ * stable while the operator can replace the bytes behind it.
33
+ */
34
+
35
+ /** Long-lived policy for content-addressed files. */
36
+ export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
37
+
38
+ /** Short, background-revalidated policy for pages (HTML documents). */
39
+ export const HTML_CACHE_CONTROL = "public, max-age=60, stale-while-revalidate=86400";
40
+
41
+ /**
42
+ * Regex (source string) matching every content-hashed URL path. It is
43
+ * matched against a site-absolute path ("/_assets/…") and uses only the
44
+ * syntax shared by ECMAScript and RE2 (no lookaround, no backrefs), so
45
+ * the same string can be handed to Firebase Hosting's `regex` header
46
+ * matcher and Caddy's `path_regexp` (both RE2) and to `RegExp` here.
47
+ */
48
+ export const CONTENT_HASHED_PATH_PATTERN =
49
+ "^/(?:_app/immutable/.+|_assets/fonts/[^/]+/[0-9a-f]{16,64}\\.(?:woff2|woff|ttf|otf)|_caelo/plugin/[^/]+/[^/]+\\.[0-9a-f]{12}\\.(?:js|css))$";
50
+
51
+ const CONTENT_HASHED_PATH_RE = new RegExp(CONTENT_HASHED_PATH_PATTERN);
52
+
53
+ /**
54
+ * True when `path` names a content-addressed build output. Accepts a
55
+ * build-dir-relative key (`_assets/fonts/inter/ab….woff2`, as the GCS
56
+ * publisher sees it) or a site-absolute URL path (`/_assets/…`).
57
+ */
58
+ export function isContentHashedPath(path: string): boolean {
59
+ return CONTENT_HASHED_PATH_RE.test(path.startsWith("/") ? path : `/${path}`);
60
+ }
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.27";
25
+ export const CAELO_VERSION = "0.10.29";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this