@growth-labs/cms 0.5.29 → 0.5.31
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/foundry-dispatch.d.ts +7 -2
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +24 -6
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +174 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
- package/dist/engine/fronts-publish-intent.js +352 -0
- package/dist/engine/fronts-publish-intent.js.map +1 -0
- package/dist/engine/fronts-publish.d.ts +80 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -0
- package/dist/engine/fronts-publish.js +286 -0
- package/dist/engine/fronts-publish.js.map +1 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +10 -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 +23 -1
- 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/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +10 -0
- package/dist/integration/index.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/fronts-publish.d.ts +10 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -0
- package/dist/routes/fronts-publish.js +250 -0
- package/dist/routes/fronts-publish.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -0
- package/dist/routes/index.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/schema/layout.d.ts +2 -2
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +36 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +2 -2
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +59 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- 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 +129 -54
- 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/api/fronts/publish-callback.d.ts +3 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
- package/dist/ui/api/fronts/publish-callback.js +8 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -0
- package/dist/ui/api/fronts/schedule.d.ts +3 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
- package/dist/ui/api/fronts/schedule.js +8 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -0
- 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/migrations/0026_fronts_publish.sql +31 -0
- package/migrations/0027_fronts_publish_intent.sql +44 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +22 -6
- package/src/engine/fronts-publish-intent.ts +676 -0
- package/src/engine/fronts-publish.ts +417 -0
- package/src/engine/index.ts +60 -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 +23 -1
- package/src/engine/source-sections.ts +153 -0
- package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
- package/src/engine/validator/index.ts +12 -0
- package/src/engine/validator/types.ts +19 -0
- package/src/integration/index.ts +10 -0
- package/src/providers/types.ts +1 -1
- package/src/routes/content.ts +23 -2
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/routes/surveys.ts +12 -1
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/surveys/access.ts +9 -0
- package/src/surveys/errors.ts +12 -0
- package/src/surveys/schema.ts +47 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- package/src/ui/api/fronts/schedule.ts +18 -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/integration/index.ts
CHANGED
|
@@ -102,6 +102,16 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
102
102
|
pattern: `${base}/api/content/insights-ingest`,
|
|
103
103
|
entrypoint: resolveEntrypoint('../ui/api/content/insights-ingest'),
|
|
104
104
|
})
|
|
105
|
+
// Fronts publish path (additive, flag-gated) — M2M siblings of the
|
|
106
|
+
// foundry-callback / insights-ingest routes. HMAC-guarded.
|
|
107
|
+
injectRoute({
|
|
108
|
+
pattern: `${base}/api/fronts/schedule`,
|
|
109
|
+
entrypoint: resolveEntrypoint('../ui/api/fronts/schedule'),
|
|
110
|
+
})
|
|
111
|
+
injectRoute({
|
|
112
|
+
pattern: `${base}/api/fronts/publish-callback`,
|
|
113
|
+
entrypoint: resolveEntrypoint('../ui/api/fronts/publish-callback'),
|
|
114
|
+
})
|
|
105
115
|
// Content list + create (Library screen)
|
|
106
116
|
injectRoute({
|
|
107
117
|
pattern: `${base}/api/content`,
|
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 },
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Fronts publish routes (cms/Masthead side) — the two machine-to-machine seams
|
|
2
|
+
// the out-of-process Fronts publish worker (foundryd) drives:
|
|
3
|
+
//
|
|
4
|
+
// GET /admin/api/fronts/schedule?window=<from>..<to> — the poll surface.
|
|
5
|
+
// POST /admin/api/fronts/publish-callback — the callback receiver.
|
|
6
|
+
//
|
|
7
|
+
// AUTH reuses the existing M2M primitive (`verifyHmac` + `X-Foundry-Signature`
|
|
8
|
+
// against `ctx.env.FOUNDRY_HMAC_SECRET` — a per-Worker secret string OR a Secrets
|
|
9
|
+
// Store binding `{ get() }`; no new auth scheme, no shared secret in code/D1),
|
|
10
|
+
// hardened against replay with a REQUIRED `X-Foundry-Timestamp`:
|
|
11
|
+
//
|
|
12
|
+
// X-Foundry-Timestamp: <unix seconds, within ±300s of now>
|
|
13
|
+
// X-Foundry-Signature: sha256=HMAC(secret, `${timestamp}\n${payload}`)
|
|
14
|
+
//
|
|
15
|
+
// where payload is the raw query string (`url.search`) for the GET poll and the
|
|
16
|
+
// raw body for the POST callback. Binding the signature to a fresh timestamp
|
|
17
|
+
// stops replay: without it a no-`window` GET would sign HMAC(secret,"") — a
|
|
18
|
+
// CONSTANT — so one captured URL+header pair would be a permanent bearer
|
|
19
|
+
// credential for the pre-publication embargo schedule.
|
|
20
|
+
//
|
|
21
|
+
// Both endpoints ship ENABLED behind FRONTS_PUBLISH_ENABLED (a rollback lever,
|
|
22
|
+
// default ON). Failures are loud: bad/stale HMAC → 401 + warn log; a `live`
|
|
23
|
+
// callback that cannot commit through the ledger → 500 + error log; disabled → 503.
|
|
24
|
+
|
|
25
|
+
import { z } from 'zod'
|
|
26
|
+
import { verifyHmac } from '../engine/foundry-dispatch.js'
|
|
27
|
+
import {
|
|
28
|
+
applyFrontsPublishCallback,
|
|
29
|
+
isFrontsPublishEnabled,
|
|
30
|
+
listFrontsPublishSchedule,
|
|
31
|
+
} from '../engine/fronts-publish.js'
|
|
32
|
+
import { hydrateFrontsPublishIntents } from '../engine/fronts-publish-intent.js'
|
|
33
|
+
import { publicUrlFor } from '../ui/screens/public-url.js'
|
|
34
|
+
import { type CmsRouteConfig, resolveConfig } from './config.js'
|
|
35
|
+
import { json, type RouteContext } from './context.js'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the Foundry HMAC secret from ctx.env.FOUNDRY_HMAC_SECRET — the SAME
|
|
39
|
+
* binding the existing foundry-callback route uses. Supports a plain string
|
|
40
|
+
* (test injection / per-Worker secret) or a Secrets Store binding `{ get() }`.
|
|
41
|
+
* Returns null → the route answers 500 "not configured".
|
|
42
|
+
*/
|
|
43
|
+
async function resolveFoundryHmacSecret(
|
|
44
|
+
env: Record<string, unknown> | undefined,
|
|
45
|
+
): Promise<string | null> {
|
|
46
|
+
const raw = env?.FOUNDRY_HMAC_SECRET
|
|
47
|
+
if (typeof raw === 'string') return raw || null
|
|
48
|
+
if (raw && typeof (raw as { get?: unknown }).get === 'function') {
|
|
49
|
+
try {
|
|
50
|
+
const val = await (raw as { get(): Promise<string> }).get()
|
|
51
|
+
return typeof val === 'string' && val.length > 0 ? val : null
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Max clock skew (seconds) tolerated on X-Foundry-Timestamp — the replay window. */
|
|
60
|
+
const SIGNATURE_MAX_SKEW_SECONDS = 300
|
|
61
|
+
|
|
62
|
+
/** Parse the X-Foundry-Timestamp header (strict unix epoch seconds) or null. */
|
|
63
|
+
function parseTimestampSeconds(raw: string): number | null {
|
|
64
|
+
const trimmed = raw.trim()
|
|
65
|
+
// Strict integer seconds so the exact header bytes are what foundryd signs.
|
|
66
|
+
if (!/^\d{1,15}$/.test(trimmed)) return null
|
|
67
|
+
const seconds = Number.parseInt(trimmed, 10)
|
|
68
|
+
return Number.isSafeInteger(seconds) ? seconds : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verify a foundryd-signed M2M request: a fresh `X-Foundry-Timestamp` (unix
|
|
73
|
+
* seconds within ±{@link SIGNATURE_MAX_SKEW_SECONDS}) plus `X-Foundry-Signature`
|
|
74
|
+
* = HMAC over `${timestamp}\n${payload}`. Returns null when authentic, or a 401
|
|
75
|
+
* Response (with a loud warn log) to short-circuit the handler.
|
|
76
|
+
*/
|
|
77
|
+
async function verifyFoundrySignedRequest(
|
|
78
|
+
request: Request,
|
|
79
|
+
secret: string,
|
|
80
|
+
payload: string,
|
|
81
|
+
label: string,
|
|
82
|
+
): Promise<Response | null> {
|
|
83
|
+
const tsRaw = (request.headers.get('X-Foundry-Timestamp') ?? '').trim()
|
|
84
|
+
const ts = parseTimestampSeconds(tsRaw)
|
|
85
|
+
if (ts === null) {
|
|
86
|
+
console.warn(`[cms] ${label} rejected: missing or invalid X-Foundry-Timestamp`)
|
|
87
|
+
return json({ error: 'Missing or invalid X-Foundry-Timestamp' }, 401)
|
|
88
|
+
}
|
|
89
|
+
const now = Math.floor(Date.now() / 1_000)
|
|
90
|
+
if (Math.abs(now - ts) > SIGNATURE_MAX_SKEW_SECONDS) {
|
|
91
|
+
console.warn(`[cms] ${label} rejected: stale X-Foundry-Timestamp (skew ${now - ts}s)`)
|
|
92
|
+
return json({ error: 'Stale or future X-Foundry-Timestamp' }, 401)
|
|
93
|
+
}
|
|
94
|
+
const signature = request.headers.get('X-Foundry-Signature') ?? ''
|
|
95
|
+
const valid = await verifyHmac(secret, `${tsRaw}\n${payload}`, signature)
|
|
96
|
+
if (!valid) {
|
|
97
|
+
console.warn(`[cms] ${label} rejected: invalid X-Foundry-Signature`)
|
|
98
|
+
return json({ error: 'Invalid signature' }, 401)
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ReceiptSchema = z.object({
|
|
104
|
+
published_urls: z.array(z.string()).nullish(),
|
|
105
|
+
platform_post_ids: z.array(z.string()).nullish(),
|
|
106
|
+
sha256: z.string().nullish(),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const CallbackSchema = z
|
|
110
|
+
.object({
|
|
111
|
+
event_id: z.string().min(1).optional(),
|
|
112
|
+
intent_id: z.string().min(1).nullish(),
|
|
113
|
+
content_id: z.string().min(1),
|
|
114
|
+
version: z.number().int().nullish(),
|
|
115
|
+
correlation_id: z.string().min(1).nullish(),
|
|
116
|
+
status: z.enum(['queued', 'publishing', 'live', 'late', 'failed']),
|
|
117
|
+
occurred_at: z.union([z.string(), z.number()]).nullish(),
|
|
118
|
+
receipt: ReceiptSchema.nullish(),
|
|
119
|
+
error: z.string().nullish(),
|
|
120
|
+
})
|
|
121
|
+
.strip()
|
|
122
|
+
|
|
123
|
+
export interface FrontsPublishRouteHandlers {
|
|
124
|
+
/** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
|
|
125
|
+
schedule(ctx: RouteContext): Promise<Response>
|
|
126
|
+
/** POST /fronts/publish-callback — foundryd's outbound lifecycle callback. */
|
|
127
|
+
publishCallback(ctx: RouteContext): Promise<Response>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Parse `window=<from>..<to>` into unix-seconds bounds; defaults to now..now+1h. */
|
|
131
|
+
function parseWindow(
|
|
132
|
+
value: string | null,
|
|
133
|
+
nowSeconds: number,
|
|
134
|
+
): { fromSeconds: number; toSeconds: number } | { error: string } {
|
|
135
|
+
if (!value) return { fromSeconds: nowSeconds, toSeconds: nowSeconds + 3600 }
|
|
136
|
+
const parts = value.split('..')
|
|
137
|
+
if (parts.length !== 2) return { error: 'window must be "<from>..<to>"' }
|
|
138
|
+
const parseBound = (raw: string): number | null => {
|
|
139
|
+
const trimmed = raw.trim()
|
|
140
|
+
if (!trimmed) return null
|
|
141
|
+
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10)
|
|
142
|
+
const ms = Date.parse(trimmed)
|
|
143
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1_000)
|
|
144
|
+
}
|
|
145
|
+
const fromSeconds = parseBound(parts[0])
|
|
146
|
+
const toSeconds = parseBound(parts[1])
|
|
147
|
+
if (fromSeconds === null || toSeconds === null) {
|
|
148
|
+
return { error: 'window bounds must be ISO-8601 timestamps or unix seconds' }
|
|
149
|
+
}
|
|
150
|
+
if (toSeconds < fromSeconds) return { error: 'window end precedes its start' }
|
|
151
|
+
return { fromSeconds, toSeconds }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublishRouteHandlers {
|
|
155
|
+
const resolved = resolveConfig(config)
|
|
156
|
+
return {
|
|
157
|
+
async schedule(ctx) {
|
|
158
|
+
const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
|
|
159
|
+
if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
|
|
160
|
+
|
|
161
|
+
const url = new URL(ctx.request.url)
|
|
162
|
+
// The GET poll has no body; the payload is the exact query string
|
|
163
|
+
// foundryd sent (leading '?' included, or '' when empty), bound to the
|
|
164
|
+
// required timestamp so an empty-window poll can't sign a constant.
|
|
165
|
+
const denied = await verifyFoundrySignedRequest(
|
|
166
|
+
ctx.request,
|
|
167
|
+
hmacSecret,
|
|
168
|
+
url.search,
|
|
169
|
+
'fronts/schedule',
|
|
170
|
+
)
|
|
171
|
+
if (denied) return denied
|
|
172
|
+
|
|
173
|
+
if (!isFrontsPublishEnabled(ctx.env)) {
|
|
174
|
+
return json({ error: 'fronts_publish_disabled' }, 503)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const now = Math.floor(Date.now() / 1_000)
|
|
178
|
+
const window = parseWindow(url.searchParams.get('window'), now)
|
|
179
|
+
if ('error' in window) return json({ error: window.error }, 400)
|
|
180
|
+
|
|
181
|
+
// `items` is the UNCHANGED 0026 legacy schedule shape — an existing
|
|
182
|
+
// consumer keeps working byte-for-byte. `intents` is the canonical
|
|
183
|
+
// publish-intent contract (packages#500) hydrated over the SAME due
|
|
184
|
+
// predicate, and `incomplete` names every due item that cannot form a
|
|
185
|
+
// complete intent, so a missing canonical field is an explicit, visible
|
|
186
|
+
// contract error rather than a silently short `intents` array.
|
|
187
|
+
const items = await listFrontsPublishSchedule(ctx.db, {
|
|
188
|
+
toSeconds: window.toSeconds,
|
|
189
|
+
nowSeconds: now,
|
|
190
|
+
})
|
|
191
|
+
const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
|
|
192
|
+
toSeconds: window.toSeconds,
|
|
193
|
+
nowSeconds: now,
|
|
194
|
+
mediaPublicDomain: resolved.mediaPublicDomain,
|
|
195
|
+
// Site-RELATIVE paths: the CMS does not know the site origin, and the
|
|
196
|
+
// consumer joins its own trusted configured origin. A host-supplied
|
|
197
|
+
// origin is deliberately deferred (packages-docs/cms.md).
|
|
198
|
+
resolveContentUrl: (row) => publicUrlFor(row),
|
|
199
|
+
})
|
|
200
|
+
return json({
|
|
201
|
+
ok: true,
|
|
202
|
+
now,
|
|
203
|
+
window: { from: window.fromSeconds, to: window.toSeconds },
|
|
204
|
+
count: items.length,
|
|
205
|
+
items,
|
|
206
|
+
intents: hydrated.intents,
|
|
207
|
+
incomplete: hydrated.incomplete,
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async publishCallback(ctx) {
|
|
212
|
+
const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
|
|
213
|
+
if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
|
|
214
|
+
|
|
215
|
+
let rawBody: string
|
|
216
|
+
try {
|
|
217
|
+
rawBody = await ctx.request.text()
|
|
218
|
+
} catch {
|
|
219
|
+
return json({ error: 'Failed to read request body' }, 400)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const denied = await verifyFoundrySignedRequest(
|
|
223
|
+
ctx.request,
|
|
224
|
+
hmacSecret,
|
|
225
|
+
rawBody,
|
|
226
|
+
'fronts/publish-callback',
|
|
227
|
+
)
|
|
228
|
+
if (denied) return denied
|
|
229
|
+
|
|
230
|
+
if (!isFrontsPublishEnabled(ctx.env)) {
|
|
231
|
+
return json({ error: 'fronts_publish_disabled' }, 503)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let raw: unknown
|
|
235
|
+
try {
|
|
236
|
+
raw = JSON.parse(rawBody)
|
|
237
|
+
} catch {
|
|
238
|
+
return json({ error: 'Invalid JSON body' }, 400)
|
|
239
|
+
}
|
|
240
|
+
const parsed = CallbackSchema.safeParse(raw)
|
|
241
|
+
if (!parsed.success) {
|
|
242
|
+
return json({ error: 'Invalid payload', details: parsed.error.flatten() }, 400)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Idempotency key: body event_id, else the Idempotency-Key header.
|
|
246
|
+
const eventId = parsed.data.event_id ?? ctx.request.headers.get('Idempotency-Key') ?? null
|
|
247
|
+
if (!eventId) {
|
|
248
|
+
return json({ error: 'Missing event_id / Idempotency-Key' }, 400)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const result = await applyFrontsPublishCallback(ctx.db, {
|
|
252
|
+
eventId,
|
|
253
|
+
intentId: parsed.data.intent_id ?? null,
|
|
254
|
+
contentId: parsed.data.content_id,
|
|
255
|
+
version: parsed.data.version ?? null,
|
|
256
|
+
correlationId: parsed.data.correlation_id ?? null,
|
|
257
|
+
status: parsed.data.status,
|
|
258
|
+
occurredAt: parsed.data.occurred_at ?? null,
|
|
259
|
+
receipt: parsed.data.receipt ?? null,
|
|
260
|
+
error: parsed.data.error ?? null,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
if (!result.contentFound) {
|
|
264
|
+
return json({ error: 'content_not_found', contentId: parsed.data.content_id }, 404)
|
|
265
|
+
}
|
|
266
|
+
if (result.ledgerError) {
|
|
267
|
+
// A confirmed `live` that could not be committed through the ledger is a
|
|
268
|
+
// hard, loud failure — never a silent zero.
|
|
269
|
+
console.error(
|
|
270
|
+
`[cms] fronts/publish-callback ledger commit failed for content ${parsed.data.content_id}: ${result.ledgerError}`,
|
|
271
|
+
)
|
|
272
|
+
return json(
|
|
273
|
+
{
|
|
274
|
+
ok: false,
|
|
275
|
+
error: 'ledger_publish_failed',
|
|
276
|
+
detail: result.ledgerError,
|
|
277
|
+
status: result.status,
|
|
278
|
+
},
|
|
279
|
+
500,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return json({
|
|
284
|
+
ok: true,
|
|
285
|
+
deduped: result.deduped,
|
|
286
|
+
status: result.status,
|
|
287
|
+
published: result.published,
|
|
288
|
+
attemptId: result.attemptId,
|
|
289
|
+
})
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -100,6 +100,11 @@ export {
|
|
|
100
100
|
type DashboardRouteConfig,
|
|
101
101
|
type DashboardRouteHandlers,
|
|
102
102
|
} from './dashboard.js'
|
|
103
|
+
// Fronts publish surface — the HMAC schedule poll + publish-callback receiver.
|
|
104
|
+
export {
|
|
105
|
+
createFrontsPublishRoutes,
|
|
106
|
+
type FrontsPublishRouteHandlers,
|
|
107
|
+
} from './fronts-publish.js'
|
|
103
108
|
// Import routes — two-step parse→confirm pipeline.
|
|
104
109
|
export { createImportRoutes, type ImportRouteHandlers } from './imports.js'
|
|
105
110
|
// Media routes.
|
|
@@ -198,6 +203,7 @@ import {
|
|
|
198
203
|
} from './content-insights.js'
|
|
199
204
|
import { type CronRouteHandlers, createCronRoutes } from './cron.js'
|
|
200
205
|
import { createDashboardRoutes, type DashboardRouteHandlers } from './dashboard.js'
|
|
206
|
+
import { createFrontsPublishRoutes, type FrontsPublishRouteHandlers } from './fronts-publish.js'
|
|
201
207
|
import { createImportRoutes, type ImportRouteHandlers } from './imports.js'
|
|
202
208
|
import { createMediaRoutes, type MediaRouteHandlers } from './media.js'
|
|
203
209
|
import { createPreviewRoutes, type PreviewRouteHandlers } from './preview.js'
|
|
@@ -231,6 +237,7 @@ export interface CmsRoutes {
|
|
|
231
237
|
apiKeys: ApiKeyRouteHandlers
|
|
232
238
|
webhooks: WebhookRouteHandlers
|
|
233
239
|
contentInsights: ContentInsightsRouteHandlers
|
|
240
|
+
frontsPublish: FrontsPublishRouteHandlers
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
/**
|
|
@@ -297,5 +304,6 @@ export function createCmsRoutes(
|
|
|
297
304
|
authz: config.authz,
|
|
298
305
|
activeWorkspaceId: config.activeWorkspaceId,
|
|
299
306
|
}),
|
|
307
|
+
frontsPublish: createFrontsPublishRoutes(base),
|
|
300
308
|
}
|
|
301
309
|
}
|