@actuate-media/cms-core 0.18.0 → 0.20.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/__tests__/api/ai-quality.test.d.ts +2 -0
- package/dist/__tests__/api/ai-quality.test.d.ts.map +1 -0
- package/dist/__tests__/api/ai-quality.test.js +470 -0
- package/dist/__tests__/api/ai-quality.test.js.map +1 -0
- package/dist/__tests__/api/preview-and-scheduling.test.d.ts +2 -0
- package/dist/__tests__/api/preview-and-scheduling.test.d.ts.map +1 -0
- package/dist/__tests__/api/preview-and-scheduling.test.js +426 -0
- package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -0
- package/dist/__tests__/preview.test.d.ts +2 -0
- package/dist/__tests__/preview.test.d.ts.map +1 -0
- package/dist/__tests__/preview.test.js +71 -0
- package/dist/__tests__/preview.test.js.map +1 -0
- package/dist/__tests__/seo/config-store.test.d.ts +2 -0
- package/dist/__tests__/seo/config-store.test.d.ts.map +1 -0
- package/dist/__tests__/seo/config-store.test.js +167 -0
- package/dist/__tests__/seo/config-store.test.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +4 -0
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +792 -38
- package/dist/api/handlers.js.map +1 -1
- package/dist/preview/index.d.ts +23 -1
- package/dist/preview/index.d.ts.map +1 -1
- package/dist/preview/index.js +30 -7
- package/dist/preview/index.js.map +1 -1
- package/dist/seo/config-store.d.ts +61 -0
- package/dist/seo/config-store.d.ts.map +1 -0
- package/dist/seo/config-store.js +158 -0
- package/dist/seo/config-store.js.map +1 -0
- package/dist/seo/index.d.ts +2 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +1 -0
- package/dist/seo/index.js.map +1 -1
- package/package.json +1 -1
package/dist/preview/index.d.ts
CHANGED
|
@@ -2,13 +2,35 @@ export interface PreviewSession {
|
|
|
2
2
|
token: string;
|
|
3
3
|
collection: string;
|
|
4
4
|
documentId: string;
|
|
5
|
+
/** When the token expires (server clock). */
|
|
5
6
|
expiresAt: Date;
|
|
7
|
+
/** The admin who issued the token (used for audit + future revocation). */
|
|
8
|
+
issuedBy?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CreatePreviewSessionOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Token lifetime in seconds. Defaults to 7 days. The maximum is
|
|
13
|
+
* enforced server-side ({@link MAX_PREVIEW_TOKEN_TTL_SECONDS}). A short
|
|
14
|
+
* TTL is preferable for one-off client reviews; longer TTLs are okay
|
|
15
|
+
* for staging-style preview links shared with trusted partners.
|
|
16
|
+
*/
|
|
17
|
+
ttlSeconds?: number;
|
|
18
|
+
/** Issuing admin's user id (recorded in the JWT for audit). */
|
|
19
|
+
issuedBy?: string;
|
|
6
20
|
}
|
|
7
21
|
export interface PreviewAdapter {
|
|
8
|
-
createPreviewSession(collection: string, documentId: string): Promise<PreviewSession>;
|
|
22
|
+
createPreviewSession(collection: string, documentId: string, options?: CreatePreviewSessionOptions): Promise<PreviewSession>;
|
|
9
23
|
validatePreviewToken(token: string): Promise<PreviewSession | null>;
|
|
10
24
|
exitPreview(token: string): Promise<void>;
|
|
11
25
|
getPreviewData(session: PreviewSession): Promise<Record<string, unknown> | null>;
|
|
12
26
|
}
|
|
27
|
+
/** 5 minutes — fits "one tab, one read" review flows. */
|
|
28
|
+
export declare const DEFAULT_PREVIEW_TOKEN_TTL_SECONDS: number;
|
|
29
|
+
/**
|
|
30
|
+
* Upper bound for token lifetime. Tokens are JWT-signed and stateless, so a
|
|
31
|
+
* leaked token is valid until expiry — keep the ceiling reasonable. Sites
|
|
32
|
+
* that need shareable URLs for months should use draft-publishing instead.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_PREVIEW_TOKEN_TTL_SECONDS: number;
|
|
13
35
|
export declare function createPreviewAdapter(secret: string, db: unknown): PreviewAdapter;
|
|
14
36
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/preview/index.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,IAAI,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/preview/index.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,6CAA6C;IAC7C,SAAS,EAAE,IAAI,CAAA;IACf,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,2BAA2B;IAC1C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,oBAAoB,CAClB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,2BAA2B,GACpC,OAAO,CAAC,cAAc,CAAC,CAAA;IAC1B,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAA;IACnE,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzC,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAAA;CACjF;AAED,yDAAyD;AACzD,eAAO,MAAM,iCAAiC,QAAmB,CAAA;AAEjE;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAoB,CAAA;AAE9D,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,cAAc,CA2DhF"}
|
package/dist/preview/index.js
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
|
+
/** 5 minutes — fits "one tab, one read" review flows. */
|
|
3
|
+
export const DEFAULT_PREVIEW_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
4
|
+
/**
|
|
5
|
+
* Upper bound for token lifetime. Tokens are JWT-signed and stateless, so a
|
|
6
|
+
* leaked token is valid until expiry — keep the ceiling reasonable. Sites
|
|
7
|
+
* that need shareable URLs for months should use draft-publishing instead.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_PREVIEW_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
2
10
|
export function createPreviewAdapter(secret, db) {
|
|
3
11
|
const secretKey = new TextEncoder().encode(secret);
|
|
4
12
|
return {
|
|
5
|
-
async createPreviewSession(collection, documentId) {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
13
|
+
async createPreviewSession(collection, documentId, options) {
|
|
14
|
+
const requested = options?.ttlSeconds ?? DEFAULT_PREVIEW_TOKEN_TTL_SECONDS;
|
|
15
|
+
const ttl = Math.max(60, Math.min(requested, MAX_PREVIEW_TOKEN_TTL_SECONDS));
|
|
16
|
+
const expiresAt = new Date(Date.now() + ttl * 1000);
|
|
17
|
+
let builder = new jose.SignJWT({ collection, documentId })
|
|
8
18
|
.setProtectedHeader({ alg: 'HS256' })
|
|
9
19
|
.setIssuedAt()
|
|
10
20
|
.setExpirationTime(expiresAt)
|
|
11
|
-
.setIssuer('actuate-cms-preview')
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
.setIssuer('actuate-cms-preview');
|
|
22
|
+
if (options?.issuedBy) {
|
|
23
|
+
builder = builder.setSubject(options.issuedBy);
|
|
24
|
+
}
|
|
25
|
+
const token = await builder.sign(secretKey);
|
|
26
|
+
return {
|
|
27
|
+
token,
|
|
28
|
+
collection,
|
|
29
|
+
documentId,
|
|
30
|
+
expiresAt,
|
|
31
|
+
issuedBy: options?.issuedBy,
|
|
32
|
+
};
|
|
14
33
|
},
|
|
15
34
|
async validatePreviewToken(token) {
|
|
16
35
|
try {
|
|
@@ -22,6 +41,7 @@ export function createPreviewAdapter(secret, db) {
|
|
|
22
41
|
collection: payload.collection,
|
|
23
42
|
documentId: payload.documentId,
|
|
24
43
|
expiresAt: new Date((payload.exp ?? 0) * 1000),
|
|
44
|
+
issuedBy: typeof payload.sub === 'string' ? payload.sub : undefined,
|
|
25
45
|
};
|
|
26
46
|
}
|
|
27
47
|
catch {
|
|
@@ -29,7 +49,10 @@ export function createPreviewAdapter(secret, db) {
|
|
|
29
49
|
}
|
|
30
50
|
},
|
|
31
51
|
async exitPreview(_token) {
|
|
32
|
-
// JWT-based tokens are stateless; no server-side invalidation needed
|
|
52
|
+
// JWT-based tokens are stateless; no server-side invalidation needed.
|
|
53
|
+
// For revocation, rotate the global CMS_SECRET (invalidates all tokens
|
|
54
|
+
// at once). Per-token revocation requires a backing table — track via
|
|
55
|
+
// future Slice if needed.
|
|
33
56
|
},
|
|
34
57
|
async getPreviewData(session) {
|
|
35
58
|
const prisma = db;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/preview/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/preview/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editable SEO config — DB-backed overrides for the static `actuate.config.ts`
|
|
3
|
+
* `seo` block + per-collection `CollectionSEOConfig`.
|
|
4
|
+
*
|
|
5
|
+
* Static config in `actuate.config.ts` is the source of truth at code level,
|
|
6
|
+
* but admins need to be able to change site name, default OG image, robots
|
|
7
|
+
* policy, default Schema.org types per collection, etc. without redeploying.
|
|
8
|
+
*
|
|
9
|
+
* Storage: a single Document row with `collection = '__seo_config__'` and
|
|
10
|
+
* `data.{site, collections}`. The leading double underscore makes the slug
|
|
11
|
+
* un-spellable as a normal collection (slugs are lowercased + dashed in the
|
|
12
|
+
* config validator), so it can never collide with a user collection.
|
|
13
|
+
*
|
|
14
|
+
* Merge semantics: DB values take precedence over static config, field by
|
|
15
|
+
* field. Removing a value in the UI (setting it to undefined / empty string)
|
|
16
|
+
* falls back to the static default — so the admin panel is purely additive,
|
|
17
|
+
* never destructive of code-level config.
|
|
18
|
+
*/
|
|
19
|
+
import type { ActuateCMSConfig, CollectionSEOConfig, SiteSEOConfig } from '../config/types.js';
|
|
20
|
+
export declare const SEO_CONFIG_COLLECTION = "__seo_config__";
|
|
21
|
+
/**
|
|
22
|
+
* Shape of the overrides document stored in the DB. Every field is optional —
|
|
23
|
+
* a freshly-installed CMS has no row at all and the UI lazily creates one on
|
|
24
|
+
* first save.
|
|
25
|
+
*/
|
|
26
|
+
export interface SeoConfigOverrides {
|
|
27
|
+
/** Site-wide overrides. Merged shallow-then-deep over static `config.seo`. */
|
|
28
|
+
site?: Partial<SiteSEOConfig>;
|
|
29
|
+
/** Per-collection overrides keyed by collection slug. */
|
|
30
|
+
collections?: Record<string, Partial<CollectionSEOConfig>>;
|
|
31
|
+
/** Last update timestamp (set by the writer). */
|
|
32
|
+
updatedAt?: string;
|
|
33
|
+
/** User id of the last editor (set by the writer). */
|
|
34
|
+
updatedBy?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Read the SEO overrides document. Returns null when no row exists yet. */
|
|
37
|
+
export declare function getSeoOverrides(db: unknown): Promise<SeoConfigOverrides | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Upsert the SEO overrides document. The caller is responsible for auth/role
|
|
40
|
+
* checks — this function only handles storage.
|
|
41
|
+
*/
|
|
42
|
+
export declare function putSeoOverrides(db: unknown, overrides: SeoConfigOverrides, userId: string): Promise<SeoConfigOverrides>;
|
|
43
|
+
/**
|
|
44
|
+
* Merge static site-wide SEO config with the DB overrides. Nested objects
|
|
45
|
+
* (`robots`, `sitemap`, `ogImage`, `organization`) merge field-by-field;
|
|
46
|
+
* primitive values use override-wins-if-set.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mergeSiteSeoConfig(base: SiteSEOConfig | undefined, over: Partial<SiteSEOConfig> | undefined): SiteSEOConfig;
|
|
49
|
+
/** Same merge semantics as the site-wide version, applied to a single collection. */
|
|
50
|
+
export declare function mergeCollectionSeoConfig(base: CollectionSEOConfig | undefined, over: Partial<CollectionSEOConfig> | undefined): CollectionSEOConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Return a new config object with DB-stored SEO overrides applied. The
|
|
53
|
+
* static config is never mutated — callers receive a shallow copy with
|
|
54
|
+
* `seo` and each collection's `seo` block replaced by the merged values.
|
|
55
|
+
*
|
|
56
|
+
* Routes that compose SEO output call this once per request, then thread
|
|
57
|
+
* the result through to `composePageMeta` / sitemap helpers exactly like
|
|
58
|
+
* the static config they replaced.
|
|
59
|
+
*/
|
|
60
|
+
export declare function applySeoOverrides(config: ActuateCMSConfig | null | undefined, overrides: SeoConfigOverrides | null): ActuateCMSConfig | null;
|
|
61
|
+
//# sourceMappingURL=config-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-store.d.ts","sourceRoot":"","sources":["../../src/seo/config-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EACV,gBAAgB,EAEhB,mBAAmB,EACnB,aAAa,EACd,MAAM,oBAAoB,CAAA;AAE3B,eAAO,MAAM,qBAAqB,mBAAmB,CAAA;AAErD;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;IAC7B,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAA;IAC1D,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,4EAA4E;AAC5E,wBAAsB,eAAe,CAAC,EAAE,EAAE,OAAO,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAerF;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,OAAO,EACX,SAAS,EAAE,kBAAkB,EAC7B,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,kBAAkB,CAAC,CA8B7B;AAwBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,GAAG,SAAS,EAC/B,IAAI,EAAE,OAAO,CAAC,aAAa,CAAC,GAAG,SAAS,GACvC,aAAa,CA6Bf;AAED,qFAAqF;AACrF,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,mBAAmB,GAAG,SAAS,EACrC,IAAI,EAAE,OAAO,CAAC,mBAAmB,CAAC,GAAG,SAAS,GAC7C,mBAAmB,CAiBrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAC3C,SAAS,EAAE,kBAAkB,GAAG,IAAI,GACnC,gBAAgB,GAAG,IAAI,CAkBzB"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editable SEO config — DB-backed overrides for the static `actuate.config.ts`
|
|
3
|
+
* `seo` block + per-collection `CollectionSEOConfig`.
|
|
4
|
+
*
|
|
5
|
+
* Static config in `actuate.config.ts` is the source of truth at code level,
|
|
6
|
+
* but admins need to be able to change site name, default OG image, robots
|
|
7
|
+
* policy, default Schema.org types per collection, etc. without redeploying.
|
|
8
|
+
*
|
|
9
|
+
* Storage: a single Document row with `collection = '__seo_config__'` and
|
|
10
|
+
* `data.{site, collections}`. The leading double underscore makes the slug
|
|
11
|
+
* un-spellable as a normal collection (slugs are lowercased + dashed in the
|
|
12
|
+
* config validator), so it can never collide with a user collection.
|
|
13
|
+
*
|
|
14
|
+
* Merge semantics: DB values take precedence over static config, field by
|
|
15
|
+
* field. Removing a value in the UI (setting it to undefined / empty string)
|
|
16
|
+
* falls back to the static default — so the admin panel is purely additive,
|
|
17
|
+
* never destructive of code-level config.
|
|
18
|
+
*/
|
|
19
|
+
export const SEO_CONFIG_COLLECTION = '__seo_config__';
|
|
20
|
+
/** Read the SEO overrides document. Returns null when no row exists yet. */
|
|
21
|
+
export async function getSeoOverrides(db) {
|
|
22
|
+
const d = db;
|
|
23
|
+
if (!d?.document?.findFirst)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const doc = await d.document.findFirst({
|
|
27
|
+
where: { collection: SEO_CONFIG_COLLECTION, deletedAt: null },
|
|
28
|
+
select: { data: true },
|
|
29
|
+
});
|
|
30
|
+
if (!doc)
|
|
31
|
+
return null;
|
|
32
|
+
const data = doc.data;
|
|
33
|
+
if (!data || typeof data !== 'object')
|
|
34
|
+
return null;
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Upsert the SEO overrides document. The caller is responsible for auth/role
|
|
43
|
+
* checks — this function only handles storage.
|
|
44
|
+
*/
|
|
45
|
+
export async function putSeoOverrides(db, overrides, userId) {
|
|
46
|
+
const d = db;
|
|
47
|
+
const data = {
|
|
48
|
+
...overrides,
|
|
49
|
+
updatedAt: new Date().toISOString(),
|
|
50
|
+
updatedBy: userId,
|
|
51
|
+
};
|
|
52
|
+
const existing = await d.document.findFirst({
|
|
53
|
+
where: { collection: SEO_CONFIG_COLLECTION, deletedAt: null },
|
|
54
|
+
select: { id: true },
|
|
55
|
+
});
|
|
56
|
+
if (existing) {
|
|
57
|
+
await d.document.update({
|
|
58
|
+
where: { id: existing.id },
|
|
59
|
+
data: { data, updatedById: userId },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
await d.document.create({
|
|
64
|
+
data: {
|
|
65
|
+
collection: SEO_CONFIG_COLLECTION,
|
|
66
|
+
data,
|
|
67
|
+
status: 'PUBLISHED',
|
|
68
|
+
title: 'SEO defaults',
|
|
69
|
+
slug: 'seo-config',
|
|
70
|
+
createdById: userId,
|
|
71
|
+
updatedById: userId,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Shallow-merge that drops undefined / empty-string values on the override
|
|
79
|
+
* side so the UI can "unset" a field by submitting an empty string.
|
|
80
|
+
*/
|
|
81
|
+
function mergeFields(base, over) {
|
|
82
|
+
const out = { ...(base ?? {}) };
|
|
83
|
+
if (!over)
|
|
84
|
+
return out;
|
|
85
|
+
for (const [k, v] of Object.entries(over)) {
|
|
86
|
+
if (v === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
if (typeof v === 'string' && v.trim() === '')
|
|
89
|
+
continue;
|
|
90
|
+
if (v === null) {
|
|
91
|
+
delete out[k];
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
out[k] = v;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Merge static site-wide SEO config with the DB overrides. Nested objects
|
|
100
|
+
* (`robots`, `sitemap`, `ogImage`, `organization`) merge field-by-field;
|
|
101
|
+
* primitive values use override-wins-if-set.
|
|
102
|
+
*/
|
|
103
|
+
export function mergeSiteSeoConfig(base, over) {
|
|
104
|
+
if (!over)
|
|
105
|
+
return { ...(base ?? {}) };
|
|
106
|
+
// The SEO config interfaces are declared with explicit named fields and no
|
|
107
|
+
// index signature, so we cast through Record<string, unknown> when calling
|
|
108
|
+
// the generic merge helper. Behaviour is unchanged; this is purely a
|
|
109
|
+
// structural-typing escape hatch.
|
|
110
|
+
const merged = mergeFields(base, over);
|
|
111
|
+
return {
|
|
112
|
+
...merged,
|
|
113
|
+
robots: mergeFields(base?.robots, over.robots),
|
|
114
|
+
sitemap: mergeFields(base?.sitemap, over.sitemap),
|
|
115
|
+
ogImage: mergeFields(base?.ogImage, over.ogImage),
|
|
116
|
+
organization: mergeFields(base?.organization, over.organization),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Same merge semantics as the site-wide version, applied to a single collection. */
|
|
120
|
+
export function mergeCollectionSeoConfig(base, over) {
|
|
121
|
+
if (!over)
|
|
122
|
+
return { ...(base ?? {}) };
|
|
123
|
+
const merged = mergeFields(base, over);
|
|
124
|
+
return {
|
|
125
|
+
...merged,
|
|
126
|
+
defaultRobots: mergeFields(base?.defaultRobots, over.defaultRobots),
|
|
127
|
+
defaultOgImage: mergeFields(base?.defaultOgImage, over.defaultOgImage),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Return a new config object with DB-stored SEO overrides applied. The
|
|
132
|
+
* static config is never mutated — callers receive a shallow copy with
|
|
133
|
+
* `seo` and each collection's `seo` block replaced by the merged values.
|
|
134
|
+
*
|
|
135
|
+
* Routes that compose SEO output call this once per request, then thread
|
|
136
|
+
* the result through to `composePageMeta` / sitemap helpers exactly like
|
|
137
|
+
* the static config they replaced.
|
|
138
|
+
*/
|
|
139
|
+
export function applySeoOverrides(config, overrides) {
|
|
140
|
+
if (!config)
|
|
141
|
+
return null;
|
|
142
|
+
if (!overrides)
|
|
143
|
+
return config;
|
|
144
|
+
const mergedSite = mergeSiteSeoConfig(config.seo, overrides.site);
|
|
145
|
+
const mergedCollections = {};
|
|
146
|
+
for (const [slug, col] of Object.entries(config.collections ?? {})) {
|
|
147
|
+
const colOver = overrides.collections?.[slug];
|
|
148
|
+
mergedCollections[slug] = colOver
|
|
149
|
+
? { ...col, seo: mergeCollectionSeoConfig(col.seo, colOver) }
|
|
150
|
+
: col;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
...config,
|
|
154
|
+
seo: mergedSite,
|
|
155
|
+
collections: mergedCollections,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=config-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-store.js","sourceRoot":"","sources":["../../src/seo/config-store.ts"],"names":[],"mappings":"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"}
|
package/dist/seo/index.d.ts
CHANGED
|
@@ -10,4 +10,6 @@ export { generateLlmsTxt } from './llms-txt.js';
|
|
|
10
10
|
export type { LlmsTxtConfig, LlmsTxtPage } from './llms-txt.js';
|
|
11
11
|
export { composePageMeta, detectSchemaType } from './page-meta.js';
|
|
12
12
|
export type { ComposePageMetaInput, ComposedPageMeta, ComposeDocumentInput } from './page-meta.js';
|
|
13
|
+
export { SEO_CONFIG_COLLECTION, getSeoOverrides, putSeoOverrides, applySeoOverrides, mergeSiteSeoConfig, mergeCollectionSeoConfig, } from './config-store.js';
|
|
14
|
+
export type { SeoConfigOverrides } from './config-store.js';
|
|
13
15
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/seo/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/seo/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,eAAe,GAChB,MAAM,eAAe,CAAA;AAEtB,YAAY,EAAE,iBAAiB,EAAE,QAAQ,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAElG,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAE3F,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAElD,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAErD,YAAY,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAE1F,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAErE,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAE9E,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE/C,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE/D,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAElE,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/seo/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,eAAe,GAChB,MAAM,eAAe,CAAA;AAEtB,YAAY,EAAE,iBAAiB,EAAE,QAAQ,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAElG,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAE3F,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAElD,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAErD,YAAY,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAE1F,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAErE,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAE9E,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE/C,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE/D,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAElE,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAElG,OAAO,EACL,qBAAqB,EACrB,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,wBAAwB,GACzB,MAAM,mBAAmB,CAAA;AAE1B,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA"}
|
package/dist/seo/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { resolveRobotsDirectives } from './robots.js';
|
|
|
4
4
|
export { resolveTitle, getDocumentTitle } from './title-templates.js';
|
|
5
5
|
export { generateLlmsTxt } from './llms-txt.js';
|
|
6
6
|
export { composePageMeta, detectSchemaType } from './page-meta.js';
|
|
7
|
+
export { SEO_CONFIG_COLLECTION, getSeoOverrides, putSeoOverrides, applySeoOverrides, mergeSiteSeoConfig, mergeCollectionSeoConfig, } from './config-store.js';
|
|
7
8
|
//# sourceMappingURL=index.js.map
|
package/dist/seo/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/seo/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,eAAe,GAChB,MAAM,eAAe,CAAA;AAItB,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAI3F,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAIrD,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAIrE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAI/C,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/seo/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,eAAe,GAChB,MAAM,eAAe,CAAA;AAItB,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAI3F,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAIrD,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAIrE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAI/C,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAIlE,OAAO,EACL,qBAAqB,EACrB,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,wBAAwB,GACzB,MAAM,mBAAmB,CAAA"}
|