@growth-labs/cms 0.6.7 → 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 (49) hide show
  1. package/README.md +20 -0
  2. package/dist/integration/options.d.ts +117 -0
  3. package/dist/integration/options.d.ts.map +1 -1
  4. package/dist/integration/options.js +19 -0
  5. package/dist/integration/options.js.map +1 -1
  6. package/dist/routes/config.d.ts +4 -0
  7. package/dist/routes/config.d.ts.map +1 -1
  8. package/dist/routes/config.js +1 -0
  9. package/dist/routes/config.js.map +1 -1
  10. package/dist/routes/content.d.ts.map +1 -1
  11. package/dist/routes/content.js +7 -5
  12. package/dist/routes/content.js.map +1 -1
  13. package/dist/ui/api/_content-config.d.ts.map +1 -1
  14. package/dist/ui/api/_content-config.js +6 -0
  15. package/dist/ui/api/_content-config.js.map +1 -1
  16. package/dist/ui/client/mount.d.ts.map +1 -1
  17. package/dist/ui/client/mount.js +1 -0
  18. package/dist/ui/client/mount.js.map +1 -1
  19. package/dist/ui/editor/ContentForm.d.ts +3 -2
  20. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  21. package/dist/ui/editor/ContentForm.js +18 -4
  22. package/dist/ui/editor/ContentForm.js.map +1 -1
  23. package/dist/ui/editor/content-payload.d.ts +4 -1
  24. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  25. package/dist/ui/editor/content-payload.js +9 -1
  26. package/dist/ui/editor/content-payload.js.map +1 -1
  27. package/dist/ui/screens/ContentRoute.d.ts +3 -2
  28. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  29. package/dist/ui/screens/ContentRoute.js +2 -2
  30. package/dist/ui/screens/ContentRoute.js.map +1 -1
  31. package/dist/ui/screens/EditorScreen.d.ts +3 -2
  32. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  33. package/dist/ui/screens/EditorScreen.js +2 -2
  34. package/dist/ui/screens/EditorScreen.js.map +1 -1
  35. package/dist/ui/screens/registry.d.ts +3 -2
  36. package/dist/ui/screens/registry.d.ts.map +1 -1
  37. package/dist/ui/screens/registry.js +2 -2
  38. package/dist/ui/screens/registry.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/integration/options.ts +23 -0
  41. package/src/routes/config.ts +5 -0
  42. package/src/routes/content.ts +8 -5
  43. package/src/ui/api/_content-config.ts +9 -0
  44. package/src/ui/client/mount.tsx +1 -0
  45. package/src/ui/editor/ContentForm.tsx +45 -3
  46. package/src/ui/editor/content-payload.ts +8 -0
  47. package/src/ui/screens/ContentRoute.tsx +4 -1
  48. package/src/ui/screens/EditorScreen.tsx +4 -1
  49. package/src/ui/screens/registry.tsx +4 -1
@@ -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
  ),