@growth-labs/cms 0.5.17 → 0.5.19

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 (72) hide show
  1. package/README.md +66 -0
  2. package/dist/engine/published-content.js +1 -1
  3. package/dist/engine/published-content.js.map +1 -1
  4. package/dist/engine/publisher.d.ts +6 -0
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +16 -7
  7. package/dist/engine/publisher.js.map +1 -1
  8. package/dist/engine/revisions.d.ts.map +1 -1
  9. package/dist/engine/revisions.js +1 -0
  10. package/dist/engine/revisions.js.map +1 -1
  11. package/dist/routes/content.d.ts.map +1 -1
  12. package/dist/routes/content.js +19 -1
  13. package/dist/routes/content.js.map +1 -1
  14. package/dist/schema/index.d.ts +1 -0
  15. package/dist/schema/index.d.ts.map +1 -1
  16. package/dist/schema/index.js +1 -0
  17. package/dist/schema/index.js.map +1 -1
  18. package/dist/schema/migrations.d.ts.map +1 -1
  19. package/dist/schema/migrations.js +7 -0
  20. package/dist/schema/migrations.js.map +1 -1
  21. package/dist/schema/portable-text.d.ts +52 -0
  22. package/dist/schema/portable-text.d.ts.map +1 -0
  23. package/dist/schema/portable-text.js +94 -0
  24. package/dist/schema/portable-text.js.map +1 -0
  25. package/dist/schema/types.d.ts +2 -0
  26. package/dist/schema/types.d.ts.map +1 -1
  27. package/dist/schema/types.js.map +1 -1
  28. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  29. package/dist/ui/editor/ContentForm.js +5 -3
  30. package/dist/ui/editor/ContentForm.js.map +1 -1
  31. package/dist/ui/editor/Rte.d.ts +12 -3
  32. package/dist/ui/editor/Rte.d.ts.map +1 -1
  33. package/dist/ui/editor/Rte.js +91 -10
  34. package/dist/ui/editor/Rte.js.map +1 -1
  35. package/dist/ui/editor/blocks.d.ts +17 -0
  36. package/dist/ui/editor/blocks.d.ts.map +1 -0
  37. package/dist/ui/editor/blocks.js +113 -0
  38. package/dist/ui/editor/blocks.js.map +1 -0
  39. package/dist/ui/editor/content-payload.d.ts +2 -0
  40. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  41. package/dist/ui/editor/content-payload.js +1 -0
  42. package/dist/ui/editor/content-payload.js.map +1 -1
  43. package/dist/ui/editor/extensions.d.ts +1 -1
  44. package/dist/ui/editor/extensions.d.ts.map +1 -1
  45. package/dist/ui/editor/extensions.js +9 -0
  46. package/dist/ui/editor/extensions.js.map +1 -1
  47. package/dist/ui/editor/portable-text.d.ts +7 -0
  48. package/dist/ui/editor/portable-text.d.ts.map +1 -0
  49. package/dist/ui/editor/portable-text.js +517 -0
  50. package/dist/ui/editor/portable-text.js.map +1 -0
  51. package/dist/ui/editor/serialize.d.ts.map +1 -1
  52. package/dist/ui/editor/serialize.js +195 -5
  53. package/dist/ui/editor/serialize.js.map +1 -1
  54. package/dist/ui/styles/broadsheet.css +21 -0
  55. package/migrations/0024_article_portable_text.sql +6 -0
  56. package/package.json +2 -1
  57. package/src/engine/published-content.ts +1 -1
  58. package/src/engine/publisher.ts +22 -5
  59. package/src/engine/revisions.ts +2 -0
  60. package/src/routes/content.ts +20 -1
  61. package/src/schema/index.ts +8 -0
  62. package/src/schema/migrations.ts +7 -0
  63. package/src/schema/portable-text.ts +118 -0
  64. package/src/schema/types.ts +2 -0
  65. package/src/ui/editor/ContentForm.tsx +8 -2
  66. package/src/ui/editor/Rte.tsx +120 -10
  67. package/src/ui/editor/blocks.ts +140 -0
  68. package/src/ui/editor/content-payload.ts +3 -0
  69. package/src/ui/editor/extensions.ts +9 -0
  70. package/src/ui/editor/portable-text.ts +582 -0
  71. package/src/ui/editor/serialize.ts +207 -5
  72. package/src/ui/styles/broadsheet.css +21 -0
@@ -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,14 @@
18
19
  // ![alt](src) 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)
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)
21
30
 
22
31
  import type { JSONContent } from '@tiptap/core'
23
32
  import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
