@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.
- package/README.md +29 -0
- package/dist/engine/published-content.js +1 -1
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +6 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +16 -7
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +1 -0
- package/dist/engine/revisions.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +19 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- 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/portable-text.d.ts +52 -0
- package/dist/schema/portable-text.d.ts.map +1 -0
- package/dist/schema/portable-text.js +86 -0
- package/dist/schema/portable-text.js.map +1 -0
- 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/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +5 -3
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/Rte.d.ts +12 -3
- package/dist/ui/editor/Rte.d.ts.map +1 -1
- package/dist/ui/editor/Rte.js +91 -10
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +2 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +1 -0
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/extensions.d.ts.map +1 -1
- package/dist/ui/editor/extensions.js +4 -0
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/portable-text.d.ts +7 -0
- package/dist/ui/editor/portable-text.d.ts.map +1 -0
- package/dist/ui/editor/portable-text.js +461 -0
- package/dist/ui/editor/portable-text.js.map +1 -0
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +99 -3
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +11 -0
- package/migrations/0024_article_portable_text.sql +6 -0
- package/package.json +2 -1
- package/src/engine/published-content.ts +1 -1
- package/src/engine/publisher.ts +22 -5
- package/src/engine/revisions.ts +2 -0
- package/src/routes/content.ts +20 -1
- package/src/schema/index.ts +8 -0
- package/src/schema/migrations.ts +7 -0
- package/src/schema/portable-text.ts +110 -0
- package/src/schema/types.ts +2 -0
- package/src/ui/editor/ContentForm.tsx +8 -2
- package/src/ui/editor/Rte.tsx +120 -10
- package/src/ui/editor/content-payload.ts +3 -0
- package/src/ui/editor/extensions.ts +4 -0
- package/src/ui/editor/portable-text.ts +523 -0
- package/src/ui/editor/serialize.ts +107 -3
- package/src/ui/styles/broadsheet.css +11 -0
package/src/ui/editor/Rte.tsx
CHANGED
|
@@ -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)
|
|
9
|
-
//
|
|
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
|
-
/**
|
|
67
|
-
|
|
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:
|
|
166
|
+
content: docFromStored(initialValueRef.current, initialPortableTextRef.current),
|
|
113
167
|
editable: !readOnly,
|
|
114
168
|
onUpdate({ editor: e }) {
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|