@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.
Files changed (87) hide show
  1. package/dist/engine/fronts-publish.d.ts +29 -0
  2. package/dist/engine/fronts-publish.d.ts.map +1 -1
  3. package/dist/engine/fronts-publish.js +58 -0
  4. package/dist/engine/fronts-publish.js.map +1 -1
  5. package/dist/engine/publication-current-guard.d.ts +19 -0
  6. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  7. package/dist/engine/publication-current-guard.js +75 -0
  8. package/dist/engine/publication-current-guard.js.map +1 -1
  9. package/dist/engine/publisher.d.ts +3 -0
  10. package/dist/engine/publisher.d.ts.map +1 -1
  11. package/dist/engine/publisher.js +15 -2
  12. package/dist/engine/publisher.js.map +1 -1
  13. package/dist/foundry-emit.d.ts +59 -6
  14. package/dist/foundry-emit.d.ts.map +1 -1
  15. package/dist/foundry-emit.js +16 -1
  16. package/dist/foundry-emit.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts.map +1 -1
  18. package/dist/routes/content-insights.js +6 -0
  19. package/dist/routes/content-insights.js.map +1 -1
  20. package/dist/routes/content.d.ts +3 -0
  21. package/dist/routes/content.d.ts.map +1 -1
  22. package/dist/routes/content.js +1 -0
  23. package/dist/routes/content.js.map +1 -1
  24. package/dist/routes/foundry-publish.d.ts.map +1 -1
  25. package/dist/routes/foundry-publish.js +101 -12
  26. package/dist/routes/foundry-publish.js.map +1 -1
  27. package/dist/routes/fronts-publish.d.ts +2 -0
  28. package/dist/routes/fronts-publish.d.ts.map +1 -1
  29. package/dist/routes/fronts-publish.js +47 -1
  30. package/dist/routes/fronts-publish.js.map +1 -1
  31. package/dist/routes/media-lib.d.ts +2 -0
  32. package/dist/routes/media-lib.d.ts.map +1 -1
  33. package/dist/routes/media-lib.js +6 -0
  34. package/dist/routes/media-lib.js.map +1 -1
  35. package/dist/schema/insights-ingest.d.ts +88 -88
  36. package/dist/ui/api/fronts/publish-callback.d.ts +1 -0
  37. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
  38. package/dist/ui/api/fronts/publish-callback.js +1 -0
  39. package/dist/ui/api/fronts/publish-callback.js.map +1 -1
  40. package/dist/ui/editor/extensions.d.ts +1 -1
  41. package/dist/ui/editor/extensions.d.ts.map +1 -1
  42. package/dist/ui/editor/extensions.js +15 -1
  43. package/dist/ui/editor/extensions.js.map +1 -1
  44. package/dist/ui/editor/heading-level.d.ts +4 -0
  45. package/dist/ui/editor/heading-level.d.ts.map +1 -0
  46. package/dist/ui/editor/heading-level.js +13 -0
  47. package/dist/ui/editor/heading-level.js.map +1 -0
  48. package/dist/ui/editor/portable-text.d.ts.map +1 -1
  49. package/dist/ui/editor/portable-text.js +3 -2
  50. package/dist/ui/editor/portable-text.js.map +1 -1
  51. package/dist/ui/editor/serialize.d.ts.map +1 -1
  52. package/dist/ui/editor/serialize.js +4 -3
  53. package/dist/ui/editor/serialize.js.map +1 -1
  54. package/dist/ui/preview/SitePreviewFrame.d.ts +11 -0
  55. package/dist/ui/preview/SitePreviewFrame.d.ts.map +1 -0
  56. package/dist/ui/preview/SitePreviewFrame.js +86 -0
  57. package/dist/ui/preview/SitePreviewFrame.js.map +1 -0
  58. package/dist/ui/preview/preview-layout.d.ts +6 -0
  59. package/dist/ui/preview/preview-layout.d.ts.map +1 -1
  60. package/dist/ui/preview/preview-layout.js +15 -0
  61. package/dist/ui/preview/preview-layout.js.map +1 -1
  62. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  63. package/dist/ui/screens/EditorScreen.js +61 -25
  64. package/dist/ui/screens/EditorScreen.js.map +1 -1
  65. package/dist/ui/screens/public-url.d.ts +12 -0
  66. package/dist/ui/screens/public-url.d.ts.map +1 -1
  67. package/dist/ui/screens/public-url.js +30 -0
  68. package/dist/ui/screens/public-url.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/engine/fronts-publish.ts +88 -0
  71. package/src/engine/publication-current-guard.ts +103 -0
  72. package/src/engine/publisher.ts +22 -3
  73. package/src/foundry-emit.ts +17 -0
  74. package/src/routes/content-insights.ts +6 -0
  75. package/src/routes/content.ts +4 -0
  76. package/src/routes/foundry-publish.ts +134 -12
  77. package/src/routes/fronts-publish.ts +57 -0
  78. package/src/routes/media-lib.ts +6 -0
  79. package/src/ui/api/fronts/publish-callback.ts +3 -0
  80. package/src/ui/editor/extensions.ts +16 -1
  81. package/src/ui/editor/heading-level.ts +14 -0
  82. package/src/ui/editor/portable-text.ts +3 -2
  83. package/src/ui/editor/serialize.ts +4 -3
  84. package/src/ui/preview/SitePreviewFrame.tsx +111 -0
  85. package/src/ui/preview/preview-layout.ts +17 -0
  86. package/src/ui/screens/EditorScreen.tsx +76 -27
  87. package/src/ui/screens/public-url.ts +36 -0
@@ -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 { PublicationCurrentSnapshotError } from '../engine/publication-current-guard.js'
6
- import { PublishContentValidationError } from '../engine/publisher.js'
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: await readReadyImagePublicUrl(ctx.db, item.hero_image_id),
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 json({ ...v2Receipt(row), working: result.data })
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 = body.expectedRevisionId
836
- ? (await getRevision(ctx.db, bound.id, body.expectedRevisionId))?.payload
837
- : prior
838
- ? await lastFoundryWriteSnapshot(ctx, prior)
839
- : null
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') return json({ error: 'invalid_working_source_request' }, 400)
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'
@@ -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–h6), bulletList, orderedList,
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
+ }