@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.
- package/README.md +48 -0
- package/dist/engine/published-content.js +1 -1
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +8 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +15 -8
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +6 -2
- package/dist/engine/revisions.js.map +1 -1
- package/dist/puck/config.d.ts +21 -0
- package/dist/puck/config.d.ts.map +1 -0
- package/dist/puck/config.js +121 -0
- package/dist/puck/config.js.map +1 -0
- package/dist/puck/index.d.ts +4 -0
- package/dist/puck/index.d.ts.map +1 -0
- package/dist/puck/index.js +10 -0
- package/dist/puck/index.js.map +1 -0
- package/dist/puck/markdown.d.ts +5 -0
- package/dist/puck/markdown.d.ts.map +1 -0
- package/dist/puck/markdown.js +78 -0
- package/dist/puck/markdown.js.map +1 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +17 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/layout.d.ts +126 -0
- package/dist/schema/layout.d.ts.map +1 -0
- package/dist/schema/layout.js +52 -0
- package/dist/schema/layout.js.map +1 -0
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +2 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +54 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/LayoutBuilder.d.ts +12 -0
- package/dist/ui/editor/LayoutBuilder.d.ts.map +1 -0
- package/dist/ui/editor/LayoutBuilder.js +66 -0
- package/dist/ui/editor/LayoutBuilder.js.map +1 -0
- package/dist/ui/editor/content-payload.d.ts +2 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +3 -0
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/editor/layout-mode.d.ts +21 -0
- package/dist/ui/editor/layout-mode.d.ts.map +1 -0
- package/dist/ui/editor/layout-mode.js +41 -0
- package/dist/ui/editor/layout-mode.js.map +1 -0
- package/migrations/0025_page_layout_json.sql +7 -0
- package/package.json +12 -1
- package/src/engine/published-content.ts +1 -1
- package/src/engine/publisher.ts +21 -6
- package/src/engine/revisions.ts +12 -2
- package/src/puck/config.tsx +194 -0
- package/src/puck/index.ts +23 -0
- package/src/puck/markdown.ts +78 -0
- package/src/routes/content.ts +18 -1
- package/src/schema/layout.ts +73 -0
- package/src/schema/migrations.ts +7 -0
- package/src/schema/types.ts +2 -0
- package/src/ui/editor/ContentForm.tsx +117 -6
- package/src/ui/editor/LayoutBuilder.tsx +100 -0
- package/src/ui/editor/content-payload.ts +5 -0
- 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 [``]
|
|
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
|
+
}
|
package/src/routes/content.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/schema/migrations.ts
CHANGED
package/src/schema/types.ts
CHANGED
|
@@ -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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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('&', '&')
|
|
26
|
+
.replaceAll('<', '<')
|
|
27
|
+
.replaceAll('>', '>')
|
|
28
|
+
.replaceAll('"', '"')
|
|
29
|
+
.replaceAll("'", ''')
|
|
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
|
+
}
|