@growth-labs/cms 0.6.7 → 0.7.0
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/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +7 -3
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/published-content.d.ts +10 -0
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +3 -0
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +26 -2
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +113 -6
- package/dist/engine/publisher.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/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.map +1 -1
- package/dist/routes/content.js +7 -5
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +6 -0
- package/dist/ui/api/_content-config.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/publication.ts +7 -1
- package/src/engine/published-content.ts +13 -0
- package/src/engine/publisher.ts +119 -4
- package/src/integration/options.ts +23 -0
- package/src/routes/config.ts +5 -0
- package/src/routes/content.ts +8 -5
- package/src/ui/api/_content-config.ts +9 -0
- 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
|
@@ -54,6 +54,16 @@ export interface PublishedContent {
|
|
|
54
54
|
type: ContentType
|
|
55
55
|
publishedAt: number
|
|
56
56
|
updatedAt: number
|
|
57
|
+
/**
|
|
58
|
+
* Row creation time (unix seconds), from the published revision snapshot.
|
|
59
|
+
*
|
|
60
|
+
* Distinct from `publishedAt`, which is the date the article *claims* and is
|
|
61
|
+
* deliberately backdated to the source video's date on the channel sites.
|
|
62
|
+
* Consumers pass this to `resolveAdaptedArticleDates` as the `createdAt`
|
|
63
|
+
* fallback for `datePublished`; without it that fallback is dead code.
|
|
64
|
+
* `undefined` only for a revision snapshot predating the column.
|
|
65
|
+
*/
|
|
66
|
+
createdAt: number | undefined
|
|
57
67
|
payload: ContentRevisionPayload
|
|
58
68
|
metadata: ContentMetadata
|
|
59
69
|
}
|
|
@@ -358,6 +368,9 @@ function decodePublishedContent(row: PublishedContentRow, requireBodies = true):
|
|
|
358
368
|
type: item.type as ContentType,
|
|
359
369
|
publishedAt: row.canonical_published_at,
|
|
360
370
|
updatedAt: item.updated_at as number,
|
|
371
|
+
// getContentSnapshot selects * from content_items, so created_at rides
|
|
372
|
+
// along in the revision payload; no extra column selection needed.
|
|
373
|
+
createdAt: Number.isSafeInteger(item.created_at) ? (item.created_at as number) : undefined,
|
|
361
374
|
payload,
|
|
362
375
|
metadata,
|
|
363
376
|
}
|
package/src/engine/publisher.ts
CHANGED
|
@@ -1464,16 +1464,129 @@ export async function assertContentBodyValidForPublish(
|
|
|
1464
1464
|
}
|
|
1465
1465
|
}
|
|
1466
1466
|
|
|
1467
|
+
/**
|
|
1468
|
+
* Publish-time date stamping on `content_items.metadata_json`.
|
|
1469
|
+
*
|
|
1470
|
+
* Adapted articles (an article written from a YouTube video script) need two
|
|
1471
|
+
* dates the row did not previously carry:
|
|
1472
|
+
*
|
|
1473
|
+
* - `adaptedAt` — the date the article first went live **on the site**. This is
|
|
1474
|
+
* what `@growth-labs/seo`'s `resolveAdaptedArticleDates` turns into JSON-LD
|
|
1475
|
+
* `datePublished`.
|
|
1476
|
+
* - `updatedAt` — the date of the most recent re-publish, which becomes
|
|
1477
|
+
* `dateModified`.
|
|
1478
|
+
*
|
|
1479
|
+
* ## The distinction that matters: claimed date vs. wall clock
|
|
1480
|
+
*
|
|
1481
|
+
* `published_at` is the date the article *claims*, and on all ten channel sites
|
|
1482
|
+
* it is deliberately backdated to the **YouTube video's** publish date — that is
|
|
1483
|
+
* the whole reason these two keys exist. `adaptedAt` and `updatedAt` are the
|
|
1484
|
+
* opposite: the wall-clock moment the publish transition actually committed.
|
|
1485
|
+
*
|
|
1486
|
+
* Deriving either of them from the publish timestamp would collapse
|
|
1487
|
+
* `datePublished` straight back onto the video date and make the feature a
|
|
1488
|
+
* no-op for every future article. So the stamp takes its own `now` value,
|
|
1489
|
+
* distinct from `publishedAt`, at every call site.
|
|
1490
|
+
*
|
|
1491
|
+
* Invariants:
|
|
1492
|
+
* - `adaptedAt` is written once and **never rewritten**. It is the article's
|
|
1493
|
+
* identity date; rewriting it would walk `datePublished` forward on every edit.
|
|
1494
|
+
* - A re-publish stamps `updatedAt` at wall clock. A first publish does not —
|
|
1495
|
+
* `dateModified` defaults to `datePublished` in the consumer. Wall clock also
|
|
1496
|
+
* means a backdated re-publish cannot emit a `dateModified` before
|
|
1497
|
+
* `datePublished`, and a scheduled forward-dated publish cannot emit a
|
|
1498
|
+
* *future* `dateModified` (a Discover quality signal problem that the seo
|
|
1499
|
+
* helper's clamp does not catch).
|
|
1500
|
+
* - A row already published before this shipped has no `adaptedAt`. It is
|
|
1501
|
+
* backfilled from `COALESCE(created_at, published_at)` — `created_at` is the
|
|
1502
|
+
* real row-creation moment, whereas `published_at` is the claimed (possibly
|
|
1503
|
+
* backdated) date. The two are equal on exactly the rows that need the
|
|
1504
|
+
* backfill, so this is a no-op on current data and correct by construction
|
|
1505
|
+
* rather than by coincidence. `content_items.created_at` is NOT NULL, so the
|
|
1506
|
+
* `published_at` arm of that COALESCE is unreachable by schema and kept only
|
|
1507
|
+
* as defence in depth — there is deliberately no test for it, because no test
|
|
1508
|
+
* can construct the state.
|
|
1509
|
+
*
|
|
1510
|
+
* Expressed as SQL rather than read-modify-write so the stamp lands in the same
|
|
1511
|
+
* atomic statement as the publish transition. A separate UPDATE would open a
|
|
1512
|
+
* window where an item is published but unstamped, and in the 2PC kernel it
|
|
1513
|
+
* would not be covered by the pointer CAS. In SQLite the right-hand side of a
|
|
1514
|
+
* SET clause sees the row's **pre-update** values, so `published_at` and
|
|
1515
|
+
* `created_at` below are the previous values, which is what the backfill needs.
|
|
1516
|
+
*
|
|
1517
|
+
* Malformed or absent `metadata_json` degrades to `{}` rather than failing the
|
|
1518
|
+
* publish; `json_set` on invalid JSON would otherwise throw and block the item.
|
|
1519
|
+
*/
|
|
1520
|
+
const METADATA_OBJECT_SQL = `CASE
|
|
1521
|
+
WHEN metadata_json IS NOT NULL AND json_valid(metadata_json) AND json_type(metadata_json) = 'object'
|
|
1522
|
+
THEN metadata_json
|
|
1523
|
+
ELSE '{}'
|
|
1524
|
+
END`
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* `metadata_json` with `adaptedAt` resolved:
|
|
1528
|
+
* already set → untouched; previously published → backfilled from the row's own
|
|
1529
|
+
* creation time; otherwise a genuine first publish → the wall clock `?`.
|
|
1530
|
+
*/
|
|
1531
|
+
const METADATA_WITH_ADAPTED_AT_SQL = `CASE
|
|
1532
|
+
WHEN json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
|
|
1533
|
+
THEN ${METADATA_OBJECT_SQL}
|
|
1534
|
+
WHEN published_at IS NOT NULL
|
|
1535
|
+
THEN json_set(${METADATA_OBJECT_SQL}, '$.adaptedAt', date(COALESCE(created_at, published_at), 'unixepoch'))
|
|
1536
|
+
ELSE json_set(${METADATA_OBJECT_SQL}, '$.adaptedAt', date(?, 'unixepoch'))
|
|
1537
|
+
END`
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* The full `metadata_json = ...` assignment. Append to a publish UPDATE's SET
|
|
1541
|
+
* clause and bind with {@link publishMetadataStampBinds}.
|
|
1542
|
+
*
|
|
1543
|
+
* "Already published" is `published_at IS NOT NULL OR adaptedAt IS NOT NULL` —
|
|
1544
|
+
* the second disjunct keeps unpublish→republish correct, since `unpublishContent`
|
|
1545
|
+
* clears `published_at` but the article did go live once.
|
|
1546
|
+
*/
|
|
1547
|
+
export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = CASE
|
|
1548
|
+
WHEN published_at IS NOT NULL
|
|
1549
|
+
OR json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
|
|
1550
|
+
THEN json_set(${METADATA_WITH_ADAPTED_AT_SQL}, '$.updatedAt', date(?, 'unixepoch'))
|
|
1551
|
+
ELSE ${METADATA_WITH_ADAPTED_AT_SQL}
|
|
1552
|
+
END`
|
|
1553
|
+
|
|
1554
|
+
/** Placeholder count in {@link PUBLISH_METADATA_STAMP_SQL}, derived so it cannot drift. */
|
|
1555
|
+
const PUBLISH_METADATA_STAMP_PLACEHOLDERS = (PUBLISH_METADATA_STAMP_SQL.match(/\?/g) ?? []).length
|
|
1556
|
+
|
|
1557
|
+
/** Wall-clock unix seconds — the moment a publish transition commits. */
|
|
1558
|
+
export function nowSeconds(): number {
|
|
1559
|
+
return Math.floor(Date.now() / 1000)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Binds for {@link PUBLISH_METADATA_STAMP_SQL}. Every placeholder is the same
|
|
1564
|
+
* wall-clock `now` (unix seconds) — deliberately NOT the publish timestamp,
|
|
1565
|
+
* which is routinely backdated. The count is derived from the SQL itself so
|
|
1566
|
+
* editing the expression cannot silently desynchronize the bind list.
|
|
1567
|
+
*/
|
|
1568
|
+
export function publishMetadataStampBinds(now: number): number[] {
|
|
1569
|
+
return new Array(PUBLISH_METADATA_STAMP_PLACEHOLDERS).fill(now)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* @param publishedAt the date the article claims — may be deliberately backdated.
|
|
1574
|
+
* @param now wall-clock unix seconds for the date stamps; defaults to real now.
|
|
1575
|
+
* Kept separate from `publishedAt` on purpose (see PUBLISH_METADATA_STAMP_SQL)
|
|
1576
|
+
* and injectable so tests stay deterministic.
|
|
1577
|
+
*/
|
|
1467
1578
|
export async function publishContent(
|
|
1468
1579
|
db: D1Database,
|
|
1469
1580
|
id: string,
|
|
1470
1581
|
publishedAt: number,
|
|
1582
|
+
now: number = nowSeconds(),
|
|
1471
1583
|
): Promise<void> {
|
|
1472
1584
|
await db
|
|
1473
1585
|
.prepare(
|
|
1474
|
-
`UPDATE content_items SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch()
|
|
1586
|
+
`UPDATE content_items SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch(),
|
|
1587
|
+
${PUBLISH_METADATA_STAMP_SQL} WHERE id = ?`,
|
|
1475
1588
|
)
|
|
1476
|
-
.bind(publishedAt, id)
|
|
1589
|
+
.bind(publishedAt, ...publishMetadataStampBinds(now), id)
|
|
1477
1590
|
.run()
|
|
1478
1591
|
}
|
|
1479
1592
|
|
|
@@ -1493,14 +1606,16 @@ export async function claimScheduledForPublish(
|
|
|
1493
1606
|
db: D1Database,
|
|
1494
1607
|
id: string,
|
|
1495
1608
|
publishedAt: number,
|
|
1609
|
+
now: number = nowSeconds(),
|
|
1496
1610
|
): Promise<boolean> {
|
|
1497
1611
|
const res = await db
|
|
1498
1612
|
.prepare(
|
|
1499
1613
|
`UPDATE content_items
|
|
1500
|
-
SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch()
|
|
1614
|
+
SET status = 'published', published_at = ?, publish_at = NULL, updated_at = unixepoch(),
|
|
1615
|
+
${PUBLISH_METADATA_STAMP_SQL}
|
|
1501
1616
|
WHERE id = ? AND status = 'scheduled'`,
|
|
1502
1617
|
)
|
|
1503
|
-
.bind(publishedAt, id)
|
|
1618
|
+
.bind(publishedAt, ...publishMetadataStampBinds(now), id)
|
|
1504
1619
|
.run()
|
|
1505
1620
|
const changes = (res.meta as { changes?: number } | undefined)?.changes ?? 0
|
|
1506
1621
|
return changes > 0
|
|
@@ -13,6 +13,28 @@ const primaryCategorySchema = z.object({
|
|
|
13
13
|
label: z.string().min(1),
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
+
const contentTypeSchema = z.enum(['article', 'video', 'podcast', 'newsletter', 'page'])
|
|
17
|
+
|
|
18
|
+
const primaryTopicControlSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
label: z.string().trim().min(1).max(100),
|
|
21
|
+
contentTypes: z.array(contentTypeSchema).min(1),
|
|
22
|
+
options: z.array(primaryCategorySchema).min(1),
|
|
23
|
+
})
|
|
24
|
+
.strict()
|
|
25
|
+
.superRefine((control, ctx) => {
|
|
26
|
+
const slugs = control.options.map(({ slug }) => slug)
|
|
27
|
+
if (new Set(slugs).size !== slugs.length) {
|
|
28
|
+
ctx.addIssue({
|
|
29
|
+
code: 'custom',
|
|
30
|
+
path: ['options'],
|
|
31
|
+
message: 'primary topic option slugs must be unique',
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type PrimaryTopicControl = z.output<typeof primaryTopicControlSchema>
|
|
37
|
+
|
|
16
38
|
const contentMetadataKeySchema = z
|
|
17
39
|
.string()
|
|
18
40
|
.regex(/^[a-z][a-z0-9_]{0,63}$/, 'metadata keys must be lowercase snake_case')
|
|
@@ -106,6 +128,7 @@ export const cmsIntegrationOptionsSchema = z
|
|
|
106
128
|
.object({
|
|
107
129
|
workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
|
|
108
130
|
primaryCategories: z.array(primaryCategorySchema).default([]),
|
|
131
|
+
primaryTopic: primaryTopicControlSchema.optional(),
|
|
109
132
|
contentMetadataSelects: contentMetadataSelectsSchema,
|
|
110
133
|
activeWorkspaceId: z.string().optional(),
|
|
111
134
|
adminBasePath: z.string().default('/admin'),
|
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
|
@@ -392,13 +392,16 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
392
392
|
tags: z.array(z.string()).optional(),
|
|
393
393
|
metadata: ContentMetadataSchema.optional(),
|
|
394
394
|
})
|
|
395
|
+
const requiredTopicTypes = new Set(resolved.requiredPrimaryTopicContentTypes)
|
|
396
|
+
const baseSchemaFor = (type: ContentType) =>
|
|
397
|
+
requiredTopicTypes.has(type) ? BaseSchema.extend({ primaryTopic: topic }) : BaseSchema
|
|
395
398
|
|
|
396
399
|
const CreateSchema = z.discriminatedUnion('type', [
|
|
397
|
-
|
|
400
|
+
baseSchemaFor('article').extend({
|
|
398
401
|
type: z.literal('article'),
|
|
399
402
|
content: BodyContentCreateSchema,
|
|
400
403
|
}),
|
|
401
|
-
|
|
404
|
+
baseSchemaFor('video').extend({
|
|
402
405
|
type: z.literal('video'),
|
|
403
406
|
content: z.object({
|
|
404
407
|
script: z.string().default(''),
|
|
@@ -409,7 +412,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
409
412
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
410
413
|
}),
|
|
411
414
|
}),
|
|
412
|
-
|
|
415
|
+
baseSchemaFor('podcast').extend({
|
|
413
416
|
type: z.literal('podcast'),
|
|
414
417
|
content: z.object({
|
|
415
418
|
// Empty allowed: a podcast is created as a draft with audio uploaded
|
|
@@ -429,11 +432,11 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
429
432
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
430
433
|
}),
|
|
431
434
|
}),
|
|
432
|
-
|
|
435
|
+
baseSchemaFor('newsletter').extend({
|
|
433
436
|
type: z.literal('newsletter'),
|
|
434
437
|
content: BodyContentCreateSchema,
|
|
435
438
|
}),
|
|
436
|
-
|
|
439
|
+
baseSchemaFor('page').extend({
|
|
437
440
|
type: z.literal('page'),
|
|
438
441
|
content: BodyContentCreateSchema,
|
|
439
442
|
}),
|
|
@@ -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,
|
|
@@ -62,8 +63,16 @@ export function toContentCtx(c: APIContext, params?: Record<string, string>): Ro
|
|
|
62
63
|
export function toContentConfig(c: APIContext): ContentRouteConfig {
|
|
63
64
|
const locals = c.locals as { cmsHooks?: CmsHooksLocals }
|
|
64
65
|
const hooks = locals.cmsHooks ?? {}
|
|
66
|
+
const primaryTopicSlugs = config.primaryTopic?.options.map(
|
|
67
|
+
(option: { slug: string }) => option.slug,
|
|
68
|
+
)
|
|
65
69
|
return {
|
|
66
70
|
authz: buildSiteAuthz(c),
|
|
71
|
+
primaryTopicSlugs:
|
|
72
|
+
primaryTopicSlugs && primaryTopicSlugs.length > 0
|
|
73
|
+
? (primaryTopicSlugs as [string, ...string[]])
|
|
74
|
+
: undefined,
|
|
75
|
+
requiredPrimaryTopicContentTypes: config.primaryTopic?.contentTypes,
|
|
67
76
|
activeWorkspaceId: hooks.activeWorkspaceId,
|
|
68
77
|
hooks: {
|
|
69
78
|
llm: hooks.llm,
|
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
|
),
|