@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.
- package/README.md +20 -0
- package/dist/engine/slug.d.ts.map +1 -1
- package/dist/engine/slug.js +11 -1
- package/dist/engine/slug.js.map +1 -1
- package/dist/integration/options.d.ts +196 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +62 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +30 -0
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts +3 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +35 -8
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +12 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +24 -0
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/screens/ContentRoute.d.ts +3 -1
- package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
- package/dist/ui/screens/ContentRoute.js +2 -2
- package/dist/ui/screens/ContentRoute.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +3 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +2 -2
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/registry.d.ts +3 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/slug.ts +12 -1
- package/src/integration/options.ts +69 -0
- package/src/routes/content.ts +37 -0
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/editor/ContentForm.tsx +74 -5
- package/src/ui/editor/content-payload.ts +39 -0
- package/src/ui/screens/ContentRoute.tsx +4 -0
- package/src/ui/screens/EditorScreen.tsx +4 -0
- 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),
|
package/src/routes/content.ts
CHANGED
|
@@ -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
|
|
package/src/ui/client/mount.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 />,
|