@growth-labs/cms 0.5.27 → 0.5.30

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 (130) hide show
  1. package/README.md +20 -0
  2. package/dist/engine/index.d.ts +5 -0
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +5 -0
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/engine/publication-continuity.d.ts +77 -0
  7. package/dist/engine/publication-continuity.d.ts.map +1 -0
  8. package/dist/engine/publication-continuity.js +94 -0
  9. package/dist/engine/publication-continuity.js.map +1 -0
  10. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  11. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  12. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  13. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  14. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  15. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  16. package/dist/engine/publication-receipt/receipt.js +131 -0
  17. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  18. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  19. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  20. package/dist/engine/publication-receipt/signature.js +79 -0
  21. package/dist/engine/publication-receipt/signature.js.map +1 -0
  22. package/dist/engine/publication.d.ts.map +1 -1
  23. package/dist/engine/publication.js +2 -0
  24. package/dist/engine/publication.js.map +1 -1
  25. package/dist/engine/publisher.d.ts.map +1 -1
  26. package/dist/engine/publisher.js +2 -0
  27. package/dist/engine/publisher.js.map +1 -1
  28. package/dist/engine/slug.d.ts.map +1 -1
  29. package/dist/engine/slug.js +11 -1
  30. package/dist/engine/slug.js.map +1 -1
  31. package/dist/engine/source-sections.d.ts +20 -0
  32. package/dist/engine/source-sections.d.ts.map +1 -0
  33. package/dist/engine/source-sections.js +130 -0
  34. package/dist/engine/source-sections.js.map +1 -0
  35. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  36. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  37. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  38. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  39. package/dist/engine/validator/index.d.ts +10 -0
  40. package/dist/engine/validator/index.d.ts.map +1 -1
  41. package/dist/engine/validator/index.js +4 -0
  42. package/dist/engine/validator/index.js.map +1 -1
  43. package/dist/engine/validator/types.d.ts +1 -1
  44. package/dist/engine/validator/types.d.ts.map +1 -1
  45. package/dist/engine/validator/types.js +10 -0
  46. package/dist/engine/validator/types.js.map +1 -1
  47. package/dist/integration/options.d.ts +196 -0
  48. package/dist/integration/options.d.ts.map +1 -1
  49. package/dist/integration/options.js +62 -0
  50. package/dist/integration/options.js.map +1 -1
  51. package/dist/providers/types.d.ts +1 -0
  52. package/dist/providers/types.d.ts.map +1 -1
  53. package/dist/routes/content.d.ts.map +1 -1
  54. package/dist/routes/content.js +52 -2
  55. package/dist/routes/content.js.map +1 -1
  56. package/dist/routes/surveys.d.ts.map +1 -1
  57. package/dist/routes/surveys.js +13 -1
  58. package/dist/routes/surveys.js.map +1 -1
  59. package/dist/schema/insights-ingest.d.ts +28 -28
  60. package/dist/surveys/access.d.ts +2 -0
  61. package/dist/surveys/access.d.ts.map +1 -1
  62. package/dist/surveys/access.js +7 -0
  63. package/dist/surveys/access.js.map +1 -1
  64. package/dist/surveys/errors.d.ts +8 -0
  65. package/dist/surveys/errors.d.ts.map +1 -1
  66. package/dist/surveys/errors.js +12 -0
  67. package/dist/surveys/errors.js.map +1 -1
  68. package/dist/surveys/schema.d.ts +75 -0
  69. package/dist/surveys/schema.d.ts.map +1 -1
  70. package/dist/surveys/schema.js +45 -0
  71. package/dist/surveys/schema.js.map +1 -1
  72. package/dist/ui/client/mount.d.ts.map +1 -1
  73. package/dist/ui/client/mount.js +1 -0
  74. package/dist/ui/client/mount.js.map +1 -1
  75. package/dist/ui/editor/ContentForm.d.ts +3 -1
  76. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  77. package/dist/ui/editor/ContentForm.js +87 -9
  78. package/dist/ui/editor/ContentForm.js.map +1 -1
  79. package/dist/ui/editor/body-warnings.d.ts +27 -0
  80. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  81. package/dist/ui/editor/body-warnings.js +58 -0
  82. package/dist/ui/editor/body-warnings.js.map +1 -0
  83. package/dist/ui/editor/content-payload.d.ts +12 -0
  84. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  85. package/dist/ui/editor/content-payload.js +24 -0
  86. package/dist/ui/editor/content-payload.js.map +1 -1
  87. package/dist/ui/screens/ContentRoute.d.ts +3 -1
  88. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  89. package/dist/ui/screens/ContentRoute.js +2 -2
  90. package/dist/ui/screens/ContentRoute.js.map +1 -1
  91. package/dist/ui/screens/EditorScreen.d.ts +3 -1
  92. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  93. package/dist/ui/screens/EditorScreen.js +2 -2
  94. package/dist/ui/screens/EditorScreen.js.map +1 -1
  95. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  96. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  97. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  98. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  99. package/dist/ui/screens/registry.d.ts +3 -1
  100. package/dist/ui/screens/registry.d.ts.map +1 -1
  101. package/dist/ui/screens/registry.js +2 -2
  102. package/dist/ui/screens/registry.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/engine/index.ts +33 -0
  105. package/src/engine/publication-continuity.ts +140 -0
  106. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  107. package/src/engine/publication-receipt/receipt.ts +160 -0
  108. package/src/engine/publication-receipt/signature.ts +112 -0
  109. package/src/engine/publication.ts +2 -0
  110. package/src/engine/publisher.ts +2 -0
  111. package/src/engine/slug.ts +12 -1
  112. package/src/engine/source-sections.ts +153 -0
  113. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  114. package/src/engine/validator/index.ts +12 -0
  115. package/src/engine/validator/types.ts +19 -0
  116. package/src/integration/options.ts +69 -0
  117. package/src/providers/types.ts +1 -1
  118. package/src/routes/content.ts +60 -2
  119. package/src/routes/surveys.ts +12 -1
  120. package/src/surveys/access.ts +9 -0
  121. package/src/surveys/errors.ts +12 -0
  122. package/src/surveys/schema.ts +47 -0
  123. package/src/ui/client/mount.tsx +1 -0
  124. package/src/ui/editor/ContentForm.tsx +167 -5
  125. package/src/ui/editor/body-warnings.ts +78 -0
  126. package/src/ui/editor/content-payload.ts +39 -0
  127. package/src/ui/screens/ContentRoute.tsx +4 -0
  128. package/src/ui/screens/EditorScreen.tsx +4 -0
  129. package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
  130. package/src/ui/screens/registry.tsx +4 -0
