@growth-labs/cms 0.6.6 → 0.6.8

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 (112) hide show
  1. package/README.md +20 -0
  2. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  3. package/dist/engine/foundry-dispatch.js +36 -7
  4. package/dist/engine/foundry-dispatch.js.map +1 -1
  5. package/dist/engine/fronts-publish-intent.d.ts +15 -4
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +52 -19
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/fronts-publish.d.ts +19 -0
  10. package/dist/engine/fronts-publish.d.ts.map +1 -1
  11. package/dist/engine/fronts-publish.js +158 -5
  12. package/dist/engine/fronts-publish.js.map +1 -1
  13. package/dist/engine/index.d.ts +1 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +1 -0
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/engine/podcast-source.d.ts +28 -0
  18. package/dist/engine/podcast-source.d.ts.map +1 -0
  19. package/dist/engine/podcast-source.js +50 -0
  20. package/dist/engine/podcast-source.js.map +1 -0
  21. package/dist/engine/publication-current-guard.d.ts +10 -0
  22. package/dist/engine/publication-current-guard.d.ts.map +1 -0
  23. package/dist/engine/publication-current-guard.js +76 -0
  24. package/dist/engine/publication-current-guard.js.map +1 -0
  25. package/dist/engine/publication.d.ts +4 -0
  26. package/dist/engine/publication.d.ts.map +1 -1
  27. package/dist/engine/publication.js +13 -0
  28. package/dist/engine/publication.js.map +1 -1
  29. package/dist/engine/publisher.d.ts.map +1 -1
  30. package/dist/engine/publisher.js +20 -13
  31. package/dist/engine/publisher.js.map +1 -1
  32. package/dist/engine/soft-delete.js +2 -2
  33. package/dist/engine/soft-delete.js.map +1 -1
  34. package/dist/integration/options.d.ts +117 -0
  35. package/dist/integration/options.d.ts.map +1 -1
  36. package/dist/integration/options.js +19 -0
  37. package/dist/integration/options.js.map +1 -1
  38. package/dist/providers/types.d.ts +9 -3
  39. package/dist/providers/types.d.ts.map +1 -1
  40. package/dist/providers/types.js.map +1 -1
  41. package/dist/routes/config.d.ts +4 -0
  42. package/dist/routes/config.d.ts.map +1 -1
  43. package/dist/routes/config.js +1 -0
  44. package/dist/routes/config.js.map +1 -1
  45. package/dist/routes/content.d.ts +2 -0
  46. package/dist/routes/content.d.ts.map +1 -1
  47. package/dist/routes/content.js +8 -6
  48. package/dist/routes/content.js.map +1 -1
  49. package/dist/routes/fronts-publish.d.ts +2 -2
  50. package/dist/routes/fronts-publish.d.ts.map +1 -1
  51. package/dist/routes/fronts-publish.js +52 -23
  52. package/dist/routes/fronts-publish.js.map +1 -1
  53. package/dist/routes/index.js +1 -1
  54. package/dist/routes/index.js.map +1 -1
  55. package/dist/schema/layout.d.ts +2 -2
  56. package/dist/ui/api/_content-config.d.ts.map +1 -1
  57. package/dist/ui/api/_content-config.js +7 -0
  58. package/dist/ui/api/_content-config.js.map +1 -1
  59. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
  60. package/dist/ui/api/fronts/publish-callback.js +2 -6
  61. package/dist/ui/api/fronts/publish-callback.js.map +1 -1
  62. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  63. package/dist/ui/api/fronts/schedule.js +2 -6
  64. package/dist/ui/api/fronts/schedule.js.map +1 -1
  65. package/dist/ui/client/mount.d.ts.map +1 -1
  66. package/dist/ui/client/mount.js +1 -0
  67. package/dist/ui/client/mount.js.map +1 -1
  68. package/dist/ui/editor/ContentForm.d.ts +3 -2
  69. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  70. package/dist/ui/editor/ContentForm.js +18 -4
  71. package/dist/ui/editor/ContentForm.js.map +1 -1
  72. package/dist/ui/editor/content-payload.d.ts +4 -1
  73. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  74. package/dist/ui/editor/content-payload.js +9 -1
  75. package/dist/ui/editor/content-payload.js.map +1 -1
  76. package/dist/ui/screens/ContentRoute.d.ts +3 -2
  77. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  78. package/dist/ui/screens/ContentRoute.js +2 -2
  79. package/dist/ui/screens/ContentRoute.js.map +1 -1
  80. package/dist/ui/screens/EditorScreen.d.ts +3 -2
  81. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  82. package/dist/ui/screens/EditorScreen.js +2 -2
  83. package/dist/ui/screens/EditorScreen.js.map +1 -1
  84. package/dist/ui/screens/registry.d.ts +3 -2
  85. package/dist/ui/screens/registry.d.ts.map +1 -1
  86. package/dist/ui/screens/registry.js +2 -2
  87. package/dist/ui/screens/registry.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/engine/foundry-dispatch.ts +67 -12
  90. package/src/engine/fronts-publish-intent.ts +78 -26
  91. package/src/engine/fronts-publish.ts +223 -5
  92. package/src/engine/index.ts +4 -0
  93. package/src/engine/podcast-source.ts +82 -0
  94. package/src/engine/publication-current-guard.ts +87 -0
  95. package/src/engine/publication.ts +23 -0
  96. package/src/engine/publisher.ts +21 -13
  97. package/src/engine/soft-delete.ts +2 -2
  98. package/src/integration/options.ts +23 -0
  99. package/src/providers/types.ts +6 -1
  100. package/src/routes/config.ts +5 -0
  101. package/src/routes/content.ts +11 -6
  102. package/src/routes/fronts-publish.ts +63 -25
  103. package/src/routes/index.ts +1 -1
  104. package/src/ui/api/_content-config.ts +11 -0
  105. package/src/ui/api/fronts/publish-callback.ts +2 -8
  106. package/src/ui/api/fronts/schedule.ts +2 -8
  107. package/src/ui/client/mount.tsx +1 -0
  108. package/src/ui/editor/ContentForm.tsx +45 -3
  109. package/src/ui/editor/content-payload.ts +8 -0
  110. package/src/ui/screens/ContentRoute.tsx +4 -1
  111. package/src/ui/screens/EditorScreen.tsx +4 -1
  112. package/src/ui/screens/registry.tsx +4 -1
