@bettercms-ai/sdk 1.32.0 → 1.33.0
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/config.cjs +55 -0
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.cts +42 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +29 -0
- package/dist/config.js.map +1 -0
- package/dist/draft.cjs +86 -0
- package/dist/draft.cjs.map +1 -0
- package/dist/draft.d.cts +24 -0
- package/dist/draft.d.ts +24 -0
- package/dist/draft.js +56 -0
- package/dist/draft.js.map +1 -0
- package/dist/index.d.cts +3 -98
- package/dist/index.d.ts +3 -98
- package/dist/seo.cjs +153 -0
- package/dist/seo.cjs.map +1 -0
- package/dist/seo.d.cts +99 -0
- package/dist/seo.d.ts +99 -0
- package/dist/seo.js +127 -0
- package/dist/seo.js.map +1 -0
- package/package.json +16 -1
package/dist/config.cjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/config.ts
|
|
21
|
+
var config_exports = {};
|
|
22
|
+
__export(config_exports, {
|
|
23
|
+
DEFAULT_MEDIA_URL: () => DEFAULT_MEDIA_URL,
|
|
24
|
+
resolveBcmsConfig: () => resolveBcmsConfig
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(config_exports);
|
|
27
|
+
var DEFAULT_MEDIA_URL = "https://cdn.bettercms.ai";
|
|
28
|
+
function resolveBcmsConfig(input) {
|
|
29
|
+
const env = input.env ?? {};
|
|
30
|
+
const fromEnv = (name) => {
|
|
31
|
+
for (const p of input.prefixes) {
|
|
32
|
+
const v = env[`${p}BCMS_${name}`];
|
|
33
|
+
if (v) return v;
|
|
34
|
+
}
|
|
35
|
+
return void 0;
|
|
36
|
+
};
|
|
37
|
+
let snap;
|
|
38
|
+
const snapshot = () => snap ??= input.snapshot?.() ?? {};
|
|
39
|
+
const o = input.options ?? {};
|
|
40
|
+
const config = {
|
|
41
|
+
apiUrl: (o.apiUrl ?? fromEnv("API_URL") ?? snapshot().apiUrl ?? "").replace(/\/+$/, ""),
|
|
42
|
+
workspace: o.workspace ?? fromEnv("WORKSPACE") ?? snapshot().workspace ?? "",
|
|
43
|
+
projectId: o.projectId ?? fromEnv("PROJECT_ID") ?? snapshot().projectId,
|
|
44
|
+
mediaUrl: (o.mediaUrl ?? fromEnv("MEDIA_URL") ?? DEFAULT_MEDIA_URL).replace(/\/+$/, ""),
|
|
45
|
+
studioUrl: o.studioUrl ?? fromEnv("STUDIO_URL")
|
|
46
|
+
};
|
|
47
|
+
const missing = ["apiUrl", "workspace"].filter((k) => !config[k]);
|
|
48
|
+
return { config, missing };
|
|
49
|
+
}
|
|
50
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
51
|
+
0 && (module.exports = {
|
|
52
|
+
DEFAULT_MEDIA_URL,
|
|
53
|
+
resolveBcmsConfig
|
|
54
|
+
});
|
|
55
|
+
//# sourceMappingURL=config.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts"],"sourcesContent":["/**\n * Where a site's BetterCMS settings come from, the same way on every framework: explicit options,\n * then `<PREFIX>BCMS_*` env (each framework exposes env to the browser under its own prefix:\n * `PUBLIC_` for Astro and SvelteKit, `NEXT_PUBLIC_` for Next, `VITE_` for Vite React,\n * `NUXT_PUBLIC_` for Nuxt), then the build snapshot `bettercms pull-content` writes.\n *\n * Pure: the adapter passes its env and reads the snapshot (this module touches no filesystem).\n */\n\nexport const DEFAULT_MEDIA_URL = \"https://cdn.bettercms.ai\";\n\nexport interface BcmsSiteOptions {\n apiUrl?: string;\n workspace?: string;\n projectId?: string;\n mediaUrl?: string;\n studioUrl?: string;\n}\n\nexport interface BcmsSiteConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n mediaUrl: string;\n studioUrl?: string;\n}\n\nexport interface ResolveBcmsConfigInput {\n options?: BcmsSiteOptions;\n env?: Record<string, string | undefined>;\n /** Env prefixes, first match wins: `[\"NEXT_PUBLIC_\", \"\"]` reads NEXT_PUBLIC_BCMS_API_URL, then BCMS_API_URL. */\n prefixes: string[];\n /** The build snapshot's identity, read only when options and env both miss. */\n snapshot?: () => { apiUrl?: string; workspace?: string; projectId?: string };\n}\n\n/** The resolved settings, and which essentials are missing (the adapter words the error). */\nexport function resolveBcmsConfig(input: ResolveBcmsConfigInput): { config: BcmsSiteConfig; missing: (\"apiUrl\" | \"workspace\")[] } {\n const env = input.env ?? {};\n const fromEnv = (name: string) => {\n for (const p of input.prefixes) {\n const v = env[`${p}BCMS_${name}`];\n if (v) return v;\n }\n return undefined;\n };\n let snap: ReturnType<NonNullable<ResolveBcmsConfigInput[\"snapshot\"]>> | undefined;\n const snapshot = () => (snap ??= input.snapshot?.() ?? {});\n const o = input.options ?? {};\n const config: BcmsSiteConfig = {\n apiUrl: (o.apiUrl ?? fromEnv(\"API_URL\") ?? snapshot().apiUrl ?? \"\").replace(/\\/+$/, \"\"),\n workspace: o.workspace ?? fromEnv(\"WORKSPACE\") ?? snapshot().workspace ?? \"\",\n projectId: o.projectId ?? fromEnv(\"PROJECT_ID\") ?? snapshot().projectId,\n mediaUrl: (o.mediaUrl ?? fromEnv(\"MEDIA_URL\") ?? DEFAULT_MEDIA_URL).replace(/\\/+$/, \"\"),\n studioUrl: o.studioUrl ?? fromEnv(\"STUDIO_URL\"),\n };\n const missing = ([\"apiUrl\", \"workspace\"] as const).filter((k) => !config[k]);\n return { config, missing };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASO,IAAM,oBAAoB;AA4B1B,SAAS,kBAAkB,OAAgG;AAChI,QAAM,MAAM,MAAM,OAAO,CAAC;AAC1B,QAAM,UAAU,CAAC,SAAiB;AAChC,eAAW,KAAK,MAAM,UAAU;AAC9B,YAAM,IAAI,IAAI,GAAG,CAAC,QAAQ,IAAI,EAAE;AAChC,UAAI,EAAG,QAAO;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AACA,MAAI;AACJ,QAAM,WAAW,MAAO,SAAS,MAAM,WAAW,KAAK,CAAC;AACxD,QAAM,IAAI,MAAM,WAAW,CAAC;AAC5B,QAAM,SAAyB;AAAA,IAC7B,SAAS,EAAE,UAAU,QAAQ,SAAS,KAAK,SAAS,EAAE,UAAU,IAAI,QAAQ,QAAQ,EAAE;AAAA,IACtF,WAAW,EAAE,aAAa,QAAQ,WAAW,KAAK,SAAS,EAAE,aAAa;AAAA,IAC1E,WAAW,EAAE,aAAa,QAAQ,YAAY,KAAK,SAAS,EAAE;AAAA,IAC9D,WAAW,EAAE,YAAY,QAAQ,WAAW,KAAK,mBAAmB,QAAQ,QAAQ,EAAE;AAAA,IACtF,WAAW,EAAE,aAAa,QAAQ,YAAY;AAAA,EAChD;AACA,QAAM,UAAW,CAAC,UAAU,WAAW,EAAY,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AAC3E,SAAO,EAAE,QAAQ,QAAQ;AAC3B;","names":[]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a site's BetterCMS settings come from, the same way on every framework: explicit options,
|
|
3
|
+
* then `<PREFIX>BCMS_*` env (each framework exposes env to the browser under its own prefix:
|
|
4
|
+
* `PUBLIC_` for Astro and SvelteKit, `NEXT_PUBLIC_` for Next, `VITE_` for Vite React,
|
|
5
|
+
* `NUXT_PUBLIC_` for Nuxt), then the build snapshot `bettercms pull-content` writes.
|
|
6
|
+
*
|
|
7
|
+
* Pure: the adapter passes its env and reads the snapshot (this module touches no filesystem).
|
|
8
|
+
*/
|
|
9
|
+
declare const DEFAULT_MEDIA_URL = "https://cdn.bettercms.ai";
|
|
10
|
+
interface BcmsSiteOptions {
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
workspace?: string;
|
|
13
|
+
projectId?: string;
|
|
14
|
+
mediaUrl?: string;
|
|
15
|
+
studioUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
interface BcmsSiteConfig {
|
|
18
|
+
apiUrl: string;
|
|
19
|
+
workspace: string;
|
|
20
|
+
projectId?: string;
|
|
21
|
+
mediaUrl: string;
|
|
22
|
+
studioUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
interface ResolveBcmsConfigInput {
|
|
25
|
+
options?: BcmsSiteOptions;
|
|
26
|
+
env?: Record<string, string | undefined>;
|
|
27
|
+
/** Env prefixes, first match wins: `["NEXT_PUBLIC_", ""]` reads NEXT_PUBLIC_BCMS_API_URL, then BCMS_API_URL. */
|
|
28
|
+
prefixes: string[];
|
|
29
|
+
/** The build snapshot's identity, read only when options and env both miss. */
|
|
30
|
+
snapshot?: () => {
|
|
31
|
+
apiUrl?: string;
|
|
32
|
+
workspace?: string;
|
|
33
|
+
projectId?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The resolved settings, and which essentials are missing (the adapter words the error). */
|
|
37
|
+
declare function resolveBcmsConfig(input: ResolveBcmsConfigInput): {
|
|
38
|
+
config: BcmsSiteConfig;
|
|
39
|
+
missing: ("apiUrl" | "workspace")[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { type BcmsSiteConfig, type BcmsSiteOptions, DEFAULT_MEDIA_URL, type ResolveBcmsConfigInput, resolveBcmsConfig };
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a site's BetterCMS settings come from, the same way on every framework: explicit options,
|
|
3
|
+
* then `<PREFIX>BCMS_*` env (each framework exposes env to the browser under its own prefix:
|
|
4
|
+
* `PUBLIC_` for Astro and SvelteKit, `NEXT_PUBLIC_` for Next, `VITE_` for Vite React,
|
|
5
|
+
* `NUXT_PUBLIC_` for Nuxt), then the build snapshot `bettercms pull-content` writes.
|
|
6
|
+
*
|
|
7
|
+
* Pure: the adapter passes its env and reads the snapshot (this module touches no filesystem).
|
|
8
|
+
*/
|
|
9
|
+
declare const DEFAULT_MEDIA_URL = "https://cdn.bettercms.ai";
|
|
10
|
+
interface BcmsSiteOptions {
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
workspace?: string;
|
|
13
|
+
projectId?: string;
|
|
14
|
+
mediaUrl?: string;
|
|
15
|
+
studioUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
interface BcmsSiteConfig {
|
|
18
|
+
apiUrl: string;
|
|
19
|
+
workspace: string;
|
|
20
|
+
projectId?: string;
|
|
21
|
+
mediaUrl: string;
|
|
22
|
+
studioUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
interface ResolveBcmsConfigInput {
|
|
25
|
+
options?: BcmsSiteOptions;
|
|
26
|
+
env?: Record<string, string | undefined>;
|
|
27
|
+
/** Env prefixes, first match wins: `["NEXT_PUBLIC_", ""]` reads NEXT_PUBLIC_BCMS_API_URL, then BCMS_API_URL. */
|
|
28
|
+
prefixes: string[];
|
|
29
|
+
/** The build snapshot's identity, read only when options and env both miss. */
|
|
30
|
+
snapshot?: () => {
|
|
31
|
+
apiUrl?: string;
|
|
32
|
+
workspace?: string;
|
|
33
|
+
projectId?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The resolved settings, and which essentials are missing (the adapter words the error). */
|
|
37
|
+
declare function resolveBcmsConfig(input: ResolveBcmsConfigInput): {
|
|
38
|
+
config: BcmsSiteConfig;
|
|
39
|
+
missing: ("apiUrl" | "workspace")[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { type BcmsSiteConfig, type BcmsSiteOptions, DEFAULT_MEDIA_URL, type ResolveBcmsConfigInput, resolveBcmsConfig };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
var DEFAULT_MEDIA_URL = "https://cdn.bettercms.ai";
|
|
3
|
+
function resolveBcmsConfig(input) {
|
|
4
|
+
const env = input.env ?? {};
|
|
5
|
+
const fromEnv = (name) => {
|
|
6
|
+
for (const p of input.prefixes) {
|
|
7
|
+
const v = env[`${p}BCMS_${name}`];
|
|
8
|
+
if (v) return v;
|
|
9
|
+
}
|
|
10
|
+
return void 0;
|
|
11
|
+
};
|
|
12
|
+
let snap;
|
|
13
|
+
const snapshot = () => snap ??= input.snapshot?.() ?? {};
|
|
14
|
+
const o = input.options ?? {};
|
|
15
|
+
const config = {
|
|
16
|
+
apiUrl: (o.apiUrl ?? fromEnv("API_URL") ?? snapshot().apiUrl ?? "").replace(/\/+$/, ""),
|
|
17
|
+
workspace: o.workspace ?? fromEnv("WORKSPACE") ?? snapshot().workspace ?? "",
|
|
18
|
+
projectId: o.projectId ?? fromEnv("PROJECT_ID") ?? snapshot().projectId,
|
|
19
|
+
mediaUrl: (o.mediaUrl ?? fromEnv("MEDIA_URL") ?? DEFAULT_MEDIA_URL).replace(/\/+$/, ""),
|
|
20
|
+
studioUrl: o.studioUrl ?? fromEnv("STUDIO_URL")
|
|
21
|
+
};
|
|
22
|
+
const missing = ["apiUrl", "workspace"].filter((k) => !config[k]);
|
|
23
|
+
return { config, missing };
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
DEFAULT_MEDIA_URL,
|
|
27
|
+
resolveBcmsConfig
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts"],"sourcesContent":["/**\n * Where a site's BetterCMS settings come from, the same way on every framework: explicit options,\n * then `<PREFIX>BCMS_*` env (each framework exposes env to the browser under its own prefix:\n * `PUBLIC_` for Astro and SvelteKit, `NEXT_PUBLIC_` for Next, `VITE_` for Vite React,\n * `NUXT_PUBLIC_` for Nuxt), then the build snapshot `bettercms pull-content` writes.\n *\n * Pure: the adapter passes its env and reads the snapshot (this module touches no filesystem).\n */\n\nexport const DEFAULT_MEDIA_URL = \"https://cdn.bettercms.ai\";\n\nexport interface BcmsSiteOptions {\n apiUrl?: string;\n workspace?: string;\n projectId?: string;\n mediaUrl?: string;\n studioUrl?: string;\n}\n\nexport interface BcmsSiteConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n mediaUrl: string;\n studioUrl?: string;\n}\n\nexport interface ResolveBcmsConfigInput {\n options?: BcmsSiteOptions;\n env?: Record<string, string | undefined>;\n /** Env prefixes, first match wins: `[\"NEXT_PUBLIC_\", \"\"]` reads NEXT_PUBLIC_BCMS_API_URL, then BCMS_API_URL. */\n prefixes: string[];\n /** The build snapshot's identity, read only when options and env both miss. */\n snapshot?: () => { apiUrl?: string; workspace?: string; projectId?: string };\n}\n\n/** The resolved settings, and which essentials are missing (the adapter words the error). */\nexport function resolveBcmsConfig(input: ResolveBcmsConfigInput): { config: BcmsSiteConfig; missing: (\"apiUrl\" | \"workspace\")[] } {\n const env = input.env ?? {};\n const fromEnv = (name: string) => {\n for (const p of input.prefixes) {\n const v = env[`${p}BCMS_${name}`];\n if (v) return v;\n }\n return undefined;\n };\n let snap: ReturnType<NonNullable<ResolveBcmsConfigInput[\"snapshot\"]>> | undefined;\n const snapshot = () => (snap ??= input.snapshot?.() ?? {});\n const o = input.options ?? {};\n const config: BcmsSiteConfig = {\n apiUrl: (o.apiUrl ?? fromEnv(\"API_URL\") ?? snapshot().apiUrl ?? \"\").replace(/\\/+$/, \"\"),\n workspace: o.workspace ?? fromEnv(\"WORKSPACE\") ?? snapshot().workspace ?? \"\",\n projectId: o.projectId ?? fromEnv(\"PROJECT_ID\") ?? snapshot().projectId,\n mediaUrl: (o.mediaUrl ?? fromEnv(\"MEDIA_URL\") ?? DEFAULT_MEDIA_URL).replace(/\\/+$/, \"\"),\n studioUrl: o.studioUrl ?? fromEnv(\"STUDIO_URL\"),\n };\n const missing = ([\"apiUrl\", \"workspace\"] as const).filter((k) => !config[k]);\n return { config, missing };\n}\n"],"mappings":";AASO,IAAM,oBAAoB;AA4B1B,SAAS,kBAAkB,OAAgG;AAChI,QAAM,MAAM,MAAM,OAAO,CAAC;AAC1B,QAAM,UAAU,CAAC,SAAiB;AAChC,eAAW,KAAK,MAAM,UAAU;AAC9B,YAAM,IAAI,IAAI,GAAG,CAAC,QAAQ,IAAI,EAAE;AAChC,UAAI,EAAG,QAAO;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AACA,MAAI;AACJ,QAAM,WAAW,MAAO,SAAS,MAAM,WAAW,KAAK,CAAC;AACxD,QAAM,IAAI,MAAM,WAAW,CAAC;AAC5B,QAAM,SAAyB;AAAA,IAC7B,SAAS,EAAE,UAAU,QAAQ,SAAS,KAAK,SAAS,EAAE,UAAU,IAAI,QAAQ,QAAQ,EAAE;AAAA,IACtF,WAAW,EAAE,aAAa,QAAQ,WAAW,KAAK,SAAS,EAAE,aAAa;AAAA,IAC1E,WAAW,EAAE,aAAa,QAAQ,YAAY,KAAK,SAAS,EAAE;AAAA,IAC9D,WAAW,EAAE,YAAY,QAAQ,WAAW,KAAK,mBAAmB,QAAQ,QAAQ,EAAE;AAAA,IACtF,WAAW,EAAE,aAAa,QAAQ,YAAY;AAAA,EAChD;AACA,QAAM,UAAW,CAAC,UAAU,WAAW,EAAY,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AAC3E,SAAO,EAAE,QAAQ,QAAQ;AAC3B;","names":[]}
|
package/dist/draft.cjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/draft.ts
|
|
21
|
+
var draft_exports = {};
|
|
22
|
+
__export(draft_exports, {
|
|
23
|
+
DRAFT_COOKIE: () => DRAFT_COOKIE,
|
|
24
|
+
decodeTokenSlug: () => decodeTokenSlug,
|
|
25
|
+
previewTokenValid: () => previewTokenValid,
|
|
26
|
+
safeRedirectPath: () => safeRedirectPath,
|
|
27
|
+
signValue: () => signValue,
|
|
28
|
+
verifyValue: () => verifyValue
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(draft_exports);
|
|
31
|
+
var DRAFT_COOKIE = "bcms-draft";
|
|
32
|
+
var enc = new TextEncoder();
|
|
33
|
+
function base64url(bytes) {
|
|
34
|
+
const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
|
35
|
+
return b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
36
|
+
}
|
|
37
|
+
async function hmac(value, secret) {
|
|
38
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
39
|
+
return base64url(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
|
|
40
|
+
}
|
|
41
|
+
async function signValue(value, secret) {
|
|
42
|
+
return `${value}.${await hmac(value, secret)}`;
|
|
43
|
+
}
|
|
44
|
+
async function verifyValue(signed, secret) {
|
|
45
|
+
const idx = signed.lastIndexOf(".");
|
|
46
|
+
if (idx < 0) return null;
|
|
47
|
+
const value = signed.slice(0, idx);
|
|
48
|
+
const sig = signed.slice(idx + 1);
|
|
49
|
+
const expected = await hmac(value, secret);
|
|
50
|
+
if (sig.length !== expected.length) return null;
|
|
51
|
+
let diff = 0;
|
|
52
|
+
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
53
|
+
return diff === 0 ? value : null;
|
|
54
|
+
}
|
|
55
|
+
function safeRedirectPath(to) {
|
|
56
|
+
return to.startsWith("/") && !to.startsWith("//") && !to.startsWith("/\\") ? to : "/";
|
|
57
|
+
}
|
|
58
|
+
function decodeTokenSlug(token) {
|
|
59
|
+
try {
|
|
60
|
+
const part = token.split(".")[1];
|
|
61
|
+
if (!part) return null;
|
|
62
|
+
const bytes = Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
63
|
+
const json = JSON.parse(new TextDecoder().decode(bytes));
|
|
64
|
+
return json.entrySlug ?? null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function previewTokenValid(apiUrl, token, fetchImpl = fetch) {
|
|
70
|
+
const slug = decodeTokenSlug(token);
|
|
71
|
+
if (!slug) return false;
|
|
72
|
+
const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);
|
|
73
|
+
await res.body?.cancel().catch(() => {
|
|
74
|
+
});
|
|
75
|
+
return res.ok;
|
|
76
|
+
}
|
|
77
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
78
|
+
0 && (module.exports = {
|
|
79
|
+
DRAFT_COOKIE,
|
|
80
|
+
decodeTokenSlug,
|
|
81
|
+
previewTokenValid,
|
|
82
|
+
safeRedirectPath,
|
|
83
|
+
signValue,
|
|
84
|
+
verifyValue
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=draft.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/draft.ts"],"sourcesContent":["/**\n * Draft mode, shared by every framework adapter: the signed cookie that says \"this visitor is\n * previewing\", the preview token's slug, and the redirect a draft route may follow.\n *\n * Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter\n * mounts its own routes around these; the rules live here once.\n */\n\n/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** The original value of a signed one, or null when it was tampered with. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths: blocks \"//evil.com\", \"https://evil.com\" and \"/\\\\evil.com\". */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") && !to.startsWith(\"/\\\\\") ? to : \"/\";\n}\n\n/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n const json = JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Ask the backend whether a preview token is good: the backend is the signature authority, so a\n * draft route enables draft mode only on its yes.\n */\nexport async function previewTokenValid(apiUrl: string, token: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {\n const slug = decodeTokenSlug(token);\n if (!slug) return false;\n const res = await fetchImpl(`${apiUrl.replace(/\\/+$/, \"\")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);\n await res.body?.cancel().catch(() => {});\n return res.ok;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASO,IAAM,eAAe;AAE5B,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,IAAI,OAAO,MAAM,GAAG,EAAE,MAAM,QAAQ,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;AACvH,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAGA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AACzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;AAGO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,KAAK,CAAC,GAAG,WAAW,KAAK,IAAI,KAAK;AACpF;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAElB,UAAM,QAAQ,WAAW,KAAK,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AACtG,UAAM,OAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AACvD,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,eAAsB,kBAAkB,QAAgB,OAAe,YAA0B,OAAyB;AACxH,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,MAAM,MAAM,UAAU,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC,EAAE;AACzI,QAAM,IAAI,MAAM,OAAO,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AACvC,SAAO,IAAI;AACb;","names":[]}
|
package/dist/draft.d.cts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft mode, shared by every framework adapter: the signed cookie that says "this visitor is
|
|
3
|
+
* previewing", the preview token's slug, and the redirect a draft route may follow.
|
|
4
|
+
*
|
|
5
|
+
* Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter
|
|
6
|
+
* mounts its own routes around these; the rules live here once.
|
|
7
|
+
*/
|
|
8
|
+
/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */
|
|
9
|
+
declare const DRAFT_COOKIE = "bcms-draft";
|
|
10
|
+
/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */
|
|
11
|
+
declare function signValue(value: string, secret: string): Promise<string>;
|
|
12
|
+
/** The original value of a signed one, or null when it was tampered with. */
|
|
13
|
+
declare function verifyValue(signed: string, secret: string): Promise<string | null>;
|
|
14
|
+
/** Only same-origin relative paths: blocks "//evil.com", "https://evil.com" and "/\\evil.com". */
|
|
15
|
+
declare function safeRedirectPath(to: string): string;
|
|
16
|
+
/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */
|
|
17
|
+
declare function decodeTokenSlug(token: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Ask the backend whether a preview token is good: the backend is the signature authority, so a
|
|
20
|
+
* draft route enables draft mode only on its yes.
|
|
21
|
+
*/
|
|
22
|
+
declare function previewTokenValid(apiUrl: string, token: string, fetchImpl?: typeof fetch): Promise<boolean>;
|
|
23
|
+
|
|
24
|
+
export { DRAFT_COOKIE, decodeTokenSlug, previewTokenValid, safeRedirectPath, signValue, verifyValue };
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft mode, shared by every framework adapter: the signed cookie that says "this visitor is
|
|
3
|
+
* previewing", the preview token's slug, and the redirect a draft route may follow.
|
|
4
|
+
*
|
|
5
|
+
* Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter
|
|
6
|
+
* mounts its own routes around these; the rules live here once.
|
|
7
|
+
*/
|
|
8
|
+
/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */
|
|
9
|
+
declare const DRAFT_COOKIE = "bcms-draft";
|
|
10
|
+
/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */
|
|
11
|
+
declare function signValue(value: string, secret: string): Promise<string>;
|
|
12
|
+
/** The original value of a signed one, or null when it was tampered with. */
|
|
13
|
+
declare function verifyValue(signed: string, secret: string): Promise<string | null>;
|
|
14
|
+
/** Only same-origin relative paths: blocks "//evil.com", "https://evil.com" and "/\\evil.com". */
|
|
15
|
+
declare function safeRedirectPath(to: string): string;
|
|
16
|
+
/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */
|
|
17
|
+
declare function decodeTokenSlug(token: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Ask the backend whether a preview token is good: the backend is the signature authority, so a
|
|
20
|
+
* draft route enables draft mode only on its yes.
|
|
21
|
+
*/
|
|
22
|
+
declare function previewTokenValid(apiUrl: string, token: string, fetchImpl?: typeof fetch): Promise<boolean>;
|
|
23
|
+
|
|
24
|
+
export { DRAFT_COOKIE, decodeTokenSlug, previewTokenValid, safeRedirectPath, signValue, verifyValue };
|
package/dist/draft.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/draft.ts
|
|
2
|
+
var DRAFT_COOKIE = "bcms-draft";
|
|
3
|
+
var enc = new TextEncoder();
|
|
4
|
+
function base64url(bytes) {
|
|
5
|
+
const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
|
6
|
+
return b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7
|
+
}
|
|
8
|
+
async function hmac(value, secret) {
|
|
9
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
10
|
+
return base64url(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
|
|
11
|
+
}
|
|
12
|
+
async function signValue(value, secret) {
|
|
13
|
+
return `${value}.${await hmac(value, secret)}`;
|
|
14
|
+
}
|
|
15
|
+
async function verifyValue(signed, secret) {
|
|
16
|
+
const idx = signed.lastIndexOf(".");
|
|
17
|
+
if (idx < 0) return null;
|
|
18
|
+
const value = signed.slice(0, idx);
|
|
19
|
+
const sig = signed.slice(idx + 1);
|
|
20
|
+
const expected = await hmac(value, secret);
|
|
21
|
+
if (sig.length !== expected.length) return null;
|
|
22
|
+
let diff = 0;
|
|
23
|
+
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
24
|
+
return diff === 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
function safeRedirectPath(to) {
|
|
27
|
+
return to.startsWith("/") && !to.startsWith("//") && !to.startsWith("/\\") ? to : "/";
|
|
28
|
+
}
|
|
29
|
+
function decodeTokenSlug(token) {
|
|
30
|
+
try {
|
|
31
|
+
const part = token.split(".")[1];
|
|
32
|
+
if (!part) return null;
|
|
33
|
+
const bytes = Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
34
|
+
const json = JSON.parse(new TextDecoder().decode(bytes));
|
|
35
|
+
return json.entrySlug ?? null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function previewTokenValid(apiUrl, token, fetchImpl = fetch) {
|
|
41
|
+
const slug = decodeTokenSlug(token);
|
|
42
|
+
if (!slug) return false;
|
|
43
|
+
const res = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);
|
|
44
|
+
await res.body?.cancel().catch(() => {
|
|
45
|
+
});
|
|
46
|
+
return res.ok;
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
DRAFT_COOKIE,
|
|
50
|
+
decodeTokenSlug,
|
|
51
|
+
previewTokenValid,
|
|
52
|
+
safeRedirectPath,
|
|
53
|
+
signValue,
|
|
54
|
+
verifyValue
|
|
55
|
+
};
|
|
56
|
+
//# sourceMappingURL=draft.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/draft.ts"],"sourcesContent":["/**\n * Draft mode, shared by every framework adapter: the signed cookie that says \"this visitor is\n * previewing\", the preview token's slug, and the redirect a draft route may follow.\n *\n * Web Crypto and `atob` only, so it runs in Node, edge runtimes and workers alike. Each adapter\n * mounts its own routes around these; the rules live here once.\n */\n\n/** The draft cookie every adapter sets: one name, so an editor link works on any framework. */\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage (HMAC-SHA256, base64url). */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** The original value of a signed one, or null when it was tampered with. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths: blocks \"//evil.com\", \"https://evil.com\" and \"/\\\\evil.com\". */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") && !to.startsWith(\"/\\\\\") ? to : \"/\";\n}\n\n/** A preview token's `entrySlug`, read from its payload UNVERIFIED (the backend verifies it). */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n // atob gives bytes as Latin-1: decoded as UTF-8, a slug like `über-uns` survives.\n const bytes = Uint8Array.from(atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")), (c) => c.charCodeAt(0));\n const json = JSON.parse(new TextDecoder().decode(bytes)) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * Ask the backend whether a preview token is good: the backend is the signature authority, so a\n * draft route enables draft mode only on its yes.\n */\nexport async function previewTokenValid(apiUrl: string, token: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {\n const slug = decodeTokenSlug(token);\n if (!slug) return false;\n const res = await fetchImpl(`${apiUrl.replace(/\\/+$/, \"\")}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`);\n await res.body?.cancel().catch(() => {});\n return res.ok;\n}\n"],"mappings":";AASO,IAAM,eAAe;AAE5B,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,IAAI,OAAO,MAAM,GAAG,EAAE,MAAM,QAAQ,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;AACvH,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAGA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AACzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;AAGO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,KAAK,CAAC,GAAG,WAAW,KAAK,IAAI,KAAK;AACpF;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAElB,UAAM,QAAQ,WAAW,KAAK,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AACtG,UAAM,OAAO,KAAK,MAAM,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AACvD,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,eAAsB,kBAAkB,QAAgB,OAAe,YAA0B,OAAyB;AACxH,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,MAAM,MAAM,UAAU,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC,EAAE;AACzI,QAAM,IAAI,MAAM,OAAO,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AACvC,SAAO,IAAI;AACb;","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as _bettercms_ai_types from '@bettercms-ai/types';
|
|
2
|
-
import { ContentPageResult, Content, DeliveredLayout, ContentModelField, Fieldset, ComponentPreview, BetterCMSErrorCode, LayoutStructureDocument, LayoutDataDocument, PageLayoutOverrideDocument, ManagementLayoutCommand, AuthUser, AuthSession, SignInInput, SignUpInput, Perspective, DeliveryEntry, DeliveryList, DeliveryPage, DeliveryComponent,
|
|
2
|
+
import { ContentPageResult, Content, DeliveredLayout, ContentModelField, Fieldset, ComponentPreview, BetterCMSErrorCode, LayoutStructureDocument, LayoutDataDocument, PageLayoutOverrideDocument, ManagementLayoutCommand, AuthUser, AuthSession, SignInInput, SignUpInput, Perspective, DeliveryEntry, DeliveryList, DeliveryPage, DeliveryComponent, Page, MediaAsset, FormField, Form, FormSubmission, Workspace, ApiKey, ApiKeyPermission, ApiKeyTokenType } from '@bettercms-ai/types';
|
|
3
3
|
export { ApiError, ApiKey, ApiKeyPermission, ApiKeyTokenType, AssertEqual, AuthSession, AuthUser, BetterCMSErrorCode, BlockType, ButtonBlock, ButtonProps, ColumnsBlock, ColumnsProps, Content, ContentBlock, ContentEntry, ContentModel, ContentModelField, ContentPageResult, ContentResponse, DeepReadonly, DeliveryComponent, DeliveryEntry, DeliveryList, DeliveryPage, Form, FormBlock, FormField, FormProps, FormSubmission, HeadingBlock, HeadingProps, ImageBlock, ImageProps, MediaAsset, Page, PageMetaJson, PaginatedResult, Perspective, Redirect, SignInInput, SignUpInput, SiteSeoDefaults, SpacerBlock, SpacerProps, TextBlock, TextProps, VideoBlock, VideoProps, Workspace, getBlockType, isBlock } from '@bettercms-ai/types';
|
|
4
|
+
export { ResolvedSeo, SeoInput, SeoLocation, entrySeoInput, resolveSeo } from './seo.cjs';
|
|
4
5
|
export { ImageFit, ImageFormat, ImageSource, ImageUrlBuilder, ImageUrlOptions, imageUrl, default as imageUrlBuilder, imageValueObjectPosition, imageValueUrl } from '@bettercms-ai/image-url';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -1475,102 +1476,6 @@ declare function rich(value: TextOrRich, fallback?: string): string;
|
|
|
1475
1476
|
*/
|
|
1476
1477
|
declare function lines(value: TextOrRich, fallback?: string): string;
|
|
1477
1478
|
|
|
1478
|
-
/**
|
|
1479
|
-
* resolveSeo — framework-agnostic page-over-site SEO merge for headless consumers.
|
|
1480
|
-
*
|
|
1481
|
-
* The single source of truth for "per-page meta layered over project defaults",
|
|
1482
|
-
* mirroring the server renderer's `mergeSeoMeta` so `*.bettercms.site` and headless
|
|
1483
|
-
* sites (@bettercms-ai/next, @bettercms-ai/astro) emit identical <head> SEO. Pure: empty
|
|
1484
|
-
* inputs collapse to empty strings ("omit the tag"), never fabricated values.
|
|
1485
|
-
*
|
|
1486
|
-
* const seo = resolveSeo(page, siteDefaults);
|
|
1487
|
-
* // → { title, description, canonical, og, twitter, jsonLd }
|
|
1488
|
-
*
|
|
1489
|
-
* Next consumers map this to a `Metadata` via `buildMetadata` (@bettercms-ai/next);
|
|
1490
|
-
* Astro consumers read the fields straight into `<head>`.
|
|
1491
|
-
*/
|
|
1492
|
-
|
|
1493
|
-
/** Minimal page shape `resolveSeo` reads — a `DeliveryPage`/`BetterCMSPage` satisfies it. */
|
|
1494
|
-
interface SeoInput {
|
|
1495
|
-
title: string;
|
|
1496
|
-
metaTitle?: string | null;
|
|
1497
|
-
metaDescription?: string | null;
|
|
1498
|
-
metaJson?: PageMetaJson | null;
|
|
1499
|
-
/** `"home"` makes the Automatic JSON-LD a WebSite, as the hosted renderer does. */
|
|
1500
|
-
slug?: string | null;
|
|
1501
|
-
/** Exclude from search engines. Surfaces as `ResolvedSeo.noindex` / `robots`. */
|
|
1502
|
-
noindex?: boolean | null;
|
|
1503
|
-
}
|
|
1504
|
-
/** Fully-resolved SEO values, page-over-site. Empty string ⇒ omit that tag. */
|
|
1505
|
-
interface ResolvedSeo {
|
|
1506
|
-
title: string;
|
|
1507
|
-
description: string;
|
|
1508
|
-
/**
|
|
1509
|
-
* Canonical URL — the page's own `canonical`, else `siteUrl` + `path` when both are given,
|
|
1510
|
-
* else "". Empty means the consumer must omit the tag: a RELATIVE canonical is worse than
|
|
1511
|
-
* none at all.
|
|
1512
|
-
*/
|
|
1513
|
-
canonical: string;
|
|
1514
|
-
og: {
|
|
1515
|
-
title: string;
|
|
1516
|
-
description: string;
|
|
1517
|
-
type: string;
|
|
1518
|
-
image: string;
|
|
1519
|
-
url: string;
|
|
1520
|
-
};
|
|
1521
|
-
twitter: {
|
|
1522
|
-
card: string;
|
|
1523
|
-
title: string;
|
|
1524
|
-
description: string;
|
|
1525
|
-
image: string;
|
|
1526
|
-
site: string;
|
|
1527
|
-
};
|
|
1528
|
-
/**
|
|
1529
|
-
* JSON-LD nodes (site schema first, then the page's), already flattened + de-empted. The page's
|
|
1530
|
-
* part follows the renderer's precedence: Custom (`metaJson.schema`) > `metaJson.schemaType` >
|
|
1531
|
-
* Automatic (WebPage; WebSite on the home page).
|
|
1532
|
-
*/
|
|
1533
|
-
jsonLd: Array<Record<string, unknown>>;
|
|
1534
|
-
/** True when the page is excluded from search engines (the renderer's `<meta name="robots" content="noindex">`). */
|
|
1535
|
-
noindex: boolean;
|
|
1536
|
-
/** `noindex` in the `{ index, follow }` shape Next's `Metadata.robots` takes. */
|
|
1537
|
-
robots: {
|
|
1538
|
-
index: boolean;
|
|
1539
|
-
follow: boolean;
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* A Delivery API entry as a `resolveSeo` input. The server already resolved the collection
|
|
1544
|
-
* template's `{{field}}` tokens for this entry and merged the entry's own SEO over it, so this is
|
|
1545
|
-
* a reshape, not a resolver:
|
|
1546
|
-
*
|
|
1547
|
-
* const entry = await bcms.getEntry("astro");
|
|
1548
|
-
* const seo = resolveSeo(entrySeoInput(entry), siteDefaults, { siteUrl, path: `/tags/${entry.slug}` });
|
|
1549
|
-
* // seo.jsonLd holds the template's schema, resolved for this entry
|
|
1550
|
-
*/
|
|
1551
|
-
declare function entrySeoInput(entry: {
|
|
1552
|
-
slug: string;
|
|
1553
|
-
seo?: DeliveryEntrySeo | null;
|
|
1554
|
-
}): SeoInput;
|
|
1555
|
-
/** Where this page lives, so a canonical can be derived when the page sets none. */
|
|
1556
|
-
interface SeoLocation {
|
|
1557
|
-
/** Absolute site origin, e.g. `https://acme.com`. Trailing slash optional. */
|
|
1558
|
-
siteUrl?: string;
|
|
1559
|
-
/** This page's path, e.g. `/about` or `/`. */
|
|
1560
|
-
path?: string;
|
|
1561
|
-
}
|
|
1562
|
-
/**
|
|
1563
|
-
* Merge a page's own SEO over the site defaults: page meta wins, then site defaults, then
|
|
1564
|
-
* sensible fallbacks.
|
|
1565
|
-
*
|
|
1566
|
-
* The CANONICAL is the one field that cannot be resolved from content alone, and getting this
|
|
1567
|
-
* wrong is silent. The hosted renderer (`mergeSeoMeta` in the backend) falls back to a canonical
|
|
1568
|
-
* it derives from the request's own origin and path — headless consumers have no request, so
|
|
1569
|
-
* they must pass `location`. Without it this returns "" and the caller ships no
|
|
1570
|
-
* `<link rel="canonical">` and no `og:url`, exactly as it did before `location` existed.
|
|
1571
|
-
*/
|
|
1572
|
-
declare function resolveSeo(page: SeoInput, defaults?: SiteSeoDefaults, location?: SeoLocation): ResolvedSeo;
|
|
1573
|
-
|
|
1574
1479
|
/**
|
|
1575
1480
|
* Public site search — the headless surface for the visitor-facing search box.
|
|
1576
1481
|
*
|
|
@@ -1970,4 +1875,4 @@ interface FramedWindow {
|
|
|
1970
1875
|
/** True inside the editor's canvas. Safe where there is no window (a server render): false. */
|
|
1971
1876
|
declare function inEditor(win?: FramedWindow | undefined): boolean;
|
|
1972
1877
|
|
|
1973
|
-
export { type AddPageFieldsInput, type ApiKeyUsage, type ApiKeyWithRaw, type AuthResult, BetterCMSError as BCMSClientError, ErrorCodes as BCMSErrorCodes, BetterCMS, BetterCMSAdminClient, type BetterCMSAdminOptions, BetterCMSDeliveryClient, BetterCMSError, BetterCMSManagementClient, type BetterCMSManagementOptions, type BetterCMSReadClient, type BetterCMSSiteOptions, CONTENT_APPLIED, type CommandManagedLayoutInput, type ContentAppliedMessage, type CreateApiKeyInput, type CreateClientOptions, type CreateEntryInput, type CreateFormInput, type CreateManagedPageInput, type CreateModelInput, type CreatePageInput, type CreateWebhookInput, type CreateWorkspaceInput, type DeliveryForm, type DeliveryFormField, type DeliveryFormFieldType, EDITOR_FRAME_NAME, ErrorCodes, type ExtractionCandidate, type FormFieldInput, type FormListOptions, type FormSubmitError, type FormValue, type FormValues, type GetEntryOptions, type GetManagedLayoutOptions, type ListContentAllOptions, type ListContentOptions, type ListEntriesFilter, type ListEntriesOptions, type ListPagesOptions, type ManagedComponent, type ManagedComponentInput, type ManagedComponentSummary, type ManagedContentEntry, type ManagedContentModel, type ManagedForm, type ManagedFormInput, type ManagedLayoutClient, type ManagedLayoutDocument, type ManagedMediaAsset, type ManagedPage, type ManagementClient, type MediaListOptions, type MediaMetadata, type Member, type MemberRole, type ModelFieldset, PORTABLE_TEXT_FORMAT, type PageCreateReport, type PageListOptions, type PendingInvite, type PublishManagedLayoutInput, type PublishedContent, type
|
|
1878
|
+
export { type AddPageFieldsInput, type ApiKeyUsage, type ApiKeyWithRaw, type AuthResult, BetterCMSError as BCMSClientError, ErrorCodes as BCMSErrorCodes, BetterCMS, BetterCMSAdminClient, type BetterCMSAdminOptions, BetterCMSDeliveryClient, BetterCMSError, BetterCMSManagementClient, type BetterCMSManagementOptions, type BetterCMSReadClient, type BetterCMSSiteOptions, CONTENT_APPLIED, type CommandManagedLayoutInput, type ContentAppliedMessage, type CreateApiKeyInput, type CreateClientOptions, type CreateEntryInput, type CreateFormInput, type CreateManagedPageInput, type CreateModelInput, type CreatePageInput, type CreateWebhookInput, type CreateWorkspaceInput, type DeliveryForm, type DeliveryFormField, type DeliveryFormFieldType, EDITOR_FRAME_NAME, ErrorCodes, type ExtractionCandidate, type FormFieldInput, type FormListOptions, type FormSubmitError, type FormValue, type FormValues, type GetEntryOptions, type GetManagedLayoutOptions, type ListContentAllOptions, type ListContentOptions, type ListEntriesFilter, type ListEntriesOptions, type ListPagesOptions, type ManagedComponent, type ManagedComponentInput, type ManagedComponentSummary, type ManagedContentEntry, type ManagedContentModel, type ManagedForm, type ManagedFormInput, type ManagedLayoutClient, type ManagedLayoutDocument, type ManagedMediaAsset, type ManagedPage, type ManagementClient, type MediaListOptions, type MediaMetadata, type Member, type MemberRole, type ModelFieldset, PORTABLE_TEXT_FORMAT, type PageCreateReport, type PageListOptions, type PendingInvite, type PublishManagedLayoutInput, type PublishedContent, type RichTextValue, STEGA_OPEN, type SearchHit, type SearchOptions, type SeoMeta, type SeoMetaInput, type SetPageContentInput, type StegaNode, type StegaPayload, type SubmissionListOptions, type SubmitFormOptions, type SubmitFormResult, type TextOrRich, type UpdateApiKeyInput, type UpdateAssetInput, type UpdateEntryInput, type UpdateFormInput, type UpdateModelInput, type UpdatePageInput, type UpdateWebhookInput, type UpdateWorkspaceInput, type UploadAssetInput, type UploadedAsset, type Webhook, type WebhookAdvancedConfig, type WebhookEvent, type WebhookListResponse, type WebhookWithSecret, type WriteContentInput, addModelFields, addPageFields, asStringArray, commandManagedLayout, contentApplied, createApiKey, createClient, createEntry, createForm, createManagedPage, createModel, createPage, createWorkspace, decodeStega, deleteForm, deleteMedia, deletePage, deleteSubmission, deleteWorkspace, encodeStega, formInitialValues, getApiKeyUsage, getEntry, getForm, getManagedLayout, getManagedPage, getMedia, getMember, getModel, getPage, getSubmission, getWorkspace, inEditor, inviteMember, isMultiValueField, isRichText, lines, listApiKeys, listEntries, listForms, listManagedPages, listMedia, listMembers, listModels, listPages, listSubmissions, listWorkspaces, onContentApplied, plain, portableText, publishManagedLayout, publishPage, publishedContentFile, publishedEntry, readPublishedContent, regenerateApiKey, removeMember, resetPublishedContentCache, resolveStega, revokeApiKey, rich, search, setPageContent, shouldShowField, signIn, signInWithGithub, signInWithGoogle, signUp, stegaClean, stripStega, submitForm, toggleOption, updateApiKey, updateEntry, updateForm, updateMemberRole, updateModel, updatePage, updatePageModel, updateWorkspace, uploadMedia };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as _bettercms_ai_types from '@bettercms-ai/types';
|
|
2
|
-
import { ContentPageResult, Content, DeliveredLayout, ContentModelField, Fieldset, ComponentPreview, BetterCMSErrorCode, LayoutStructureDocument, LayoutDataDocument, PageLayoutOverrideDocument, ManagementLayoutCommand, AuthUser, AuthSession, SignInInput, SignUpInput, Perspective, DeliveryEntry, DeliveryList, DeliveryPage, DeliveryComponent,
|
|
2
|
+
import { ContentPageResult, Content, DeliveredLayout, ContentModelField, Fieldset, ComponentPreview, BetterCMSErrorCode, LayoutStructureDocument, LayoutDataDocument, PageLayoutOverrideDocument, ManagementLayoutCommand, AuthUser, AuthSession, SignInInput, SignUpInput, Perspective, DeliveryEntry, DeliveryList, DeliveryPage, DeliveryComponent, Page, MediaAsset, FormField, Form, FormSubmission, Workspace, ApiKey, ApiKeyPermission, ApiKeyTokenType } from '@bettercms-ai/types';
|
|
3
3
|
export { ApiError, ApiKey, ApiKeyPermission, ApiKeyTokenType, AssertEqual, AuthSession, AuthUser, BetterCMSErrorCode, BlockType, ButtonBlock, ButtonProps, ColumnsBlock, ColumnsProps, Content, ContentBlock, ContentEntry, ContentModel, ContentModelField, ContentPageResult, ContentResponse, DeepReadonly, DeliveryComponent, DeliveryEntry, DeliveryList, DeliveryPage, Form, FormBlock, FormField, FormProps, FormSubmission, HeadingBlock, HeadingProps, ImageBlock, ImageProps, MediaAsset, Page, PageMetaJson, PaginatedResult, Perspective, Redirect, SignInInput, SignUpInput, SiteSeoDefaults, SpacerBlock, SpacerProps, TextBlock, TextProps, VideoBlock, VideoProps, Workspace, getBlockType, isBlock } from '@bettercms-ai/types';
|
|
4
|
+
export { ResolvedSeo, SeoInput, SeoLocation, entrySeoInput, resolveSeo } from './seo.js';
|
|
4
5
|
export { ImageFit, ImageFormat, ImageSource, ImageUrlBuilder, ImageUrlOptions, imageUrl, default as imageUrlBuilder, imageValueObjectPosition, imageValueUrl } from '@bettercms-ai/image-url';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -1475,102 +1476,6 @@ declare function rich(value: TextOrRich, fallback?: string): string;
|
|
|
1475
1476
|
*/
|
|
1476
1477
|
declare function lines(value: TextOrRich, fallback?: string): string;
|
|
1477
1478
|
|
|
1478
|
-
/**
|
|
1479
|
-
* resolveSeo — framework-agnostic page-over-site SEO merge for headless consumers.
|
|
1480
|
-
*
|
|
1481
|
-
* The single source of truth for "per-page meta layered over project defaults",
|
|
1482
|
-
* mirroring the server renderer's `mergeSeoMeta` so `*.bettercms.site` and headless
|
|
1483
|
-
* sites (@bettercms-ai/next, @bettercms-ai/astro) emit identical <head> SEO. Pure: empty
|
|
1484
|
-
* inputs collapse to empty strings ("omit the tag"), never fabricated values.
|
|
1485
|
-
*
|
|
1486
|
-
* const seo = resolveSeo(page, siteDefaults);
|
|
1487
|
-
* // → { title, description, canonical, og, twitter, jsonLd }
|
|
1488
|
-
*
|
|
1489
|
-
* Next consumers map this to a `Metadata` via `buildMetadata` (@bettercms-ai/next);
|
|
1490
|
-
* Astro consumers read the fields straight into `<head>`.
|
|
1491
|
-
*/
|
|
1492
|
-
|
|
1493
|
-
/** Minimal page shape `resolveSeo` reads — a `DeliveryPage`/`BetterCMSPage` satisfies it. */
|
|
1494
|
-
interface SeoInput {
|
|
1495
|
-
title: string;
|
|
1496
|
-
metaTitle?: string | null;
|
|
1497
|
-
metaDescription?: string | null;
|
|
1498
|
-
metaJson?: PageMetaJson | null;
|
|
1499
|
-
/** `"home"` makes the Automatic JSON-LD a WebSite, as the hosted renderer does. */
|
|
1500
|
-
slug?: string | null;
|
|
1501
|
-
/** Exclude from search engines. Surfaces as `ResolvedSeo.noindex` / `robots`. */
|
|
1502
|
-
noindex?: boolean | null;
|
|
1503
|
-
}
|
|
1504
|
-
/** Fully-resolved SEO values, page-over-site. Empty string ⇒ omit that tag. */
|
|
1505
|
-
interface ResolvedSeo {
|
|
1506
|
-
title: string;
|
|
1507
|
-
description: string;
|
|
1508
|
-
/**
|
|
1509
|
-
* Canonical URL — the page's own `canonical`, else `siteUrl` + `path` when both are given,
|
|
1510
|
-
* else "". Empty means the consumer must omit the tag: a RELATIVE canonical is worse than
|
|
1511
|
-
* none at all.
|
|
1512
|
-
*/
|
|
1513
|
-
canonical: string;
|
|
1514
|
-
og: {
|
|
1515
|
-
title: string;
|
|
1516
|
-
description: string;
|
|
1517
|
-
type: string;
|
|
1518
|
-
image: string;
|
|
1519
|
-
url: string;
|
|
1520
|
-
};
|
|
1521
|
-
twitter: {
|
|
1522
|
-
card: string;
|
|
1523
|
-
title: string;
|
|
1524
|
-
description: string;
|
|
1525
|
-
image: string;
|
|
1526
|
-
site: string;
|
|
1527
|
-
};
|
|
1528
|
-
/**
|
|
1529
|
-
* JSON-LD nodes (site schema first, then the page's), already flattened + de-empted. The page's
|
|
1530
|
-
* part follows the renderer's precedence: Custom (`metaJson.schema`) > `metaJson.schemaType` >
|
|
1531
|
-
* Automatic (WebPage; WebSite on the home page).
|
|
1532
|
-
*/
|
|
1533
|
-
jsonLd: Array<Record<string, unknown>>;
|
|
1534
|
-
/** True when the page is excluded from search engines (the renderer's `<meta name="robots" content="noindex">`). */
|
|
1535
|
-
noindex: boolean;
|
|
1536
|
-
/** `noindex` in the `{ index, follow }` shape Next's `Metadata.robots` takes. */
|
|
1537
|
-
robots: {
|
|
1538
|
-
index: boolean;
|
|
1539
|
-
follow: boolean;
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* A Delivery API entry as a `resolveSeo` input. The server already resolved the collection
|
|
1544
|
-
* template's `{{field}}` tokens for this entry and merged the entry's own SEO over it, so this is
|
|
1545
|
-
* a reshape, not a resolver:
|
|
1546
|
-
*
|
|
1547
|
-
* const entry = await bcms.getEntry("astro");
|
|
1548
|
-
* const seo = resolveSeo(entrySeoInput(entry), siteDefaults, { siteUrl, path: `/tags/${entry.slug}` });
|
|
1549
|
-
* // seo.jsonLd holds the template's schema, resolved for this entry
|
|
1550
|
-
*/
|
|
1551
|
-
declare function entrySeoInput(entry: {
|
|
1552
|
-
slug: string;
|
|
1553
|
-
seo?: DeliveryEntrySeo | null;
|
|
1554
|
-
}): SeoInput;
|
|
1555
|
-
/** Where this page lives, so a canonical can be derived when the page sets none. */
|
|
1556
|
-
interface SeoLocation {
|
|
1557
|
-
/** Absolute site origin, e.g. `https://acme.com`. Trailing slash optional. */
|
|
1558
|
-
siteUrl?: string;
|
|
1559
|
-
/** This page's path, e.g. `/about` or `/`. */
|
|
1560
|
-
path?: string;
|
|
1561
|
-
}
|
|
1562
|
-
/**
|
|
1563
|
-
* Merge a page's own SEO over the site defaults: page meta wins, then site defaults, then
|
|
1564
|
-
* sensible fallbacks.
|
|
1565
|
-
*
|
|
1566
|
-
* The CANONICAL is the one field that cannot be resolved from content alone, and getting this
|
|
1567
|
-
* wrong is silent. The hosted renderer (`mergeSeoMeta` in the backend) falls back to a canonical
|
|
1568
|
-
* it derives from the request's own origin and path — headless consumers have no request, so
|
|
1569
|
-
* they must pass `location`. Without it this returns "" and the caller ships no
|
|
1570
|
-
* `<link rel="canonical">` and no `og:url`, exactly as it did before `location` existed.
|
|
1571
|
-
*/
|
|
1572
|
-
declare function resolveSeo(page: SeoInput, defaults?: SiteSeoDefaults, location?: SeoLocation): ResolvedSeo;
|
|
1573
|
-
|
|
1574
1479
|
/**
|
|
1575
1480
|
* Public site search — the headless surface for the visitor-facing search box.
|
|
1576
1481
|
*
|
|
@@ -1970,4 +1875,4 @@ interface FramedWindow {
|
|
|
1970
1875
|
/** True inside the editor's canvas. Safe where there is no window (a server render): false. */
|
|
1971
1876
|
declare function inEditor(win?: FramedWindow | undefined): boolean;
|
|
1972
1877
|
|
|
1973
|
-
export { type AddPageFieldsInput, type ApiKeyUsage, type ApiKeyWithRaw, type AuthResult, BetterCMSError as BCMSClientError, ErrorCodes as BCMSErrorCodes, BetterCMS, BetterCMSAdminClient, type BetterCMSAdminOptions, BetterCMSDeliveryClient, BetterCMSError, BetterCMSManagementClient, type BetterCMSManagementOptions, type BetterCMSReadClient, type BetterCMSSiteOptions, CONTENT_APPLIED, type CommandManagedLayoutInput, type ContentAppliedMessage, type CreateApiKeyInput, type CreateClientOptions, type CreateEntryInput, type CreateFormInput, type CreateManagedPageInput, type CreateModelInput, type CreatePageInput, type CreateWebhookInput, type CreateWorkspaceInput, type DeliveryForm, type DeliveryFormField, type DeliveryFormFieldType, EDITOR_FRAME_NAME, ErrorCodes, type ExtractionCandidate, type FormFieldInput, type FormListOptions, type FormSubmitError, type FormValue, type FormValues, type GetEntryOptions, type GetManagedLayoutOptions, type ListContentAllOptions, type ListContentOptions, type ListEntriesFilter, type ListEntriesOptions, type ListPagesOptions, type ManagedComponent, type ManagedComponentInput, type ManagedComponentSummary, type ManagedContentEntry, type ManagedContentModel, type ManagedForm, type ManagedFormInput, type ManagedLayoutClient, type ManagedLayoutDocument, type ManagedMediaAsset, type ManagedPage, type ManagementClient, type MediaListOptions, type MediaMetadata, type Member, type MemberRole, type ModelFieldset, PORTABLE_TEXT_FORMAT, type PageCreateReport, type PageListOptions, type PendingInvite, type PublishManagedLayoutInput, type PublishedContent, type
|
|
1878
|
+
export { type AddPageFieldsInput, type ApiKeyUsage, type ApiKeyWithRaw, type AuthResult, BetterCMSError as BCMSClientError, ErrorCodes as BCMSErrorCodes, BetterCMS, BetterCMSAdminClient, type BetterCMSAdminOptions, BetterCMSDeliveryClient, BetterCMSError, BetterCMSManagementClient, type BetterCMSManagementOptions, type BetterCMSReadClient, type BetterCMSSiteOptions, CONTENT_APPLIED, type CommandManagedLayoutInput, type ContentAppliedMessage, type CreateApiKeyInput, type CreateClientOptions, type CreateEntryInput, type CreateFormInput, type CreateManagedPageInput, type CreateModelInput, type CreatePageInput, type CreateWebhookInput, type CreateWorkspaceInput, type DeliveryForm, type DeliveryFormField, type DeliveryFormFieldType, EDITOR_FRAME_NAME, ErrorCodes, type ExtractionCandidate, type FormFieldInput, type FormListOptions, type FormSubmitError, type FormValue, type FormValues, type GetEntryOptions, type GetManagedLayoutOptions, type ListContentAllOptions, type ListContentOptions, type ListEntriesFilter, type ListEntriesOptions, type ListPagesOptions, type ManagedComponent, type ManagedComponentInput, type ManagedComponentSummary, type ManagedContentEntry, type ManagedContentModel, type ManagedForm, type ManagedFormInput, type ManagedLayoutClient, type ManagedLayoutDocument, type ManagedMediaAsset, type ManagedPage, type ManagementClient, type MediaListOptions, type MediaMetadata, type Member, type MemberRole, type ModelFieldset, PORTABLE_TEXT_FORMAT, type PageCreateReport, type PageListOptions, type PendingInvite, type PublishManagedLayoutInput, type PublishedContent, type RichTextValue, STEGA_OPEN, type SearchHit, type SearchOptions, type SeoMeta, type SeoMetaInput, type SetPageContentInput, type StegaNode, type StegaPayload, type SubmissionListOptions, type SubmitFormOptions, type SubmitFormResult, type TextOrRich, type UpdateApiKeyInput, type UpdateAssetInput, type UpdateEntryInput, type UpdateFormInput, type UpdateModelInput, type UpdatePageInput, type UpdateWebhookInput, type UpdateWorkspaceInput, type UploadAssetInput, type UploadedAsset, type Webhook, type WebhookAdvancedConfig, type WebhookEvent, type WebhookListResponse, type WebhookWithSecret, type WriteContentInput, addModelFields, addPageFields, asStringArray, commandManagedLayout, contentApplied, createApiKey, createClient, createEntry, createForm, createManagedPage, createModel, createPage, createWorkspace, decodeStega, deleteForm, deleteMedia, deletePage, deleteSubmission, deleteWorkspace, encodeStega, formInitialValues, getApiKeyUsage, getEntry, getForm, getManagedLayout, getManagedPage, getMedia, getMember, getModel, getPage, getSubmission, getWorkspace, inEditor, inviteMember, isMultiValueField, isRichText, lines, listApiKeys, listEntries, listForms, listManagedPages, listMedia, listMembers, listModels, listPages, listSubmissions, listWorkspaces, onContentApplied, plain, portableText, publishManagedLayout, publishPage, publishedContentFile, publishedEntry, readPublishedContent, regenerateApiKey, removeMember, resetPublishedContentCache, resolveStega, revokeApiKey, rich, search, setPageContent, shouldShowField, signIn, signInWithGithub, signInWithGoogle, signUp, stegaClean, stripStega, submitForm, toggleOption, updateApiKey, updateEntry, updateForm, updateMemberRole, updateModel, updatePage, updatePageModel, updateWorkspace, uploadMedia };
|
package/dist/seo.cjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/seo.ts
|
|
21
|
+
var seo_exports = {};
|
|
22
|
+
__export(seo_exports, {
|
|
23
|
+
entrySeoInput: () => entrySeoInput,
|
|
24
|
+
resolveSeo: () => resolveSeo
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(seo_exports);
|
|
27
|
+
function derivedCanonical({ siteUrl, path }) {
|
|
28
|
+
if (!siteUrl || !path) return "";
|
|
29
|
+
try {
|
|
30
|
+
return new URL(path, siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`).toString();
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function jsonLdNodes(schema) {
|
|
36
|
+
if (!schema) return [];
|
|
37
|
+
const arr = Array.isArray(schema) ? schema : [schema];
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const node of arr) {
|
|
40
|
+
if (!node || Object.keys(node).length === 0) continue;
|
|
41
|
+
const graph = node["@graph"];
|
|
42
|
+
if (Array.isArray(graph)) {
|
|
43
|
+
for (const child of graph) {
|
|
44
|
+
if (child && typeof child === "object" && !Array.isArray(child) && Object.keys(child).length > 0) {
|
|
45
|
+
out.push(child);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
out.push(node);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
var SCHEMA_TYPE_CHOICES = [
|
|
55
|
+
"WebPage",
|
|
56
|
+
"AboutPage",
|
|
57
|
+
"ContactPage",
|
|
58
|
+
"CollectionPage",
|
|
59
|
+
"Blog",
|
|
60
|
+
"BlogPosting",
|
|
61
|
+
"Article",
|
|
62
|
+
"Product",
|
|
63
|
+
"FAQPage",
|
|
64
|
+
"Event",
|
|
65
|
+
"Organization",
|
|
66
|
+
"LocalBusiness"
|
|
67
|
+
];
|
|
68
|
+
function compact(node) {
|
|
69
|
+
return Object.fromEntries(Object.entries(node).filter(([, value]) => value != null && value !== ""));
|
|
70
|
+
}
|
|
71
|
+
function schemaNode(type, { name, description, image, url }) {
|
|
72
|
+
switch (type) {
|
|
73
|
+
case "BlogPosting":
|
|
74
|
+
case "Article":
|
|
75
|
+
return compact({ "@type": type, headline: name, description, image, url, mainEntityOfPage: url });
|
|
76
|
+
case "Product":
|
|
77
|
+
case "Event":
|
|
78
|
+
return compact({ "@type": type, name, description, image, url });
|
|
79
|
+
case "FAQPage":
|
|
80
|
+
case "WebSite":
|
|
81
|
+
return compact({ "@type": type, name, description, url });
|
|
82
|
+
case "Organization":
|
|
83
|
+
return compact({ "@type": type, name, description, url, logo: image });
|
|
84
|
+
default:
|
|
85
|
+
return compact({ "@type": type, name, description, url, image });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function pageJsonLd(page, meta, canonical, siteSchema) {
|
|
89
|
+
const custom = jsonLdNodes(meta.schema);
|
|
90
|
+
if (custom.length > 0) return custom;
|
|
91
|
+
const props = {
|
|
92
|
+
name: (page.metaTitle || page.title || "").trim(),
|
|
93
|
+
description: (page.metaDescription || "").trim(),
|
|
94
|
+
image: (meta.og?.image || "").trim(),
|
|
95
|
+
url: canonical.trim()
|
|
96
|
+
};
|
|
97
|
+
const schemaType = meta.schemaType;
|
|
98
|
+
if (typeof schemaType === "string" && SCHEMA_TYPE_CHOICES.includes(schemaType)) {
|
|
99
|
+
return [schemaNode(schemaType, props)];
|
|
100
|
+
}
|
|
101
|
+
if (page.slug === "home") {
|
|
102
|
+
return jsonLdNodes(siteSchema).length > 0 ? [] : [schemaNode("WebSite", props)];
|
|
103
|
+
}
|
|
104
|
+
return [schemaNode("WebPage", props)];
|
|
105
|
+
}
|
|
106
|
+
function entrySeoInput(entry) {
|
|
107
|
+
const { metaTitle, metaDescription, noindex, ...metaJson } = entry.seo ?? {};
|
|
108
|
+
return {
|
|
109
|
+
title: metaTitle || entry.slug,
|
|
110
|
+
metaTitle: metaTitle ?? null,
|
|
111
|
+
metaDescription: metaDescription ?? null,
|
|
112
|
+
metaJson,
|
|
113
|
+
noindex: noindex === true
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function resolveSeo(page, defaults = {}, location = {}) {
|
|
117
|
+
const meta = page.metaJson ?? {};
|
|
118
|
+
const title = page.metaTitle || page.title || "";
|
|
119
|
+
const description = page.metaDescription || defaults.metaDescription || "";
|
|
120
|
+
const ogImage = meta.og?.image || defaults.og?.image || defaults.ogImage || "";
|
|
121
|
+
const ogTitle = meta.og?.title || title;
|
|
122
|
+
const ogDescription = meta.og?.description || description;
|
|
123
|
+
const twImage = meta.twitter?.image || ogImage;
|
|
124
|
+
const canonical = meta.canonical || derivedCanonical(location);
|
|
125
|
+
return {
|
|
126
|
+
title,
|
|
127
|
+
description,
|
|
128
|
+
canonical,
|
|
129
|
+
og: {
|
|
130
|
+
title: ogTitle,
|
|
131
|
+
description: ogDescription,
|
|
132
|
+
type: meta.og?.type || defaults.og?.type || "website",
|
|
133
|
+
image: ogImage,
|
|
134
|
+
url: canonical
|
|
135
|
+
},
|
|
136
|
+
twitter: {
|
|
137
|
+
card: meta.twitter?.card || defaults.twitter?.card || (twImage ? "summary_large_image" : "summary"),
|
|
138
|
+
title: meta.twitter?.title || ogTitle,
|
|
139
|
+
description: meta.twitter?.description || ogDescription,
|
|
140
|
+
image: twImage,
|
|
141
|
+
site: defaults.twitterHandle || ""
|
|
142
|
+
},
|
|
143
|
+
jsonLd: [...jsonLdNodes(defaults.siteSchema), ...pageJsonLd(page, meta, canonical, defaults.siteSchema)],
|
|
144
|
+
noindex: page.noindex === true,
|
|
145
|
+
robots: { index: page.noindex !== true, follow: true }
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
149
|
+
0 && (module.exports = {
|
|
150
|
+
entrySeoInput,
|
|
151
|
+
resolveSeo
|
|
152
|
+
});
|
|
153
|
+
//# sourceMappingURL=seo.cjs.map
|
package/dist/seo.cjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/seo.ts"],"sourcesContent":["/**\n * resolveSeo — framework-agnostic page-over-site SEO merge for headless consumers.\n *\n * The single source of truth for \"per-page meta layered over project defaults\",\n * mirroring the server renderer's `mergeSeoMeta` so `*.bettercms.site` and headless\n * sites (@bettercms-ai/next, @bettercms-ai/astro) emit identical <head> SEO. Pure: empty\n * inputs collapse to empty strings (\"omit the tag\"), never fabricated values.\n *\n * const seo = resolveSeo(page, siteDefaults);\n * // → { title, description, canonical, og, twitter, jsonLd }\n *\n * Next consumers map this to a `Metadata` via `buildMetadata` (@bettercms-ai/next);\n * Astro consumers read the fields straight into `<head>`.\n */\nimport type { DeliveryEntrySeo, PageMetaJson, SiteSeoDefaults } from \"@bettercms-ai/types\";\n\n/** Minimal page shape `resolveSeo` reads — a `DeliveryPage`/`BetterCMSPage` satisfies it. */\nexport interface SeoInput {\n title: string;\n metaTitle?: string | null;\n metaDescription?: string | null;\n metaJson?: PageMetaJson | null;\n /** `\"home\"` makes the Automatic JSON-LD a WebSite, as the hosted renderer does. */\n slug?: string | null;\n /** Exclude from search engines. Surfaces as `ResolvedSeo.noindex` / `robots`. */\n noindex?: boolean | null;\n}\n\n/** Fully-resolved SEO values, page-over-site. Empty string ⇒ omit that tag. */\nexport interface ResolvedSeo {\n title: string;\n description: string;\n /**\n * Canonical URL — the page's own `canonical`, else `siteUrl` + `path` when both are given,\n * else \"\". Empty means the consumer must omit the tag: a RELATIVE canonical is worse than\n * none at all.\n */\n canonical: string;\n og: {\n title: string;\n description: string;\n type: string;\n image: string;\n url: string;\n };\n twitter: {\n card: string;\n title: string;\n description: string;\n image: string;\n site: string;\n };\n /**\n * JSON-LD nodes (site schema first, then the page's), already flattened + de-empted. The page's\n * part follows the renderer's precedence: Custom (`metaJson.schema`) > `metaJson.schemaType` >\n * Automatic (WebPage; WebSite on the home page).\n */\n jsonLd: Array<Record<string, unknown>>;\n /** True when the page is excluded from search engines (the renderer's `<meta name=\"robots\" content=\"noindex\">`). */\n noindex: boolean;\n /** `noindex` in the `{ index, follow }` shape Next's `Metadata.robots` takes. */\n robots: { index: boolean; follow: boolean };\n}\n\n/**\n * `siteUrl` + `path` → an absolute canonical, or \"\" when either is missing or unparseable.\n * Never returns a relative URL: a relative canonical is worse than no canonical.\n */\nfunction derivedCanonical({ siteUrl, path }: SeoLocation): string {\n if (!siteUrl || !path) return \"\";\n try {\n return new URL(path, siteUrl.endsWith(\"/\") ? siteUrl : `${siteUrl}/`).toString();\n } catch {\n return \"\";\n }\n}\n\n/** Flatten one-or-many JSON-LD into a node array, dropping empty objects. */\nfunction jsonLdNodes(\n schema: Record<string, unknown> | Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n if (!schema) return [];\n const arr = Array.isArray(schema) ? schema : [schema];\n const out: Array<Record<string, unknown>> = [];\n for (const node of arr) {\n if (!node || Object.keys(node).length === 0) continue;\n const graph = node[\"@graph\"];\n if (Array.isArray(graph)) {\n for (const child of graph) {\n if (child && typeof child === \"object\" && !Array.isArray(child) && Object.keys(child).length > 0) {\n out.push(child as Record<string, unknown>);\n }\n }\n continue;\n }\n out.push(node);\n }\n return out;\n}\n\n/*\n * Structured data for a page whose `metaJson.schema` is empty. A port of the page path of\n * `resolveStructuredData` in the backend (src/lib/seo/structured-data.ts, called from\n * `pageStructuredData` in src/lib/sites/render-page.ts), with `site.include: false` as the\n * Delivery API calls it: the site-wide schema is emitted separately, from `siteSchema`.\n * A page has no field data, so only the properties the page's own meta supplies are filled.\n * Keep in step with that file; the parity test imports it.\n */\nconst SCHEMA_TYPE_CHOICES = [\n \"WebPage\",\n \"AboutPage\",\n \"ContactPage\",\n \"CollectionPage\",\n \"Blog\",\n \"BlogPosting\",\n \"Article\",\n \"Product\",\n \"FAQPage\",\n \"Event\",\n \"Organization\",\n \"LocalBusiness\",\n] as const;\n\ntype PageProps = { name: string; description: string; image: string; url: string };\n\n/** Drop \"\", null and undefined so a node never carries a blank. */\nfunction compact(node: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(node).filter(([, value]) => value != null && value !== \"\"));\n}\n\nfunction schemaNode(type: string, { name, description, image, url }: PageProps): Record<string, unknown> {\n switch (type) {\n case \"BlogPosting\":\n case \"Article\":\n return compact({ \"@type\": type, headline: name, description, image, url, mainEntityOfPage: url });\n case \"Product\":\n case \"Event\":\n return compact({ \"@type\": type, name, description, image, url });\n case \"FAQPage\":\n case \"WebSite\":\n return compact({ \"@type\": type, name, description, url });\n case \"Organization\":\n return compact({ \"@type\": type, name, description, url, logo: image });\n default:\n // WebPage, AboutPage, ContactPage, CollectionPage, Blog, LocalBusiness\n return compact({ \"@type\": type, name, description, url, image });\n }\n}\n\n/** The page's own JSON-LD nodes: Custom > explicit `schemaType` > Automatic. */\nfunction pageJsonLd(\n page: SeoInput,\n meta: PageMetaJson,\n canonical: string,\n siteSchema: SiteSeoDefaults[\"siteSchema\"],\n): Array<Record<string, unknown>> {\n const custom = jsonLdNodes(meta.schema);\n if (custom.length > 0) return custom;\n const props: PageProps = {\n name: (page.metaTitle || page.title || \"\").trim(),\n description: (page.metaDescription || \"\").trim(),\n image: (meta.og?.image || \"\").trim(),\n url: canonical.trim(),\n };\n const schemaType = (meta as { schemaType?: unknown }).schemaType;\n if (typeof schemaType === \"string\" && (SCHEMA_TYPE_CHOICES as readonly string[]).includes(schemaType)) {\n return [schemaNode(schemaType, props)];\n }\n if (page.slug === \"home\") {\n // The home page IS the WebSite, unless the site-wide schema already describes it.\n return jsonLdNodes(siteSchema).length > 0 ? [] : [schemaNode(\"WebSite\", props)];\n }\n return [schemaNode(\"WebPage\", props)];\n}\n\n/**\n * A Delivery API entry as a `resolveSeo` input. The server already resolved the collection\n * template's `{{field}}` tokens for this entry and merged the entry's own SEO over it, so this is\n * a reshape, not a resolver:\n *\n * const entry = await bcms.getEntry(\"astro\");\n * const seo = resolveSeo(entrySeoInput(entry), siteDefaults, { siteUrl, path: `/tags/${entry.slug}` });\n * // seo.jsonLd holds the template's schema, resolved for this entry\n */\nexport function entrySeoInput(entry: { slug: string; seo?: DeliveryEntrySeo | null }): SeoInput {\n const { metaTitle, metaDescription, noindex, ...metaJson } = entry.seo ?? {};\n return {\n title: metaTitle || entry.slug,\n metaTitle: metaTitle ?? null,\n metaDescription: metaDescription ?? null,\n metaJson,\n noindex: noindex === true,\n };\n}\n\n/** Where this page lives, so a canonical can be derived when the page sets none. */\nexport interface SeoLocation {\n /** Absolute site origin, e.g. `https://acme.com`. Trailing slash optional. */\n siteUrl?: string;\n /** This page's path, e.g. `/about` or `/`. */\n path?: string;\n}\n\n/**\n * Merge a page's own SEO over the site defaults: page meta wins, then site defaults, then\n * sensible fallbacks.\n *\n * The CANONICAL is the one field that cannot be resolved from content alone, and getting this\n * wrong is silent. The hosted renderer (`mergeSeoMeta` in the backend) falls back to a canonical\n * it derives from the request's own origin and path — headless consumers have no request, so\n * they must pass `location`. Without it this returns \"\" and the caller ships no\n * `<link rel=\"canonical\">` and no `og:url`, exactly as it did before `location` existed.\n */\nexport function resolveSeo(\n page: SeoInput,\n defaults: SiteSeoDefaults = {},\n location: SeoLocation = {},\n): ResolvedSeo {\n const meta = page.metaJson ?? {};\n const title = page.metaTitle || page.title || \"\";\n const description = page.metaDescription || defaults.metaDescription || \"\";\n const ogImage = meta.og?.image || defaults.og?.image || defaults.ogImage || \"\";\n const ogTitle = meta.og?.title || title;\n const ogDescription = meta.og?.description || description;\n const twImage = meta.twitter?.image || ogImage;\n const canonical = meta.canonical || derivedCanonical(location);\n return {\n title,\n description,\n canonical,\n og: {\n title: ogTitle,\n description: ogDescription,\n type: meta.og?.type || defaults.og?.type || \"website\",\n image: ogImage,\n url: canonical,\n },\n twitter: {\n card: meta.twitter?.card || defaults.twitter?.card || (twImage ? \"summary_large_image\" : \"summary\"),\n title: meta.twitter?.title || ogTitle,\n description: meta.twitter?.description || ogDescription,\n image: twImage,\n site: defaults.twitterHandle || \"\",\n },\n jsonLd: [...jsonLdNodes(defaults.siteSchema), ...pageJsonLd(page, meta, canonical, defaults.siteSchema)],\n noindex: page.noindex === true,\n robots: { index: page.noindex !== true, follow: true },\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/seo.d.cts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { PageMetaJson, DeliveryEntrySeo, SiteSeoDefaults } from '@bettercms-ai/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* resolveSeo — framework-agnostic page-over-site SEO merge for headless consumers.
|
|
5
|
+
*
|
|
6
|
+
* The single source of truth for "per-page meta layered over project defaults",
|
|
7
|
+
* mirroring the server renderer's `mergeSeoMeta` so `*.bettercms.site` and headless
|
|
8
|
+
* sites (@bettercms-ai/next, @bettercms-ai/astro) emit identical <head> SEO. Pure: empty
|
|
9
|
+
* inputs collapse to empty strings ("omit the tag"), never fabricated values.
|
|
10
|
+
*
|
|
11
|
+
* const seo = resolveSeo(page, siteDefaults);
|
|
12
|
+
* // → { title, description, canonical, og, twitter, jsonLd }
|
|
13
|
+
*
|
|
14
|
+
* Next consumers map this to a `Metadata` via `buildMetadata` (@bettercms-ai/next);
|
|
15
|
+
* Astro consumers read the fields straight into `<head>`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal page shape `resolveSeo` reads — a `DeliveryPage`/`BetterCMSPage` satisfies it. */
|
|
19
|
+
interface SeoInput {
|
|
20
|
+
title: string;
|
|
21
|
+
metaTitle?: string | null;
|
|
22
|
+
metaDescription?: string | null;
|
|
23
|
+
metaJson?: PageMetaJson | null;
|
|
24
|
+
/** `"home"` makes the Automatic JSON-LD a WebSite, as the hosted renderer does. */
|
|
25
|
+
slug?: string | null;
|
|
26
|
+
/** Exclude from search engines. Surfaces as `ResolvedSeo.noindex` / `robots`. */
|
|
27
|
+
noindex?: boolean | null;
|
|
28
|
+
}
|
|
29
|
+
/** Fully-resolved SEO values, page-over-site. Empty string ⇒ omit that tag. */
|
|
30
|
+
interface ResolvedSeo {
|
|
31
|
+
title: string;
|
|
32
|
+
description: string;
|
|
33
|
+
/**
|
|
34
|
+
* Canonical URL — the page's own `canonical`, else `siteUrl` + `path` when both are given,
|
|
35
|
+
* else "". Empty means the consumer must omit the tag: a RELATIVE canonical is worse than
|
|
36
|
+
* none at all.
|
|
37
|
+
*/
|
|
38
|
+
canonical: string;
|
|
39
|
+
og: {
|
|
40
|
+
title: string;
|
|
41
|
+
description: string;
|
|
42
|
+
type: string;
|
|
43
|
+
image: string;
|
|
44
|
+
url: string;
|
|
45
|
+
};
|
|
46
|
+
twitter: {
|
|
47
|
+
card: string;
|
|
48
|
+
title: string;
|
|
49
|
+
description: string;
|
|
50
|
+
image: string;
|
|
51
|
+
site: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* JSON-LD nodes (site schema first, then the page's), already flattened + de-empted. The page's
|
|
55
|
+
* part follows the renderer's precedence: Custom (`metaJson.schema`) > `metaJson.schemaType` >
|
|
56
|
+
* Automatic (WebPage; WebSite on the home page).
|
|
57
|
+
*/
|
|
58
|
+
jsonLd: Array<Record<string, unknown>>;
|
|
59
|
+
/** True when the page is excluded from search engines (the renderer's `<meta name="robots" content="noindex">`). */
|
|
60
|
+
noindex: boolean;
|
|
61
|
+
/** `noindex` in the `{ index, follow }` shape Next's `Metadata.robots` takes. */
|
|
62
|
+
robots: {
|
|
63
|
+
index: boolean;
|
|
64
|
+
follow: boolean;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A Delivery API entry as a `resolveSeo` input. The server already resolved the collection
|
|
69
|
+
* template's `{{field}}` tokens for this entry and merged the entry's own SEO over it, so this is
|
|
70
|
+
* a reshape, not a resolver:
|
|
71
|
+
*
|
|
72
|
+
* const entry = await bcms.getEntry("astro");
|
|
73
|
+
* const seo = resolveSeo(entrySeoInput(entry), siteDefaults, { siteUrl, path: `/tags/${entry.slug}` });
|
|
74
|
+
* // seo.jsonLd holds the template's schema, resolved for this entry
|
|
75
|
+
*/
|
|
76
|
+
declare function entrySeoInput(entry: {
|
|
77
|
+
slug: string;
|
|
78
|
+
seo?: DeliveryEntrySeo | null;
|
|
79
|
+
}): SeoInput;
|
|
80
|
+
/** Where this page lives, so a canonical can be derived when the page sets none. */
|
|
81
|
+
interface SeoLocation {
|
|
82
|
+
/** Absolute site origin, e.g. `https://acme.com`. Trailing slash optional. */
|
|
83
|
+
siteUrl?: string;
|
|
84
|
+
/** This page's path, e.g. `/about` or `/`. */
|
|
85
|
+
path?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Merge a page's own SEO over the site defaults: page meta wins, then site defaults, then
|
|
89
|
+
* sensible fallbacks.
|
|
90
|
+
*
|
|
91
|
+
* The CANONICAL is the one field that cannot be resolved from content alone, and getting this
|
|
92
|
+
* wrong is silent. The hosted renderer (`mergeSeoMeta` in the backend) falls back to a canonical
|
|
93
|
+
* it derives from the request's own origin and path — headless consumers have no request, so
|
|
94
|
+
* they must pass `location`. Without it this returns "" and the caller ships no
|
|
95
|
+
* `<link rel="canonical">` and no `og:url`, exactly as it did before `location` existed.
|
|
96
|
+
*/
|
|
97
|
+
declare function resolveSeo(page: SeoInput, defaults?: SiteSeoDefaults, location?: SeoLocation): ResolvedSeo;
|
|
98
|
+
|
|
99
|
+
export { type ResolvedSeo, type SeoInput, type SeoLocation, entrySeoInput, resolveSeo };
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { PageMetaJson, DeliveryEntrySeo, SiteSeoDefaults } from '@bettercms-ai/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* resolveSeo — framework-agnostic page-over-site SEO merge for headless consumers.
|
|
5
|
+
*
|
|
6
|
+
* The single source of truth for "per-page meta layered over project defaults",
|
|
7
|
+
* mirroring the server renderer's `mergeSeoMeta` so `*.bettercms.site` and headless
|
|
8
|
+
* sites (@bettercms-ai/next, @bettercms-ai/astro) emit identical <head> SEO. Pure: empty
|
|
9
|
+
* inputs collapse to empty strings ("omit the tag"), never fabricated values.
|
|
10
|
+
*
|
|
11
|
+
* const seo = resolveSeo(page, siteDefaults);
|
|
12
|
+
* // → { title, description, canonical, og, twitter, jsonLd }
|
|
13
|
+
*
|
|
14
|
+
* Next consumers map this to a `Metadata` via `buildMetadata` (@bettercms-ai/next);
|
|
15
|
+
* Astro consumers read the fields straight into `<head>`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal page shape `resolveSeo` reads — a `DeliveryPage`/`BetterCMSPage` satisfies it. */
|
|
19
|
+
interface SeoInput {
|
|
20
|
+
title: string;
|
|
21
|
+
metaTitle?: string | null;
|
|
22
|
+
metaDescription?: string | null;
|
|
23
|
+
metaJson?: PageMetaJson | null;
|
|
24
|
+
/** `"home"` makes the Automatic JSON-LD a WebSite, as the hosted renderer does. */
|
|
25
|
+
slug?: string | null;
|
|
26
|
+
/** Exclude from search engines. Surfaces as `ResolvedSeo.noindex` / `robots`. */
|
|
27
|
+
noindex?: boolean | null;
|
|
28
|
+
}
|
|
29
|
+
/** Fully-resolved SEO values, page-over-site. Empty string ⇒ omit that tag. */
|
|
30
|
+
interface ResolvedSeo {
|
|
31
|
+
title: string;
|
|
32
|
+
description: string;
|
|
33
|
+
/**
|
|
34
|
+
* Canonical URL — the page's own `canonical`, else `siteUrl` + `path` when both are given,
|
|
35
|
+
* else "". Empty means the consumer must omit the tag: a RELATIVE canonical is worse than
|
|
36
|
+
* none at all.
|
|
37
|
+
*/
|
|
38
|
+
canonical: string;
|
|
39
|
+
og: {
|
|
40
|
+
title: string;
|
|
41
|
+
description: string;
|
|
42
|
+
type: string;
|
|
43
|
+
image: string;
|
|
44
|
+
url: string;
|
|
45
|
+
};
|
|
46
|
+
twitter: {
|
|
47
|
+
card: string;
|
|
48
|
+
title: string;
|
|
49
|
+
description: string;
|
|
50
|
+
image: string;
|
|
51
|
+
site: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* JSON-LD nodes (site schema first, then the page's), already flattened + de-empted. The page's
|
|
55
|
+
* part follows the renderer's precedence: Custom (`metaJson.schema`) > `metaJson.schemaType` >
|
|
56
|
+
* Automatic (WebPage; WebSite on the home page).
|
|
57
|
+
*/
|
|
58
|
+
jsonLd: Array<Record<string, unknown>>;
|
|
59
|
+
/** True when the page is excluded from search engines (the renderer's `<meta name="robots" content="noindex">`). */
|
|
60
|
+
noindex: boolean;
|
|
61
|
+
/** `noindex` in the `{ index, follow }` shape Next's `Metadata.robots` takes. */
|
|
62
|
+
robots: {
|
|
63
|
+
index: boolean;
|
|
64
|
+
follow: boolean;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A Delivery API entry as a `resolveSeo` input. The server already resolved the collection
|
|
69
|
+
* template's `{{field}}` tokens for this entry and merged the entry's own SEO over it, so this is
|
|
70
|
+
* a reshape, not a resolver:
|
|
71
|
+
*
|
|
72
|
+
* const entry = await bcms.getEntry("astro");
|
|
73
|
+
* const seo = resolveSeo(entrySeoInput(entry), siteDefaults, { siteUrl, path: `/tags/${entry.slug}` });
|
|
74
|
+
* // seo.jsonLd holds the template's schema, resolved for this entry
|
|
75
|
+
*/
|
|
76
|
+
declare function entrySeoInput(entry: {
|
|
77
|
+
slug: string;
|
|
78
|
+
seo?: DeliveryEntrySeo | null;
|
|
79
|
+
}): SeoInput;
|
|
80
|
+
/** Where this page lives, so a canonical can be derived when the page sets none. */
|
|
81
|
+
interface SeoLocation {
|
|
82
|
+
/** Absolute site origin, e.g. `https://acme.com`. Trailing slash optional. */
|
|
83
|
+
siteUrl?: string;
|
|
84
|
+
/** This page's path, e.g. `/about` or `/`. */
|
|
85
|
+
path?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Merge a page's own SEO over the site defaults: page meta wins, then site defaults, then
|
|
89
|
+
* sensible fallbacks.
|
|
90
|
+
*
|
|
91
|
+
* The CANONICAL is the one field that cannot be resolved from content alone, and getting this
|
|
92
|
+
* wrong is silent. The hosted renderer (`mergeSeoMeta` in the backend) falls back to a canonical
|
|
93
|
+
* it derives from the request's own origin and path — headless consumers have no request, so
|
|
94
|
+
* they must pass `location`. Without it this returns "" and the caller ships no
|
|
95
|
+
* `<link rel="canonical">` and no `og:url`, exactly as it did before `location` existed.
|
|
96
|
+
*/
|
|
97
|
+
declare function resolveSeo(page: SeoInput, defaults?: SiteSeoDefaults, location?: SeoLocation): ResolvedSeo;
|
|
98
|
+
|
|
99
|
+
export { type ResolvedSeo, type SeoInput, type SeoLocation, entrySeoInput, resolveSeo };
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// src/seo.ts
|
|
2
|
+
function derivedCanonical({ siteUrl, path }) {
|
|
3
|
+
if (!siteUrl || !path) return "";
|
|
4
|
+
try {
|
|
5
|
+
return new URL(path, siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`).toString();
|
|
6
|
+
} catch {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function jsonLdNodes(schema) {
|
|
11
|
+
if (!schema) return [];
|
|
12
|
+
const arr = Array.isArray(schema) ? schema : [schema];
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const node of arr) {
|
|
15
|
+
if (!node || Object.keys(node).length === 0) continue;
|
|
16
|
+
const graph = node["@graph"];
|
|
17
|
+
if (Array.isArray(graph)) {
|
|
18
|
+
for (const child of graph) {
|
|
19
|
+
if (child && typeof child === "object" && !Array.isArray(child) && Object.keys(child).length > 0) {
|
|
20
|
+
out.push(child);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(node);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
var SCHEMA_TYPE_CHOICES = [
|
|
30
|
+
"WebPage",
|
|
31
|
+
"AboutPage",
|
|
32
|
+
"ContactPage",
|
|
33
|
+
"CollectionPage",
|
|
34
|
+
"Blog",
|
|
35
|
+
"BlogPosting",
|
|
36
|
+
"Article",
|
|
37
|
+
"Product",
|
|
38
|
+
"FAQPage",
|
|
39
|
+
"Event",
|
|
40
|
+
"Organization",
|
|
41
|
+
"LocalBusiness"
|
|
42
|
+
];
|
|
43
|
+
function compact(node) {
|
|
44
|
+
return Object.fromEntries(Object.entries(node).filter(([, value]) => value != null && value !== ""));
|
|
45
|
+
}
|
|
46
|
+
function schemaNode(type, { name, description, image, url }) {
|
|
47
|
+
switch (type) {
|
|
48
|
+
case "BlogPosting":
|
|
49
|
+
case "Article":
|
|
50
|
+
return compact({ "@type": type, headline: name, description, image, url, mainEntityOfPage: url });
|
|
51
|
+
case "Product":
|
|
52
|
+
case "Event":
|
|
53
|
+
return compact({ "@type": type, name, description, image, url });
|
|
54
|
+
case "FAQPage":
|
|
55
|
+
case "WebSite":
|
|
56
|
+
return compact({ "@type": type, name, description, url });
|
|
57
|
+
case "Organization":
|
|
58
|
+
return compact({ "@type": type, name, description, url, logo: image });
|
|
59
|
+
default:
|
|
60
|
+
return compact({ "@type": type, name, description, url, image });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function pageJsonLd(page, meta, canonical, siteSchema) {
|
|
64
|
+
const custom = jsonLdNodes(meta.schema);
|
|
65
|
+
if (custom.length > 0) return custom;
|
|
66
|
+
const props = {
|
|
67
|
+
name: (page.metaTitle || page.title || "").trim(),
|
|
68
|
+
description: (page.metaDescription || "").trim(),
|
|
69
|
+
image: (meta.og?.image || "").trim(),
|
|
70
|
+
url: canonical.trim()
|
|
71
|
+
};
|
|
72
|
+
const schemaType = meta.schemaType;
|
|
73
|
+
if (typeof schemaType === "string" && SCHEMA_TYPE_CHOICES.includes(schemaType)) {
|
|
74
|
+
return [schemaNode(schemaType, props)];
|
|
75
|
+
}
|
|
76
|
+
if (page.slug === "home") {
|
|
77
|
+
return jsonLdNodes(siteSchema).length > 0 ? [] : [schemaNode("WebSite", props)];
|
|
78
|
+
}
|
|
79
|
+
return [schemaNode("WebPage", props)];
|
|
80
|
+
}
|
|
81
|
+
function entrySeoInput(entry) {
|
|
82
|
+
const { metaTitle, metaDescription, noindex, ...metaJson } = entry.seo ?? {};
|
|
83
|
+
return {
|
|
84
|
+
title: metaTitle || entry.slug,
|
|
85
|
+
metaTitle: metaTitle ?? null,
|
|
86
|
+
metaDescription: metaDescription ?? null,
|
|
87
|
+
metaJson,
|
|
88
|
+
noindex: noindex === true
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function resolveSeo(page, defaults = {}, location = {}) {
|
|
92
|
+
const meta = page.metaJson ?? {};
|
|
93
|
+
const title = page.metaTitle || page.title || "";
|
|
94
|
+
const description = page.metaDescription || defaults.metaDescription || "";
|
|
95
|
+
const ogImage = meta.og?.image || defaults.og?.image || defaults.ogImage || "";
|
|
96
|
+
const ogTitle = meta.og?.title || title;
|
|
97
|
+
const ogDescription = meta.og?.description || description;
|
|
98
|
+
const twImage = meta.twitter?.image || ogImage;
|
|
99
|
+
const canonical = meta.canonical || derivedCanonical(location);
|
|
100
|
+
return {
|
|
101
|
+
title,
|
|
102
|
+
description,
|
|
103
|
+
canonical,
|
|
104
|
+
og: {
|
|
105
|
+
title: ogTitle,
|
|
106
|
+
description: ogDescription,
|
|
107
|
+
type: meta.og?.type || defaults.og?.type || "website",
|
|
108
|
+
image: ogImage,
|
|
109
|
+
url: canonical
|
|
110
|
+
},
|
|
111
|
+
twitter: {
|
|
112
|
+
card: meta.twitter?.card || defaults.twitter?.card || (twImage ? "summary_large_image" : "summary"),
|
|
113
|
+
title: meta.twitter?.title || ogTitle,
|
|
114
|
+
description: meta.twitter?.description || ogDescription,
|
|
115
|
+
image: twImage,
|
|
116
|
+
site: defaults.twitterHandle || ""
|
|
117
|
+
},
|
|
118
|
+
jsonLd: [...jsonLdNodes(defaults.siteSchema), ...pageJsonLd(page, meta, canonical, defaults.siteSchema)],
|
|
119
|
+
noindex: page.noindex === true,
|
|
120
|
+
robots: { index: page.noindex !== true, follow: true }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export {
|
|
124
|
+
entrySeoInput,
|
|
125
|
+
resolveSeo
|
|
126
|
+
};
|
|
127
|
+
//# sourceMappingURL=seo.js.map
|
package/dist/seo.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/seo.ts"],"sourcesContent":["/**\n * resolveSeo — framework-agnostic page-over-site SEO merge for headless consumers.\n *\n * The single source of truth for \"per-page meta layered over project defaults\",\n * mirroring the server renderer's `mergeSeoMeta` so `*.bettercms.site` and headless\n * sites (@bettercms-ai/next, @bettercms-ai/astro) emit identical <head> SEO. Pure: empty\n * inputs collapse to empty strings (\"omit the tag\"), never fabricated values.\n *\n * const seo = resolveSeo(page, siteDefaults);\n * // → { title, description, canonical, og, twitter, jsonLd }\n *\n * Next consumers map this to a `Metadata` via `buildMetadata` (@bettercms-ai/next);\n * Astro consumers read the fields straight into `<head>`.\n */\nimport type { DeliveryEntrySeo, PageMetaJson, SiteSeoDefaults } from \"@bettercms-ai/types\";\n\n/** Minimal page shape `resolveSeo` reads — a `DeliveryPage`/`BetterCMSPage` satisfies it. */\nexport interface SeoInput {\n title: string;\n metaTitle?: string | null;\n metaDescription?: string | null;\n metaJson?: PageMetaJson | null;\n /** `\"home\"` makes the Automatic JSON-LD a WebSite, as the hosted renderer does. */\n slug?: string | null;\n /** Exclude from search engines. Surfaces as `ResolvedSeo.noindex` / `robots`. */\n noindex?: boolean | null;\n}\n\n/** Fully-resolved SEO values, page-over-site. Empty string ⇒ omit that tag. */\nexport interface ResolvedSeo {\n title: string;\n description: string;\n /**\n * Canonical URL — the page's own `canonical`, else `siteUrl` + `path` when both are given,\n * else \"\". Empty means the consumer must omit the tag: a RELATIVE canonical is worse than\n * none at all.\n */\n canonical: string;\n og: {\n title: string;\n description: string;\n type: string;\n image: string;\n url: string;\n };\n twitter: {\n card: string;\n title: string;\n description: string;\n image: string;\n site: string;\n };\n /**\n * JSON-LD nodes (site schema first, then the page's), already flattened + de-empted. The page's\n * part follows the renderer's precedence: Custom (`metaJson.schema`) > `metaJson.schemaType` >\n * Automatic (WebPage; WebSite on the home page).\n */\n jsonLd: Array<Record<string, unknown>>;\n /** True when the page is excluded from search engines (the renderer's `<meta name=\"robots\" content=\"noindex\">`). */\n noindex: boolean;\n /** `noindex` in the `{ index, follow }` shape Next's `Metadata.robots` takes. */\n robots: { index: boolean; follow: boolean };\n}\n\n/**\n * `siteUrl` + `path` → an absolute canonical, or \"\" when either is missing or unparseable.\n * Never returns a relative URL: a relative canonical is worse than no canonical.\n */\nfunction derivedCanonical({ siteUrl, path }: SeoLocation): string {\n if (!siteUrl || !path) return \"\";\n try {\n return new URL(path, siteUrl.endsWith(\"/\") ? siteUrl : `${siteUrl}/`).toString();\n } catch {\n return \"\";\n }\n}\n\n/** Flatten one-or-many JSON-LD into a node array, dropping empty objects. */\nfunction jsonLdNodes(\n schema: Record<string, unknown> | Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n if (!schema) return [];\n const arr = Array.isArray(schema) ? schema : [schema];\n const out: Array<Record<string, unknown>> = [];\n for (const node of arr) {\n if (!node || Object.keys(node).length === 0) continue;\n const graph = node[\"@graph\"];\n if (Array.isArray(graph)) {\n for (const child of graph) {\n if (child && typeof child === \"object\" && !Array.isArray(child) && Object.keys(child).length > 0) {\n out.push(child as Record<string, unknown>);\n }\n }\n continue;\n }\n out.push(node);\n }\n return out;\n}\n\n/*\n * Structured data for a page whose `metaJson.schema` is empty. A port of the page path of\n * `resolveStructuredData` in the backend (src/lib/seo/structured-data.ts, called from\n * `pageStructuredData` in src/lib/sites/render-page.ts), with `site.include: false` as the\n * Delivery API calls it: the site-wide schema is emitted separately, from `siteSchema`.\n * A page has no field data, so only the properties the page's own meta supplies are filled.\n * Keep in step with that file; the parity test imports it.\n */\nconst SCHEMA_TYPE_CHOICES = [\n \"WebPage\",\n \"AboutPage\",\n \"ContactPage\",\n \"CollectionPage\",\n \"Blog\",\n \"BlogPosting\",\n \"Article\",\n \"Product\",\n \"FAQPage\",\n \"Event\",\n \"Organization\",\n \"LocalBusiness\",\n] as const;\n\ntype PageProps = { name: string; description: string; image: string; url: string };\n\n/** Drop \"\", null and undefined so a node never carries a blank. */\nfunction compact(node: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(node).filter(([, value]) => value != null && value !== \"\"));\n}\n\nfunction schemaNode(type: string, { name, description, image, url }: PageProps): Record<string, unknown> {\n switch (type) {\n case \"BlogPosting\":\n case \"Article\":\n return compact({ \"@type\": type, headline: name, description, image, url, mainEntityOfPage: url });\n case \"Product\":\n case \"Event\":\n return compact({ \"@type\": type, name, description, image, url });\n case \"FAQPage\":\n case \"WebSite\":\n return compact({ \"@type\": type, name, description, url });\n case \"Organization\":\n return compact({ \"@type\": type, name, description, url, logo: image });\n default:\n // WebPage, AboutPage, ContactPage, CollectionPage, Blog, LocalBusiness\n return compact({ \"@type\": type, name, description, url, image });\n }\n}\n\n/** The page's own JSON-LD nodes: Custom > explicit `schemaType` > Automatic. */\nfunction pageJsonLd(\n page: SeoInput,\n meta: PageMetaJson,\n canonical: string,\n siteSchema: SiteSeoDefaults[\"siteSchema\"],\n): Array<Record<string, unknown>> {\n const custom = jsonLdNodes(meta.schema);\n if (custom.length > 0) return custom;\n const props: PageProps = {\n name: (page.metaTitle || page.title || \"\").trim(),\n description: (page.metaDescription || \"\").trim(),\n image: (meta.og?.image || \"\").trim(),\n url: canonical.trim(),\n };\n const schemaType = (meta as { schemaType?: unknown }).schemaType;\n if (typeof schemaType === \"string\" && (SCHEMA_TYPE_CHOICES as readonly string[]).includes(schemaType)) {\n return [schemaNode(schemaType, props)];\n }\n if (page.slug === \"home\") {\n // The home page IS the WebSite, unless the site-wide schema already describes it.\n return jsonLdNodes(siteSchema).length > 0 ? [] : [schemaNode(\"WebSite\", props)];\n }\n return [schemaNode(\"WebPage\", props)];\n}\n\n/**\n * A Delivery API entry as a `resolveSeo` input. The server already resolved the collection\n * template's `{{field}}` tokens for this entry and merged the entry's own SEO over it, so this is\n * a reshape, not a resolver:\n *\n * const entry = await bcms.getEntry(\"astro\");\n * const seo = resolveSeo(entrySeoInput(entry), siteDefaults, { siteUrl, path: `/tags/${entry.slug}` });\n * // seo.jsonLd holds the template's schema, resolved for this entry\n */\nexport function entrySeoInput(entry: { slug: string; seo?: DeliveryEntrySeo | null }): SeoInput {\n const { metaTitle, metaDescription, noindex, ...metaJson } = entry.seo ?? {};\n return {\n title: metaTitle || entry.slug,\n metaTitle: metaTitle ?? null,\n metaDescription: metaDescription ?? null,\n metaJson,\n noindex: noindex === true,\n };\n}\n\n/** Where this page lives, so a canonical can be derived when the page sets none. */\nexport interface SeoLocation {\n /** Absolute site origin, e.g. `https://acme.com`. Trailing slash optional. */\n siteUrl?: string;\n /** This page's path, e.g. `/about` or `/`. */\n path?: string;\n}\n\n/**\n * Merge a page's own SEO over the site defaults: page meta wins, then site defaults, then\n * sensible fallbacks.\n *\n * The CANONICAL is the one field that cannot be resolved from content alone, and getting this\n * wrong is silent. The hosted renderer (`mergeSeoMeta` in the backend) falls back to a canonical\n * it derives from the request's own origin and path — headless consumers have no request, so\n * they must pass `location`. Without it this returns \"\" and the caller ships no\n * `<link rel=\"canonical\">` and no `og:url`, exactly as it did before `location` existed.\n */\nexport function resolveSeo(\n page: SeoInput,\n defaults: SiteSeoDefaults = {},\n location: SeoLocation = {},\n): ResolvedSeo {\n const meta = page.metaJson ?? {};\n const title = page.metaTitle || page.title || \"\";\n const description = page.metaDescription || defaults.metaDescription || \"\";\n const ogImage = meta.og?.image || defaults.og?.image || defaults.ogImage || \"\";\n const ogTitle = meta.og?.title || title;\n const ogDescription = meta.og?.description || description;\n const twImage = meta.twitter?.image || ogImage;\n const canonical = meta.canonical || derivedCanonical(location);\n return {\n title,\n description,\n canonical,\n og: {\n title: ogTitle,\n description: ogDescription,\n type: meta.og?.type || defaults.og?.type || \"website\",\n image: ogImage,\n url: canonical,\n },\n twitter: {\n card: meta.twitter?.card || defaults.twitter?.card || (twImage ? \"summary_large_image\" : \"summary\"),\n title: meta.twitter?.title || ogTitle,\n description: meta.twitter?.description || ogDescription,\n image: twImage,\n site: defaults.twitterHandle || \"\",\n },\n jsonLd: [...jsonLdNodes(defaults.siteSchema), ...pageJsonLd(page, meta, canonical, defaults.siteSchema)],\n noindex: page.noindex === true,\n robots: { index: page.noindex !== true, follow: true },\n };\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"import": "./dist/index.js",
|
|
17
17
|
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./draft": {
|
|
20
|
+
"types": "./dist/draft.d.ts",
|
|
21
|
+
"import": "./dist/draft.js",
|
|
22
|
+
"default": "./dist/draft.js"
|
|
23
|
+
},
|
|
24
|
+
"./config": {
|
|
25
|
+
"types": "./dist/config.d.ts",
|
|
26
|
+
"import": "./dist/config.js",
|
|
27
|
+
"default": "./dist/config.js"
|
|
28
|
+
},
|
|
29
|
+
"./seo": {
|
|
30
|
+
"types": "./dist/seo.d.ts",
|
|
31
|
+
"import": "./dist/seo.js",
|
|
32
|
+
"default": "./dist/seo.js"
|
|
18
33
|
}
|
|
19
34
|
},
|
|
20
35
|
"dependencies": {
|