@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
@@ -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),
@@ -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
 
@@ -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
- const bodyMarkdown = articleContent.bodyMarkdown
1233
- const bodyHtml = articleContent.bodyHtml
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
 
@@ -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
  ])
@@ -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
+ }
@@ -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.',
@@ -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)) {
@@ -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} />)