@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.
- 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/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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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 +11 -1
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +7 -3
- 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/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 +2 -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 +8 -0
- package/src/seo.ts +18 -4
- package/src/static-cache-policy.test.ts +66 -0
- package/src/static-cache-policy.ts +60 -0
- 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("&", "&")
|
|
119
|
+
.replaceAll('"', """)
|
|
120
|
+
.replaceAll("<", "<")
|
|
121
|
+
.replaceAll(">", ">");
|
|
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"><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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|