@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
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Cache-Control policy for the published static site — the single
4
+ * source of truth every static publisher (GCS object metadata,
5
+ * Firebase Hosting version headers, self-hosted Caddy) derives from.
6
+ *
7
+ * The rule: only a URL whose bytes can never change may be cached
8
+ * forever. That holds exactly when the URL itself changes whenever the
9
+ * content does, i.e. the file name carries a content hash. Everything
10
+ * else (pages, robots.txt, sitemap.xml, manifests, media served by its
11
+ * stable slug under `_assets/<slug>…`) must stay short-lived or
12
+ * revalidating so a publish shows up promptly.
13
+ *
14
+ * Content-hashed paths the static generator emits today:
15
+ *
16
+ * - `_assets/fonts/<family-slug>/<16 hex>.woff2` — self-hosted
17
+ * Google Fonts faces; the name is a hash of the upstream face URL,
18
+ * which Google versions per file (a changed face gets a new URL,
19
+ * hence a new name).
20
+ * - `_assets/fonts/pinned/<sha256>.<ttf|otf|woff|woff2>` — library
21
+ * fonts pinned by the sha256 of their bytes. (The sibling
22
+ * `pinned/<font-id>.license.txt` is id-named, NOT hashed.)
23
+ * - `_caelo/plugin/<slug>/<stem>.<12 hex>.<js|css>` — plugin client
24
+ * assets, sha256 of the content in the name
25
+ * (`plugin-host/src/client-assets.ts`).
26
+ * - `_app/immutable/**` — Vite's hashed-output convention, kept for
27
+ * builds that ship SvelteKit-style bundles.
28
+ *
29
+ * NOT content-hashed (deliberately excluded): media under
30
+ * `_assets/<slug>.<ext>` / `_assets/<slug>/<variant>.<ext>` — the slug is
31
+ * stable while the operator can replace the bytes behind it.
32
+ */
33
+ /** Long-lived policy for content-addressed files. */
34
+ export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
35
+ /** Short, background-revalidated policy for pages (HTML documents). */
36
+ export const HTML_CACHE_CONTROL = "public, max-age=60, stale-while-revalidate=86400";
37
+ /**
38
+ * Regex (source string) matching every content-hashed URL path. It is
39
+ * matched against a site-absolute path ("/_assets/…") and uses only the
40
+ * syntax shared by ECMAScript and RE2 (no lookaround, no backrefs), so
41
+ * the same string can be handed to Firebase Hosting's `regex` header
42
+ * matcher and Caddy's `path_regexp` (both RE2) and to `RegExp` here.
43
+ */
44
+ export const CONTENT_HASHED_PATH_PATTERN = "^/(?:_app/immutable/.+|_assets/fonts/[^/]+/[0-9a-f]{16,64}\\.(?:woff2|woff|ttf|otf)|_caelo/plugin/[^/]+/[^/]+\\.[0-9a-f]{12}\\.(?:js|css))$";
45
+ const CONTENT_HASHED_PATH_RE = new RegExp(CONTENT_HASHED_PATH_PATTERN);
46
+ /**
47
+ * True when `path` names a content-addressed build output. Accepts a
48
+ * build-dir-relative key (`_assets/fonts/inter/ab….woff2`, as the GCS
49
+ * publisher sees it) or a site-absolute URL path (`/_assets/…`).
50
+ */
51
+ export function isContentHashedPath(path) {
52
+ return CONTENT_HASHED_PATH_RE.test(path.startsWith("/") ? path : `/${path}`);
53
+ }
54
+ //# sourceMappingURL=static-cache-policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-cache-policy.js","sourceRoot":"","sources":["../src/static-cache-policy.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,qDAAqD;AACrD,MAAM,CAAC,MAAM,uBAAuB,GAAG,qCAAqC,CAAC;AAE7E,uEAAuE;AACvE,MAAM,CAAC,MAAM,kBAAkB,GAAG,kDAAkD,CAAC;AAErF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,2BAA2B,GACtC,6IAA6I,CAAC;AAEhJ,MAAM,sBAAsB,GAAG,IAAI,MAAM,CAAC,2BAA2B,CAAC,CAAC;AAEvE;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;AAC/E,CAAC"}
package/dist/version.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
20
20
  * follow standard SemVer.
21
21
  */
