@growth-labs/cms 0.6.7 → 0.7.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 (63) hide show
  1. package/README.md +20 -0
  2. package/dist/engine/publication.d.ts.map +1 -1
  3. package/dist/engine/publication.js +7 -3
  4. package/dist/engine/publication.js.map +1 -1
  5. package/dist/engine/published-content.d.ts +10 -0
  6. package/dist/engine/published-content.d.ts.map +1 -1
  7. package/dist/engine/published-content.js +3 -0
  8. package/dist/engine/published-content.js.map +1 -1
  9. package/dist/engine/publisher.d.ts +26 -2
  10. package/dist/engine/publisher.d.ts.map +1 -1
  11. package/dist/engine/publisher.js +113 -6
  12. package/dist/engine/publisher.js.map +1 -1
  13. package/dist/integration/options.d.ts +117 -0
  14. package/dist/integration/options.d.ts.map +1 -1
  15. package/dist/integration/options.js +19 -0
  16. package/dist/integration/options.js.map +1 -1
  17. package/dist/routes/config.d.ts +4 -0
  18. package/dist/routes/config.d.ts.map +1 -1
  19. package/dist/routes/config.js +1 -0
  20. package/dist/routes/config.js.map +1 -1
  21. package/dist/routes/content.d.ts.map +1 -1
  22. package/dist/routes/content.js +7 -5
  23. package/dist/routes/content.js.map +1 -1
  24. package/dist/ui/api/_content-config.d.ts.map +1 -1
  25. package/dist/ui/api/_content-config.js +6 -0
  26. package/dist/ui/api/_content-config.js.map +1 -1
  27. package/dist/ui/client/mount.d.ts.map +1 -1
  28. package/dist/ui/client/mount.js +1 -0
  29. package/dist/ui/client/mount.js.map +1 -1
  30. package/dist/ui/editor/ContentForm.d.ts +3 -2
  31. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  32. package/dist/ui/editor/ContentForm.js +18 -4
  33. package/dist/ui/editor/ContentForm.js.map +1 -1
  34. package/dist/ui/editor/content-payload.d.ts +4 -1
  35. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  36. package/dist/ui/editor/content-payload.js +9 -1
  37. package/dist/ui/editor/content-payload.js.map +1 -1
  38. package/dist/ui/screens/ContentRoute.d.ts +3 -2
  39. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  40. package/dist/ui/screens/ContentRoute.js +2 -2
  41. package/dist/ui/screens/ContentRoute.js.map +1 -1
  42. package/dist/ui/screens/EditorScreen.d.ts +3 -2
  43. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  44. package/dist/ui/screens/EditorScreen.js +2 -2
  45. package/dist/ui/screens/EditorScreen.js.map +1 -1
  46. package/dist/ui/screens/registry.d.ts +3 -2
  47. package/dist/ui/screens/registry.d.ts.map +1 -1
  48. package/dist/ui/screens/registry.js +2 -2
  49. package/dist/ui/screens/registry.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/engine/publication.ts +7 -1
  52. package/src/engine/published-content.ts +13 -0
  53. package/src/engine/publisher.ts +119 -4
  54. package/src/integration/options.ts +23 -0
  55. package/src/routes/config.ts +5 -0
  56. package/src/routes/content.ts +8 -5
  57. package/src/ui/api/_content-config.ts +9 -0
  58. package/src/ui/client/mount.tsx +1 -0
  59. package/src/ui/editor/ContentForm.tsx +45 -3
  60. package/src/ui/editor/content-payload.ts +8 -0
  61. package/src/ui/screens/ContentRoute.tsx +4 -1
  62. package/src/ui/screens/EditorScreen.tsx +4 -1
  63. package/src/ui/screens/registry.tsx +4 -1
@@ -54,6 +54,16 @@ export interface PublishedContent {
54
54
  type: ContentType
55
55
  publishedAt: number
56
56
  updatedAt: number
57
+ /**
58
+ * Row creation time (unix seconds), from the published revision snapshot.
59
+ *
60
+ * Distinct from `publishedAt`, which is the date the article *claims* and is
61
+ * deliberately backdated to the source video's date on the channel sites.
62
+ * Consumers pass this to `resolveAdaptedArticleDates` as the `createdAt`
63
+ * fallback for `datePublished`; without it that fallback is dead code.
64
+ * `undefined` only for a revision snapshot predating the column.
65
+ */
66
+ createdAt: number | undefined
57
67
  payload: ContentRevisionPayload
58
68
  metadata: ContentMetadata
59
69
  }
