@growth-labs/cms 0.5.17 → 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 (67) hide show
  1. package/README.md +29 -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 +6 -0
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +16 -7
  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 +1 -0
  10. package/dist/engine/revisions.js.map +1 -1
  11. package/dist/routes/content.d.ts.map +1 -1
  12. package/dist/routes/content.js +19 -1
  13. package/dist/routes/content.js.map +1 -1
  14. package/dist/schema/index.d.ts +1 -0
  15. package/dist/schema/index.d.ts.map +1 -1
  16. package/dist/schema/index.js +1 -0
  17. package/dist/schema/index.js.map +1 -1
  18. package/dist/schema/migrations.d.ts.map +1 -1
  19. package/dist/schema/migrations.js +7 -0
  20. package/dist/schema/migrations.js.map +1 -1
  21. package/dist/schema/portable-text.d.ts +52 -0
  22. package/dist/schema/portable-text.d.ts.map +1 -0
  23. package/dist/schema/portable-text.js +86 -0
  24. package/dist/schema/portable-text.js.map +1 -0
  25. package/dist/schema/types.d.ts +2 -0
  26. package/dist/schema/types.d.ts.map +1 -1
  27. package/dist/schema/types.js.map +1 -1
  28. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  29. package/dist/ui/editor/ContentForm.js +5 -3
  30. package/dist/ui/editor/ContentForm.js.map +1 -1
  31. package/dist/ui/editor/Rte.d.ts +12 -3
  32. package/dist/ui/editor/Rte.d.ts.map +1 -1
  33. package/dist/ui/editor/Rte.js +91 -10
  34. package/dist/ui/editor/Rte.js.map +1 -1
  35. package/dist/ui/editor/content-payload.d.ts +2 -0
  36. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  37. package/dist/ui/editor/content-payload.js +1 -0
  38. package/dist/ui/editor/content-payload.js.map +1 -1
  39. package/dist/ui/editor/extensions.d.ts +1 -1
  40. package/dist/ui/editor/extensions.d.ts.map +1 -1
  41. package/dist/ui/editor/extensions.js +4 -0
  42. package/dist/ui/editor/extensions.js.map +1 -1
  43. package/dist/ui/editor/portable-text.d.ts +7 -0
  44. package/dist/ui/editor/portable-text.d.ts.map +1 -0
  45. package/dist/ui/editor/portable-text.js +461 -0
  46. package/dist/ui/editor/portable-text.js.map +1 -0
  47. package/dist/ui/editor/serialize.d.ts.map +1 -1
  48. package/dist/ui/editor/serialize.js +99 -3
  49. package/dist/ui/editor/serialize.js.map +1 -1
  50. package/dist/ui/styles/broadsheet.css +11 -0
  51. package/migrations/0024_article_portable_text.sql +6 -0
  52. package/package.json +2 -1
  53. package/src/engine/published-content.ts +1 -1
  54. package/src/engine/publisher.ts +22 -5
  55. package/src/engine/revisions.ts +2 -0
  56. package/src/routes/content.ts +20 -1
  57. package/src/schema/index.ts +8 -0
  58. package/src/schema/migrations.ts +7 -0
  59. package/src/schema/portable-text.ts +110 -0
  60. package/src/schema/types.ts +2 -0
  61. package/src/ui/editor/ContentForm.tsx +8 -2
  62. package/src/ui/editor/Rte.tsx +120 -10
  63. package/src/ui/editor/content-payload.ts +3 -0
  64. package/src/ui/editor/extensions.ts +4 -0
  65. package/src/ui/editor/portable-text.ts +523 -0
  66. package/src/ui/editor/serialize.ts +107 -3
  67. package/src/ui/styles/broadsheet.css +11 -0
@@ -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
  }