@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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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: {
|
|
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"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("&", "&")
|
|
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
|
@@ -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,
|