feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782
Draft
AlessioGr wants to merge 20 commits into
Draft
feat(richtext-lexical)!: type-safe lexical schemas and generated types#16782AlessioGr wants to merge 20 commits into
AlessioGr wants to merge 20 commits into
Conversation
…ame outputSchema to jsonSchema
…-types # Conflicts: # docs/fields/array.mdx # docs/fields/blocks.mdx # docs/fields/checkbox.mdx # docs/fields/code.mdx # docs/fields/date.mdx # docs/fields/email.mdx # docs/fields/group.mdx # docs/fields/join.mdx # docs/fields/json.mdx # docs/fields/number.mdx # docs/fields/point.mdx # docs/fields/radio.mdx # docs/fields/relationship.mdx # docs/fields/rich-text.mdx # docs/fields/select.mdx # docs/fields/text.mdx # docs/fields/textarea.mdx # docs/fields/upload.mdx # docs/migration-guide/v4.mdx # packages/payload/src/admin/RichText.ts # packages/richtext-lexical/src/features/blockquote/server/index.ts # packages/richtext-lexical/src/features/blocks/client/component/index.tsx # packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx # packages/richtext-lexical/src/features/blocks/client/nodes/BlocksNode.tsx # packages/richtext-lexical/src/features/blocks/client/nodes/InlineBlocksNode.tsx # packages/richtext-lexical/src/features/blocks/server/index.ts # packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx # packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx # packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts # packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts # packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx # packages/richtext-lexical/src/features/experimental_table/server/index.ts # packages/richtext-lexical/src/features/heading/server/index.ts # packages/richtext-lexical/src/features/horizontalRule/server/nodes/HorizontalRuleNode.tsx # packages/richtext-lexical/src/features/link/nodes/types.ts # packages/richtext-lexical/src/features/lists/plugin/index.tsx # packages/richtext-lexical/src/features/relationship/server/nodes/RelationshipNode.tsx # packages/richtext-lexical/src/features/typesServer.ts # packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx # packages/richtext-lexical/src/field/Diff/converters/upload/index.tsx # packages/richtext-lexical/src/lexical/config/server/sanitize.ts # packages/richtext-lexical/src/types/nodeTypes.ts # packages/richtext-lexical/src/types/schema.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR makes
richTextfields properly typed inpayload-types.ts. Until now, everyrichTextfield was typed as a vague{ [k: string]: unknown }blob. TypeScript couldn't help you do anything with rich text content — no autocomplete on node fields, no narrowing bytype, no safe converters or renderers.Now each node has a real interface (
SerializedTextNode,SerializedHeadingNode,SerializedBlockNode, etc.) and the field is typed as a union of all the nodes its editor uses.What the generated types look like
Before.
After.
The union name is a hash of its own contents, so two fields with the same set of nodes share one alias instead of duplicating.
Breaking changes
Most projects need no code changes — regenerate
payload-types.tsand everything keeps working, just with tighter types. The renames below only matter if you wrote a custom rich-text adapter, your own type-generation script, or a custom lexical feature.Every block now auto-generates a top-level interface
Before, a block only got a top-level interface in
payload-types.tsif you setinterfaceNameon it. Without that, the block's fields were inlined wherever the block appeared.Now every block always emits a top-level interface. The name comes from the slug as PascalCase (
'content-block'→ContentBlock).interfaceNamestill works, but it's an override for that default name rather than the switch that enables generation.Same rule applies to lexical block nodes — they now emit as
SerializedBlockNode<…>/SerializedInlineBlockNode<…>referencing the per-block interface, instead of inlining a{ [k: string]: unknown }.The only thing to watch for is name collisions. If a block's auto-derived name clashes with another type in your generated file, set
interfaceNameto disambiguate.Field
typescriptSchemais nowjsonSchemaThe old name lied — it accepted JSON Schema, not TypeScript.
{ name: 'tags', type: 'json', - typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })], + jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })], }Rich-text adapter
outputSchemais nowjsonSchemaSame rename on the editor side. The arguments object also gains
typeStringDefinitions: Set<string>— adapters use it to add raw TS source topayload-types.ts.myAdapter: RichTextAdapter = { - outputSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired }) => { + jsonSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired, typeStringDefinitions }) => { // return JSONSchema4 }, }@payloadcms/richtext-lexicalalready uses the new name. Only third-party adapters need to be updated.configToJSONSchemareturns an object nowIt used to return a
JSONSchema4. It now returns{ jsonSchema, typeStringDefinitions }.fieldsToJSONSchematakes one object instead of 6 positional argsentityToJSONSchemagot a new required argumenttypeStringDefinitionsis now a required positional argument at position 5. The oldoptsobject becomes an optionalforceInlineBlocks?: booleanat the end.entityToJSONSchema( config, entity, interfaceNameDefinitions, defaultIDType, + typeStringDefinitions, collectionIDFieldTypes, i18n, - { forceInlineBlocks: true }, + true, )Lexical:
modifyOutputSchemais goneFeatures used to mutate the field's whole JSON Schema after the fact via
generatedTypes.modifyOutputSchema. Now each node contributes its own schema directly:export const MyFeature = createServerFeature({ feature: () => ({ - generatedTypes: { - modifyOutputSchema: ({ currentSchema, interfaceNameDefinitions }) => { - return currentSchema - }, - }, nodes: [ createNode({ node: MyNode, + jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => { + typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { /* ... */ }`) + return elementNodeSchema({ nodeType: 'my', tsType: `SerializedMyNode<${nodeUnionName}>` }) + }, }), ], }), })Custom nodes without a
jsonSchemafunction fall back to{ [k: string]: unknown }, so leaving it out is fine — your types just stay loose for that node.In practice only
BlocksFeatureever usedmodifyOutputSchema, and the comment in its implementation admitted it: "we don't actually use the JSON Schema itself in the generated types yet". It was registering interfaces for blocks as a side effect, with the wrong (loose) shape — which is why blockid/blockNameused to be too permissive in the old types.Lexical: registering the same node twice now throws
sanitizeServerFeaturesrejects two features registering the same node type. Before, it silently kept the last one. The default editor is unaffected — the built-in lists features already coordinate viashouldRegisterListBaseNodes.If you had a custom feature that re-registered an existing node (e.g. to swap in a subclass), use lexical's
LexicalNodeReplacementshape:createNode({ - node: MyExtendedHeadingNode, + node: { replace: HeadingNode, with: (node) => new MyExtendedHeadingNode(node.__tag) }, })How features contribute types
Each feature attaches a
jsonSchemafunction to its node viacreateNode. The function gets a helper for the shared element shape and aSet<string>it can dump raw TS source into:The same TS source string from many nodes only lands in the output once —
Set<string>deduplicates for free. Nodes withoutjsonSchemastay as{ [k: string]: unknown }, so features can opt in node by node.Internal refactor changes
packages/richtext-lexical/src/index.tsshrank by ~700 lines. The fourRichTextHooksimplementations moved tohooks.ts(getLexicalHooks({ editorConfig })), and the field-level JSON Schema builder moved totypes/schema.ts(getFieldToJSONSchema({ editorConfig })). What's left inindex.tsis mostly re-exports plus thelexicalEditor()factory.types/builtInNodes.ts(SerializedLexicalElementBase,LexicalElementFormat, …). Per-node helpers live next to their schemas underfeatures/*/server/schema.ts.nodeTypes.tsre-exports from the new locations.