@growth-labs/cms 0.5.21 → 0.5.23

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 (66) hide show
  1. package/README.md +48 -0
  2. package/dist/engine/published-content.js +1 -1
  3. package/dist/engine/published-content.js.map +1 -1
  4. package/dist/engine/publisher.d.ts +8 -0
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +15 -8
  7. package/dist/engine/publisher.js.map +1 -1
  8. package/dist/engine/revisions.d.ts.map +1 -1
  9. package/dist/engine/revisions.js +6 -2
  10. package/dist/engine/revisions.js.map +1 -1
  11. package/dist/puck/config.d.ts +21 -0
  12. package/dist/puck/config.d.ts.map +1 -0
  13. package/dist/puck/config.js +121 -0
  14. package/dist/puck/config.js.map +1 -0
  15. package/dist/puck/index.d.ts +4 -0
  16. package/dist/puck/index.d.ts.map +1 -0
  17. package/dist/puck/index.js +10 -0
  18. package/dist/puck/index.js.map +1 -0
  19. package/dist/puck/markdown.d.ts +5 -0
  20. package/dist/puck/markdown.d.ts.map +1 -0
  21. package/dist/puck/markdown.js +78 -0
  22. package/dist/puck/markdown.js.map +1 -0
  23. package/dist/routes/content.d.ts.map +1 -1
  24. package/dist/routes/content.js +17 -1
  25. package/dist/routes/content.js.map +1 -1
  26. package/dist/schema/layout.d.ts +126 -0
  27. package/dist/schema/layout.d.ts.map +1 -0
  28. package/dist/schema/layout.js +52 -0
  29. package/dist/schema/layout.js.map +1 -0
  30. package/dist/schema/migrations.d.ts.map +1 -1
  31. package/dist/schema/migrations.js +7 -0
  32. package/dist/schema/migrations.js.map +1 -1
  33. package/dist/schema/types.d.ts +2 -0
  34. package/dist/schema/types.d.ts.map +1 -1
  35. package/dist/schema/types.js.map +1 -1
  36. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  37. package/dist/ui/editor/ContentForm.js +54 -1
  38. package/dist/ui/editor/ContentForm.js.map +1 -1
  39. package/dist/ui/editor/LayoutBuilder.d.ts +12 -0
  40. package/dist/ui/editor/LayoutBuilder.d.ts.map +1 -0
  41. package/dist/ui/editor/LayoutBuilder.js +66 -0
  42. package/dist/ui/editor/LayoutBuilder.js.map +1 -0
  43. package/dist/ui/editor/content-payload.d.ts +2 -0
  44. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  45. package/dist/ui/editor/content-payload.js +3 -0
  46. package/dist/ui/editor/content-payload.js.map +1 -1
  47. package/dist/ui/editor/layout-mode.d.ts +21 -0
  48. package/dist/ui/editor/layout-mode.d.ts.map +1 -0
  49. package/dist/ui/editor/layout-mode.js +41 -0
  50. package/dist/ui/editor/layout-mode.js.map +1 -0
  51. package/migrations/0025_page_layout_json.sql +7 -0
  52. package/package.json +12 -1
  53. package/src/engine/published-content.ts +1 -1
  54. package/src/engine/publisher.ts +21 -6
  55. package/src/engine/revisions.ts +12 -2
  56. package/src/puck/config.tsx +194 -0
  57. package/src/puck/index.ts +23 -0
  58. package/src/puck/markdown.ts +78 -0
  59. package/src/routes/content.ts +18 -1
  60. package/src/schema/layout.ts +73 -0
  61. package/src/schema/migrations.ts +7 -0
  62. package/src/schema/types.ts +2 -0
  63. package/src/ui/editor/ContentForm.tsx +117 -6
  64. package/src/ui/editor/LayoutBuilder.tsx +100 -0
  65. package/src/ui/editor/content-payload.ts +5 -0
  66. package/src/ui/editor/layout-mode.ts +58 -0
