@growth-labs/cms 0.5.29 → 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 (95) hide show
  1. package/dist/engine/index.d.ts +5 -0
  2. package/dist/engine/index.d.ts.map +1 -1
  3. package/dist/engine/index.js +5 -0
  4. package/dist/engine/index.js.map +1 -1
  5. package/dist/engine/publication-continuity.d.ts +77 -0
  6. package/dist/engine/publication-continuity.d.ts.map +1 -0
  7. package/dist/engine/publication-continuity.js +94 -0
  8. package/dist/engine/publication-continuity.js.map +1 -0
  9. package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
  10. package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
  11. package/dist/engine/publication-receipt/canonical-json.js +100 -0
  12. package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
  13. package/dist/engine/publication-receipt/receipt.d.ts +316 -0
  14. package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
  15. package/dist/engine/publication-receipt/receipt.js +131 -0
  16. package/dist/engine/publication-receipt/receipt.js.map +1 -0
  17. package/dist/engine/publication-receipt/signature.d.ts +36 -0
  18. package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
  19. package/dist/engine/publication-receipt/signature.js +79 -0
  20. package/dist/engine/publication-receipt/signature.js.map +1 -0
  21. package/dist/engine/publication.d.ts.map +1 -1
  22. package/dist/engine/publication.js +2 -0
  23. package/dist/engine/publication.js.map +1 -1
  24. package/dist/engine/publisher.d.ts.map +1 -1
  25. package/dist/engine/publisher.js +2 -0
  26. package/dist/engine/publisher.js.map +1 -1
  27. package/dist/engine/source-sections.d.ts +20 -0
  28. package/dist/engine/source-sections.d.ts.map +1 -0
  29. package/dist/engine/source-sections.js +130 -0
  30. package/dist/engine/source-sections.js.map +1 -0
  31. package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
  32. package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
  33. package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
  34. package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
  35. package/dist/engine/validator/index.d.ts +10 -0
  36. package/dist/engine/validator/index.d.ts.map +1 -1
  37. package/dist/engine/validator/index.js +4 -0
  38. package/dist/engine/validator/index.js.map +1 -1
  39. package/dist/engine/validator/types.d.ts +1 -1
  40. package/dist/engine/validator/types.d.ts.map +1 -1
  41. package/dist/engine/validator/types.js +10 -0
  42. package/dist/engine/validator/types.js.map +1 -1
  43. package/dist/providers/types.d.ts +1 -0
  44. package/dist/providers/types.d.ts.map +1 -1
  45. package/dist/routes/content.d.ts.map +1 -1
  46. package/dist/routes/content.js +22 -2
  47. package/dist/routes/content.js.map +1 -1
  48. package/dist/routes/surveys.d.ts.map +1 -1
  49. package/dist/routes/surveys.js +13 -1
  50. package/dist/routes/surveys.js.map +1 -1
  51. package/dist/schema/insights-ingest.d.ts +28 -28
  52. package/dist/surveys/access.d.ts +2 -0
  53. package/dist/surveys/access.d.ts.map +1 -1
  54. package/dist/surveys/access.js +7 -0
  55. package/dist/surveys/access.js.map +1 -1
  56. package/dist/surveys/errors.d.ts +8 -0
  57. package/dist/surveys/errors.d.ts.map +1 -1
  58. package/dist/surveys/errors.js +12 -0
  59. package/dist/surveys/errors.js.map +1 -1
  60. package/dist/surveys/schema.d.ts +75 -0
  61. package/dist/surveys/schema.d.ts.map +1 -1
  62. package/dist/surveys/schema.js +45 -0
  63. package/dist/surveys/schema.js.map +1 -1
  64. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  65. package/dist/ui/editor/ContentForm.js +52 -1
  66. package/dist/ui/editor/ContentForm.js.map +1 -1
  67. package/dist/ui/editor/body-warnings.d.ts +27 -0
  68. package/dist/ui/editor/body-warnings.d.ts.map +1 -0
  69. package/dist/ui/editor/body-warnings.js +58 -0
  70. package/dist/ui/editor/body-warnings.js.map +1 -0
  71. package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
  72. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  73. package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
  74. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/engine/index.ts +33 -0
  77. package/src/engine/publication-continuity.ts +140 -0
  78. package/src/engine/publication-receipt/canonical-json.ts +123 -0
  79. package/src/engine/publication-receipt/receipt.ts +160 -0
  80. package/src/engine/publication-receipt/signature.ts +112 -0
  81. package/src/engine/publication.ts +2 -0
  82. package/src/engine/publisher.ts +2 -0
  83. package/src/engine/source-sections.ts +153 -0
  84. package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
  85. package/src/engine/validator/index.ts +12 -0
  86. package/src/engine/validator/types.ts +19 -0
  87. package/src/providers/types.ts +1 -1
  88. package/src/routes/content.ts +23 -2
  89. package/src/routes/surveys.ts +12 -1
  90. package/src/surveys/access.ts +9 -0
  91. package/src/surveys/errors.ts +12 -0
  92. package/src/surveys/schema.ts +47 -0
  93. package/src/ui/editor/ContentForm.tsx +93 -0
  94. package/src/ui/editor/body-warnings.ts +78 -0
  95. package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