@@ -6,6 +6,7 @@
6
6
  import type { OgImageRenderer } from '../engine/og-render.js'
7
7
  import { nullProviders } from '../providers/null.js'
8
8
  import type { CmsProviders, FoundryDispatch, LlmDispatch } from '../providers/types.js'
9
+ import type { ContentType } from '../schema/types.js'
9
10
  import type { Authz } from './context.js'
10
11
 
11
12
  /**
@@ -35,6 +36,8 @@ export interface CmsRouteConfig {
35
36
  primaryTopicSlugs?: readonly [string, ...string[]]
36
37
  /** Default primary topic for create (Fronts: 'global'). */
37
38
  defaultPrimaryTopic?: string
39
+ /** Content types for which `primaryTopic` is required on create. */
40
+ requiredPrimaryTopicContentTypes?: readonly ContentType[]
38
41
 
39
42
  /** R2 binding name recorded on media rows. Fronts default: 'PUBLIC_MEDIA'. */
40
43
  mediaR2Binding?: string
@@ -66,6 +69,7 @@ export interface ResolvedCmsRouteConfig {
66
69
  defaultPrimaryCategory: string
67
70
  primaryTopicSlugs: readonly string[] | null
68
71
  defaultPrimaryTopic: string
72
+ requiredPrimaryTopicContentTypes: readonly ContentType[]
69
73
  mediaR2Binding: string
70
74
  mediaSiteId: string
71
75
  mediaPublicDomain: string
@@ -88,6 +92,7 @@ export function resolveConfig(config: CmsRouteConfig): ResolvedCmsRouteConfig {
88
92
  defaultPrimaryCategory: config.defaultPrimaryCategory ?? 'analysis',
89
93
  primaryTopicSlugs: config.primaryTopicSlugs ?? null,
90
94
  defaultPrimaryTopic: config.defaultPrimaryTopic ?? 'global',
95
+ requiredPrimaryTopicContentTypes: config.requiredPrimaryTopicContentTypes ?? [],
91
96
  mediaR2Binding: config.mediaR2Binding ?? 'PUBLIC_MEDIA',
92
97
  mediaSiteId: config.mediaSiteId ?? 'fronts',
93
98
  mediaPublicDomain: config.mediaPublicDomain ?? 'media.fronts.co',
@@ -197,6 +197,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
197
197
  dispatchFaq?: DispatchFaqHook
198
198
  /** Optional host-specific readiness guard. A not-ready result blocks publish. */
199
199
  publishReadiness?: PublishReadinessHook
200
+ /** Host's existing production rollback control; omitted means enabled. */
201
+ podcastSourceProductionEnabled?: boolean
200
202
  /** Internal machine-route constraint: atomically update only in this status. */
201
203
  updateStatusConstraint?: ContentStatus
202
204
  /**
@@ -390,13 +392,16 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
390
392
  tags: z.array(z.string()).optional(),
391
393
  metadata: ContentMetadataSchema.optional(),
392
394
  })
395
+ const requiredTopicTypes = new Set(resolved.requiredPrimaryTopicContentTypes)
396
+ const baseSchemaFor = (type: ContentType) =>
397
+ requiredTopicTypes.has(type) ? BaseSchema.extend({ primaryTopic: topic }) : BaseSchema
393
398
 
394
399
  const CreateSchema = z.discriminatedUnion('type', [
395
- BaseSchema.extend({
400
+ baseSchemaFor('article').extend({
396
401
  type: z.literal('article'),
397
402
  content: BodyContentCreateSchema,
398
403
  }),
399
- BaseSchema.extend({
404
+ baseSchemaFor('video').extend({
400
405
  type: z.literal('video'),
401
406
  content: z.object({
402
407
  script: z.string().default(''),
@@ -407,7 +412,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
407
412
  processingSourceKind: z.string().min(1).optional().nullable(),
408
413
  }),
409
414
  }),
410
- BaseSchema.extend({
415
+ baseSchemaFor('podcast').extend({
411
416
  type: z.literal('podcast'),
412
417
  content: z.object({
413
418
  // Empty allowed: a podcast is created as a draft with audio uploaded
@@ -427,11 +432,11 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
427
432
  processingSourceKind: z.string().min(1).optional().nullable(),
428
433
  }),
429
434
  }),
430
- BaseSchema.extend({
435
+ baseSchemaFor('newsletter').extend({
431
436
  type: z.literal('newsletter'),
432
437
  content: BodyContentCreateSchema,
433
438
  }),
434
- BaseSchema.extend({
439
+ baseSchemaFor('page').extend({
435
440
  type: z.literal('page'),
436
441
  content: BodyContentCreateSchema,
437
442
  }),
@@ -1890,7 +1895,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1890
1895
  if (action === 'archive') {
1891
1896
  await ctx.db
1892
1897
  .prepare(
1893
- "UPDATE content_items SET status = 'archived', updated_at = unixepoch() WHERE id = ?",
1898
+ "UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
1894
1899
  )
1895
1900
  .bind(id)
1896
1901
  .run()
@@ -29,9 +29,13 @@ import {
29
29
  isFrontsPublishEnabled,
30
30
  listFrontsPublishSchedule,
31
31
  } from '../engine/fronts-publish.js'
32
- import { hydrateFrontsPublishIntents } from '../engine/fronts-publish-intent.js'
32
+ import {
33
+ FrontsScheduleOverflowError,
34
+ hydrateFrontsPublishIntents,
35
+ } from '../engine/fronts-publish-intent.js'
33
36
  import { publicUrlFor } from '../ui/screens/public-url.js'
34
- import { type CmsRouteConfig, resolveConfig } from './config.js'
37
+ import { resolveConfig } from './config.js'
38
+ import type { ContentRouteConfig } from './content.js'
35
39
  import { json, type RouteContext } from './context.js'
36
40
 
37
41
  /**
@@ -112,6 +116,7 @@ const CallbackSchema = z
112
116
  intent_id: z.string().min(1).nullish(),
113
117
  content_id: z.string().min(1),
114
118
  version: z.number().int().nullish(),
119
+ publish_at: z.number().int().positive().nullish(),
115
120
  correlation_id: z.string().min(1).nullish(),
116
121
  status: z.enum(['queued', 'publishing', 'live', 'late', 'failed']),
117
122
  occurred_at: z.union([z.string(), z.number()]).nullish(),
@@ -151,7 +156,7 @@ function parseWindow(
151
156
  return { fromSeconds, toSeconds }
152
157
  }
153
158
 
154
- export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublishRouteHandlers {
159
+ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
155
160
  const resolved = resolveConfig(config)
156
161
  return {
157
162
  async schedule(ctx) {
@@ -184,28 +189,37 @@ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublish
184
189
  // predicate, and `incomplete` names every due item that cannot form a
185
190
  // complete intent, so a missing canonical field is an explicit, visible
186
191
  // contract error rather than a silently short `intents` array.
187
- const items = await listFrontsPublishSchedule(ctx.db, {
188
- toSeconds: window.toSeconds,
189
- nowSeconds: now,
190
- })
191
- const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
192
- toSeconds: window.toSeconds,
193
- nowSeconds: now,
194
- mediaPublicDomain: resolved.mediaPublicDomain,
195
- // Site-RELATIVE paths: the CMS does not know the site origin, and the
196
- // consumer joins its own trusted configured origin. A host-supplied
197
- // origin is deliberately deferred (packages-docs/cms.md).
198
- resolveContentUrl: (row) => publicUrlFor(row),
199
- })
200
- return json({
201
- ok: true,
202
- now,
203
- window: { from: window.fromSeconds, to: window.toSeconds },
204
- count: items.length,
205
- items,
206
- intents: hydrated.intents,
207
- incomplete: hydrated.incomplete,
208
- })
192
+ try {
193
+ const items = await listFrontsPublishSchedule(ctx.db, {
194
+ toSeconds: window.toSeconds,
195
+ nowSeconds: now,
196
+ })
197
+ const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
198
+ toSeconds: window.toSeconds,
199
+ nowSeconds: now,
200
+ mediaPublicDomain: resolved.mediaPublicDomain,
201
+ podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
202
+ // Site-RELATIVE paths: the CMS does not know the site origin, and the
203
+ // consumer joins its own trusted configured origin. A host-supplied
204
+ // origin is deliberately deferred (packages-docs/cms.md).
205
+ resolveContentUrl: (row) => publicUrlFor(row),
206
+ })
207
+ return json({
208
+ ok: true,
209
+ now,
210
+ window: { from: window.fromSeconds, to: window.toSeconds },
211
+ count: items.length,
212
+ items,
213
+ intents: hydrated.intents,
214
+ incomplete: hydrated.incomplete,
215
+ })
216
+ } catch (error) {
217
+ if (error instanceof FrontsScheduleOverflowError) {
218
+ console.error(`[cms] fronts/schedule unavailable: ${error.message}`)
219
+ return json({ ok: false, error: 'schedule_overflow', message: error.message }, 503)
220
+ }
221
+ throw error
222
+ }
209
223
  },
210
224
 
211
225
  async publishCallback(ctx) {
@@ -253,16 +267,40 @@ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublish
253
267
  intentId: parsed.data.intent_id ?? null,
254
268
  contentId: parsed.data.content_id,
255
269
  version: parsed.data.version ?? null,
270
+ publishAt: parsed.data.publish_at ?? null,
256
271
  correlationId: parsed.data.correlation_id ?? null,
257
272
  status: parsed.data.status,
258
273
  occurredAt: parsed.data.occurred_at ?? null,
259
274
  receipt: parsed.data.receipt ?? null,
260
275
  error: parsed.data.error ?? null,
276
+ checkReadiness: config.publishReadiness
277
+ ? (item) =>
278
+ config.publishReadiness!({
279
+ ctx,
280
+ contentId: item.id,
281
+ type: item.type,
282
+ status: item.status,
283
+ })
284
+ : undefined,
261
285
  })
262
286
 
263
287
  if (!result.contentFound) {
264
288
  return json({ error: 'content_not_found', contentId: parsed.data.content_id }, 404)
265
289
  }
290
+ if (result.rejection) {
291
+ console.warn(
292
+ `[cms] fronts/publish-callback rejected for content ${parsed.data.content_id}: ${result.rejection.code}`,
293
+ )
294
+ return json(
295
+ {
296
+ ok: false,
297
+ error: result.rejection.code,
298
+ message: result.rejection.message,
299
+ ...(result.rejection.blockers ? { blockers: result.rejection.blockers } : {}),
300
+ },
301
+ result.rejection.status,
302
+ )
303
+ }
266
304
  if (result.ledgerError) {
267
305
  // A confirmed `live` that could not be committed through the ledger is a
268
306
  // hard, loud failure — never a silent zero.
@@ -312,7 +312,7 @@ export function createCmsRoutes(
312
312
  authz: config.authz,
313
313
  activeWorkspaceId: config.activeWorkspaceId,
314
314
  }),
315
- frontsPublish: createFrontsPublishRoutes(base),
315
+ frontsPublish: createFrontsPublishRoutes(config),
316
316
  foundryPublish: createFoundryPublishRoutes(config),
317
317
  }
318
318
  }
@@ -1,6 +1,7 @@
1
1
  // src/ui/api/_content-config.ts — shared helpers for content + tag Astro endpoint adapters.
2
2
  // Builds a RouteContext and a ContentRouteConfig from an APIContext at request time.
3
3
 
4
+ import { config } from 'virtual:growth-labs/cms/config'
4
5
  import type { APIContext } from 'astro'
5
6
  import type {
6
7
  ContentRouteConfig,
@@ -24,6 +25,7 @@ type CmsHooksLocals = ContentRouteConfig['hooks'] & {
24
25
  dispatchFaq?: DispatchFaqHook
25
26
  dispatchWebhook?: DispatchWebhookHook
26
27
  publishReadiness?: PublishReadinessHook
28
+ podcastSourceProductionEnabled?: boolean
27
29
  }
28
30
 
29
31
  /**
@@ -61,8 +63,16 @@ export function toContentCtx(c: APIContext, params?: Record<string, string>): Ro
61
63
  export function toContentConfig(c: APIContext): ContentRouteConfig {
62
64
  const locals = c.locals as { cmsHooks?: CmsHooksLocals }
63
65
  const hooks = locals.cmsHooks ?? {}
66
+ const primaryTopicSlugs = config.primaryTopic?.options.map(
67
+ (option: { slug: string }) => option.slug,
68
+ )
64
69
  return {
65
70
  authz: buildSiteAuthz(c),
71
+ primaryTopicSlugs:
72
+ primaryTopicSlugs && primaryTopicSlugs.length > 0
73
+ ? (primaryTopicSlugs as [string, ...string[]])
74
+ : undefined,
75
+ requiredPrimaryTopicContentTypes: config.primaryTopic?.contentTypes,
66
76
  activeWorkspaceId: hooks.activeWorkspaceId,
67
77
  hooks: {
68
78
  llm: hooks.llm,
@@ -73,5 +83,6 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
73
83
  dispatchFaq: hooks.dispatchFaq,
74
84
  dispatchWebhook: hooks.dispatchWebhook,
75
85
  publishReadiness: hooks.publishReadiness,
86
+ podcastSourceProductionEnabled: hooks.podcastSourceProductionEnabled,
76
87
  }
77
88
  }
@@ -5,14 +5,8 @@
5
5
  // content_publication_attempts 2PC ledger. HMAC-SHA256 (X-Foundry-Signature vs
6
6
  // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher.
7
7
  import type { APIContext } from 'astro'
8
- import type { RouteContext } from '../../../routes/context.js'
9
8
  import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
10
- import { buildSiteAuthz } from '../_authz.js'
11
-
12
- function toCtx(c: APIContext): RouteContext {
13
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
- return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
- }
9
+ import { toContentConfig, toContentCtx } from '../_content-config.js'
16
10
 
17
11
  export const POST = (c: APIContext) =>
18
- createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).publishCallback(toCtx(c))
12
+ createFrontsPublishRoutes(toContentConfig(c)).publishCallback(toContentCtx(c, {}))
@@ -5,14 +5,8 @@
5
5
  // env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher. Sibling of the
6
6
  // content foundry-callback / insights-ingest M2M routes.
7
7
  import type { APIContext } from 'astro'
8
- import type { RouteContext } from '../../../routes/context.js'
9
8
  import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
10
- import { buildSiteAuthz } from '../_authz.js'
11
-
12
- function toCtx(c: APIContext): RouteContext {
13
- const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
- return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
- }
9
+ import { toContentConfig, toContentCtx } from '../_content-config.js'
16
10
 
17
11
  export const GET = (c: APIContext) =>
18
- createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).schedule(toCtx(c))
12
+ createFrontsPublishRoutes(toContentConfig(c)).schedule(toContentCtx(c, {}))
@@ -39,6 +39,7 @@ export async function mount(elementId = 'masthead-root') {
39
39
  const screens = createCmsScreens({
40
40
  allowInlineAuthorCreation: config.socialSharing.allowInlineAuthorCreation,
41
41
  primaryCategories: config.primaryCategories,
42
+ primaryTopic: config.primaryTopic,
42
43
  contentMetadataSelects: config.contentMetadataSelects,
43
44
  surveyId: config.surveyResults?.surveyId,
44
45
  })
@@ -21,7 +21,7 @@
21
21
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
22
22
  import { type ContentMetadata, parseContentMetadata } from '../../engine/content-metadata.js'
23
23
  import { countWords, estimateReadTime } from '../../engine/publisher.js'
24
- import type { ContentMetadataSelectField } from '../../integration/options.js'
24
+ import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
25
25
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
26
26
  import { Icon } from '../icons.js'
27
27
  import { uploadMediaAsset } from '../screens/media-upload.js'
@@ -76,6 +76,7 @@ export interface ContentFormProps {
76
76
  /** Called when save state changes so editor chrome can show a real save action. */
77
77
  onSaveStateChange?: (state: ContentSaveState) => void
78
78
  primaryCategories?: Array<{ slug: string; label: string }>
79
+ primaryTopic?: PrimaryTopicControl
79
80
  contentMetadataSelects?: ContentMetadataSelectField[]
80
81
  }
81
82
 
@@ -117,6 +118,7 @@ interface ExistingContentItem {
117
118
  author_name?: string | null
118
119
  author_slug?: string | null
119
120
  channel?: string | null
121
+ primary_topic?: string | null
120
122
  published_at?: number | null
121
123
  hero_image_id?: string | null
122
124
  hero_image_url?: string | null
@@ -165,6 +167,7 @@ interface FormState {
165
167
  publishedAt: string | null
166
168
  premium: boolean
167
169
  channel: string | null
170
+ primaryTopic: string | null | undefined
168
171
  metadata: ContentMetadata
169
172
  heroImageId: string
170
173
  heroImageUrl: string | null
@@ -224,6 +227,7 @@ function createEmptyFormState(docId: string | null, metadata: ContentMetadata =
224
227
  publishedAt: null,
225
228
  premium: false,
226
229
  channel: null,
230
+ primaryTopic: undefined,
227
231
  metadata,
228
232
  heroImageId: '',
229
233
  heroImageUrl: null,
@@ -270,8 +274,10 @@ export function ContentForm({
270
274
  onFrontsPublishStatusChange,
271
275
  onSaveStateChange,
272
276
  primaryCategories = [],
277
+ primaryTopic,
273
278
  contentMetadataSelects = EMPTY_CONTENT_METADATA_SELECTS,
274
279
  }: ContentFormProps) {
280
+ const primaryTopicEnabled = primaryTopic?.contentTypes.includes(contentType) ?? false
275
281
  const metadataFields = useMemo(
276
282
  () => contentMetadataSelects.filter((field) => field.contentTypes.includes(contentType)),
277
283
  [contentMetadataSelects, contentType],
@@ -314,6 +320,7 @@ export function ContentForm({
314
320
  }
315
321
  const data = (await res.json()) as ExistingContentResponse
316
322
  const next = contentResponseToFormState(data, contentType, docId)
323
+ if (!primaryTopicEnabled) next.primaryTopic = undefined
317
324
  setForm((prev) => ({
318
325
  ...next,
319
326
  aiOpen: prev.aiOpen,
@@ -334,6 +341,7 @@ export function ContentForm({
334
341
  metadataFields,
335
342
  onStatusChange,
336
343
  onFrontsPublishStatusChange,
344
+ primaryTopicEnabled,
337
345
  publishDraft,
338
346
  ])
339
347
 
@@ -402,7 +410,11 @@ export function ContentForm({
402
410
  const draft: ContentDraftFields = currentForm
403
411
 
404
412
  if (id === null) {
405
- if (!canCreateContentDraft(contentType, draft)) {
413
+ if (
414
+ !canCreateContentDraft(contentType, draft, {
415
+ requirePrimaryTopic: primaryTopicEnabled,
416
+ })
417
+ ) {
406
418
  const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
407
419
  const missingMediaDuration =
408
420
  mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null
@@ -537,7 +549,7 @@ export function ContentForm({
537
549
  }
538
550
  }
539
551
  },
540
- [contentType, onDocCreated, queueFoundryProcessing],
552
+ [contentType, onDocCreated, primaryTopicEnabled, queueFoundryProcessing],
541
553
  )
542
554
 
543
555
  useEffect(() => {
@@ -603,6 +615,13 @@ export function ContentForm({
603
615
  scheduleAutosave(next)
604
616
  }
605
617
 
618
+ function handlePrimaryTopicChange(value: string) {
619
+ const next = { ...latestForm.current, primaryTopic: value || null }
620
+ setForm(next)
621
+ publishDraft(next)
622
+ scheduleAutosave(next)
623
+ }
624
+
606
625
  function handleMetadataSelectChange(field: ContentMetadataSelectField, value: string) {
607
626
  const next = {
608
627
  ...latestForm.current,
@@ -999,6 +1018,28 @@ export function ContentForm({
999
1018
  </div>
1000
1019
  )}
1001
1020
 
1021
+ {primaryTopicEnabled && primaryTopic && (
1022
+ <div style={fieldGroup}>
1023
+ <label style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--ink-muted)' }}>
1024
+ {primaryTopic.label}
1025
+ <select
1026
+ value={form.primaryTopic ?? ''}
1027
+ onChange={(event) => handlePrimaryTopicChange(event.currentTarget.value)}
1028
+ aria-label={primaryTopic.label}
1029
+ required
1030
+ style={urlInput}
1031
+ >
1032
+ <option value="">Select {primaryTopic.label.toLowerCase()}…</option>
1033
+ {primaryTopic.options.map((option) => (
1034
+ <option key={option.slug} value={option.slug}>
1035
+ {option.label}
1036
+ </option>
1037
+ ))}
1038
+ </select>
1039
+ </label>
1040
+ </div>
1041
+ )}
1042
+
1002
1043
  {metadataFields.map((field) => {
1003
1044
  const storedValue = form.metadata[field.key]
1004
1045
  const value = typeof storedValue === 'string' ? storedValue : ''
@@ -1645,6 +1686,7 @@ function contentResponseToFormState(
1645
1686
  next.publishedAt = unixSecondsToIso(item.published_at)
1646
1687
  next.premium = item.visibility === 'premium'
1647
1688
  next.channel = toNullableString(item.channel)
1689
+ next.primaryTopic = toNullableString(item.primary_topic)
1648
1690
  next.metadata = storedContentMetadata(item.metadata_json)
1649
1691
  next.heroImageId = heroImageId
1650
1692
  next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
@@ -21,6 +21,7 @@ export interface ContentDraftFields {
21
21
  podcastSourceKind?: string | null
22
22
  durationSeconds: number | null
23
23
  channel?: string | null
24
+ primaryTopic?: string | null
24
25
  metadata?: ContentMetadata
25
26
  }
26
27
 
@@ -116,8 +117,10 @@ export function slugifyTitle(title: string): string {
116
117
  export function canCreateContentDraft(
117
118
  contentType: ContentType,
118
119
  draft: ContentDraftFields,
120
+ requirements: { requirePrimaryTopic?: boolean } = {},
119
121
  ): boolean {
120
122
  if (draft.title.trim().length < 3) return false
123
+ if (requirements.requirePrimaryTopic && !draft.primaryTopic?.trim()) return false
121
124
  if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
122
125
  return draft.body.trim().length > 0
123
126
  }
@@ -181,6 +184,10 @@ export function buildContentUpdatePayload(
181
184
  if (draft.metadata !== undefined) payload.metadata = draft.metadata
182
185
  const channel = draft.channel?.trim()
183
186
  payload.primaryCategory = channel || null
187
+ if (draft.primaryTopic !== undefined) {
188
+ const primaryTopic = draft.primaryTopic?.trim()
189
+ payload.primaryTopic = primaryTopic || null
190
+ }
184
191
  const heroImageId = draft.heroImageId?.trim()
185
192
  if (heroImageId) {
186
193
  payload.heroImageId = heroImageId
@@ -249,5 +256,6 @@ export function buildContentCreatePayload(
249
256
  ...buildContentUpdatePayload(contentType, draft),
250
257
  }
251
258
  if (payload.primaryCategory === null) delete payload.primaryCategory
259
+ if (payload.primaryTopic === null) delete payload.primaryTopic
252
260
  return payload
253
261
  }
@@ -8,7 +8,7 @@
8
8
  // This file creates thin stubs so the registry can compile now (Task 10).
9
9
 
10
10
  import { useReducer } from 'react'
11
- import type { ContentMetadataSelectField } from '../../integration/options.js'
11
+ import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
12
12
  import { type ContentType, contentViewReducer, initialContentView } from './content-view.js'
13
13
  import { EditorScreen } from './EditorScreen.js'
14
14
  import { LibraryScreen } from './LibraryScreen.js'
@@ -16,10 +16,12 @@ import { LibraryScreen } from './LibraryScreen.js'
16
16
  export function ContentRoute({
17
17
  allowInlineAuthorCreation = true,
18
18
  primaryCategories = [],
19
+ primaryTopic,
19
20
  contentMetadataSelects = [],
20
21
  }: {
21
22
  allowInlineAuthorCreation?: boolean
22
23
  primaryCategories?: Array<{ slug: string; label: string }>
24
+ primaryTopic?: PrimaryTopicControl
23
25
  contentMetadataSelects?: ContentMetadataSelectField[]
24
26
  }) {
25
27
  const [view, dispatch] = useReducer(contentViewReducer, initialContentView)
@@ -52,6 +54,7 @@ export function ContentRoute({
52
54
  onClose={handleClose}
53
55
  allowInlineAuthorCreation={allowInlineAuthorCreation}
54
56
  primaryCategories={primaryCategories}
57
+ primaryTopic={primaryTopic}
55
58
  contentMetadataSelects={contentMetadataSelects}
56
59
  />
57
60
  )
@@ -17,7 +17,7 @@
17
17
  // is Playwright-smoked only.
18
18
 
19
19
  import { useCallback, useState } from 'react'
20
- import type { ContentMetadataSelectField } from '../../integration/options.js'
20
+ import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
21
21
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
22
22
  import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
23
23
  import {
@@ -41,6 +41,7 @@ export interface EditorScreenProps {
41
41
  onClose: () => void
42
42
  allowInlineAuthorCreation?: boolean
43
43
  primaryCategories?: Array<{ slug: string; label: string }>
44
+ primaryTopic?: PrimaryTopicControl
44
45
  contentMetadataSelects?: ContentMetadataSelectField[]
45
46
  }
46
47
 
@@ -101,6 +102,7 @@ export function EditorScreen({
101
102
  onClose,
102
103
  allowInlineAuthorCreation = true,
103
104
  primaryCategories = [],
105
+ primaryTopic,
104
106
  contentMetadataSelects = [],
105
107
  }: EditorScreenProps) {
106
108
  const contentType: ContentType = (docRef.contentType as ContentType | undefined) ?? 'article'
@@ -410,6 +412,7 @@ export function EditorScreen({
410
412
  onFrontsPublishStatusChange={setFrontsPublishStatus}
411
413
  onSaveStateChange={handleSaveStateChange}
412
414
  primaryCategories={primaryCategories}
415
+ primaryTopic={primaryTopic}
413
416
  contentMetadataSelects={contentMetadataSelects}
414
417
  />
415
418
  </div>
@@ -11,7 +11,7 @@
11
11
  // internal router state (which is not exported). The ContentRoute itself owns
12
12
  // the full open-doc reducer (which doc is open, new vs existing).
13
13
 
14
- import type { ContentMetadataSelectField } from '../../integration/options.js'
14
+ import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
15
15
  import type { ScreenRegistry } from '../components/CmsApp.js'
16
16
  import { useHashRouter } from '../use-hash-router.js'
17
17
  import { AnalyticsScreen } from './AnalyticsScreen.js'
@@ -43,11 +43,13 @@ function CalendarRoute() {
43
43
  export function createCmsScreens({
44
44
  allowInlineAuthorCreation = true,
45
45
  primaryCategories = [],
46
+ primaryTopic,
46
47
  contentMetadataSelects = [],
47
48
  surveyId,
48
49
  }: {
49
50
  allowInlineAuthorCreation?: boolean
50
51
  primaryCategories?: Array<{ slug: string; label: string }>
52
+ primaryTopic?: PrimaryTopicControl
51
53
  contentMetadataSelects?: ContentMetadataSelectField[]
52
54
  surveyId?: string
53
55
  } = {}): ScreenRegistry {
@@ -56,6 +58,7 @@ export function createCmsScreens({
56
58
  <ContentRoute
57
59
  allowInlineAuthorCreation={allowInlineAuthorCreation}
58
60
  primaryCategories={primaryCategories}
61
+ primaryTopic={primaryTopic}
59
62
  contentMetadataSelects={contentMetadataSelects}
60
63
  />
61
64
  ),