@growth-labs/cms 0.5.20 → 0.5.22
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 +32 -0
- package/dist/engine/published-content.js +1 -1
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +8 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +15 -8
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +6 -2
- package/dist/engine/revisions.js.map +1 -1
- package/dist/puck/config.d.ts +15 -0
- package/dist/puck/config.d.ts.map +1 -0
- package/dist/puck/config.js +118 -0
- package/dist/puck/config.js.map +1 -0
- package/dist/puck/index.d.ts +4 -0
- package/dist/puck/index.d.ts.map +1 -0
- package/dist/puck/index.js +10 -0
- package/dist/puck/index.js.map +1 -0
- package/dist/puck/markdown.d.ts +5 -0
- package/dist/puck/markdown.d.ts.map +1 -0
- package/dist/puck/markdown.js +78 -0
- package/dist/puck/markdown.js.map +1 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +17 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/layout.d.ts +126 -0
- package/dist/schema/layout.d.ts.map +1 -0
- package/dist/schema/layout.js +52 -0
- package/dist/schema/layout.js.map +1 -0
- 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/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/serialize.js +20 -2
- package/dist/ui/editor/serialize.js.map +1 -1
- package/migrations/0025_page_layout_json.sql +7 -0
- package/package.json +12 -1
- package/src/engine/published-content.ts +1 -1
- package/src/engine/publisher.ts +21 -6
- package/src/engine/revisions.ts +12 -2
- package/src/puck/config.tsx +185 -0
- package/src/puck/index.ts +23 -0
- package/src/puck/markdown.ts +78 -0
- package/src/routes/content.ts +18 -1
- package/src/schema/layout.ts +73 -0
- package/src/schema/migrations.ts +7 -0
- package/src/schema/types.ts +2 -0
- package/src/ui/editor/serialize.ts +24 -2
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// src/puck/config.tsx
|
|
2
|
+
// The cms-owned Puck component library (v1): the Config consumed by both the
|
|
3
|
+
// admin editor (<Puck>) and the public renderer (<Render> from
|
|
4
|
+
// @puckeditor/core/rsc). Every render function is server-safe — plain JSX,
|
|
5
|
+
// no hooks, no DOM access — so the fronts Worker can render layouts with
|
|
6
|
+
// zero client JS. @puckeditor/core itself is only a TYPE dependency here;
|
|
7
|
+
// the runtime dependency lives with the consumer (optional peer).
|
|
8
|
+
//
|
|
9
|
+
// RichText stores a Portable Text envelope (schema/portable-text.ts) in its
|
|
10
|
+
// props — NEVER Puck's built-in `richtext` field, whose /rsc render path
|
|
11
|
+
// requires a DOM (@tiptap/html + happy-dom) and breaks on workerd. The HTML
|
|
12
|
+
// projection is injected by the consumer so site render pipelines (sanitize,
|
|
13
|
+
// heading ids, entity linking) stay site-owned.
|
|
14
|
+
|
|
15
|
+
import type { Config } from '@puckeditor/core'
|
|
16
|
+
import type { ReactNode } from 'react'
|
|
17
|
+
|
|
18
|
+
export interface PuckConfigOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Portable Text envelope JSON → trusted HTML for RichText blocks. The
|
|
21
|
+
* consumer owns this (fronts routes it through its article render
|
|
22
|
+
* pipeline). Without it, RichText renders a loud inline marker instead of
|
|
23
|
+
* silently dropping content.
|
|
24
|
+
*/
|
|
25
|
+
renderRichTextHtml?: (portableTextEnvelopeJson: string) => string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The v1 layout component set, in palette order. */
|
|
29
|
+
export const LAYOUT_COMPONENT_TYPES = [
|
|
30
|
+
'Section',
|
|
31
|
+
'Heading',
|
|
32
|
+
'RichText',
|
|
33
|
+
'Image',
|
|
34
|
+
'CTAButton',
|
|
35
|
+
'Spacer',
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
export type LayoutComponentType = (typeof LAYOUT_COMPONENT_TYPES)[number]
|
|
39
|
+
|
|
40
|
+
export function createPuckConfig(options: PuckConfigOptions = {}): Config {
|
|
41
|
+
const { renderRichTextHtml } = options
|
|
42
|
+
return {
|
|
43
|
+
components: {
|
|
44
|
+
Section: {
|
|
45
|
+
label: 'Section',
|
|
46
|
+
fields: {
|
|
47
|
+
width: {
|
|
48
|
+
type: 'select',
|
|
49
|
+
label: 'Width',
|
|
50
|
+
options: [
|
|
51
|
+
{ label: 'Normal', value: 'normal' },
|
|
52
|
+
{ label: 'Wide', value: 'wide' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
content: { type: 'slot' },
|
|
56
|
+
},
|
|
57
|
+
defaultProps: { width: 'normal', content: [] },
|
|
58
|
+
render: ({ width, content }) => (
|
|
59
|
+
<section className={`gl-layout-section gl-layout-section--${width || 'normal'}`}>
|
|
60
|
+
{typeof content === 'function' ? content() : null}
|
|
61
|
+
</section>
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
Heading: {
|
|
65
|
+
label: 'Heading',
|
|
66
|
+
fields: {
|
|
67
|
+
text: { type: 'text', label: 'Text', contentEditable: true },
|
|
68
|
+
level: {
|
|
69
|
+
type: 'select',
|
|
70
|
+
label: 'Level',
|
|
71
|
+
options: [
|
|
72
|
+
{ label: 'Heading 2', value: '2' },
|
|
73
|
+
{ label: 'Heading 3', value: '3' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
defaultProps: { text: '', level: '2' },
|
|
78
|
+
render: ({ text, level }) =>
|
|
79
|
+
level === '3' ? (
|
|
80
|
+
<h3 className="gl-layout-heading">{text}</h3>
|
|
81
|
+
) : (
|
|
82
|
+
<h2 className="gl-layout-heading">{text}</h2>
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
RichText: {
|
|
86
|
+
label: 'Rich text',
|
|
87
|
+
// Placeholder field until the Builder-mode editor lands its
|
|
88
|
+
// Portable Text custom field; the stored prop shape is final.
|
|
89
|
+
fields: {
|
|
90
|
+
portableText: { type: 'textarea', label: 'Portable Text envelope' },
|
|
91
|
+
},
|
|
92
|
+
defaultProps: { portableText: '' },
|
|
93
|
+
render: ({ portableText }) => {
|
|
94
|
+
const raw = typeof portableText === 'string' ? portableText : ''
|
|
95
|
+
if (renderRichTextHtml && raw !== '') {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className="gl-layout-richtext"
|
|
99
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: the consumer-injected projection owns sanitization
|
|
100
|
+
dangerouslySetInnerHTML={{ __html: renderRichTextHtml(raw) }}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
// Loud, greppable marker — mirrors the fronts data-pt-unknown
|
|
105
|
+
// convention so a missing renderer never silently drops prose.
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
className="gl-layout-richtext"
|
|
109
|
+
data-gl-layout-unknown="richtext-renderer-missing"
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
Image: {
|
|
115
|
+
label: 'Image',
|
|
116
|
+
fields: {
|
|
117
|
+
src: { type: 'text', label: 'Image URL' },
|
|
118
|
+
alt: { type: 'text', label: 'Alt text' },
|
|
119
|
+
caption: { type: 'text', label: 'Caption' },
|
|
120
|
+
},
|
|
121
|
+
defaultProps: { src: '', alt: '', caption: '' },
|
|
122
|
+
render: ({ src, alt, caption }) => (
|
|
123
|
+
<figure className="gl-layout-image">
|
|
124
|
+
{typeof src === 'string' && src !== '' ? (
|
|
125
|
+
<img src={src} alt={typeof alt === 'string' ? alt : ''} loading="lazy" />
|
|
126
|
+
) : null}
|
|
127
|
+
{typeof caption === 'string' && caption !== '' ? (
|
|
128
|
+
<figcaption>{caption}</figcaption>
|
|
129
|
+
) : null}
|
|
130
|
+
</figure>
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
CTAButton: {
|
|
134
|
+
label: 'CTA button',
|
|
135
|
+
fields: {
|
|
136
|
+
label: { type: 'text', label: 'Label', contentEditable: true },
|
|
137
|
+
href: { type: 'text', label: 'Link URL' },
|
|
138
|
+
tone: {
|
|
139
|
+
type: 'select',
|
|
140
|
+
label: 'Tone',
|
|
141
|
+
options: [
|
|
142
|
+
{ label: 'Primary', value: 'primary' },
|
|
143
|
+
{ label: 'Secondary', value: 'secondary' },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
defaultProps: { label: '', href: '', tone: 'primary' },
|
|
148
|
+
render: ({ label, href, tone }) => (
|
|
149
|
+
<a
|
|
150
|
+
className={`gl-layout-cta gl-layout-cta--${tone || 'primary'}`}
|
|
151
|
+
href={typeof href === 'string' && href !== '' ? href : undefined}
|
|
152
|
+
>
|
|
153
|
+
{label}
|
|
154
|
+
</a>
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
Spacer: {
|
|
158
|
+
label: 'Spacer',
|
|
159
|
+
fields: {
|
|
160
|
+
size: {
|
|
161
|
+
type: 'select',
|
|
162
|
+
label: 'Size',
|
|
163
|
+
options: [
|
|
164
|
+
{ label: 'Small', value: 'sm' },
|
|
165
|
+
{ label: 'Medium', value: 'md' },
|
|
166
|
+
{ label: 'Large', value: 'lg' },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
defaultProps: { size: 'md' },
|
|
171
|
+
render: ({ size }) => (
|
|
172
|
+
<div
|
|
173
|
+
className={`gl-layout-spacer gl-layout-spacer--${size || 'md'}`}
|
|
174
|
+
aria-hidden="true"
|
|
175
|
+
/>
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
root: {
|
|
180
|
+
// The page shell (nav, footer, prose width) belongs to the consumer;
|
|
181
|
+
// the layout root is deliberately chromeless.
|
|
182
|
+
render: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/puck/index.ts
|
|
2
|
+
// The `@growth-labs/cms/puck` subpath: the page-builder component library
|
|
3
|
+
// (Puck Config factory + server-safe React renders), the derived-markdown
|
|
4
|
+
// projection, and the layout storage envelope contract. Consumers pair this
|
|
5
|
+
// with @puckeditor/core (optional peer): the admin editor mounts <Puck>, the
|
|
6
|
+
// public site renders with <Render> from @puckeditor/core/rsc.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
LAYOUT_VERSION,
|
|
10
|
+
type LayoutComponent,
|
|
11
|
+
type LayoutData,
|
|
12
|
+
type LayoutEnvelope,
|
|
13
|
+
layoutEnvelopeSchema,
|
|
14
|
+
parseLayoutEnvelope,
|
|
15
|
+
wrapLayout,
|
|
16
|
+
} from '../schema/layout.js'
|
|
17
|
+
export {
|
|
18
|
+
createPuckConfig,
|
|
19
|
+
LAYOUT_COMPONENT_TYPES,
|
|
20
|
+
type LayoutComponentType,
|
|
21
|
+
type PuckConfigOptions,
|
|
22
|
+
} from './config.js'
|
|
23
|
+
export { layoutJsonToMarkdown, layoutToMarkdown } from './markdown.js'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/puck/markdown.ts
|
|
2
|
+
// Derived-markdown projection: turn a stored layout envelope back into a
|
|
3
|
+
// classic body_markdown string. Builder-mode saves use this to keep
|
|
4
|
+
// body_markdown populated (search, SEO extraction, non-layout surfaces) while
|
|
5
|
+
// layout_json stays the source of truth for builder pages. The projection is
|
|
6
|
+
// lossy by design — structural chrome (Section widths, Spacers) has no
|
|
7
|
+
// markdown equivalent and is dropped.
|
|
8
|
+
|
|
9
|
+
import { type LayoutComponent, type LayoutEnvelope, parseLayoutEnvelope } from '../schema/layout.js'
|
|
10
|
+
import { parsePortableTextEnvelope } from '../schema/portable-text.js'
|
|
11
|
+
import { portableTextToDoc } from '../ui/editor/portable-text.js'
|
|
12
|
+
import { docToMarkdown } from '../ui/editor/serialize.js'
|
|
13
|
+
|
|
14
|
+
export function layoutToMarkdown(envelope: LayoutEnvelope): string {
|
|
15
|
+
return componentsToMarkdown(envelope.data.content).join('\n\n')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Parse stored layout JSON and project it; null when the JSON is malformed. */
|
|
19
|
+
export function layoutJsonToMarkdown(raw: unknown): string | null {
|
|
20
|
+
const envelope = parseLayoutEnvelope(raw)
|
|
21
|
+
if (!envelope) return null
|
|
22
|
+
return layoutToMarkdown(envelope)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function componentsToMarkdown(components: LayoutComponent[]): string[] {
|
|
26
|
+
const fragments: string[] = []
|
|
27
|
+
for (const component of components) {
|
|
28
|
+
for (const fragment of componentToMarkdown(component)) {
|
|
29
|
+
const trimmed = fragment.trim()
|
|
30
|
+
if (trimmed !== '') fragments.push(trimmed)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return fragments
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function componentToMarkdown(component: LayoutComponent): string[] {
|
|
37
|
+
const props = component.props ?? {}
|
|
38
|
+
switch (component.type) {
|
|
39
|
+
case 'Heading': {
|
|
40
|
+
const text = typeof props.text === 'string' ? props.text.trim() : ''
|
|
41
|
+
if (text === '') return []
|
|
42
|
+
const level = props.level === '3' ? 3 : 2
|
|
43
|
+
return [`${'#'.repeat(level)} ${text}`]
|
|
44
|
+
}
|
|
45
|
+
case 'RichText': {
|
|
46
|
+
const parsed = parsePortableTextEnvelope(props.portableText)
|
|
47
|
+
if (!parsed) return []
|
|
48
|
+
return [docToMarkdown(portableTextToDoc(parsed.content))]
|
|
49
|
+
}
|
|
50
|
+
case 'Image': {
|
|
51
|
+
const src = typeof props.src === 'string' ? props.src.trim() : ''
|
|
52
|
+
if (src === '') return []
|
|
53
|
+
const alt = typeof props.alt === 'string' ? props.alt : ''
|
|
54
|
+
return [``]
|
|
55
|
+
}
|
|
56
|
+
case 'CTAButton': {
|
|
57
|
+
const label = typeof props.label === 'string' ? props.label.trim() : ''
|
|
58
|
+
const href = typeof props.href === 'string' ? props.href.trim() : ''
|
|
59
|
+
if (label === '' || href === '') return []
|
|
60
|
+
return [`[${label}](${href})`]
|
|
61
|
+
}
|
|
62
|
+
case 'Section': {
|
|
63
|
+
// Slot content is stored inline under the slot prop as
|
|
64
|
+
// { type, props }[] (Puck 0.23 slot fields; ids optional).
|
|
65
|
+
if (!Array.isArray(props.content)) return []
|
|
66
|
+
const nested = props.content.filter(
|
|
67
|
+
(entry): entry is LayoutComponent =>
|
|
68
|
+
typeof entry === 'object' &&
|
|
69
|
+
entry !== null &&
|
|
70
|
+
typeof (entry as LayoutComponent).type === 'string',
|
|
71
|
+
)
|
|
72
|
+
return componentsToMarkdown(nested)
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
// Spacer and unknown/future types have no markdown projection.
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
}
|
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 { parseLayoutEnvelope } from '../schema/layout.js'
|
|
55
56
|
import { parsePortableTextEnvelope } from '../schema/portable-text.js'
|
|
56
57
|
import type { ContentStatus } from '../schema/types.js'
|
|
57
58
|
import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
|
|
@@ -258,10 +259,25 @@ const bodyPortableTextField = z
|
|
|
258
259
|
.optional()
|
|
259
260
|
.nullable()
|
|
260
261
|
|
|
262
|
+
// Puck layout envelopes get the same boundary treatment.
|
|
263
|
+
const layoutJsonField = z
|
|
264
|
+
.string()
|
|
265
|
+
.superRefine((value, ctx) => {
|
|
266
|
+
if (parseLayoutEnvelope(value) === null) {
|
|
267
|
+
ctx.addIssue({
|
|
268
|
+
code: z.ZodIssueCode.custom,
|
|
269
|
+
message: 'layoutJson must be a valid layout envelope',
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
.optional()
|
|
274
|
+
.nullable()
|
|
275
|
+
|
|
261
276
|
const BodyContentCreateSchema = z.object({
|
|
262
277
|
bodyMarkdown: z.string().min(1),
|
|
263
278
|
bodyHtml: z.string().optional().nullable(),
|
|
264
279
|
bodyPortableText: bodyPortableTextField,
|
|
280
|
+
layoutJson: layoutJsonField,
|
|
265
281
|
subtitle: z.string().optional().nullable(),
|
|
266
282
|
wordCount: z.number().int().optional().nullable(),
|
|
267
283
|
readTimeMinutes: z.number().int().optional().nullable(),
|
|
@@ -368,6 +384,7 @@ const ArticleContentUpdateSchema = z.object({
|
|
|
368
384
|
bodyMarkdown: z.string().optional(),
|
|
369
385
|
bodyHtml: z.string().optional().nullable(),
|
|
370
386
|
bodyPortableText: bodyPortableTextField,
|
|
387
|
+
layoutJson: layoutJsonField,
|
|
371
388
|
subtitle: z.string().optional().nullable(),
|
|
372
389
|
wordCount: z.number().int().optional().nullable(),
|
|
373
390
|
readTimeMinutes: z.number().int().optional().nullable(),
|
|
@@ -570,7 +587,7 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
|
|
|
570
587
|
if (type === 'article' || type === 'newsletter' || type === 'page') {
|
|
571
588
|
return ctx.db
|
|
572
589
|
.prepare(
|
|
573
|
-
`SELECT body_markdown, body_html, body_portable_text, word_count, read_time_minutes, subtitle,
|
|
590
|
+
`SELECT body_markdown, body_html, body_portable_text, layout_json, word_count, read_time_minutes, subtitle,
|
|
574
591
|
ai_takeaways, ai_takeaways_at, ai_takeaways_model, editor_takeaways,
|
|
575
592
|
ai_takeaways_correlation_id
|
|
576
593
|
FROM article_content WHERE content_id = ? LIMIT 1`,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/schema/layout.ts
|
|
2
|
+
// The page-layout durable-store contract: envelope shape, version, and the
|
|
3
|
+
// boundary validator. `article_content.layout_json` holds the envelope —
|
|
4
|
+
// `{ version, data }` — where `data` is a Puck Data document (content array
|
|
5
|
+
// of typed components, root props, optional zones). The internal adapter
|
|
6
|
+
// (src/puck/) owns the Puck-specific semantics; this module only guarantees
|
|
7
|
+
// the stored shape is structurally sound so malformed layouts fail loudly at
|
|
8
|
+
// the boundary and renderers fall back to the classic body path.
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
export const LAYOUT_VERSION = 1
|
|
13
|
+
|
|
14
|
+
export interface LayoutComponent {
|
|
15
|
+
type: string
|
|
16
|
+
props?: Record<string, unknown>
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LayoutData {
|
|
21
|
+
content: LayoutComponent[]
|
|
22
|
+
root?: Record<string, unknown>
|
|
23
|
+
zones?: Record<string, LayoutComponent[]>
|
|
24
|
+
[key: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LayoutEnvelope {
|
|
28
|
+
version: typeof LAYOUT_VERSION
|
|
29
|
+
data: LayoutData
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const layoutComponentSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
type: z.string().min(1),
|
|
35
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
36
|
+
})
|
|
37
|
+
.passthrough()
|
|
38
|
+
|
|
39
|
+
const layoutDataSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
content: z.array(layoutComponentSchema),
|
|
42
|
+
root: z.record(z.string(), z.unknown()).optional(),
|
|
43
|
+
zones: z.record(z.string(), z.array(layoutComponentSchema)).optional(),
|
|
44
|
+
})
|
|
45
|
+
.passthrough()
|
|
46
|
+
|
|
47
|
+
export const layoutEnvelopeSchema = z.object({
|
|
48
|
+
version: z.literal(LAYOUT_VERSION),
|
|
49
|
+
data: layoutDataSchema,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** Wrap a Puck Data document in the versioned storage envelope. */
|
|
53
|
+
export function wrapLayout(data: LayoutData): LayoutEnvelope {
|
|
54
|
+
return { version: LAYOUT_VERSION, data }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse stored layout JSON into a validated envelope. Returns null for
|
|
59
|
+
* anything malformed — absent value, bad JSON, wrong shape, unknown version —
|
|
60
|
+
* so callers fall back to the classic body render path.
|
|
61
|
+
*/
|
|
62
|
+
export function parseLayoutEnvelope(raw: unknown): LayoutEnvelope | null {
|
|
63
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
64
|
+
let decoded: unknown
|
|
65
|
+
try {
|
|
66
|
+
decoded = JSON.parse(raw)
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const parsed = layoutEnvelopeSchema.safeParse(decoded)
|
|
71
|
+
if (!parsed.success) return null
|
|
72
|
+
return parsed.data as LayoutEnvelope
|
|
73
|
+
}
|
package/src/schema/migrations.ts
CHANGED
package/src/schema/types.ts
CHANGED
|
@@ -93,6 +93,8 @@ export interface ArticleContentRow {
|
|
|
93
93
|
body_html: string | null
|
|
94
94
|
/** Portable Text envelope JSON (migration 0024); NULL → render markdown. */
|
|
95
95
|
body_portable_text: string | null
|
|
96
|
+
/** Puck layout envelope JSON (migration 0025); NULL → classic page. */
|
|
97
|
+
layout_json: string | null
|
|
96
98
|
word_count: number | null
|
|
97
99
|
read_time_minutes: number | null
|
|
98
100
|
subtitle: string | null
|
|
@@ -633,7 +633,13 @@ function parseInline(text: string): JSONContent[] {
|
|
|
633
633
|
}
|
|
634
634
|
|
|
635
635
|
// ── bold (**text** or __text__) ────────────────────────────────────
|
|
636
|
-
const
|
|
636
|
+
const boldStarMatch = /^\*\*([^*]+)\*\*/.exec(remaining)
|
|
637
|
+
const boldUnderscoreMatch = boldStarMatch ? null : /^__([^_]+)__/.exec(remaining)
|
|
638
|
+
const boldMatch =
|
|
639
|
+
boldStarMatch ??
|
|
640
|
+
(boldUnderscoreMatch && !isIntrawordUnderscore(text, remaining, boldUnderscoreMatch)
|
|
641
|
+
? boldUnderscoreMatch
|
|
642
|
+
: null)
|
|
637
643
|
if (boldMatch) {
|
|
638
644
|
const inner = parseInlineWithMark(boldMatch[1], { type: 'bold' })
|
|
639
645
|
nodes.push(...inner)
|
|
@@ -656,7 +662,10 @@ function parseInline(text: string): JSONContent[] {
|
|
|
656
662
|
// underline mark, which round-tripped underline↔italic inconsistently
|
|
657
663
|
// and was the visible half of the Word-paste formatting incidents.
|
|
658
664
|
const underscoreEmphasisMatch = /^_([^_]+)_/.exec(remaining)
|
|
659
|
-
if (
|
|
665
|
+
if (
|
|
666
|
+
underscoreEmphasisMatch &&
|
|
667
|
+
!isIntrawordUnderscore(text, remaining, underscoreEmphasisMatch)
|
|
668
|
+
) {
|
|
660
669
|
const inner = parseInlineWithMark(underscoreEmphasisMatch[1], { type: 'italic' })
|
|
661
670
|
nodes.push(...inner)
|
|
662
671
|
remaining = remaining.slice(underscoreEmphasisMatch[0].length)
|
|
@@ -692,6 +701,19 @@ function parseInline(text: string): JSONContent[] {
|
|
|
692
701
|
return mergeTextNodes(nodes)
|
|
693
702
|
}
|
|
694
703
|
|
|
704
|
+
/**
|
|
705
|
+
* CommonMark's intraword rule for `_`: an underscore flanked by alphanumerics
|
|
706
|
+
* (like `?gaa_at=eafs&gaa_n=…` in a bare URL, or `snake_case`) is literal
|
|
707
|
+
* text, never emphasis. Without this, URLs with query params get italic marks
|
|
708
|
+
* spliced through them and re-serialize corrupted.
|
|
709
|
+
*/
|
|
710
|
+
function isIntrawordUnderscore(text: string, remaining: string, match: RegExpExecArray): boolean {
|
|
711
|
+
const consumed = text.length - remaining.length
|
|
712
|
+
const previousChar = consumed > 0 ? text[consumed - 1] : ''
|
|
713
|
+
const nextChar = remaining[match[0].length] ?? ''
|
|
714
|
+
return /[A-Za-z0-9]/.test(previousChar) || /[A-Za-z0-9]/.test(nextChar)
|
|
715
|
+
}
|
|
716
|
+
|
|
695
717
|
function parseInlineWithMark(text: string, mark: InlineMark): JSONContent[] {
|
|
696
718
|
const inner = parseInline(text)
|
|
697
719
|
return inner.map((n) => ({
|