@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.
- 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/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/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 +22 -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/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +52 -1
- 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/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/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/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/providers/types.ts +1 -1
- package/src/routes/content.ts +23 -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/editor/ContentForm.tsx +93 -0
- package/src/ui/editor/body-warnings.ts +78 -0
- 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 {
|
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
|
@@ -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
|
-
|
|
1262
|
-
|
|
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 },
|
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)) {
|
|
@@ -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
|
+
}
|