@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.
Files changed (35) hide show
  1. package/dist/__tests__/api/ai-quality.test.d.ts +2 -0
  2. package/dist/__tests__/api/ai-quality.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/ai-quality.test.js +470 -0
  4. package/dist/__tests__/api/ai-quality.test.js.map +1 -0
  5. package/dist/__tests__/api/preview-and-scheduling.test.d.ts +2 -0
  6. package/dist/__tests__/api/preview-and-scheduling.test.d.ts.map +1 -0
  7. package/dist/__tests__/api/preview-and-scheduling.test.js +426 -0
  8. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -0
  9. package/dist/__tests__/preview.test.d.ts +2 -0
  10. package/dist/__tests__/preview.test.d.ts.map +1 -0
  11. package/dist/__tests__/preview.test.js +71 -0
  12. package/dist/__tests__/preview.test.js.map +1 -0
  13. package/dist/__tests__/seo/config-store.test.d.ts +2 -0
  14. package/dist/__tests__/seo/config-store.test.d.ts.map +1 -0
  15. package/dist/__tests__/seo/config-store.test.js +167 -0
  16. package/dist/__tests__/seo/config-store.test.js.map +1 -0
  17. package/dist/api/handler-factory.d.ts.map +1 -1
  18. package/dist/api/handler-factory.js +4 -0
  19. package/dist/api/handler-factory.js.map +1 -1
  20. package/dist/api/handlers.d.ts.map +1 -1
  21. package/dist/api/handlers.js +792 -38
  22. package/dist/api/handlers.js.map +1 -1
  23. package/dist/preview/index.d.ts +23 -1
  24. package/dist/preview/index.d.ts.map +1 -1
  25. package/dist/preview/index.js +30 -7
  26. package/dist/preview/index.js.map +1 -1
  27. package/dist/seo/config-store.d.ts +61 -0
  28. package/dist/seo/config-store.d.ts.map +1 -0
  29. package/dist/seo/config-store.js +158 -0
  30. package/dist/seo/config-store.js.map +1 -0
  31. package/dist/seo/index.d.ts +2 -0
  32. package/dist/seo/index.d.ts.map +1 -1
  33. package/dist/seo/index.js +1 -0
  34. package/dist/seo/index.js.map +1 -1
  35. package/package.json +1 -1
@@ -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;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,oBAAoB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAA;IACrF,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,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,cAAc,CA4ChF"}
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"}
@@ -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 expiresAt = new Date(Date.now() + 5 * 60 * 1000);
7
- const token = await new jose.SignJWT({ collection, documentId })
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
- .sign(secretKey);
13
- return { token, collection, documentId, expiresAt };
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;AAgB5B,MAAM,UAAU,oBAAoB,CAAC,MAAc,EAAE,EAAW;IAC9D,MAAM,SAAS,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAElD,OAAO;QACL,KAAK,CAAC,oBAAoB,CAAC,UAAU,EAAE,UAAU;YAC/C,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAA;YACtD,MAAM,KAAK,GAAG,MAAM,IAAI,IAAI,CAAC,OAAO,CAAC,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;iBAC7D,kBAAkB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC;iBACpC,WAAW,EAAE;iBACb,iBAAiB,CAAC,SAAS,CAAC;iBAC5B,SAAS,CAAC,qBAAqB,CAAC;iBAChC,IAAI,CAAC,SAAS,CAAC,CAAA;YAClB,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,CAAA;QACrD,CAAC;QAED,KAAK,CAAC,oBAAoB,CAAC,KAAK;YAC9B,IAAI,CAAC;gBACH,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE;oBACzD,MAAM,EAAE,qBAAqB;iBAC9B,CAAC,CAAA;gBACF,OAAO;oBACL,KAAK;oBACL,UAAU,EAAE,OAAO,CAAC,UAAoB;oBACxC,UAAU,EAAE,OAAO,CAAC,UAAoB;oBACxC,SAAS,EAAE,IAAI,IAAI,CAAC,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;iBAC/C,CAAA;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,IAAI,CAAA;YACb,CAAC;QACH,CAAC;QAED,KAAK,CAAC,WAAW,CAAC,MAAM;YACtB,qEAAqE;QACvE,CAAC;QAED,KAAK,CAAC,cAAc,CAAC,OAAO;YAC1B,MAAM,MAAM,GAAG,EAAS,CAAA;YACxB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC;gBAC1C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE;aAClE,CAAC,CAAA;YACF,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,EAAE,GAAG,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAA;QACnC,CAAC;KACF,CAAA;AACH,CAAC"}
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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"}
@@ -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
@@ -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
@@ -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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-core",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",