@growth-labs/cms 0.5.16 → 0.5.18

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 (69) hide show
  1. package/README.md +29 -0
  2. package/dist/engine/published-content.d.ts +10 -0
  3. package/dist/engine/published-content.d.ts.map +1 -1
  4. package/dist/engine/published-content.js +48 -8
  5. package/dist/engine/published-content.js.map +1 -1
  6. package/dist/engine/publisher.d.ts +6 -0
  7. package/dist/engine/publisher.d.ts.map +1 -1
  8. package/dist/engine/publisher.js +16 -7
  9. package/dist/engine/publisher.js.map +1 -1
  10. package/dist/engine/revisions.d.ts.map +1 -1
  11. package/dist/engine/revisions.js +1 -0
  12. package/dist/engine/revisions.js.map +1 -1
  13. package/dist/routes/content.d.ts.map +1 -1
  14. package/dist/routes/content.js +19 -1
  15. package/dist/routes/content.js.map +1 -1
  16. package/dist/schema/index.d.ts +1 -0
  17. package/dist/schema/index.d.ts.map +1 -1
  18. package/dist/schema/index.js +1 -0
  19. package/dist/schema/index.js.map +1 -1
  20. package/dist/schema/migrations.d.ts.map +1 -1
  21. package/dist/schema/migrations.js +7 -0
  22. package/dist/schema/migrations.js.map +1 -1
  23. package/dist/schema/portable-text.d.ts +52 -0
  24. package/dist/schema/portable-text.d.ts.map +1 -0
  25. package/dist/schema/portable-text.js +86 -0
  26. package/dist/schema/portable-text.js.map +1 -0
  27. package/dist/schema/types.d.ts +2 -0
  28. package/dist/schema/types.d.ts.map +1 -1
  29. package/dist/schema/types.js.map +1 -1
  30. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  31. package/dist/ui/editor/ContentForm.js +5 -3
  32. package/dist/ui/editor/ContentForm.js.map +1 -1
  33. package/dist/ui/editor/Rte.d.ts +12 -3
  34. package/dist/ui/editor/Rte.d.ts.map +1 -1
  35. package/dist/ui/editor/Rte.js +91 -10
  36. package/dist/ui/editor/Rte.js.map +1 -1
  37. package/dist/ui/editor/content-payload.d.ts +2 -0
  38. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  39. package/dist/ui/editor/content-payload.js +1 -0
  40. package/dist/ui/editor/content-payload.js.map +1 -1
  41. package/dist/ui/editor/extensions.d.ts +1 -1
  42. package/dist/ui/editor/extensions.d.ts.map +1 -1
  43. package/dist/ui/editor/extensions.js +4 -0
  44. package/dist/ui/editor/extensions.js.map +1 -1
  45. package/dist/ui/editor/portable-text.d.ts +7 -0
  46. package/dist/ui/editor/portable-text.d.ts.map +1 -0
  47. package/dist/ui/editor/portable-text.js +461 -0
  48. package/dist/ui/editor/portable-text.js.map +1 -0
  49. package/dist/ui/editor/serialize.d.ts.map +1 -1
  50. package/dist/ui/editor/serialize.js +99 -3
  51. package/dist/ui/editor/serialize.js.map +1 -1
  52. package/dist/ui/styles/broadsheet.css +11 -0
  53. package/migrations/0024_article_portable_text.sql +6 -0
  54. package/package.json +2 -1
  55. package/src/engine/published-content.ts +68 -8
  56. package/src/engine/publisher.ts +22 -5
  57. package/src/engine/revisions.ts +2 -0
  58. package/src/routes/content.ts +20 -1
  59. package/src/schema/index.ts +8 -0
  60. package/src/schema/migrations.ts +7 -0
  61. package/src/schema/portable-text.ts +110 -0
  62. package/src/schema/types.ts +2 -0
  63. package/src/ui/editor/ContentForm.tsx +8 -2
  64. package/src/ui/editor/Rte.tsx +120 -10
  65. package/src/ui/editor/content-payload.ts +3 -0
  66. package/src/ui/editor/extensions.ts +4 -0
  67. package/src/ui/editor/portable-text.ts +523 -0
  68. package/src/ui/editor/serialize.ts +107 -3
  69. package/src/ui/styles/broadsheet.css +11 -0
