Skip to content

Commit 61d64fd

Browse files
committed
feat(cms): render lexical blocks in the version diff view
Port the reusable lexical block-diff plugin from the eve-travel repo so that blocks embedded in rich text render their field contents in the admin version diff view instead of showing only the block name/slug. - Add the generic `shared/lexical/plugin` folder (auto block converter, HTML helpers, editor wrapper) plus the project glue files (`LexicalBlocksDiffComponent`, `diffComponentPath`). - Wire `lexicalEditorWithBlockDiff` into the root editor and the Articles `content` editor (the only editor embedding blocks: CodeBlock). - Register the diff component in the generated import map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rhs2Em2ipydqqPHpH464J4
1 parent f918df5 commit 61d64fd

12 files changed

Lines changed: 880 additions & 18 deletions

cms/src/app/(payload)/admin/importMap.js

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cms/src/collections/Articles.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin'
2-
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
2+
import { BlocksFeature } from '@payloadcms/richtext-lexical'
33

44
import CodeBlock from '@/blocks/CodeBlock'
55
import { authenticated } from '@/shared/access/authenticated'
66
import { CollectionGroups } from '@/shared/CollectionGroups'
77
import { restrictMcpToDraft } from '@/shared/hooks/restrictMcpToDraft'
88
import { lazyLoadingLivePreviewComponent } from '@/shared/lazyLoadingLivePreviewComponent'
9+
import { DIFF_COMPONENT_PATH } from '@/shared/lexical/diffComponentPath'
10+
import { lexicalEditorWithBlockDiff } from '@/shared/lexical/plugin'
911

