@growth-labs/cms 0.5.27 → 0.5.29

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 (44) hide show
  1. package/README.md +20 -0
  2. package/dist/engine/slug.d.ts.map +1 -1
  3. package/dist/engine/slug.js +11 -1
  4. package/dist/engine/slug.js.map +1 -1
  5. package/dist/integration/options.d.ts +196 -0
  6. package/dist/integration/options.d.ts.map +1 -1
  7. package/dist/integration/options.js +62 -0
  8. package/dist/integration/options.js.map +1 -1
  9. package/dist/routes/content.d.ts.map +1 -1
  10. package/dist/routes/content.js +30 -0
  11. package/dist/routes/content.js.map +1 -1
  12. package/dist/ui/client/mount.d.ts.map +1 -1
  13. package/dist/ui/client/mount.js +1 -0
  14. package/dist/ui/client/mount.js.map +1 -1
  15. package/dist/ui/editor/ContentForm.d.ts +3 -1
  16. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  17. package/dist/ui/editor/ContentForm.js +35 -8
  18. package/dist/ui/editor/ContentForm.js.map +1 -1
  19. package/dist/ui/editor/content-payload.d.ts +12 -0
  20. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  21. package/dist/ui/editor/content-payload.js +24 -0
  22. package/dist/ui/editor/content-payload.js.map +1 -1
  23. package/dist/ui/screens/ContentRoute.d.ts +3 -1
  24. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  25. package/dist/ui/screens/ContentRoute.js +2 -2
  26. package/dist/ui/screens/ContentRoute.js.map +1 -1
  27. package/dist/ui/screens/EditorScreen.d.ts +3 -1
  28. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  29. package/dist/ui/screens/EditorScreen.js +2 -2
  30. package/dist/ui/screens/EditorScreen.js.map +1 -1
  31. package/dist/ui/screens/registry.d.ts +3 -1
  32. package/dist/ui/screens/registry.d.ts.map +1 -1
  33. package/dist/ui/screens/registry.js +2 -2
  34. package/dist/ui/screens/registry.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/engine/slug.ts +12 -1
  37. package/src/integration/options.ts +69 -0
  38. package/src/routes/content.ts +37 -0
  39. package/src/ui/client/mount.tsx +1 -0
  40. package/src/ui/editor/ContentForm.tsx +74 -5
  41. package/src/ui/editor/content-payload.ts +39 -0
  42. package/src/ui/screens/ContentRoute.tsx +4 -0
  43. package/src/ui/screens/EditorScreen.tsx +4 -0
  44. package/src/ui/screens/registry.tsx +4 -0
@@ -13,6 +13,74 @@ const primaryCategorySchema = z.object({
13
13
  label: z.string().min(1),
14
14
  })
15
15
 
16
+ const contentMetadataKeySchema = z
17
+ .string()
18
+ .regex(/^[a-z][a-z0-9_]{0,63}$/, 'metadata keys must be lowercase snake_case')
19
+
20
+ const contentMetadataSelectFieldSchema = z
21
+ .object({
22
+ key: contentMetadataKeySchema,
23
+ label: z.string().trim().min(1).max(100),
24
+ helpText: z.string().trim().min(1).max(500).optional(),
25
+ contentTypes: z.array(z.enum(['article', 'video', 'podcast', 'newsletter', 'page'])).min(1),
26
+ options: z
27
+ .array(
28
+ z
29
+ .object({
30
+ value: z.string().trim().min(1).max(100),
31
+ label: z.string().trim().min(1).max(100),
32
+ })
33
+ .strict(),
34
+ )
35
+ .min(1),
36
+ defaultValue: z.string().trim().min(1).max(100).optional(),
37
+ inferenceMarkerKey: contentMetadataKeySchema.optional(),
38
+ })
39
+ .strict()
40
+ .superRefine((field, ctx) => {
41
+ const values = field.options.map(({ value }) => value)
42
+ if (new Set(values).size !== values.length) {
43
+ ctx.addIssue({
44
+ code: 'custom',
45
+ path: ['options'],
46
+ message: 'metadata select option values must be unique',
47
+ })
48
+ }
49
+ if (field.defaultValue && !values.includes(field.defaultValue)) {
50
+ ctx.addIssue({
51
+ code: 'custom',
52
+ path: ['defaultValue'],
53
+ message: 'metadata select defaultValue must match a declared option',
54
+ })
55
+ }
56
+ if (field.inferenceMarkerKey === field.key) {
57
+ ctx.addIssue({
58
+ code: 'custom',
59
+ path: ['inferenceMarkerKey'],
60
+ message: 'metadata select inference marker must use a separate key',
61
+ })
62
+ }
63
+ })
64
+
65
+ const contentMetadataSelectsSchema = z
66
+ .array(contentMetadataSelectFieldSchema)
67
+ .superRefine((fields, ctx) => {
68
+ const keys = new Set<string>()
69
+ for (const [index, field] of fields.entries()) {
70
+ if (keys.has(field.key)) {
71
+ ctx.addIssue({
72
+ code: 'custom',
73
+ path: [index, 'key'],
74
+ message: 'metadata select keys must be unique',
75
+ })
76
+ }
77
+ keys.add(field.key)
78
+ }
79
+ })
80
+ .default([])
81
+
82
+ export type ContentMetadataSelectField = z.output<typeof contentMetadataSelectFieldSchema>
83
+
16
84
  const themeSchema = z