@@ -358,6 +368,9 @@ function decodePublishedContent(row: PublishedContentRow, requireBodies = true):
358
368
  type: item.type as ContentType,
359
369
  publishedAt: row.canonical_published_at,
360
370
  updatedAt: item.updated_at as number,
371
+ // getContentSnapshot selects * from content_items, so created_at rides
372
+ // along in the revision payload; no extra column selection needed.
373
+ createdAt: Number.isSafeInteger(item.created_at) ? (item.created_at as number) : undefined,
361
374
  payload,
362
375
  metadata,
363
376
  }
@@ -1464,16 +1464,129 @@ export async function assertContentBodyValidForPublish(
1464
1464
  }
1465
1465
  }
1466
1466
 
1467
+ /**
1468
+ * Publish-time date stamping on `content_items.metadata_json`.
1469
+ *
1470
+ * Adapted articles (an article written from a YouTube video script) need two
1471
+ * dates the row did not previously carry:
1472
+ *
1473
+ * - `adaptedAt` — the date the article first went live **on the site**. This is
1474
+ * what `@growth-labs/seo`'s `resolveAdaptedArticleDates` turns into JSON-LD
1475
+ * `datePublished`.
1476
+ * - `updatedAt` — the date of the most recent re-publish, which becomes
1477
+ * `dateModified`.
1478
+ *
1479
+ * ## The distinction that matters: claimed date vs. wall clock
1480
+ *
1481
+ * `published_at` is the date the article *claims*, and on all ten channel sites
1482
+ * it is deliberately backdated to the **YouTube video's** publish date — that is
1483
+ * the whole reason these two keys exist. `adaptedAt` and `updatedAt` are the
1484
+ * opposite: the wall-clock moment the publish transition actually committed.
1485
+ *
1486
+ * Deriving either of them from the publish timestamp would collapse
1487
+ * `datePublished` straight back onto the video date and make the feature a
1488
+ * no-op for every future article. So the stamp takes its own `now` value,
1489
+ * distinct from `publishedAt`, at every call site.
1490
+ *
1491
+ * Invariants:
1492
+ * - `adaptedAt` is written once and **never rewritten**. It is the article's
1493
+ * identity date; rewriting it would walk `datePublished` forward on every edit.
1494
+ * - A re-publish stamps `updatedAt` at wall clock. A first publish does not —
1495
+ * `dateModified` defaults to `datePublished` in the consumer. Wall clock also
1496
+ * means a backdated re-publish cannot emit a `dateModified` before
1497
+ * `datePublished`, and a scheduled forward-dated publish cannot emit a
1498
+ * *future* `dateModified` (a Discover quality signal problem that the seo
1499
+ * helper's clamp does not catch).
1500
+ * - A row already published before this shipped has no `adaptedAt`. It is
1501
+ * backfilled from `COALESCE(created_at, published_at)` — `created_at` is the
1502
+ * real row-creation moment, whereas `published_at` is the claimed (possibly
1503
+ * backdated) date. The two are equal on exactly the rows that need the
1504
+ * backfill, so this is a no-op on current data and correct by construction
1505
+ * rather than by coincidence. `content_items.created_at` is NOT NULL, so the
1506
+ * `published_at` arm of that COALESCE is unreachable by schema and kept only
1507
+ * as defence in depth — there is deliberately no test for it, because no test
1508
+ * can construct the state.
1509
+ *
1510
+ * Expressed as SQL rather than read-modify-write so the stamp lands in the same
1511
+ * atomic statement as the publish transition. A separate UPDATE would open a
1512
+ * window where an item is published but unstamped, and in the 2PC kernel it
1513
+ * would not be covered by the pointer CAS. In SQLite the right-hand side of a
1514
+ * SET clause sees the row's **pre-update** values, so `published_at` and
1515
+ * `created_at` below are the previous values, which is what the backfill needs.
1516
+ *
1517
+ * Malformed or absent `metadata_json` degrades to `{}` rather than failing the
1518
+ * publish; `json_set` on invalid JSON would otherwise throw and block the item.
1519
+ */
1520
+ const METADATA_OBJECT_SQL = `CASE
1521
+ WHEN metadata_json IS NOT NULL AND json_valid(metadata_json) AND json_type(metadata_json) = 'object'
1522
+ THEN metadata_json
1523
+ ELSE '{}'
1524
+ END`
1525
+
1526
+ /**
1527
+ * `metadata_json` with `adaptedAt` resolved:
1528
+ * already set → untouched; previously published → backfilled from the row's own
1529
+ * creation time; otherwise a genuine first publish → the wall clock `?`.
1530
+ */
1531
+ const METADATA_WITH_ADAPTED_AT_SQL = `CASE
1532
+ WHEN json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
1533
+ THEN ${METADATA_OBJECT_SQL}
1534
+ WHEN published_at IS NOT NULL
1535
+ THEN json_set(${METADATA_OBJECT_SQL}, '$.adaptedAt', date(COALESCE(created_at, published_at), 'unixepoch'))
1536
+ ELSE json_set(${METADATA_OBJECT_SQL}, '$.adaptedAt', date(?, 'unixepoch'))
1537
+ END`
1538
+
1539
+ /**
1540
+ * The full `metadata_json = ...` assignment. Append to a publish UPDATE's SET
1541
+ * clause and bind with {@link publishMetadataStampBinds}.
1542
+ *
1543
+ * "Already published" is `published_at IS NOT NULL OR adaptedAt IS NOT NULL` —
1544
+ * the second disjunct keeps unpublish→republish correct, since `unpublishContent`
1545
+ * clears `published_at` but the article did go live once.
1546
+ */
1547
+ export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = CASE
1548
+ WHEN published_at IS NOT NULL
1549
+ OR json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
1550
+ THEN json_set(${METADATA_WITH_ADAPTED_AT_SQL}, '$.updatedAt', date(?, 'unixepoch'))
1551
+ ELSE ${METADATA_WITH_ADAPTED_AT_SQL}
1552
+ END`
1553
+
1554
+ /** Placeholder count in {@link PUBLISH_METADATA_STAMP_SQL}, derived so it cannot drift. */
1555
+ const PUBLISH_METADATA_STAMP_PLACEHOLDERS = (PUBLISH_METADATA_STAMP_SQL.match(/\?/g) ?? []).length
1556
+
1557
+ /** Wall-clock unix seconds — the moment a publish transition commits. */
1558
+ export function nowSeconds(): number {
1559
+ return Math.floor(Date.now() / 1000)
1560
+ }
1561
+
1562
+ /**
1563
+ * Binds for {@link PUBLISH_METADATA_STAMP_SQL}. Every placeholder is the same
1564
+ * wall-clock `now` (unix seconds) — deliberately NOT the publish timestamp,
1565
+ * which is routinely backdated. The count is derived from the SQL itself so
1566
+ * editing the expression cannot silently desynchronize the bind list.
1567
+ */
1568
+ export function publishMetadataStampBinds(now: number): number[] {
1569
+ return new Array(PUBLISH_METADATA_STAMP_PLACEHOLDERS).fill(now)
1570
+ }
1571
+
1572
+ /**
1573
+ * @param publishedAt the date the article claims — may be deliberately backdated.
1574
+ * @param now wall-clock unix seconds for the date stamps; defaults to real now.
1575
+ * Kept separate from `publishedAt` on purpose (see PUBLISH_METADATA_STAMP_SQL)
1576
+ * and injectable so tests stay deterministic.
1577
+ */
1467
1578
  export async function publishContent(
1468
1579
  db: D1Database,
1469
1580
  id: string,
1470
1581
  publishedAt: number,
1582
+ now: number = nowSeconds(),
1471
1583
  ): Promise<void> {
1472
1584
  await db
1473
1585
  .prepare(
1474
- `UPDATE content_items SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch() WHERE id = ?`,
1586
+ `UPDATE content_items SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch(),
1587
+ ${PUBLISH_METADATA_STAMP_SQL} WHERE id = ?`,
1475
1588
  )
1476
- .bind(publishedAt, id)
1589
+ .bind(publishedAt, ...publishMetadataStampBinds(now), id)
1477
1590
  .run()
1478
1591
  }
1479
1592
 
@@ -1493,14 +1606,16 @@ export async function claimScheduledForPublish(
1493
1606
  db: D1Database,
1494
1607
  id: string,
1495
1608
  publishedAt: number,
1609
+ now: number = nowSeconds(),
1496
1610
  ): Promise<boolean> {
1497
1611
  const res = await db
1498
1612
  .prepare(
1499
1613
  `UPDATE content_items
