@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
|
@@ -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
|
//  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 (
|
|
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); }
|