@growth-labs/cms 0.8.38 → 0.8.40
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/fronts-publish.d.ts +29 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +58 -0
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +19 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +75 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +15 -2
- package/dist/engine/publisher.js.map +1 -1
- package/dist/foundry-emit.d.ts +59 -6
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +16 -1
- package/dist/foundry-emit.js.map +1 -1
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +6 -0
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/content.d.ts +3 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +1 -0
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +101 -12
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +47 -1
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/media-lib.d.ts +2 -0
- package/dist/routes/media-lib.d.ts.map +1 -1
- package/dist/routes/media-lib.js +6 -0
- package/dist/routes/media-lib.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +88 -88
- package/dist/ui/api/fronts/publish-callback.d.ts +1 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
- package/dist/ui/api/fronts/publish-callback.js +1 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -1
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/extensions.d.ts.map +1 -1
- package/dist/ui/editor/extensions.js +15 -1
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/heading-level.d.ts +4 -0
- package/dist/ui/editor/heading-level.d.ts.map +1 -0
- package/dist/ui/editor/heading-level.js +13 -0
- package/dist/ui/editor/heading-level.js.map +1 -0
- package/dist/ui/editor/portable-text.d.ts.map +1 -1
- package/dist/ui/editor/portable-text.js +3 -2
- package/dist/ui/editor/portable-text.js.map +1 -1
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +4 -3
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/preview/SitePreviewFrame.d.ts +11 -0
- package/dist/ui/preview/SitePreviewFrame.d.ts.map +1 -0
- package/dist/ui/preview/SitePreviewFrame.js +86 -0
- package/dist/ui/preview/SitePreviewFrame.js.map +1 -0
- package/dist/ui/preview/preview-layout.d.ts +6 -0
- package/dist/ui/preview/preview-layout.d.ts.map +1 -1
- package/dist/ui/preview/preview-layout.js +15 -0
- package/dist/ui/preview/preview-layout.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +61 -25
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/public-url.d.ts +12 -0
- package/dist/ui/screens/public-url.d.ts.map +1 -1
- package/dist/ui/screens/public-url.js +30 -0
- package/dist/ui/screens/public-url.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/fronts-publish.ts +88 -0
- package/src/engine/publication-current-guard.ts +103 -0
- package/src/engine/publisher.ts +22 -3
- package/src/foundry-emit.ts +17 -0
- package/src/routes/content-insights.ts +6 -0
- package/src/routes/content.ts +4 -0
- package/src/routes/foundry-publish.ts +134 -12
- package/src/routes/fronts-publish.ts +57 -0
- package/src/routes/media-lib.ts +6 -0
- package/src/ui/api/fronts/publish-callback.ts +3 -0
- package/src/ui/editor/extensions.ts +16 -1
- package/src/ui/editor/heading-level.ts +14 -0
- package/src/ui/editor/portable-text.ts +3 -2
- package/src/ui/editor/serialize.ts +4 -3
- package/src/ui/preview/SitePreviewFrame.tsx +111 -0
- package/src/ui/preview/preview-layout.ts +17 -0
- package/src/ui/screens/EditorScreen.tsx +76 -27
- package/src/ui/screens/public-url.ts +36 -0
package/src/routes/content.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
setFrontsChannelTargets,
|
|
36
36
|
} from '../engine/fronts-publish-intent.js'
|
|
37
37
|
import { classifyGoogleDriveUrl } from '../engine/google-drive-source.js'
|
|
38
|
+
import type { NativeArticleDraftSnapshot } from '../engine/publication-current-guard.js'
|
|
38
39
|
import { evaluateArticleBody } from '../engine/publish-guard.js'
|
|
39
40
|
import {
|
|
40
41
|
createContent,
|
|
@@ -237,6 +238,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
237
238
|
updateStatusConstraint?: ContentStatus
|
|
238
239
|
/** Internal machine-route pin; joins the existing status-constrained atomic batch. */
|
|
239
240
|
updateCanonicalVersionConstraint?: number
|
|
241
|
+
/** Exact native draft input; joins the status/version constrained batch. */
|
|
242
|
+
updateNativeDraftSnapshotConstraint?: NativeArticleDraftSnapshot
|
|
240
243
|
/**
|
|
241
244
|
* Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
|
|
242
245
|
* commit step (publishContent + createRevision) with the caller's — publishOne
|
|
@@ -1810,6 +1813,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1810
1813
|
updated = await updateContentItem(ctx.db, id, parsed.data, {
|
|
1811
1814
|
requiredStatus: config.updateStatusConstraint,
|
|
1812
1815
|
expectedCanonicalVersion: config.updateCanonicalVersionConstraint,
|
|
1816
|
+
expectedNativeDraftSnapshot: config.updateNativeDraftSnapshotConstraint,
|
|
1813
1817
|
...(expectedMediaSource ? { expectedMediaSource } : {}),
|
|
1814
1818
|
})
|
|
1815
1819
|
} catch (error) {
|
|
@@ -2,15 +2,22 @@ import { isCanonicalHost, sha256Hex, verifyFoundryRequest } from '@growth-labs/f
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { ContentMetadataSchema, mergePinnedArticleMetadata } from '../engine/content-metadata.js'
|
|
4
4
|
import { type PublicationReconciler, publishOne } from '../engine/publication.js'
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
type NativeArticleDraftSnapshot,
|
|
7
|
+
PublicationCurrentSnapshotError,
|
|
8
|
+
} from '../engine/publication-current-guard.js'
|
|
9
|
+
import {
|
|
10
|
+
canonicalJsonSha256,
|
|
11
|
+
type JsonValue,
|
|
12
|
+
} from '../engine/publication-receipt/canonical-json.js'
|
|
13
|
+
import { getContentSnapshot, PublishContentValidationError } from '../engine/publisher.js'
|
|
7
14
|
import { createRevisionWithDelta, getRevision } from '../engine/revisions.js'
|
|
8
15
|
import { buildContentPath, slugify } from '../engine/slug.js'
|
|
9
16
|
import { type FoundryArticleV2, FoundryArticleV2Schema } from '../foundry-emit.js'
|
|
10
17
|
import { type ContentRouteConfig, createContentRoutes } from './content.js'
|
|
11
18
|
import { json, type RouteContext } from './context.js'
|
|
12
19
|
import { markFoundryMachineContext } from './machine-context.js'
|
|
13
|
-
import { insertMediaAsset, readReadyImagePublicUrl } from './media-lib.js'
|
|
20
|
+
import { insertMediaAsset, readReadyImagePublicUrl, readyImagePublicUrl } from './media-lib.js'
|
|
14
21
|
|
|
15
22
|
const SOURCE_PREFIX = 'foundry:'
|
|
16
23
|
|
|
@@ -627,6 +634,16 @@ async function workingArticleSource(ctx: RouteContext, row: FoundryArticleRow):
|
|
|
627
634
|
const snapshot = await lastFoundryWriteSnapshot(ctx, row)
|
|
628
635
|
if (!snapshot)
|
|
629
636
|
return json({ error: 'foundry_working_revision_changed', external_id: row.external_id }, 409)
|
|
637
|
+
const projection = await projectArticleSource(ctx, row, snapshot)
|
|
638
|
+
if (projection instanceof Response) return projection
|
|
639
|
+
return json({ ...v2Receipt(row), working: projection })
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function projectArticleSource(
|
|
643
|
+
ctx: RouteContext,
|
|
644
|
+
row: FoundryArticleRow,
|
|
645
|
+
snapshot: Record<string, unknown>,
|
|
646
|
+
): Promise<z.infer<typeof WorkingArticleSourceSchema> | Response> {
|
|
630
647
|
const item = snapshot.item as Record<string, unknown>
|
|
631
648
|
const content = snapshot.content as Record<string, unknown>
|
|
632
649
|
let metadata: unknown
|
|
@@ -662,11 +679,81 @@ async function workingArticleSource(ctx: RouteContext, row: FoundryArticleRow):
|
|
|
662
679
|
slug: item.slug,
|
|
663
680
|
body_html: content?.body_html,
|
|
664
681
|
hero_image_id: item.hero_image_id ?? null,
|
|
665
|
-
hero_image_url:
|
|
682
|
+
hero_image_url: Object.hasOwn(snapshot, 'hero_asset')
|
|
683
|
+
? readyImagePublicUrl(snapshot.hero_asset as Record<string, unknown> | null)
|
|
684
|
+
: await readReadyImagePublicUrl(ctx.db, item.hero_image_id),
|
|
666
685
|
metadata: Object.fromEntries(Object.entries(recorded.data).filter(([key]) => allowed.has(key))),
|
|
667
686
|
})
|
|
668
687
|
if (!result.success) return json({ error: 'foundry_working_source_invalid' }, 409)
|
|
669
|
-
return
|
|
688
|
+
return result.data
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Current input for governed preparation, never evidence of an earlier PUT. */
|
|
692
|
+
async function draftPreparationSnapshot(
|
|
693
|
+
ctx: RouteContext,
|
|
694
|
+
row: FoundryArticleRow,
|
|
695
|
+
): Promise<Record<string, unknown> | null> {
|
|
696
|
+
if (row.status !== 'draft') return null
|
|
697
|
+
const snapshot = await getContentSnapshot(ctx.db, row.content_id)
|
|
698
|
+
const item = snapshot?.item as Record<string, unknown> | undefined
|
|
699
|
+
if (
|
|
700
|
+
!(
|
|
701
|
+
snapshot &&
|
|
702
|
+
item?.id === row.content_id &&
|
|
703
|
+
item.type === 'article' &&
|
|
704
|
+
item.status === 'draft' &&
|
|
705
|
+
item.deleted_at == null &&
|
|
706
|
+
item.source_id === `${SOURCE_PREFIX}${row.external_id}` &&
|
|
707
|
+
typeof item.canonical_version === 'number' &&
|
|
708
|
+
Number.isSafeInteger(item.canonical_version) &&
|
|
709
|
+
item.canonical_version > 0
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
return null
|
|
713
|
+
const hero =
|
|
714
|
+
typeof item.hero_image_id === 'string'
|
|
715
|
+
? await ctx.db
|
|
716
|
+
.prepare('SELECT * FROM media_assets WHERE id=? LIMIT 1')
|
|
717
|
+
.bind(item.hero_image_id)
|
|
718
|
+
.first<Record<string, unknown>>()
|
|
719
|
+
: null
|
|
720
|
+
return { ...snapshot, hero_asset: hero }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function draftPreparationSource(
|
|
724
|
+
ctx: RouteContext,
|
|
725
|
+
row: FoundryArticleRow,
|
|
726
|
+
): Promise<Response> {
|
|
727
|
+
const snapshot = await draftPreparationSnapshot(ctx, row)
|
|
728
|
+
if (!snapshot) return json({ error: 'draft_preparation_source_unavailable' }, 409)
|
|
729
|
+
const projection = await projectArticleSource(ctx, row, snapshot)
|
|
730
|
+
if (projection instanceof Response) return projection
|
|
731
|
+
return json({
|
|
732
|
+
...v2Receipt(row),
|
|
733
|
+
draft_preparation: {
|
|
734
|
+
...projection,
|
|
735
|
+
schema: 'cms.foundry-draft-preparation.v1',
|
|
736
|
+
source_id: `${SOURCE_PREFIX}${row.external_id}`,
|
|
737
|
+
snapshot_sha256: await canonicalJsonSha256(
|
|
738
|
+
nativeDraftInput(snapshot, row) as unknown as JsonValue,
|
|
739
|
+
),
|
|
740
|
+
},
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function nativeDraftInput(
|
|
745
|
+
snapshot: Record<string, unknown>,
|
|
746
|
+
row: FoundryArticleRow,
|
|
747
|
+
): NativeArticleDraftSnapshot {
|
|
748
|
+
return {
|
|
749
|
+
snapshot,
|
|
750
|
+
binding: {
|
|
751
|
+
externalId: row.external_id,
|
|
752
|
+
contentId: row.content_id,
|
|
753
|
+
receiptId: row.receipt_id,
|
|
754
|
+
requestSHA256: row.request_sha256,
|
|
755
|
+
},
|
|
756
|
+
}
|
|
670
757
|
}
|
|
671
758
|
|
|
672
759
|
/** The v2 receipt: the four v1 fields plus the stored request hash. */
|
|
@@ -694,6 +781,34 @@ async function putV2(
|
|
|
694
781
|
|
|
695
782
|
const prior = await findArticle(ctx, id)
|
|
696
783
|
let expectedWorkingVersion: number | undefined
|
|
784
|
+
let preparedDraftSnapshot: Record<string, unknown> | null = null
|
|
785
|
+
if (body.expectedDraftSnapshot) {
|
|
786
|
+
const pin = body.expectedDraftSnapshot
|
|
787
|
+
// A replay is read-only and still requires this exact PUT's snapshot.
|
|
788
|
+
if (prior?.request_sha256 === bodyHash) {
|
|
789
|
+
if (!(await lastFoundryWriteSnapshot(ctx, prior)))
|
|
790
|
+
return json({ error: 'foundry_working_revision_changed', external_id: id }, 409)
|
|
791
|
+
return json(v2Receipt(prior))
|
|
792
|
+
}
|
|
793
|
+
if (
|
|
794
|
+
!prior ||
|
|
795
|
+
prior.content_id !== pin.contentId ||
|
|
796
|
+
prior.request_sha256 !== pin.priorRequestSHA256
|
|
797
|
+
)
|
|
798
|
+
return json({ error: 'draft_preparation_source_changed', external_id: id }, 409)
|
|
799
|
+
preparedDraftSnapshot = await draftPreparationSnapshot(ctx, prior)
|
|
800
|
+
const item = preparedDraftSnapshot?.item as Record<string, unknown> | undefined
|
|
801
|
+
if (
|
|
802
|
+
!preparedDraftSnapshot ||
|
|
803
|
+
item?.canonical_version !== pin.canonicalVersion ||
|
|
804
|
+
item?.slug !== body.slug ||
|
|
805
|
+
(await canonicalJsonSha256(
|
|
806
|
+
nativeDraftInput(preparedDraftSnapshot, prior) as unknown as JsonValue,
|
|
807
|
+
)) !== pin.sha256
|
|
808
|
+
)
|
|
809
|
+
return json({ error: 'draft_preparation_source_changed', external_id: id }, 409)
|
|
810
|
+
expectedWorkingVersion = pin.canonicalVersion
|
|
811
|
+
}
|
|
697
812
|
if (body.expectedRequestSHA256) {
|
|
698
813
|
if (
|
|
699
814
|
!prior ||
|
|
@@ -828,15 +943,17 @@ async function putV2(
|
|
|
828
943
|
// image provenance and other recorded fields survive preparation updates.
|
|
829
944
|
let metadata = body.metadata
|
|
830
945
|
if (
|
|
831
|
-
(body.expectedRevisionId || body.expectedRequestSHA256) &&
|
|
946
|
+
(body.expectedRevisionId || body.expectedRequestSHA256 || body.expectedDraftSnapshot) &&
|
|
832
947
|
body.metadata !== undefined &&
|
|
833
948
|
bound
|
|
834
949
|
) {
|
|
835
|
-
const source =
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
? await
|
|
839
|
-
:
|
|
950
|
+
const source =
|
|
951
|
+
preparedDraftSnapshot ??
|
|
952
|
+
(body.expectedRevisionId
|
|
953
|
+
? (await getRevision(ctx.db, bound.id, body.expectedRevisionId))?.payload
|
|
954
|
+
: prior
|
|
955
|
+
? await lastFoundryWriteSnapshot(ctx, prior)
|
|
956
|
+
: null)
|
|
840
957
|
let original: unknown
|
|
841
958
|
try {
|
|
842
959
|
original = JSON.parse(
|
|
@@ -878,6 +995,9 @@ async function putV2(
|
|
|
878
995
|
...machineConfig(config),
|
|
879
996
|
updateStatusConstraint: bound.status,
|
|
880
997
|
updateCanonicalVersionConstraint: priorVersion,
|
|
998
|
+
...(preparedDraftSnapshot && prior
|
|
999
|
+
? { updateNativeDraftSnapshotConstraint: nativeDraftInput(preparedDraftSnapshot, prior) }
|
|
1000
|
+
: {}),
|
|
881
1001
|
})
|
|
882
1002
|
nativeResponse = await mapUpdateConflict(
|
|
883
1003
|
await writer.update(nativeContext(ctx, 'PATCH', nativeBody, { id: bound.id })),
|
|
@@ -1229,9 +1349,11 @@ export function createFoundryPublishRoutes(
|
|
|
1229
1349
|
if (!row) return json({ error: 'not_found' }, 404)
|
|
1230
1350
|
const working = new URL(ctx.request.url).searchParams.get('working')
|
|
1231
1351
|
if (working !== null) {
|
|
1232
|
-
if (working !== '1'
|
|
1352
|
+
if (working !== '1' && working !== 'prepare')
|
|
1353
|
+
return json({ error: 'invalid_working_source_request' }, 400)
|
|
1233
1354
|
if (!autoPublishEnabled(config))
|
|
1234
1355
|
return json({ error: 'native_working_source_unavailable' }, 409)
|
|
1356
|
+
if (working === 'prepare') return draftPreparationSource(ctx, row)
|
|
1235
1357
|
return workingArticleSource(ctx, row)
|
|
1236
1358
|
}
|
|
1237
1359
|
console.info('[cms] foundry article read', { externalId: id, receiptId: row.receipt_id })
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
applyFrontsPublishCallback,
|
|
31
31
|
isFrontsPublishEnabled,
|
|
32
32
|
listFrontsPublishSchedule,
|
|
33
|
+
readFrontsPublishObservation,
|
|
33
34
|
} from '../engine/fronts-publish.js'
|
|
34
35
|
import {
|
|
35
36
|
backfillFrontsChannelTargets,
|
|
@@ -225,6 +226,8 @@ async function runScheduleMaintenance(
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
export interface FrontsPublishRouteHandlers {
|
|
229
|
+
/** GET /fronts/publish-callback — exact authenticated receiver/commit observation. */
|
|
230
|
+
readPublishCallback(ctx: RouteContext): Promise<Response>
|
|
228
231
|
/** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
|
|
229
232
|
schedule(ctx: RouteContext): Promise<Response>
|
|
230
233
|
/** POST /fronts/schedule — bounded target arming or host-owned maintenance. */
|
|
@@ -260,6 +263,60 @@ function parseWindow(
|
|
|
260
263
|
export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
|
|
261
264
|
const resolved = resolveConfig(config)
|
|
262
265
|
return {
|
|
266
|
+
async readPublishCallback(ctx) {
|
|
267
|
+
const secret = await resolveFoundryHmacSecret(ctx.env)
|
|
268
|
+
if (!secret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
|
|
269
|
+
const url = new URL(ctx.request.url)
|
|
270
|
+
const payload = `foundry.fronts-publish-observation.v1\nGET\n${url.pathname}\n${url.search}`
|
|
271
|
+
const denied = await verifyFoundrySignedRequest(
|
|
272
|
+
ctx.request,
|
|
273
|
+
secret,
|
|
274
|
+
payload,
|
|
275
|
+
'fronts/publish-callback.read',
|
|
276
|
+
)
|
|
277
|
+
if (denied) return denied
|
|
278
|
+
if (ctx.request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405)
|
|
279
|
+
const keys = [
|
|
280
|
+
'event_id',
|
|
281
|
+
'intent_id',
|
|
282
|
+
'content_id',
|
|
283
|
+
'version',
|
|
284
|
+
'publish_at',
|
|
285
|
+
'status',
|
|
286
|
+
'receipt_sha256',
|
|
287
|
+
]
|
|
288
|
+
if (
|
|
289
|
+
[...url.searchParams.keys()].some((key) => !keys.includes(key)) ||
|
|
290
|
+
keys.some((key) => url.searchParams.getAll(key).length !== 1)
|
|
291
|
+
)
|
|
292
|
+
return json({ error: 'invalid_observation_binding' }, 400)
|
|
293
|
+
const values = Object.fromEntries(url.searchParams)
|
|
294
|
+
if (
|
|
295
|
+
['event_id', 'intent_id', 'content_id'].some(
|
|
296
|
+
(key) => !values[key] || values[key]!.length > 200,
|
|
297
|
+
) ||
|
|
298
|
+
!/^\d+$/.test(values.version!) ||
|
|
299
|
+
!Number.isSafeInteger(Number(values.version)) ||
|
|
300
|
+
Number(values.version) < 1 ||
|
|
301
|
+
!/^\d+$/.test(values.publish_at!) ||
|
|
302
|
+
!Number.isSafeInteger(Number(values.publish_at)) ||
|
|
303
|
+
!['publishing', 'live'].includes(values.status!) ||
|
|
304
|
+
(values.status === 'live'
|
|
305
|
+
? !/^[0-9a-f]{64}$/.test(values.receipt_sha256!)
|
|
306
|
+
: values.receipt_sha256 !== '')
|
|
307
|
+
)
|
|
308
|
+
return json({ error: 'invalid_observation_binding' }, 400)
|
|
309
|
+
const result = await readFrontsPublishObservation(ctx.db, {
|
|
310
|
+
eventId: values.event_id!,
|
|
311
|
+
intentId: values.intent_id!,
|
|
312
|
+
contentId: values.content_id!,
|
|
313
|
+
version: Number(values.version),
|
|
314
|
+
publishAt: Number(values.publish_at),
|
|
315
|
+
status: values.status as 'publishing' | 'live',
|
|
316
|
+
receiptSHA256: values.receipt_sha256!,
|
|
317
|
+
})
|
|
318
|
+
return json(result, result.state === 'conflict' ? 409 : 200)
|
|
319
|
+
},
|
|
263
320
|
async maintainSchedule(ctx) {
|
|
264
321
|
let responseOperation: 'arm-channel-targets' | FrontsScheduleMaintenanceOperation =
|
|
265
322
|
'arm-channel-targets'
|
package/src/routes/media-lib.ts
CHANGED
|
@@ -28,6 +28,12 @@ export async function readReadyImagePublicUrl(db: D1Database, id: unknown): Prom
|
|
|
28
28
|
)
|
|
29
29
|
.bind(id)
|
|
30
30
|
.first<{ public_url: string | null }>()
|
|
31
|
+
return readyImagePublicUrl(asset ? { ...asset, asset_type: 'image', status: 'ready' } : null)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The same projection from an already captured asset row. */
|
|
35
|
+
export function readyImagePublicUrl(asset: Record<string, unknown> | null): string | null {
|
|
36
|
+
if (!asset || asset.asset_type !== 'image' || asset.status !== 'ready') return null
|
|
31
37
|
const url = z.string().url().safeParse(asset?.public_url)
|
|
32
38
|
if (!url.success) return null
|
|
33
39
|
const parsed = new URL(url.data)
|
|
@@ -10,3 +10,6 @@ import { toContentConfig, toContentCtx } from '../_content-config.js'
|
|
|
10
10
|
|
|
11
11
|
export const POST = (c: APIContext) =>
|
|
12
12
|
createFrontsPublishRoutes(toContentConfig(c)).publishCallback(toContentCtx(c, {}))
|
|
13
|
+
|
|
14
|
+
export const GET = (c: APIContext) =>
|
|
15
|
+
createFrontsPublishRoutes(toContentConfig(c)).readPublishCallback(toContentCtx(c, {}))
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { DragHandle } from '@tiptap/extension-drag-handle'
|
|
2
|
+
import Heading from '@tiptap/extension-heading'
|
|
2
3
|
import Image from '@tiptap/extension-image'
|
|
3
4
|
import Link from '@tiptap/extension-link'
|
|
4
5
|
import { TableKit } from '@tiptap/extension-table'
|
|
5
6
|
import StarterKit from '@tiptap/starter-kit'
|
|
6
7
|
import { Callout, Citation, GeneratedFaq, PullQuote } from './blocks.js'
|
|
8
|
+
import { BODY_HEADING_LEVELS, bodyHeadingLevel } from './heading-level.js'
|
|
7
9
|
import { SlashMenu } from './slash-menu.js'
|
|
8
10
|
import { TrimBoundaryMarks } from './trim-boundary-marks.js'
|
|
9
11
|
import { TweetEmbed } from './tweet-embed.js'
|
|
10
12
|
|
|
13
|
+
/** StarterKit's heading restricted to the body levels. Every pasted h1–h6
|
|
14
|
+
* parses onto the nearest body level instead of dropping to a paragraph, so a
|
|
15
|
+
* document's Heading 1 chapter titles arrive as H2. */
|
|
16
|
+
const BodyHeading = Heading.extend({
|
|
17
|
+
parseHTML() {
|
|
18
|
+
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
|
19
|
+
tag: `h${level}`,
|
|
20
|
+
attrs: { level: bodyHeadingLevel(level) },
|
|
21
|
+
}))
|
|
22
|
+
},
|
|
23
|
+
}).configure({ levels: [...BODY_HEADING_LEVELS] })
|
|
24
|
+
|
|
11
25
|
function renderDragHandle(): HTMLElement {
|
|
12
26
|
const handle = document.createElement('div')
|
|
13
27
|
handle.className = 'drag-handle'
|
|
@@ -25,7 +39,8 @@ function renderDragHandle(): HTMLElement {
|
|
|
25
39
|
* silently drops underline styling on paste — Word documents come in clean. */
|
|
26
40
|
export function createRteExtensions() {
|
|
27
41
|
return [
|
|
28
|
-
StarterKit.configure({ link: false, underline: false }),
|
|
42
|
+
StarterKit.configure({ link: false, underline: false, heading: false }),
|
|
43
|
+
BodyHeading,
|
|
29
44
|
Link.configure({ openOnClick: false }),
|
|
30
45
|
TrimBoundaryMarks,
|
|
31
46
|
Image,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/ui/editor/heading-level.ts — PURE (no Tiptap import; serializers use it).
|
|
2
|
+
// Body heading levels. The page title is the article's only H1, so a body
|
|
3
|
+
// Heading 1 (typed as `# ` or pasted from a document) is a chapter title and
|
|
4
|
+
// becomes an H2; h5/h6 fold into H4, the deepest level the public prose styles.
|
|
5
|
+
|
|
6
|
+
export const BODY_HEADING_LEVELS = [2, 3, 4] as const
|
|
7
|
+
|
|
8
|
+
export type BodyHeadingLevel = (typeof BODY_HEADING_LEVELS)[number]
|
|
9
|
+
|
|
10
|
+
export function bodyHeadingLevel(level: number): BodyHeadingLevel {
|
|
11
|
+
if (!Number.isFinite(level) || level <= 2) return 2
|
|
12
|
+
if (level >= 4) return 4
|
|
13
|
+
return 3
|
|
14
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type { JSONContent } from '@tiptap/core'
|
|
21
21
|
import type { PortableTextObject } from '../../schema/portable-text.js'
|
|
22
22
|
import { normalizeBoundaryMarks } from './boundary-marks.js'
|
|
23
|
+
import { bodyHeadingLevel } from './heading-level.js'
|
|
23
24
|
|
|
24
25
|
type KeyGenerator = () => string
|
|
25
26
|
|
|
@@ -78,7 +79,7 @@ function serializeBlockNode(
|
|
|
78
79
|
out.push(buildTextBlock('normal', node.content ?? [], nextKey, list))
|
|
79
80
|
return
|
|
80
81
|
case 'heading': {
|
|
81
|
-
const level = (node.attrs?.level as number) ?? 2
|
|
82
|
+
const level = bodyHeadingLevel((node.attrs?.level as number) ?? 2)
|
|
82
83
|
out.push(buildTextBlock(`h${level}`, node.content ?? [], nextKey, list))
|
|
83
84
|
return
|
|
84
85
|
}
|
|
@@ -407,7 +408,7 @@ function parseObject(block: PortableTextObject): JSONContent {
|
|
|
407
408
|
if (heading) {
|
|
408
409
|
return {
|
|
409
410
|
type: 'heading',
|
|
410
|
-
attrs: { level: Number(heading[1]) },
|
|
411
|
+
attrs: { level: bodyHeadingLevel(Number(heading[1])) },
|
|
411
412
|
content: parseInline(block),
|
|
412
413
|
}
|
|
413
414
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// no EditorView, no DOM, fully testable in vitest (Node env).
|
|
5
5
|
//
|
|
6
6
|
// Supported nodes (StarterKit set + TweetEmbed + tables):
|
|
7
|
-
// block: doc, paragraph, heading (h1
|
|
7
|
+
// block: doc, paragraph, heading (h2–h4; h1 → h2, h5/h6 → h4), bulletList, orderedList,
|
|
8
8
|
// listItem, blockquote, codeBlock, image, tweetEmbed, hardBreak,
|
|
9
9
|
// table (tableRow / tableHeader / tableCell)
|
|
10
10
|
// inline marks: bold, italic, strike, code, link
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import type { JSONContent } from '@tiptap/core'
|
|
32
32
|
import { normalizeBoundaryMarks } from './boundary-marks.js'
|
|
33
|
+
import { bodyHeadingLevel } from './heading-level.js'
|
|
33
34
|
|
|
34
35
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
36
|
// docToMarkdown — JSONContent → string
|
|
@@ -52,7 +53,7 @@ function serializeBlock(node: JSONContent): string | null {
|
|
|
52
53
|
return text
|
|
53
54
|
}
|
|
54
55
|
case 'heading': {
|
|
55
|
-
const level = (node.attrs?.level as number) ?? 2
|
|
56
|
+
const level = bodyHeadingLevel((node.attrs?.level as number) ?? 2)
|
|
56
57
|
const prefix = '#'.repeat(level)
|
|
57
58
|
const text = serializeInlineContent(node.content ?? [])
|
|
58
59
|
return `${prefix} ${text}`
|
|
@@ -389,7 +390,7 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
389
390
|
// Heading
|
|
390
391
|
const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
391
392
|
if (headingMatch) {
|
|
392
|
-
const level = headingMatch[1].length
|
|
393
|
+
const level = bodyHeadingLevel(headingMatch[1].length)
|
|
393
394
|
const text = headingMatch[2].trim()
|
|
394
395
|
nodes.push({ type: 'heading', attrs: { level }, content: parseInline(text) })
|
|
395
396
|
i++
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/ui/preview/SitePreviewFrame.tsx — the real site page for a draft.
|
|
2
|
+
//
|
|
3
|
+
// Embeds the publication's own draft render (`previewUrlFor`, a same-origin
|
|
4
|
+
// route gated on publisher access) so the editor sees the live template and
|
|
5
|
+
// CSS, not a copy of them. The frame renders at the device's true viewport
|
|
6
|
+
// width (SITE_FRAME_WIDTHS) and is scaled to fit the pane, so the site's own
|
|
7
|
+
// breakpoints pick the layout. Each save bumps `version`, which reloads the
|
|
8
|
+
// frame in place (a reload keeps the reader's scroll position).
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import { type PreviewDevice, SITE_FRAME_WIDTHS, siteFrameScale } from './preview-layout.js'
|
|
12
|
+
|
|
13
|
+
export interface SitePreviewFrameProps {
|
|
14
|
+
/** Draft preview URL from `previewUrlFor`. */
|
|
15
|
+
url: string
|
|
16
|
+
device: PreviewDevice
|
|
17
|
+
/** Changes after each save; a new value reloads the frame. */
|
|
18
|
+
version: number | null
|
|
19
|
+
title: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SitePreviewFrame({ url, device, version, title }: SitePreviewFrameProps) {
|
|
23
|
+
const paneRef = useRef<HTMLDivElement | null>(null)
|
|
24
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
|
25
|
+
const [pane, setPane] = useState({ width: 0, height: 0 })
|
|
26
|
+
const [loading, setLoading] = useState(true)
|
|
27
|
+
const shown = useRef({ url, version })
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const node = paneRef.current
|
|
31
|
+
if (!node || typeof ResizeObserver === 'undefined') return
|
|
32
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
33
|
+
setPane({ width: entry.contentRect.width, height: entry.contentRect.height })
|
|
34
|
+
})
|
|
35
|
+
observer.observe(node)
|
|
36
|
+
return () => observer.disconnect()
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const previous = shown.current
|
|
41
|
+
shown.current = { url, version }
|
|
42
|
+
// A new URL (the saved slug changed) navigates through `src`; reloading
|
|
43
|
+
// here too would reload the old page over that navigation.
|
|
44
|
+
if (previous.url !== url) {
|
|
45
|
+
setLoading(true)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (previous.version === version) return
|
|
49
|
+
const frameWindow = frameRef.current?.contentWindow
|
|
50
|
+
if (!frameWindow) return
|
|
51
|
+
setLoading(true)
|
|
52
|
+
try {
|
|
53
|
+
frameWindow.location.reload()
|
|
54
|
+
} catch {
|
|
55
|
+
// The frame navigated off-origin (a link clicked inside it); reset it.
|
|
56
|
+
if (frameRef.current) frameRef.current.src = url
|
|
57
|
+
}
|
|
58
|
+
}, [version, url])
|
|
59
|
+
|
|
60
|
+
const viewportWidth = SITE_FRAME_WIDTHS[device]
|
|
61
|
+
const scale = siteFrameScale(pane.width, device)
|
|
62
|
+
const frameHeight = pane.height > 0 ? pane.height / scale : 800
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div ref={paneRef} style={paneStyle}>
|
|
66
|
+
<iframe
|
|
67
|
+
ref={frameRef}
|
|
68
|
+
src={url}
|
|
69
|
+
title={`Preview: ${title || 'Untitled'}`}
|
|
70
|
+
onLoad={() => setLoading(false)}
|
|
71
|
+
style={{
|
|
72
|
+
width: viewportWidth,
|
|
73
|
+
height: frameHeight,
|
|
74
|
+
border: 'none',
|
|
75
|
+
background: '#fff',
|
|
76
|
+
transform: `scale(${scale})`,
|
|
77
|
+
transformOrigin: 'top left',
|
|
78
|
+
position: 'absolute',
|
|
79
|
+
top: 0,
|
|
80
|
+
left: Math.max(0, (pane.width - viewportWidth * scale) / 2),
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
{loading && (
|
|
84
|
+
<span style={badgeStyle} role="status">
|
|
85
|
+
Updating preview…
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const paneStyle: React.CSSProperties = {
|
|
93
|
+
position: 'relative',
|
|
94
|
+
flex: 1,
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
background: 'var(--panel)',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const badgeStyle: React.CSSProperties = {
|
|
100
|
+
position: 'absolute',
|
|
101
|
+
top: 10,
|
|
102
|
+
right: 12,
|
|
103
|
+
padding: '3px 9px',
|
|
104
|
+
borderRadius: 20,
|
|
105
|
+
fontSize: 11,
|
|
106
|
+
fontWeight: 600,
|
|
107
|
+
background: 'var(--surface-raised-nav)',
|
|
108
|
+
color: 'var(--ink-muted)',
|
|
109
|
+
border: '1px solid var(--border)',
|
|
110
|
+
pointerEvents: 'none',
|
|
111
|
+
}
|
|
@@ -47,3 +47,20 @@ export function previewLayout(
|
|
|
47
47
|
device,
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
// ---- Real-site preview frame ---------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** The viewport width each device renders the real site at. The site's own
|
|
54
|
+
* breakpoints decide the layout, so desktop needs a desktop-sized viewport even
|
|
55
|
+
* when the pane is narrower. */
|
|
56
|
+
export const SITE_FRAME_WIDTHS: Record<PreviewDevice, number> = {
|
|
57
|
+
mobile: 390,
|
|
58
|
+
tablet: 768,
|
|
59
|
+
desktop: 1280,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Scale that fits a device viewport into the pane width; never enlarges. */
|
|
63
|
+
export function siteFrameScale(paneWidth: number, device: PreviewDevice): number {
|
|
64
|
+
if (!Number.isFinite(paneWidth) || paneWidth <= 0) return 1
|
|
65
|
+
return Math.min(1, paneWidth / SITE_FRAME_WIDTHS[device])
|
|
66
|
+
}
|