@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.
Files changed (47) hide show
  1. package/README.md +32 -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 +15 -0
  12. package/dist/puck/config.d.ts.map +1 -0
  13. package/dist/puck/config.js +118 -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/migrations/0025_page_layout_json.sql +7 -0
  37. package/package.json +12 -1
  38. package/src/engine/published-content.ts +1 -1
  39. package/src/engine/publisher.ts +21 -6
  40. package/src/engine/revisions.ts +12 -2
  41. package/src/puck/config.tsx +185 -0
  42. package/src/puck/index.ts +23 -0
  43. package/src/puck/markdown.ts +78 -0
  44. package/src/routes/content.ts +18 -1
  45. package/src/schema/layout.ts +73 -0
  46. package/src/schema/migrations.ts +7 -0
  47. 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 [`![${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