@caelo-cms/shared 0.10.29 → 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/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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/seo.d.ts +66 -12
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +110 -18
- package/dist/seo.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/database-url.test.ts +116 -0
- package/src/database-url.ts +83 -0
- package/src/index.ts +1 -0
- package/src/seo.test.ts +137 -1
- package/src/seo.ts +133 -24
- package/src/version.ts +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database connection URLs as every Caelo process reads them from its
|
|
3
|
+
* environment.
|
|
4
|
+
*
|
|
5
|
+
* A URL var (`ADMIN_DATABASE_URL`, `PUBLIC_ADMIN_DATABASE_URL`,
|
|
6
|
+
* `PUBLIC_DATABASE_URL`) may carry its password inline — the shape the
|
|
7
|
+
* self-hosted compose stack, CI and `.env` use — or leave it out and have
|
|
8
|
+
* the password come from a companion `<NAME>_PASSWORD` var
|
|
9
|
+
* (`ADMIN_DATABASE_URL` → `ADMIN_DATABASE_PASSWORD`). Cloud installs use the
|
|
10
|
+
* second shape: the URL (host, role, database) is a plain env var, the
|
|
11
|
+
* password is a Secret Manager reference, so the password never shows up in
|
|
12
|
+
* the service's configuration or its revision history.
|
|
13
|
+
*
|
|
14
|
+
* Pure module — no I/O.
|
|
15
|
+
*/
|
|
16
|
+
/** The `<NAME>_PASSWORD` var that pairs with a `<NAME>_URL` var. */
|
|
17
|
+
export declare function databasePasswordVar(urlVar: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Inject `password` into a password-less connection URL. Refuses a URL that
|
|
20
|
+
* already carries one: two sources for the same credential is a
|
|
21
|
+
* misconfiguration, and silently preferring either would hide it.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* withDatabasePassword("postgresql://admin_role@10.0.0.3:5432/cms_admin", "s3cr3t")
|
|
25
|
+
* // → "postgresql://admin_role:s3cr3t@10.0.0.3:5432/cms_admin"
|
|
26
|
+
*/
|
|
27
|
+
export declare function withDatabasePassword(url: string, password: string, label?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* The connection URL from the first of `urlVars` that is set, with its
|
|
30
|
+
* companion `_PASSWORD` var applied when that is set. `undefined` when none
|
|
31
|
+
* of `urlVars` is set, so callers keep their own "X is required" errors.
|
|
32
|
+
*
|
|
33
|
+
* @param urlVars URL var names in order of preference, e.g.
|
|
34
|
+
* `["PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_DATABASE_URL"]`.
|
|
35
|
+
* @param env The environment to read (defaults to `process.env`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function databaseUrlFromEnv(urlVars: readonly string[], env?: Readonly<Record<string, string | undefined>>): string | undefined;
|
|
38
|
+
//# sourceMappingURL=database-url.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-url.d.ts","sourceRoot":"","sources":["../src/database-url.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AAEH,oEAAoE;AACpE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAK1D;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,KAAK,SAAiB,GACrB,MAAM,CAoBR;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,GAAG,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAe,GAC9D,MAAM,GAAG,SAAS,CASpB"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Database connection URLs as every Caelo process reads them from its
|
|
4
|
+
* environment.
|
|
5
|
+
*
|
|
6
|
+
* A URL var (`ADMIN_DATABASE_URL`, `PUBLIC_ADMIN_DATABASE_URL`,
|
|
7
|
+
* `PUBLIC_DATABASE_URL`) may carry its password inline — the shape the
|
|
8
|
+
* self-hosted compose stack, CI and `.env` use — or leave it out and have
|
|
9
|
+
* the password come from a companion `<NAME>_PASSWORD` var
|
|
10
|
+
* (`ADMIN_DATABASE_URL` → `ADMIN_DATABASE_PASSWORD`). Cloud installs use the
|
|
11
|
+
* second shape: the URL (host, role, database) is a plain env var, the
|
|
12
|
+
* password is a Secret Manager reference, so the password never shows up in
|
|
13
|
+
* the service's configuration or its revision history.
|
|
14
|
+
*
|
|
15
|
+
* Pure module — no I/O.
|
|
16
|
+
*/
|
|
17
|
+
/** The `<NAME>_PASSWORD` var that pairs with a `<NAME>_URL` var. */
|
|
18
|
+
export function databasePasswordVar(urlVar) {
|
|
19
|
+
if (!urlVar.endsWith("_URL")) {
|
|
20
|
+
throw new Error(`database URL var must end in _URL, got ${urlVar}`);
|
|
21
|
+
}
|
|
22
|
+
return `${urlVar.slice(0, -"_URL".length)}_PASSWORD`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Inject `password` into a password-less connection URL. Refuses a URL that
|
|
26
|
+
* already carries one: two sources for the same credential is a
|
|
27
|
+
* misconfiguration, and silently preferring either would hide it.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* withDatabasePassword("postgresql://admin_role@10.0.0.3:5432/cms_admin", "s3cr3t")
|
|
31
|
+
* // → "postgresql://admin_role:s3cr3t@10.0.0.3:5432/cms_admin"
|
|
32
|
+
*/
|
|
33
|
+
export function withDatabasePassword(url, password, label = "database URL") {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = new URL(url);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`${label} is not a valid URL`);
|
|
40
|
+
}
|
|
41
|
+
if (parsed.password) {
|
|
42
|
+
throw new Error(`${label} already carries a password and a separate password is set too — keep one`);
|
|
43
|
+
}
|
|
44
|
+
if (!parsed.username) {
|
|
45
|
+
throw new Error(`${label} names no user to attach the password to`);
|
|
46
|
+
}
|
|
47
|
+
// Encode first: the URL setter leaves a literal `%` alone, so `p%40ss`
|
|
48
|
+
// would decode to `p@ss` and `p%ss` would be an invalid escape. Postgres
|
|
49
|
+
// clients decode the userinfo again.
|
|
50
|
+
parsed.password = encodeURIComponent(password);
|
|
51
|
+
return parsed.toString();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The connection URL from the first of `urlVars` that is set, with its
|
|
55
|
+
* companion `_PASSWORD` var applied when that is set. `undefined` when none
|
|
56
|
+
* of `urlVars` is set, so callers keep their own "X is required" errors.
|
|
57
|
+
*
|
|
58
|
+
* @param urlVars URL var names in order of preference, e.g.
|
|
59
|
+
* `["PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_DATABASE_URL"]`.
|
|
60
|
+
* @param env The environment to read (defaults to `process.env`).
|
|
61
|
+
*/
|
|
62
|
+
export function databaseUrlFromEnv(urlVars, env = process.env) {
|
|
63
|
+
for (const urlVar of urlVars) {
|
|
64
|
+
const url = env[urlVar];
|
|
65
|
+
if (!url)
|
|
66
|
+
continue;
|
|
67
|
+
const passwordVar = databasePasswordVar(urlVar);
|
|
68
|
+
const password = env[passwordVar];
|
|
69
|
+
return password ? withDatabasePassword(url, password, `${urlVar} (with ${passwordVar})`) : url;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=database-url.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-url.js","sourceRoot":"","sources":["../src/database-url.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;;;;;;;GAcG;AAEH,oEAAoE;AACpE,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,0CAA0C,MAAM,EAAE,CAAC,CAAC;IACtE,CAAC;IACD,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC;AACvD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAClC,GAAW,EACX,QAAgB,EAChB,KAAK,GAAG,cAAc;IAEtB,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,qBAAqB,CAAC,CAAC;IACjD,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CACb,GAAG,KAAK,2EAA2E,CACpF,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,0CAA0C,CAAC,CAAC;IACtE,CAAC;IACD,uEAAuE;IACvE,yEAAyE;IACzE,qCAAqC;IACrC,MAAM,CAAC,QAAQ,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IAC/C,OAAO,MAAM,CAAC,QAAQ,EAAE,CAAC;AAC3B,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAChC,OAA0B,EAC1B,MAAoD,OAAO,CAAC,GAAG;IAE/D,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC;QACxB,IAAI,CAAC,GAAG;YAAE,SAAS;QACnB,MAAM,WAAW,GAAG,mBAAmB,CAAC,MAAM,CAAC,CAAC;QAChD,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,CAAC,CAAC;QAClC,OAAO,QAAQ,CAAC,CAAC,CAAC,oBAAoB,CAAC,GAAG,EAAE,QAAQ,EAAE,GAAG,MAAM,UAAU,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;IACjG,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./content.js";
|
|
|
8
8
|
export * from "./context.js";
|
|
9
9
|
export * from "./css-gradient-scan.js";
|
|
10
10
|
export * from "./css-var-scan.js";
|
|
11
|
+
export * from "./database-url.js";
|
|
11
12
|
export * from "./design-draft-shell.js";
|
|
12
13
|
export * from "./document-language.js";
|
|
13
14
|
export * from "./font-assets.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,cAAc,CAAC;AAExC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,oBAAoB,CAAC;AACnC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EACL,KAAK,UAAU,EACf,KAAK,MAAM,EACX,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,UAAU,EACV,aAAa,EACb,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAC;AACrB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kCAAkC,CAAC;AACjD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,UAAU,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,cAAc,CAAC;AAExC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,oBAAoB,CAAC;AACnC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EACL,KAAK,UAAU,EACf,KAAK,MAAM,EACX,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,UAAU,EACV,aAAa,EACb,MAAM,EACN,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAC;AACrB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kCAAkC,CAAC;AACjD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,UAAU,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./content.js";
|
|
|
9
9
|
export * from "./context.js";
|
|
10
10
|
export * from "./css-gradient-scan.js";
|
|
11
11
|
export * from "./css-var-scan.js";
|
|
12
|
+
export * from "./database-url.js";
|
|
12
13
|
export * from "./design-draft-shell.js";
|
|
13
14
|
export * from "./document-language.js";
|
|
14
15
|
export * from "./font-assets.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC;AAExC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,oBAAoB,CAAC;AACnC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAKL,UAAU,EACV,aAAa,EACb,MAAM,GAGP,MAAM,aAAa,CAAC;AACrB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kCAAkC,CAAC;AACjD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,UAAU,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAqB,YAAY,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC;AAExC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,oBAAoB,CAAC;AACnC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAKL,UAAU,EACV,aAAa,EACb,MAAM,GAGP,MAAM,aAAa,CAAC;AACrB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kCAAkC,CAAC;AACjD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,UAAU,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAqB,YAAY,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/seo.d.ts
CHANGED
|
@@ -54,6 +54,46 @@ export declare const siteDefaultsSetSeoInputSchema: z.ZodObject<{
|
|
|
54
54
|
}, z.core.$strict>>;
|
|
55
55
|
}, z.core.$strict>;
|
|
56
56
|
export type SiteDefaultsSetSeoInput = z.infer<typeof siteDefaultsSetSeoInputSchema>;
|
|
57
|
+
/**
|
|
58
|
+
* Input of `site_defaults.propose_set_seo` — the AI's path to the site SEO
|
|
59
|
+
* settings (CLAUDE.md §11.A). Every field is optional so the AI changes only
|
|
60
|
+
* what the operator asked for; an omitted field keeps its stored value when
|
|
61
|
+
* the Owner approves. `organizationJson` replaces the whole object.
|
|
62
|
+
*/
|
|
63
|
+
export declare const siteSeoProposalInputSchema: z.ZodObject<{
|
|
64
|
+
siteBaseUrl: z.ZodOptional<z.ZodString>;
|
|
65
|
+
sitemapEnabled: z.ZodOptional<z.ZodBoolean>;
|
|
66
|
+
organizationJson: z.ZodOptional<z.ZodObject<{
|
|
67
|
+
name: z.ZodOptional<z.ZodString>;
|
|
68
|
+
url: z.ZodOptional<z.ZodString>;
|
|
69
|
+
logo: z.ZodOptional<z.ZodString>;
|
|
70
|
+
sameAs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
71
|
+
}, z.core.$strict>>;
|
|
72
|
+
}, z.core.$strict>;
|
|
73
|
+
export type SiteSeoProposalInput = z.infer<typeof siteSeoProposalInputSchema>;
|
|
74
|
+
/** Result of {@link checkPublicSiteBaseUrl}. */
|
|
75
|
+
export type PublicSiteBaseUrlCheck = {
|
|
76
|
+
ok: true;
|
|
77
|
+
url: string;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
message: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Validate a public site base URL and normalise it to its origin
|
|
84
|
+
* (`https://example.com`, no trailing slash).
|
|
85
|
+
*
|
|
86
|
+
* Every canonical, og:url, JSON-LD url, hreflang target and sitemap entry is
|
|
87
|
+
* `<base><path>`, so the base must be exactly the origin visitors reach: no
|
|
88
|
+
* path, query, fragment or credentials. On a cloud install (`provider` is a
|
|
89
|
+
* `CAELO_PROVIDER` other than self-hosted) it must be https and not a
|
|
90
|
+
* loopback host — a localhost base there ships unreachable canonicals to
|
|
91
|
+
* production. A self-hosted install may use `http://localhost:<port>`, the
|
|
92
|
+
* documented local-dev value; any other host still needs https.
|
|
93
|
+
*
|
|
94
|
+
* @param provider - `CAELO_PROVIDER` of the install; `undefined`/`""`/`"self-hosted"` = self-hosted.
|
|
95
|
+
*/
|
|
96
|
+
export declare function checkPublicSiteBaseUrl(raw: string, provider: string | undefined): PublicSiteBaseUrlCheck;
|
|
57
97
|
export interface PageSeoRow {
|
|
58
98
|
pageId: string;
|
|
59
99
|
metaDescription: string;
|
|
@@ -81,12 +121,26 @@ export interface SiteSeoSettings {
|
|
|
81
121
|
};
|
|
82
122
|
}
|
|
83
123
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
124
|
+
* How a deploy target serves pages (`deploy_targets.page_url_style`):
|
|
125
|
+
* 'directory' emits `<path>/index.html` and serves `/<path>/`;
|
|
126
|
+
* 'no-extension' emits a bare `<path>` file and serves `/<path>`.
|
|
127
|
+
*/
|
|
128
|
+
export declare const PAGE_URL_STYLES: readonly ["directory", "no-extension"];
|
|
129
|
+
export type PageUrlStyle = (typeof PAGE_URL_STYLES)[number];
|
|
130
|
+
/**
|
|
131
|
+
* THE public page URL builder (#590). Every absolute page URL Caelo
|
|
132
|
+
* emits — canonical, og:url, JSON-LD url, sitemap `<loc>`,
|
|
133
|
+
* hreflang/x-default targets, language-switcher links — comes from
|
|
134
|
+
* here, so they are byte-identical by construction. Two builders
|
|
135
|
+
* drifting apart is what shipped `/en/about` as hreflang next to an
|
|
136
|
+
* `/en/about/` canonical.
|
|
137
|
+
*
|
|
138
|
+
* `pages_seo.canonical_url` (`override`) wins when set. Otherwise the
|
|
139
|
+
* URL is `<scheme>//<host><path>`: the path is the COMPOSED public path
|
|
140
|
+
* from `pages.current_path` (#390), its trailing slash follows the
|
|
141
|
+
* serving target's {@link PageUrlStyle}, the host is the URL composition
|
|
142
|
+
* point's `host` slot (a host-strategy locale) or else the site base
|
|
143
|
+
* URL's, and the scheme always comes from the base URL.
|
|
90
144
|
*/
|
|
91
145
|
export declare function resolveCanonicalUrl(args: {
|
|
92
146
|
siteBaseUrl: string;
|
|
@@ -94,12 +148,12 @@ export declare function resolveCanonicalUrl(args: {
|
|
|
94
148
|
* "/" for the site root. */
|
|
95
149
|
pagePath: string;
|
|
96
150
|
override: string | null;
|
|
97
|
-
/**
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*/
|
|
102
|
-
|
|
151
|
+
/** The serving deploy target's page emission style. Required — a
|
|
152
|
+
* defaulted style is how the preview drifted from the build. */
|
|
153
|
+
pageUrlStyle: PageUrlStyle;
|
|
154
|
+
/** Host from the URL composition point's `host` slot (e.g.
|
|
155
|
+
* `de.example.com`); null/absent → the site base URL's host. */
|
|
156
|
+
host?: string | null;
|
|
103
157
|
}): string;
|
|
104
158
|
export interface SeoMetaInput {
|
|
105
159
|
title: string;
|
package/dist/seo.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,iBAAiB,gFAQpB,CAAC;AACX,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,6EAA6E;AAC7E,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAC5C,eAAO,MAAM,+BAA+B,MAAM,CAAC;AAEnD,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;kBAUnB,CAAC;AACZ,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,eAAO,MAAM,sBAAsB;;;;kBAMxB,CAAC;AACZ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,eAAO,MAAM,sBAAsB;;;;;kBAQxB,CAAC;AACZ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"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"}
|
package/dist/seo.js
CHANGED
|
@@ -48,6 +48,15 @@ export const seoOptimizeInputSchema = z
|
|
|
48
48
|
context: z.string().max(4000).optional(),
|
|
49
49
|
})
|
|
50
50
|
.strict();
|
|
51
|
+
/** Organization JSON-LD fields — structured only, never raw markup (CLAUDE.md §2). */
|
|
52
|
+
const organizationJsonSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
name: z.string().max(256).optional(),
|
|
55
|
+
url: z.string().max(2048).optional(),
|
|
56
|
+
logo: z.string().max(2048).optional(),
|
|
57
|
+
sameAs: z.array(z.string().max(2048)).max(20).optional(),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
51
60
|
export const siteDefaultsSetSeoInputSchema = z
|
|
52
61
|
.object({
|
|
53
62
|
siteBaseUrl: z
|
|
@@ -56,34 +65,117 @@ export const siteDefaultsSetSeoInputSchema = z
|
|
|
56
65
|
.max(2048)
|
|
57
66
|
.url("siteBaseUrl must be an absolute URL (https://example.com)"),
|
|
58
67
|
sitemapEnabled: z.boolean(),
|
|
59
|
-
organizationJson:
|
|
60
|
-
.object({
|
|
61
|
-
name: z.string().max(256).optional(),
|
|
62
|
-
url: z.string().max(2048).optional(),
|
|
63
|
-
logo: z.string().max(2048).optional(),
|
|
64
|
-
sameAs: z.array(z.string().max(2048)).max(20).optional(),
|
|
65
|
-
})
|
|
66
|
-
.strict()
|
|
67
|
-
.default({}),
|
|
68
|
+
organizationJson: organizationJsonSchema.default({}),
|
|
68
69
|
})
|
|
69
70
|
.strict();
|
|
70
71
|
/**
|
|
71
|
-
*
|
|
72
|
-
* is
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
* Input of `site_defaults.propose_set_seo` — the AI's path to the site SEO
|
|
73
|
+
* settings (CLAUDE.md §11.A). Every field is optional so the AI changes only
|
|
74
|
+
* what the operator asked for; an omitted field keeps its stored value when
|
|
75
|
+
* the Owner approves. `organizationJson` replaces the whole object.
|
|
76
|
+
*/
|
|
77
|
+
export const siteSeoProposalInputSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
siteBaseUrl: z.string().min(1).max(2048).optional(),
|
|
80
|
+
sitemapEnabled: z.boolean().optional(),
|
|
81
|
+
organizationJson: organizationJsonSchema.optional(),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
.refine((v) => v.siteBaseUrl !== undefined ||
|
|
85
|
+
v.sitemapEnabled !== undefined ||
|
|
86
|
+
v.organizationJson !== undefined, "pass at least one of `siteBaseUrl`, `sitemapEnabled`, `organizationJson`");
|
|
87
|
+
const LOOPBACK_HOST = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/i;
|
|
88
|
+
/** Wildcard bind addresses: a server listens on them, no browser can visit them. */
|
|
89
|
+
const WILDCARD_HOST = /^(0\.0\.0\.0|\[::\])$/;
|
|
90
|
+
/**
|
|
91
|
+
* Validate a public site base URL and normalise it to its origin
|
|
92
|
+
* (`https://example.com`, no trailing slash).
|
|
93
|
+
*
|
|
94
|
+
* Every canonical, og:url, JSON-LD url, hreflang target and sitemap entry is
|
|
95
|
+
* `<base><path>`, so the base must be exactly the origin visitors reach: no
|
|
96
|
+
* path, query, fragment or credentials. On a cloud install (`provider` is a
|
|
97
|
+
* `CAELO_PROVIDER` other than self-hosted) it must be https and not a
|
|
98
|
+
* loopback host — a localhost base there ships unreachable canonicals to
|
|
99
|
+
* production. A self-hosted install may use `http://localhost:<port>`, the
|
|
100
|
+
* documented local-dev value; any other host still needs https.
|
|
101
|
+
*
|
|
102
|
+
* @param provider - `CAELO_PROVIDER` of the install; `undefined`/`""`/`"self-hosted"` = self-hosted.
|
|
103
|
+
*/
|
|
104
|
+
export function checkPublicSiteBaseUrl(raw, provider) {
|
|
105
|
+
let u;
|
|
106
|
+
try {
|
|
107
|
+
u = new URL(raw.trim());
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
message: `"${raw}" is not an absolute URL — pass the public origin, e.g. https://www.example.com`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const origin = `${u.protocol}//${u.host}`;
|
|
116
|
+
if (u.username || u.password) {
|
|
117
|
+
return { ok: false, message: "the site URL must not contain credentials" };
|
|
118
|
+
}
|
|
119
|
+
if ((u.pathname !== "/" && u.pathname !== "") || u.search || u.hash) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
message: `the site URL must be the origin only, without path, query or fragment — use ${origin}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (WILDCARD_HOST.test(u.hostname)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: `${u.hostname} is a bind address, not an address visitors can open — use the public domain (e.g. https://www.example.com) or, on a local self-hosted install, http://localhost:<port>`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const selfHosted = !provider || provider === "self-hosted";
|
|
132
|
+
const loopback = LOOPBACK_HOST.test(u.hostname);
|
|
133
|
+
if (loopback && !selfHosted) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
message: `${origin} is a local address; on this ${provider} install the site URL must be the public domain visitors use (e.g. https://www.example.com)`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (u.protocol !== "https:" && !(selfHosted && loopback && u.protocol === "http:")) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
message: `the site URL must use https (got ${u.protocol.replace(":", "")}) — use https://${u.host}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, url: origin };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* How a deploy target serves pages (`deploy_targets.page_url_style`):
|
|
149
|
+
* 'directory' emits `<path>/index.html` and serves `/<path>/`;
|
|
150
|
+
* 'no-extension' emits a bare `<path>` file and serves `/<path>`.
|
|
151
|
+
*/
|
|
152
|
+
export const PAGE_URL_STYLES = ["directory", "no-extension"];
|
|
153
|
+
/**
|
|
154
|
+
* THE public page URL builder (#590). Every absolute page URL Caelo
|
|
155
|
+
* emits — canonical, og:url, JSON-LD url, sitemap `<loc>`,
|
|
156
|
+
* hreflang/x-default targets, language-switcher links — comes from
|
|
157
|
+
* here, so they are byte-identical by construction. Two builders
|
|
158
|
+
* drifting apart is what shipped `/en/about` as hreflang next to an
|
|
159
|
+
* `/en/about/` canonical.
|
|
160
|
+
*
|
|
161
|
+
* `pages_seo.canonical_url` (`override`) wins when set. Otherwise the
|
|
162
|
+
* URL is `<scheme>//<host><path>`: the path is the COMPOSED public path
|
|
163
|
+
* from `pages.current_path` (#390), its trailing slash follows the
|
|
164
|
+
* serving target's {@link PageUrlStyle}, the host is the URL composition
|
|
165
|
+
* point's `host` slot (a host-strategy locale) or else the site base
|
|
166
|
+
* URL's, and the scheme always comes from the base URL.
|
|
77
167
|
*/
|
|
78
168
|
export function resolveCanonicalUrl(args) {
|
|
79
169
|
if (args.override && args.override.length > 0)
|
|
80
170
|
return args.override;
|
|
81
|
-
const
|
|
171
|
+
const siteBase = args.siteBaseUrl.endsWith("/")
|
|
172
|
+
? args.siteBaseUrl.slice(0, -1)
|
|
173
|
+
: args.siteBaseUrl;
|
|
174
|
+
const base = args.host ? `${new URL(siteBase).protocol}//${args.host}` : siteBase;
|
|
82
175
|
const trimmed = trimSlashes(args.pagePath);
|
|
83
176
|
if (trimmed.length === 0)
|
|
84
177
|
return `${base}/`;
|
|
85
|
-
|
|
86
|
-
return style === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
|
|
178
|
+
return args.pageUrlStyle === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
|
|
87
179
|
}
|
|
88
180
|
/**
|
|
89
181
|
* Render the <head> meta block for a page. Returns the HTML string
|
package/dist/seo.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seo.js","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;GAKG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,QAAQ;IACR,QAAQ;IACR,OAAO;IACP,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,OAAO;CACC,CAAC;AAGX,6EAA6E;AAC7E,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,CAAC;AAC5C,MAAM,CAAC,MAAM,+BAA+B,GAAG,GAAG,CAAC;AAEnD,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAC;AAC5C,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAEtC,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC;KAC/B,MAAM,CAAC;IACN,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE;IACzB,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,QAAQ,EAAE;IACpE,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IACvD,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IACrE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE;IAC/B,UAAU,EAAE,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,QAAQ,EAAE;IAChD,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;CAC9C,CAAC;KACD,MAAM,EAAE,CAAC;AAGZ,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC;KACpC,MAAM,CAAC;IACN,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE;IACzB,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,wBAAwB,CAAC;IAChE,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;CACxD,CAAC;KACD,MAAM,EAAE,CAAC;AAGZ,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC;KACpC,MAAM,CAAC;IACN,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE;IACzB,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,wBAAwB,CAAC;IAChE,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IACvD,0FAA0F;IAC1F,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE;CACzC,CAAC;KACD,MAAM,EAAE,CAAC;AAGZ,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC;KAC3C,MAAM,CAAC;IACN,WAAW,EAAE,CAAC;SACX,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,GAAG,CAAC,IAAI,CAAC;SACT,GAAG,CAAC,2DAA2D,CAAC;IACnE,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE;IAC3B,gBAAgB,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"seo.js","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"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"}
|
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.29";
|
|
|
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.",
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ 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";
|
|
15
16
|
export * from "./document-language.js";
|
|
16
17
|
export * from "./font-assets.js";
|
package/src/seo.test.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
4
|
import {
|
|
5
|
+
checkPublicSiteBaseUrl,
|
|
5
6
|
injectSeoIntoHead,
|
|
6
7
|
renderSeoHead,
|
|
7
8
|
resolveCanonicalUrl,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
seoOptimizeInputSchema,
|
|
10
11
|
seoSetInputSchema,
|
|
11
12
|
siteDefaultsSetSeoInputSchema,
|
|
13
|
+
siteSeoProposalInputSchema,
|
|
12
14
|
} from "./seo.js";
|
|
13
15
|
|
|
14
16
|
describe("resolveCanonicalUrl", () => {
|
|
@@ -18,6 +20,8 @@ describe("resolveCanonicalUrl", () => {
|
|
|
18
20
|
siteBaseUrl: "https://example.com",
|
|
19
21
|
pagePath: "/anything",
|
|
20
22
|
override: "https://canonical.example.com/x",
|
|
23
|
+
pageUrlStyle: "directory",
|
|
24
|
+
host: "de.example.com",
|
|
21
25
|
}),
|
|
22
26
|
).toBe("https://canonical.example.com/x");
|
|
23
27
|
});
|
|
@@ -28,6 +32,7 @@ describe("resolveCanonicalUrl", () => {
|
|
|
28
32
|
siteBaseUrl: "https://example.com",
|
|
29
33
|
pagePath: "/",
|
|
30
34
|
override: null,
|
|
35
|
+
pageUrlStyle: "directory",
|
|
31
36
|
}),
|
|
32
37
|
).toBe("https://example.com/");
|
|
33
38
|
});
|
|
@@ -38,10 +43,42 @@ describe("resolveCanonicalUrl", () => {
|
|
|
38
43
|
siteBaseUrl: "https://example.com/",
|
|
39
44
|
pagePath: "/about",
|
|
40
45
|
override: null,
|
|
46
|
+
pageUrlStyle: "directory",
|
|
41
47
|
}),
|
|
42
48
|
).toBe("https://example.com/about/");
|
|
43
49
|
});
|
|
44
50
|
|
|
51
|
+
describe("#590 — composed host (host-strategy locales)", () => {
|
|
52
|
+
for (const [style, expected] of [
|
|
53
|
+
["directory", "https://de.example.com/preise/"],
|
|
54
|
+
["no-extension", "https://de.example.com/preise"],
|
|
55
|
+
] as const) {
|
|
56
|
+
it(`swaps the host, keeps the base scheme and the ${style} slash rule`, () => {
|
|
57
|
+
expect(
|
|
58
|
+
resolveCanonicalUrl({
|
|
59
|
+
siteBaseUrl: "https://example.com/",
|
|
60
|
+
pagePath: "/preise",
|
|
61
|
+
override: null,
|
|
62
|
+
pageUrlStyle: style,
|
|
63
|
+
host: "de.example.com",
|
|
64
|
+
}),
|
|
65
|
+
).toBe(expected);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it("a null host keeps the site base host", () => {
|
|
70
|
+
expect(
|
|
71
|
+
resolveCanonicalUrl({
|
|
72
|
+
siteBaseUrl: "https://example.com",
|
|
73
|
+
pagePath: "/",
|
|
74
|
+
override: null,
|
|
75
|
+
pageUrlStyle: "no-extension",
|
|
76
|
+
host: null,
|
|
77
|
+
}),
|
|
78
|
+
).toBe("https://example.com/");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
45
82
|
describe("v0.2.85 — pageUrlStyle='no-extension'", () => {
|
|
46
83
|
it("omits the trailing slash for non-home pages", () => {
|
|
47
84
|
expect(
|
|
@@ -65,12 +102,13 @@ describe("resolveCanonicalUrl", () => {
|
|
|
65
102
|
).toBe("https://example.com/");
|
|
66
103
|
});
|
|
67
104
|
|
|
68
|
-
it("
|
|
105
|
+
it("'directory' keeps the trailing slash for non-home pages", () => {
|
|
69
106
|
expect(
|
|
70
107
|
resolveCanonicalUrl({
|
|
71
108
|
siteBaseUrl: "https://example.com",
|
|
72
109
|
pagePath: "/about",
|
|
73
110
|
override: null,
|
|
111
|
+
pageUrlStyle: "directory",
|
|
74
112
|
}),
|
|
75
113
|
).toBe("https://example.com/about/");
|
|
76
114
|
});
|
|
@@ -200,3 +238,101 @@ describe("schemas", () => {
|
|
|
200
238
|
expect(r.success).toBe(false);
|
|
201
239
|
});
|
|
202
240
|
});
|
|
241
|
+
|
|
242
|
+
describe("siteSeoProposalInputSchema", () => {
|
|
243
|
+
it("accepts any single field", () => {
|
|
244
|
+
expect(siteSeoProposalInputSchema.safeParse({ siteBaseUrl: "https://a.example" }).success).toBe(
|
|
245
|
+
true,
|
|
246
|
+
);
|
|
247
|
+
expect(siteSeoProposalInputSchema.safeParse({ sitemapEnabled: false }).success).toBe(true);
|
|
248
|
+
expect(
|
|
249
|
+
siteSeoProposalInputSchema.safeParse({ organizationJson: { name: "Acme" } }).success,
|
|
250
|
+
).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("rejects an empty proposal with a message naming the fields", () => {
|
|
254
|
+
const r = siteSeoProposalInputSchema.safeParse({});
|
|
255
|
+
expect(r.success).toBe(false);
|
|
256
|
+
expect(JSON.stringify(r.error?.issues)).toContain("siteBaseUrl");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("rejects unknown keys at the top level and inside organizationJson", () => {
|
|
260
|
+
expect(siteSeoProposalInputSchema.safeParse({ sitemapEnabled: true, x: 1 }).success).toBe(
|
|
261
|
+
false,
|
|
262
|
+
);
|
|
263
|
+
expect(
|
|
264
|
+
siteSeoProposalInputSchema.safeParse({ organizationJson: { name: "A", script: "<x>" } })
|
|
265
|
+
.success,
|
|
266
|
+
).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("checkPublicSiteBaseUrl", () => {
|
|
271
|
+
it("normalises a valid https URL to its origin", () => {
|
|
272
|
+
expect(checkPublicSiteBaseUrl("https://www.Example.com/", "gcp")).toEqual({
|
|
273
|
+
ok: true,
|
|
274
|
+
url: "https://www.example.com",
|
|
275
|
+
});
|
|
276
|
+
expect(checkPublicSiteBaseUrl(" https://example.com:8443 ", undefined)).toEqual({
|
|
277
|
+
ok: true,
|
|
278
|
+
url: "https://example.com:8443",
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("rejects a path, query or fragment and names the origin to use instead", () => {
|
|
283
|
+
for (const raw of [
|
|
284
|
+
"https://example.com/blog",
|
|
285
|
+
"https://example.com/?a=1",
|
|
286
|
+
"https://example.com/#top",
|
|
287
|
+
]) {
|
|
288
|
+
const r = checkPublicSiteBaseUrl(raw, "aws");
|
|
289
|
+
expect(r.ok).toBe(false);
|
|
290
|
+
if (!r.ok) expect(r.message).toContain("use https://example.com");
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("rejects non-URLs and credentials", () => {
|
|
295
|
+
expect(checkPublicSiteBaseUrl("example.com", "gcp").ok).toBe(false);
|
|
296
|
+
expect(checkPublicSiteBaseUrl("https://u:p@example.com", "gcp").ok).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("requires https for public hosts on every provider", () => {
|
|
300
|
+
expect(checkPublicSiteBaseUrl("http://example.com", "gcp").ok).toBe(false);
|
|
301
|
+
expect(checkPublicSiteBaseUrl("http://example.com", "self-hosted").ok).toBe(false);
|
|
302
|
+
expect(checkPublicSiteBaseUrl("ftp://example.com", undefined).ok).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("rejects loopback hosts on cloud providers", () => {
|
|
306
|
+
for (const provider of ["gcp", "gcp-firebase", "aws", "azure"]) {
|
|
307
|
+
for (const raw of [
|
|
308
|
+
"https://localhost",
|
|
309
|
+
"http://localhost:8082",
|
|
310
|
+
"https://127.0.0.1",
|
|
311
|
+
"https://[::1]",
|
|
312
|
+
"https://app.localhost",
|
|
313
|
+
]) {
|
|
314
|
+
const r = checkPublicSiteBaseUrl(raw, provider);
|
|
315
|
+
expect(r.ok).toBe(false);
|
|
316
|
+
if (!r.ok) expect(r.message).toContain("public domain");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("allows http://localhost on a self-hosted install (local dev)", () => {
|
|
322
|
+
expect(checkPublicSiteBaseUrl("http://localhost:8082", undefined)).toEqual({
|
|
323
|
+
ok: true,
|
|
324
|
+
url: "http://localhost:8082",
|
|
325
|
+
});
|
|
326
|
+
expect(checkPublicSiteBaseUrl("http://127.0.0.1:8082", "self-hosted").ok).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rejects wildcard bind addresses on every provider, self-hosted included", () => {
|
|
330
|
+
for (const provider of [undefined, "self-hosted", "gcp"]) {
|
|
331
|
+
for (const raw of ["http://0.0.0.0:8082", "https://0.0.0.0", "http://[::]:8082"]) {
|
|
332
|
+
const r = checkPublicSiteBaseUrl(raw, provider);
|
|
333
|
+
expect(r.ok).toBe(false);
|
|
334
|
+
if (!r.ok) expect(r.message).toContain("bind address");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
package/src/seo.ts
CHANGED
|
@@ -61,6 +61,16 @@ export const seoOptimizeInputSchema = z
|
|
|
61
61
|
.strict();
|
|
62
62
|
export type SeoOptimizeInput = z.infer<typeof seoOptimizeInputSchema>;
|
|
63
63
|
|
|
64
|
+
/** Organization JSON-LD fields — structured only, never raw markup (CLAUDE.md §2). */
|
|
65
|
+
const organizationJsonSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
name: z.string().max(256).optional(),
|
|
68
|
+
url: z.string().max(2048).optional(),
|
|
69
|
+
logo: z.string().max(2048).optional(),
|
|
70
|
+
sameAs: z.array(z.string().max(2048)).max(20).optional(),
|
|
71
|
+
})
|
|
72
|
+
.strict();
|
|
73
|
+
|
|
64
74
|
export const siteDefaultsSetSeoInputSchema = z
|
|
65
75
|
.object({
|
|
66
76
|
siteBaseUrl: z
|
|
@@ -69,19 +79,101 @@ export const siteDefaultsSetSeoInputSchema = z
|
|
|
69
79
|
.max(2048)
|
|
70
80
|
.url("siteBaseUrl must be an absolute URL (https://example.com)"),
|
|
71
81
|
sitemapEnabled: z.boolean(),
|
|
72
|
-
organizationJson:
|
|
73
|
-
.object({
|
|
74
|
-
name: z.string().max(256).optional(),
|
|
75
|
-
url: z.string().max(2048).optional(),
|
|
76
|
-
logo: z.string().max(2048).optional(),
|
|
77
|
-
sameAs: z.array(z.string().max(2048)).max(20).optional(),
|
|
78
|
-
})
|
|
79
|
-
.strict()
|
|
80
|
-
.default({}),
|
|
82
|
+
organizationJson: organizationJsonSchema.default({}),
|
|
81
83
|
})
|
|
82
84
|
.strict();
|
|
83
85
|
export type SiteDefaultsSetSeoInput = z.infer<typeof siteDefaultsSetSeoInputSchema>;
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Input of `site_defaults.propose_set_seo` — the AI's path to the site SEO
|
|
89
|
+
* settings (CLAUDE.md §11.A). Every field is optional so the AI changes only
|
|
90
|
+
* what the operator asked for; an omitted field keeps its stored value when
|
|
91
|
+
* the Owner approves. `organizationJson` replaces the whole object.
|
|
92
|
+
*/
|
|
93
|
+
export const siteSeoProposalInputSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
siteBaseUrl: z.string().min(1).max(2048).optional(),
|
|
96
|
+
sitemapEnabled: z.boolean().optional(),
|
|
97
|
+
organizationJson: organizationJsonSchema.optional(),
|
|
98
|
+
})
|
|
99
|
+
.strict()
|
|
100
|
+
.refine(
|
|
101
|
+
(v) =>
|
|
102
|
+
v.siteBaseUrl !== undefined ||
|
|
103
|
+
v.sitemapEnabled !== undefined ||
|
|
104
|
+
v.organizationJson !== undefined,
|
|
105
|
+
"pass at least one of `siteBaseUrl`, `sitemapEnabled`, `organizationJson`",
|
|
106
|
+
);
|
|
107
|
+
export type SiteSeoProposalInput = z.infer<typeof siteSeoProposalInputSchema>;
|
|
108
|
+
|
|
109
|
+
/** Result of {@link checkPublicSiteBaseUrl}. */
|
|
110
|
+
export type PublicSiteBaseUrlCheck = { ok: true; url: string } | { ok: false; message: string };
|
|
111
|
+
|
|
112
|
+
const LOOPBACK_HOST = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/i;
|
|
113
|
+
|
|
114
|
+
/** Wildcard bind addresses: a server listens on them, no browser can visit them. */
|
|
115
|
+
const WILDCARD_HOST = /^(0\.0\.0\.0|\[::\])$/;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate a public site base URL and normalise it to its origin
|
|
119
|
+
* (`https://example.com`, no trailing slash).
|
|
120
|
+
*
|
|
121
|
+
* Every canonical, og:url, JSON-LD url, hreflang target and sitemap entry is
|
|
122
|
+
* `<base><path>`, so the base must be exactly the origin visitors reach: no
|
|
123
|
+
* path, query, fragment or credentials. On a cloud install (`provider` is a
|
|
124
|
+
* `CAELO_PROVIDER` other than self-hosted) it must be https and not a
|
|
125
|
+
* loopback host — a localhost base there ships unreachable canonicals to
|
|
126
|
+
* production. A self-hosted install may use `http://localhost:<port>`, the
|
|
127
|
+
* documented local-dev value; any other host still needs https.
|
|
128
|
+
*
|
|
129
|
+
* @param provider - `CAELO_PROVIDER` of the install; `undefined`/`""`/`"self-hosted"` = self-hosted.
|
|
130
|
+
*/
|
|
131
|
+
export function checkPublicSiteBaseUrl(
|
|
132
|
+
raw: string,
|
|
133
|
+
provider: string | undefined,
|
|
134
|
+
): PublicSiteBaseUrlCheck {
|
|
135
|
+
let u: URL;
|
|
136
|
+
try {
|
|
137
|
+
u = new URL(raw.trim());
|
|
138
|
+
} catch {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
message: `"${raw}" is not an absolute URL — pass the public origin, e.g. https://www.example.com`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const origin = `${u.protocol}//${u.host}`;
|
|
145
|
+
if (u.username || u.password) {
|
|
146
|
+
return { ok: false, message: "the site URL must not contain credentials" };
|
|
147
|
+
}
|
|
148
|
+
if ((u.pathname !== "/" && u.pathname !== "") || u.search || u.hash) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
message: `the site URL must be the origin only, without path, query or fragment — use ${origin}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (WILDCARD_HOST.test(u.hostname)) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
message: `${u.hostname} is a bind address, not an address visitors can open — use the public domain (e.g. https://www.example.com) or, on a local self-hosted install, http://localhost:<port>`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const selfHosted = !provider || provider === "self-hosted";
|
|
161
|
+
const loopback = LOOPBACK_HOST.test(u.hostname);
|
|
162
|
+
if (loopback && !selfHosted) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
message: `${origin} is a local address; on this ${provider} install the site URL must be the public domain visitors use (e.g. https://www.example.com)`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (u.protocol !== "https:" && !(selfHosted && loopback && u.protocol === "http:")) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
message: `the site URL must use https (got ${u.protocol.replace(":", "")}) — use https://${u.host}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, url: origin };
|
|
175
|
+
}
|
|
176
|
+
|
|
85
177
|
export interface PageSeoRow {
|
|
86
178
|
pageId: string;
|
|
87
179
|
metaDescription: string;
|
|
@@ -111,12 +203,27 @@ export interface SiteSeoSettings {
|
|
|
111
203
|
}
|
|
112
204
|
|
|
113
205
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
206
|
+
* How a deploy target serves pages (`deploy_targets.page_url_style`):
|
|
207
|
+
* 'directory' emits `<path>/index.html` and serves `/<path>/`;
|
|
208
|
+
* 'no-extension' emits a bare `<path>` file and serves `/<path>`.
|
|
209
|
+
*/
|
|
210
|
+
export const PAGE_URL_STYLES = ["directory", "no-extension"] as const;
|
|
211
|
+
export type PageUrlStyle = (typeof PAGE_URL_STYLES)[number];
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* THE public page URL builder (#590). Every absolute page URL Caelo
|
|
215
|
+
* emits — canonical, og:url, JSON-LD url, sitemap `<loc>`,
|
|
216
|
+
* hreflang/x-default targets, language-switcher links — comes from
|
|
217
|
+
* here, so they are byte-identical by construction. Two builders
|
|
218
|
+
* drifting apart is what shipped `/en/about` as hreflang next to an
|
|
219
|
+
* `/en/about/` canonical.
|
|
220
|
+
*
|
|
221
|
+
* `pages_seo.canonical_url` (`override`) wins when set. Otherwise the
|
|
222
|
+
* URL is `<scheme>//<host><path>`: the path is the COMPOSED public path
|
|
223
|
+
* from `pages.current_path` (#390), its trailing slash follows the
|
|
224
|
+
* serving target's {@link PageUrlStyle}, the host is the URL composition
|
|
225
|
+
* point's `host` slot (a host-strategy locale) or else the site base
|
|
226
|
+
* URL's, and the scheme always comes from the base URL.
|
|
120
227
|
*/
|
|
121
228
|
export function resolveCanonicalUrl(args: {
|
|
122
229
|
siteBaseUrl: string;
|
|
@@ -124,19 +231,21 @@ export function resolveCanonicalUrl(args: {
|
|
|
124
231
|
* "/" for the site root. */
|
|
125
232
|
pagePath: string;
|
|
126
233
|
override: string | null;
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
*/
|
|
132
|
-
|
|
234
|
+
/** The serving deploy target's page emission style. Required — a
|
|
235
|
+
* defaulted style is how the preview drifted from the build. */
|
|
236
|
+
pageUrlStyle: PageUrlStyle;
|
|
237
|
+
/** Host from the URL composition point's `host` slot (e.g.
|
|
238
|
+
* `de.example.com`); null/absent → the site base URL's host. */
|
|
239
|
+
host?: string | null;
|
|
133
240
|
}): string {
|
|
134
241
|
if (args.override && args.override.length > 0) return args.override;
|
|
135
|
-
const
|
|
242
|
+
const siteBase = args.siteBaseUrl.endsWith("/")
|
|
243
|
+
? args.siteBaseUrl.slice(0, -1)
|
|
244
|
+
: args.siteBaseUrl;
|
|
245
|
+
const base = args.host ? `${new URL(siteBase).protocol}//${args.host}` : siteBase;
|
|
136
246
|
const trimmed = trimSlashes(args.pagePath);
|
|
137
247
|
if (trimmed.length === 0) return `${base}/`;
|
|
138
|
-
|
|
139
|
-
return style === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
|
|
248
|
+
return args.pageUrlStyle === "no-extension" ? `${base}/${trimmed}` : `${base}/${trimmed}/`;
|
|
140
249
|
}
|
|
141
250
|
|
|
142
251
|
export interface SeoMetaInput {
|