@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.
- package/README.md +66 -0
- package/dist/engine/published-content.js +1 -1
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +6 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +16 -7
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +1 -0
- package/dist/engine/revisions.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +19 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +52 -0
- package/dist/schema/portable-text.d.ts.map +1 -0
- package/dist/schema/portable-text.js +94 -0
- package/dist/schema/portable-text.js.map +1 -0
- package/dist/schema/types.d.ts +2 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +5 -3
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/Rte.d.ts +12 -3
- package/dist/ui/editor/Rte.d.ts.map +1 -1
- package/dist/ui/editor/Rte.js +91 -10
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/editor/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/content-payload.d.ts +2 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +1 -0
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/extensions.d.ts.map +1 -1
- package/dist/ui/editor/extensions.js +9 -0
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/portable-text.d.ts +7 -0
- package/dist/ui/editor/portable-text.d.ts.map +1 -0
- package/dist/ui/editor/portable-text.js +517 -0
- package/dist/ui/editor/portable-text.js.map +1 -0
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +195 -5
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +21 -0
- package/migrations/0024_article_portable_text.sql +6 -0
- package/package.json +2 -1
- package/src/engine/published-content.ts +1 -1
- package/src/engine/publisher.ts +22 -5
- package/src/engine/revisions.ts +2 -0
- package/src/routes/content.ts +20 -1
- package/src/schema/index.ts +8 -0
- package/src/schema/migrations.ts +7 -0
- package/src/schema/portable-text.ts +118 -0
- package/src/schema/types.ts +2 -0
- package/src/ui/editor/ContentForm.tsx +8 -2
- package/src/ui/editor/Rte.tsx +120 -10
- package/src/ui/editor/blocks.ts +140 -0
- package/src/ui/editor/content-payload.ts +3 -0
- package/src/ui/editor/extensions.ts +9 -0
- package/src/ui/editor/portable-text.ts +582 -0
- package/src/ui/editor/serialize.ts +207 -5
- package/src/ui/styles/broadsheet.css +21 -0
package/src/routes/content.ts
CHANGED
|
@@ -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`,
|
package/src/schema/index.ts
CHANGED
|
@@ -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,
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/schema/types.ts
CHANGED
|
@@ -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') {
|
package/src/ui/editor/Rte.tsx
CHANGED
|
@@ -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)
|
|
9
|
-
//
|
|
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
|
-
/**
|
|
67
|
-
|
|
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:
|
|
166
|
+
content: docFromStored(initialValueRef.current, initialPortableTextRef.current),
|
|
113
167
|
editable: !readOnly,
|
|
114
168
|
onUpdate({ editor: e }) {
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|