@caelo-cms/shared 0.10.28 → 0.10.30

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 (51) 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/database-url.d.ts +38 -0
  6. package/dist/database-url.d.ts.map +1 -0
  7. package/dist/database-url.js +73 -0
  8. package/dist/database-url.js.map +1 -0
  9. package/dist/document-language.d.ts +56 -0
  10. package/dist/document-language.d.ts.map +1 -0
  11. package/dist/document-language.js +142 -0
  12. package/dist/document-language.js.map +1 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/media.d.ts +3 -1
  18. package/dist/media.d.ts.map +1 -1
  19. package/dist/media.js +8 -0
  20. package/dist/media.js.map +1 -1
  21. package/dist/preview-compose.d.ts +6 -0
  22. package/dist/preview-compose.d.ts.map +1 -1
  23. package/dist/preview-compose.js +32 -1
  24. package/dist/preview-compose.js.map +1 -1
  25. package/dist/seo.d.ts +77 -13
  26. package/dist/seo.d.ts.map +1 -1
  27. package/dist/seo.js +117 -21
  28. package/dist/seo.js.map +1 -1
  29. package/dist/static-cache-policy.d.ts +50 -0
  30. package/dist/static-cache-policy.d.ts.map +1 -0
  31. package/dist/static-cache-policy.js +54 -0
  32. package/dist/static-cache-policy.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/ai-tools.ts +1 -0
  37. package/src/database-url.test.ts +116 -0
  38. package/src/database-url.ts +83 -0
  39. package/src/design-draft-shell.test.ts +5 -1
  40. package/src/document-language.test.ts +118 -0
  41. package/src/document-language.ts +146 -0
  42. package/src/index.ts +3 -0
  43. package/src/media.test.ts +8 -0
  44. package/src/media.ts +8 -0
  45. package/src/preview-compose.test.ts +82 -0
  46. package/src/preview-compose.ts +40 -1
  47. package/src/seo.test.ts +145 -1
  48. package/src/seo.ts +151 -28
  49. package/src/static-cache-policy.test.ts +66 -0
  50. package/src/static-cache-policy.ts +60 -0
  51. package/src/version.ts +1 -1
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
+ checkPublicSiteBaseUrl,
5
6
  injectSeoIntoHead,
6
7
  renderSeoHead,
7
8
  resolveCanonicalUrl,
@@ -9,6 +10,7 @@ import {
9
10
  seoOptimizeInputSchema,
10
11
  seoSetInputSchema,
11
12
  siteDefaultsSetSeoInputSchema,
13
+ siteSeoProposalInputSchema,
12
14
  } from "./seo.js";
13
15
 
14
16
  describe("resolveCanonicalUrl", () => {
@@ -18,6 +20,8 @@ describe("resolveCanonicalUrl", () => {
18
20
  siteBaseUrl: "https://example.com",
19
21
  pagePath: "/anything",
20
22
  override: "https://canonical.example.com/x",
23
+ pageUrlStyle: "directory",
24
+ host: "de.example.com",
21
25
  }),
22
26
  ).toBe("https://canonical.example.com/x");
23
27
  });
@@ -28,6 +32,7 @@ describe("resolveCanonicalUrl", () => {
28
32
  siteBaseUrl: "https://example.com",
29
33
  pagePath: "/",
30
34
  override: null,
35
+ pageUrlStyle: "directory",
31
36
  }),
32
37
  ).toBe("https://example.com/");
33
38
  });
@@ -38,10 +43,42 @@ describe("resolveCanonicalUrl", () => {
38
43
  siteBaseUrl: "https://example.com/",
39
44
  pagePath: "/about",
40
45
  override: null,
46
+ pageUrlStyle: "directory",
41
47
  }),
42
48
  ).toBe("https://example.com/about/");
43
49
  });
44
50
 
