@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.
Files changed (41) hide show
  1. package/README.md +46 -0
  2. package/dist/content/index.d.ts +4 -0
  3. package/dist/content/index.d.ts.map +1 -0
  4. package/dist/content/index.js +10 -0
  5. package/dist/content/index.js.map +1 -0
  6. package/dist/schema/insights-ingest.d.ts +18 -18
  7. package/dist/schema/portable-text.d.ts.map +1 -1
  8. package/dist/schema/portable-text.js +8 -0
  9. package/dist/schema/portable-text.js.map +1 -1
  10. package/dist/ui/editor/Rte.d.ts.map +1 -1
  11. package/dist/ui/editor/Rte.js +22 -0
  12. package/dist/ui/editor/Rte.js.map +1 -1
  13. package/dist/ui/editor/blocks.d.ts +17 -0
  14. package/dist/ui/editor/blocks.d.ts.map +1 -0
  15. package/dist/ui/editor/blocks.js +113 -0
  16. package/dist/ui/editor/blocks.js.map +1 -0
  17. package/dist/ui/editor/extensions.d.ts +1 -1
  18. package/dist/ui/editor/extensions.d.ts.map +1 -1
  19. package/dist/ui/editor/extensions.js +19 -0
  20. package/dist/ui/editor/extensions.js.map +1 -1
  21. package/dist/ui/editor/portable-text.d.ts.map +1 -1
  22. package/dist/ui/editor/portable-text.js +57 -1
  23. package/dist/ui/editor/portable-text.js.map +1 -1
  24. package/dist/ui/editor/serialize.d.ts.map +1 -1
  25. package/dist/ui/editor/serialize.js +96 -2
  26. package/dist/ui/editor/serialize.js.map +1 -1
  27. package/dist/ui/editor/slash-menu.d.ts +14 -0
  28. package/dist/ui/editor/slash-menu.d.ts.map +1 -0
  29. package/dist/ui/editor/slash-menu.js +200 -0
  30. package/dist/ui/editor/slash-menu.js.map +1 -0
  31. package/dist/ui/styles/broadsheet.css +20 -0
  32. package/package.json +11 -1
  33. package/src/content/index.ts +16 -0
  34. package/src/schema/portable-text.ts +8 -0
  35. package/src/ui/editor/Rte.tsx +25 -0
  36. package/src/ui/editor/blocks.ts +140 -0
  37. package/src/ui/editor/extensions.ts +20 -0
  38. package/src/ui/editor/portable-text.ts +60 -1
  39. package/src/ui/editor/serialize.ts +100 -2
  40. package/src/ui/editor/slash-menu.ts +218 -0
  41. 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.18",
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
 
@@ -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
- // Unknown annotation types have no Tiptap node yet; drop the mark but
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 (linkMark) {
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) {