@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
@@ -52,6 +52,7 @@ import {
52
52
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
53
53
  import { softDeleteContent } from '../engine/soft-delete.js'
54
54
  import { TagInputError } from '../engine/taxonomy.js'
55
+ import { parsePortableTextEnvelope } from '../schema/portable-text.js'
55
56
  import type { ContentStatus } from '../schema/types.js'
56
57
  import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
57
58
  import type { CmsRouteConfig } from './config.js'
@@ -241,9 +242,26 @@ function slugEnum(values: readonly string[] | null) {
241
242
  : z.string().min(1)
242
243
  }
243
244
 
245
+ // Portable Text arrives as the serialized envelope JSON; reject anything that
246
+ // does not parse as a valid envelope so malformed structured bodies fail at
247
+ // the boundary instead of landing in D1.
248
+ const bodyPortableTextField = z
249
+ .string()
250
+ .superRefine((value, ctx) => {
251
+ if (parsePortableTextEnvelope(value) === null) {
252
+ ctx.addIssue({
253
+ code: z.ZodIssueCode.custom,
254
+ message: 'bodyPortableText must be a valid Portable Text envelope',
255
+ })
256
+ }
257
+ })
258
+ .optional()
259
+ .nullable()
260
+
244
261
  const BodyContentCreateSchema = z.object({
245
262
  bodyMarkdown: z.string().min(1),
246
263
  bodyHtml: z.string().optional().nullable(),
264
+ bodyPortableText: bodyPortableTextField,
247
265
  subtitle: z.string().optional().nullable(),
248
266
  wordCount: z.number().int().optional().nullable(),
249
267
  readTimeMinutes: z.number().int().optional().nullable(),
@@ -349,6 +367,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
349
367
  const ArticleContentUpdateSchema = z.object({
350
368
  bodyMarkdown: z.string().optional(),
351
369
  bodyHtml: z.string().optional().nullable(),
370
+ bodyPortableText: bodyPortableTextField,
352
371
  subtitle: z.string().optional().nullable(),
353
372
  wordCount: z.number().int().optional().nullable(),
354
373
  readTimeMinutes: z.number().int().optional().nullable(),
@@ -551,7 +570,7 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
551
570
  if (type === 'article' || type === 'newsletter' || type === 'page') {
552
571
  return ctx.db
553
572
  .prepare(
554
- `SELECT body_markdown, body_html, word_count, read_time_minutes, subtitle,
573
+ `SELECT body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle,
555
574
  ai_takeaways, ai_takeaways_at, ai_takeaways_model, editor_takeaways,
556
575
  ai_takeaways_correlation_id
557
576
  FROM article_content WHERE content_id = ? LIMIT 1`,
@@ -8,6 +8,14 @@ export {
8
8
  CMS_MIGRATIONS_0002_PLUS,
9
9
  splitStatements,
10
10
  } from './migrations.js'
11
+ export {
12
+ PORTABLE_TEXT_VERSION,
13
+ type PortableTextEnvelope,
14
+ type PortableTextObject,
15
+ parsePortableTextEnvelope,
16
+ portableTextEnvelopeSchema,
17
+ wrapPortableText,
18
+ } from './portable-text.js'
11
19
  export {
12
20
  CMS_TABLES,
13
21
  type CmsTableName,
@@ -827,6 +827,13 @@ SET label = (
827
827
  WHERE label IS NULL;
828
828
  `,
829
829
  },
830
+ {
831
+ id: '0024_article_portable_text',
832
+ sql: `
833
+ ALTER TABLE article_content
834
+ ADD COLUMN body_portable_text TEXT;
835
+ `,
836
+ },
830
837
  ]
831
838
 
832
839
  /**
@@ -0,0 +1,118 @@
1
+ // src/schema/portable-text.ts
2
+ // The Portable Text durable-store contract: envelope shape, schema version,
3
+ // and the boundary validator. The envelope — `{ version, content }` — is what
4
+ // `article_content.body_portable_text` holds as JSON text. Consumers (routes,
5
+ // site renderers) parse through here so malformed payloads fail loudly at the
6
+ // boundary instead of corrupting a render.
7
+ //
8
+ // Version 1 vocabulary (custom `_type`s follow the @portabletext/markdown
9
+ // canonical shapes so ecosystem tooling understands them):
10
+ // block standard Portable Text block (style, children, markDefs,
11
+ // listItem, level; decorators strong/em/code/strike-through;
12
+ // link annotations via markDefs)
13
+ // image { src, alt, title }
14
+ // code { language, code }
15
+ // tweetEmbed { url }
16
+ // horizontalRule {}
17
+ // table { headerRows, rows: [{ _type: 'row', cells: [{ _type:
18
+ // 'cell', value: block[] }] }] } — cells hold full Portable
19
+ // Text arrays so in-cell links are ordinary annotations.
20
+ // Cell extension fields: header, colspan, rowspan, colwidth,
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))
30
+
31
+ import { z } from 'zod'
32
+
33
+ export const PORTABLE_TEXT_VERSION = 1
34
+
35
+ /** Any Portable Text object: a typed object, optionally keyed. */
36
+ export interface PortableTextObject {
37
+ _type: string
38
+ _key?: string
39
+ [key: string]: unknown
40
+ }
41
+
42
+ export interface PortableTextEnvelope {
43
+ version: typeof PORTABLE_TEXT_VERSION
44
+ content: PortableTextObject[]
45
+ }
46
+
47
+ const typedObjectSchema = z
48
+ .object({
49
+ _type: z.string().min(1),
50
+ _key: z.string().optional(),
51
+ })
52
+ .passthrough()
53
+ .superRefine((obj, ctx) => {
54
+ const record = obj as Record<string, unknown>
55
+ const children = record.children
56
+ if (children !== undefined) {
57
+ if (
58
+ !Array.isArray(children) ||
59
+ children.some(
60
+ (child) =>
61
+ child === null ||
62
+ typeof child !== 'object' ||
63
+ typeof (child as Record<string, unknown>)._type !== 'string',
64
+ )
65
+ ) {
66
+ ctx.addIssue({
67
+ code: z.ZodIssueCode.custom,
68
+ message: 'children must be an array of _type-bearing objects',
69
+ })
70
+ }
71
+ }
72
+ const markDefs = record.markDefs
73
+ if (markDefs !== undefined) {
74
+ if (
75
+ !Array.isArray(markDefs) ||
76
+ markDefs.some(
77
+ (def) =>
78
+ def === null ||
79
+ typeof def !== 'object' ||
80
+ typeof (def as Record<string, unknown>)._type !== 'string' ||
81
+ typeof (def as Record<string, unknown>)._key !== 'string',
82
+ )
83
+ ) {
84
+ ctx.addIssue({
85
+ code: z.ZodIssueCode.custom,
86
+ message: 'markDefs must be an array of objects with _type and _key',
87
+ })
88
+ }
89
+ }
90
+ })
91
+
92
+ export const portableTextEnvelopeSchema = z.object({
93
+ version: z.literal(PORTABLE_TEXT_VERSION),
94
+ content: z.array(typedObjectSchema),
95
+ })
96
+
97
+ /** Wrap serialized blocks in the versioned storage envelope. */
98
+ export function wrapPortableText(content: PortableTextObject[]): PortableTextEnvelope {
99
+ return { version: PORTABLE_TEXT_VERSION, content }
100
+ }
101
+
102
+ /**
103
+ * Parse the stored JSON text into a validated envelope. Returns null for
104
+ * anything malformed — absent value, bad JSON, wrong shape, unknown version —
105
+ * so callers fall back to the markdown path instead of rendering garbage.
106
+ */
107
+ export function parsePortableTextEnvelope(raw: unknown): PortableTextEnvelope | null {
108
+ if (typeof raw !== 'string' || raw === '') return null
109
+ let decoded: unknown
110
+ try {
111
+ decoded = JSON.parse(raw)
112
+ } catch {
113
+ return null
114
+ }
115
+ const parsed = portableTextEnvelopeSchema.safeParse(decoded)
116
+ if (!parsed.success) return null
117
+ return parsed.data as PortableTextEnvelope
118
+ }
@@ -91,6 +91,8 @@ export interface ArticleContentRow {
91
91
  content_id: string
92
92
  body_markdown: string
93
93
  body_html: string | null
94
+ /** Portable Text envelope JSON (migration 0024); NULL → render markdown. */
95
+ body_portable_text: string | null
94
96
  word_count: number | null
95
97
  read_time_minutes: number | null
96
98
  subtitle: string | null
@@ -109,6 +109,7 @@ interface ExistingContentItem {
109
109
  interface ExistingContentPayload {
110
110
  body_markdown?: string | null
111
111
  body_html?: string | null
112
+ body_portable_text?: string | null
112
113
  subtitle?: string | null
113
114
  script?: string | null
114
115
  video_id?: string | null
@@ -131,6 +132,8 @@ interface FormState {
131
132
  title: string
132
133
  dek: string
133
134
  body: string
135
+ /** Serialized Portable Text envelope paired with `body`; null until the editor emits one. */
136
+ bodyPortableText: string | null
134
137
  slug: string
135
138
  byline: string | null
136
139
  authorId: string | null
@@ -176,6 +179,7 @@ function createEmptyFormState(docId: string | null): FormState {
176
179
  title: '',
177
180
  dek: '',
178
181
  body: '',
182
+ bodyPortableText: null,
179
183
  slug: '',
180
184
  byline: null,
181
185
  authorId: null,
@@ -508,8 +512,8 @@ export function ContentForm({
508
512
  scheduleAutosave(next)
509
513
  }
510
514
 
511
- function handleBodyChange(md: string) {
512
- const next = { ...latestForm.current, body: md }
515
+ function handleBodyChange(md: string, portableText: string) {
516
+ const next = { ...latestForm.current, body: md, bodyPortableText: portableText }
513
517
  setForm(next)
514
518
  publishDraft(next)
515
519
  scheduleAutosave(next)
@@ -940,6 +944,7 @@ export function ContentForm({
940
944
  )}
941
945
  <Rte
942
946
  value={form.body}
947
+ portableText={form.bodyPortableText}
943
948
  onChange={handleBodyChange}
944
949
  onImageUpload={handleEditorImageUpload}
945
950
  />
@@ -1365,6 +1370,7 @@ function contentResponseToFormState(
1365
1370
 
1366
1371
  if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
1367
1372
  next.body = toStringValue(content.body_markdown) || toStringValue(content.body_html)
1373
+ next.bodyPortableText = toNullableString(content.body_portable_text)
1368
1374
  }
1369
1375
 
1370
1376
  if (contentType === 'video') {
@@ -5,20 +5,42 @@
5
5
  // so the module can be imported in Node/SSR contexts without throwing.
6
6
  //
7
7
  // Serialization: the internal state is a Tiptap JSON doc; onChange emits
8
- // docToMarkdown(json) so callers work with markdown (matching the P0 engine
9
- // body_markdown storage format).
8
+ // docToMarkdown(json) the derived body_markdown AND the serialized
9
+ // Portable Text envelope (the durable store, schema/portable-text.ts). On
10
+ // load, a valid stored envelope takes precedence over markdown so structure
11
+ // that markdown cannot express (cell spans, alignment) survives an edit
12
+ // session; rows without Portable Text fall back to markdownToDoc.
10
13
  //
11
14
  // The toolbar maps RTE_TOOLS to editor.chain() toggle commands.
12
15
  //
13
16
  // Compile-gated: all Tiptap/React usage verified by `pnpm run build` (tsc).
14
17
  // Runtime/visual verification is done via the Playwright smoke (SMOKE.md P2 section).
15
18
 
16
- import { Editor } from '@tiptap/core'
19
+ import { Editor, type JSONContent } from '@tiptap/core'
17
20
  import { useEffect, useRef, useState } from 'react'
21
+ import { parsePortableTextEnvelope, wrapPortableText } from '../../schema/portable-text.js'
18
22
  import { Icon } from '../icons.js'
19
23
  import { createRteExtensions } from './extensions.js'
24
+ import { docToPortableText, portableTextToDoc } from './portable-text.js'
20
25
  import { docToMarkdown, markdownToDoc } from './serialize.js'
21
26
 
27
+ /** Editor doc for a stored (markdown, Portable Text) pair: envelope wins. */
28
+ function docFromStored(md: string, portableText: string | null | undefined): JSONContent {
29
+ const envelope = parsePortableTextEnvelope(portableText ?? null)
30
+ if (envelope) {
31
+ try {
32
+ return portableTextToDoc(envelope.content)
33
+ } catch {
34
+ // Unknown _type (content newer than this editor): fall back to markdown.
35
+ }
36
+ }
37
+ return markdownToDoc(md)
38
+ }
39
+
40
+ function serializeDocPortableText(doc: JSONContent): string {
41
+ return JSON.stringify(wrapPortableText(docToPortableText(doc)))
42
+ }
43
+
22
44
  // ---------------------------------------------------------------------------
23
45
  // Toolbar config
24
46
  // ---------------------------------------------------------------------------
@@ -35,6 +57,13 @@ type ToolCmd =
35
57
  | 'ordered'
36
58
  | 'link'
37
59
  | 'image'
60
+ | 'table'
61
+ | 'tableAddRow'
62
+ | 'tableAddCol'
63
+ | 'tableHeaderRow'
64
+ | 'tableDelRow'
65
+ | 'tableDelCol'
66
+ | 'tableDelete'
38
67
 
39
68
  interface ToolDef {
40
69
  cmd: ToolCmd
@@ -54,6 +83,17 @@ const RTE_TOOLS: ToolDef[] = [
54
83
  { cmd: 'ordered', label: 'Ordered list', icon: 'rows' },
55
84
  { cmd: 'link', label: 'Link', icon: 'link' },
56
85
  { cmd: 'image', label: 'Image', icon: 'image' },
86
+ { cmd: 'table', label: 'Insert table', icon: 'grid' },
87
+ ]
88
+
89
+ /** Shown only while the selection sits inside a table. */
90
+ const TABLE_TOOLS: ToolDef[] = [
91
+ { cmd: 'tableAddRow', label: 'Add row below', icon: 'rows' },
92
+ { cmd: 'tableAddCol', label: 'Add column right', icon: 'columns' },
93
+ { cmd: 'tableHeaderRow', label: 'Toggle header row', icon: 'layers' },
94
+ { cmd: 'tableDelRow', label: 'Delete row', icon: 'x' },
95
+ { cmd: 'tableDelCol', label: 'Delete column', icon: 'x' },
96
+ { cmd: 'tableDelete', label: 'Delete table', icon: 'trash' },
57
97
  ]
58
98
 
59
99
  // ---------------------------------------------------------------------------
@@ -63,8 +103,17 @@ const RTE_TOOLS: ToolDef[] = [
63
103
  export interface RteProps {
64
104
  /** Current markdown value (controlled). */
65
105
  value: string
66
- /** Called with the updated markdown on every doc change. */
67
- onChange: (md: string) => void
106
+ /**
107
+ * Stored Portable Text envelope JSON for the same body, if any. When it
108
+ * parses, it takes precedence over `value` for (re)loading editor content.
109
+ */
110
+ portableText?: string | null
111
+ /**
112
+ * Called with the updated markdown AND the serialized Portable Text
113
+ * envelope on every doc change. The two are always derived from the same
114
+ * doc — persist them together.
115
+ */
116
+ onChange: (md: string, portableText: string) => void
68
117
  /** Optional editor image uploader. When present, the image tool opens a file picker. */
69
118
  onImageUpload?: (file: File) => Promise<{ url: string }>
70
119
  /** Optional placeholder text. */
@@ -79,6 +128,7 @@ export interface RteProps {
79
128
 
80
129
  export function Rte({
81
130
  value,
131
+ portableText = null,
82
132
  onChange,
83
133
  onImageUpload,
84
134
  placeholder: _placeholder,
@@ -96,7 +146,11 @@ export function Rte({
96
146
  // clobbering the cursor. The editor is mounted once; callbacks stay fresh
97
147
  // via the ref.
98
148
  const initialValueRef = useRef(value)
149
+ const initialPortableTextRef = useRef(portableText)
99
150
  const lastAppliedValueRef = useRef(value)
151
+ const lastAppliedPortableTextRef = useRef(portableText ?? '')
152
+ const portableTextRef = useRef(portableText)
153
+ portableTextRef.current = portableText
100
154
  const onChangeRef = useRef(onChange)
101
155
  onChangeRef.current = onChange
102
156
 
@@ -109,13 +163,20 @@ export function Rte({
109
163
  const editor = new Editor({
110
164
  element: el,
111
165
  extensions: createRteExtensions(),
112
- content: markdownToDoc(initialValueRef.current),
166
+ content: docFromStored(initialValueRef.current, initialPortableTextRef.current),
113
167
  editable: !readOnly,
114
168
  onUpdate({ editor: e }) {
115
- const md = docToMarkdown(e.getJSON())
116
- if (md === lastAppliedValueRef.current) return
169
+ const json = e.getJSON()
170
+ const md = docToMarkdown(json)
171
+ const pt = serializeDocPortableText(json)
172
+ // Markdown alone is not enough to detect a change: cell spans and
173
+ // alignment have no markdown form, so compare both serializations.
174
+ if (md === lastAppliedValueRef.current && pt === lastAppliedPortableTextRef.current) {
175
+ return
176
+ }
117
177
  lastAppliedValueRef.current = md
118
- onChangeRef.current(md)
178
+ lastAppliedPortableTextRef.current = pt
179
+ onChangeRef.current(md, pt)
119
180
  },
120
181
  onSelectionUpdate() {
121
182
  // Re-render to reflect active state changes in the toolbar
@@ -139,7 +200,17 @@ export function Rte({
139
200
  if (!editor) return
140
201
  if (value === lastAppliedValueRef.current) return
141
202
  lastAppliedValueRef.current = value
142
- editor.commands.setContent(markdownToDoc(value))
203
+ // Trust the Portable Text prop only when it is NEW (differs from what
204
+ // the editor last emitted or applied). A markdown-only external update —
205
+ // e.g. an AI writeback — means the stored envelope is stale for this
206
+ // value, so parse the markdown instead of resurrecting old content.
207
+ const incomingPortableText = portableTextRef.current ?? ''
208
+ const freshPortableText =
209
+ incomingPortableText !== '' && incomingPortableText !== lastAppliedPortableTextRef.current
210
+ ? incomingPortableText
211
+ : null
212
+ if (freshPortableText !== null) lastAppliedPortableTextRef.current = freshPortableText
213
+ editor.commands.setContent(docFromStored(value, freshPortableText))
143
214
  }, [value])
144
215
 
145
216
  const editor = editorRef.current
@@ -179,6 +250,14 @@ export function Rte({
179
250
  disabled={tool.cmd === 'image' && uploadingImage}
180
251
  />
181
252
  ))}
253
+ {editor?.isActive('table') && (
254
+ <>
255
+ <span style={toolbarDivider} aria-hidden="true" />
256
+ {TABLE_TOOLS.map((tool) => (
257
+ <ToolButton key={tool.cmd} tool={tool} editor={editor} />
258
+ ))}
259
+ </>
260
+ )}
182
261
  {onImageUpload && (
183
262
  <input
184
263
  ref={imageInputRef}
@@ -265,6 +344,27 @@ function ToolButton({
265
344
  }
266
345
  break
267
346
  }
347
+ case 'table':
348
+ chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
349
+ break
350
+ case 'tableAddRow':
351
+ chain.addRowAfter().run()
352
+ break
353
+ case 'tableAddCol':
354
+ chain.addColumnAfter().run()
355
+ break
356
+ case 'tableHeaderRow':
357
+ chain.toggleHeaderRow().run()
358
+ break
359
+ case 'tableDelRow':
360
+ chain.deleteRow().run()
361
+ break
362
+ case 'tableDelCol':
363
+ chain.deleteColumn().run()
364
+ break
365
+ case 'tableDelete':
366
+ chain.deleteTable().run()
367
+ break
268
368
  }
269
369
  }
270
370
 
@@ -289,6 +389,8 @@ function ToolButton({
289
389
  return editor.isActive('bulletList')
290
390
  case 'ordered':
291
391
  return editor.isActive('orderedList')
392
+ case 'table':
393
+ return editor.isActive('table')
292
394
  default:
293
395
  return false
294
396
  }
@@ -342,6 +444,14 @@ const rteWrap: React.CSSProperties = {
342
444
  minHeight: 240,
343
445
  }
344
446
 
447
+ const toolbarDivider: React.CSSProperties = {
448
+ width: 1,
449
+ height: 18,
450
+ margin: '0 4px',
451
+ alignSelf: 'center',
452
+ background: 'var(--border)',
453
+ }
454
+
345
455
  const toolbarStyle: React.CSSProperties = {
346
456
  display: 'flex',
347
457
  flexWrap: 'wrap',
@@ -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
+ })
@@ -5,6 +5,8 @@ export interface ContentDraftFields {
5
5
  title: string
6
6
  dek: string
7
7
  body: string
8
+ /** Serialized Portable Text envelope for the body; null until the editor emits one. */
9
+ bodyPortableText?: string | null
8
10
  videoUrl: string
9
11
  videoSourceKind?: string | null
10
12
  videoId?: string | null
@@ -104,6 +106,7 @@ export function buildContentUpdatePayload(
104
106
  const wordCount = countWords(draft.body)
105
107
  payload.content = {
106
108
  bodyMarkdown: draft.body,
109
+ bodyPortableText: draft.bodyPortableText || null,
107
110
  wordCount,
108
111
  readTimeMinutes: estimateReadTime(wordCount),
109
112
  }
@@ -1,6 +1,8 @@
1
1
  import Image from '@tiptap/extension-image'
2
2
  import Link from '@tiptap/extension-link'
3
+ import { TableKit } from '@tiptap/extension-table'
3
4
  import StarterKit from '@tiptap/starter-kit'
5
+ import { Callout, Citation, GeneratedFaq, PullQuote } from './blocks.js'
4
6
  import { TrimBoundaryMarks } from './trim-boundary-marks.js'
5
7
  import { TweetEmbed } from './tweet-embed.js'
6
8
 
@@ -19,5 +21,12 @@ export function createRteExtensions() {
19
21
  TrimBoundaryMarks,
20
22
  Image,
21
23
  TweetEmbed,
24
+ // One consolidated package registers table/tableRow/tableHeader/tableCell.
25
+ // Gapcursor (via StarterKit) lets the caret sit before/after a table.
26
+ TableKit.configure({ table: { resizable: true } }),
27
+ Callout,
28
+ PullQuote,
29
+ GeneratedFaq,
30
+ Citation,
22
31
  ]
23
32
  }