@@ -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 {
@@ -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
 
@@ -56,6 +56,7 @@ import {
56
56
  } from '../engine/revisions.js'
57
57
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
58
58
  import { softDeleteContent } from '../engine/soft-delete.js'
59
+ import { normalizeSourceSections } from '../engine/source-sections.js'
59
60
  import { TagInputError } from '../engine/taxonomy.js'
60
61
  import type { ValidationError } from '../engine/validator/index.js'
61
62
  import { parseLayoutEnvelope } from '../schema/layout.js'
@@ -1258,9 +1259,29 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1258
1259
  bodyMarkdown?: string
1259
1260
  bodyHtml?: string | null
1260
1261
  }
1261
- const bodyMarkdown = articleContent.bodyMarkdown
1262
- const bodyHtml = articleContent.bodyHtml
1262
+ let bodyMarkdown = articleContent.bodyMarkdown
1263
+ let bodyHtml = articleContent.bodyHtml
1263
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
+ }
1264
1285
  if (bodyMarkdown !== undefined || bodyHtml !== undefined) {
1265
1286
  const guard = evaluateArticleBody(
1266
1287
  { body_markdown: bodyMarkdown ?? null, body_html: bodyHtml ?? null },
@@ -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)) {
@@ -27,6 +27,7 @@ import { Icon } from '../icons.js'
27
27
  import { uploadMediaAsset } from '../screens/media-upload.js'
28
28
  import { AiAssistPanel } from './AiAssistPanel.js'
29
29
  import { AUTOSAVE_DEBOUNCE_MS, shouldSnapshot } from './autosave.js'
30
+ import { type BodyWarning, describeBodyWarnings, sortBodyWarnings } from './body-warnings.js'
30
31
  import {
31
32
  buildContentCreatePayload,
32
33
  buildContentUpdatePayload,
@@ -185,6 +186,12 @@ interface FormState {
185
186
  lastSavedAt: number | null
186
187
  /** Whether the AI assist panel is open. */
187
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[]
188
195
  }
189
196
 
190
197
  const EMPTY_CONTENT_METADATA_SELECTS: ContentMetadataSelectField[] = []
@@ -229,6 +236,7 @@ function createEmptyFormState(docId: string | null, metadata: ContentMetadata =
229
236
  savedId: docId,
230
237
  saving: false,
231
238
  saveError: null,
239
+ bodyWarnings: [],
232
240
  lastSavedAt: null,
233
241
  aiOpen: false,
234
242
  }
@@ -441,6 +449,17 @@ export function ContentForm({
441
449
  return
442
450
  }
443
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
+
444
463
  // Snapshot cadence check
445
464
  const now = Date.now()
446
465
  savesSinceSnapshot.current += 1
@@ -1104,6 +1123,33 @@ export function ContentForm({
1104
1123
  )}
1105
1124
  </div>
1106
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
+
1107
1153
  {/* Footer: word count + read time (only for prose content) */}
1108
1154
  {(contentType === 'article' || contentType === 'newsletter' || contentType === 'page') && (
1109
1155
  <div style={footer}>
@@ -1680,6 +1726,53 @@ const dekInput: React.CSSProperties = {
1680
1726
  resize: 'none',
1681
1727
  }
1682
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
+
1683
1776
  const footer: React.CSSProperties = {
1684
1777
  display: 'flex',
1685
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
+ }