1012
const Articles: PageCollectionConfig = {
1113
slug: 'articles',
@@ -113,14 +115,17 @@ const Articles: PageCollectionConfig = {
113115
{
114116
name: 'content',
115117
type: 'richText',
116-
editor: lexicalEditor({
117-
features: ({ rootFeatures }) => [
118-
...rootFeatures,
119-
BlocksFeature({
120-
blocks: [CodeBlock],
121-
}),
122-
],
123-
}),
118+
editor: lexicalEditorWithBlockDiff(
119+
{
120+
features: ({ rootFeatures }) => [
121+
...rootFeatures,
122+
BlocksFeature({
123+
blocks: [CodeBlock],
124+
}),
125+
],
126+
},
127+
{ diffComponentPath: DIFF_COMPONENT_PATH },
128+
),
124129
label: {
125130
de: 'Inhalt',
126131
en: 'Content',

cms/src/payload.config.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { resendAdapter } from '@payloadcms/email-resend'
1717
import { mcpPlugin, type MCPPluginConfig } from '@payloadcms/plugin-mcp'
1818
import { searchPlugin } from '@payloadcms/plugin-search'
1919
import { seoPlugin } from '@payloadcms/plugin-seo'
20-
import { FixedToolbarFeature, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical'
20+
import { FixedToolbarFeature, LinkFeature } from '@payloadcms/richtext-lexical'
2121
import { s3Storage } from '@payloadcms/storage-s3'
2222
import { de } from '@payloadcms/translations/languages/de'
2323
import { en } from '@payloadcms/translations/languages/en'
@@ -68,6 +68,8 @@ import { listResources, readResource } from './mcp/resources'
6868
import { authenticated } from './shared/access/authenticated'
6969
import { CollectionGroups } from './shared/CollectionGroups'
7070
import { customTranslations } from './shared/customTranslations'
71+
import { DIFF_COMPONENT_PATH } from './shared/lexical/diffComponentPath'
72+
import { lexicalEditorWithBlockDiff } from './shared/lexical/plugin'
7173

7274
const filename = fileURLToPath(import.meta.url)
7375
const dirname = path.dirname(filename)
@@ -391,13 +393,16 @@ export default buildConfig({
391393
allowIDOnCreate: true,
392394
url: process.env.MONGODB_URI!,
393395
}),
394-
editor: lexicalEditor({
395-
features: ({ defaultFeatures }) => [
396-
...defaultFeatures.filter((feature) => feature.key !== 'relationship'),
397-
FixedToolbarFeature(),
398-
LinkFeature({ enabledCollections: pageCollectionsSlugs }),
399-
],
400-
}),
396+
editor: lexicalEditorWithBlockDiff(
397+
{
398+
features: ({ defaultFeatures }) => [
399+
...defaultFeatures.filter((feature) => feature.key !== 'relationship'),
400+
FixedToolbarFeature(),
401+
LinkFeature({ enabledCollections: pageCollectionsSlugs }),
402+
],
403+
},
404+
{ diffComponentPath: DIFF_COMPONENT_PATH },
405+
),
401406
email: resendAdapter({
402407
apiKey: process.env.RESEND_API_KEY!,
403408
defaultFromAddress: 'cms@jhb.software',
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createLexicalBlocksDiffComponent } from './plugin'
2+
3+
/**
4+
* Renders Lexical blocks in the admin version diff view. Every block in the
5+
* Payload config gets an auto-generated converter derived from its field
6+
* config — no per-block overrides are needed on this site yet. Add an
7+
* `overrides` map (see the plugin README) if a block ever needs a bespoke
8+
* diff layout.
9+
*/
10+
export const LexicalBlocksDiffComponent = createLexicalBlocksDiffComponent()
11+
12+
export default LexicalBlocksDiffComponent
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const DIFF_COMPONENT_PATH =
2+
'/shared/lexical/LexicalBlocksDiffComponent#LexicalBlocksDiffComponent'
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Lexical Block Diff
2+
3+
Generic utilities for rendering custom Payload Lexical blocks in the admin
4+
**version diff view** via HTML converters, instead of showing only the block
5+
name/slug.
6+
7+
## Get started
8+
9+
Every block in `req.payload.config.blocks` gets a converter auto-generated
10+
from its field config. You only write converters for blocks that need a
11+
bespoke layout.
12+
13+
### 1. Instantiate the diff component
14+
15+
`cms/src/shared/lexical/LexicalBlocksDiffComponent.tsx`
16+
17+
```tsx
18+
import { createLexicalBlocksDiffComponent } from './plugin'
19+
20+
export const LexicalBlocksDiffComponent = createLexicalBlocksDiffComponent()
21+
22+
export const DIFF_COMPONENT_PATH =
23+
'/shared/lexical/LexicalBlocksDiffComponent#LexicalBlocksDiffComponent'
24+
```
25+
26+
The component must live in the project so Payload's import map can resolve
27+
it by static path — see the TODO below.
28+
29+
### 2. Wire the editor wrapper
30+
31+
Use it for every `lexicalEditor(...)` call that should render blocks in the
32+
diff view — both the root editor in `payload.config.ts` and any nested rich
33+
text fields inside blocks that define their own editor:
34+
35+
```ts
36+
import { DIFF_COMPONENT_PATH } from './shared/lexical/LexicalBlocksDiffComponent'
37+
import { lexicalEditorWithBlockDiff } from './shared/lexical/plugin'
38+
39+
editor: lexicalEditorWithBlockDiff(
40+
{ features: ({ defaultFeatures }) => [...defaultFeatures, BlocksFeature({ blocks: [...] })] },
41+
{ diffComponentPath: DIFF_COMPONENT_PATH },
42+
)
43+
```
44+
45+
### 3. Regenerate the import map
46+
47+
```bash
48+
pnpm --filter cms generate:importmap
49+
```
50+
51+
### 4. (Optional) Override specific blocks
52+
53+
When the auto layout isn't good enough — e.g. a block with side-by-side
54+
columns or a custom visual grouping — supply an `override`:
55+
56+
```tsx
57+
// blockDiffConverters.ts
58+
import type { TwoColumnBlock } from '../../payload-types'
59+
import {
60+
type BlockDiffConverter,
61+
blockContainer,
62+
defineBlockConverters,
63+
richText,
64+
styles,
65+
} from './plugin'
66+
67+
const TwoColumnOverride: BlockDiffConverter<TwoColumnBlock> = async (args) => {
68+
const [left, right] = await Promise.all([
69+
richText(args, args.node.fields.left),
70+
richText(args, args.node.fields.right),
71+
])
72+
return blockContainer(
73+
'Two-Column',
74+
`<div style="${styles.columns}"><div style="${styles.column}">${left}</div><div style="${styles.column}">${right}</div></div>`,
75+
)
76+
}
77+
78+
export const blockOverrides = defineBlockConverters({
79+
twoColumn: TwoColumnOverride,
80+
})
81+
```
82+
83+
```tsx
84+
// LexicalBlocksDiffComponent.tsx
85+
import { blockOverrides } from './blockDiffConverters'
86+
import { createLexicalBlocksDiffComponent } from './plugin'
87+
88+
export const LexicalBlocksDiffComponent = createLexicalBlocksDiffComponent({
89+
overrides: blockOverrides,
90+
})
91+
```
92+
93+
## Auto-walker field support
94+
95+
| Payload field type | Rendered as |
96+
| ----------------------------------------------------- | ---------------------------------------------------------------- |
97+
| `text`, `textarea`, `email`, `number`, `code`, `date` | `field(label, value)` with italic `(empty)` fallback. |
98+
| `checkbox` | `Yes` / `No`. |
99+
| `select`, `radio` | Option label (localized), joined by `, ` if `hasMany`. |
100+
| `point` | `lat, lng` formatted to 5 decimal places. |
101+
| `upload` | Payload-native `upload-diff` markup with thumbnail + filename. |
102+
| `relationship` | `title` / `name` / `slug` / `id` of the populated doc. |
103+
| `richText` | Recursively converted HTML (nested blocks resolve via same map). |
104+
| `array` | Numbered list, each row rendered recursively. |
105+
| `group` | Indented block with a left border. |
106+
| `blocks` | Each item rendered via its top-level block config. |
107+
| `row`, `collapsible`, `tabs` | Structural — children flattened into parent. |
108+
| `ui`, `join`, `admin.hidden`, `admin.disabled` | Skipped. |
109+
| `json` | `JSON.stringify(value)`. |
110+
111+
Field labels use the block/field's configured `label`, resolved for the
112+
current admin locale. Missing labels fall back to a humanised field name.
113+
114+
## API reference
115+
116+
### Helpers
117+
118+
| Helper | Purpose |
119+
| ---------------------------------------------- | --------------------------------------------------------------------------------- |
120+
| `blockContainer(blockType, content)` | Outer frame with the block label. |
121+
| `field(label, value)` | `Label: value` row, with italic `(empty)` fallback. |
122+
| `coordinates(label, [lng, lat])` | Formats a Payload `point` field. |
123+
| `createUploadArrayHelper({ mediaCollection })` | Returns an `uploadArray(label, items, populate)` that renders thumbnails. |
124+
| `richText(args, data)` | Recursively converts nested Lexical rich text, forwarding outer `converters`. |
125+
| `styles` | Inline-style string map (uses Payload admin CSS vars like `--theme-elevation-*`). |
126+
| `defineBlockConverters(map)` | Identity helper that widens per-block types to `BlockDiffConvertersMap`. |
127+
128+
### Types
129+
130+
- `BlockDiffConverter<B>` — typed converter for a specific block. Use this on
131+
per-block converter declarations.
132+
- `BlockDiffConvertersMap` — map passed to `createLexicalBlocksDiffComponent`.
133+
- `UploadDoc`, `PopulateFn`, `LexicalEditorState` — structural helpers.
134+
135+
### Nested blocks
136+
137+
Block converters receive `args.converters` — forward it so nested blocks
138+
inside nested rich text resolve via the same map. The `richText` helper does
139+
this automatically:
140+
141+
```ts
142+
const Converter: BlockDiffConverter<TwoColumn> = async (args) => {
143+
const left = await richText(args, args.node.fields.left)
144+
const right = await richText(args, args.node.fields.right)
145+
// ...
146+
}
147+
```
148+
149+
## What's in here
150+
151+
| File | Purpose |
152+
| -------------------------------------- | -------------------------------------------------------------------------------------------------- |
153+
| `createLexicalBlocksDiffComponent.tsx` | Factory returning a `RichTextFieldDiffServerComponent` that merges auto + project overrides. |
154+
| `autoBlockConverter.ts` | Walks a Payload `Block`'s field config and renders each field with the matching helper. |
155+
| `lexicalEditorWithBlockDiff.ts` | Wraps `lexicalEditor()`, overrides `DiffComponent`, registers it in the generated import map. |
156+
| `helpers.ts` | HTML rendering (`field`, `coordinates`, `uploadArray`, `richText`, ...) + `defineBlockConverters`. |
157+
| `types.ts` | `BlockDiffConverter`, `BlockDiffConvertersMap`, `LexicalEditorState`, `UploadDoc`, `PopulateFn`. |
158+
159+
## TODO
160+
161+
- [ ] Extract this folder into a standalone package and publish on npm, e.g.
162+
`@jhb.software/payload-lexical-block-diff`.
163+
- [ ] Reuse it across the other websites in `jhb-websites/` (website,
164+
houseofu, silke-liederbach-website, freund-immo, blumengreif,
165+
bewusstseinsakademie, martinasaur, le-chauffeur) — each currently shows
166+
only block names in the version diff view.
167+
- [ ] Before extracting, verify nothing in this folder imports from the host
168+
project (payload-types, config, collections). Only the consumer's glue
169+
files should be project-specific.
170+
- [ ] Investigate whether the plugin could **own the diff component** itself
171+
(exporting a prebuilt component at a path like
172+
`@jhb.software/payload-lexical-block-diff/rsc#LexicalBlocksDiffComponent`)
173+
so consumers no longer have to create a `LexicalBlocksDiffComponent.tsx`
174+
file or pass `diffComponentPath` to the wrapper. The open problem is
175+
getting the project-specific `converters` into that component — Payload's
176+
import map resolves components via static string paths, so converters
177+
can't be passed through. Possible approaches, both with trade-offs: - Global registry populated at module load (order-dependent, hacky). - Turn this into a proper Payload plugin with an `init`/config hook that
178+
injects converters into the component via shared state on
179+
`req.payload.config` or similar.

0 commit comments

Comments
 (0)