1500
- SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch()
1614
+ SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch(),
1615
+ ${PUBLISH_METADATA_STAMP_SQL}
1501
1616
  WHERE id = ? AND status = 'scheduled'`,
1502
1617
  )
1503
- .bind(publishedAt, id)
1618
+ .bind(publishedAt, ...publishMetadataStampBinds(now), id)
1504
1619
  .run()
1505
1620
  const changes = (res.meta as { changes?: number } | undefined)?.changes ?? 0
1506
1621
  return changes > 0
@@ -13,6 +13,28 @@ const primaryCategorySchema = z.object({
13
13
  label: z.string().min(1),
14
14
  })
15
15
 
16
+ const contentTypeSchema = z.enum(['article', 'video', 'podcast', 'newsletter', 'page'])
17
+
18
+ const primaryTopicControlSchema = z
19
+ .object({
20
+ label: z.string().trim().min(1).max(100),
21
+ contentTypes: z.array(contentTypeSchema).min(1),
22
+ options: z.array(primaryCategorySchema).min(1),
23
+ })
24
+ .strict()
25
+ .superRefine((control, ctx) => {
26
+ const slugs = control.options.map(({ slug }) => slug)
27
+ if (new Set(slugs).size !== slugs.length) {
28
+ ctx.addIssue({
29
+ code: 'custom',
30
+ path: ['options'],
31
+ message: 'primary topic option slugs must be unique',
32
+ })
33
+ }
34
+ })
35
+
36
+ export type PrimaryTopicControl = z.output<typeof primaryTopicControlSchema>
37
+
16
38
  const contentMetadataKeySchema = z
17
39
  .string()
18
40
  .regex(/^[a-z][a-z0-9_]{0,63}$/, 'metadata keys must be lowercase snake_case')
@@ -106,6 +128,7 @@ export const cmsIntegrationOptionsSchema = z
106
128
  .object({
107
129
  workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
108
130
  primaryCategories: z.array(primaryCategorySchema).default([]),
131
+ primaryTopic: primaryTopicControlSchema.optional(),
109
132
  contentMetadataSelects: contentMetadataSelectsSchema,
110
133
  activeWorkspaceId: z.string().optional(),
111
134
  adminBasePath: z.string().default('/admin'),
@@ -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',
@@ -392,13 +392,16 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
392
392
  tags: z.array(z.string()).optional(),
393
393
  metadata: ContentMetadataSchema.optional(),
394
394
  })
395
+ const requiredTopicTypes = new Set(resolved.requiredPrimaryTopicContentTypes)
396
+ const baseSchemaFor = (type: ContentType) =>
397
+ requiredTopicTypes.has(type) ? BaseSchema.extend({ primaryTopic: topic }) : BaseSchema
395
398
 
396
399
  const CreateSchema = z.discriminatedUnion('type', [
397
- BaseSchema.extend({
400
+ baseSchemaFor('article').extend({
398
401
  type: z.literal('article'),
399
402
  content: BodyContentCreateSchema,
400
403
  }),
401
- BaseSchema.extend({
404
+ baseSchemaFor('video').extend({
402
405
  type: z.literal('video'),
403
406
  content: z.object({
404
407
  script: z.string().default(''),
@@ -409,7 +412,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
409
412
  processingSourceKind: z.string().min(1).optional().nullable(),
410
413
  }),
411
414
  }),
412
- BaseSchema.extend({
415
+ baseSchemaFor('podcast').extend({
413
416
  type: z.literal('podcast'),
414
417
  content: z.object({
415
418
  // Empty allowed: a podcast is created as a draft with audio uploaded
@@ -429,11 +432,11 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
429
432
  processingSourceKind: z.string().min(1).optional().nullable(),
430
433
  }),
431
434
  }),
432
- BaseSchema.extend({
435
+ baseSchemaFor('newsletter').extend({
433
436
  type: z.literal('newsletter'),
434
437
  content: BodyContentCreateSchema,
435
438
  }),
436
- BaseSchema.extend({
439
+ baseSchemaFor('page').extend({
437
440
  type: z.literal('page'),
438
441
  content: BodyContentCreateSchema,
439
442
  }),
@@ -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,
@@ -62,8 +63,16 @@ export function toContentCtx(c: APIContext, params?: Record<string, string>): Ro
62
63
  export function toContentConfig(c: APIContext): ContentRouteConfig {
63
64
  const locals = c.locals as { cmsHooks?: CmsHooksLocals }
64
65
  const hooks = locals.cmsHooks ?? {}
66
+ const primaryTopicSlugs = config.primaryTopic?.options.map(
67
+ (option: { slug: string }) => option.slug,
68
+ )
65
69
  return {
66
70
  authz: buildSiteAuthz(c),
71
+ primaryTopicSlugs:
72
+ primaryTopicSlugs && primaryTopicSlugs.length > 0
73
+ ? (primaryTopicSlugs as [string, ...string[]])
74
+ : undefined,
75
+ requiredPrimaryTopicContentTypes: config.primaryTopic?.contentTypes,
67
76
  activeWorkspaceId: hooks.activeWorkspaceId,
68
77
  hooks: {
69
78
  llm: hooks.llm,
@@ -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
  ),