@growth-labs/cms 0.5.20 → 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 (50) 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/dist/ui/editor/serialize.js +20 -2
  37. package/dist/ui/editor/serialize.js.map +1 -1
  38. package/migrations/0025_page_layout_json.sql +7 -0
  39. package/package.json +12 -1
  40. package/src/engine/published-content.ts +1 -1
  41. package/src/engine/publisher.ts +21 -6
  42. package/src/engine/revisions.ts +12 -2
  43. package/src/puck/config.tsx +185 -0
  44. package/src/puck/index.ts +23 -0
  45. package/src/puck/markdown.ts +78 -0
  46. package/src/routes/content.ts +18 -1
  47. package/src/schema/layout.ts +73 -0
  48. package/src/schema/migrations.ts +7 -0
  49. package/src/schema/types.ts +2 -0
  50. package/src/ui/editor/serialize.ts +24 -2
@@ -0,0 +1,185 @@
1
+ // src/puck/config.tsx
2
+ // The cms-owned Puck component library (v1): the Config consumed by both the
3
+ // admin editor (<Puck>) and the public renderer (<Render> from
4
+ // @puckeditor/core/rsc). Every render function is server-safe — plain JSX,
5
+ // no hooks, no DOM access — so the fronts Worker can render layouts with
6
+ // zero client JS. @puckeditor/core itself is only a TYPE dependency here;
7
+ // the runtime dependency lives with the consumer (optional peer).
8
+ //
9
+ // RichText stores a Portable Text envelope (schema/portable-text.ts) in its
10
+ // props — NEVER Puck's built-in `richtext` field, whose /rsc render path
11
+ // requires a DOM (@tiptap/html + happy-dom) and breaks on workerd. The HTML
12
+ // projection is injected by the consumer so site render pipelines (sanitize,
13
+ // heading ids, entity linking) stay site-owned.
14
+
15
+ import type { Config } from '@puckeditor/core'
16
+ import type { ReactNode } from 'react'
17
+
18
+ export interface PuckConfigOptions {
19
+ /**
20
+ * Portable Text envelope JSON → trusted HTML for RichText blocks. The
21
+ * consumer owns this (fronts routes it through its article render
22
+ * pipeline). Without it, RichText renders a loud inline marker instead of
23
+ * silently dropping content.
24
+ */
25
+ renderRichTextHtml?: (portableTextEnvelopeJson: string) => string
26
+ }
27
+
28
+ /** The v1 layout component set, in palette order. */
29
+ export const LAYOUT_COMPONENT_TYPES = [
30
+ 'Section',
31
+ 'Heading',
32
+ 'RichText',
33
+ 'Image',
34
+ 'CTAButton',
35
+ 'Spacer',
36
+ ] as const
37
+
38
+ export type LayoutComponentType = (typeof LAYOUT_COMPONENT_TYPES)[number]
39
+
40
+ export function createPuckConfig(options: PuckConfigOptions = {}): Config {
41
+ const { renderRichTextHtml } = options
42
+ return {
43
+ components: {
44
+ Section: {
45
+ label: 'Section',
46
+ fields: {
47
+ width: {
48
+ type: 'select',
49
+ label: 'Width',
50
+ options: [
51
+ { label: 'Normal', value: 'normal' },
52
+ { label: 'Wide', value: 'wide' },
53
+ ],
54
+ },
55
+ content: { type: 'slot' },
56
+ },
57
+ defaultProps: { width: 'normal', content: [] },
58
+ render: ({ width, content }) => (
59
+ <section className={`gl-layout-section gl-layout-section--${width || 'normal'}`}>
60
+ {typeof content === 'function' ? content() : null}
61
+ </section>
62
+ ),
63
+ },
64
+ Heading: {
65
+ label: 'Heading',
66
+ fields: {
67
+ text: { type: 'text', label: 'Text', contentEditable: true },
68
+ level: {
69
+ type: 'select',
70
+ label: 'Level',
71
+ options: [
72
+ { label: 'Heading 2', value: '2' },
73
+ { label: 'Heading 3', value: '3' },
74
+ ],
75
+ },
76
+ },
77
+ defaultProps: { text: '', level: '2' },
78
+ render: ({ text, level }) =>
79
+ level === '3' ? (
80
+ <h3 className="gl-layout-heading">{text}</h3>
81
+ ) : (
82
+ <h2 className="gl-layout-heading">{text}</h2>
83
+ ),
84
+ },
85
+ RichText: {
86
+ label: 'Rich text',
87
+ // Placeholder field until the Builder-mode editor lands its
88
+ // Portable Text custom field; the stored prop shape is final.
89
+ fields: {
90
+ portableText: { type: 'textarea', label: 'Portable Text envelope' },
91
+ },
92
+ defaultProps: { portableText: '' },
93
+ render: ({ portableText }) => {
94
+ const raw = typeof portableText === 'string' ? portableText : ''
95
+ if (renderRichTextHtml && raw !== '') {
96
+ return (
97
+ <div
98
+ className="gl-layout-richtext"
99
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: the consumer-injected projection owns sanitization
100
+ dangerouslySetInnerHTML={{ __html: renderRichTextHtml(raw) }}
101
+ />
102
+ )
103
+ }
104
+ // Loud, greppable marker — mirrors the fronts data-pt-unknown
105
+ // convention so a missing renderer never silently drops prose.
106
+ return (
107
+ <div
108
+ className="gl-layout-richtext"
109
+ data-gl-layout-unknown="richtext-renderer-missing"
110
+ />
111
+ )
112
+ },
113
+ },
114
+ Image: {
115
+ label: 'Image',
116
+ fields: {
117
+ src: { type: 'text', label: 'Image URL' },
118
+ alt: { type: 'text', label: 'Alt text' },
119
+ caption: { type: 'text', label: 'Caption' },
120
+ },
121
+ defaultProps: { src: '', alt: '', caption: '' },
122
+ render: ({ src, alt, caption }) => (
123
+ <figure className="gl-layout-image">
124
+ {typeof src === 'string' && src !== '' ? (
125
+ <img src={src} alt={typeof alt === 'string' ? alt : ''} loading="lazy" />
126
+ ) : null}
127
+ {typeof caption === 'string' && caption !== '' ? (
128
+ <figcaption>{caption}</figcaption>
129
+ ) : null}
130
+ </figure>
131
+ ),
132
+ },
133
+ CTAButton: {
134
+ label: 'CTA button',
135
+ fields: {
136
+ label: { type: 'text', label: 'Label', contentEditable: true },
137
+ href: { type: 'text', label: 'Link URL' },
138
+ tone: {
139
+ type: 'select',
140
+ label: 'Tone',
141
+ options: [
142
+ { label: 'Primary', value: 'primary' },
143
+ { label: 'Secondary', value: 'secondary' },
144
+ ],
145
+ },
146
+ },
147
+ defaultProps: { label: '', href: '', tone: 'primary' },
148
+ render: ({ label, href, tone }) => (
149
+ <a
150
+ className={`gl-layout-cta gl-layout-cta--${tone || 'primary'}`}
151
+ href={typeof href === 'string' && href !== '' ? href : undefined}
152
+ >
153
+ {label}
154
+ </a>
155
+ ),
156
+ },
157
+ Spacer: {
158
+ label: 'Spacer',
159
+ fields: {
160
+ size: {
161
+ type: 'select',
162
+ label: 'Size',
163
+ options: [
164
+ { label: 'Small', value: 'sm' },
165
+ { label: 'Medium', value: 'md' },
166
+ { label: 'Large', value: 'lg' },
167
+ ],
168
+ },
169
+ },
170
+ defaultProps: { size: 'md' },
171
+ render: ({ size }) => (
172
+ <div
173
+ className={`gl-layout-spacer gl-layout-spacer--${size || 'md'}`}
174
+ aria-hidden="true"
175
+ />
176
+ ),
177
+ },
178
+ },
179
+ root: {
180
+ // The page shell (nav, footer, prose width) belongs to the consumer;
181
+ // the layout root is deliberately chromeless.
182
+ render: ({ children }: { children?: ReactNode }) => <>{children}</>,
183
+ },
184
+ }
185
+ }
@@ -0,0 +1,23 @@
1
+ // src/puck/index.ts
2
+ // The `@growth-labs/cms/puck` subpath: the page-builder component library
3
+ // (Puck Config factory + server-safe React renders), the derived-markdown
4
+ // projection, and the layout storage envelope contract. Consumers pair this
5
+ // with @puckeditor/core (optional peer): the admin editor mounts <Puck>, the
6
+ // public site renders with <Render> from @puckeditor/core/rsc.
7
+
8
+ export {
9
+ LAYOUT_VERSION,
10
+ type LayoutComponent,
11
+ type LayoutData,
12
+ type LayoutEnvelope,
13
+ layoutEnvelopeSchema,
14
+ parseLayoutEnvelope,
15
+ wrapLayout,
16
+ } from '../schema/layout.js'
17
+ export {
18
+ createPuckConfig,
19
+ LAYOUT_COMPONENT_TYPES,
20
+ type LayoutComponentType,
21
+ type PuckConfigOptions,
22
+ } from './config.js'
23
+ export { layoutJsonToMarkdown, layoutToMarkdown } from './markdown.js'
@@ -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
@@ -633,7 +633,13 @@ function parseInline(text: string): JSONContent[] {
633
633
  }
