@growth-labs/cms 0.8.30 → 0.8.32
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/content-metadata.d.ts +2 -0
- package/dist/engine/content-metadata.d.ts.map +1 -1
- package/dist/engine/content-metadata.js +15 -0
- package/dist/engine/content-metadata.js.map +1 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +23 -3
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts +8 -8
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +27 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/foundry-emit.d.ts +5 -0
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +3 -0
- package/dist/foundry-emit.js.map +1 -1
- package/dist/integration/options.d.ts +14 -14
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +1 -15
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +1 -0
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/ui/api/content/insights-ingest.d.ts.map +1 -1
- package/dist/ui/api/content/insights-ingest.js +12 -3
- package/dist/ui/api/content/insights-ingest.js.map +1 -1
- package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
- package/dist/ui/api/content/insights-inputs.js +3 -2
- package/dist/ui/api/content/insights-inputs.js.map +1 -1
- package/docs/foundry-publish.md +6 -0
- package/package.json +1 -1
- package/src/engine/content-metadata.ts +17 -0
- package/src/engine/publication.ts +26 -2
- package/src/engine/publisher.ts +46 -1
- package/src/foundry-emit.ts +3 -0
- package/src/routes/content.ts +1 -20
- package/src/routes/foundry-publish.ts +1 -0
- package/src/ui/api/content/insights-ingest.ts +10 -3
- package/src/ui/api/content/insights-inputs.ts +3 -2
package/package.json
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
1
3
|
export type ContentMetadataPrimitive = string | number | boolean | null
|
|
2
4
|
export type ContentMetadataValue =
|
|
3
5
|
| ContentMetadataPrimitive
|
|
@@ -120,3 +122,18 @@ export function parseContentMetadata(raw: unknown): ContentMetadata {
|
|
|
120
122
|
serializeContentMetadata(parsed as ContentMetadata)
|
|
121
123
|
return parsed as ContentMetadata
|
|
122
124
|
}
|
|
125
|
+
|
|
126
|
+
export const ContentMetadataSchema = z
|
|
127
|
+
.record(z.string(), z.unknown())
|
|
128
|
+
.superRefine((metadata, ctx) => {
|
|
129
|
+
try {
|
|
130
|
+
serializeContentMetadata(metadata as ContentMetadata)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
ctx.addIssue({
|
|
133
|
+
code: 'custom',
|
|
134
|
+
message:
|
|
135
|
+
error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.transform((metadata) => metadata as ContentMetadata)
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
getContentSnapshot,
|
|
34
34
|
PUBLISH_METADATA_STAMP_SQL,
|
|
35
35
|
PublishContentValidationError,
|
|
36
|
+
publicationMetadataForSnapshot,
|
|
36
37
|
publishMetadataStampBinds,
|
|
37
38
|
} from './publisher.js'
|
|
38
39
|
import { getRevision } from './revisions.js'
|
|
@@ -812,6 +813,20 @@ async function createRevisionAndPublicationAttempt(
|
|
|
812
813
|
}
|
|
813
814
|
}
|
|
814
815
|
assertPublicationSnapshotBodyValid(snapshot, input.contentId)
|
|
816
|
+
if (!input.mediaReplacement) {
|
|
817
|
+
const item = snapshotRecord(snapshot.item)
|
|
818
|
+
// The native route binds/adopts article source_id under this namespace.
|
|
819
|
+
// Preserve generic and archive-import snapshot metadata verbatim; their
|
|
820
|
+
// historical published_at remains the read model's publication date.
|
|
821
|
+
if (
|
|
822
|
+
item.type === 'article' &&
|
|
823
|
+
typeof item.source_id === 'string' &&
|
|
824
|
+
item.source_id.startsWith('foundry:')
|
|
825
|
+
) {
|
|
826
|
+
item.metadata_json = await publicationMetadataForSnapshot(db, item, input.now)
|
|
827
|
+
snapshot.publication_metadata_stamp = 'native-article-v1'
|
|
828
|
+
}
|
|
829
|
+
}
|
|
815
830
|
const revisionId = input.mediaReplacement
|
|
816
831
|
? `media:${input.mediaReplacement.identity}`
|
|
817
832
|
: crypto.randomUUID()
|
|
@@ -1713,6 +1728,15 @@ async function movePointerAndBeginCommit(
|
|
|
1713
1728
|
now: number,
|
|
1714
1729
|
): Promise<void> {
|
|
1715
1730
|
const targetRevision = await getRevision(db, attempt.contentId, attempt.revisionId)
|
|
1731
|
+
const targetSnapshot = snapshotRecord(targetRevision?.payload)
|
|
1732
|
+
const stampedMetadata = snapshotRecord(targetSnapshot.item).metadata_json
|
|
1733
|
+
const copyStampedMetadata =
|
|
1734
|
+
targetSnapshot.publication_metadata_stamp === 'native-article-v1' &&
|
|
1735
|
+
typeof stampedMetadata === 'string'
|
|
1736
|
+
const metadataStampSql = copyStampedMetadata ? 'metadata_json = ?' : PUBLISH_METADATA_STAMP_SQL
|
|
1737
|
+
const metadataStampBinds = copyStampedMetadata
|
|
1738
|
+
? [stampedMetadata]
|
|
1739
|
+
: publishMetadataStampBinds(now)
|
|
1716
1740
|
const mediaReplacement =
|
|
1717
1741
|
snapshotRecord(targetRevision?.payload).publication_guard === CURRENT_MEDIA_PUBLICATION_GUARD
|
|
1718
1742
|
if (
|
|
@@ -1770,7 +1794,7 @@ async function movePointerAndBeginCommit(
|
|
|
1770
1794
|
`UPDATE content_items
|
|
1771
1795
|
SET status = 'published', published_at = ?, publish_at = NULL,
|
|
1772
1796
|
published_revision_id = ?, updated_at = unixepoch(),
|
|
1773
|
-
${
|
|
1797
|
+
${metadataStampSql}
|
|
1774
1798
|
WHERE id = ?
|
|
1775
1799
|
AND COALESCE(published_revision_id, '') = ?
|
|
1776
1800
|
AND EXISTS (
|
|
@@ -1784,7 +1808,7 @@ async function movePointerAndBeginCommit(
|
|
|
1784
1808
|
attempt.revisionId,
|
|
1785
1809
|
// Wall clock, NOT targetPublishedAt — the latter is routinely backdated
|
|
1786
1810
|
// to the source video's date on the channel sites.
|
|
1787
|
-
...
|
|
1811
|
+
...metadataStampBinds,
|
|
1788
1812
|
attempt.contentId,
|
|
1789
1813
|
attempt.previousRevisionId ?? '',
|
|
1790
1814
|
attempt.id,
|
package/src/engine/publisher.ts
CHANGED
|
@@ -1665,13 +1665,58 @@ END`
|
|
|
1665
1665
|
* the second disjunct keeps unpublish→republish correct, since `unpublishContent`
|
|
1666
1666
|
* clears `published_at` but the article did go live once.
|
|
1667
1667
|
*/
|
|
1668
|
-
|
|
1668
|
+
const PUBLISH_METADATA_STAMP_EXPRESSION = `CASE
|
|
1669
1669
|
WHEN published_at IS NOT NULL
|
|
1670
1670
|
OR json_extract(${METADATA_OBJECT_SQL}, '$.adaptedAt') IS NOT NULL
|
|
1671
1671
|
THEN json_set(${METADATA_WITH_ADAPTED_AT_SQL}, '$.updatedAt', date(?, 'unixepoch'))
|
|
1672
1672
|
ELSE ${METADATA_WITH_ADAPTED_AT_SQL}
|
|
1673
1673
|
END`
|
|
1674
1674
|
|
|
1675
|
+
export const PUBLISH_METADATA_STAMP_SQL = `metadata_json = ${PUBLISH_METADATA_STAMP_EXPRESSION}`
|
|
1676
|
+
|
|
1677
|
+
/** Project the same publish stamp onto the immutable snapshot's captured row. */
|
|
1678
|
+
export async function publicationMetadataForSnapshot(
|
|
1679
|
+
db: D1Database,
|
|
1680
|
+
item: {
|
|
1681
|
+
id?: unknown
|
|
1682
|
+
published_revision_id?: unknown
|
|
1683
|
+
metadata_json?: unknown
|
|
1684
|
+
published_at?: unknown
|
|
1685
|
+
created_at?: unknown
|
|
1686
|
+
},
|
|
1687
|
+
now: number,
|
|
1688
|
+
): Promise<string> {
|
|
1689
|
+
// A draft metadata edit can remove adaptedAt. The previous immutable native
|
|
1690
|
+
// revision retains the actual first publication day; creation time may be
|
|
1691
|
+
// earlier and published_at may be the source video's historical date.
|
|
1692
|
+
let priorAdaptedAt: string | null = null
|
|
1693
|
+
if (typeof item.id === 'string' && typeof item.published_revision_id === 'string') {
|
|
1694
|
+
const prior = await db
|
|
1695
|
+
.prepare(`SELECT
|
|
1696
|
+
json_extract(json_extract(payload_json, '$.item.metadata_json'), '$.adaptedAt') AS adapted_at
|
|
1697
|
+
FROM content_revisions WHERE id = ? AND content_id = ?`)
|
|
1698
|
+
.bind(item.published_revision_id, item.id)
|
|
1699
|
+
.first<{ adapted_at: string | null }>()
|
|
1700
|
+
priorAdaptedAt = prior?.adapted_at ?? null
|
|
1701
|
+
}
|
|
1702
|
+
const projected = await db
|
|
1703
|
+
.prepare(`SELECT ${PUBLISH_METADATA_STAMP_EXPRESSION} AS metadata_json
|
|
1704
|
+
FROM (SELECT CASE WHEN ? IS NOT NULL THEN json_set(COALESCE(?, '{}'), '$.adaptedAt', ?)
|
|
1705
|
+
ELSE ? END AS metadata_json, ? AS published_at, ? AS created_at)`)
|
|
1706
|
+
.bind(
|
|
1707
|
+
...publishMetadataStampBinds(now),
|
|
1708
|
+
priorAdaptedAt,
|
|
1709
|
+
item.metadata_json ?? null,
|
|
1710
|
+
priorAdaptedAt,
|
|
1711
|
+
item.metadata_json ?? null,
|
|
1712
|
+
item.published_at ?? null,
|
|
1713
|
+
item.created_at ?? null,
|
|
1714
|
+
)
|
|
1715
|
+
.first<{ metadata_json: string }>()
|
|
1716
|
+
if (!projected) throw new Error('publication metadata projection failed')
|
|
1717
|
+
return projected.metadata_json
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1675
1720
|
/** Placeholder count in {@link PUBLISH_METADATA_STAMP_SQL}, derived so it cannot drift. */
|
|
1676
1721
|
const PUBLISH_METADATA_STAMP_PLACEHOLDERS = (PUBLISH_METADATA_STAMP_SQL.match(/\?/g) ?? []).length
|
|
1677
1722
|
|
package/src/foundry-emit.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { sha256Hex } from '@growth-labs/foundry-signing'
|
|
11
11
|
import { z } from 'zod'
|
|
12
|
+
import { ContentMetadataSchema } from './engine/content-metadata.js'
|
|
12
13
|
import type { ContentArchiveItem } from './engine/publication.js'
|
|
13
14
|
import { slugify } from './engine/slug.js'
|
|
14
15
|
|
|
@@ -58,6 +59,7 @@ export const FoundryArticleV2Schema = z
|
|
|
58
59
|
tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
|
59
60
|
description: z.string().trim().min(1).optional(),
|
|
60
61
|
visibility: z.enum(['free', 'premium']).optional(),
|
|
62
|
+
metadata: ContentMetadataSchema.optional(),
|
|
61
63
|
adoptSourceId: z
|
|
62
64
|
.string()
|
|
63
65
|
.min(1)
|
|
@@ -147,6 +149,7 @@ export function toFoundryArticleV2(
|
|
|
147
149
|
...(item.tags && item.tags.length > 0 ? { tags: [...item.tags] } : {}),
|
|
148
150
|
...(description ? { description } : {}),
|
|
149
151
|
...(item.visibility ? { visibility: item.visibility } : {}),
|
|
152
|
+
...(item.metadata != null ? { metadata: item.metadata } : {}),
|
|
150
153
|
...(present(item.sourceId) ? { adoptSourceId: item.sourceId } : {}),
|
|
151
154
|
}
|
|
152
155
|
return FoundryArticleV2Schema.parse(body)
|
package/src/routes/content.ts
CHANGED
|
@@ -18,11 +18,7 @@ import { z } from 'zod'
|
|
|
18
18
|
import { logActivity } from '../engine/activity-log.js'
|
|
19
19
|
import { generateContentDek } from '../engine/ai-dek.js'
|
|
20
20
|
import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
|
|
21
|
-
import {
|
|
22
|
-
type ContentMetadata,
|
|
23
|
-
ContentMetadataError,
|
|
24
|
-
serializeContentMetadata,
|
|
25
|
-
} from '../engine/content-metadata.js'
|
|
21
|
+
import { ContentMetadataSchema } from '../engine/content-metadata.js'
|
|
26
22
|
import { listContributors, setContributors } from '../engine/contributors.js'
|
|
27
23
|
import type { D1Database } from '../engine/d1.js'
|
|
28
24
|
import {
|
|
@@ -410,21 +406,6 @@ const BodyContentCreateSchema = z.object({
|
|
|
410
406
|
editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
|
|
411
407
|
})
|
|
412
408
|
|
|
413
|
-
const ContentMetadataSchema = z
|
|
414
|
-
.record(z.string(), z.unknown())
|
|
415
|
-
.superRefine((metadata, ctx) => {
|
|
416
|
-
try {
|
|
417
|
-
serializeContentMetadata(metadata as ContentMetadata)
|
|
418
|
-
} catch (error) {
|
|
419
|
-
ctx.addIssue({
|
|
420
|
-
code: 'custom',
|
|
421
|
-
message:
|
|
422
|
-
error instanceof ContentMetadataError ? error.message : 'content metadata is invalid',
|
|
423
|
-
})
|
|
424
|
-
}
|
|
425
|
-
})
|
|
426
|
-
.transform((metadata) => metadata as ContentMetadata)
|
|
427
|
-
|
|
428
409
|
// A Nextcloud share PAGE is HTML, never ingestable media, so the stored source
|
|
429
410
|
// must be the direct-download URL. Editors paste the share link -- that is what
|
|
430
411
|
// Nextcloud's "Copy link" button yields -- so normalise it here instead of
|
|
@@ -671,6 +671,7 @@ async function putV2(
|
|
|
671
671
|
...(body.desk !== undefined ? { primaryCategory: body.desk } : {}),
|
|
672
672
|
...(body.tags !== undefined ? { tags: body.tags } : {}),
|
|
673
673
|
...(body.visibility !== undefined ? { visibility: body.visibility } : {}),
|
|
674
|
+
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
674
675
|
content: { bodyMarkdown: body.body_html, bodyHtml: body.body_html },
|
|
675
676
|
}
|
|
676
677
|
|
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
// POST /admin/api/content/insights-ingest — machine ingest (replay-safe replace).
|
|
3
3
|
// HMAC-SHA256 (X-Foundry-Signature vs env.INSIGHTS_INGEST_HMAC_SECRET) only —
|
|
4
4
|
// NOT behind requirePublisher. Sibling of foundry-callback under /content/.
|
|
5
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
6
|
+
import { config } from 'virtual:growth-labs/cms/config'
|
|
5
7
|
import type { APIContext } from 'astro'
|
|
6
8
|
import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
|
|
7
9
|
import type { RouteContext } from '../../../routes/context.js'
|
|
8
10
|
import { buildSiteAuthz } from '../_authz.js'
|
|
11
|
+
import { toContentConfig } from '../_content-config.js'
|
|
9
12
|
|
|
10
13
|
function toCtx(c: APIContext): RouteContext {
|
|
11
|
-
const env =
|
|
14
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
12
15
|
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
function siteId(c: APIContext): string {
|
|
16
|
-
const env =
|
|
17
|
-
return (
|
|
19
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
20
|
+
return (
|
|
21
|
+
toContentConfig(c).activeWorkspaceId ??
|
|
22
|
+
(env.ACTIVE_WORKSPACE_ID as string | undefined) ??
|
|
23
|
+
(config.workspaces?.length === 1 ? config.workspaces[0]!.id : 'default')
|
|
24
|
+
)
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export const POST = (c: APIContext) =>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
|
|
6
6
|
// requirePublisher. Sibling of insights-ingest under /content/.
|
|
7
7
|
|
|
8
|
+
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
8
9
|
import { config } from 'virtual:growth-labs/cms/config'
|
|
9
10
|
import type { APIContext } from 'astro'
|
|
10
11
|
import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
|
|
@@ -13,12 +14,12 @@ import { buildSiteAuthz } from '../_authz.js'
|
|
|
13
14
|
import { toContentConfig } from '../_content-config.js'
|
|
14
15
|
|
|
15
16
|
function toCtx(c: APIContext): RouteContext {
|
|
16
|
-
const env =
|
|
17
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
17
18
|
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function siteId(c: APIContext): string {
|
|
21
|
-
const env =
|
|
22
|
+
const env = cloudflareEnv as Record<string, unknown>
|
|
22
23
|
return (
|
|
23
24
|
toContentConfig(c).activeWorkspaceId ??
|
|
24
25
|
(env.ACTIVE_WORKSPACE_ID as string | undefined) ??
|