@growth-labs/cms 0.5.18 → 0.5.20
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 +46 -0
- package/dist/content/index.d.ts +4 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +10 -0
- package/dist/content/index.js.map +1 -0
- package/dist/schema/insights-ingest.d.ts +18 -18
- package/dist/schema/portable-text.d.ts.map +1 -1
- package/dist/schema/portable-text.js +8 -0
- package/dist/schema/portable-text.js.map +1 -1
- package/dist/ui/editor/Rte.d.ts.map +1 -1
- package/dist/ui/editor/Rte.js +22 -0
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/editor/blocks.d.ts +17 -0
- package/dist/ui/editor/blocks.d.ts.map +1 -0
- package/dist/ui/editor/blocks.js +113 -0
- package/dist/ui/editor/blocks.js.map +1 -0
- 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 +19 -0
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/portable-text.d.ts.map +1 -1
- package/dist/ui/editor/portable-text.js +57 -1
- package/dist/ui/editor/portable-text.js.map +1 -1
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +96 -2
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/editor/slash-menu.d.ts +14 -0
- package/dist/ui/editor/slash-menu.d.ts.map +1 -0
- package/dist/ui/editor/slash-menu.js +200 -0
- package/dist/ui/editor/slash-menu.js.map +1 -0
- package/dist/ui/styles/broadsheet.css +20 -0
- package/package.json +11 -1
- package/src/content/index.ts +16 -0
- package/src/schema/portable-text.ts +8 -0
- package/src/ui/editor/Rte.tsx +25 -0
- package/src/ui/editor/blocks.ts +140 -0
- package/src/ui/editor/extensions.ts +20 -0
- package/src/ui/editor/portable-text.ts +60 -1
- package/src/ui/editor/serialize.ts +100 -2
- package/src/ui/editor/slash-menu.ts +218 -0
- package/src/ui/styles/broadsheet.css +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/cms",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"growth-labs-cms-migrations": "./dist/cli/migrations.js"
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./dist/schema/index.d.ts",
|
|
16
16
|
"import": "./dist/schema/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./content": {
|
|
19
|
+
"types": "./dist/content/index.d.ts",
|
|
20
|
+
"import": "./dist/content/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./engine": {
|
|
19
23
|
"types": "./dist/engine/index.d.ts",
|
|
20
24
|
"import": "./dist/engine/index.js"
|
|
@@ -60,11 +64,17 @@
|
|
|
60
64
|
},
|
|
61
65
|
"dependencies": {
|
|
62
66
|
"@tiptap/core": "3.24.0",
|
|
67
|
+
"@tiptap/extension-collaboration": "3.24.0",
|
|
68
|
+
"@tiptap/extension-drag-handle": "3.24.0",
|
|
63
69
|
"@tiptap/extension-image": "3.24.0",
|
|
64
70
|
"@tiptap/extension-link": "3.24.0",
|
|
71
|
+
"@tiptap/extension-node-range": "3.24.0",
|
|
65
72
|
"@tiptap/extension-table": "3.24.0",
|
|
66
73
|
"@tiptap/pm": "3.24.0",
|
|
67
74
|
"@tiptap/starter-kit": "3.24.0",
|
|
75
|
+
"@tiptap/suggestion": "3.24.0",
|
|
76
|
+
"@tiptap/y-tiptap": "^3.0.9",
|
|
77
|
+
"yjs": "^13.6.32",
|
|
68
78
|
"zod": "^3.24.0"
|
|
69
79
|
},
|
|
70
80
|
"devDependencies": {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/content/index.ts
|
|
2
|
+
// Site-side content tooling surface (`@growth-labs/cms/content`): the pure
|
|
3
|
+
// serializer quartet plus the envelope contract, for backfills, gates, and
|
|
4
|
+
// import tooling that must convert between markdown, Tiptap JSON, and
|
|
5
|
+
// Portable Text OUTSIDE the editor. No Editor/EditorView/DOM anywhere on
|
|
6
|
+
// this import path.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
PORTABLE_TEXT_VERSION,
|
|
10
|
+
type PortableTextEnvelope,
|
|
11
|
+
type PortableTextObject,
|
|
12
|
+
parsePortableTextEnvelope,
|
|
13
|
+
wrapPortableText,
|
|
14
|
+
} from '../schema/portable-text.js'
|
|
15
|
+
export { docToPortableText, portableTextToDoc } from '../ui/editor/portable-text.js'
|
|
16
|
+
export { docToMarkdown, markdownToDoc } from '../ui/editor/serialize.js'
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
// Text arrays so in-cell links are ordinary annotations.
|
|
20
20
|
// Cell extension fields: header, colspan, rowspan, colwidth,
|
|
21
21
|
// align.
|
|
22
|
+
// callout { tone: note|tip|important|warning|caution, content:
|
|
23
|
+
// block[] } (@portabletext/markdown callout shape; markdown
|
|
24
|
+
// form is a GFM alert)
|
|
25
|
+
// pullQuote { attribution: string|null, content: block[] }
|
|
26
|
+
// generatedFaq { markdown } — the machine-owned <!-- generated-faq -->
|
|
27
|
+
// region as an opaque atom
|
|
28
|
+
// citation annotation markDef { _type: 'citation', href } (markdown
|
|
29
|
+
// form [[label]](href))
|
|
22
30
|
|
|
23
31
|
import { z } from 'zod'
|
|
24
32
|
|
package/src/ui/editor/Rte.tsx
CHANGED
|
@@ -57,6 +57,9 @@ type ToolCmd =
|
|
|
57
57
|
| 'ordered'
|
|
58
58
|
| 'link'
|
|
59
59
|
| 'image'
|
|
60
|
+
| 'callout'
|
|
61
|
+
| 'pullquote'
|
|
62
|
+
| 'citation'
|
|
60
63
|
| 'table'
|
|
61
64
|
| 'tableAddRow'
|
|
62
65
|
| 'tableAddCol'
|
|
@@ -82,7 +85,10 @@ const RTE_TOOLS: ToolDef[] = [
|
|
|
82
85
|
{ cmd: 'bullet', label: 'Bullet list', icon: 'rows' },
|
|
83
86
|
{ cmd: 'ordered', label: 'Ordered list', icon: 'rows' },
|
|
84
87
|
{ cmd: 'link', label: 'Link', icon: 'link' },
|
|
88
|
+
{ cmd: 'citation', label: 'Citation', icon: 'globe' },
|
|
85
89
|
{ cmd: 'image', label: 'Image', icon: 'image' },
|
|
90
|
+
{ cmd: 'callout', label: 'Callout', icon: 'flag' },
|
|
91
|
+
{ cmd: 'pullquote', label: 'Pull quote', icon: 'star' },
|
|
86
92
|
{ cmd: 'table', label: 'Insert table', icon: 'grid' },
|
|
87
93
|
]
|
|
88
94
|
|
|
@@ -344,6 +350,19 @@ function ToolButton({
|
|
|
344
350
|
}
|
|
345
351
|
break
|
|
346
352
|
}
|
|
353
|
+
case 'callout':
|
|
354
|
+
chain.toggleWrap('callout', { tone: 'note' }).run()
|
|
355
|
+
break
|
|
356
|
+
case 'pullquote':
|
|
357
|
+
chain.toggleWrap('pullQuote').run()
|
|
358
|
+
break
|
|
359
|
+
case 'citation': {
|
|
360
|
+
const href = window.prompt('Citation URL')
|
|
361
|
+
if (href) {
|
|
362
|
+
chain.toggleMark('citation', { href }).run()
|
|
363
|
+
}
|
|
364
|
+
break
|
|
365
|
+
}
|
|
347
366
|
case 'table':
|
|
348
367
|
chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
|
349
368
|
break
|
|
@@ -389,6 +408,12 @@ function ToolButton({
|
|
|
389
408
|
return editor.isActive('bulletList')
|
|
390
409
|
case 'ordered':
|
|
391
410
|
return editor.isActive('orderedList')
|
|
411
|
+
case 'callout':
|
|
412
|
+
return editor.isActive('callout')
|
|
413
|
+
case 'pullquote':
|
|
414
|
+
return editor.isActive('pullQuote')
|
|
415
|
+
case 'citation':
|
|
416
|
+
return editor.isActive('citation')
|
|
392
417
|
case 'table':
|
|
393
418
|
return editor.isActive('table')
|
|
394
419
|
default:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// src/ui/editor/blocks.ts
|
|
2
|
+
// Pure Tiptap specs for the Phase 1 typed blocks. Like tweet-embed.ts these
|
|
3
|
+
// NEVER instantiate Editor/EditorView; the HTML encodings exist for Tiptap's
|
|
4
|
+
// clipboard pipeline. Durable forms live in the serializers:
|
|
5
|
+
// callout ↔ PT { tone, content } ↔ GFM alert (> [!NOTE] …)
|
|
6
|
+
// pullQuote ↔ PT { attribution, content } ↔ :::pullquote directive
|
|
7
|
+
// generatedFaq ↔ PT { markdown } ↔ <!-- generated-faq --> comment block
|
|
8
|
+
// citation ↔ PT markDef { _type: 'citation', href } ↔ [[label]](href)
|
|
9
|
+
|
|
10
|
+
import { Mark, mergeAttributes, Node } from '@tiptap/core'
|
|
11
|
+
|
|
12
|
+
export const CALLOUT_TONES = ['note', 'tip', 'important', 'warning', 'caution'] as const
|
|
13
|
+
export type CalloutTone = (typeof CALLOUT_TONES)[number]
|
|
14
|
+
|
|
15
|
+
/** Editorial callout: an aside with a tone, rendered as a GFM alert in markdown. */
|
|
16
|
+
export const Callout = Node.create({
|
|
17
|
+
name: 'callout',
|
|
18
|
+
|
|
19
|
+
group: 'block',
|
|
20
|
+
|
|
21
|
+
content: 'paragraph+',
|
|
22
|
+
|
|
23
|
+
defining: true,
|
|
24
|
+
|
|
25
|
+
addAttributes() {
|
|
26
|
+
return {
|
|
27
|
+
tone: {
|
|
28
|
+
default: 'note',
|
|
29
|
+
parseHTML: (element: HTMLElement) => {
|
|
30
|
+
const tone = element.getAttribute('data-tone')
|
|
31
|
+
return tone && (CALLOUT_TONES as readonly string[]).includes(tone) ? tone : 'note'
|
|
32
|
+
},
|
|
33
|
+
renderHTML: (attributes: Record<string, unknown>) => ({
|
|
34
|
+
'data-tone': attributes.tone,
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
parseHTML() {
|
|
41
|
+
return [{ tag: 'div[data-callout]' }]
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
renderHTML({ HTMLAttributes }) {
|
|
45
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-callout': '' }), 0]
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** Pull quote with an optional attribution line. */
|
|
50
|
+
export const PullQuote = Node.create({
|
|
51
|
+
name: 'pullQuote',
|
|
52
|
+
|
|
53
|
+
group: 'block',
|
|
54
|
+
|
|
55
|
+
content: 'paragraph+',
|
|
56
|
+
|
|
57
|
+
defining: true,
|
|
58
|
+
|
|
59
|
+
addAttributes() {
|
|
60
|
+
return {
|
|
61
|
+
attribution: {
|
|
62
|
+
default: null,
|
|
63
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('data-attribution'),
|
|
64
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
65
|
+
attributes.attribution ? { 'data-attribution': attributes.attribution } : {},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
parseHTML() {
|
|
71
|
+
return [{ tag: 'figure[data-pull-quote]' }]
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
renderHTML({ HTMLAttributes }) {
|
|
75
|
+
return ['figure', mergeAttributes(HTMLAttributes, { 'data-pull-quote': '' }), 0]
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generated-FAQ block: an opaque, machine-owned region. The editor shows a
|
|
81
|
+
* read-only marker; the inner markdown (without the comment markers) rides in
|
|
82
|
+
* the attrs and re-serializes byte-identically so the FAQ/JSON-LD consumers
|
|
83
|
+
* of body_markdown never notice the editor round trip.
|
|
84
|
+
*/
|
|
85
|
+
export const GeneratedFaq = Node.create({
|
|
86
|
+
name: 'generatedFaq',
|
|
87
|
+
|
|
88
|
+
group: 'block',
|
|
89
|
+
|
|
90
|
+
atom: true,
|
|
91
|
+
|
|
92
|
+
addAttributes() {
|
|
93
|
+
return {
|
|
94
|
+
markdown: {
|
|
95
|
+
default: '',
|
|
96
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('data-markdown') ?? '',
|
|
97
|
+
renderHTML: (attributes: Record<string, unknown>) => ({
|
|
98
|
+
'data-markdown': attributes.markdown,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
parseHTML() {
|
|
105
|
+
return [{ tag: 'div[data-generated-faq]' }]
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
renderHTML({ HTMLAttributes }) {
|
|
109
|
+
return [
|
|
110
|
+
'div',
|
|
111
|
+
mergeAttributes(HTMLAttributes, { 'data-generated-faq': '' }),
|
|
112
|
+
'Generated FAQ block (machine-managed)',
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/** Citation annotation: a source reference rendered as [[label]](href) in markdown. */
|
|
118
|
+
export const Citation = Mark.create({
|
|
119
|
+
name: 'citation',
|
|
120
|
+
|
|
121
|
+
addAttributes() {
|
|
122
|
+
return {
|
|
123
|
+
href: {
|
|
124
|
+
default: '',
|
|
125
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('href') ?? '',
|
|
126
|
+
renderHTML: (attributes: Record<string, unknown>) => ({
|
|
127
|
+
href: attributes.href,
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
parseHTML() {
|
|
134
|
+
return [{ tag: 'a[data-citation]' }]
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
renderHTML({ HTMLAttributes }) {
|
|
138
|
+
return ['a', mergeAttributes(HTMLAttributes, { 'data-citation': '' }), 0]
|
|
139
|
+
},
|
|
140
|
+
})
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
import { DragHandle } from '@tiptap/extension-drag-handle'
|
|
1
2
|
import Image from '@tiptap/extension-image'
|
|
2
3
|
import Link from '@tiptap/extension-link'
|
|
3
4
|
import { TableKit } from '@tiptap/extension-table'
|
|
4
5
|
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import { Callout, Citation, GeneratedFaq, PullQuote } from './blocks.js'
|
|
7
|
+
import { SlashMenu } from './slash-menu.js'
|
|
5
8
|
import { TrimBoundaryMarks } from './trim-boundary-marks.js'
|
|
6
9
|
import { TweetEmbed } from './tweet-embed.js'
|
|
7
10
|
|
|
11
|
+
function renderDragHandle(): HTMLElement {
|
|
12
|
+
const handle = document.createElement('div')
|
|
13
|
+
handle.className = 'drag-handle'
|
|
14
|
+
handle.textContent = '⋮⋮'
|
|
15
|
+
return handle
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
/** The single extension set used by Masthead and its behavioral editor tests.
|
|
9
19
|
*
|
|
10
20
|
* Underline is deliberately ABSENT. It has no markdown form — the serializer
|
|
@@ -23,5 +33,15 @@ export function createRteExtensions() {
|
|
|
23
33
|
// One consolidated package registers table/tableRow/tableHeader/tableCell.
|
|
24
34
|
// Gapcursor (via StarterKit) lets the caret sit before/after a table.
|
|
25
35
|
TableKit.configure({ table: { resizable: true } }),
|
|
36
|
+
Callout,
|
|
37
|
+
PullQuote,
|
|
38
|
+
GeneratedFaq,
|
|
39
|
+
Citation,
|
|
40
|
+
SlashMenu,
|
|
41
|
+
// DragHandle constructs its handle element (render()) during plugin
|
|
42
|
+
// setup, not lazily — browser-only, unlike every other extension here.
|
|
43
|
+
...(typeof document !== 'undefined'
|
|
44
|
+
? [DragHandle.configure({ render: renderDragHandle })]
|
|
45
|
+
: []),
|
|
26
46
|
]
|
|
27
47
|
}
|
|
@@ -124,6 +124,29 @@ function serializeBlockNode(
|
|
|
124
124
|
case 'table':
|
|
125
125
|
out.push(serializeTable(node, nextKey))
|
|
126
126
|
return
|
|
127
|
+
case 'callout':
|
|
128
|
+
out.push({
|
|
129
|
+
_type: 'callout',
|
|
130
|
+
_key: nextKey(),
|
|
131
|
+
tone: (node.attrs?.tone as string) ?? 'note',
|
|
132
|
+
content: serializeBlocks(node.content ?? [], nextKey),
|
|
133
|
+
})
|
|
134
|
+
return
|
|
135
|
+
case 'pullQuote':
|
|
136
|
+
out.push({
|
|
137
|
+
_type: 'pullQuote',
|
|
138
|
+
_key: nextKey(),
|
|
139
|
+
attribution: (node.attrs?.attribution as string | null) ?? null,
|
|
140
|
+
content: serializeBlocks(node.content ?? [], nextKey),
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
case 'generatedFaq':
|
|
144
|
+
out.push({
|
|
145
|
+
_type: 'generatedFaq',
|
|
146
|
+
_key: nextKey(),
|
|
147
|
+
markdown: (node.attrs?.markdown as string) ?? '',
|
|
148
|
+
})
|
|
149
|
+
return
|
|
127
150
|
default: {
|
|
128
151
|
// Unknown block: keep its text content rather than dropping it.
|
|
129
152
|
const inline = node.content ?? []
|
|
@@ -207,6 +230,10 @@ function buildTextBlock(
|
|
|
207
230
|
}
|
|
208
231
|
if (mark.type === 'link') {
|
|
209
232
|
marks.push(linkMarkDefKey(mark, markDefs, nextKey))
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
if (mark.type === 'citation') {
|
|
236
|
+
marks.push(citationMarkDefKey(mark, markDefs, nextKey))
|
|
210
237
|
}
|
|
211
238
|
// Marks without a Portable Text form (legacy underline) are dropped,
|
|
212
239
|
// matching the markdown serializer.
|
|
@@ -260,6 +287,19 @@ function linkMarkDefKey(
|
|
|
260
287
|
return def._key
|
|
261
288
|
}
|
|
262
289
|
|
|
290
|
+
function citationMarkDefKey(
|
|
291
|
+
mark: NonNullable<JSONContent['marks']>[number],
|
|
292
|
+
markDefs: MarkDef[],
|
|
293
|
+
nextKey: KeyGenerator,
|
|
294
|
+
): string {
|
|
295
|
+
const href = (mark.attrs?.href as string) ?? ''
|
|
296
|
+
const existing = markDefs.find((def) => def._type === 'citation' && def.href === href)
|
|
297
|
+
if (existing) return existing._key
|
|
298
|
+
const def: MarkDef = { _key: nextKey(), _type: 'citation', href }
|
|
299
|
+
markDefs.push(def)
|
|
300
|
+
return def._key
|
|
301
|
+
}
|
|
302
|
+
|
|
263
303
|
function serializeTable(node: JSONContent, nextKey: KeyGenerator): PortableTextObject {
|
|
264
304
|
const rows = (node.content ?? []).filter((row) => row.type === 'tableRow')
|
|
265
305
|
let headerRows = 0
|
|
@@ -396,6 +436,23 @@ function parseObject(block: PortableTextObject): JSONContent {
|
|
|
396
436
|
return { type: 'horizontalRule' }
|
|
397
437
|
case 'table':
|
|
398
438
|
return parseTable(block)
|
|
439
|
+
case 'callout':
|
|
440
|
+
return {
|
|
441
|
+
type: 'callout',
|
|
442
|
+
attrs: { tone: (block.tone as string) ?? 'note' },
|
|
443
|
+
content: parseBlocks((block.content as PortableTextObject[] | undefined) ?? []),
|
|
444
|
+
}
|
|
445
|
+
case 'pullQuote':
|
|
446
|
+
return {
|
|
447
|
+
type: 'pullQuote',
|
|
448
|
+
attrs: { attribution: (block.attribution as string | null) ?? null },
|
|
449
|
+
content: parseBlocks((block.content as PortableTextObject[] | undefined) ?? []),
|
|
450
|
+
}
|
|
451
|
+
case 'generatedFaq':
|
|
452
|
+
return {
|
|
453
|
+
type: 'generatedFaq',
|
|
454
|
+
attrs: { markdown: (block.markdown as string) ?? '' },
|
|
455
|
+
}
|
|
399
456
|
default:
|
|
400
457
|
throw new Error(
|
|
401
458
|
`portableTextToDoc: unknown Portable Text _type "${block._type}" — the stored content is newer than this serializer`,
|
|
@@ -475,8 +532,10 @@ function parseSpan(span: PortableTextObject, markDefs: MarkDef[]): JSONContent[]
|
|
|
475
532
|
target: (def.target as string | null) ?? null,
|
|
476
533
|
},
|
|
477
534
|
})
|
|
535
|
+
} else if (def._type === 'citation') {
|
|
536
|
+
marks.push({ type: 'citation', attrs: { href: (def.href as string) ?? '' } })
|
|
478
537
|
}
|
|
479
|
-
//
|
|
538
|
+
// Other annotation types have no Tiptap node yet; drop the mark but
|
|
480
539
|
// keep the text.
|
|
481
540
|
continue
|
|
482
541
|
}
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
// | GFM | pipe | tables | (header row + `| --- |` separator; `\|` escapes a
|
|
23
23
|
// literal pipe; colspan/rowspan/colwidth/align do NOT survive markdown —
|
|
24
24
|
// Portable Text is the durable store, markdown is the derived lossy view)
|
|
25
|
+
// > [!NOTE] GFM alert = callout (tones NOTE/TIP/IMPORTANT/WARNING/CAUTION)
|
|
26
|
+
// :::pullquote\n<text>\n[-- attribution]\n::: pull-quote directive
|
|
27
|
+
// [[label]](href) citation annotation
|
|
28
|
+
// <!-- generated-faq -->…<!-- /generated-faq --> opaque machine-owned atom
|
|
29
|
+
// (inner markdown re-serializes byte-identically)
|
|
25
30
|
|
|
26
31
|
import type { JSONContent } from '@tiptap/core'
|
|
27
32
|
import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
|
|
@@ -101,6 +106,30 @@ function serializeBlock(node: JSONContent): string | null {
|
|
|
101
106
|
case 'table': {
|
|
102
107
|
return serializeTableMarkdown(node)
|
|
103
108
|
}
|
|
109
|
+
case 'callout': {
|
|
110
|
+
const tone = ((node.attrs?.tone as string) ?? 'note').toUpperCase()
|
|
111
|
+
const inner = (node.content ?? [])
|
|
112
|
+
.map((child) => serializeBlock(child))
|
|
113
|
+
.filter((s) => s !== null)
|
|
114
|
+
.join('\n\n')
|
|
115
|
+
const quoted = inner
|
|
116
|
+
.split('\n')
|
|
117
|
+
.map((line) => (line === '' ? '>' : `> ${line}`))
|
|
118
|
+
.join('\n')
|
|
119
|
+
return `> [!${tone}]\n${quoted}`
|
|
120
|
+
}
|
|
121
|
+
case 'pullQuote': {
|
|
122
|
+
const inner = (node.content ?? [])
|
|
123
|
+
.map((child) => serializeBlock(child))
|
|
124
|
+
.filter((s) => s !== null)
|
|
125
|
+
.join('\n\n')
|
|
126
|
+
const attribution = node.attrs?.attribution ? `\n-- ${node.attrs.attribution as string}` : ''
|
|
127
|
+
return `:::pullquote\n${inner}${attribution}\n:::`
|
|
128
|
+
}
|
|
129
|
+
case 'generatedFaq': {
|
|
130
|
+
const inner = (node.attrs?.markdown as string) ?? ''
|
|
131
|
+
return `<!-- generated-faq -->\n${inner}\n<!-- /generated-faq -->`
|
|
132
|
+
}
|
|
104
133
|
default: {
|
|
105
134
|
// Fall back to serializing any text content
|
|
106
135
|
const fallback = serializeInlineContent(node.content ?? [])
|
|
@@ -203,6 +232,7 @@ function serializeInlineNode(node: JSONContent): string {
|
|
|
203
232
|
const hasItalic = marks.some((m) => m.type === 'italic')
|
|
204
233
|
const hasStrike = marks.some((m) => m.type === 'strike')
|
|
205
234
|
const linkMark = marks.find((m) => m.type === 'link')
|
|
235
|
+
const citationMark = marks.find((m) => m.type === 'citation')
|
|
206
236
|
|
|
207
237
|
if (hasCode) return `\`${result}\``
|
|
208
238
|
|
|
@@ -214,7 +244,10 @@ function serializeInlineNode(node: JSONContent): string {
|
|
|
214
244
|
// Word paste underlined whole runs.
|
|
215
245
|
if (hasStrike) result = `~~${result}~~`
|
|
216
246
|
|
|
217
|
-
if (
|
|
247
|
+
if (citationMark) {
|
|
248
|
+
const href = (citationMark.attrs?.href as string) ?? ''
|
|
249
|
+
result = `[[${result}]](${href})`
|
|
250
|
+
} else if (linkMark) {
|
|
218
251
|
const href = (linkMark.attrs?.href as string) ?? ''
|
|
219
252
|
const title = linkMark.attrs?.title ? ` "${linkMark.attrs.title as string}"` : ''
|
|
220
253
|
result = `[${result}](${href}${title})`
|
|
@@ -272,6 +305,45 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
272
305
|
continue
|
|
273
306
|
}
|
|
274
307
|
|
|
308
|
+
// Generated-FAQ block — the comment-delimited region becomes one atom
|
|
309
|
+
// that re-serializes byte-identically (the markers stay authoritative
|
|
310
|
+
// for every body_markdown consumer).
|
|
311
|
+
if (line.trim() === '<!-- generated-faq -->') {
|
|
312
|
+
const inner: string[] = []
|
|
313
|
+
i++
|
|
314
|
+
while (i < lines.length && lines[i].trim() !== '<!-- /generated-faq -->') {
|
|
315
|
+
inner.push(lines[i])
|
|
316
|
+
i++
|
|
317
|
+
}
|
|
318
|
+
i++ // consume the closing marker
|
|
319
|
+
nodes.push({ type: 'generatedFaq', attrs: { markdown: inner.join('\n') } })
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Pull-quote directive :::pullquote (optional trailing `-- attribution`)
|
|
324
|
+
if (/^:::pullquote/.test(line)) {
|
|
325
|
+
i++
|
|
326
|
+
const inner: string[] = []
|
|
327
|
+
while (i < lines.length && lines[i].trim() !== ':::') {
|
|
328
|
+
inner.push(lines[i])
|
|
329
|
+
i++
|
|
330
|
+
}
|
|
331
|
+
i++ // consume :::
|
|
332
|
+
let attribution: string | null = null
|
|
333
|
+
const last = inner[inner.length - 1]
|
|
334
|
+
if (last?.startsWith('-- ')) {
|
|
335
|
+
attribution = last.slice(3)
|
|
336
|
+
inner.pop()
|
|
337
|
+
}
|
|
338
|
+
const content = parseBlocks(inner.join('\n'))
|
|
339
|
+
nodes.push({
|
|
340
|
+
type: 'pullQuote',
|
|
341
|
+
attrs: { attribution },
|
|
342
|
+
content: content.length ? content : [{ type: 'paragraph', content: [] }],
|
|
343
|
+
})
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
|
|
275
347
|
// Tweet-embed directive :::tweet
|
|
276
348
|
if (/^:::tweet/.test(line)) {
|
|
277
349
|
i++
|
|
@@ -286,13 +358,26 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
286
358
|
continue
|
|
287
359
|
}
|
|
288
360
|
|
|
289
|
-
// Blockquote — collect contiguous > lines
|
|
361
|
+
// Blockquote — collect contiguous > lines. A `[!TONE]` first line is a
|
|
362
|
+
// GFM alert and parses as a callout instead.
|
|
290
363
|
if (/^>/.test(line)) {
|
|
291
364
|
const bqLines: string[] = []
|
|
292
365
|
while (i < lines.length && /^>/.test(lines[i])) {
|
|
293
366
|
bqLines.push(lines[i].replace(/^>\s?/, ''))
|
|
294
367
|
i++
|
|
295
368
|
}
|
|
369
|
+
const alertMatch = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/.exec(
|
|
370
|
+
bqLines[0]?.trim() ?? '',
|
|
371
|
+
)
|
|
372
|
+
if (alertMatch) {
|
|
373
|
+
const inner = parseBlocks(bqLines.slice(1).join('\n'))
|
|
374
|
+
nodes.push({
|
|
375
|
+
type: 'callout',
|
|
376
|
+
attrs: { tone: alertMatch[1].toLowerCase() },
|
|
377
|
+
content: inner.length ? inner : [{ type: 'paragraph', content: [] }],
|
|
378
|
+
})
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
296
381
|
const inner = parseBlocks(bqLines.join('\n'))
|
|
297
382
|
nodes.push({
|
|
298
383
|
type: 'blockquote',
|
|
@@ -364,6 +449,7 @@ function parseBlocks(md: string): JSONContent[] {
|
|
|
364
449
|
!/^[-*+]\s/.test(lines[i]) &&
|
|
365
450
|
!/^\d+\.\s/.test(lines[i]) &&
|
|
366
451
|
!/^---+$/.test(lines[i].trim()) &&
|
|
452
|
+
lines[i].trim() !== '<!-- generated-faq -->' &&
|
|
367
453
|
!isTableStart(lines, i)
|
|
368
454
|
) {
|
|
369
455
|
paraLines.push(lines[i])
|
|
@@ -502,6 +588,18 @@ function parseInline(text: string): JSONContent[] {
|
|
|
502
588
|
continue
|
|
503
589
|
}
|
|
504
590
|
|
|
591
|
+
// ── citation ([[label]](href)) — must run before the link matcher ──
|
|
592
|
+
const citationMatch = /^\[\[([^\]]+)\]\]\(([^)]+)\)/.exec(remaining)
|
|
593
|
+
if (citationMatch) {
|
|
594
|
+
const inner = parseInline(citationMatch[1]).map((n) => ({
|
|
595
|
+
...n,
|
|
596
|
+
marks: [...(n.marks ?? []), { type: 'citation', attrs: { href: citationMatch[2] } }],
|
|
597
|
+
}))
|
|
598
|
+
nodes.push(...inner)
|
|
599
|
+
remaining = remaining.slice(citationMatch[0].length)
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
|
|
505
603
|
// ── link ───────────────────────────────────────────────────────────
|
|
506
604
|
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)/.exec(remaining)
|
|
507
605
|
if (linkMatch) {
|