@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.
- package/dist/ai-tools.d.ts +1 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +1 -0
- package/dist/ai-tools.js.map +1 -1
- package/dist/database-url.d.ts +38 -0
- package/dist/database-url.d.ts.map +1 -0
- package/dist/database-url.js +73 -0
- package/dist/database-url.js.map +1 -0
- package/dist/document-language.d.ts +56 -0
- package/dist/document-language.d.ts.map +1 -0
- package/dist/document-language.js +142 -0
- package/dist/document-language.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts +3 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +8 -0
- package/dist/media.js.map +1 -1
- package/dist/preview-compose.d.ts +6 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +32 -1
- package/dist/preview-compose.js.map +1 -1
- package/dist/seo.d.ts +77 -13
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +117 -21
- package/dist/seo.js.map +1 -1
- package/dist/static-cache-policy.d.ts +50 -0
- package/dist/static-cache-policy.d.ts.map +1 -0
- package/dist/static-cache-policy.js +54 -0
- package/dist/static-cache-policy.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +1 -0
- package/src/database-url.test.ts +116 -0
- package/src/database-url.ts +83 -0
- package/src/design-draft-shell.test.ts +5 -1
- package/src/document-language.test.ts +118 -0
- package/src/document-language.ts +146 -0
- package/src/index.ts +3 -0
- package/src/media.test.ts +8 -0
- package/src/media.ts +8 -0
- package/src/preview-compose.test.ts +82 -0
- package/src/preview-compose.ts +40 -1
- package/src/seo.test.ts +145 -1
- package/src/seo.ts +151 -28
- package/src/static-cache-policy.test.ts +66 -0
- package/src/static-cache-policy.ts +60 -0
- 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"><script>"');
|
|
733
|
+
expect(out.html).not.toContain('/x"><script>');
|
|
734
|
+
});
|
|
735
|
+
});
|
package/src/preview-compose.ts
CHANGED
|
@@ -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
|
-
//
|
|
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("
|
|
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:
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
*/
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|