@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-section diagnostics (#329).
|
|
3
|
+
*
|
|
4
|
+
* These run against markdown that has ALREADY been through
|
|
5
|
+
* `normalizeSourceSections()`. That ordering is the whole point: the
|
|
6
|
+
* normalizer fixes everything mechanically fixable, so anything still present
|
|
7
|
+
* here needs an editor's judgement rather than another rewrite — and each of
|
|
8
|
+
* these is a proven render breaker, which is why they block publish.
|
|
9
|
+
*
|
|
10
|
+
* Draft autosave stays allowed regardless; an editor must never lose work to a
|
|
11
|
+
* formatting complaint.
|
|
12
|
+
*/
|
|
13
|
+
import { normalizeSourceSections } from '../../source-sections.js'
|
|
14
|
+
import type {
|
|
15
|
+
ArticleBodyInput,
|
|
16
|
+
ValidationError,
|
|
17
|
+
ValidationErrorCode,
|
|
18
|
+
ValidationSpan,
|
|
19
|
+
} from '../types.js'
|
|
20
|
+
import { makeError } from '../types.js'
|
|
21
|
+
|
|
22
|
+
export interface SourceSectionOptions {
|
|
23
|
+
/** Escalate these diagnostics from warn to block. Publish sets this. */
|
|
24
|
+
blockSourceSectionDefects?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const NBSP = ' '
|
|
28
|
+
const MANUAL_BULLET = /^\s*[●•▪]/u
|
|
29
|
+
const LINK = /\[([^\]]*)\]\(([^)]*)\)/g
|
|
30
|
+
|
|
31
|
+
interface SectionLine {
|
|
32
|
+
text: string
|
|
33
|
+
/** Byte offset of this line in the source document. */
|
|
34
|
+
start: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Lines inside every Sources/References section, with document offsets. */
|
|
38
|
+
function collectSectionLines(source: string): SectionLine[] {
|
|
39
|
+
const lines = source.split('\n')
|
|
40
|
+
const collected: SectionLine[] = []
|
|
41
|
+
let offset = 0
|
|
42
|
+
let sectionLevel: number | null = null
|
|
43
|
+
|
|
44
|
+
for (const text of lines) {
|
|
45
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(text)
|
|
46
|
+
const level = heading ? (heading[1]?.length ?? 0) : null
|
|
47
|
+
|
|
48
|
+
if (sectionLevel !== null && level !== null && level <= sectionLevel) {
|
|
49
|
+
sectionLevel = null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sectionLevel !== null) {
|
|
53
|
+
collected.push({ text, start: offset })
|
|
54
|
+
} else if (level !== null) {
|
|
55
|
+
const word = (heading?.[2] ?? '')
|
|
56
|
+
.replace(/[*_]/g, '')
|
|
57
|
+
.replace(/\s*:\s*$/, '')
|
|
58
|
+
.trim()
|
|
59
|
+
if (/^(sources|references)$/i.test(word)) sectionLevel = level
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
offset += text.length + 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return collected
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isFormattingOnly(text: string): boolean {
|
|
69
|
+
if (text.trim() === '') return false
|
|
70
|
+
return (
|
|
71
|
+
text
|
|
72
|
+
.replaceAll(NBSP, ' ')
|
|
73
|
+
.replace(/[*_~`#]/g, '')
|
|
74
|
+
.trim() === ''
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Emphasis delimiters wrapping the entire label, e.g. `[**t**](u)`. */
|
|
79
|
+
function hasWholeLabelEmphasis(label: string): boolean {
|
|
80
|
+
const match = /^(\*\*|__|\*|_)([\s\S]+)\1$/.exec(label.trim())
|
|
81
|
+
if (!match) return false
|
|
82
|
+
return !(match[2] ?? '').includes(match[1] as string)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An odd number of `**` or `*` runs — ambiguous, so the normalizer left it. */
|
|
86
|
+
function hasUnbalancedEmphasis(label: string): boolean {
|
|
87
|
+
const strong = (label.match(/\*\*/g) ?? []).length
|
|
88
|
+
if (strong % 2 !== 0) return true
|
|
89
|
+
const em = (label.replace(/\*\*/g, '').match(/\*/g) ?? []).length
|
|
90
|
+
if (em % 2 !== 0) return true
|
|
91
|
+
const under = (label.match(/__/g) ?? []).length
|
|
92
|
+
return under % 2 !== 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function check(
|
|
96
|
+
input: ArticleBodyInput,
|
|
97
|
+
options: SourceSectionOptions = {},
|
|
98
|
+
): ValidationError[] {
|
|
99
|
+
// Warn on save, block on publish. The design keeps draft autosave working
|
|
100
|
+
// so an editor cannot lose work to a formatting complaint, while still
|
|
101
|
+
// refusing to publish a section that is a proven render breaker.
|
|
102
|
+
const escalate = options.blockSourceSectionDefects === true
|
|
103
|
+
const report = (
|
|
104
|
+
code: ValidationErrorCode,
|
|
105
|
+
message: string,
|
|
106
|
+
span?: ValidationSpan,
|
|
107
|
+
): ValidationError => {
|
|
108
|
+
const error = makeError(code, message, span)
|
|
109
|
+
return escalate ? { ...error, severity: 'block' } : error
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!input.body_markdown) return []
|
|
113
|
+
|
|
114
|
+
// Report against normalized text so a defect the normalizer would have
|
|
115
|
+
// fixed is never surfaced to the editor as their problem.
|
|
116
|
+
const source = normalizeSourceSections(input.body_markdown).markdown
|
|
117
|
+
const errors: ValidationError[] = []
|
|
118
|
+
const lines = collectSectionLines(source)
|
|
119
|
+
|
|
120
|
+
let linkLike = 0
|
|
121
|
+
let listItems = 0
|
|
122
|
+
|
|
123
|
+
for (const { text, start } of lines) {
|
|
124
|
+
const span = { start, end: start + text.length }
|
|
125
|
+
|
|
126
|
+
if (MANUAL_BULLET.test(text)) {
|
|
127
|
+
errors.push(
|
|
128
|
+
report(
|
|
129
|
+
'source-section-manual-bullet',
|
|
130
|
+
`This source line still starts with a manual bullet character. Replace it with "- " so it renders as one list, not separate paragraphs.`,
|
|
131
|
+
span,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isFormattingOnly(text)) {
|
|
137
|
+
errors.push(
|
|
138
|
+
report(
|
|
139
|
+
'source-section-formatting-only-line',
|
|
140
|
+
`This line contains only formatting characters and renders as an empty heading. Delete it.`,
|
|
141
|
+
span,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (/^\s*[-*+]\s+/.test(text)) listItems++
|
|
147
|
+
|
|
148
|
+
for (const link of text.matchAll(LINK)) {
|
|
149
|
+
const label = link[1] ?? ''
|
|
150
|
+
if (label.trim() !== '') linkLike++
|
|
151
|
+
|
|
152
|
+
if (hasWholeLabelEmphasis(label)) {
|
|
153
|
+
errors.push(
|
|
154
|
+
report(
|
|
155
|
+
'source-section-wrapped-link-label',
|
|
156
|
+
`The link title "${label.trim()}" is wrapped in emphasis markers, which stay visible inside the link. Remove the surrounding * or _ characters.`,
|
|
157
|
+
span,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
} else if (hasUnbalancedEmphasis(label)) {
|
|
161
|
+
errors.push(
|
|
162
|
+
report(
|
|
163
|
+
'source-section-unbalanced-emphasis',
|
|
164
|
+
`The link title "${label.trim()}" has an unmatched emphasis marker. Close it or remove it — it cannot be corrected automatically without changing the title.`,
|
|
165
|
+
span,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A section that reads as a list of sources but has no list items at all
|
|
173
|
+
// renders as a run of paragraphs. Only claim this when there is something
|
|
174
|
+
// list-shaped to talk about.
|
|
175
|
+
if (listItems === 0 && linkLike > 1) {
|
|
176
|
+
const first = lines[0]
|
|
177
|
+
errors.push(
|
|
178
|
+
report(
|
|
179
|
+
'source-section-unlistable',
|
|
180
|
+
`This source section has ${linkLike} links but no list items, so it renders as separate paragraphs. Start each source line with "- ".`,
|
|
181
|
+
first ? { start: first.start, end: first.start + first.text.length } : undefined,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return errors
|
|
187
|
+
}
|
|
@@ -21,6 +21,7 @@ import * as iframeMissingDims from './checks/iframe-missing-dims-and-wrapper.js'
|
|
|
21
21
|
import * as invisibleControl from './checks/invisible-control-chars.js'
|
|
22
22
|
import * as paywallMarker from './checks/paywall-marker-leak.js'
|
|
23
23
|
import * as rawBlockHtml from './checks/raw-block-html.js'
|
|
24
|
+
import * as sourceSectionDiagnostics from './checks/source-section-diagnostics.js'
|
|
24
25
|
import * as unresolvedAnchor from './checks/unresolved-footnote-anchor.js'
|
|
25
26
|
import * as wordGdocs from './checks/word-gdocs-paste-artifacts.js'
|
|
26
27
|
|
|
@@ -302,6 +303,7 @@ export const checkBareUrlNotAutolinked = bareUrl.check
|
|
|
302
303
|
export const checkEmptyAltText = emptyAlt.check
|
|
303
304
|
export const checkIframeMissingDimsAndWrapper = iframeMissingDims.check
|
|
304
305
|
export const checkHeadingHierarchySkip = headingHierarchy.check
|
|
306
|
+
export const checkSourceSectionDiagnostics = sourceSectionDiagnostics.check
|
|
305
307
|
export const checkPaywallMarkerLeak = paywallMarker.check
|
|
306
308
|
|
|
307
309
|
/* -------------------------------------------------------------------------- */
|
|
@@ -310,11 +312,20 @@ export const checkPaywallMarkerLeak = paywallMarker.check
|
|
|
310
312
|
|
|
311
313
|
export interface RunOptions extends ValidatorOptions {
|
|
312
314
|
requireBody: boolean
|
|
315
|
+
/**
|
|
316
|
+
* Escalate source-section diagnostics (#329) from warn to block.
|
|
317
|
+
*
|
|
318
|
+
* Defaults to false so a draft save is never rejected for formatting — the
|
|
319
|
+
* PATCH route returns 422 on any block-severity error. Publish call sites
|
|
320
|
+
* set it to true, which is where the design wants these enforced.
|
|
321
|
+
*/
|
|
322
|
+
blockSourceSectionDefects: boolean
|
|
313
323
|
}
|
|
314
324
|
|
|
315
325
|
export const DEFAULT_RUN_OPTIONS: RunOptions = {
|
|
316
326
|
...DEFAULT_OPTIONS,
|
|
317
327
|
requireBody: true,
|
|
328
|
+
blockSourceSectionDefects: false,
|
|
318
329
|
}
|
|
319
330
|
|
|
320
331
|
/**
|
|
@@ -349,6 +360,7 @@ export function validateArticleBody(
|
|
|
349
360
|
errors.push(...checkEmptyAltText(input))
|
|
350
361
|
errors.push(...checkIframeMissingDimsAndWrapper(input))
|
|
351
362
|
errors.push(...checkHeadingHierarchySkip(input))
|
|
363
|
+
errors.push(...checkSourceSectionDiagnostics(input, opts))
|
|
352
364
|
errors.push(...checkPaywallMarkerLeak(input))
|
|
353
365
|
// Heuristic.
|
|
354
366
|
errors.push(...checkUnbalancedMarkdownEmphasis(input))
|
|
@@ -55,6 +55,15 @@ export type ValidationErrorCode =
|
|
|
55
55
|
| 'heading-hierarchy-skip'
|
|
56
56
|
| 'paywall-marker-leak'
|
|
57
57
|
| 'stale-body-html'
|
|
58
|
+
// Source-section diagnostics (#329). Reported only for defects that
|
|
59
|
+
// SURVIVE normalizeSourceSections() — the normalizer already fixed
|
|
60
|
+
// everything mechanically fixable, so what is left needs an editor's
|
|
61
|
+
// judgement. Warn on save, block on publish; see SEVERITY below.
|
|
62
|
+
| 'source-section-manual-bullet'
|
|
63
|
+
| 'source-section-formatting-only-line'
|
|
64
|
+
| 'source-section-wrapped-link-label'
|
|
65
|
+
| 'source-section-unbalanced-emphasis'
|
|
66
|
+
| 'source-section-unlistable'
|
|
58
67
|
|
|
59
68
|
export type ValidationResult = { ok: true } | { ok: false; errors: ValidationError[] }
|
|
60
69
|
|
|
@@ -88,6 +97,16 @@ export const SEVERITY: Readonly<Record<ValidationErrorCode, Severity>> = {
|
|
|
88
97
|
'heading-hierarchy-skip': 'warn',
|
|
89
98
|
'paywall-marker-leak': 'block',
|
|
90
99
|
'stale-body-html': 'block',
|
|
100
|
+
// #329. WARN by default so a draft save is never rejected — the design is
|
|
101
|
+
// explicit that an editor must not lose work to a formatting complaint, and
|
|
102
|
+
// the PATCH route returns 422 on any block-severity error. Publish call
|
|
103
|
+
// sites pass `blockSourceSectionDefects` to escalate these to block, which
|
|
104
|
+
// is where the design wants them enforced.
|
|
105
|
+
'source-section-manual-bullet': 'warn',
|
|
106
|
+
'source-section-formatting-only-line': 'warn',
|
|
107
|
+
'source-section-wrapped-link-label': 'warn',
|
|
108
|
+
'source-section-unbalanced-emphasis': 'warn',
|
|
109
|
+
'source-section-unlistable': 'warn',
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
export interface ValidatorOptions {
|
|
@@ -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/providers/types.ts
CHANGED
|
@@ -430,7 +430,7 @@ export interface SettingsProvider {
|
|
|
430
430
|
}
|
|
431
431
|
|
|
432
432
|
export interface SurveyProvider {
|
|
433
|
-
getResults(input: { surveyId: string }): Promise<SurveyResults>
|
|
433
|
+
getResults(input: { surveyId: string; offset?: number }): Promise<SurveyResults>
|
|
434
434
|
getCsv(input: { surveyId: string }): Promise<SurveyCsvResult>
|
|
435
435
|
}
|
|
436
436
|
|
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 {
|
|
@@ -51,7 +56,9 @@ import {
|
|
|
51
56
|
} from '../engine/revisions.js'
|
|
52
57
|
import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
|
|
53
58
|
import { softDeleteContent } from '../engine/soft-delete.js'
|
|
59
|
+
import { normalizeSourceSections } from '../engine/source-sections.js'
|
|
54
60
|
import { TagInputError } from '../engine/taxonomy.js'
|
|
61
|
+
import type { ValidationError } from '../engine/validator/index.js'
|
|
55
62
|
import { parseLayoutEnvelope } from '../schema/layout.js'
|
|
56
63
|
import { parsePortableTextEnvelope } from '../schema/portable-text.js'
|
|
57
64
|
import type { ContentStatus } from '../schema/types.js'
|
|
@@ -284,6 +291,21 @@ const BodyContentCreateSchema = z.object({
|
|
|
284
291
|
editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
|
|
285
292
|
})
|
|
286
293
|
|
|
294
|
+
const ContentMetadataSchema = z
|
|
295
|
+
.record(z.string(), z.unknown())
|
|
296
|
+
.superRefine((metadata, ctx) => {
|
|
297
|
+
try {
|
|
298
|
+
serializeContentMetadata(metadata as ContentMetadata)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
ctx.addIssue({
|
|
301
|
+
code: 'custom',
|
|
302
|
+
message:
|
|
303
|
+
error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
.transform((metadata) => metadata as ContentMetadata)
|
|
308
|
+
|
|
287
309
|
function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
288
310
|
const category = slugEnum(resolved.primaryCategorySlugs)
|
|
289
311
|
const topic = slugEnum(resolved.primaryTopicSlugs)
|
|
@@ -306,6 +328,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
306
328
|
socialImageId: z.string().optional().nullable(),
|
|
307
329
|
canonicalUrl: z.string().url().optional().nullable(),
|
|
308
330
|
tags: z.array(z.string()).optional(),
|
|
331
|
+
metadata: ContentMetadataSchema.optional(),
|
|
309
332
|
})
|
|
310
333
|
|
|
311
334
|
const CreateSchema = z.discriminatedUnion('type', [
|
|
@@ -370,6 +393,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
370
393
|
socialImageId: z.string().optional().nullable(),
|
|
371
394
|
canonicalUrl: z.string().url().optional().nullable(),
|
|
372
395
|
tags: z.array(z.string()).optional(),
|
|
396
|
+
metadata: ContentMetadataSchema.optional(),
|
|
373
397
|
content: z.record(z.string(), z.any()).optional(),
|
|
374
398
|
relations: z.array(RelationSchema).optional(),
|
|
375
399
|
aiLockedFields: z.array(z.string()).optional(),
|
|
@@ -1205,6 +1229,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1205
1229
|
return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
|
|
1206
1230
|
}
|
|
1207
1231
|
|
|
1232
|
+
// Warn-severity body-lint results. Declared here so the success payload
|
|
1233
|
+
// below can carry them: they do not block the save, but an editor who is
|
|
1234
|
+
// never told is how a malformed Sources section reached a published page
|
|
1235
|
+
// (packages#329).
|
|
1236
|
+
let bodyWarnings: ValidationError[] = []
|
|
1237
|
+
|
|
1208
1238
|
if (parsed.data.content !== undefined) {
|
|
1209
1239
|
const contentSchema = getContentUpdateSchema(existing.type)
|
|
1210
1240
|
if (!contentSchema) return json({ error: 'Invalid content type' }, 400)
|
|
@@ -1229,9 +1259,29 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1229
1259
|
bodyMarkdown?: string
|
|
1230
1260
|
bodyHtml?: string | null
|
|
1231
1261
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1262
|
+
let bodyMarkdown = articleContent.bodyMarkdown
|
|
1263
|
+
let bodyHtml = articleContent.bodyHtml
|
|
1234
1264
|
const isDraft = existing.status === 'draft'
|
|
1265
|
+
|
|
1266
|
+
// Safe, deterministic Sources/References cleanup is applied before
|
|
1267
|
+
// the body is persisted, so the stored Markdown is canonical and the
|
|
1268
|
+
// editor's next read shows what will actually render (#329).
|
|
1269
|
+
//
|
|
1270
|
+
// When normalization changes the Markdown, any body_html the client
|
|
1271
|
+
// sent alongside it was rendered from the pre-normalization text and
|
|
1272
|
+
// is stale by construction. Persisting it would let the exact defect
|
|
1273
|
+
// this fixes survive in the rendered output, which is how the
|
|
1274
|
+
// original incident reached a published page — so it is dropped and
|
|
1275
|
+
// re-rendered downstream.
|
|
1276
|
+
if (typeof bodyMarkdown === 'string') {
|
|
1277
|
+
const normalized = normalizeSourceSections(bodyMarkdown)
|
|
1278
|
+
if (normalized.changed) {
|
|
1279
|
+
bodyMarkdown = normalized.markdown
|
|
1280
|
+
bodyHtml = null
|
|
1281
|
+
articleContent.bodyMarkdown = normalized.markdown
|
|
1282
|
+
articleContent.bodyHtml = null
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1235
1285
|
if (bodyMarkdown !== undefined || bodyHtml !== undefined) {
|
|
1236
1286
|
const guard = evaluateArticleBody(
|
|
1237
1287
|
{ body_markdown: bodyMarkdown ?? null, body_html: bodyHtml ?? null },
|
|
@@ -1243,6 +1293,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1243
1293
|
422,
|
|
1244
1294
|
)
|
|
1245
1295
|
}
|
|
1296
|
+
// Warn-severity violations do not block the save, but the editor
|
|
1297
|
+
// has to be told: an unreported warning is why a malformed Sources
|
|
1298
|
+
// section reached a published page (packages#329). Carried into
|
|
1299
|
+
// the 200 payload below rather than only console.warn'd.
|
|
1300
|
+
bodyWarnings = guard.warnings
|
|
1246
1301
|
}
|
|
1247
1302
|
}
|
|
1248
1303
|
}
|
|
@@ -1276,6 +1331,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1276
1331
|
podcastFoundryQueued: Boolean(podcastAttempt.result),
|
|
1277
1332
|
podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
|
|
1278
1333
|
podcastFoundryReason: podcastAttempt.reason,
|
|
1334
|
+
// Present and empty when the body linted clean, so a client can
|
|
1335
|
+
// distinguish "no warnings" from "this build does not report them".
|
|
1336
|
+
bodyWarnings,
|
|
1279
1337
|
})
|
|
1280
1338
|
},
|
|
1281
1339
|
|
package/src/routes/surveys.ts
CHANGED
|
@@ -28,9 +28,11 @@ export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandle
|
|
|
28
28
|
if (denied) return withNoStoreAuthFailure(denied)
|
|
29
29
|
const surveyId = parseSurveyId(ctx)
|
|
30
30
|
if (!surveyId) return surveyErrorResponse('survey_invalid_request')
|
|
31
|
+
const offset = parseOffset(ctx)
|
|
32
|
+
if (offset === null) return surveyErrorResponse('survey_invalid_request')
|
|
31
33
|
try {
|
|
32
34
|
const parsed = SurveyResultsSchema.safeParse(
|
|
33
|
-
await providers.surveys?.getResults({ surveyId }),
|
|
35
|
+
await providers.surveys?.getResults({ surveyId, offset }),
|
|
34
36
|
)
|
|
35
37
|
if (!parsed.success || parsed.data.survey.id !== surveyId) {
|
|
36
38
|
return surveyErrorResponse('survey_provider_malformed')
|
|
@@ -70,6 +72,13 @@ async function requireSurveyAccess(authz: Authz, ctx: RouteContext): Promise<Res
|
|
|
70
72
|
return authz.requireRole('view_survey_responses' satisfies Capability, ctx)
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function parseOffset(ctx: RouteContext): number | null {
|
|
76
|
+
const raw = new URL(ctx.request.url).searchParams.get('offset')
|
|
77
|
+
if (raw === null) return 0
|
|
78
|
+
const value = Number(raw)
|
|
79
|
+
return Number.isInteger(value) && value >= 0 ? value : null
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
function parseSurveyId(ctx: RouteContext): string | null {
|
|
74
83
|
const value = ctx.params.surveyId ?? ctx.params.id
|
|
75
84
|
const parsed = SurveyIdSchema.safeParse(value)
|
|
@@ -103,6 +112,8 @@ const ProviderFailureAllowlist = new Map<string, SurveyErrorCode>([
|
|
|
103
112
|
['404:survey_not_found', 'survey_not_found'],
|
|
104
113
|
['500:survey_internal_failure', 'survey_internal_failure'],
|
|
105
114
|
['502:survey_upstream_failure', 'survey_upstream_failure'],
|
|
115
|
+
['502:survey_upstream_auth_failed', 'survey_upstream_auth_failed'],
|
|
116
|
+
['502:survey_invalid_response', 'survey_invalid_response'],
|
|
106
117
|
['503:survey_provider_unavailable', 'survey_provider_unavailable'],
|
|
107
118
|
['503:survey_upstream_unavailable', 'survey_upstream_unavailable'],
|
|
108
119
|
])
|
package/src/surveys/access.ts
CHANGED
|
@@ -9,3 +9,12 @@ export const SURVEY_RESPONSE_ROLES: ReadonlySet<CmsRole> = new Set([
|
|
|
9
9
|
export function canViewSurveyResponses(role: CmsRole | null | undefined): boolean {
|
|
10
10
|
return role != null && SURVEY_RESPONSE_ROLES.has(role)
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// Aggregates are for anyone who can open the screen; individual answers and the
|
|
14
|
+
// respondent attached to them are not. Splitting these lets a team see what
|
|
15
|
+
// readers said in aggregate without exposing every member's words by name.
|
|
16
|
+
export const SURVEY_VERBATIM_ROLES: ReadonlySet<CmsRole> = new Set(['owner', 'senior_editor'])
|
|
17
|
+
|
|
18
|
+
export function canViewSurveyVerbatims(role: CmsRole | null | undefined): boolean {
|
|
19
|
+
return role != null && SURVEY_VERBATIM_ROLES.has(role)
|
|
20
|
+
}
|
package/src/surveys/errors.ts
CHANGED
|
@@ -29,6 +29,18 @@ export const SURVEY_ERROR_DEFINITIONS = {
|
|
|
29
29
|
status: 502,
|
|
30
30
|
error: 'Survey reporting could not be loaded.',
|
|
31
31
|
},
|
|
32
|
+
// Distinct causes, distinct codes. These previously collapsed into
|
|
33
|
+
// survey_upstream_failure, so the screen showed one message for an auth
|
|
34
|
+
// rejection, an upstream outage, and a payload it could not parse — which is
|
|
35
|
+
// how a broken fetch receiver in a consuming host stayed invisible for a month.
|
|
36
|
+
survey_upstream_auth_failed: {
|
|
37
|
+
status: 502,
|
|
38
|
+
error: 'Survey reporting rejected this site\u2019s credentials.',
|
|
39
|
+
},
|
|
40
|
+
survey_invalid_response: {
|
|
41
|
+
status: 502,
|
|
42
|
+
error: 'Survey reporting returned a response we could not read.',
|
|
43
|
+
},
|
|
32
44
|
survey_internal_failure: {
|
|
33
45
|
status: 500,
|
|
34
46
|
error: 'Survey reporting could not be loaded.',
|
package/src/surveys/schema.ts
CHANGED
|
@@ -29,6 +29,11 @@ const BoundedRecordIdSchema = z
|
|
|
29
29
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
|
|
30
30
|
const IsoTimestampSchema = z.string().datetime({ offset: true })
|
|
31
31
|
const CountSchema = z.number().int().nonnegative().max(MAX_COUNT)
|
|
32
|
+
const AnswerValueSchema = z.union([
|
|
33
|
+
z.string().max(MAX_FREE_TEXT_LENGTH),
|
|
34
|
+
z.number().int(),
|
|
35
|
+
z.array(z.string().max(MAX_OPTION_LENGTH)).max(MAX_CHOICE_OPTIONS),
|
|
36
|
+
])
|
|
32
37
|
const NonemptyBoundedStringSchema = z
|
|
33
38
|
.string()
|
|
34
39
|
.min(1)
|
|
@@ -208,6 +213,35 @@ export const SurveyResultsSchema = z
|
|
|
208
213
|
submittedAt: IsoTimestampSchema,
|
|
209
214
|
respondentSegment: SurveyRespondentSegmentSchema,
|
|
210
215
|
anonymous: z.boolean(),
|
|
216
|
+
// Opaque upstream contact handle. The host resolves its own member
|
|
217
|
+
// record from this; no email or name crosses the reporting boundary.
|
|
218
|
+
// Optional because the reporting upstream only emits these when the
|
|
219
|
+
// caller opts in. A consumer that always required them could not
|
|
220
|
+
// read a legacy payload, and this object is strict, so a consumer
|
|
221
|
+
// that never expected them discards the whole response.
|
|
222
|
+
contactId: BoundedRecordIdSchema.nullable().optional(),
|
|
223
|
+
// Host-enriched, optional. The consuming site joins contactId to its
|
|
224
|
+
// own member record and attaches a display label here; the reporting
|
|
225
|
+
// upstream never sees it. Absent means "not resolved", not "anonymous".
|
|
226
|
+
respondent: z
|
|
227
|
+
.object({
|
|
228
|
+
label: NonemptyBoundedStringSchema,
|
|
229
|
+
detail: z.string().max(MAX_OPTION_LENGTH).nullable().optional(),
|
|
230
|
+
})
|
|
231
|
+
.strict()
|
|
232
|
+
.nullable()
|
|
233
|
+
.optional(),
|
|
234
|
+
answers: z
|
|
235
|
+
.array(
|
|
236
|
+
z
|
|
237
|
+
.object({
|
|
238
|
+
questionId: SurveyQuestionIdSchema,
|
|
239
|
+
value: AnswerValueSchema,
|
|
240
|
+
})
|
|
241
|
+
.strict(),
|
|
242
|
+
)
|
|
243
|
+
.max(MAX_QUESTIONS)
|
|
244
|
+
.optional(),
|
|
211
245
|
})
|
|
212
246
|
.strict(),
|
|
213
247
|
)
|
|
@@ -256,6 +290,19 @@ export const SurveyResultsSchema = z
|
|
|
256
290
|
if (results.responses.length > results.survey.responseCount) {
|
|
257
291
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Response rows exceed survey total' })
|
|
258
292
|
}
|
|
293
|
+
for (const [index, response] of results.responses.entries()) {
|
|
294
|
+
const seen = new Set<string>()
|
|
295
|
+
for (const [answerIndex, answer] of (response.answers ?? []).entries()) {
|
|
296
|
+
if (seen.has(answer.questionId)) {
|
|
297
|
+
ctx.addIssue({
|
|
298
|
+
code: z.ZodIssueCode.custom,
|
|
299
|
+
path: ['responses', index, 'answers', answerIndex, 'questionId'],
|
|
300
|
+
message: 'Answers must reference distinct questions',
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
seen.add(answer.questionId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
259
306
|
const responses = new Map<string, (typeof results.responses)[number]>()
|
|
260
307
|
for (const [index, response] of results.responses.entries()) {
|
|
261
308
|
if (responses.has(response.id)) {
|
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} />)
|