17
85
  .object({
18
86
  mode: z.enum(['light', 'dark']).default('dark'),
@@ -38,6 +106,7 @@ export const cmsIntegrationOptionsSchema = z
38
106
  .object({
39
107
  workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
40
108
  primaryCategories: z.array(primaryCategorySchema).default([]),
109
+ contentMetadataSelects: contentMetadataSelectsSchema,
41
110
  activeWorkspaceId: z.string().optional(),
42
111
  adminBasePath: z.string().default('/admin'),
43
112
  searchEnabled: z.boolean().default(true),
@@ -18,6 +18,11 @@ import { z } from 'zod'
18
18
  import { logActivity } from '../engine/activity-log.js'
19
19
  import { generateContentDek } from '../engine/ai-dek.js'
20
20
  import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
21
+ import {
22
+ type ContentMetadata,
23
+ ContentMetadataError,
24
+ serializeContentMetadata,
25
+ } from '../engine/content-metadata.js'
21
26
  import { listContributors, setContributors } from '../engine/contributors.js'
22
27
  import type { D1Database } from '../engine/d1.js'
23
28
  import {
@@ -52,6 +57,7 @@ import {
52
57
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
53
58
  import { softDeleteContent } from '../engine/soft-delete.js'
54
59
  import { TagInputError } from '../engine/taxonomy.js'
60
+ import type { ValidationError } from '../engine/validator/index.js'
55
61
  import { parseLayoutEnvelope } from '../schema/layout.js'
56
62
  import { parsePortableTextEnvelope } from '../schema/portable-text.js'
57
63
  import type { ContentStatus } from '../schema/types.js'
@@ -284,6 +290,21 @@ const BodyContentCreateSchema = z.object({
284
290
  editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
285
291
  })
286
292
 
293
+ const ContentMetadataSchema = z
294
+ .record(z.string(), z.unknown())
295
+ .superRefine((metadata, ctx) => {
296
+ try {
297
+ serializeContentMetadata(metadata as ContentMetadata)
298
+ } catch (error) {
299
+ ctx.addIssue({
300
+ code: 'custom',
301
+ message:
302
+ error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
303
+ })
304
+ }
305
+ })
306
+ .transform((metadata) => metadata as ContentMetadata)
307
+
287
308
  function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
288
309
  const category = slugEnum(resolved.primaryCategorySlugs)
289
310
  const topic = slugEnum(resolved.primaryTopicSlugs)
@@ -306,6 +327,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
306
327
  socialImageId: z.string().optional().nullable(),
307
328
  canonicalUrl: z.string().url().optional().nullable(),
308
329
  tags: z.array(z.string()).optional(),
330
+ metadata: ContentMetadataSchema.optional(),
309
331
  })
310
332
 
311
333
  const CreateSchema = z.discriminatedUnion('type', [
@@ -370,6 +392,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
370
392
  socialImageId: z.string().optional().nullable(),
371
393
  canonicalUrl: z.string().url().optional().nullable(),
372
394
  tags: z.array(z.string()).optional(),
395
+ metadata: ContentMetadataSchema.optional(),
373
396
  content: z.record(z.string(), z.any()).optional(),
374
397
  relations: z.array(RelationSchema).optional(),
375
398
  aiLockedFields: z.array(z.string()).optional(),
@@ -1205,6 +1228,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1205
1228
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
1206
1229
  }
1207
1230
 
1231
+ // Warn-severity body-lint results. Declared here so the success payload
1232
+ // below can carry them: they do not block the save, but an editor who is
1233
+ // never told is how a malformed Sources section reached a published page
1234
+ // (packages#329).
1235
+ let bodyWarnings: ValidationError[] = []
1236
+
1208
1237
  if (parsed.data.content !== undefined) {
1209
1238
  const contentSchema = getContentUpdateSchema(existing.type)
1210
1239
  if (!contentSchema) return json({ error: 'Invalid content type' }, 400)
@@ -1243,6 +1272,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1243
1272
  422,
1244
1273
  )
1245
1274
  }
1275
+ // Warn-severity violations do not block the save, but the editor
1276
+ // has to be told: an unreported warning is why a malformed Sources
1277
+ // section reached a published page (packages#329). Carried into
1278
+ // the 200 payload below rather than only console.warn'd.
1279
+ bodyWarnings = guard.warnings
1246
1280
  }
1247
1281
  }
1248
1282
  }
@@ -1276,6 +1310,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1276
1310
  podcastFoundryQueued: Boolean(podcastAttempt.result),
1277
1311
  podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1278
1312
  podcastFoundryReason: podcastAttempt.reason,
1313
+ // Present and empty when the body linted clean, so a client can
1314
+ // distinguish "no warnings" from "this build does not report them".
1315
+ bodyWarnings,
1279
1316
  })