@@ -18,21 +18,26 @@
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'
26
28
  import { AiAssistPanel } from './AiAssistPanel.js'
27
29
  import { AUTOSAVE_DEBOUNCE_MS, shouldSnapshot } from './autosave.js'
30
+ import { type BodyWarning, describeBodyWarnings, sortBodyWarnings } from './body-warnings.js'
28
31
  import {
29
32
  buildContentCreatePayload,
30
33
  buildContentUpdatePayload,
31
34
  type ContentDraftFields,
32
35
  canCreateContentDraft,
33
36
  foundryDispatchSourceForDraft,
37
+ initialContentMetadata,
34
38
  normalizeMediaDurationSeconds,
35
39
  slugifyTitle,
40
+ updateContentMetadataSelection,
36
41
  } from './content-payload.js'
37
42
  import { measureEditorMediaDurationSeconds } from './editor-media-duration.js'
38
43
  import { uploadEditorImage } from './editor-media-upload.js'
@@ -63,6 +68,7 @@ export interface ContentFormProps {
63
68
  /** Called when save state changes so editor chrome can show a real save action. */
64
69
  onSaveStateChange?: (state: ContentSaveState) => void
65
70
  primaryCategories?: Array<{ slug: string; label: string }>
71
+ contentMetadataSelects?: ContentMetadataSelectField[]
66
72
  }
