@growth-labs/cms 0.5.21 → 0.5.22
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 +32 -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 +15 -0
- package/dist/puck/config.d.ts.map +1 -0
- package/dist/puck/config.js +118 -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/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 +185 -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
|
@@ -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
|