22
- export declare const CAELO_VERSION = "0.10.28";
22
+ export declare const CAELO_VERSION = "0.10.30";
23
23
  /**
24
24
  * Deprecated alias for back-compat — early P17 work spelled this
25
25
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
@@ -31,7 +31,7 @@ export declare const CAELO_VERSION = "0.10.28";
31
31
  *
32
32
  * @deprecated use CAELO_VERSION
33
33
  */
34
- export declare const CALEO_VERSION = "0.10.28";
34
+ export declare const CALEO_VERSION = "0.10.30";
35
35
  /**
36
36
  * Parsed shape — major/minor/patch + optional pre-release tag.
37
37
  * Stable interface for callers that need to feature-gate (rare —
package/dist/version.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
21
21
  * follow standard SemVer.
22
22
  */
23
- export const CAELO_VERSION = "0.10.28";
23
+ export const CAELO_VERSION = "0.10.30";
24
24
  /**
25
25
  * Deprecated alias for back-compat — early P17 work spelled this
26
26
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caelo-cms/shared",
3
- "version": "0.10.28",
3
+ "version": "0.10.30",
4
4
  "private": false,
5
5
  "license": "MPL-2.0",
6
6
  "description": "Shared Zod schemas + types + the two-pass HTML composer used by every Caelo CMS workspace package. Stable enough for plugin authors and embedders to depend on directly.",
package/src/ai-tools.ts CHANGED
@@ -756,6 +756,7 @@ export const findMediaToolInput = z
756
756
  "image/avif",
757
757
  "image/gif",
758
758
  "image/svg+xml",
759
+ "image/x-icon",
759
760
  "application/pdf",
760
761
  "video/mp4",
761
762
  ])
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { databasePasswordVar, databaseUrlFromEnv, withDatabasePassword } from "./database-url.js";
5
+
6
+ describe("databasePasswordVar", () => {
7
+ it("pairs each URL var with its _PASSWORD var", () => {
8
+ expect(databasePasswordVar("ADMIN_DATABASE_URL")).toBe("ADMIN_DATABASE_PASSWORD");
9
+ expect(databasePasswordVar("PUBLIC_ADMIN_DATABASE_URL")).toBe("PUBLIC_ADMIN_DATABASE_PASSWORD");
10
+ expect(databasePasswordVar("PUBLIC_DATABASE_URL")).toBe("PUBLIC_DATABASE_PASSWORD");
11
+ });
12
+
13
+ it("rejects a name that is not a URL var", () => {
14
+ expect(() => databasePasswordVar("ADMIN_DATABASE")).toThrow(/_URL/);
15
+ });
16
+ });
17
+
18
+ describe("withDatabasePassword", () => {
19
+ it("injects the password into a password-less URL, keeping host, db and params", () => {
20
+ expect(
21
+ withDatabasePassword(
22
+ "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
23
+ "0123abcd",
24
+ ),
25
+ ).toBe("postgresql://admin_role:0123abcd@10.20.0.3:5432/cms_admin?sslmode=require");
26
+ });
27
+
28
+ it("keeps literal percent signs in the password (p%40ss stays p%40ss, p%ss stays valid)", () => {
29
+ for (const password of ["p%40ss", "p%ss", "100%"]) {
30
+ const parsed = new URL(withDatabasePassword("postgres://u@h:5432/d", password));
31
+ expect(decodeURIComponent(parsed.password)).toBe(password);
32
+ }
33
+ });
34
+
35
+ it("percent-encodes reserved characters so the URL stays parseable", () => {
36
+ const url = withDatabasePassword("postgres://u@h:5432/d", "p@ss/w:rd#?");
37
+ const parsed = new URL(url);
38
+ expect(decodeURIComponent(parsed.password)).toBe("p@ss/w:rd#?");
39
+ expect(parsed.host).toBe("h:5432");
40
+ expect(parsed.pathname).toBe("/d");
41
+ });
42
+
43
+ it("refuses a URL that already carries a password", () => {
44
+ expect(() => withDatabasePassword("postgres://u:old@h/d", "new")).toThrow(/already carries/);
45
+ });
46
+
47
+ it("refuses a URL without a user and a malformed URL", () => {
48
+ expect(() => withDatabasePassword("postgres://h/d", "p")).toThrow(/no user/);
49
+ expect(() => withDatabasePassword("not a url", "p")).toThrow(/not a valid URL/);
50
+ });
51
+
52
+ it("never echoes the password in its errors", () => {
53
+ try {
54
+ withDatabasePassword("postgres://u:old@h/d", "top-secret-value");
55
+ } catch (e) {
56
+ expect(String(e)).not.toContain("top-secret-value");
57
+ expect(String(e)).not.toContain("old");
58
+ }
59
+ });
60
+ });
61
+
62
+ describe("databaseUrlFromEnv", () => {
63
+ it("returns an inline-password URL unchanged (self-hosted compose, CI, .env)", () => {
64
+ const env = { ADMIN_DATABASE_URL: "postgres://admin_role:dev@localhost:5432/cms_admin" };
65
+ expect(databaseUrlFromEnv(["ADMIN_DATABASE_URL"], env)).toBe(env.ADMIN_DATABASE_URL);
66
+ });
67
+
68
+ it("composes the URL from the plain URL var + its secret-mounted password var (cloud)", () => {
69
+ const env = {
70
+ ADMIN_DATABASE_URL: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
71
+ ADMIN_DATABASE_PASSWORD: "s3cret",
72
+ };
73
+ expect(databaseUrlFromEnv(["ADMIN_DATABASE_URL"], env)).toBe(
74
+ "postgresql://admin_role:s3cret@10.20.0.3:5432/cms_admin?sslmode=require",
75
+ );
76
+ });
77
+
78
+ it("uses the password var that belongs to the URL var it picked", () => {
79
+ const env = {
80
+ PUBLIC_DATABASE_URL: "postgresql://public_role@h:5432/cms_public",
81
+ PUBLIC_DATABASE_PASSWORD: "pub",
82
+ // Not consulted: it pairs with PUBLIC_ADMIN_DATABASE_URL, which is unset.
83
+ PUBLIC_ADMIN_DATABASE_PASSWORD: "adm",
84
+ };
85
+ expect(databaseUrlFromEnv(["PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_DATABASE_URL"], env)).toBe(
86
+ "postgresql://public_role:pub@h:5432/cms_public",
87
+ );
88
+ });
89
+
90
+ it("prefers the first URL var that is set", () => {
91
+ const env = {
92
+ PUBLIC_ADMIN_DATABASE_URL: "postgresql://admin_role@h:5432/cms_public",
93
+ PUBLIC_ADMIN_DATABASE_PASSWORD: "adm",
94
+ PUBLIC_DATABASE_URL: "postgresql://public_role@h:5432/cms_public",
95
+ };
96
+ expect(databaseUrlFromEnv(["PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_DATABASE_URL"], env)).toBe(
97
+ "postgresql://admin_role:adm@h:5432/cms_public",
98
+ );
99
+ });
100
+
101
+ it("is undefined when no URL var is set", () => {
102
+ expect(databaseUrlFromEnv(["ADMIN_DATABASE_URL"], { ADMIN_DATABASE_PASSWORD: "x" })).toBe(
103
+ undefined,
104
+ );
105
+ });
106
+
107
+ it("fails loudly when both the URL and the password var carry a password", () => {
108
+ const env = {
109
+ ADMIN_DATABASE_URL: "postgres://admin_role:inline@h/cms_admin",
110
+ ADMIN_DATABASE_PASSWORD: "mounted",
111
+ };
112
+ expect(() => databaseUrlFromEnv(["ADMIN_DATABASE_URL"], env)).toThrow(
113
+ /ADMIN_DATABASE_URL \(with ADMIN_DATABASE_PASSWORD\) already carries a password/,
114
+ );
115
+ });
116
+ });
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Database connection URLs as every Caelo process reads them from its
5
+ * environment.
6
+ *
7
+ * A URL var (`ADMIN_DATABASE_URL`, `PUBLIC_ADMIN_DATABASE_URL`,
8
+ * `PUBLIC_DATABASE_URL`) may carry its password inline — the shape the
9
+ * self-hosted compose stack, CI and `.env` use — or leave it out and have
10
+ * the password come from a companion `<NAME>_PASSWORD` var
11
+ * (`ADMIN_DATABASE_URL` → `ADMIN_DATABASE_PASSWORD`). Cloud installs use the
12
+ * second shape: the URL (host, role, database) is a plain env var, the
13
+ * password is a Secret Manager reference, so the password never shows up in
14
+ * the service's configuration or its revision history.
15
+ *
16
+ * Pure module — no I/O.
17
+ */
18
+
19
+ /** The `<NAME>_PASSWORD` var that pairs with a `<NAME>_URL` var. */
20
+ export function databasePasswordVar(urlVar: string): string {
21
+ if (!urlVar.endsWith("_URL")) {
22
+ throw new Error(`database URL var must end in _URL, got ${urlVar}`);
23
+ }
24
+ return `${urlVar.slice(0, -"_URL".length)}_PASSWORD`;
25
+ }
26
+
27
+ /**
28
+ * Inject `password` into a password-less connection URL. Refuses a URL that
29
+ * already carries one: two sources for the same credential is a
30
+ * misconfiguration, and silently preferring either would hide it.
31
+ *
32
+ * @example
33
+ * withDatabasePassword("postgresql://admin_role@10.0.0.3:5432/cms_admin", "s3cr3t")
34
+ * // → "postgresql://admin_role:s3cr3t@10.0.0.3:5432/cms_admin"
35
+ */
36
+ export function withDatabasePassword(
37
+ url: string,
38
+ password: string,
39
+ label = "database URL",
40
+ ): string {
41
+ let parsed: URL;
42
+ try {
43
+ parsed = new URL(url);
44
+ } catch {
45
+ throw new Error(`${label} is not a valid URL`);
46
+ }
47
+ if (parsed.password) {
48
+ throw new Error(
49
+ `${label} already carries a password and a separate password is set too — keep one`,
50
+ );
51
+ }
52
+ if (!parsed.username) {
53
+ throw new Error(`${label} names no user to attach the password to`);
54
+ }
55
+ // Encode first: the URL setter leaves a literal `%` alone, so `p%40ss`
56
+ // would decode to `p@ss` and `p%ss` would be an invalid escape. Postgres
57
+ // clients decode the userinfo again.
58
+ parsed.password = encodeURIComponent(password);
59
+ return parsed.toString();
60
+ }
61
+
62
+ /**
63
+ * The connection URL from the first of `urlVars` that is set, with its
64
+ * companion `_PASSWORD` var applied when that is set. `undefined` when none
65
+ * of `urlVars` is set, so callers keep their own "X is required" errors.
66
+ *
67
+ * @param urlVars URL var names in order of preference, e.g.
68
+ * `["PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_DATABASE_URL"]`.
69
+ * @param env The environment to read (defaults to `process.env`).
70
+ */
71
+ export function databaseUrlFromEnv(
72
+ urlVars: readonly string[],
73
+ env: Readonly<Record<string, string | undefined>> = process.env,
74
+ ): string | undefined {
75
+ for (const urlVar of urlVars) {
76
+ const url = env[urlVar];
77
+ if (!url) continue;
78
+ const passwordVar = databasePasswordVar(urlVar);
79
+ const password = env[passwordVar];
80
+ return password ? withDatabasePassword(url, password, `${urlVar} (with ${passwordVar})`) : url;
81
+ }
82
+ return undefined;
83
+ }
@@ -21,7 +21,11 @@ const TOKENS = {
21
21
  const THEME: ComposeTheme = {
22
22
  tokens: TOKENS,
23
23
  assets: {
24
- logo: { mediaId: "11111111-1111-4111-8111-111111111111", url: "/_caelo/media/logo/orig" },
24
+ logo: {
25
+ mediaId: "11111111-1111-4111-8111-111111111111",
26
+ url: "/_caelo/media/logo/orig",
27
+ mime: "image/png",
28
+ },
25
29
  logoDark: null,
26
30
  favicon: null,
27
31
  socialShare: null,
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ applyDocumentLanguage,
6
+ languageTagSchema,
7
+ resolveDocumentLanguage,
8
+ } from "./document-language.js";
9
+
10
+ describe("applyDocumentLanguage", () => {
11
+ // Regression: every composed page started with a bare `<html>`, so
12
+ // Lighthouse flagged html-has-lang on the whole site.
13
+ it("adds lang to a bare <html>", () => {
14
+ expect(applyDocumentLanguage("<!doctype html><html><head></head></html>", "en")).toBe(
15
+ '<!doctype html><html lang="en"><head></head></html>',
16
+ );
17
+ });
18
+
19
+ it("replaces a layout-authored lang (quoted, single-quoted, unquoted, bare)", () => {
20
+ for (const tag of [
21
+ '<html lang="xx">',
22
+ "<html lang='xx'>",
23
+ "<html lang=xx>",
24
+ "<html lang>",
25
+ '<html LANG="xx">',
26
+ ]) {
27
+ expect(applyDocumentLanguage(`${tag}<head></head>`, "de")).toBe(
28
+ '<html lang="de"><head></head>',
29
+ );
30
+ }
31
+ });
32
+
33
+ it("keeps other attributes, including xml:lang and `>` inside quoted values", () => {
34
+ expect(
35
+ applyDocumentLanguage(
36
+ '<html class="dark" data-x="a>b" xml:lang="xx" lang="xx" dir="ltr"><head></head>',
37
+ "pt-BR",
38
+ ),
39
+ ).toBe('<html lang="pt-BR" class="dark" data-x="a>b" xml:lang="xx" dir="ltr"><head></head>');
40
+ });
41
+
42
+ it("only touches the first <html> start tag, never look-alikes", () => {
43
+ const html = '<html-widget lang="xx"></html-widget><html><body><html></body></html>';
44
+ expect(applyDocumentLanguage(html, "en")).toBe(
45
+ '<html-widget lang="xx"></html-widget><html lang="en"><body><html></body></html>',
46
+ );
47
+ });
48
+
49
+ it("inserts an <html> start tag after the doctype when the layout omits it", () => {
50
+ expect(applyDocumentLanguage("<!DOCTYPE html>\n<head></head><body></body>", "en")).toBe(
51
+ '<!DOCTYPE html><html lang="en">\n<head></head><body></body>',
52
+ );
53
+ expect(applyDocumentLanguage("<head></head><body></body>", "en")).toBe(
54
+ '<html lang="en"><head></head><body></body>',
55
+ );
56
+ });
57
+
58
+ it("keeps data-lang and self-closing slashes, drops every lang", () => {
59
+ expect(applyDocumentLanguage('<html data-lang="x" lang=a\tlang="b" />', "en")).toBe(
60
+ '<html lang="en" data-lang="x" />',
61
+ );
62
+ });
63
+
64
+ // Regression (CodeQL js/polynomial-redos): the attribute strip was a
65
+ // global `\s+lang…` regex that backtracked quadratically over long
66
+ // whitespace runs in layout HTML.
67
+ it("stays linear on long whitespace runs", () => {
68
+ const ws = "\t".repeat(200_000);
69
+ const started = performance.now();
70
+ expect(applyDocumentLanguage(`<html${ws}x${ws}>`, "en")).toBe(`<html lang="en"${ws}x${ws}>`);
71
+ expect(applyDocumentLanguage(`<html${ws}`, "en")).toBe(`<html lang="en"><html${ws}`);
72
+ expect(performance.now() - started).toBeLessThan(1000);
73
+ });
74
+
75
+ // Migration 0232: an unconfigured site language renders no `lang`
76
+ // rather than a substituted one.
77
+ it("with no language, strips a layout-authored lang and adds none", () => {
78
+ expect(applyDocumentLanguage('<html lang="en" class="x"><head></head>', null)).toBe(
79
+ '<html class="x"><head></head>',
80
+ );
81
+ expect(applyDocumentLanguage("<!doctype html><head></head>", null)).toBe(
82
+ "<!doctype html><head></head>",
83
+ );
84
+ });
85
+
86
+ it("escapes the value", () => {
87
+ expect(applyDocumentLanguage("<html>", 'a"b')).toBe('<html lang="a&quot;b">');
88
+ });
89
+ });
90
+
91
+ describe("resolveDocumentLanguage", () => {
92
+ it("prefers the plugin-contributed per-page language", () => {
93
+ expect(resolveDocumentLanguage({ contributed: "de", siteLanguage: "en" })).toBe("de");
94
+ });
95
+
96
+ it("uses the stored site language when no plugin assigns one", () => {
97
+ expect(resolveDocumentLanguage({ contributed: undefined, siteLanguage: "fr" })).toBe("fr");
98
+ });
99
+
100
+ it("returns null when neither a plugin nor the site sets a language — never 'en'", () => {
101
+ expect(resolveDocumentLanguage({ contributed: undefined, siteLanguage: null })).toBeNull();
102
+ expect(resolveDocumentLanguage({ contributed: "de", siteLanguage: null })).toBe("de");
103
+ });
104
+ });
105
+
106
+ describe("languageTagSchema", () => {
107
+ it("accepts BCP 47 shaped tags", () => {
108
+ for (const tag of ["en", "de", "pt-BR", "zh-Hant-TW", "es-419", "gsw"]) {
109
+ expect(languageTagSchema.safeParse(tag).success).toBe(true);
110
+ }
111
+ });
112
+
113
+ it("rejects malformed tags", () => {
114
+ for (const tag of ["", "e", "en_US", "en-", "-en", 'en" onload="x', "x".repeat(36)]) {
115
+ expect(languageTagSchema.safeParse(tag).success).toBe(false);
116
+ }
117
+ });
118
+ });
@@ -0,0 +1,146 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The document language — the `lang` attribute on `<html>`.
5
+ *
6
+ * Screen readers pick their pronunciation from it and search engines
7
+ * use it to classify the page, so every page Caelo renders must carry
8
+ * one. Like `<title>` and the rest of the SEO head, it is a structured
9
+ * value owned by core, never something a layout author hand-writes:
10
+ * the composed page always carries the value core resolved, replacing
11
+ * whatever `lang` the layout HTML happened to contain.
12
+ *
13
+ * Where the value comes from (resolved identically by the admin
14
+ * preview and the static generator):
15
+ * 1. the per-page language a plugin contributes through the head
16
+ * contribution point (the `international-site` plugin knows each
17
+ * page's locale — core does not, since epic #380), else
18
+ * 2. the site's stored language (`site_defaults.site_language`, set
19
+ * by the AI via `set_site_identity` or by the Owner at /security/seo).
20
+ * The stored language has no default (migration 0232, CLAUDE.md §2): NULL
21
+ * means nobody chose one yet. Nothing here substitutes a language for it —
22
+ * the preview renders `<html>` without `lang` and flags
23
+ * `site-language-unset`, and the static generator refuses to build.
24
+ */
25
+
26
+ import { z } from "zod";
27
+
28
+ /**
29
+ * A BCP 47 language tag as accepted for `<html lang>`: a 2–8 letter
30
+ * primary subtag followed by optional alphanumeric subtags (`en`,
31
+ * `de-AT`, `zh-Hant-TW`). Deliberately structural rather than a full
32
+ * registry check — the same shape the `site_defaults.site_language`
33
+ * CHECK constraint enforces.
34
+ */
35
+ export const languageTagSchema = z
36
+ .string()
37
+ .max(35)
38
+ .regex(
39
+ /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/,
40
+ "must be a BCP 47 language tag such as `en`, `de` or `pt-BR`",
41
+ );
42
+
43
+ /**
44
+ * Pick the language for one page: a plugin-contributed per-page value
45
+ * wins over the site's stored language. Exported so preview and build
46
+ * resolve through the same expression.
47
+ *
48
+ * @returns `null` when no plugin assigns one and the site language is not
49
+ * configured — the caller surfaces that state, never a guessed tag.
50
+ */
51
+ export function resolveDocumentLanguage(args: {
52
+ readonly contributed: string | undefined;
53
+ readonly siteLanguage: string | null;
54
+ }): string | null {
55
+ return args.contributed ?? args.siteLanguage;
56
+ }
57
+
58
+ // The `<html` start-tag opener; the lookahead keeps look-alikes such as
59
+ // `<html-widget>` out. Fixed-width, so matching is linear.
60
+ const HTML_TAG_OPENER_RE = /<html(?=[\s>/])/i;
61
+ const DOCTYPE_RE = /^\s*<!doctype[^>]*>/i;
62
+ // HTML's ASCII whitespace (the tokenizer's attribute separators).
63
+ const HTML_WS = new Set(["\t", "\n", "\f", "\r", " "]);
64
+
65
+ /**
66
+ * Scan the attributes of the start tag beginning at `from` (just past
67
+ * `<html`) and return them with every `lang` attribute removed, plus the
68
+ * index just past the closing `>`. `null` when the tag never closes.
69
+ *
70
+ * A single forward pass over the tag, character by character: layout
71
+ * HTML is AI- or operator-authored input, and a backtracking regex over
72
+ * it (`\s+lang…` with a global flag) is polynomial on long whitespace
73
+ * runs (CodeQL js/polynomial-redos). Quoted values may contain `>`.
74
+ * `xml:lang` and `data-lang` are different attribute names and survive.
75
+ */
76
+ function stripLangAttributes(
77
+ html: string,
78
+ from: number,
79
+ ): { readonly attrs: string; readonly end: number } | null {
80
+ const n = html.length;
81
+ let i = from;
82
+ let attrs = "";
83
+ while (i < n) {
84
+ const segmentStart = i;
85
+ while (i < n && HTML_WS.has(html[i] as string)) i++;
86
+ if (i >= n) return null;
87
+ if (html[i] === ">") return { attrs: attrs + html.slice(segmentStart, i), end: i + 1 };
88
+ const nameStart = i;
89
+ // An attribute name runs to whitespace, `=`, `>` or `/`; a stray `=`
90
+ // or `/` is consumed as a one-character name so the scan always moves.
91
+ i++;
92
+ while (i < n && !HTML_WS.has(html[i] as string) && !"=>/".includes(html[i] as string)) i++;
93
+ const name = html.slice(nameStart, i).toLowerCase();
94
+ const afterName = i;
95
+ while (i < n && HTML_WS.has(html[i] as string)) i++;
96
+ if (html[i] === "=") {
97
+ i++;
98
+ while (i < n && HTML_WS.has(html[i] as string)) i++;
99
+ const quote = html[i];
100
+ if (quote === '"' || quote === "'") {
101
+ const close = html.indexOf(quote, i + 1);
102
+ if (close === -1) return null;
103
+ i = close + 1;
104
+ } else {
105
+ while (i < n && !HTML_WS.has(html[i] as string) && html[i] !== ">") i++;
106
+ }
107
+ } else {
108
+ // No value: the whitespace belongs to the next attribute.
109
+ i = afterName;
110
+ }
111
+ if (name !== "lang") attrs += html.slice(segmentStart, i);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function escapeAttr(s: string): string {
117
+ return s
118
+ .replaceAll("&", "&amp;")
119
+ .replaceAll('"', "&quot;")
120
+ .replaceAll("<", "&lt;")
121
+ .replaceAll(">", "&gt;");
122
+ }
123
+
124
+ /**
125
+ * Set `lang` on the document's `<html>` start tag, replacing any value
126
+ * the layout carried. A layout with no `<html>` start tag (the tag is
127
+ * optional in HTML) gets one inserted right after the doctype, which
128
+ * parses to the same document with the language attached. An `<html`
129
+ * start tag that never closes is malformed and treated as absent.
130
+ *
131
+ * `lang: null` (no language configured) removes every layout-authored
132
+ * `lang` and adds none: core owns the value, so a hand-written one must
133
+ * not pass for a configured language.
134
+ */
135
+ export function applyDocumentLanguage(html: string, lang: string | null): string {
136
+ const attr = lang === null ? "" : ` lang="${escapeAttr(lang)}"`;
137
+ const open = HTML_TAG_OPENER_RE.exec(html);
138
+ const tag = open ? stripLangAttributes(html, open.index + "<html".length) : null;
139
+ if (open && tag) {
140
+ return `${html.slice(0, open.index)}<html${attr}${tag.attrs}>${html.slice(tag.end)}`;
141
+ }
142
+ if (lang === null) return html;
143
+ const doctype = DOCTYPE_RE.exec(html);
144
+ const at = doctype ? doctype[0].length : 0;
145
+ return `${html.slice(0, at)}<html${attr}>${html.slice(at)}`;
146
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,9 @@ export * from "./content.js";
11
11
  export * from "./context.js";
12
12
  export * from "./css-gradient-scan.js";
13
13
  export * from "./css-var-scan.js";
14
+ export * from "./database-url.js";
14
15
  export * from "./design-draft-shell.js";
16
+ export * from "./document-language.js";
15
17
  export * from "./font-assets.js";
16
18
  export * from "./fonts.js";
17
19
  export * from "./genesis.js";
@@ -42,6 +44,7 @@ export * from "./safe-keys.js";
42
44
  export * from "./seo.js";
43
45
  export * from "./skills.js";
44
46
  export * from "./snapshots.js";
47
+ export * from "./static-cache-policy.js";
45
48
  export * from "./strip-cdata.js";
46
49
  export * from "./structured-sets.js";
47
50
  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,