@@ -0,0 +1,110 @@
1
+ // src/schema/portable-text.ts
2
+ // The Portable Text durable-store contract: envelope shape, schema version,
3
+ // and the boundary validator. The envelope — `{ version, content }` — is what
4
+ // `article_content.body_portable_text` holds as JSON text. Consumers (routes,
5
+ // site renderers) parse through here so malformed payloads fail loudly at the
6
+ // boundary instead of corrupting a render.
7
+ //
8
+ // Version 1 vocabulary (custom `_type`s follow the @portabletext/markdown
9
+ // canonical shapes so ecosystem tooling understands them):
10
+ // block standard Portable Text block (style, children, markDefs,
11
+ // listItem, level; decorators strong/em/code/strike-through;
12
+ // link annotations via markDefs)
13
+ // image { src, alt, title }
14
+ // code { language, code }
15
+ // tweetEmbed { url }
16
+ // horizontalRule {}
17
+ // table { headerRows, rows: [{ _type: 'row', cells: [{ _type:
18
+ // 'cell', value: block[] }] }] } — cells hold full Portable
19
+ // Text arrays so in-cell links are ordinary annotations.
20
+ // Cell extension fields: header, colspan, rowspan, colwidth,
21
+ // align.
22
+
23
+ import { z } from 'zod'
24
+
25
+ export const PORTABLE_TEXT_VERSION = 1
26
+
27
+ /** Any Portable Text object: a typed object, optionally keyed. */
28
+ export interface PortableTextObject {
29
+ _type: string
30
+ _key?: string
31
+ [key: string]: unknown
32
+ }
33
+
34
+ export interface PortableTextEnvelope {
35
+ version: typeof PORTABLE_TEXT_VERSION
36
+ content: PortableTextObject[]
37
+ }
38
+
39
+ const typedObjectSchema = z
40
+ .object({
41
+ _type: z.string().min(1),
42
+ _key: z.string().optional(),
43
+ })
44
+ .passthrough()
45
+ .superRefine((obj, ctx) => {
46
+ const record = obj as Record<string, unknown>
47
+ const children = record.children
48
+ if (children !== undefined) {
49
+ if (
50
+ !Array.isArray(children) ||
51
+ children.some(
52
+ (child) =>
53
+ child === null ||
54
+ typeof child !== 'object' ||
55
+ typeof (child as Record<string, unknown>)._type !== 'string',
56
+ )
57
+ ) {
58
+ ctx.addIssue({
59
+ code: z.ZodIssueCode.custom,
60
+ message: 'children must be an array of _type-bearing objects',
61
+ })
62
+ }
63
+ }
64
+ const markDefs = record.markDefs
65
+ if (markDefs !== undefined) {
66
+ if (
67
+ !Array.isArray(markDefs) ||
68
+ markDefs.some(
69
+ (def) =>
70
+ def === null ||
71
+ typeof def !== 'object' ||
72
+ typeof (def as Record<string, unknown>)._type !== 'string' ||
73
+ typeof (def as Record<string, unknown>)._key !== 'string',
74
+ )
75
+ ) {
76
+ ctx.addIssue({
77
+ code: z.ZodIssueCode.custom,
78
+ message: 'markDefs must be an array of objects with _type and _key',
79
+ })
80
+ }
81
+ }
82
+ })
83
+
84
+ export const portableTextEnvelopeSchema = z.object({
85
+ version: z.literal(PORTABLE_TEXT_VERSION),
86
+ content: z.array(typedObjectSchema),
87
+ })
88
+
89
+ /** Wrap serialized blocks in the versioned storage envelope. */
90
+ export function wrapPortableText(content: PortableTextObject[]): PortableTextEnvelope {
91
+ return { version: PORTABLE_TEXT_VERSION, content }
92
+ }
93
+
94
+ /**
95
+ * Parse the stored JSON text into a validated envelope. Returns null for
96
+ * anything malformed — absent value, bad JSON, wrong shape, unknown version —
97
+ * so callers fall back to the markdown path instead of rendering garbage.
98
+ */
99
+ export function parsePortableTextEnvelope(raw: unknown): PortableTextEnvelope | null {
100
+ if (typeof raw !== 'string' || raw === '') return null
101
+ let decoded: unknown
102
+ try {
103
+ decoded = JSON.parse(raw)
104
+ } catch {
105
+ return null
106
+ }
107
+ const parsed = portableTextEnvelopeSchema.safeParse(decoded)
108
+ if (!parsed.success) return null
109
+ return parsed.data as PortableTextEnvelope
110
+ }
@@ -91,6 +91,8 @@ export interface ArticleContentRow {
91
91
  content_id: string
92
92
  body_markdown: string
93
93
  body_html: string | null
94
+ /** Portable Text envelope JSON (migration 0024); NULL → render markdown. */
95
+ body_portable_text: string | null
94
96
  word_count: number | null
95
97
  read_time_minutes: number | null
96
98
  subtitle: string | null
@@ -109,6 +109,7 @@ interface ExistingContentItem {
109
109
  interface ExistingContentPayload {
110
110
  body_markdown?: string | null
111
111
  body_html?: string | null
112
+ body_portable_text?: string | null
112
113
  subtitle?: string | null
113
114
  script?: string | null
114
115
  video_id?: string | null
@@ -131,6 +132,8 @@ interface FormState {
131
132
  title: string
132
133
  dek: string
133
134
  body: string
135
+ /** Serialized Portable Text envelope paired with `body`; null until the editor emits one. */
136
+ bodyPortableText: string | null
134
137
  slug: string
135
138
  byline: string | null
136
139
  authorId: string | null
@@ -176,6 +179,7 @@ function createEmptyFormState(docId: string | null): FormState {
176
179
  title: '',
177
180
  dek: '',
178
181
  body: '',
182
+ bodyPortableText: null,
179
183
  slug: '',
180
184
  byline: null,
181
185
  authorId: null,
@@ -508,8 +512,8 @@ export function ContentForm({
508
512
  scheduleAutosave(next)
509
513
  }
510
514
 
511
- function handleBodyChange(md: string) {
512
- const next = { ...latestForm.current, body: md }
515
+ function handleBodyChange(md: string, portableText: string) {
516
+ const next = { ...latestForm.current, body: md, bodyPortableText: portableText }
513
517
  setForm(next)
514
518
  publishDraft(next)
515
519
  scheduleAutosave(next)
@@ -940,6 +944,7 @@ export function ContentForm({
940
944
  )}
941
945
  <Rte
942
946
  value={form.body}
947
+ portableText={form.bodyPortableText}
943
948
  onChange={handleBodyChange}
944
949
  onImageUpload={handleEditorImageUpload}
945
950
  />
@@ -1365,6 +1370,7 @@ function contentResponseToFormState(
1365
1370
 
1366
1371
  if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
1367
1372
  next.body = toStringValue(content.body_markdown) || toStringValue(content.body_html)
1373
+ next.bodyPortableText = toNullableString(content.body_portable_text)
1368
1374
  }
1369
1375
 
1370
1376
  if (contentType === 'video') {
@@ -5,20 +5,42 @@
5
5
  // so the module can be imported in Node/SSR contexts without throwing.
6
6
  //
7
7
  // Serialization: the internal state is a Tiptap JSON doc; onChange emits
8
- // docToMarkdown(json) so callers work with markdown (matching the P0 engine
9
- // body_markdown storage format).
8
+ // docToMarkdown(json) the derived body_markdown AND the serialized
9
+ // Portable Text envelope (the durable store, schema/portable-text.ts). On
10
+ // load, a valid stored envelope takes precedence over markdown so structure
11
+ // that markdown cannot express (cell spans, alignment) survives an edit
12
+ // session; rows without Portable Text fall back to markdownToDoc.
10
13
  //
11
14
  // The toolbar maps RTE_TOOLS to editor.chain() toggle commands.
12
15
  //
13
16
  // Compile-gated: all Tiptap/React usage verified by `pnpm run build` (tsc).
14
17
  // Runtime/visual verification is done via the Playwright smoke (SMOKE.md P2 section).
15
18
 
16
- import { Editor } from '@tiptap/core'
19
+ import { Editor, type JSONContent } from '@tiptap/core'
17
20
  import { useEffect, useRef, useState } from 'react'
21
+ import { parsePortableTextEnvelope, wrapPortableText } from '../../schema/portable-text.js'
18
22
  import { Icon } from '../icons.js'
19
23
  import { createRteExtensions } from './extensions.js'
24
+ import { docToPortableText, portableTextToDoc } from './portable-text.js'
20
25
  import { docToMarkdown, markdownToDoc } from './serialize.js'
21
26
 
27
+ /** Editor doc for a stored (markdown, Portable Text) pair: envelope wins. */
28
+ function docFromStored(md: string, portableText: string | null | undefined): JSONContent {
29
+ const envelope = parsePortableTextEnvelope(portableText ?? null)
30
+ if (envelope) {
31
+ try {
32
+ return portableTextToDoc(envelope.content)
33
+ } catch {
34
+ // Unknown _type (content newer than this editor): fall back to markdown.
35
+ }
36
+ }
37
+ return markdownToDoc(md)
38
+ }
39
+
40
+ function serializeDocPortableText(doc: JSONContent): string {
41
+ return JSON.stringify(wrapPortableText(docToPortableText(doc)))
42
+ }
43
+
22
44
  // ---------------------------------------------------------------------------
23
45
  // Toolbar config
24
46
  // ---------------------------------------------------------------------------
@@ -35,6 +57,13 @@ type ToolCmd =
35
57
  | 'ordered'
36
58
  | 'link'
37
59
  | 'image'
60
+ | 'table'
61
+ | 'tableAddRow'
62
+ | 'tableAddCol'
63
+ | 'tableHeaderRow'
64
+ | 'tableDelRow'
65
+ | 'tableDelCol'
66
+ | 'tableDelete'
38
67
 
39
68
  interface ToolDef {
40
69
  cmd: ToolCmd
@@ -54,6 +83,17 @@ const RTE_TOOLS: ToolDef[] = [
54
83
  { cmd: 'ordered', label: 'Ordered list', icon: 'rows' },
55
84
  { cmd: 'link', label: 'Link', icon: 'link' },
56
85
  { cmd: 'image', label: 'Image', icon: 'image' },
86
+ { cmd: 'table', label: 'Insert table', icon: 'grid' },
87
+ ]
88
+
89
+ /** Shown only while the selection sits inside a table. */
90
+ const TABLE_TOOLS: ToolDef[] = [
91
+ { cmd: 'tableAddRow', label: 'Add row below', icon: 'rows' },
92
+ { cmd: 'tableAddCol', label: 'Add column right', icon: 'columns' },
93
+ { cmd: 'tableHeaderRow', label: 'Toggle header row', icon: 'layers' },
94
+ { cmd: 'tableDelRow', label: 'Delete row', icon: 'x' },
95
+ { cmd: 'tableDelCol', label: 'Delete column', icon: 'x' },
96
+ { cmd: 'tableDelete', label: 'Delete table', icon: 'trash' },
57
97
  ]
58
98
 
59
99
  // ---------------------------------------------------------------------------
@@ -63,8 +103,17 @@ const RTE_TOOLS: ToolDef[] = [
63
103
  export interface RteProps {
64
104
  /** Current markdown value (controlled). */
65
105
  value: string
66
- /** Called with the updated markdown on every doc change. */
67
- onChange: (md: string) => void
106
+ /**
107
+ * Stored Portable Text envelope JSON for the same body, if any. When it
108
+ * parses, it takes precedence over `value` for (re)loading editor content.
109
+ */
110
+ portableText?: string | null
111
+ /**
112
+ * Called with the updated markdown AND the serialized Portable Text
113
+ * envelope on every doc change. The two are always derived from the same
114
+ * doc — persist them together.
115
+ */
116
+ onChange: (md: string, portableText: string) => void
68
117
  /** Optional editor image uploader. When present, the image tool opens a file picker. */
69
118
  onImageUpload?: (file: File) => Promise<{ url: string }>
70
119
  /** Optional placeholder text. */
@@ -79,6 +128,7 @@ export interface RteProps {
79
128
 
80
129
  export function Rte({
81
130
  value,
131
+ portableText = null,
82
132
  onChange,
83
133
  onImageUpload,
84
134
  placeholder: _placeholder,
@@ -96,7 +146,11 @@ export function Rte({
96
146
  // clobbering the cursor. The editor is mounted once; callbacks stay fresh
97
147
  // via the ref.
98
148
  const initialValueRef = useRef(value)
149
+ const initialPortableTextRef = useRef(portableText)
99
150
  const lastAppliedValueRef = useRef(value)
151
+ const lastAppliedPortableTextRef = useRef(portableText ?? '')
152
+ const portableTextRef = useRef(portableText)
153
+ portableTextRef.current = portableText
100
154
  const onChangeRef = useRef(onChange)
101
155
  onChangeRef.current = onChange
102
156
 
@@ -109,13 +163,20 @@ export function Rte({
109
163
  const editor = new Editor({
110
164
  element: el,
111
165
  extensions: createRteExtensions(),
112
- content: markdownToDoc(initialValueRef.current),
166
+ content: docFromStored(initialValueRef.current, initialPortableTextRef.current),
113
167
  editable: !readOnly,
114
168
  onUpdate({ editor: e }) {
115
- const md = docToMarkdown(e.getJSON())
116
- if (md === lastAppliedValueRef.current) return
169
+ const json = e.getJSON()
170
+ const md = docToMarkdown(json)
171
+ const pt = serializeDocPortableText(json)
172
+ // Markdown alone is not enough to detect a change: cell spans and
173
+ // alignment have no markdown form, so compare both serializations.
174
+ if (md === lastAppliedValueRef.current && pt === lastAppliedPortableTextRef.current) {
175
+ return
176
+ }
117
177
  lastAppliedValueRef.current = md
118
- onChangeRef.current(md)
178
+ lastAppliedPortableTextRef.current = pt
179
+ onChangeRef.current(md, pt)
119
180
  },
120
181
  onSelectionUpdate() {
121
182
  // Re-render to reflect active state changes in the toolbar
@@ -139,7 +200,17 @@ export function Rte({
139
200
  if (!editor) return
140
201
  if (value === lastAppliedValueRef.current) return
141
202
  lastAppliedValueRef.current = value
142
- editor.commands.setContent(markdownToDoc(value))
203
+ // Trust the Portable Text prop only when it is NEW (differs from what
204
+ // the editor last emitted or applied). A markdown-only external update —
205
+ // e.g. an AI writeback — means the stored envelope is stale for this
206
+ // value, so parse the markdown instead of resurrecting old content.
207
+ const incomingPortableText = portableTextRef.current ?? ''
208
+ const freshPortableText =
209
+ incomingPortableText !== '' && incomingPortableText !== lastAppliedPortableTextRef.current
210
+ ? incomingPortableText
211
+ : null
212
+ if (freshPortableText !== null) lastAppliedPortableTextRef.current = freshPortableText
213
+ editor.commands.setContent(docFromStored(value, freshPortableText))
143
214
  }, [value])
144
215
 
145
216
  const editor = editorRef.current
@@ -179,6 +250,14 @@ export function Rte({
179
250
  disabled={tool.cmd === 'image' && uploadingImage}
180
251
  />
181
252
  ))}
253
+ {editor?.isActive('table') && (
254
+ <>
255
+ <span style={toolbarDivider} aria-hidden="true" />
256
+ {TABLE_TOOLS.map((tool) => (
257
+ <ToolButton key={tool.cmd} tool={tool} editor={editor} />
258
+ ))}
259
+ </>
260
+ )}
182
261
  {onImageUpload && (
183
262
  <input
184
263
  ref={imageInputRef}
@@ -265,6 +344,27 @@ function ToolButton({
265
344
  }
266
345
  break
267
346
  }
347
+ case 'table':
348
+ chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
349
+ break
350
+ case 'tableAddRow':
351
+ chain.addRowAfter().run()
352
+ break
353
+ case 'tableAddCol':
354
+ chain.addColumnAfter().run()
355
+ break
356
+ case 'tableHeaderRow':
357
+ chain.toggleHeaderRow().run()
358
+ break
359
+ case 'tableDelRow':
360
+ chain.deleteRow().run()
361
+ break
362
+ case 'tableDelCol':
363
+ chain.deleteColumn().run()
364
+ break
365
+ case 'tableDelete':
366
+ chain.deleteTable().run()
367
+ break
268
368
  }
269
369
  }
270
370
 
@@ -289,6 +389,8 @@ function ToolButton({
289
389
  return editor.isActive('bulletList')
290
390
  case 'ordered':
291
391
  return editor.isActive('orderedList')
392
+ case 'table':
393
+ return editor.isActive('table')
292
394
  default:
293
395
  return false
294
396
  }
@@ -342,6 +444,14 @@ const rteWrap: React.CSSProperties = {
342
444
  minHeight: 240,
343
445
  }
344
446
 
447
+ const toolbarDivider: React.CSSProperties = {
448
+ width: 1,
449
+ height: 18,
450
+ margin: '0 4px',
451
+ alignSelf: 'center',
452
+ background: 'var(--border)',
453
+ }
454
+
345
455
  const toolbarStyle: React.CSSProperties = {
346
456
  display: 'flex',
347
457
  flexWrap: 'wrap',
@@ -5,6 +5,8 @@ export interface ContentDraftFields {
5
5
  title: string
6
6
  dek: string
7
7
  body: string
8
+ /** Serialized Portable Text envelope for the body; null until the editor emits one. */
9
+ bodyPortableText?: string | null
8
10
  videoUrl: string
9
11
  videoSourceKind?: string | null
10
12
  videoId?: string | null
@@ -104,6 +106,7 @@ export function buildContentUpdatePayload(
104
106
  const wordCount = countWords(draft.body)
105
107
  payload.content = {
106
108
  bodyMarkdown: draft.body,
109
+ bodyPortableText: draft.bodyPortableText || null,
107
110
  wordCount,
108
111
  readTimeMinutes: estimateReadTime(wordCount),
109
112
  }
@@ -1,5 +1,6 @@
1
1
  import Image from '@tiptap/extension-image'
2
2
  import Link from '@tiptap/extension-link'
3
+ import { TableKit } from '@tiptap/extension-table'
3
4
  import StarterKit from '@tiptap/starter-kit'
4
5
  import { TrimBoundaryMarks } from './trim-boundary-marks.js'
5
6
  import { TweetEmbed } from './tweet-embed.js'
@@ -19,5 +20,8 @@ export function createRteExtensions() {
19
20
  TrimBoundaryMarks,
20
21
  Image,
21
22
  TweetEmbed,
23
+ // One consolidated package registers table/tableRow/tableHeader/tableCell.
24
+ // Gapcursor (via StarterKit) lets the caret sit before/after a table.
25
+ TableKit.configure({ table: { resizable: true } }),
22
26
  ]
23
27
  }