51
+ describe("#590 — composed host (host-strategy locales)", () => {
52
+ for (const [style, expected] of [
53
+ ["directory", "https://de.example.com/preise/"],
54
+ ["no-extension", "https://de.example.com/preise"],
55
+ ] as const) {
56
+ it(`swaps the host, keeps the base scheme and the ${style} slash rule`, () => {
57
+ expect(
58
+ resolveCanonicalUrl({
59
+ siteBaseUrl: "https://example.com/",
60
+ pagePath: "/preise",
61
+ override: null,
62
+ pageUrlStyle: style,
63
+ host: "de.example.com",
64
+ }),
65
+ ).toBe(expected);
66
+ });
67
+ }
68
+
69
+ it("a null host keeps the site base host", () => {
70
+ expect(
71
+ resolveCanonicalUrl({
72
+ siteBaseUrl: "https://example.com",
73
+ pagePath: "/",
74
+ override: null,
75
+ pageUrlStyle: "no-extension",
76
+ host: null,
77
+ }),
78
+ ).toBe("https://example.com/");
79
+ });
80
+ });
81
+
45
82
  describe("v0.2.85 — pageUrlStyle='no-extension'", () => {
46
83
  it("omits the trailing slash for non-home pages", () => {
47
84
  expect(
@@ -65,12 +102,13 @@ describe("resolveCanonicalUrl", () => {
65
102
  ).toBe("https://example.com/");
66
103
  });
67
104
 
68
- it("default style preserves pre-v0.2.85 trailing-slash behavior", () => {
105
+ it("'directory' keeps the trailing slash for non-home pages", () => {
69
106
  expect(
70
107
  resolveCanonicalUrl({
71
108
  siteBaseUrl: "https://example.com",
72
109
  pagePath: "/about",
73
110
  override: null,
111
+ pageUrlStyle: "directory",
74
112
  }),
75
113
  ).toBe("https://example.com/about/");
76
114
  });
@@ -87,6 +125,14 @@ describe("renderSeoHead", () => {
87
125
  organization: {},
88
126
  };
89
127
 
128
+ it("#551: omits canonical, og:url and the JSON-LD url when the base URL is unset", () => {
129
+ const head = renderSeoHead({ ...base, canonical: null });
130
+ expect(head).not.toContain('rel="canonical"');
131
+ expect(head).not.toContain("og:url");
132
+ expect(head).not.toContain('"url"');
133
+ expect(head).toContain("<title>Welcome</title>");
134
+ });
135
+
90
136
  it("emits canonical + og:type + og:url for the simplest valid input", () => {
91
137
  const head = renderSeoHead(base);
92
138
  expect(head).toContain("<title>Welcome</title>");
@@ -192,3 +238,101 @@ describe("schemas", () => {
192
238
  expect(r.success).toBe(false);
193
239
  });
194
240
  });
241
+
242
+ describe("siteSeoProposalInputSchema", () => {
243
+ it("accepts any single field", () => {
244
+ expect(siteSeoProposalInputSchema.safeParse({ siteBaseUrl: "https://a.example" }).success).toBe(
245
+ true,
246
+ );
247
+ expect(siteSeoProposalInputSchema.safeParse({ sitemapEnabled: false }).success).toBe(true);
248
+ expect(
249
+ siteSeoProposalInputSchema.safeParse({ organizationJson: { name: "Acme" } }).success,
250
+ ).toBe(true);
251
+ });
252
+
253
+ it("rejects an empty proposal with a message naming the fields", () => {
254
+ const r = siteSeoProposalInputSchema.safeParse({});
255
+ expect(r.success).toBe(false);
256
+ expect(JSON.stringify(r.error?.issues)).toContain("siteBaseUrl");
257
+ });
258
+
259
+ it("rejects unknown keys at the top level and inside organizationJson", () => {
260
+ expect(siteSeoProposalInputSchema.safeParse({ sitemapEnabled: true, x: 1 }).success).toBe(
261
+ false,
262
+ );
263
+ expect(
264
+ siteSeoProposalInputSchema.safeParse({ organizationJson: { name: "A", script: "<x>" } })
265
+ .success,
266
+ ).toBe(false);
267
+ });
268
+ });
269
+
270
+ describe("checkPublicSiteBaseUrl", () => {
271
+ it("normalises a valid https URL to its origin", () => {
272
+ expect(checkPublicSiteBaseUrl("https://www.Example.com/", "gcp")).toEqual({
273
+ ok: true,
274
+ url: "https://www.example.com",
275
+ });
276
+ expect(checkPublicSiteBaseUrl(" https://example.com:8443 ", undefined)).toEqual({
277
+ ok: true,
278
+ url: "https://example.com:8443",
279
+ });
280
+ });
281
+
282
+ it("rejects a path, query or fragment and names the origin to use instead", () => {
283
+ for (const raw of [
284
+ "https://example.com/blog",
285
+ "https://example.com/?a=1",
286
+ "https://example.com/#top",
287
+ ]) {
288
+ const r = checkPublicSiteBaseUrl(raw, "aws");
289
+ expect(r.ok).toBe(false);
290
+ if (!r.ok) expect(r.message).toContain("use https://example.com");
291
+ }
292
+ });
293
+
294
+ it("rejects non-URLs and credentials", () => {
295
+ expect(checkPublicSiteBaseUrl("example.com", "gcp").ok).toBe(false);
296
+ expect(checkPublicSiteBaseUrl("https://u:p@example.com", "gcp").ok).toBe(false);
297
+ });
298
+
299
+ it("requires https for public hosts on every provider", () => {
300
+ expect(checkPublicSiteBaseUrl("http://example.com", "gcp").ok).toBe(false);
301
+ expect(checkPublicSiteBaseUrl("http://example.com", "self-hosted").ok).toBe(false);
302
+ expect(checkPublicSiteBaseUrl("ftp://example.com", undefined).ok).toBe(false);
303
+ });
304
+
305
+ it("rejects loopback hosts on cloud providers", () => {
306
+ for (const provider of ["gcp", "gcp-firebase", "aws", "azure"]) {
307
+ for (const raw of [
308
+ "https://localhost",
309
+ "http://localhost:8082",
310
+ "https://127.0.0.1",
311
+ "https://[::1]",
312
+ "https://app.localhost",
313
+ ]) {
314
+ const r = checkPublicSiteBaseUrl(raw, provider);
315
+ expect(r.ok).toBe(false);
316
+ if (!r.ok) expect(r.message).toContain("public domain");
317
+ }
318
+ }
319
+ });
320
+
321
+ it("allows http://localhost on a self-hosted install (local dev)", () => {
322
+ expect(checkPublicSiteBaseUrl("http://localhost:8082", undefined)).toEqual({
323
+ ok: true,
324
+ url: "http://localhost:8082",
325
+ });
326
+ expect(checkPublicSiteBaseUrl("http://127.0.0.1:8082", "self-hosted").ok).toBe(true);
327
+ });
328
+
329
+ it("rejects wildcard bind addresses on every provider, self-hosted included", () => {
330
+ for (const provider of [undefined, "self-hosted", "gcp"]) {
331
+ for (const raw of ["http://0.0.0.0:8082", "https://0.0.0.0", "http://[::]:8082"]) {
332
+ const r = checkPublicSiteBaseUrl(raw, provider);
333
+ expect(r.ok).toBe(false);
334
+ if (!r.ok) expect(r.message).toContain("bind address");
335
+ }
336
+ }
337
+ });
338
+ });
package/src/seo.ts CHANGED
@@ -61,6 +61,16 @@ export const seoOptimizeInputSchema = z
61
61
  .strict();
62
62
  export type SeoOptimizeInput = z.infer<typeof seoOptimizeInputSchema>;
63
63
 
64
+ /** Organization JSON-LD fields — structured only, never raw markup (CLAUDE.md §2). */
65
+ const organizationJsonSchema = z
66
+ .object({
67
+ name: z.string().max(256).optional(),
68
+ url: z.string().max(2048).optional(),
69
+ logo: z.string().max(2048).optional(),
70
+ sameAs: z.array(z.string().max(2048)).max(20).optional(),
71
+ })
72
+ .strict();
73
+
64
74
  export const siteDefaultsSetSeoInputSchema = z
65
75
  .object({
66
76
  siteBaseUrl: z
@@ -69,19 +79,101 @@ export const siteDefaultsSetSeoInputSchema = z
69
79
  .max(2048)
70
80
  .url("siteBaseUrl must be an absolute URL (https://example.com)"),
71
81
  sitemapEnabled: z.boolean(),
72
- organizationJson: z
73
- .object({
74
- name: z.string().max(256).optional(),
75
- url: z.string().max(2048).optional(),
76
- logo: z.string().max(2048).optional(),
77
- sameAs: z.array(z.string().max(2048)).max(20).optional(),
78
- })
79
- .strict()
80
- .default({}),
82
+ organizationJson: organizationJsonSchema.default({}),
81
83
  })
82
84
  .strict();
83
85
  export type SiteDefaultsSetSeoInput = z.infer<typeof siteDefaultsSetSeoInputSchema>;
84
86
 
87
+ /**
88
+ * Input of `site_defaults.propose_set_seo` — the AI's path to the site SEO
89
+ * settings (CLAUDE.md §11.A). Every field is optional so the AI changes only
90
+ * what the operator asked for; an omitted field keeps its stored value when
91
+ * the Owner approves. `organizationJson` replaces the whole object.
92
+ */
93
+ export const siteSeoProposalInputSchema = z
94
+ .object({
95
+ siteBaseUrl: z.string().min(1).max(2048).optional(),
96
+ sitemapEnabled: z.boolean().optional(),
97
+ organizationJson: organizationJsonSchema.optional(),
98
+ })
99
+ .strict()
100
+ .refine(
101
+ (v) =>
102
+ v.siteBaseUrl !== undefined ||
103
+ v.sitemapEnabled !== undefined ||
104
+ v.organizationJson !== undefined,
105
+ "pass at least one of `siteBaseUrl`, `sitemapEnabled`, `organizationJson`",
106
+ );
107
+ export type SiteSeoProposalInput = z.infer<typeof siteSeoProposalInputSchema>;
108
+
109
+ /** Result of {@link checkPublicSiteBaseUrl}. */
110
+ export type PublicSiteBaseUrlCheck = { ok: true; url: string } | { ok: false; message: string };
111
+
112
+ const LOOPBACK_HOST = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/i;
113
+
114
+ /** Wildcard bind addresses: a server listens on them, no browser can visit them. */
115
+ const WILDCARD_HOST = /^(0\.0\.0\.0|\[::\])$/;
116
+
117
+ /**
118
+ * Validate a public site base URL and normalise it to its origin
119
+ * (`https://example.com`, no trailing slash).
120
+ *
121
+ * Every canonical, og:url, JSON-LD url, hreflang target and sitemap entry is
122
+ * `<base><path>`, so the base must be exactly the origin visitors reach: no
123
+ * path, query, fragment or credentials. On a cloud install (`provider` is a
124
+ * `CAELO_PROVIDER` other than self-hosted) it must be https and not a
125
+ * loopback host — a localhost base there ships unreachable canonicals to
126
+ * production. A self-hosted install may use `http://localhost:<port>`, the
127
+ * documented local-dev value; any other host still needs https.
128
+ *
129
+ * @param provider - `CAELO_PROVIDER` of the install; `undefined`/`""`/`"self-hosted"` = self-hosted.
130
+ */
131
+ export function checkPublicSiteBaseUrl(
132
+ raw: string,
133
+ provider: string | undefined,
134
+ ): PublicSiteBaseUrlCheck {
135
+ let u: URL;
136
+ try {
137
+ u = new URL(raw.trim());
138
+ } catch {
139
+ return {
140
+ ok: false,
141
+ message: `"${raw}" is not an absolute URL — pass the public origin, e.g. https://www.example.com`,
142
+ };
143
+ }
144
+ const origin = `${u.protocol}//${u.host}`;
145
+ if (u.username || u.password) {
146
+ return { ok: false, message: "the site URL must not contain credentials" };
147
+ }
148
+ if ((u.pathname !== "/" && u.pathname !== "") || u.search || u.hash) {
149
+ return {
150
+ ok: false,
151
+ message: `the site URL must be the origin only, without path, query or fragment — use ${origin}`,
152
+ };
153
+ }
154
+ if (WILDCARD_HOST.test(u.hostname)) {
155
+ return {
156
+ ok: false,
157
+ message: `${u.hostname} is a bind address, not an address visitors can open — use the public domain (e.g. https://www.example.com) or, on a local self-hosted install, http://localhost:<port>`,
158
+ };
159
+ }
160
+ const selfHosted = !provider || provider === "self-hosted";
161
+ const loopback = LOOPBACK_HOST.test(u.hostname);
162
+ if (loopback && !selfHosted) {
163
+ return {
164
+ ok: false,
165
+ message: `${origin} is a local address; on this ${provider} install the site URL must be the public domain visitors use (e.g. https://www.example.com)`,
166
+ };
167
+ }
168
+ if (u.protocol !== "https:" && !(selfHosted && loopback && u.protocol === "http:")) {
169
+ return {
170
+ ok: false,
171
+ message: `the site URL must use https (got ${u.protocol.replace(":", "")}) — use https://${u.host}`,
172
+ };
173
+ }
174
+ return { ok: true, url: origin };
175
+ }
176
+
85
177
  export interface PageSeoRow {
86
178
  pageId: string;
87
179
  metaDescription: string;
@@ -98,6 +190,10 @@ export interface PageSeoRow {
98
190
  export interface SiteSeoSettings {
99
191
  siteBaseUrl: string;
100
192
  sitemapEnabled: boolean;
193
+ /** `site_defaults.site_language` — the `<html lang>` of every page no
194
+ * plugin assigns its own locale to (see document-language.ts). Always
195
+ * set here: the static generator refuses to build while it is NULL. */
196
+ siteLanguage: string;
101
197
  organization: {
102
198
  name?: string;
103
199
  url?: string;
@@ -107,12 +203,27 @@ export interface SiteSeoSettings {
107
203
  }
108
204
 
109
205
  /**
110
- * Resolve the canonical URL for a page. If `pages_seo.canonical_url`
111
- * is set it wins; otherwise `<siteBaseUrl><pagePath>` — where
112
- * `pagePath` is the COMPOSED public path from `pages.current_path`
113
- * (#390: the URL composition point materializes prefixes, slug
114
- * formats, and the home designation into that one column; canonical
115
- * simply follows it).
206
+ * How a deploy target serves pages (`deploy_targets.page_url_style`):
207
+ * 'directory' emits `<path>/index.html` and serves `/<path>/`;
208
+ * 'no-extension' emits a bare `<path>` file and serves `/<path>`.
209
+ */
210
+ export const PAGE_URL_STYLES = ["directory", "no-extension"] as const;
211
+ export type PageUrlStyle = (typeof PAGE_URL_STYLES)[number];
212
+
213
+ /**
214
+ * THE public page URL builder (#590). Every absolute page URL Caelo
215
+ * emits — canonical, og:url, JSON-LD url, sitemap `<loc>`,
216
+ * hreflang/x-default targets, language-switcher links — comes from
217
+ * here, so they are byte-identical by construction. Two builders
218
+ * drifting apart is what shipped `/en/about` as hreflang next to an
219
+ * `/en/about/` canonical.
220
+ *
221
+ * `pages_seo.canonical_url` (`override`) wins when set. Otherwise the
222
+ * URL is `<scheme>//<host><path>`: the path is the COMPOSED public path
223
+ * from `pages.current_path` (#390), its trailing slash follows the
224
+ * serving target's {@link PageUrlStyle}, the host is the URL composition
225
+ * point's `host` slot (a host-strategy locale) or else the site base
226
+ * URL's, and the scheme always comes from the base URL.
116
227
  */
117
228
  export function resolveCanonicalUrl(args: {
118
229
  siteBaseUrl: string;
@@ -120,25 +231,33 @@ export function resolveCanonicalUrl(args: {
120
231
  * "/" for the site root. */
121
232
  pagePath: string;
122
233
  override: string | null;
123
- /**
124
- * v0.2.85 — page emission style. 'directory' (default) → URLs end
125
- * in `/…/`; 'no-extension' → no trailing slash, matching what the
126
- * bucket serves when pages are emitted as bare files.
127
- */
128
- pageUrlStyle?: "directory" | "no-extension";
234
+ /** The serving deploy target's page emission style. Required — a
235
+ * defaulted style is how the preview drifted from the build. */
236
+ pageUrlStyle: PageUrlStyle;
237
+ /** Host from the URL composition point's `host` slot (e.g.
238
+ * `de.example.com`); null/absent → the site base URL's host. */
239
+ host?: string | null;
129
240
  }): string {
130
241
  if (args.override && args.override.length > 0) return args.override;
131
- const base = args.siteBaseUrl.endsWith("/") ? args.siteBaseUrl.slice(0, -1) : args.siteBaseUrl;
242
+ const siteBase = args.siteBaseUrl.endsWith("/")
243
+ ? args.siteBaseUrl.slice(0, -1)
244
+ : args.siteBaseUrl;
245
+ const base = args.host ? `${new URL(siteBase).protocol}//${args.host}` : siteBase;
132
246
  const trimmed = trimSlashes(args.pagePath);
133
247
  if (trimmed.length === 0) return `${base}/`;
134
- const style = args.pageUrlStyle ?? "directory";
135
- return style === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
248
+ return args.pageUrlStyle === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
136
249
  }
137
250
 
138
251
  export interface SeoMetaInput {
139
252
  title: string;
140
253
  metaDescription: string;
141
- canonical: string;
254
+ /**
255
+ * Absolute canonical URL, or null when the site base URL is not
256
+ * configured yet (#551). Only the admin preview renders with null — it
257
+ * omits canonical, og:url and the JSON-LD url and flags
258
+ * `site-base-url-unset`; the static generator refuses to build instead.
259
+ */
260
+ canonical: string | null;
142
261
  noindex: boolean;
143
262
  ogImageUrl: string | null;
144
263
  organization: SiteSeoSettings["organization"];
@@ -162,7 +281,9 @@ export function renderSeoHead(input: SeoMetaInput): string {
162
281
  if (input.metaDescription) {
163
282
  lines.push(`<meta name="description" content="${enc(input.metaDescription)}" />`);
164
283
  }
165
- lines.push(`<link rel="canonical" href="${enc(input.canonical)}" />`);
284
+ if (input.canonical) {
285
+ lines.push(`<link rel="canonical" href="${enc(input.canonical)}" />`);
286
+ }
166
287
  if (input.noindex) {
167
288
  lines.push(`<meta name="robots" content="noindex" />`);
168
289
  }
@@ -172,7 +293,9 @@ export function renderSeoHead(input: SeoMetaInput): string {
172
293
  lines.push(`<meta property="og:description" content="${enc(input.metaDescription)}" />`);
173
294
  }
174
295
  lines.push(`<meta property="og:type" content="website" />`);
175
- lines.push(`<meta property="og:url" content="${enc(input.canonical)}" />`);
296
+ if (input.canonical) {
297
+ lines.push(`<meta property="og:url" content="${enc(input.canonical)}" />`);
298
+ }
176
299
  if (input.ogImageUrl) {
177
300
  lines.push(`<meta property="og:image" content="${enc(input.ogImageUrl)}" />`);
178
301
  }
@@ -185,7 +308,7 @@ export function renderSeoHead(input: SeoMetaInput): string {
185
308
  "@context": "https://schema.org",
186
309
  "@type": "WebPage",
187
310
  name: input.title,
188
- url: input.canonical,
311
+ ...(input.canonical ? { url: input.canonical } : {}),
189
312
  };
190
313
  if (input.metaDescription) ld.description = input.metaDescription;
191
314
  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
+ });