@@ -94,6 +103,33 @@ function serializeBlock(node: JSONContent): string | null {
94
103
  case 'horizontalRule': {
95
104
  return '---'
96
105
  }
106
+ case 'table': {
107
+ return serializeTableMarkdown(node)
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
+ }
97
133
  default: {
98
134
  // Fall back to serializing any text content
99
135
  const fallback = serializeInlineContent(node.content ?? [])
@@ -140,6 +176,36 @@ function serializeListItem(item: JSONContent, bullet: string): string {
140
176
  return parts.join('\n')
141
177
  }
142
178
 
179
+ function serializeTableMarkdown(node: JSONContent): string {
180
+ const rows = (node.content ?? []).filter((row) => row.type === 'tableRow')
181
+ if (rows.length === 0) return ''
182
+
183
+ const cellText = (cell: JSONContent): string =>
184
+ (cell.content ?? [])
185
+ .map((child) => serializeInlineContent(child.content ?? []))
186
+ .join(' ')
187
+ .replace(/\n/g, ' ')
188
+ .replace(/\|/g, '\\|')
189
+ const rowLine = (row: JSONContent): string =>
190
+ `| ${(row.content ?? []).map(cellText).join(' | ')} |`
191
+
192
+ const firstCells = rows[0].content ?? []
193
+ const hasHeader = firstCells.length > 0 && firstCells.every((c) => c.type === 'tableHeader')
194
+ const columns = firstCells.length || 1
195
+ const separator = `| ${Array.from({ length: columns }, () => '---').join(' | ')} |`
196
+
197
+ const lines: string[] = []
198
+ if (hasHeader) {
199
+ lines.push(rowLine(rows[0]), separator)
200
+ for (const row of rows.slice(1)) lines.push(rowLine(row))
201
+ } else {
202
+ // GFM requires a header row; emit an empty one so body rows survive.
203
+ lines.push(`| ${Array.from({ length: columns }, () => '').join(' | ')} |`, separator)
204
+ for (const row of rows) lines.push(rowLine(row))
205
+ }
206
+ return lines.join('\n')
207
+ }
208
+
143
209
  function serializeInlineContent(nodes: JSONContent[]): string {
144
210
  return nodes.map((n) => serializeInlineNode(n)).join('')
145
211
  }
@@ -166,6 +232,7 @@ function serializeInlineNode(node: JSONContent): string {
166
232
  const hasItalic = marks.some((m) => m.type === 'italic')
167
233
  const hasStrike = marks.some((m) => m.type === 'strike')
168
234
  const linkMark = marks.find((m) => m.type === 'link')
235
+ const citationMark = marks.find((m) => m.type === 'citation')
169
236
 
170
237
  if (hasCode) return `\`${result}\``
171
238
 
@@ -177,7 +244,10 @@ function serializeInlineNode(node: JSONContent): string {
177
244
  // Word paste underlined whole runs.
178
245
  if (hasStrike) result = `~~${result}~~`
179
246
 
180
- if (linkMark) {
247
+ if (citationMark) {
248
+ const href = (citationMark.attrs?.href as string) ?? ''
249
+ result = `[[${result}]](${href})`
250
+ } else if (linkMark) {
181
251
  const href = (linkMark.attrs?.href as string) ?? ''
182
252
  const title = linkMark.attrs?.title ? ` "${linkMark.attrs.title as string}"` : ''
183
253
  result = `[${result}](${href}${title})`
@@ -235,6 +305,45 @@ function parseBlocks(md: string): JSONContent[] {
235
305
  continue
236
306
  }
237
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
+
238
347
  // Tweet-embed directive :::tweet
239
348
  if (/^:::tweet/.test(line)) {
240
349
  i++
@@ -249,13 +358,26 @@ function parseBlocks(md: string): JSONContent[] {
249
358
  continue
250
359
  }
251
360
 
252
- // Blockquote — collect contiguous > lines
361
+ // Blockquote — collect contiguous > lines. A `[!TONE]` first line is a
362
+ // GFM alert and parses as a callout instead.
253
363
  if (/^>/.test(line)) {
254
364
  const bqLines: string[] = []
255
365
  while (i < lines.length && /^>/.test(lines[i])) {
256
366
  bqLines.push(lines[i].replace(/^>\s?/, ''))
257
367
  i++
258
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
+ }
259
381
  const inner = parseBlocks(bqLines.join('\n'))
260
382
  nodes.push({
261
383
  type: 'blockquote',
@@ -281,6 +403,14 @@ function parseBlocks(md: string): JSONContent[] {
281
403
  continue
282
404
  }
283
405
 
406
+ // GFM pipe table (header row followed by a `| --- |` separator line)
407
+ if (isTableStart(lines, i)) {
408
+ const { node, next } = parseMarkdownTable(lines, i)
409
+ nodes.push(node)
410
+ i = next
411
+ continue
412
+ }
413
+
284
414
  // Bullet list
285
415
  if (/^[-*+]\s/.test(line)) {
286
416
  const { node, next } = parseBulletList(lines, i)
@@ -318,7 +448,9 @@ function parseBlocks(md: string): JSONContent[] {
318
448
  !/^#{1,6}\s/.test(lines[i]) &&
319
449
  !/^[-*+]\s/.test(lines[i]) &&
320
450
  !/^\d+\.\s/.test(lines[i]) &&
321
- !/^---+$/.test(lines[i].trim())
451
+ !/^---+$/.test(lines[i].trim()) &&
452
+ lines[i].trim() !== '<!-- generated-faq -->' &&
453
+ !isTableStart(lines, i)
322
454
  ) {
323
455
  paraLines.push(lines[i])
324
456
  i++
@@ -332,6 +464,64 @@ function parseBlocks(md: string): JSONContent[] {
332
464
  return nodes
333
465
  }
334
466
 
467
+ // ── Table parser ─────────────────────────────────────────────────────────────
468
+
469
+ function isTableStart(lines: string[], index: number): boolean {
470
+ if (!/^\|/.test(lines[index] ?? '')) return false
471
+ return /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1] ?? '')
472
+ }
473
+
474
+ function splitTableRow(line: string): string[] {
475
+ let inner = line.trim()
476
+ if (inner.startsWith('|')) inner = inner.slice(1)
477
+ if (inner.endsWith('|')) inner = inner.slice(0, -1)
478
+
479
+ const cells: string[] = []
480
+ let current = ''
481
+ for (let i = 0; i < inner.length; i++) {
482
+ const ch = inner[i]
483
+ if (ch === '\\' && inner[i + 1] === '|') {
484
+ current += '\\|'
485
+ i++
486
+ continue
487
+ }
488
+ if (ch === '|') {
489
+ cells.push(current)
490
+ current = ''
491
+ continue
492
+ }
493
+ current += ch
494
+ }
495
+ cells.push(current)
496
+ return cells.map((cell) => cell.trim().replace(/\\\|/g, '|'))
497
+ }
498
+
499
+ function parseMarkdownTable(lines: string[], start: number): { node: JSONContent; next: number } {
500
+ const makeCell = (kind: 'tableHeader' | 'tableCell', text: string): JSONContent => ({
501
+ type: kind,
502
+ attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
503
+ content: [{ type: 'paragraph', content: parseInline(text) }],
504
+ })
505
+
506
+ const rows: JSONContent[] = [
507
+ {
508
+ type: 'tableRow',
509
+ content: splitTableRow(lines[start]).map((text) => makeCell('tableHeader', text)),
510
+ },
511
+ ]
512
+
513
+ let i = start + 2 // skip the header row and the separator line
514
+ while (i < lines.length && /^\|/.test(lines[i])) {
515
+ rows.push({
516
+ type: 'tableRow',
517
+ content: splitTableRow(lines[i]).map((text) => makeCell('tableCell', text)),
518
+ })
519
+ i++
520
+ }
521
+
522
+ return { node: { type: 'table', content: rows }, next: i }
523
+ }
524
+
335
525
  // ── List parsers ─────────────────────────────────────────────────────────────
336
526
 
337
527
  function parseBulletList(lines: string[], start: number): { node: JSONContent; next: number } {
@@ -398,6 +588,18 @@ function parseInline(text: string): JSONContent[] {
398
588
  continue
399
589
  }
400
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
+
401
603
  // ── link ───────────────────────────────────────────────────────────
402
604
  const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)/.exec(remaining)
403
605
  if (linkMatch) {
@@ -392,3 +392,24 @@ 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; }
406
+
407
+ /* ── Typed editorial blocks (Phase 1) ──────────────────────────────────── */
408
+ .ProseMirror div[data-callout] { border-left: 3px solid var(--accent); background: var(--panel-2); border-radius: 6px; padding: 10px 14px; margin: 1em 0; }
409
+ .ProseMirror div[data-callout][data-tone="warning"], .ProseMirror div[data-callout][data-tone="caution"] { border-left-color: #b45309; }
410
+ .ProseMirror div[data-callout][data-tone="tip"] { border-left-color: #15803d; }
411
+ .ProseMirror div[data-callout][data-tone="important"] { border-left-color: #7c3aed; }
412
+ .ProseMirror figure[data-pull-quote] { border-left: 3px solid var(--ink-faint); margin: 1.5em 0; padding: 4px 18px; font-size: 1.15em; font-style: italic; color: var(--ink-soft); }
413
+ .ProseMirror figure[data-pull-quote][data-attribution]::after { content: "— " attr(data-attribution); display: block; font-size: 0.8em; font-style: normal; color: var(--ink-faint); margin-top: 6px; }
414
+ .ProseMirror div[data-generated-faq] { border: 1px dashed var(--border); border-radius: 6px; padding: 8px 12px; margin: 1em 0; color: var(--ink-faint); font-size: 0.85em; user-select: none; }
415
+ .ProseMirror a[data-citation] { text-decoration: underline dotted; color: var(--accent); }