@@ -0,0 +1,78 @@
1
+ // src/puck/markdown.ts
2
+ // Derived-markdown projection: turn a stored layout envelope back into a
3
+ // classic body_markdown string. Builder-mode saves use this to keep
4
+ // body_markdown populated (search, SEO extraction, non-layout surfaces) while
5
+ // layout_json stays the source of truth for builder pages. The projection is
6
+ // lossy by design — structural chrome (Section widths, Spacers) has no
7
+ // markdown equivalent and is dropped.
8
+
9
+ import { type LayoutComponent, type LayoutEnvelope, parseLayoutEnvelope } from '../schema/layout.js'
10
+ import { parsePortableTextEnvelope } from '../schema/portable-text.js'
11
+ import { portableTextToDoc } from '../ui/editor/portable-text.js'
12
+ import { docToMarkdown } from '../ui/editor/serialize.js'
13
+
14
+ export function layoutToMarkdown(envelope: LayoutEnvelope): string {
15
+ return componentsToMarkdown(envelope.data.content).join('\n\n')
16
+ }
17
+
18
+ /** Parse stored layout JSON and project it; null when the JSON is malformed. */
19
+ export function layoutJsonToMarkdown(raw: unknown): string | null {
20
+ const envelope = parseLayoutEnvelope(raw)
21
+ if (!envelope) return null
22
+ return layoutToMarkdown(envelope)
23
+ }
24
+
25
+ function componentsToMarkdown(components: LayoutComponent[]): string[] {
26
+ const fragments: string[] = []
27
+ for (const component of components) {
28
+ for (const fragment of componentToMarkdown(component)) {
29
+ const trimmed = fragment.trim()
30
+ if (trimmed !== '') fragments.push(trimmed)
31
+ }
32
+ }
33
+ return fragments
34
+ }
35
+
36
+ function componentToMarkdown(component: LayoutComponent): string[] {
37
+ const props = component.props ?? {}
38
+ switch (component.type) {
39
+ case 'Heading': {
40
+ const text = typeof props.text === 'string' ? props.text.trim() : ''
41
+ if (text === '') return []
42
+ const level = props.level === '3' ? 3 : 2
43
+ return [`${'#'.repeat(level)} ${text}`]
44
+ }
45
+ case 'RichText': {
46
+ const parsed = parsePortableTextEnvelope(props.portableText)
47
+ if (!parsed) return []
48
+ return [docToMarkdown(portableTextToDoc(parsed.content))]
49
+ }
50
+ case 'Image': {
51
+ const src = typeof props.src === 'string' ? props.src.trim() : ''
52
+ if (src === '') return []
53
+ const alt = typeof props.alt === 'string' ? props.alt : ''
54
+ return [`![${alt}](${src})`]
55
+ }
56
+ case 'CTAButton': {
57
+ const label = typeof props.label === 'string' ? props.label.trim() : ''
58
+ const href = typeof props.href === 'string' ? props.href.trim() : ''
59
+ if (label === '' || href === '') return []
60
+ return [`[${label}](${href})`]
61
+ }
62
+ case 'Section': {
63
+ // Slot content is stored inline under the slot prop as
64
+ // { type, props }[] (Puck 0.23 slot fields; ids optional).
65
+ if (!Array.isArray(props.content)) return []
66
+ const nested = props.content.filter(
67
+ (entry): entry is LayoutComponent =>
68
+ typeof entry === 'object' &&
69
+ entry !== null &&
70
+ typeof (entry as LayoutComponent).type === 'string',
71
+ )
72
+ return componentsToMarkdown(nested)
73
+ }
74
+ default:
75
+ // Spacer and unknown/future types have no markdown projection.
76
+ return []
77
+ }
78
+ }
@@ -52,6 +52,7 @@ import {
52
52
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
53
53
  import { softDeleteContent } from '../engine/soft-delete.js'
54
54
  import { TagInputError } from '../engine/taxonomy.js'
55
+ import { parseLayoutEnvelope } from '../schema/layout.js'
55
56
  import { parsePortableTextEnvelope } from '../schema/portable-text.js'
56
57
  import type { ContentStatus } from '../schema/types.js'
57
58
  import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
@@ -258,10 +259,25 @@ const bodyPortableTextField = z
258
259
  .optional()
259
260
  .nullable()
260
261
 
262
+ // Puck layout envelopes get the same boundary treatment.
263
+ const layoutJsonField = z
264
+ .string()
265
+ .superRefine((value, ctx) => {
266
+ if (parseLayoutEnvelope(value) === null) {
267
+ ctx.addIssue({
268
+ code: z.ZodIssueCode.custom,
269
+ message: 'layoutJson must be a valid layout envelope',
270
+ })
271
+ }
272
+ })
273
+ .optional()
274
+ .nullable()
275
+
261
276
  const BodyContentCreateSchema = z.object({
262
277
  bodyMarkdown: z.string().min(1),
263
278
  bodyHtml: z.string().optional().nullable(),
264
279
  bodyPortableText: bodyPortableTextField,
280
+ layoutJson: layoutJsonField,
265
281
  subtitle: z.string().optional().nullable(),
266
282
  wordCount: z.number().int().optional().nullable(),
267
283
  readTimeMinutes: z.number().int().optional().nullable(),
@@ -368,6 +384,7 @@ const ArticleContentUpdateSchema = z.object({
368
384
  bodyMarkdown: z.string().optional(),
369
385
  bodyHtml: z.string().optional().nullable(),
370
386
  bodyPortableText: bodyPortableTextField,
387
+ layoutJson: layoutJsonField,
371
388
  subtitle: z.string().optional().nullable(),
372
389
  wordCount: z.number().int().optional().nullable(),
373
390
  readTimeMinutes: z.number().int().optional().nullable(),
@@ -570,7 +587,7 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
570
587
  if (type === 'article' || type === 'newsletter' || type === 'page') {
571
588
  return ctx.db
572
589
  .prepare(
573
- `SELECT body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle,
590
+ `SELECT body_markdown, body_html, body_portable_text, layout_json, word_count, read_time_minutes, subtitle,
574
591
  ai_takeaways, ai_takeaways_at, ai_takeaways_model, editor_takeaways,
575
592
  ai_takeaways_correlation_id
576
593
  FROM article_content WHERE content_id = ? LIMIT 1`,
@@ -0,0 +1,73 @@
1
+ // src/schema/layout.ts
2
+ // The page-layout durable-store contract: envelope shape, version, and the
3
+ // boundary validator. `article_content.layout_json` holds the envelope —
4
+ // `{ version, data }` — where `data` is a Puck Data document (content array
5
+ // of typed components, root props, optional zones). The internal adapter
6
+ // (src/puck/) owns the Puck-specific semantics; this module only guarantees
7
+ // the stored shape is structurally sound so malformed layouts fail loudly at
8
+ // the boundary and renderers fall back to the classic body path.
9
+
10
+ import { z } from 'zod'
11
+
12
+ export const LAYOUT_VERSION = 1
13
+
14
+ export interface LayoutComponent {
15
+ type: string
16
+ props?: Record<string, unknown>
17
+ [key: string]: unknown
18
+ }
19
+
20
+ export interface LayoutData {
21
+ content: LayoutComponent[]
22
+ root?: Record<string, unknown>
23
+ zones?: Record<string, LayoutComponent[]>
24
+ [key: string]: unknown
25
+ }
26
+
27
+ export interface LayoutEnvelope {
28
+ version: typeof LAYOUT_VERSION
29
+ data: LayoutData
30
+ }
31
+
32
+ const layoutComponentSchema = z
33
+ .object({
34
+ type: z.string().min(1),
35
+ props: z.record(z.string(), z.unknown()).optional(),
36
+ })
37
+ .passthrough()
38
+
39
+ const layoutDataSchema = z
40
+ .object({
41
+ content: z.array(layoutComponentSchema),
42
+ root: z.record(z.string(), z.unknown()).optional(),
43
+ zones: z.record(z.string(), z.array(layoutComponentSchema)).optional(),
44
+ })
45
+ .passthrough()
46
+
47
+ export const layoutEnvelopeSchema = z.object({
48
+ version: z.literal(LAYOUT_VERSION),
49
+ data: layoutDataSchema,
50
+ })
51
+
52
+ /** Wrap a Puck Data document in the versioned storage envelope. */
53
+ export function wrapLayout(data: LayoutData): LayoutEnvelope {
54
+ return { version: LAYOUT_VERSION, data }
55
+ }
56
+
57
+ /**
58
+ * Parse stored layout JSON into a validated envelope. Returns null for
59
+ * anything malformed — absent value, bad JSON, wrong shape, unknown version —
60
+ * so callers fall back to the classic body render path.
61
+ */
62
+ export function parseLayoutEnvelope(raw: unknown): LayoutEnvelope | null {
63
+ if (typeof raw !== 'string' || raw === '') return null
64
+ let decoded: unknown
65
+ try {
66
+ decoded = JSON.parse(raw)
67
+ } catch {
68
+ return null
69
+ }
70
+ const parsed = layoutEnvelopeSchema.safeParse(decoded)
71
+ if (!parsed.success) return null
72
+ return parsed.data as LayoutEnvelope
73
+ }
@@ -834,6 +834,13 @@ ALTER TABLE article_content
834
834
  ADD COLUMN body_portable_text TEXT;
835
835
  `,
836
836
  },
837
+ {
838
+ id: '0025_page_layout_json',
839
+ sql: `
840
+ ALTER TABLE article_content
841
+ ADD COLUMN layout_json TEXT;
842
+ `,
843
+ },
837
844
  ]
838
845
 
839
846
  /**
@@ -93,6 +93,8 @@ export interface ArticleContentRow {
93
93
  body_html: string | null
94
94
  /** Portable Text envelope JSON (migration 0024); NULL → render markdown. */
95
95
  body_portable_text: string | null
96
+ /** Puck layout envelope JSON (migration 0025); NULL → classic page. */
97
+ layout_json: string | null
96
98
  word_count: number | null
97
99
  read_time_minutes: number | null
98
100
  subtitle: string | null
@@ -36,6 +36,8 @@ import {
36
36
  } from './content-payload.js'
37
37
  import { measureEditorMediaDurationSeconds } from './editor-media-duration.js'
38
38
  import { uploadEditorImage } from './editor-media-upload.js'
39
+ import { LayoutBuilder } from './LayoutBuilder.js'
40
+ import { layoutDraftFromData } from './layout-mode.js'
39
41
  import { Rte } from './Rte.js'
40
42
  import { docToMarkdown } from './serialize.js'
41
43
 
@@ -110,6 +112,7 @@ interface ExistingContentPayload {
110
112
  body_markdown?: string | null
111
113
  body_html?: string | null
112
114
  body_portable_text?: string | null
115
+ layout_json?: string | null
113
116
  subtitle?: string | null
114
117
  script?: string | null
115
118
  video_id?: string | null
@@ -134,6 +137,10 @@ interface FormState {
134
137
  body: string
135
138
  /** Serialized Portable Text envelope paired with `body`; null until the editor emits one. */
136
139
  bodyPortableText: string | null
140
+ /** Serialized Puck layout envelope (pages only); null = classic page. */
141
+ layoutJson: string | null
142
+ /** Whether the Builder canvas (vs the classic Rte) is showing (pages only). */
143
+ builderOpen: boolean
137
144
  slug: string
138
145
  byline: string | null
139
146
  authorId: string | null
@@ -180,6 +187,8 @@ function createEmptyFormState(docId: string | null): FormState {
180
187
  dek: '',
181
188
  body: '',
182
189
  bodyPortableText: null,
190
+ layoutJson: null,
191
+ builderOpen: false,
183
192
  slug: '',
184
193
  byline: null,
185
194
  authorId: null,
@@ -519,6 +528,35 @@ export function ContentForm({
519
528
  scheduleAutosave(next)
520
529
  }
521
530
 
531
+ function handleLayoutDataChange(data: Parameters<typeof layoutDraftFromData>[0]) {
532
+ const current = latestForm.current
533
+ const next = {
534
+ ...current,
535
+ ...layoutDraftFromData(data, current.body, current.title),
536
+ }
537
+ setForm(next)
538
+ publishDraft(next)
539
+ scheduleAutosave(next)
540
+ }
541
+
542
+ function handleBuilderToggle(open: boolean) {
543
+ setForm((prev) => ({ ...prev, builderOpen: open }))
544
+ }
545
+
546
+ function handleRemoveLayout() {
547
+ if (
548
+ !window.confirm(
549
+ 'Remove the page layout? The page renders its classic body again; the layout cannot be recovered after the next save.',
550
+ )
551
+ ) {
552
+ return
553
+ }
554
+ const next = { ...latestForm.current, layoutJson: null, builderOpen: false }
555
+ setForm(next)
556
+ publishDraft(next)
557
+ scheduleAutosave(next)
558
+ }
559
+
522
560
  function handleVideoUrlChange(value: string) {
523
561
  const next = {
524
562
  ...latestForm.current,
@@ -942,12 +980,66 @@ export function ContentForm({
942
980
  />
943
981
  </div>
944
982
  )}
945
- <Rte
946
- value={form.body}
947
- portableText={form.bodyPortableText}
948
- onChange={handleBodyChange}
949
- onImageUpload={handleEditorImageUpload}
950
- />
983
+ {contentType === 'page' && (
984
+ <div
985
+ style={{
986
+ display: 'flex',
987
+ alignItems: 'center',
988
+ gap: 10,
989
+ marginBottom: 10,
990
+ fontSize: 13,
991
+ }}
992
+ >
993
+ <button
994
+ type="button"
995
+ onClick={() => handleBuilderToggle(false)}
996
+ style={modeTabStyle(!form.builderOpen)}
997
+ >
998
+ Classic body
999
+ </button>
1000
+ <button
1001
+ type="button"
1002
+ onClick={() => handleBuilderToggle(true)}
1003
+ style={modeTabStyle(form.builderOpen)}
1004
+ >
1005
+ Builder
1006
+ </button>
1007
+ {form.layoutJson !== null && (
1008
+ <>
1009
+ {!form.builderOpen && (
1010
+ <span style={{ color: 'var(--ink-faint)' }}>
1011
+ This page has a layout — the layout wins on the live page.
1012
+ </span>
1013
+ )}
1014
+ <button
1015
+ type="button"
1016
+ onClick={handleRemoveLayout}
1017
+ style={{
1018
+ ...modeTabStyle(false),
1019
+ marginLeft: 'auto',
1020
+ color: 'var(--danger, #b91c1c)',
1021
+ }}
1022
+ >
1023
+ Remove layout
1024
+ </button>
1025
+ </>
1026
+ )}
1027
+ </div>
1028
+ )}
1029
+ {contentType === 'page' && form.builderOpen ? (
1030
+ <LayoutBuilder
1031
+ layoutJson={form.layoutJson}
1032
+ onDataChange={handleLayoutDataChange}
1033
+ onImageUpload={handleEditorImageUpload}
1034
+ />
1035
+ ) : (
1036
+ <Rte
1037
+ value={form.body}
1038
+ portableText={form.bodyPortableText}
1039
+ onChange={handleBodyChange}
1040
+ onImageUpload={handleEditorImageUpload}
1041
+ />
1042
+ )}
951
1043
  </>
952
1044
  )}
953
1045
  </div>
@@ -1373,6 +1465,12 @@ function contentResponseToFormState(
1373
1465
  next.bodyPortableText = toNullableString(content.body_portable_text)
1374
1466
  }
1375
1467
 
1468
+ if (contentType === 'page') {
1469
+ next.layoutJson = toNullableString(content.layout_json)
1470
+ // A stored layout means this is a builder page — open the canvas.
1471
+ next.builderOpen = next.layoutJson !== null
1472
+ }
1473
+
1376
1474
  if (contentType === 'video') {
1377
1475
  next.body = toStringValue(content.script)
1378
1476
  next.videoUrl = toStringValue(content.processing_source_url)
@@ -1452,6 +1550,19 @@ function timeSince(ts: number): string {
1452
1550
  // Styles
1453
1551
  // ---------------------------------------------------------------------------
1454
1552
 
1553
+ function modeTabStyle(active: boolean): React.CSSProperties {
1554
+ return {
1555
+ border: '1px solid var(--line)',
1556
+ borderRadius: 6,
1557
+ background: active ? 'var(--ink)' : 'transparent',
1558
+ color: active ? 'var(--paper, #fff)' : 'var(--ink)',
1559
+ cursor: 'pointer',
1560
+ fontSize: 12,
1561
+ fontWeight: 600,
1562
+ padding: '4px 10px',
1563
+ }
1564
+ }
1565
+
1455
1566
  const formWrap: React.CSSProperties = {
1456
1567
  display: 'flex',
1457
1568
  flexDirection: 'column',
@@ -0,0 +1,100 @@
1
+ // src/ui/editor/LayoutBuilder.tsx
2
+ // Builder mode for page documents: mounts the Puck editor over the shared
3
+ // component config (src/puck/config.tsx). Browser-only by nature — this
4
+ // module imports the full @puckeditor/core editor and TipTap HTML generation,
5
+ // and it ships in the admin client bundle only.
6
+ //
7
+ // <Puck data> is initial-only: the parent renders this component conditionally
8
+ // (or keyed per doc), so every mount re-reads the stored envelope. Canvas
9
+ // changes flow up through onDataChange as plain LayoutData; the parent owns
10
+ // wrapping, projection, and autosave (layout-mode.ts).
11
+
12
+ import { Puck } from '@puckeditor/core'
13
+ import '@puckeditor/core/no-external.css'
14
+ import { generateHTML } from '@tiptap/core'
15
+ import { useMemo, useState } from 'react'
16
+ import { createPuckConfig, type LayoutData } from '../../puck/index.js'
17
+ import { parsePortableTextEnvelope } from '../../schema/portable-text.js'
18
+ import { createRteExtensions } from './extensions.js'
19
+ import { initialLayoutData, richTextMarkdownFromEnvelope } from './layout-mode.js'
20
+ import { portableTextToDoc } from './portable-text.js'
21
+ import { Rte, type RteProps } from './Rte.js'
22
+
23
+ function escapeHtml(value: string): string {
24
+ return value
25
+ .replaceAll('&', '&amp;')
26
+ .replaceAll('<', '&lt;')
27
+ .replaceAll('>', '&gt;')
28
+ .replaceAll('"', '&quot;')
29
+ .replaceAll("'", '&#39;')
30
+ }
31
+
32
+ // Canvas preview for RichText blocks: the same TipTap schema the Rte uses,
33
+ // so the preview matches what the field editor shows. Falls back to escaped
34
+ // text if HTML generation fails; empty envelopes show a placeholder hint.
35
+ function canvasRichTextHtml(envelopeJson: string): string {
36
+ const parsed = parsePortableTextEnvelope(envelopeJson)
37
+ if (!parsed) {
38
+ return '<p class="gl-builder-richtext-empty">Empty rich text — select the block to write.</p>'
39
+ }
40
+ try {
41
+ return generateHTML(portableTextToDoc(parsed.content), createRteExtensions())
42
+ } catch {
43
+ return `<p>${escapeHtml(richTextMarkdownFromEnvelope(envelopeJson))}</p>`
44
+ }
45
+ }
46
+
47
+ function makeRichTextField(onImageUpload: RteProps['onImageUpload']) {
48
+ return {
49
+ type: 'custom' as const,
50
+ label: 'Rich text',
51
+ render: ({ value, onChange }: { value: unknown; onChange: (value: string) => void }) => {
52
+ const stored = typeof value === 'string' ? value : ''
53
+ return (
54
+ <div className="gl-builder-richtext-field">
55
+ <Rte
56
+ value={richTextMarkdownFromEnvelope(stored)}
57
+ portableText={stored || null}
58
+ onChange={(_md, portableText) => onChange(portableText)}
59
+ onImageUpload={onImageUpload}
60
+ />
61
+ </div>
62
+ )
63
+ },
64
+ }
65
+ }
66
+
67
+ const EMPTY_DATA: LayoutData = { root: { props: {} }, content: [] }
68
+
69
+ export interface LayoutBuilderProps {
70
+ /** Stored layout envelope JSON at mount time; null starts a blank canvas. */
71
+ layoutJson: string | null
72
+ /** Fires on every canvas edit with the raw Puck data. */
73
+ onDataChange: (data: LayoutData) => void
74
+ onImageUpload?: RteProps['onImageUpload']
75
+ }
76
+
77
+ export function LayoutBuilder({ layoutJson, onDataChange, onImageUpload }: LayoutBuilderProps) {
78
+ const config = useMemo(
79
+ () =>
80
+ createPuckConfig({
81
+ renderRichTextHtml: canvasRichTextHtml,
82
+ richTextField: makeRichTextField(onImageUpload),
83
+ }),
84
+ [onImageUpload],
85
+ )
86
+ // Initial-only by contract — a lazy state initializer reads the prop once
87
+ // at mount (Puck ignores later data props; a remount re-reads the envelope).
88
+ const [initialData] = useState(() => initialLayoutData(layoutJson) ?? EMPTY_DATA)
89
+ return (
90
+ <div className="gl-layout-builder" style={{ border: '1px solid var(--line)', borderRadius: 8 }}>
91
+ <Puck
92
+ config={config}
93
+ data={initialData as never}
94
+ onChange={(data) => onDataChange(data as unknown as LayoutData)}
95
+ iframe={{ enabled: false }}
96
+ height="72vh"
97
+ />
98
+ </div>
99
+ )
100
+ }
@@ -7,6 +7,8 @@ export interface ContentDraftFields {
7
7
  body: string
8
8
  /** Serialized Portable Text envelope for the body; null until the editor emits one. */
9
9
  bodyPortableText?: string | null
10
+ /** Serialized Puck layout envelope (pages only); null clears, undefined never sent. */
11
+ layoutJson?: string | null
10
12
  videoUrl: string
11
13
  videoSourceKind?: string | null
12
14
  videoId?: string | null
@@ -109,6 +111,9 @@ export function buildContentUpdatePayload(
109
111
  bodyPortableText: draft.bodyPortableText || null,
110
112
  wordCount,
111
113
  readTimeMinutes: estimateReadTime(wordCount),
114
+ // Pages always state their layout explicitly: a value writes it,
115
+ // null clears it (the engine preserves only when the key is absent).
116
+ ...(contentType === 'page' ? { layoutJson: draft.layoutJson ?? null } : {}),
112
117
  }
113
118
  } else if (contentType === 'video') {
114
119
  const sourceUrl = draft.videoUrl.trim()
@@ -0,0 +1,58 @@
1
+ // src/ui/editor/layout-mode.ts
2
+ // Pure Builder-mode logic: canvas data → persisted draft triple, stored
3
+ // envelope → canvas data, and the Portable Text ↔ markdown bridge for the
4
+ // RichText custom field. No DOM, no Puck imports — the LayoutBuilder
5
+ // component owns the browser-only pieces.
6
+
7
+ import {
8
+ type LayoutData,
9
+ layoutToMarkdown,
10
+ parseLayoutEnvelope,
11
+ wrapLayout,
12
+ } from '../../puck/index.js'
13
+ import { parsePortableTextEnvelope } from '../../schema/portable-text.js'
14
+ import { portableTextToDoc } from './portable-text.js'
15
+ import { docToMarkdown } from './serialize.js'
16
+
17
+ export interface LayoutDraft {
18
+ layoutJson: string
19
+ body: string
20
+ bodyPortableText: null
21
+ }
22
+
23
+ /**
24
+ * Turn a Puck canvas change into the fields a builder save persists. The
25
+ * classic body stays populated with the derived-markdown projection (search,
26
+ * SEO, non-layout surfaces); an unprojectable layout keeps the previous body,
27
+ * then the title, so create/publish body requirements still hold.
28
+ */
29
+ export function layoutDraftFromData(
30
+ data: LayoutData,
31
+ previousBody: string,
32
+ title: string,
33
+ ): LayoutDraft {
34
+ const envelope = wrapLayout(data)
35
+ const projected = layoutToMarkdown(envelope).trim()
36
+ return {
37
+ layoutJson: JSON.stringify(envelope),
38
+ body: projected || previousBody.trim() || title.trim(),
39
+ // The derived body is markdown-only — stale Portable Text must not
40
+ // survive a builder save (same staleness rule the engine enforces).
41
+ bodyPortableText: null,
42
+ }
43
+ }
44
+
45
+ /** Stored layout JSON → Puck canvas data; null for absent/malformed. */
46
+ export function initialLayoutData(layoutJson: string | null): LayoutData | null {
47
+ return parseLayoutEnvelope(layoutJson)?.data ?? null
48
+ }
49
+
50
+ /**
51
+ * Portable Text envelope JSON (a RichText block's stored prop) → markdown,
52
+ * for seeding the embedded Rte. Empty string when the envelope is malformed.
53
+ */
54
+ export function richTextMarkdownFromEnvelope(envelopeJson: string): string {
55
+ const parsed = parsePortableTextEnvelope(envelopeJson)
56
+ if (!parsed) return ''
57
+ return docToMarkdown(portableTextToDoc(parsed.content)).trim()
58
+ }