@growth-labs/cms 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +36 -7
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +15 -4
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +52 -19
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +19 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +158 -5
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +28 -0
- package/dist/engine/podcast-source.d.ts.map +1 -0
- package/dist/engine/podcast-source.js +50 -0
- package/dist/engine/podcast-source.js.map +1 -0
- package/dist/engine/publication-current-guard.d.ts +10 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -0
- package/dist/engine/publication-current-guard.js +76 -0
- package/dist/engine/publication-current-guard.js.map +1 -0
- package/dist/engine/publication.d.ts +4 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +13 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +20 -13
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.js +2 -2
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/integration/options.d.ts +117 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +19 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/types.d.ts +9 -3
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/config.d.ts +4 -0
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/config.js +1 -0
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/content.d.ts +2 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +8 -6
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -2
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +52 -23
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/index.js +1 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/schema/layout.d.ts +2 -2
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +7 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
- package/dist/ui/api/fronts/publish-callback.js +2 -6
- package/dist/ui/api/fronts/publish-callback.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -6
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts +3 -2
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +18 -4
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +4 -1
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +9 -1
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/screens/ContentRoute.d.ts +3 -2
- package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
- package/dist/ui/screens/ContentRoute.js +2 -2
- package/dist/ui/screens/ContentRoute.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +3 -2
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +2 -2
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/registry.d.ts +3 -2
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +67 -12
- package/src/engine/fronts-publish-intent.ts +78 -26
- package/src/engine/fronts-publish.ts +223 -5
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +82 -0
- package/src/engine/publication-current-guard.ts +87 -0
- package/src/engine/publication.ts +23 -0
- package/src/engine/publisher.ts +21 -13
- package/src/engine/soft-delete.ts +2 -2
- package/src/integration/options.ts +23 -0
- package/src/providers/types.ts +6 -1
- package/src/routes/config.ts +5 -0
- package/src/routes/content.ts +11 -6
- package/src/routes/fronts-publish.ts +63 -25
- package/src/routes/index.ts +1 -1
- package/src/ui/api/_content-config.ts +11 -0
- package/src/ui/api/fronts/publish-callback.ts +2 -8
- package/src/ui/api/fronts/schedule.ts +2 -8
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/editor/ContentForm.tsx +45 -3
- package/src/ui/editor/content-payload.ts +8 -0
- package/src/ui/screens/ContentRoute.tsx +4 -1
- package/src/ui/screens/EditorScreen.tsx +4 -1
- package/src/ui/screens/registry.tsx +4 -1
package/src/routes/config.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { OgImageRenderer } from '../engine/og-render.js'
|
|
7
7
|
import { nullProviders } from '../providers/null.js'
|
|
8
8
|
import type { CmsProviders, FoundryDispatch, LlmDispatch } from '../providers/types.js'
|
|
9
|
+
import type { ContentType } from '../schema/types.js'
|
|
9
10
|
import type { Authz } from './context.js'
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -35,6 +36,8 @@ export interface CmsRouteConfig {
|
|
|
35
36
|
primaryTopicSlugs?: readonly [string, ...string[]]
|
|
36
37
|
/** Default primary topic for create (Fronts: 'global'). */
|
|
37
38
|
defaultPrimaryTopic?: string
|
|
39
|
+
/** Content types for which `primaryTopic` is required on create. */
|
|
40
|
+
requiredPrimaryTopicContentTypes?: readonly ContentType[]
|
|
38
41
|
|
|
39
42
|
/** R2 binding name recorded on media rows. Fronts default: 'PUBLIC_MEDIA'. */
|
|
40
43
|
mediaR2Binding?: string
|
|
@@ -66,6 +69,7 @@ export interface ResolvedCmsRouteConfig {
|
|
|
66
69
|
defaultPrimaryCategory: string
|
|
67
70
|
primaryTopicSlugs: readonly string[] | null
|
|
68
71
|
defaultPrimaryTopic: string
|
|
72
|
+
requiredPrimaryTopicContentTypes: readonly ContentType[]
|
|
69
73
|
mediaR2Binding: string
|
|
70
74
|
mediaSiteId: string
|
|
71
75
|
mediaPublicDomain: string
|
|
@@ -88,6 +92,7 @@ export function resolveConfig(config: CmsRouteConfig): ResolvedCmsRouteConfig {
|
|
|
88
92
|
defaultPrimaryCategory: config.defaultPrimaryCategory ?? 'analysis',
|
|
89
93
|
primaryTopicSlugs: config.primaryTopicSlugs ?? null,
|
|
90
94
|
defaultPrimaryTopic: config.defaultPrimaryTopic ?? 'global',
|
|
95
|
+
requiredPrimaryTopicContentTypes: config.requiredPrimaryTopicContentTypes ?? [],
|
|
91
96
|
mediaR2Binding: config.mediaR2Binding ?? 'PUBLIC_MEDIA',
|
|
92
97
|
mediaSiteId: config.mediaSiteId ?? 'fronts',
|
|
93
98
|
mediaPublicDomain: config.mediaPublicDomain ?? 'media.fronts.co',
|
package/src/routes/content.ts
CHANGED
|
@@ -197,6 +197,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
197
197
|
dispatchFaq?: DispatchFaqHook
|
|
198
198
|
/** Optional host-specific readiness guard. A not-ready result blocks publish. */
|
|
199
199
|
publishReadiness?: PublishReadinessHook
|
|
200
|
+
/** Host's existing production rollback control; omitted means enabled. */
|
|
201
|
+
podcastSourceProductionEnabled?: boolean
|
|
200
202
|
/** Internal machine-route constraint: atomically update only in this status. */
|
|
201
203
|
updateStatusConstraint?: ContentStatus
|
|
202
204
|
/**
|
|
@@ -390,13 +392,16 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
390
392
|
tags: z.array(z.string()).optional(),
|
|
391
393
|
metadata: ContentMetadataSchema.optional(),
|
|
392
394
|
})
|
|
395
|
+
const requiredTopicTypes = new Set(resolved.requiredPrimaryTopicContentTypes)
|
|
396
|
+
const baseSchemaFor = (type: ContentType) =>
|
|
397
|
+
requiredTopicTypes.has(type) ? BaseSchema.extend({ primaryTopic: topic }) : BaseSchema
|
|
393
398
|
|
|
394
399
|
const CreateSchema = z.discriminatedUnion('type', [
|
|
395
|
-
|
|
400
|
+
baseSchemaFor('article').extend({
|
|
396
401
|
type: z.literal('article'),
|
|
397
402
|
content: BodyContentCreateSchema,
|
|
398
403
|
}),
|
|
399
|
-
|
|
404
|
+
baseSchemaFor('video').extend({
|
|
400
405
|
type: z.literal('video'),
|
|
401
406
|
content: z.object({
|
|
402
407
|
script: z.string().default(''),
|
|
@@ -407,7 +412,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
407
412
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
408
413
|
}),
|
|
409
414
|
}),
|
|
410
|
-
|
|
415
|
+
baseSchemaFor('podcast').extend({
|
|
411
416
|
type: z.literal('podcast'),
|
|
412
417
|
content: z.object({
|
|
413
418
|
// Empty allowed: a podcast is created as a draft with audio uploaded
|
|
@@ -427,11 +432,11 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
427
432
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
428
433
|
}),
|
|
429
434
|
}),
|
|
430
|
-
|
|
435
|
+
baseSchemaFor('newsletter').extend({
|
|
431
436
|
type: z.literal('newsletter'),
|
|
432
437
|
content: BodyContentCreateSchema,
|
|
433
438
|
}),
|
|
434
|
-
|
|
439
|
+
baseSchemaFor('page').extend({
|
|
435
440
|
type: z.literal('page'),
|
|
436
441
|
content: BodyContentCreateSchema,
|
|
437
442
|
}),
|
|
@@ -1890,7 +1895,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1890
1895
|
if (action === 'archive') {
|
|
1891
1896
|
await ctx.db
|
|
1892
1897
|
.prepare(
|
|
1893
|
-
"UPDATE content_items SET status = 'archived', updated_at = unixepoch() WHERE id = ?",
|
|
1898
|
+
"UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
|
|
1894
1899
|
)
|
|
1895
1900
|
.bind(id)
|
|
1896
1901
|
.run()
|
|
@@ -29,9 +29,13 @@ import {
|
|
|
29
29
|
isFrontsPublishEnabled,
|
|
30
30
|
listFrontsPublishSchedule,
|
|
31
31
|
} from '../engine/fronts-publish.js'
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
FrontsScheduleOverflowError,
|
|
34
|
+
hydrateFrontsPublishIntents,
|
|
35
|
+
} from '../engine/fronts-publish-intent.js'
|
|
33
36
|
import { publicUrlFor } from '../ui/screens/public-url.js'
|
|
34
|
-
import {
|
|
37
|
+
import { resolveConfig } from './config.js'
|
|
38
|
+
import type { ContentRouteConfig } from './content.js'
|
|
35
39
|
import { json, type RouteContext } from './context.js'
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -112,6 +116,7 @@ const CallbackSchema = z
|
|
|
112
116
|
intent_id: z.string().min(1).nullish(),
|
|
113
117
|
content_id: z.string().min(1),
|
|
114
118
|
version: z.number().int().nullish(),
|
|
119
|
+
publish_at: z.number().int().positive().nullish(),
|
|
115
120
|
correlation_id: z.string().min(1).nullish(),
|
|
116
121
|
status: z.enum(['queued', 'publishing', 'live', 'late', 'failed']),
|
|
117
122
|
occurred_at: z.union([z.string(), z.number()]).nullish(),
|
|
@@ -151,7 +156,7 @@ function parseWindow(
|
|
|
151
156
|
return { fromSeconds, toSeconds }
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
export function createFrontsPublishRoutes(config:
|
|
159
|
+
export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
|
|
155
160
|
const resolved = resolveConfig(config)
|
|
156
161
|
return {
|
|
157
162
|
async schedule(ctx) {
|
|
@@ -184,28 +189,37 @@ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublish
|
|
|
184
189
|
// predicate, and `incomplete` names every due item that cannot form a
|
|
185
190
|
// complete intent, so a missing canonical field is an explicit, visible
|
|
186
191
|
// contract error rather than a silently short `intents` array.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
192
|
+
try {
|
|
193
|
+
const items = await listFrontsPublishSchedule(ctx.db, {
|
|
194
|
+
toSeconds: window.toSeconds,
|
|
195
|
+
nowSeconds: now,
|
|
196
|
+
})
|
|
197
|
+
const hydrated = await hydrateFrontsPublishIntents(ctx.db, {
|
|
198
|
+
toSeconds: window.toSeconds,
|
|
199
|
+
nowSeconds: now,
|
|
200
|
+
mediaPublicDomain: resolved.mediaPublicDomain,
|
|
201
|
+
podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
|
|
202
|
+
// Site-RELATIVE paths: the CMS does not know the site origin, and the
|
|
203
|
+
// consumer joins its own trusted configured origin. A host-supplied
|
|
204
|
+
// origin is deliberately deferred (packages-docs/cms.md).
|
|
205
|
+
resolveContentUrl: (row) => publicUrlFor(row),
|
|
206
|
+
})
|
|
207
|
+
return json({
|
|
208
|
+
ok: true,
|
|
209
|
+
now,
|
|
210
|
+
window: { from: window.fromSeconds, to: window.toSeconds },
|
|
211
|
+
count: items.length,
|
|
212
|
+
items,
|
|
213
|
+
intents: hydrated.intents,
|
|
214
|
+
incomplete: hydrated.incomplete,
|
|
215
|
+
})
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof FrontsScheduleOverflowError) {
|
|
218
|
+
console.error(`[cms] fronts/schedule unavailable: ${error.message}`)
|
|
219
|
+
return json({ ok: false, error: 'schedule_overflow', message: error.message }, 503)
|
|
220
|
+
}
|
|
221
|
+
throw error
|
|
222
|
+
}
|
|
209
223
|
},
|
|
210
224
|
|
|
211
225
|
async publishCallback(ctx) {
|
|
@@ -253,16 +267,40 @@ export function createFrontsPublishRoutes(config: CmsRouteConfig): FrontsPublish
|
|
|
253
267
|
intentId: parsed.data.intent_id ?? null,
|
|
254
268
|
contentId: parsed.data.content_id,
|
|
255
269
|
version: parsed.data.version ?? null,
|
|
270
|
+
publishAt: parsed.data.publish_at ?? null,
|
|
256
271
|
correlationId: parsed.data.correlation_id ?? null,
|
|
257
272
|
status: parsed.data.status,
|
|
258
273
|
occurredAt: parsed.data.occurred_at ?? null,
|
|
259
274
|
receipt: parsed.data.receipt ?? null,
|
|
260
275
|
error: parsed.data.error ?? null,
|
|
276
|
+
checkReadiness: config.publishReadiness
|
|
277
|
+
? (item) =>
|
|
278
|
+
config.publishReadiness!({
|
|
279
|
+
ctx,
|
|
280
|
+
contentId: item.id,
|
|
281
|
+
type: item.type,
|
|
282
|
+
status: item.status,
|
|
283
|
+
})
|
|
284
|
+
: undefined,
|
|
261
285
|
})
|
|
262
286
|
|
|
263
287
|
if (!result.contentFound) {
|
|
264
288
|
return json({ error: 'content_not_found', contentId: parsed.data.content_id }, 404)
|
|
265
289
|
}
|
|
290
|
+
if (result.rejection) {
|
|
291
|
+
console.warn(
|
|
292
|
+
`[cms] fronts/publish-callback rejected for content ${parsed.data.content_id}: ${result.rejection.code}`,
|
|
293
|
+
)
|
|
294
|
+
return json(
|
|
295
|
+
{
|
|
296
|
+
ok: false,
|
|
297
|
+
error: result.rejection.code,
|
|
298
|
+
message: result.rejection.message,
|
|
299
|
+
...(result.rejection.blockers ? { blockers: result.rejection.blockers } : {}),
|
|
300
|
+
},
|
|
301
|
+
result.rejection.status,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
266
304
|
if (result.ledgerError) {
|
|
267
305
|
// A confirmed `live` that could not be committed through the ledger is a
|
|
268
306
|
// hard, loud failure — never a silent zero.
|
package/src/routes/index.ts
CHANGED
|
@@ -312,7 +312,7 @@ export function createCmsRoutes(
|
|
|
312
312
|
authz: config.authz,
|
|
313
313
|
activeWorkspaceId: config.activeWorkspaceId,
|
|
314
314
|
}),
|
|
315
|
-
frontsPublish: createFrontsPublishRoutes(
|
|
315
|
+
frontsPublish: createFrontsPublishRoutes(config),
|
|
316
316
|
foundryPublish: createFoundryPublishRoutes(config),
|
|
317
317
|
}
|
|
318
318
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/ui/api/_content-config.ts — shared helpers for content + tag Astro endpoint adapters.
|
|
2
2
|
// Builds a RouteContext and a ContentRouteConfig from an APIContext at request time.
|
|
3
3
|
|
|
4
|
+
import { config } from 'virtual:growth-labs/cms/config'
|
|
4
5
|
import type { APIContext } from 'astro'
|
|
5
6
|
import type {
|
|
6
7
|
ContentRouteConfig,
|
|
@@ -24,6 +25,7 @@ type CmsHooksLocals = ContentRouteConfig['hooks'] & {
|
|
|
24
25
|
dispatchFaq?: DispatchFaqHook
|
|
25
26
|
dispatchWebhook?: DispatchWebhookHook
|
|
26
27
|
publishReadiness?: PublishReadinessHook
|
|
28
|
+
podcastSourceProductionEnabled?: boolean
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -61,8 +63,16 @@ export function toContentCtx(c: APIContext, params?: Record<string, string>): Ro
|
|
|
61
63
|
export function toContentConfig(c: APIContext): ContentRouteConfig {
|
|
62
64
|
const locals = c.locals as { cmsHooks?: CmsHooksLocals }
|
|
63
65
|
const hooks = locals.cmsHooks ?? {}
|
|
66
|
+
const primaryTopicSlugs = config.primaryTopic?.options.map(
|
|
67
|
+
(option: { slug: string }) => option.slug,
|
|
68
|
+
)
|
|
64
69
|
return {
|
|
65
70
|
authz: buildSiteAuthz(c),
|
|
71
|
+
primaryTopicSlugs:
|
|
72
|
+
primaryTopicSlugs && primaryTopicSlugs.length > 0
|
|
73
|
+
? (primaryTopicSlugs as [string, ...string[]])
|
|
74
|
+
: undefined,
|
|
75
|
+
requiredPrimaryTopicContentTypes: config.primaryTopic?.contentTypes,
|
|
66
76
|
activeWorkspaceId: hooks.activeWorkspaceId,
|
|
67
77
|
hooks: {
|
|
68
78
|
llm: hooks.llm,
|
|
@@ -73,5 +83,6 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
|
|
|
73
83
|
dispatchFaq: hooks.dispatchFaq,
|
|
74
84
|
dispatchWebhook: hooks.dispatchWebhook,
|
|
75
85
|
publishReadiness: hooks.publishReadiness,
|
|
86
|
+
podcastSourceProductionEnabled: hooks.podcastSourceProductionEnabled,
|
|
76
87
|
}
|
|
77
88
|
}
|
|
@@ -5,14 +5,8 @@
|
|
|
5
5
|
// content_publication_attempts 2PC ledger. HMAC-SHA256 (X-Foundry-Signature vs
|
|
6
6
|
// env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher.
|
|
7
7
|
import type { APIContext } from 'astro'
|
|
8
|
-
import type { RouteContext } from '../../../routes/context.js'
|
|
9
8
|
import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
function toCtx(c: APIContext): RouteContext {
|
|
13
|
-
const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
|
|
14
|
-
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
15
|
-
}
|
|
9
|
+
import { toContentConfig, toContentCtx } from '../_content-config.js'
|
|
16
10
|
|
|
17
11
|
export const POST = (c: APIContext) =>
|
|
18
|
-
createFrontsPublishRoutes(
|
|
12
|
+
createFrontsPublishRoutes(toContentConfig(c)).publishCallback(toContentCtx(c, {}))
|
|
@@ -5,14 +5,8 @@
|
|
|
5
5
|
// env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher. Sibling of the
|
|
6
6
|
// content foundry-callback / insights-ingest M2M routes.
|
|
7
7
|
import type { APIContext } from 'astro'
|
|
8
|
-
import type { RouteContext } from '../../../routes/context.js'
|
|
9
8
|
import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
function toCtx(c: APIContext): RouteContext {
|
|
13
|
-
const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
|
|
14
|
-
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
15
|
-
}
|
|
9
|
+
import { toContentConfig, toContentCtx } from '../_content-config.js'
|
|
16
10
|
|
|
17
11
|
export const GET = (c: APIContext) =>
|
|
18
|
-
createFrontsPublishRoutes(
|
|
12
|
+
createFrontsPublishRoutes(toContentConfig(c)).schedule(toContentCtx(c, {}))
|
package/src/ui/client/mount.tsx
CHANGED
|
@@ -39,6 +39,7 @@ export async function mount(elementId = 'masthead-root') {
|
|
|
39
39
|
const screens = createCmsScreens({
|
|
40
40
|
allowInlineAuthorCreation: config.socialSharing.allowInlineAuthorCreation,
|
|
41
41
|
primaryCategories: config.primaryCategories,
|
|
42
|
+
primaryTopic: config.primaryTopic,
|
|
42
43
|
contentMetadataSelects: config.contentMetadataSelects,
|
|
43
44
|
surveyId: config.surveyResults?.surveyId,
|
|
44
45
|
})
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
22
22
|
import { type ContentMetadata, parseContentMetadata } from '../../engine/content-metadata.js'
|
|
23
23
|
import { countWords, estimateReadTime } from '../../engine/publisher.js'
|
|
24
|
-
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
24
|
+
import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
|
|
25
25
|
import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
|
|
26
26
|
import { Icon } from '../icons.js'
|
|
27
27
|
import { uploadMediaAsset } from '../screens/media-upload.js'
|
|
@@ -76,6 +76,7 @@ export interface ContentFormProps {
|
|
|
76
76
|
/** Called when save state changes so editor chrome can show a real save action. */
|
|
77
77
|
onSaveStateChange?: (state: ContentSaveState) => void
|
|
78
78
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
79
|
+
primaryTopic?: PrimaryTopicControl
|
|
79
80
|
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -117,6 +118,7 @@ interface ExistingContentItem {
|
|
|
117
118
|
author_name?: string | null
|
|
118
119
|
author_slug?: string | null
|
|
119
120
|
channel?: string | null
|
|
121
|
+
primary_topic?: string | null
|
|
120
122
|
published_at?: number | null
|
|
121
123
|
hero_image_id?: string | null
|
|
122
124
|
hero_image_url?: string | null
|
|
@@ -165,6 +167,7 @@ interface FormState {
|
|
|
165
167
|
publishedAt: string | null
|
|
166
168
|
premium: boolean
|
|
167
169
|
channel: string | null
|
|
170
|
+
primaryTopic: string | null | undefined
|
|
168
171
|
metadata: ContentMetadata
|
|
169
172
|
heroImageId: string
|
|
170
173
|
heroImageUrl: string | null
|
|
@@ -224,6 +227,7 @@ function createEmptyFormState(docId: string | null, metadata: ContentMetadata =
|
|
|
224
227
|
publishedAt: null,
|
|
225
228
|
premium: false,
|
|
226
229
|
channel: null,
|
|
230
|
+
primaryTopic: undefined,
|
|
227
231
|
metadata,
|
|
228
232
|
heroImageId: '',
|
|
229
233
|
heroImageUrl: null,
|
|
@@ -270,8 +274,10 @@ export function ContentForm({
|
|
|
270
274
|
onFrontsPublishStatusChange,
|
|
271
275
|
onSaveStateChange,
|
|
272
276
|
primaryCategories = [],
|
|
277
|
+
primaryTopic,
|
|
273
278
|
contentMetadataSelects = EMPTY_CONTENT_METADATA_SELECTS,
|
|
274
279
|
}: ContentFormProps) {
|
|
280
|
+
const primaryTopicEnabled = primaryTopic?.contentTypes.includes(contentType) ?? false
|
|
275
281
|
const metadataFields = useMemo(
|
|
276
282
|
() => contentMetadataSelects.filter((field) => field.contentTypes.includes(contentType)),
|
|
277
283
|
[contentMetadataSelects, contentType],
|
|
@@ -314,6 +320,7 @@ export function ContentForm({
|
|
|
314
320
|
}
|
|
315
321
|
const data = (await res.json()) as ExistingContentResponse
|
|
316
322
|
const next = contentResponseToFormState(data, contentType, docId)
|
|
323
|
+
if (!primaryTopicEnabled) next.primaryTopic = undefined
|
|
317
324
|
setForm((prev) => ({
|
|
318
325
|
...next,
|
|
319
326
|
aiOpen: prev.aiOpen,
|
|
@@ -334,6 +341,7 @@ export function ContentForm({
|
|
|
334
341
|
metadataFields,
|
|
335
342
|
onStatusChange,
|
|
336
343
|
onFrontsPublishStatusChange,
|
|
344
|
+
primaryTopicEnabled,
|
|
337
345
|
publishDraft,
|
|
338
346
|
])
|
|
339
347
|
|
|
@@ -402,7 +410,11 @@ export function ContentForm({
|
|
|
402
410
|
const draft: ContentDraftFields = currentForm
|
|
403
411
|
|
|
404
412
|
if (id === null) {
|
|
405
|
-
if (
|
|
413
|
+
if (
|
|
414
|
+
!canCreateContentDraft(contentType, draft, {
|
|
415
|
+
requirePrimaryTopic: primaryTopicEnabled,
|
|
416
|
+
})
|
|
417
|
+
) {
|
|
406
418
|
const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
|
|
407
419
|
const missingMediaDuration =
|
|
408
420
|
mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null
|
|
@@ -537,7 +549,7 @@ export function ContentForm({
|
|
|
537
549
|
}
|
|
538
550
|
}
|
|
539
551
|
},
|
|
540
|
-
[contentType, onDocCreated, queueFoundryProcessing],
|
|
552
|
+
[contentType, onDocCreated, primaryTopicEnabled, queueFoundryProcessing],
|
|
541
553
|
)
|
|
542
554
|
|
|
543
555
|
useEffect(() => {
|
|
@@ -603,6 +615,13 @@ export function ContentForm({
|
|
|
603
615
|
scheduleAutosave(next)
|
|
604
616
|
}
|
|
605
617
|
|
|
618
|
+
function handlePrimaryTopicChange(value: string) {
|
|
619
|
+
const next = { ...latestForm.current, primaryTopic: value || null }
|
|
620
|
+
setForm(next)
|
|
621
|
+
publishDraft(next)
|
|
622
|
+
scheduleAutosave(next)
|
|
623
|
+
}
|
|
624
|
+
|
|
606
625
|
function handleMetadataSelectChange(field: ContentMetadataSelectField, value: string) {
|
|
607
626
|
const next = {
|
|
608
627
|
...latestForm.current,
|
|
@@ -999,6 +1018,28 @@ export function ContentForm({
|
|
|
999
1018
|
</div>
|
|
1000
1019
|
)}
|
|
1001
1020
|
|
|
1021
|
+
{primaryTopicEnabled && primaryTopic && (
|
|
1022
|
+
<div style={fieldGroup}>
|
|
1023
|
+
<label style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--ink-muted)' }}>
|
|
1024
|
+
{primaryTopic.label}
|
|
1025
|
+
<select
|
|
1026
|
+
value={form.primaryTopic ?? ''}
|
|
1027
|
+
onChange={(event) => handlePrimaryTopicChange(event.currentTarget.value)}
|
|
1028
|
+
aria-label={primaryTopic.label}
|
|
1029
|
+
required
|
|
1030
|
+
style={urlInput}
|
|
1031
|
+
>
|
|
1032
|
+
<option value="">Select {primaryTopic.label.toLowerCase()}…</option>
|
|
1033
|
+
{primaryTopic.options.map((option) => (
|
|
1034
|
+
<option key={option.slug} value={option.slug}>
|
|
1035
|
+
{option.label}
|
|
1036
|
+
</option>
|
|
1037
|
+
))}
|
|
1038
|
+
</select>
|
|
1039
|
+
</label>
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
1042
|
+
|
|
1002
1043
|
{metadataFields.map((field) => {
|
|
1003
1044
|
const storedValue = form.metadata[field.key]
|
|
1004
1045
|
const value = typeof storedValue === 'string' ? storedValue : ''
|
|
@@ -1645,6 +1686,7 @@ function contentResponseToFormState(
|
|
|
1645
1686
|
next.publishedAt = unixSecondsToIso(item.published_at)
|
|
1646
1687
|
next.premium = item.visibility === 'premium'
|
|
1647
1688
|
next.channel = toNullableString(item.channel)
|
|
1689
|
+
next.primaryTopic = toNullableString(item.primary_topic)
|
|
1648
1690
|
next.metadata = storedContentMetadata(item.metadata_json)
|
|
1649
1691
|
next.heroImageId = heroImageId
|
|
1650
1692
|
next.heroImageUrl = heroImageUrl || (heroImageId ? mediaUrlFromId(heroImageId) : null)
|
|
@@ -21,6 +21,7 @@ export interface ContentDraftFields {
|
|
|
21
21
|
podcastSourceKind?: string | null
|
|
22
22
|
durationSeconds: number | null
|
|
23
23
|
channel?: string | null
|
|
24
|
+
primaryTopic?: string | null
|
|
24
25
|
metadata?: ContentMetadata
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -116,8 +117,10 @@ export function slugifyTitle(title: string): string {
|
|
|
116
117
|
export function canCreateContentDraft(
|
|
117
118
|
contentType: ContentType,
|
|
118
119
|
draft: ContentDraftFields,
|
|
120
|
+
requirements: { requirePrimaryTopic?: boolean } = {},
|
|
119
121
|
): boolean {
|
|
120
122
|
if (draft.title.trim().length < 3) return false
|
|
123
|
+
if (requirements.requirePrimaryTopic && !draft.primaryTopic?.trim()) return false
|
|
121
124
|
if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
|
|
122
125
|
return draft.body.trim().length > 0
|
|
123
126
|
}
|
|
@@ -181,6 +184,10 @@ export function buildContentUpdatePayload(
|
|
|
181
184
|
if (draft.metadata !== undefined) payload.metadata = draft.metadata
|
|
182
185
|
const channel = draft.channel?.trim()
|
|
183
186
|
payload.primaryCategory = channel || null
|
|
187
|
+
if (draft.primaryTopic !== undefined) {
|
|
188
|
+
const primaryTopic = draft.primaryTopic?.trim()
|
|
189
|
+
payload.primaryTopic = primaryTopic || null
|
|
190
|
+
}
|
|
184
191
|
const heroImageId = draft.heroImageId?.trim()
|
|
185
192
|
if (heroImageId) {
|
|
186
193
|
payload.heroImageId = heroImageId
|
|
@@ -249,5 +256,6 @@ export function buildContentCreatePayload(
|
|
|
249
256
|
...buildContentUpdatePayload(contentType, draft),
|
|
250
257
|
}
|
|
251
258
|
if (payload.primaryCategory === null) delete payload.primaryCategory
|
|
259
|
+
if (payload.primaryTopic === null) delete payload.primaryTopic
|
|
252
260
|
return payload
|
|
253
261
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This file creates thin stubs so the registry can compile now (Task 10).
|
|
9
9
|
|
|
10
10
|
import { useReducer } from 'react'
|
|
11
|
-
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
11
|
+
import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
|
|
12
12
|
import { type ContentType, contentViewReducer, initialContentView } from './content-view.js'
|
|
13
13
|
import { EditorScreen } from './EditorScreen.js'
|
|
14
14
|
import { LibraryScreen } from './LibraryScreen.js'
|
|
@@ -16,10 +16,12 @@ import { LibraryScreen } from './LibraryScreen.js'
|
|
|
16
16
|
export function ContentRoute({
|
|
17
17
|
allowInlineAuthorCreation = true,
|
|
18
18
|
primaryCategories = [],
|
|
19
|
+
primaryTopic,
|
|
19
20
|
contentMetadataSelects = [],
|
|
20
21
|
}: {
|
|
21
22
|
allowInlineAuthorCreation?: boolean
|
|
22
23
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
24
|
+
primaryTopic?: PrimaryTopicControl
|
|
23
25
|
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
24
26
|
}) {
|
|
25
27
|
const [view, dispatch] = useReducer(contentViewReducer, initialContentView)
|
|
@@ -52,6 +54,7 @@ export function ContentRoute({
|
|
|
52
54
|
onClose={handleClose}
|
|
53
55
|
allowInlineAuthorCreation={allowInlineAuthorCreation}
|
|
54
56
|
primaryCategories={primaryCategories}
|
|
57
|
+
primaryTopic={primaryTopic}
|
|
55
58
|
contentMetadataSelects={contentMetadataSelects}
|
|
56
59
|
/>
|
|
57
60
|
)
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// is Playwright-smoked only.
|
|
18
18
|
|
|
19
19
|
import { useCallback, useState } from 'react'
|
|
20
|
-
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
20
|
+
import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
|
|
21
21
|
import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
|
|
22
22
|
import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
|
|
23
23
|
import {
|
|
@@ -41,6 +41,7 @@ export interface EditorScreenProps {
|
|
|
41
41
|
onClose: () => void
|
|
42
42
|
allowInlineAuthorCreation?: boolean
|
|
43
43
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
44
|
+
primaryTopic?: PrimaryTopicControl
|
|
44
45
|
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -101,6 +102,7 @@ export function EditorScreen({
|
|
|
101
102
|
onClose,
|
|
102
103
|
allowInlineAuthorCreation = true,
|
|
103
104
|
primaryCategories = [],
|
|
105
|
+
primaryTopic,
|
|
104
106
|
contentMetadataSelects = [],
|
|
105
107
|
}: EditorScreenProps) {
|
|
106
108
|
const contentType: ContentType = (docRef.contentType as ContentType | undefined) ?? 'article'
|
|
@@ -410,6 +412,7 @@ export function EditorScreen({
|
|
|
410
412
|
onFrontsPublishStatusChange={setFrontsPublishStatus}
|
|
411
413
|
onSaveStateChange={handleSaveStateChange}
|
|
412
414
|
primaryCategories={primaryCategories}
|
|
415
|
+
primaryTopic={primaryTopic}
|
|
413
416
|
contentMetadataSelects={contentMetadataSelects}
|
|
414
417
|
/>
|
|
415
418
|
</div>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// internal router state (which is not exported). The ContentRoute itself owns
|
|
12
12
|
// the full open-doc reducer (which doc is open, new vs existing).
|
|
13
13
|
|
|
14
|
-
import type { ContentMetadataSelectField } from '../../integration/options.js'
|
|
14
|
+
import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
|
|
15
15
|
import type { ScreenRegistry } from '../components/CmsApp.js'
|
|
16
16
|
import { useHashRouter } from '../use-hash-router.js'
|
|
17
17
|
import { AnalyticsScreen } from './AnalyticsScreen.js'
|
|
@@ -43,11 +43,13 @@ function CalendarRoute() {
|
|
|
43
43
|
export function createCmsScreens({
|
|
44
44
|
allowInlineAuthorCreation = true,
|
|
45
45
|
primaryCategories = [],
|
|
46
|
+
primaryTopic,
|
|
46
47
|
contentMetadataSelects = [],
|
|
47
48
|
surveyId,
|
|
48
49
|
}: {
|
|
49
50
|
allowInlineAuthorCreation?: boolean
|
|
50
51
|
primaryCategories?: Array<{ slug: string; label: string }>
|
|
52
|
+
primaryTopic?: PrimaryTopicControl
|
|
51
53
|
contentMetadataSelects?: ContentMetadataSelectField[]
|
|
52
54
|
surveyId?: string
|
|
53
55
|
} = {}): ScreenRegistry {
|
|
@@ -56,6 +58,7 @@ export function createCmsScreens({
|
|
|
56
58
|
<ContentRoute
|
|
57
59
|
allowInlineAuthorCreation={allowInlineAuthorCreation}
|
|
58
60
|
primaryCategories={primaryCategories}
|
|
61
|
+
primaryTopic={primaryTopic}
|
|
59
62
|
contentMetadataSelects={contentMetadataSelects}
|
|
60
63
|
/>
|
|
61
64
|
),
|