1280
1317
  },
1281
1318
 
@@ -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
+ contentMetadataSelects: config.contentMetadataSelects,
42
43
  surveyId: config.surveyResults?.surveyId,
43
44
  })
44
45
  createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
@@ -18,8 +18,10 @@
18
18
  //
19
19
  // Compile-gated: all Tiptap/React usage verified by `pnpm run build` (tsc).
20
20
 
21
- import { useCallback, useEffect, useId, useRef, useState } from 'react'
21
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
22
+ import { type ContentMetadata, parseContentMetadata } from '../../engine/content-metadata.js'
22
23
  import { countWords, estimateReadTime } from '../../engine/publisher.js'
24
+ import type { ContentMetadataSelectField } from '../../integration/options.js'
23
25
  import type { ContentStatus, ContentType } from '../../schema/types.js'
24
26
  import { Icon } from '../icons.js'
25
27
  import { uploadMediaAsset } from '../screens/media-upload.js'
@@ -31,8 +33,10 @@ import {
31
33
  type ContentDraftFields,
32
34
  canCreateContentDraft,
33
35
  foundryDispatchSourceForDraft,
36
+ initialContentMetadata,
34
37
  normalizeMediaDurationSeconds,
35
38
  slugifyTitle,
39
+ updateContentMetadataSelection,
36
40
  } from './content-payload.js'
37
41
  import { measureEditorMediaDurationSeconds } from './editor-media-duration.js'
38
42
  import { uploadEditorImage } from './editor-media-upload.js'
@@ -63,6 +67,7 @@ export interface ContentFormProps {
63
67
  /** Called when save state changes so editor chrome can show a real save action. */
64
68
  onSaveStateChange?: (state: ContentSaveState) => void
65
69
  primaryCategories?: Array<{ slug: string; label: string }>
70
+ contentMetadataSelects?: ContentMetadataSelectField[]
66
71
  }
67
72
 