67
73
 
68
74
  export interface ContentSaveState {
@@ -106,6 +112,7 @@ interface ExistingContentItem {
106
112
  published_at?: number | null
107
113
  hero_image_id?: string | null
108
114
  hero_image_url?: string | null
115
+ metadata_json?: string
109
116
  }
110
117
 
111
118
  interface ExistingContentPayload {
@@ -149,6 +156,7 @@ interface FormState {
149
156
  publishedAt: string | null
150
157
  premium: boolean
151
158
  channel: string | null
159
+ metadata: ContentMetadata
152
160
  heroImageId: string
153
161
  heroImageUrl: string | null
154
162
  heroUploadStatus: 'idle' | 'uploading' | 'staged'
@@ -178,9 +186,17 @@ interface FormState {
178
186
  lastSavedAt: number | null
179
187
  /** Whether the AI assist panel is open. */
180
188
  aiOpen: boolean
189
+ /**
190
+ * Warn-severity body lint from the last save (#329). Previously the 200
191
+ * payload was discarded, which is how a malformed Sources section reached a
192
+ * published page — the server reported it and nothing showed the editor.
193
+ */
194
+ bodyWarnings: BodyWarning[]
181
195
  }
182
196
 
183
- function createEmptyFormState(docId: string | null): FormState {
197
+ const EMPTY_CONTENT_METADATA_SELECTS: ContentMetadataSelectField[] = []
198
+
199
+ function createEmptyFormState(docId: string | null, metadata: ContentMetadata = {}): FormState {
184
200
  return {
185
201
  status: 'draft',
186
202
  title: '',
@@ -197,6 +213,7 @@ function createEmptyFormState(docId: string | null): FormState {
197
213
  publishedAt: null,
198
214
  premium: false,
199
215
  channel: null,
216
+ metadata,
200
217
  heroImageId: '',
201
218
  heroImageUrl: null,
202
219
  heroUploadStatus: 'idle',
@@ -219,6 +236,7 @@ function createEmptyFormState(docId: string | null): FormState {
219
236
  savedId: docId,
220
237
  saving: false,
221
238
  saveError: null,
239
+ bodyWarnings: [],
222
240
  lastSavedAt: null,
223
241
  aiOpen: false,
224
242
  }
@@ -238,8 +256,15 @@ export function ContentForm({
238
256
  onStatusChange,
239
257
  onSaveStateChange,
240
258
  primaryCategories = [],
259
+ contentMetadataSelects = EMPTY_CONTENT_METADATA_SELECTS,
241
260
  }: ContentFormProps) {
242
- const [form, setForm] = useState<FormState>(() => createEmptyFormState(docId))
261
+ const metadataFields = useMemo(
262
+ () => contentMetadataSelects.filter((field) => field.contentTypes.includes(contentType)),
263
+ [contentMetadataSelects, contentType],
264
+ )
265
+ const [form, setForm] = useState<FormState>(() =>
266
+ createEmptyFormState(docId, initialContentMetadata(metadataFields, contentType)),
267
+ )
243
268
 
244
269
  // Autosave state (mutable refs, not React state — no re-render needed)
245
270
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -260,7 +285,7 @@ export function ContentForm({
260
285
 
261
286
  const loadExistingContent = useCallback(async () => {
262
287
  if (!docId) {
263
- const next = createEmptyFormState(null)
288
+ const next = createEmptyFormState(null, initialContentMetadata(metadataFields, contentType))
264
289
  setForm(next)
265
290
  publishDraft(next)
266
291
  return
@@ -288,7 +313,7 @@ export function ContentForm({
288
313
  saveError: err instanceof Error ? err.message : 'Could not load content',
289
314
  }))
290
315
  }
291
- }, [contentType, docId, onStatusChange, publishDraft])
316
+ }, [contentType, docId, metadataFields, onStatusChange, publishDraft])
292
317
 
293
318
  // -- Word-count (derived from the body markdown) -------------------------
294
319
  const wordCount = countWords(form.body)
@@ -424,6 +449,17 @@ export function ContentForm({
424
449
  return
425
450
  }
426
451
 
452
+ // Carry warn-severity body lint into the editor. The server has
453
+ // reported these since #453; dropping the 200 payload here is what
454
+ // left them invisible.
455
+ const saved = (await res.json().catch(() => ({}))) as {
456
+ bodyWarnings?: BodyWarning[]
457
+ }
458
+ setForm((prev) => ({
459
+ ...prev,
460
+ bodyWarnings: Array.isArray(saved.bodyWarnings) ? saved.bodyWarnings : [],
461
+ }))
462
+
427
463
  // Snapshot cadence check
428
464
  const now = Date.now()
429
465
  savesSinceSnapshot.current += 1
@@ -521,6 +557,16 @@ export function ContentForm({
521
557
  scheduleAutosave(next)
522
558
  }
523
559
 
560
+ function handleMetadataSelectChange(field: ContentMetadataSelectField, value: string) {
561
+ const next = {
562
+ ...latestForm.current,
563
+ metadata: updateContentMetadataSelection(latestForm.current.metadata, field, value),
564
+ }
565
+ setForm(next)
566
+ publishDraft(next)
567
+ scheduleAutosave(next)
568
+ }
569
+
524
570
  function handleBodyChange(md: string, portableText: string) {
525
571
  const next = { ...latestForm.current, body: md, bodyPortableText: portableText }
526
572
  setForm(next)
@@ -895,6 +941,39 @@ export function ContentForm({
895
941
  </div>
896
942
  )}
897
943
 
944
+ {metadataFields.map((field) => {
945
+ const storedValue = form.metadata[field.key]
946
+ const value = typeof storedValue === 'string' ? storedValue : ''
947
+ const inferred =
948
+ field.inferenceMarkerKey !== undefined && form.metadata[field.inferenceMarkerKey] === true
949
+ return (
950
+ <div key={field.key} style={fieldGroup}>
951
+ <label style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--ink-muted)' }}>
952
+ {field.label}
953
+ <select
954
+ value={value}
955
+ onChange={(event) => handleMetadataSelectChange(field, event.currentTarget.value)}
956
+ aria-label={field.label}
957
+ style={urlInput}
958
+ >
959
+ <option value="">Select…</option>
960
+ {field.options.map((option) => (
961
+ <option key={option.value} value={option.value}>
962
+ {option.label}
963
+ </option>
964
+ ))}
965
+ </select>
966
+ </label>
967
+ {(field.helpText || inferred) && (
968
+ <div style={assetHelp}>
969
+ {inferred ? 'Inferred value — review and choose the best fit. ' : ''}
970
+ {field.helpText}
971
+ </div>
972
+ )}
973
+ </div>
974
+ )
975
+ })}
976
+
898
977
  {/* Signature masthead hairline between the standfirst and the body */}
899
978
  <hr className="masthead-rule" style={{ margin: '6px 0 20px' }} />
900
979
 
@@ -1044,6 +1123,33 @@ export function ContentForm({
1044
1123
  )}
1045
1124
  </div>
1046
1125
 
1126
+ {/* Body lint (#329). Shown beside the body editor so the correction is
1127
+ next to the text it applies to, rather than in a toast that is gone
1128
+ before the editor can act on it. */}
1129
+ {form.bodyWarnings.length > 0 && (
1130
+ <div style={bodyWarningPanel} role="status" aria-label="Body formatting warnings">
1131
+ <div style={bodyWarningTitle}>
1132
+ <Icon name="edit" size={12} style={{ color: 'var(--ink-faint)' }} />
1133
+ <span>
1134
+ {form.bodyWarnings.length}{' '}
1135
+ {form.bodyWarnings.length === 1 ? 'formatting issue' : 'formatting issues'} — saved,
1136
+ but these will block publishing
1137
+ </span>
1138
+ </div>
1139
+ <ul style={bodyWarningList}>
1140
+ {sortBodyWarnings(describeBodyWarnings(form.body, form.bodyWarnings)).map((warning) => (
1141
+ <li key={`${warning.code}:${warning.line ?? 'x'}`} style={bodyWarningItem}>
1142
+ <span style={bodyWarningLine}>
1143
+ {warning.line === null ? 'Sources' : `Line ${warning.line}`}
1144
+ </span>
1145
+ <span>{warning.message}</span>
1146
+ {warning.excerpt ? <code style={bodyWarningCode}>{warning.excerpt}</code> : null}
1147
+ </li>
1148
+ ))}
1149
+ </ul>
1150
+ </div>
1151
+ )}
1152
+
1047
1153
  {/* Footer: word count + read time (only for prose content) */}
1048
1154
  {(contentType === 'article' || contentType === 'newsletter' || contentType === 'page') && (
1049
1155
  <div style={footer}>
@@ -1457,6 +1563,7 @@ function contentResponseToFormState(
1457
1563
  next.publishedAt = unixSecondsToIso(item.published_at)
1458
1564
  next.premium = item.visibility === 'premium'
1459
1565
  next.channel = toNullableString(item.channel)
1566
+ next.metadata = storedContentMetadata(item.metadata_json)
1460
1567
  next.heroImageId = heroImageId
1461
1568
  next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
1462
1569
 
@@ -1491,6 +1598,14 @@ function contentResponseToFormState(
1491
1598
  return next
1492
1599
  }
1493
1600
 
1601
+ function storedContentMetadata(raw: string | null | undefined): ContentMetadata {
1602
+ try {
1603
+ return parseContentMetadata(raw ?? '{}')
1604
+ } catch {
1605
+ return {}
1606
+ }
1607
+ }
1608
+
1494
1609
  function formToPreviewDraft(form: FormState, contentType: ContentType): ContentPreviewDraft {
1495
1610
  return {
1496
1611
  id: form.savedId ?? 'draft',
@@ -1611,6 +1726,53 @@ const dekInput: React.CSSProperties = {
1611
1726
  resize: 'none',
1612
1727
  }
1613
1728
 
1729
+ const bodyWarningPanel: React.CSSProperties = {
1730
+ marginTop: 16,
1731
+ padding: '12px 14px',
1732
+ borderRadius: 6,
1733
+ border: '1px solid var(--border-soft)',
1734
+ borderLeft: '3px solid var(--warn, #b8860b)',
1735
+ background: 'var(--surface-sunken, rgba(0,0,0,0.02))',
1736
+ }
1737
+
1738
+ const bodyWarningTitle: React.CSSProperties = {
1739
+ display: 'flex',
1740
+ alignItems: 'center',
1741
+ gap: 8,
1742
+ fontFamily: 'var(--mono)',
1743
+ fontSize: 11.5,
1744
+ color: 'var(--ink-muted)',
1745
+ marginBottom: 8,
1746
+ }
1747
+
1748
+ const bodyWarningList: React.CSSProperties = {
1749
+ display: 'grid',
1750
+ gap: 8,
1751
+ margin: 0,
1752
+ padding: 0,
1753
+ listStyle: 'none',
1754
+ }
1755
+
1756
+ const bodyWarningItem: React.CSSProperties = {
1757
+ display: 'grid',
1758
+ gap: 3,
1759
+ fontSize: 13,
1760
+ lineHeight: 1.45,
1761
+ }
1762
+
1763
+ const bodyWarningLine: React.CSSProperties = {
1764
+ fontFamily: 'var(--mono)',
1765
+ fontSize: 11,
1766
+ color: 'var(--ink-faint)',
1767
+ }
1768
+
1769
+ const bodyWarningCode: React.CSSProperties = {
1770
+ fontFamily: 'var(--mono)',
1771
+ fontSize: 11.5,
1772
+ color: 'var(--ink-muted)',
1773
+ overflowWrap: 'anywhere',
1774
+ }
1775
+
1614
1776
  const footer: React.CSSProperties = {
1615
1777
  display: 'flex',
1616
1778
  alignItems: 'center',
@@ -0,0 +1,78 @@
1
+ // Turn validator output into something an editor can act on (#329).
2
+ //
3
+ // The API returns `bodyWarnings` with character spans. An editor cannot use a
4
+ // character offset — the design asks for "the affected line and a plain-language
5
+ // correction" — so this maps each span onto a 1-based line number and the text
6
+ // of that line.
7
+ //
8
+ // Pure on purpose: the mapping is the part worth testing, and keeping it out of
9
+ // the component means it can be tested without rendering anything.
10
+
11
+ export interface BodyWarning {
12
+ code: string
13
+ message: string
14
+ span?: { start: number; end: number }
15
+ }
16
+
17
+ export interface DescribedBodyWarning {
18
+ code: string
19
+ message: string
20
+ /** 1-based line number, or null when the warning carries no span. */
21
+ line: number | null
22
+ /** The offending line, trimmed. Null when there is no span to locate. */
23
+ excerpt: string | null
24
+ }
25
+
26
+ /** Longest excerpt shown inline before truncation. */
27
+ const MAX_EXCERPT = 120
28
+
29
+ function lineAt(markdown: string, offset: number): { line: number; text: string } | null {
30
+ if (offset < 0 || offset > markdown.length) return null
31
+ const before = markdown.slice(0, offset)
32
+ const line = before.split('\n').length
33
+ const lineStart = before.lastIndexOf('\n') + 1
34
+ const nextBreak = markdown.indexOf('\n', lineStart)
35
+ const text = markdown.slice(lineStart, nextBreak === -1 ? markdown.length : nextBreak)
36
+ return { line, text }
37
+ }
38
+
39
+ function truncate(value: string): string {
40
+ return value.length > MAX_EXCERPT ? `${value.slice(0, MAX_EXCERPT - 1)}…` : value
41
+ }
42
+
43
+ /**
44
+ * Attach a line number and excerpt to each warning.
45
+ *
46
+ * Warnings without a span keep `line: null` rather than being dropped or given
47
+ * a fabricated line 1 — a wrong line number sends an editor to the wrong place,
48
+ * which is worse than admitting the position is unknown.
49
+ */
50
+ export function describeBodyWarnings(
51
+ markdown: string | null | undefined,
52
+ warnings: readonly BodyWarning[] | null | undefined,
53
+ ): DescribedBodyWarning[] {
54
+ if (!warnings || warnings.length === 0) return []
55
+ const source = markdown ?? ''
56
+
57
+ return warnings.map((warning) => {
58
+ const located = warning.span ? lineAt(source, warning.span.start) : null
59
+ return {
60
+ code: warning.code,
61
+ message: warning.message,
62
+ line: located ? located.line : null,
63
+ excerpt: located ? truncate(located.text.trim()) : null,
64
+ }
65
+ })
66
+ }
67
+
68
+ /** Stable ordering for display: by line, then by code. Unlocated ones last. */
69
+ export function sortBodyWarnings(
70
+ warnings: readonly DescribedBodyWarning[],
71
+ ): DescribedBodyWarning[] {
72
+ return [...warnings].sort((a, b) => {
73
+ if (a.line === b.line) return a.code.localeCompare(b.code)
74
+ if (a.line === null) return 1
75
+ if (b.line === null) return -1
76
+ return a.line - b.line
77
+ })
78
+ }
@@ -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