@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.
- package/README.md +20 -0
- package/dist/engine/index.d.ts +5 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +5 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-continuity.d.ts +77 -0
- package/dist/engine/publication-continuity.d.ts.map +1 -0
- package/dist/engine/publication-continuity.js +94 -0
- package/dist/engine/publication-continuity.js.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.js +100 -0
- package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
- package/dist/engine/publication-receipt/receipt.d.ts +316 -0
- package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
- package/dist/engine/publication-receipt/receipt.js +131 -0
- package/dist/engine/publication-receipt/receipt.js.map +1 -0
- package/dist/engine/publication-receipt/signature.d.ts +36 -0
- package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
- package/dist/engine/publication-receipt/signature.js +79 -0
- package/dist/engine/publication-receipt/signature.js.map +1 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +2 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +2 -0
- package/dist/engine/publisher.js.map +1 -1
- 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/engine/source-sections.d.ts +20 -0
- package/dist/engine/source-sections.d.ts.map +1 -0
- package/dist/engine/source-sections.js +130 -0
- package/dist/engine/source-sections.js.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
- package/dist/engine/validator/index.d.ts +10 -0
- package/dist/engine/validator/index.d.ts.map +1 -1
- package/dist/engine/validator/index.js +4 -0
- package/dist/engine/validator/index.js.map +1 -1
- package/dist/engine/validator/types.d.ts +1 -1
- package/dist/engine/validator/types.d.ts.map +1 -1
- package/dist/engine/validator/types.js +10 -0
- package/dist/engine/validator/types.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/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +52 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +13 -1
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +28 -28
- package/dist/surveys/access.d.ts +2 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +7 -0
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/errors.d.ts +8 -0
- package/dist/surveys/errors.d.ts.map +1 -1
- package/dist/surveys/errors.js +12 -0
- package/dist/surveys/errors.js.map +1 -1
- package/dist/surveys/schema.d.ts +75 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +45 -0
- package/dist/surveys/schema.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 +87 -9
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/body-warnings.d.ts +27 -0
- package/dist/ui/editor/body-warnings.d.ts.map +1 -0
- package/dist/ui/editor/body-warnings.js +58 -0
- package/dist/ui/editor/body-warnings.js.map +1 -0
- 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/SurveyResponsesScreen.d.ts +7 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
- package/dist/ui/screens/SurveyResponsesScreen.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/index.ts +33 -0
- package/src/engine/publication-continuity.ts +140 -0
- package/src/engine/publication-receipt/canonical-json.ts +123 -0
- package/src/engine/publication-receipt/receipt.ts +160 -0
- package/src/engine/publication-receipt/signature.ts +112 -0
- package/src/engine/publication.ts +2 -0
- package/src/engine/publisher.ts +2 -0
- package/src/engine/slug.ts +12 -1
- package/src/engine/source-sections.ts +153 -0
- package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
- package/src/engine/validator/index.ts +12 -0
- package/src/engine/validator/types.ts +19 -0
- package/src/integration/options.ts +69 -0
- package/src/providers/types.ts +1 -1
- package/src/routes/content.ts +60 -2
- package/src/routes/surveys.ts +12 -1
- package/src/surveys/access.ts +9 -0
- package/src/surveys/errors.ts +12 -0
- package/src/surveys/schema.ts +47 -0
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/editor/ContentForm.tsx +167 -5
- package/src/ui/editor/body-warnings.ts +78 -0
- 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/SurveyResponsesScreen.tsx +369 -121
- 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
|
-
|
|
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
|
|
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
|
|