634
634
 
635
635
  // ── bold (**text** or __text__) ────────────────────────────────────
636
- const boldMatch = /^\*\*([^*]+)\*\*/.exec(remaining) || /^__([^_]+)__/.exec(remaining)
636
+ const boldStarMatch = /^\*\*([^*]+)\*\*/.exec(remaining)
637
+ const boldUnderscoreMatch = boldStarMatch ? null : /^__([^_]+)__/.exec(remaining)
638
+ const boldMatch =
639
+ boldStarMatch ??
640
+ (boldUnderscoreMatch && !isIntrawordUnderscore(text, remaining, boldUnderscoreMatch)
641
+ ? boldUnderscoreMatch
642
+ : null)
637
643
  if (boldMatch) {
638
644
  const inner = parseInlineWithMark(boldMatch[1], { type: 'bold' })
639
645
  nodes.push(...inner)
@@ -656,7 +662,10 @@ function parseInline(text: string): JSONContent[] {
656
662
  // underline mark, which round-tripped underline↔italic inconsistently
657
663
  // and was the visible half of the Word-paste formatting incidents.
658
664
  const underscoreEmphasisMatch = /^_([^_]+)_/.exec(remaining)
659
- if (underscoreEmphasisMatch) {
665
+ if (
666
+ underscoreEmphasisMatch &&
667
+ !isIntrawordUnderscore(text, remaining, underscoreEmphasisMatch)
668
+ ) {
660
669
  const inner = parseInlineWithMark(underscoreEmphasisMatch[1], { type: 'italic' })
661
670
  nodes.push(...inner)
662
671
  remaining = remaining.slice(underscoreEmphasisMatch[0].length)
@@ -692,6 +701,19 @@ function parseInline(text: string): JSONContent[] {
692
701
  return mergeTextNodes(nodes)
693
702
  }
694
703
 
704
+ /**
705
+ * CommonMark's intraword rule for `_`: an underscore flanked by alphanumerics
706
+ * (like `?gaa_at=eafs&gaa_n=…` in a bare URL, or `snake_case`) is literal
707
+ * text, never emphasis. Without this, URLs with query params get italic marks
708
+ * spliced through them and re-serialize corrupted.
709
+ */
710
+ function isIntrawordUnderscore(text: string, remaining: string, match: RegExpExecArray): boolean {
711
+ const consumed = text.length - remaining.length
712
+ const previousChar = consumed > 0 ? text[consumed - 1] : ''
713
+ const nextChar = remaining[match[0].length] ?? ''
714
+ return /[A-Za-z0-9]/.test(previousChar) || /[A-Za-z0-9]/.test(nextChar)
715
+ }
716
+
695
717
  function parseInlineWithMark(text: string, mark: InlineMark): JSONContent[] {
696
718
  const inner = parseInline(text)
697
719
  return inner.map((n) => ({