68
73
  export interface ContentSaveState {
@@ -106,6 +111,7 @@ interface ExistingContentItem {
106
111
  published_at?: number | null
107
112
  hero_image_id?: string | null
108
113
  hero_image_url?: string | null
114
+ metadata_json?: string
109
115
  }
110
116
 
111
117
  interface ExistingContentPayload {
@@ -149,6 +155,7 @@ interface FormState {
149
155
  publishedAt: string | null
150
156
  premium: boolean
151
157
  channel: string | null
158
+ metadata: ContentMetadata
152
159
  heroImageId: string
153
160
  heroImageUrl: string | null
154
161
  heroUploadStatus: 'idle' | 'uploading' | 'staged'
@@ -180,7 +187,9 @@ interface FormState {
180
187
  aiOpen: boolean
181
188
  }
182
189
 
183
- function createEmptyFormState(docId: string | null): FormState {
190
+ const EMPTY_CONTENT_METADATA_SELECTS: ContentMetadataSelectField[] = []
191
+
192
+ function createEmptyFormState(docId: string | null, metadata: ContentMetadata = {}): FormState {
184
193
  return {
185
194
  status: 'draft',
186
195
  title: '',
@@ -197,6 +206,7 @@ function createEmptyFormState(docId: string | null): FormState {
197
206
  publishedAt: null,
198
207
  premium: false,
199
208
  channel: null,
209
+ metadata,
200
210
  heroImageId: '',
201
211
  heroImageUrl: null,
202
212
  heroUploadStatus: 'idle',
@@ -238,8 +248,15 @@ export function ContentForm({
238
248
  onStatusChange,
239
249
  onSaveStateChange,
240
250
  primaryCategories = [],
251
+ contentMetadataSelects = EMPTY_CONTENT_METADATA_SELECTS,
241
252
  }: ContentFormProps) {
242
- const [form, setForm] = useState<FormState>(() => createEmptyFormState(docId))
253
+ const metadataFields = useMemo(
254
+ () => contentMetadataSelects.filter((field) => field.contentTypes.includes(contentType)),
255
+ [contentMetadataSelects, contentType],
256
+ )
257
+ const [form, setForm] = useState<FormState>(() =>
258
+ createEmptyFormState(docId, initialContentMetadata(metadataFields, contentType)),
259
+ )
243
260
 
244
261
  // Autosave state (mutable refs, not React state — no re-render needed)
245
262
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -260,7 +277,7 @@ export function ContentForm({
260
277
 
261
278
  const loadExistingContent = useCallback(async () => {
262
279
  if (!docId) {
263
- const next = createEmptyFormState(null)
280
+ const next = createEmptyFormState(null, initialContentMetadata(metadataFields, contentType))
264
281
  setForm(next)
265
282
  publishDraft(next)
266
283
  return
@@ -288,7 +305,7 @@ export function ContentForm({
288
305
  saveError: err instanceof Error ? err.message : 'Could not load content',
289
306
  }))
290
307
  }
291
- }, [contentType, docId, onStatusChange, publishDraft])
308
+ }, [contentType, docId, metadataFields, onStatusChange, publishDraft])
292
309
 
293
310
  // -- Word-count (derived from the body markdown) -------------------------
294
311
  const wordCount = countWords(form.body)
@@ -521,6 +538,16 @@ export function ContentForm({
521
538
  scheduleAutosave(next)
522
539
  }
523
540
 
541
+ function handleMetadataSelectChange(field: ContentMetadataSelectField, value: string) {
542
+ const next = {
543
+ ...latestForm.current,
544
+ metadata: updateContentMetadataSelection(latestForm.current.metadata, field, value),
545
+ }
546
+ setForm(next)
547
+ publishDraft(next)
548
+ scheduleAutosave(next)
549
+ }
550
+
524
551
  function handleBodyChange(md: string, portableText: string) {
525
552
  const next = { ...latestForm.current, body: md, bodyPortableText: portableText }
526
553
  setForm(next)
@@ -895,6 +922,39 @@ export function ContentForm({
895
922
  </div>
896
923
  )}
897
924
 
925
+ {metadataFields.map((field) => {
926
+ const storedValue = form.metadata[field.key]
927
+ const value = typeof storedValue === 'string' ? storedValue : ''
928
+ const inferred =
929
+ field.inferenceMarkerKey !== undefined && form.metadata[field.inferenceMarkerKey] === true
930
+ return (
931
+ <div key={field.key} style={fieldGroup}>
932
+ <label style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--ink-muted)' }}>
933
+ {field.label}
934
+ <select
935
+ value={value}
936
+ onChange={(event) => handleMetadataSelectChange(field, event.currentTarget.value)}
937
+ aria-label={field.label}
938
+ style={urlInput}
939
+ >
940
+ <option value="">Select…</option>
941
+ {field.options.map((option) => (
942
+ <option key={option.value} value={option.value}>
943
+ {option.label}
944
+ </option>
945
+ ))}
946
+ </select>
947
+ </label>
948
+ {(field.helpText || inferred) && (
949
+ <div style={assetHelp}>
950
+ {inferred ? 'Inferred value — review and choose the best fit. ' : ''}
951
+ {field.helpText}
952
+ </div>
953
+ )}
954
+ </div>
955
+ )
956
+ })}
957
+
898
958
  {/* Signature masthead hairline between the standfirst and the body */}
899
959
  <hr className="masthead-rule" style={{ margin: '6px 0 20px' }} />
900
960
 
@@ -1457,6 +1517,7 @@ function contentResponseToFormState(
1457
1517
  next.publishedAt = unixSecondsToIso(item.published_at)
1458
1518
  next.premium = item.visibility === 'premium'
1459
1519
  next.channel = toNullableString(item.channel)
1520
+ next.metadata = storedContentMetadata(item.metadata_json)
1460
1521
  next.heroImageId = heroImageId
1461
1522
  next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
1462
1523
 
@@ -1491,6 +1552,14 @@ function contentResponseToFormState(
1491
1552
  return next
1492
1553
  }
1493
1554
 
1555
+ function storedContentMetadata(raw: string | null | undefined): ContentMetadata {
1556
+ try {
1557
+ return parseContentMetadata(raw ?? '{}')
1558
+ } catch {
1559
+ return {}
1560
+ }
1561
+ }
1562
+
1494
1563
  function formToPreviewDraft(form: FormState, contentType: ContentType): ContentPreviewDraft {
1495
1564
  return {
1496
1565
  id: form.savedId ?? 'draft',
@@ -1,4 +1,6 @@
1
+ import type { ContentMetadata } from '../../engine/content-metadata.js'
1
2
  import { countWords, estimateReadTime } from '../../engine/publisher.js'
3
+ import type { ContentMetadataSelectField } from '../../integration/options.js'
2
4
  import type { ContentType } from '../../schema/types.js'
3
5
 
4
6
  export interface ContentDraftFields {
@@ -17,6 +19,42 @@ export interface ContentDraftFields {
17
19
  audioUrl: string
18
20
  durationSeconds: number | null
19
21
  channel?: string | null
22
+ metadata?: ContentMetadata
23
+ }
24
+
25
+ type MetadataSelectFieldContract = Pick<
26
+ ContentMetadataSelectField,
27
+ 'key' | 'defaultValue' | 'inferenceMarkerKey'
28
+ > & {
29
+ contentTypes: readonly ContentType[]
30
+ options: ReadonlyArray<{ value: string }>
31
+ }
32
+
33
+ export function initialContentMetadata(
34
+ fields: readonly MetadataSelectFieldContract[],
35
+ contentType: ContentType,
36
+ ): ContentMetadata {
37
+ const metadata: ContentMetadata = {}
38
+ for (const field of fields) {
39
+ if (!field.contentTypes.includes(contentType) || !field.defaultValue) continue
40
+ metadata[field.key] = field.defaultValue
41
+ }
42
+ return metadata
43
+ }
44
+
45
+ export function updateContentMetadataSelection(
46
+ metadata: Readonly<ContentMetadata>,
47
+ field: MetadataSelectFieldContract,
48
+ value: string,
49
+ ): ContentMetadata {
50
+ if (value && !field.options.some((option) => option.value === value)) {
51
+ throw new RangeError(`Unknown option for metadata field ${field.key}`)
52
+ }
53
+ const next: ContentMetadata = { ...metadata }
54
+ if (value) next[field.key] = value
55
+ else delete next[field.key]
56
+ if (field.inferenceMarkerKey) delete next[field.inferenceMarkerKey]
57
+ return next
20
58
  }
21
59
 
22
60
  export function normalizeMediaDurationSeconds(value: unknown): number | null {
@@ -97,6 +135,7 @@ export function buildContentUpdatePayload(
97
135
  title: draft.title,
98
136
  excerpt: draft.dek || null,
99
137
  }
138
+ if (draft.metadata !== undefined) payload.metadata = draft.metadata
100
139
  const channel = draft.channel?.trim()
101
140
  payload.primaryCategory = channel || null
102
141
  const heroImageId = draft.heroImageId?.trim()
@@ -8,6 +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
12
  import { type ContentType, contentViewReducer, initialContentView } from './content-view.js'
12
13
  import { EditorScreen } from './EditorScreen.js'
13
14
  import { LibraryScreen } from './LibraryScreen.js'
@@ -15,9 +16,11 @@ import { LibraryScreen } from './LibraryScreen.js'
15
16
  export function ContentRoute({
16
17
  allowInlineAuthorCreation = true,
17
18
  primaryCategories = [],
19
+ contentMetadataSelects = [],
18
20
  }: {
19
21
  allowInlineAuthorCreation?: boolean
20
22
  primaryCategories?: Array<{ slug: string; label: string }>
23
+ contentMetadataSelects?: ContentMetadataSelectField[]
21
24
  }) {
22
25
  const [view, dispatch] = useReducer(contentViewReducer, initialContentView)
23
26
 
@@ -49,6 +52,7 @@ export function ContentRoute({
49
52
  onClose={handleClose}
50
53
  allowInlineAuthorCreation={allowInlineAuthorCreation}
51
54
  primaryCategories={primaryCategories}
55
+ contentMetadataSelects={contentMetadataSelects}
52
56
  />
53
57
  )
54
58
  }
@@ -17,6 +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
21
  import type { ContentStatus, ContentType } from '../../schema/types.js'
21
22
  import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
22
23
  import {
@@ -40,6 +41,7 @@ export interface EditorScreenProps {
40
41
  onClose: () => void
41
42
  allowInlineAuthorCreation?: boolean
42
43
  primaryCategories?: Array<{ slug: string; label: string }>
44
+ contentMetadataSelects?: ContentMetadataSelectField[]
43
45
  }
44
46
 
45
47
  // ---------------------------------------------------------------------------
@@ -81,6 +83,7 @@ export function EditorScreen({
81
83
  onClose,
82
84
  allowInlineAuthorCreation = true,
83
85
  primaryCategories = [],
86
+ contentMetadataSelects = [],
84
87
  }: EditorScreenProps) {
85
88
  const contentType: ContentType = (docRef.contentType as ContentType | undefined) ?? 'article'
86
89
  const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
@@ -377,6 +380,7 @@ export function EditorScreen({
377
380
  onStatusChange={setStatus}
378
381
  onSaveStateChange={handleSaveStateChange}
379
382
  primaryCategories={primaryCategories}
383
+ contentMetadataSelects={contentMetadataSelects}
380
384
  />
381
385
  </div>
382
386
 
@@ -11,6 +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
15
  import type { ScreenRegistry } from '../components/CmsApp.js'
15
16
  import { useHashRouter } from '../use-hash-router.js'
16
17
  import { AnalyticsScreen } from './AnalyticsScreen.js'
@@ -42,10 +43,12 @@ function CalendarRoute() {
42
43
  export function createCmsScreens({
43
44
  allowInlineAuthorCreation = true,
44
45
  primaryCategories = [],
46
+ contentMetadataSelects = [],
45
47
  surveyId,
46
48
  }: {
47
49
  allowInlineAuthorCreation?: boolean
48
50
  primaryCategories?: Array<{ slug: string; label: string }>
51
+ contentMetadataSelects?: ContentMetadataSelectField[]
49
52
  surveyId?: string
50
53
  } = {}): ScreenRegistry {
51
54
  return {
@@ -53,6 +56,7 @@ export function createCmsScreens({
53
56
  <ContentRoute
54
57
  allowInlineAuthorCreation={allowInlineAuthorCreation}
55
58
  primaryCategories={primaryCategories}
59
+ contentMetadataSelects={contentMetadataSelects}
56
60
  />
57
61
  ),
58
62
  'content-insights': () => <ContentInsightsScreen />,