@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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// src/ui/editor/portable-text.ts
|
|
2
|
+
// PURE Tiptap doc↔Portable Text serializer pair.
|
|
3
|
+
// Operates exclusively on the ProseMirror JSONContent shape — no Editor,
|
|
4
|
+
// no EditorView, no DOM, fully testable in vitest (Node env).
|
|
5
|
+
//
|
|
6
|
+
// Portable Text is the durable store (schema/portable-text.ts documents the
|
|
7
|
+
// version 1 vocabulary); markdown stays a derived view produced by
|
|
8
|
+
// serialize.ts from the same Tiptap doc. Both serializers here are total over
|
|
9
|
+
// the editor's node set — an unknown node degrades to its text content on the
|
|
10
|
+
// way out, and an unknown `_type` throws on the way in so a vocabulary drift
|
|
11
|
+
// fails loudly instead of silently dropping content.
|
|
12
|
+
//
|
|
13
|
+
// Mark mapping (Tiptap ↔ Portable Text):
|
|
14
|
+
// bold ↔ strong, italic ↔ em, strike ↔ strike-through, code ↔ code,
|
|
15
|
+
// link ↔ markDef annotation { _type: 'link', href, title?, target? }
|
|
16
|
+
// Lists are stored FLAT (listItem + level per block) per the Portable Text
|
|
17
|
+
// convention; nesting is reconstructed on load. Hard breaks are stored as
|
|
18
|
+
// newlines inside span text.
|
|
19
|
+
|
|
20
|
+
import type { JSONContent } from '@tiptap/core'
|
|
21
|
+
import type { PortableTextObject } from '../../schema/portable-text.js'
|
|
22
|
+
import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
|
|
23
|
+
|
|
24
|
+
type KeyGenerator = () => string
|
|
25
|
+
|
|
26
|
+
/** Deterministic 12-hex key sequence: identical docs serialize identically. */
|
|
27
|
+
function createKeyGenerator(): KeyGenerator {
|
|
28
|
+
let n = 0
|
|
29
|
+
return () => {
|
|
30
|
+
n += 1
|
|
31
|
+
return n.toString(16).padStart(12, '0')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// docToPortableText — JSONContent → PortableTextObject[]
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const DECORATOR_BY_TIPTAP_MARK: Record<string, string> = {
|
|
40
|
+
bold: 'strong',
|
|
41
|
+
italic: 'em',
|
|
42
|
+
strike: 'strike-through',
|
|
43
|
+
code: 'code',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MarkDef {
|
|
47
|
+
_key: string
|
|
48
|
+
_type: string
|
|
49
|
+
[key: string]: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Serialise a Tiptap JSONContent document to a Portable Text array. */
|
|
53
|
+
export function docToPortableText(doc: JSONContent): PortableTextObject[] {
|
|
54
|
+
const normalized = normalizeBoundaryMarks(doc)
|
|
55
|
+
const nextKey = createKeyGenerator()
|
|
56
|
+
return serializeBlocks(normalized.content ?? [], nextKey)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serializeBlocks(nodes: JSONContent[], nextKey: KeyGenerator): PortableTextObject[] {
|
|
60
|
+
const out: PortableTextObject[] = []
|
|
61
|
+
for (const node of nodes) serializeBlockNode(node, out, nextKey, null)
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ListContext {
|
|
66
|
+
kind: 'bullet' | 'number'
|
|
67
|
+
level: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function serializeBlockNode(
|
|
71
|
+
node: JSONContent,
|
|
72
|
+
out: PortableTextObject[],
|
|
73
|
+
nextKey: KeyGenerator,
|
|
74
|
+
list: ListContext | null,
|
|
75
|
+
): void {
|
|
76
|
+
switch (node.type ?? 'paragraph') {
|
|
77
|
+
case 'paragraph':
|
|
78
|
+
out.push(buildTextBlock('normal', node.content ?? [], nextKey, list))
|
|
79
|
+
return
|
|
80
|
+
case 'heading': {
|
|
81
|
+
const level = (node.attrs?.level as number) ?? 2
|
|
82
|
+
out.push(buildTextBlock(`h${level}`, node.content ?? [], nextKey, list))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
case 'blockquote': {
|
|
86
|
+
for (const child of node.content ?? []) {
|
|
87
|
+
if (child.type === 'paragraph') {
|
|
88
|
+
out.push(buildTextBlock('blockquote', child.content ?? [], nextKey, list))
|
|
89
|
+
} else {
|
|
90
|
+
serializeBlockNode(child, out, nextKey, list)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
case 'bulletList':
|
|
96
|
+
serializeList(node, out, nextKey, 'bullet', list ? list.level + 1 : 1)
|
|
97
|
+
return
|
|
98
|
+
case 'orderedList':
|
|
99
|
+
serializeList(node, out, nextKey, 'number', list ? list.level + 1 : 1)
|
|
100
|
+
return
|
|
101
|
+
case 'codeBlock': {
|
|
102
|
+
const code = (node.content ?? []).map((child) => child.text ?? '').join('')
|
|
103
|
+
out.push({
|
|
104
|
+
_type: 'code',
|
|
105
|
+
_key: nextKey(),
|
|
106
|
+
language: (node.attrs?.language as string | null) ?? null,
|
|
107
|
+
code,
|
|
108
|
+
})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
case 'image':
|
|
112
|
+
out.push(imageObject(node, nextKey))
|
|
113
|
+
return
|
|
114
|
+
case 'tweetEmbed':
|
|
115
|
+
out.push({
|
|
116
|
+
_type: 'tweetEmbed',
|
|
117
|
+
_key: nextKey(),
|
|
118
|
+
url: (node.attrs?.url as string) ?? '',
|
|
119
|
+
})
|
|
120
|
+
return
|
|
121
|
+
case 'horizontalRule':
|
|
122
|
+
out.push({ _type: 'horizontalRule', _key: nextKey() })
|
|
123
|
+
return
|
|
124
|
+
case 'table':
|
|
125
|
+
out.push(serializeTable(node, nextKey))
|
|
126
|
+
return
|
|
127
|
+
default: {
|
|
128
|
+
// Unknown block: keep its text content rather than dropping it.
|
|
129
|
+
const inline = node.content ?? []
|
|
130
|
+
if (inline.length > 0) out.push(buildTextBlock('normal', inline, nextKey, list))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function serializeList(
|
|
136
|
+
node: JSONContent,
|
|
137
|
+
out: PortableTextObject[],
|
|
138
|
+
nextKey: KeyGenerator,
|
|
139
|
+
kind: 'bullet' | 'number',
|
|
140
|
+
level: number,
|
|
141
|
+
): void {
|
|
142
|
+
for (const item of node.content ?? []) {
|
|
143
|
+
for (const child of item.content ?? []) {
|
|
144
|
+
if (child.type === 'paragraph') {
|
|
145
|
+
out.push(buildTextBlock('normal', child.content ?? [], nextKey, { kind, level }))
|
|
146
|
+
} else if (child.type === 'bulletList') {
|
|
147
|
+
serializeList(child, out, nextKey, 'bullet', level + 1)
|
|
148
|
+
} else if (child.type === 'orderedList') {
|
|
149
|
+
serializeList(child, out, nextKey, 'number', level + 1)
|
|
150
|
+
} else {
|
|
151
|
+
serializeBlockNode(child, out, nextKey, { kind, level })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function imageObject(node: JSONContent, nextKey: KeyGenerator): PortableTextObject {
|
|
158
|
+
return {
|
|
159
|
+
_type: 'image',
|
|
160
|
+
_key: nextKey(),
|
|
161
|
+
src: (node.attrs?.src as string) ?? '',
|
|
162
|
+
alt: (node.attrs?.alt as string | null) ?? '',
|
|
163
|
+
title: (node.attrs?.title as string | null) ?? null,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildTextBlock(
|
|
168
|
+
style: string,
|
|
169
|
+
inline: JSONContent[],
|
|
170
|
+
nextKey: KeyGenerator,
|
|
171
|
+
list: ListContext | null,
|
|
172
|
+
): PortableTextObject {
|
|
173
|
+
const markDefs: MarkDef[] = []
|
|
174
|
+
type PendingSpan = { marks: string[]; text: string }
|
|
175
|
+
const pending: Array<PendingSpan | PortableTextObject> = []
|
|
176
|
+
|
|
177
|
+
const pushText = (text: string, marks: string[]) => {
|
|
178
|
+
const previous = pending[pending.length - 1]
|
|
179
|
+
if (
|
|
180
|
+
previous &&
|
|
181
|
+
!('_type' in previous) &&
|
|
182
|
+
JSON.stringify(previous.marks) === JSON.stringify(marks)
|
|
183
|
+
) {
|
|
184
|
+
previous.text += text
|
|
185
|
+
} else {
|
|
186
|
+
pending.push({ marks, text })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const node of inline) {
|
|
191
|
+
if (node.type === 'hardBreak') {
|
|
192
|
+
pushText('\n', [])
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (node.type === 'image') {
|
|
196
|
+
pending.push(imageObject(node, nextKey))
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const text = node.text ?? ''
|
|
200
|
+
if (text === '' && (node.marks ?? []).length === 0 && node.type !== 'text') continue
|
|
201
|
+
const marks: string[] = []
|
|
202
|
+
for (const mark of node.marks ?? []) {
|
|
203
|
+
const decorator = DECORATOR_BY_TIPTAP_MARK[mark.type ?? '']
|
|
204
|
+
if (decorator) {
|
|
205
|
+
marks.push(decorator)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (mark.type === 'link') {
|
|
209
|
+
marks.push(linkMarkDefKey(mark, markDefs, nextKey))
|
|
210
|
+
}
|
|
211
|
+
// Marks without a Portable Text form (legacy underline) are dropped,
|
|
212
|
+
// matching the markdown serializer.
|
|
213
|
+
}
|
|
214
|
+
pushText(text, marks)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const children: PortableTextObject[] = pending.map((entry) =>
|
|
218
|
+
'_type' in entry
|
|
219
|
+
? entry
|
|
220
|
+
: { _type: 'span', _key: nextKey(), text: entry.text, marks: entry.marks },
|
|
221
|
+
)
|
|
222
|
+
if (children.length === 0) {
|
|
223
|
+
children.push({ _type: 'span', _key: nextKey(), text: '', marks: [] })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const block: PortableTextObject = {
|
|
227
|
+
_type: 'block',
|
|
228
|
+
_key: nextKey(),
|
|
229
|
+
style,
|
|
230
|
+
markDefs,
|
|
231
|
+
children,
|
|
232
|
+
}
|
|
233
|
+
if (list) {
|
|
234
|
+
block.listItem = list.kind
|
|
235
|
+
block.level = list.level
|
|
236
|
+
}
|
|
237
|
+
return block
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function linkMarkDefKey(
|
|
241
|
+
mark: NonNullable<JSONContent['marks']>[number],
|
|
242
|
+
markDefs: MarkDef[],
|
|
243
|
+
nextKey: KeyGenerator,
|
|
244
|
+
): string {
|
|
245
|
+
const href = (mark.attrs?.href as string) ?? ''
|
|
246
|
+
const title = (mark.attrs?.title as string | null) ?? null
|
|
247
|
+
const target = (mark.attrs?.target as string | null) ?? null
|
|
248
|
+
const existing = markDefs.find(
|
|
249
|
+
(def) =>
|
|
250
|
+
def._type === 'link' &&
|
|
251
|
+
def.href === href &&
|
|
252
|
+
(def.title ?? null) === title &&
|
|
253
|
+
(def.target ?? null) === target,
|
|
254
|
+
)
|
|
255
|
+
if (existing) return existing._key
|
|
256
|
+
const def: MarkDef = { _key: nextKey(), _type: 'link', href }
|
|
257
|
+
if (title !== null) def.title = title
|
|
258
|
+
if (target !== null) def.target = target
|
|
259
|
+
markDefs.push(def)
|
|
260
|
+
return def._key
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function serializeTable(node: JSONContent, nextKey: KeyGenerator): PortableTextObject {
|
|
264
|
+
const rows = (node.content ?? []).filter((row) => row.type === 'tableRow')
|
|
265
|
+
let headerRows = 0
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
const cells = row.content ?? []
|
|
268
|
+
if (cells.length > 0 && cells.every((cell) => cell.type === 'tableHeader')) headerRows += 1
|
|
269
|
+
else break
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
_type: 'table',
|
|
273
|
+
_key: nextKey(),
|
|
274
|
+
headerRows,
|
|
275
|
+
rows: rows.map((row, rowIndex) => ({
|
|
276
|
+
_type: 'row',
|
|
277
|
+
_key: nextKey(),
|
|
278
|
+
cells: (row.content ?? []).map((cell) =>
|
|
279
|
+
serializeTableCell(cell, rowIndex, headerRows, nextKey),
|
|
280
|
+
),
|
|
281
|
+
})),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function serializeTableCell(
|
|
286
|
+
cell: JSONContent,
|
|
287
|
+
rowIndex: number,
|
|
288
|
+
headerRows: number,
|
|
289
|
+
nextKey: KeyGenerator,
|
|
290
|
+
): PortableTextObject {
|
|
291
|
+
const out: PortableTextObject = {
|
|
292
|
+
_type: 'cell',
|
|
293
|
+
_key: nextKey(),
|
|
294
|
+
value: serializeBlocks(cell.content ?? [], nextKey),
|
|
295
|
+
}
|
|
296
|
+
if (cell.type === 'tableHeader' && rowIndex >= headerRows) out.header = true
|
|
297
|
+
const colspan = (cell.attrs?.colspan as number) ?? 1
|
|
298
|
+
const rowspan = (cell.attrs?.rowspan as number) ?? 1
|
|
299
|
+
const colwidth = (cell.attrs?.colwidth as number[] | null) ?? null
|
|
300
|
+
const align = (cell.attrs?.align as string | null) ?? null
|
|
301
|
+
if (colspan !== 1) out.colspan = colspan
|
|
302
|
+
if (rowspan !== 1) out.rowspan = rowspan
|
|
303
|
+
if (colwidth !== null) out.colwidth = colwidth
|
|
304
|
+
if (align !== null) out.align = align
|
|
305
|
+
return out
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
// portableTextToDoc — PortableTextObject[] → JSONContent
|
|
310
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
const TIPTAP_MARK_BY_DECORATOR: Record<string, string> = {
|
|
313
|
+
strong: 'bold',
|
|
314
|
+
em: 'italic',
|
|
315
|
+
'strike-through': 'strike',
|
|
316
|
+
code: 'code',
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Parse a Portable Text array back into a Tiptap JSONContent document. */
|
|
320
|
+
export function portableTextToDoc(blocks: PortableTextObject[]): JSONContent {
|
|
321
|
+
return { type: 'doc', content: parseBlocks(blocks) }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function parseBlocks(blocks: PortableTextObject[]): JSONContent[] {
|
|
325
|
+
const out: JSONContent[] = []
|
|
326
|
+
let index = 0
|
|
327
|
+
while (index < blocks.length) {
|
|
328
|
+
const block = blocks[index]
|
|
329
|
+
if (block._type === 'block' && typeof block.listItem === 'string') {
|
|
330
|
+
const run: PortableTextObject[] = []
|
|
331
|
+
while (
|
|
332
|
+
index < blocks.length &&
|
|
333
|
+
blocks[index]._type === 'block' &&
|
|
334
|
+
typeof blocks[index].listItem === 'string'
|
|
335
|
+
) {
|
|
336
|
+
run.push(blocks[index])
|
|
337
|
+
index += 1
|
|
338
|
+
}
|
|
339
|
+
out.push(...buildListNodes(run))
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
if (block._type === 'block' && block.style === 'blockquote') {
|
|
343
|
+
const paragraphs: JSONContent[] = []
|
|
344
|
+
while (
|
|
345
|
+
index < blocks.length &&
|
|
346
|
+
blocks[index]._type === 'block' &&
|
|
347
|
+
blocks[index].style === 'blockquote' &&
|
|
348
|
+
typeof blocks[index].listItem !== 'string'
|
|
349
|
+
) {
|
|
350
|
+
paragraphs.push({ type: 'paragraph', content: parseInline(blocks[index]) })
|
|
351
|
+
index += 1
|
|
352
|
+
}
|
|
353
|
+
out.push({ type: 'blockquote', content: paragraphs })
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
out.push(parseObject(block))
|
|
357
|
+
index += 1
|
|
358
|
+
}
|
|
359
|
+
return out
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function parseObject(block: PortableTextObject): JSONContent {
|
|
363
|
+
switch (block._type) {
|
|
364
|
+
case 'block': {
|
|
365
|
+
const style = (block.style as string) ?? 'normal'
|
|
366
|
+
const heading = /^h([1-6])$/.exec(style)
|
|
367
|
+
if (heading) {
|
|
368
|
+
return {
|
|
369
|
+
type: 'heading',
|
|
370
|
+
attrs: { level: Number(heading[1]) },
|
|
371
|
+
content: parseInline(block),
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { type: 'paragraph', content: parseInline(block) }
|
|
375
|
+
}
|
|
376
|
+
case 'code': {
|
|
377
|
+
const code = (block.code as string) ?? ''
|
|
378
|
+
return {
|
|
379
|
+
type: 'codeBlock',
|
|
380
|
+
attrs: { language: (block.language as string | null) ?? null },
|
|
381
|
+
content: code ? [{ type: 'text', text: code }] : [],
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
case 'image':
|
|
385
|
+
return {
|
|
386
|
+
type: 'image',
|
|
387
|
+
attrs: {
|
|
388
|
+
src: (block.src as string) ?? '',
|
|
389
|
+
alt: (block.alt as string | null) ?? '',
|
|
390
|
+
title: (block.title as string | null) ?? null,
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
case 'tweetEmbed':
|
|
394
|
+
return { type: 'tweetEmbed', attrs: { url: (block.url as string) ?? '' } }
|
|
395
|
+
case 'horizontalRule':
|
|
396
|
+
return { type: 'horizontalRule' }
|
|
397
|
+
case 'table':
|
|
398
|
+
return parseTable(block)
|
|
399
|
+
default:
|
|
400
|
+
throw new Error(
|
|
401
|
+
`portableTextToDoc: unknown Portable Text _type "${block._type}" — the stored content is newer than this serializer`,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function buildListNodes(run: PortableTextObject[]): JSONContent[] {
|
|
407
|
+
const roots: JSONContent[] = []
|
|
408
|
+
type StackEntry = { list: JSONContent; kind: string; level: number }
|
|
409
|
+
const stack: StackEntry[] = []
|
|
410
|
+
|
|
411
|
+
for (const block of run) {
|
|
412
|
+
const kind = block.listItem === 'number' ? 'number' : 'bullet'
|
|
413
|
+
const level = typeof block.level === 'number' && block.level > 0 ? block.level : 1
|
|
414
|
+
const listType = kind === 'number' ? 'orderedList' : 'bulletList'
|
|
415
|
+
|
|
416
|
+
while (stack.length > 0 && stack[stack.length - 1].level > level) stack.pop()
|
|
417
|
+
let top = stack[stack.length - 1]
|
|
418
|
+
if (top && top.level === level && top.kind !== kind) {
|
|
419
|
+
stack.pop()
|
|
420
|
+
top = stack[stack.length - 1]
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!top || top.level < level) {
|
|
424
|
+
const list: JSONContent = { type: listType, content: [] }
|
|
425
|
+
if (top && top.level < level) {
|
|
426
|
+
const items = top.list.content ?? []
|
|
427
|
+
const lastItem = items[items.length - 1]
|
|
428
|
+
if (lastItem) {
|
|
429
|
+
lastItem.content = [...(lastItem.content ?? []), list]
|
|
430
|
+
} else {
|
|
431
|
+
roots.push(list)
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
roots.push(list)
|
|
435
|
+
}
|
|
436
|
+
stack.push({ list, kind, level })
|
|
437
|
+
top = stack[stack.length - 1]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
top.list.content = [
|
|
441
|
+
...(top.list.content ?? []),
|
|
442
|
+
{ type: 'listItem', content: [{ type: 'paragraph', content: parseInline(block) }] },
|
|
443
|
+
]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return roots
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function parseInline(block: PortableTextObject): JSONContent[] {
|
|
450
|
+
const markDefs = (block.markDefs as MarkDef[] | undefined) ?? []
|
|
451
|
+
const out: JSONContent[] = []
|
|
452
|
+
for (const child of (block.children as PortableTextObject[] | undefined) ?? []) {
|
|
453
|
+
if (child._type === 'span') {
|
|
454
|
+
out.push(...parseSpan(child, markDefs))
|
|
455
|
+
} else if (child._type === 'image') {
|
|
456
|
+
out.push(parseObject(child))
|
|
457
|
+
} else {
|
|
458
|
+
throw new Error(`portableTextToDoc: unknown inline Portable Text _type "${child._type}"`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return out
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function parseSpan(span: PortableTextObject, markDefs: MarkDef[]): JSONContent[] {
|
|
465
|
+
const marks: NonNullable<JSONContent['marks']> = []
|
|
466
|
+
for (const name of (span.marks as string[] | undefined) ?? []) {
|
|
467
|
+
const def = markDefs.find((candidate) => candidate._key === name)
|
|
468
|
+
if (def) {
|
|
469
|
+
if (def._type === 'link') {
|
|
470
|
+
marks.push({
|
|
471
|
+
type: 'link',
|
|
472
|
+
attrs: {
|
|
473
|
+
href: (def.href as string) ?? '',
|
|
474
|
+
title: (def.title as string | null) ?? null,
|
|
475
|
+
target: (def.target as string | null) ?? null,
|
|
476
|
+
},
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
// Unknown annotation types have no Tiptap node yet; drop the mark but
|
|
480
|
+
// keep the text.
|
|
481
|
+
continue
|
|
482
|
+
}
|
|
483
|
+
const tiptapMark = TIPTAP_MARK_BY_DECORATOR[name]
|
|
484
|
+
if (tiptapMark) marks.push({ type: tiptapMark })
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const text = (span.text as string) ?? ''
|
|
488
|
+
const segments = text.split('\n')
|
|
489
|
+
const out: JSONContent[] = []
|
|
490
|
+
segments.forEach((segment, index) => {
|
|
491
|
+
if (index > 0) out.push({ type: 'hardBreak' })
|
|
492
|
+
if (segment !== '') {
|
|
493
|
+
const node: JSONContent = { type: 'text', text: segment }
|
|
494
|
+
if (marks.length > 0) node.marks = marks.map((mark) => ({ ...mark }))
|
|
495
|
+
out.push(node)
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
return out
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function parseTable(block: PortableTextObject): JSONContent {
|
|
502
|
+
const headerRows = typeof block.headerRows === 'number' ? block.headerRows : 0
|
|
503
|
+
const rows = (block.rows as PortableTextObject[] | undefined) ?? []
|
|
504
|
+
return {
|
|
505
|
+
type: 'table',
|
|
506
|
+
content: rows.map((row, rowIndex) => ({
|
|
507
|
+
type: 'tableRow',
|
|
508
|
+
content: ((row.cells as PortableTextObject[] | undefined) ?? []).map((cell) => {
|
|
509
|
+
const isHeader = rowIndex < headerRows || cell.header === true
|
|
510
|
+
return {
|
|
511
|
+
type: isHeader ? 'tableHeader' : 'tableCell',
|
|
512
|
+
attrs: {
|
|
513
|
+
colspan: (cell.colspan as number) ?? 1,
|
|
514
|
+
rowspan: (cell.rowspan as number) ?? 1,
|
|
515
|
+
colwidth: (cell.colwidth as number[] | null) ?? null,
|
|
516
|
+
align: (cell.align as string | null) ?? null,
|
|
517
|
+
},
|
|
518
|
+
content: parseBlocks((cell.value as PortableTextObject[] | undefined) ?? []),
|
|
519
|
+
}
|
|
520
|
+
}),
|
|
521
|
+
})),
|
|
522
|
+
}
|
|
523
|
+
}
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// Operates exclusively on the ProseMirror JSONContent shape — no Editor,
|
|
4
4
|
// no EditorView, no DOM, fully testable in vitest (Node env).
|
|
5
5
|
//
|
|
6
|
-
// Supported nodes (StarterKit set + TweetEmbed):
|
|
6
|
+
// Supported nodes (StarterKit set + TweetEmbed + tables):
|
|
7
7
|
// block: doc, paragraph, heading (h1–h6), bulletList, orderedList,
|
|
8
|
-
// listItem, blockquote, codeBlock, image, tweetEmbed, hardBreak
|
|
8
|
+
// listItem, blockquote, codeBlock, image, tweetEmbed, hardBreak,
|
|
9
|
+
// table (tableRow / tableHeader / tableCell)
|
|
9
10
|
// inline marks: bold, italic, strike, code, link
|
|
10
11
|
// (underline is retired: no markdown form exists — `_text_` is emphasis)
|
|
11
12
|
//
|
|
@@ -18,6 +19,9 @@
|
|
|
18
19
|
//  image
|
|
19
20
|
// [text](href) link
|
|
20
21
|
// :::tweet\n<url>\n::: tweet-embed directive
|
|
22
|
+
// | GFM | pipe | tables | (header row + `| --- |` separator; `\|` escapes a
|
|
23
|
+
// literal pipe; colspan/rowspan/colwidth/align do NOT survive markdown —
|
|
24
|
+
// Portable Text is the durable store, markdown is the derived lossy view)
|
|
21
25
|
|
|
22
26
|
import type { JSONContent } from '@tiptap/core'
|
|
23
27
|
import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
|
|
@@ -94,6 +98,9 @@ function serializeBlock(node: JSONContent): string | null {
|
|
|
94
98
|
case 'horizontalRule': {
|
|
95
99
|
return '---'
|
|
96
100
|
}
|
|
101
|
+
case 'table': {
|
|
102
|
+
return serializeTableMarkdown(node)
|
|
103
|
+
}
|
|
97
104
|
default: {
|
|
98
105
|
// Fall back to serializing any text content
|
|
99
106
|
const fallback = serializeInlineContent(node.content ?? [])
|
|
@@ -140,6 +147,36 @@ function serializeListItem(item: JSONContent, bullet: string): string {
|
|
|
140
147
|
return parts.join('\n')
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
function serializeTableMarkdown(node: JSONContent): string {
|
|
151
|
+
const rows = (node.content ?? []).filter((row) => row.type === 'tableRow')
|
|
152
|
+
if (rows.length === 0) return ''
|
|
153
|
+
|
|
154
|
+
const cellText = (cell: JSONContent): string =>
|
|
155
|
+
(cell.content ?? [])
|
|
156
|
+
.map((child) => serializeInlineContent(child.content ?? []))
|
|
157
|
+
.join(' ')
|
|
158
|
+
.replace(/\n/g, ' ')
|
|
159
|
+
.replace(/\|/g, '\\|')
|
|
160
|
+
const rowLine = (row: JSONContent): string =>
|
|
161
|
+
`| ${(row.content ?? []).map(cellText).join(' | ')} |`
|
|
162
|
+
|
|
163
|
+
const firstCells = rows[0].content ?? []
|
|
164
|
+
const hasHeader = firstCells.length > 0 && firstCells.every((c) => c.type === 'tableHeader')
|
|
165
|
+
const columns = firstCells.length || 1
|
|
166
|
+
const separator = `| ${Array.from({ length: columns }, () => '---').join(' | ')} |`
|
|
167
|
+
|
|
168
|
+
const lines: string[] = []
|
|
169
|
+
if (hasHeader) {
|
|
170
|
+
lines.push(rowLine(rows[0]), separator)
|
|
171
|
+
for (const row of rows.slice(1)) lines.push(rowLine(row))
|
|
172
|
+
} else {
|
|
173
|
+
// GFM requires a header row; emit an empty one so body rows survive.
|
|
174
|
+
lines.push(`| ${Array.from({ length: columns }, () => '').join(' | ')} |`, separator)
|
|
175
|
+
for (const row of rows) lines.push(rowLine(row))
|
|
176
|
+
}
|
|
177
|
+
return lines.join('\n')
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
function serializeInlineContent(nodes: JSONContent[]): string {
|
|
144
181
|
return nodes.map((n) => serializeInlineNode(n)).join('')
|
|
145
182
|
}
|
|
@@ -281,6 +318,14 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
281
318
|
continue
|
|
282
319
|
}
|
|
283
320
|
|
|
321
|
+
// GFM pipe table (header row followed by a `| --- |` separator line)
|
|
322
|
+
if (isTableStart(lines, i)) {
|
|
323
|
+
const { node, next } = parseMarkdownTable(lines, i)
|
|
324
|
+
nodes.push(node)
|
|
325
|
+
i = next
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
284
329
|
// Bullet list
|
|
285
330
|
if (/^[-*+]\s/.test(line)) {
|
|
286
331
|
const { node, next } = parseBulletList(lines, i)
|
|
@@ -318,7 +363,8 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
318
363
|
!/^#{1,6}\s/.test(lines[i]) &&
|
|
319
364
|
!/^[-*+]\s/.test(lines[i]) &&
|
|
320
365
|
!/^\d+\.\s/.test(lines[i]) &&
|
|
321
|
-
!/^---+$/.test(lines[i].trim())
|
|
366
|
+
!/^---+$/.test(lines[i].trim()) &&
|
|
367
|
+
!isTableStart(lines, i)
|
|
322
368
|
) {
|
|
323
369
|
paraLines.push(lines[i])
|
|
324
370
|
i++
|
|
@@ -332,6 +378,64 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
332
378
|
return nodes
|
|
333
379
|
}
|
|
334
380
|
|
|
381
|
+
// ── Table parser ─────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
function isTableStart(lines: string[], index: number): boolean {
|
|
384
|
+
if (!/^\|/.test(lines[index] ?? '')) return false
|
|
385
|
+
return /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1] ?? '')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function splitTableRow(line: string): string[] {
|
|
389
|
+
let inner = line.trim()
|
|
390
|
+
if (inner.startsWith('|')) inner = inner.slice(1)
|
|
391
|
+
if (inner.endsWith('|')) inner = inner.slice(0, -1)
|
|
392
|
+
|
|
393
|
+
const cells: string[] = []
|
|
394
|
+
let current = ''
|
|
395
|
+
for (let i = 0; i < inner.length; i++) {
|
|
396
|
+
const ch = inner[i]
|
|
397
|
+
if (ch === '\\' && inner[i + 1] === '|') {
|
|
398
|
+
current += '\\|'
|
|
399
|
+
i++
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (ch === '|') {
|
|
403
|
+
cells.push(current)
|
|
404
|
+
current = ''
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
current += ch
|
|
408
|
+
}
|
|
409
|
+
cells.push(current)
|
|
410
|
+
return cells.map((cell) => cell.trim().replace(/\\\|/g, '|'))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseMarkdownTable(lines: string[], start: number): { node: JSONContent; next: number } {
|
|
414
|
+
const makeCell = (kind: 'tableHeader' | 'tableCell', text: string): JSONContent => ({
|
|
415
|
+
type: kind,
|
|
416
|
+
attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
|
|
417
|
+
content: [{ type: 'paragraph', content: parseInline(text) }],
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const rows: JSONContent[] = [
|
|
421
|
+
{
|
|
422
|
+
type: 'tableRow',
|
|
423
|
+
content: splitTableRow(lines[start]).map((text) => makeCell('tableHeader', text)),
|
|
424
|
+
},
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
let i = start + 2 // skip the header row and the separator line
|
|
428
|
+
while (i < lines.length && /^\|/.test(lines[i])) {
|
|
429
|
+
rows.push({
|
|
430
|
+
type: 'tableRow',
|
|
431
|
+
content: splitTableRow(lines[i]).map((text) => makeCell('tableCell', text)),
|
|
432
|
+
})
|
|
433
|
+
i++
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { node: { type: 'table', content: rows }, next: i }
|
|
437
|
+
}
|
|
438
|
+
|
|
335
439
|
// ── List parsers ─────────────────────────────────────────────────────────────
|
|
336
440
|
|
|
337
441
|
function parseBulletList(lines: string[], start: number): { node: JSONContent; next: number } {
|
|
@@ -392,3 +392,14 @@ input, textarea, select { font-family: inherit; }
|
|
|
392
392
|
/* tooltip dots used in calendar / analytics */
|
|
393
393
|
.spark { display: flex; align-items: flex-end; gap: 3px; height: 34px; }
|
|
394
394
|
.spark span { width: 6px; border-radius: 2px; background: var(--accent); opacity: .85; }
|
|
395
|
+
|
|
396
|
+
/* ── Rich-text editor tables (TableKit) ──────────────────────────────────
|
|
397
|
+
The extension ships no CSS: without these rules the cell-selection overlay
|
|
398
|
+
and the column-resize handle are functional but invisible. */
|
|
399
|
+
.ProseMirror table { border-collapse: collapse; margin: 1em 0; table-layout: fixed; width: 100%; overflow: hidden; }
|
|
400
|
+
.ProseMirror table td, .ProseMirror table th { border: 1px solid var(--border); box-sizing: border-box; min-width: 1em; padding: 6px 8px; position: relative; vertical-align: top; }
|
|
401
|
+
.ProseMirror table th { background: var(--panel-2); font-weight: 600; text-align: left; }
|
|
402
|
+
.ProseMirror table .selectedCell:after { background: var(--accent-tint); content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 2; }
|
|
403
|
+
.ProseMirror table .column-resize-handle { background: var(--accent); bottom: -2px; pointer-events: none; position: absolute; right: -2px; top: 0; width: 4px; }
|
|
404
|
+
.ProseMirror .tableWrapper { margin: 1.5em 0; overflow-x: auto; }
|
|
405
|
+
.ProseMirror.resize-cursor { cursor: col-resize; }
|