diff --git a/docs/lexical/migration.mdx b/docs/lexical/migration.mdx index 33f81c233..c25726556 100644 --- a/docs/lexical/migration.mdx +++ b/docs/lexical/migration.mdx @@ -10,6 +10,21 @@ keywords: lexical, rich text, editor, headless cms, migrate, migration While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different. +### Migration via Migration Script (Recommended) + +Just import the `migrateSlateToLexical` function we provide, pass it the `payload` object and run it. Depending on the amount of collections, this might take a while. + +IMPORTANT: This will overwrite all slate data. We recommend doing the following first: +1. Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support. +2. Make every richText field a lexical editor. This script will only convert lexical richText fields with old Slate data +3. Add the SlateToLexicalFeature (as seen below) first, and test it out by loading up the admin panel, to see if the migrator works as expected. You might have to build some custom converters for some fields first in order to convert custom Slate nodes. The SlateToLexicalFeature is where the converters are stored. Only fields with this feature added will be migrated. + +```ts +import { migrateSlateToLexical } from '@payloadcms/richtext-lexical' + +await migrateSlateToLexical({ payload }) +``` + ### Migration via SlateToLexicalFeature One way to handle this is to just give your lexical editor the ability to read the slate JSON. @@ -42,7 +57,7 @@ This is by far the easiest way to migrate from Slate to Lexical, although it doe - There is a performance hit when initializing the lexical editor - The editor will still output the Slate data in the output JSON, as the on-the-fly converter only runs for the admin panel -The easy way to solve this: Just save the document! This overrides the slate data with the lexical data, and the next time the document is loaded, the lexical data will be used. This solves both the performance and the output issue for that specific document. +The easy way to solve this: Edit the richText field and save the document! This overrides the slate data with the lexical data, and the next time the document is loaded, the lexical data will be used. This solves both the performance and the output issue for that specific document. This, however, is a slow and gradual migration process, thus you will have to support both API formats. Especially for a large number of documents, we recommend running the migration script, as explained above. ### Migration via migration script diff --git a/packages/richtext-lexical/src/features/migrations/slateToLexical/feature.server.ts b/packages/richtext-lexical/src/features/migrations/slateToLexical/feature.server.ts index 99472b653..32a5de8cc 100644 --- a/packages/richtext-lexical/src/features/migrations/slateToLexical/feature.server.ts +++ b/packages/richtext-lexical/src/features/migrations/slateToLexical/feature.server.ts @@ -18,7 +18,12 @@ export type SlateToLexicalFeatureProps = { | SlateNodeConverterProvider[] } -export const SlateToLexicalFeature = createServerFeature({ +export const SlateToLexicalFeature = createServerFeature< + SlateToLexicalFeatureProps, + { + converters?: SlateNodeConverterProvider[] + } +>({ feature: ({ props }) => { if (!props) { props = {} @@ -56,7 +61,9 @@ export const SlateToLexicalFeature = createServerFeature = ClientFeature> +export type ResolvedClientFeatureMap = Map> -export type ClientFeatureProviderMap = Map> +export type ClientFeatureProviderMap = Map> /** * Plugins are react components which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index d811ec377..73a712423 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -356,12 +356,12 @@ export type ResolvedServerFeature = ServerFeatu order: number } -export type ResolvedServerFeatureMap = Map> +export type ResolvedServerFeatureMap = Map> -export type ServerFeatureProviderMap = Map> +export type ServerFeatureProviderMap = Map> export type SanitizedServerFeatures = Required< - Pick, 'i18n' | 'markdownTransformers' | 'nodes'> + Pick, 'i18n' | 'markdownTransformers' | 'nodes'> > & { /** The node types mapped to their converters */ converters: { diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 596954506..193dc904a 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -83,7 +83,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte } } - let features: FeatureProviderServer[] = [] + let features: FeatureProviderServer[] = [] let resolvedFeatureMap: ResolvedServerFeatureMap let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only @@ -977,5 +977,6 @@ export { defaultRichTextValue } from './populateGraphQL/defaultValue.js' export type { LexicalEditorProps, LexicalRichTextAdapter } from './types.js' export { createServerFeature } from './utilities/createServerFeature.js' +export { migrateSlateToLexical } from './utilities/migrateSlateToLexical/index.js' export * from './nodeTypes.js' diff --git a/packages/richtext-lexical/src/lexical/config/server/default.ts b/packages/richtext-lexical/src/lexical/config/server/default.ts index 6163eb052..597e6e945 100644 --- a/packages/richtext-lexical/src/lexical/config/server/default.ts +++ b/packages/richtext-lexical/src/lexical/config/server/default.ts @@ -30,7 +30,7 @@ export const defaultEditorLexicalConfig: LexicalEditorConfig = { theme: LexicalEditorTheme, } -export const defaultEditorFeatures: FeatureProviderServer[] = [ +export const defaultEditorFeatures: FeatureProviderServer[] = [ BoldFeature(), ItalicFeature(), UnderlineFeature(), diff --git a/packages/richtext-lexical/src/lexical/config/types.ts b/packages/richtext-lexical/src/lexical/config/types.ts index 34d57659a..99137c8d1 100644 --- a/packages/richtext-lexical/src/lexical/config/types.ts +++ b/packages/richtext-lexical/src/lexical/config/types.ts @@ -13,7 +13,7 @@ import type { import type { LexicalFieldAdminProps } from '../../types.js' export type ServerEditorConfig = { - features: FeatureProviderServer[] + features: FeatureProviderServer[] lexical?: LexicalEditorConfig } @@ -24,7 +24,7 @@ export type SanitizedServerEditorConfig = { } export type ClientEditorConfig = { - features: FeatureProviderClient[] + features: FeatureProviderClient[] lexical?: LexicalEditorConfig } diff --git a/packages/richtext-lexical/src/utilities/migrateSlateToLexical/index.ts b/packages/richtext-lexical/src/utilities/migrateSlateToLexical/index.ts new file mode 100644 index 000000000..945583dc4 --- /dev/null +++ b/packages/richtext-lexical/src/utilities/migrateSlateToLexical/index.ts @@ -0,0 +1,164 @@ +import type { CollectionConfig, Field, GlobalConfig, Payload } from 'payload' + +import { migrateDocumentFields } from './recurse.js' + +/** + * This goes through every single collection and field in the payload config, and migrates its data from Slate to Lexical. This does not support sub-fields within slate. + * + * It will only translate fields fulfilling all these requirements: + * - field schema uses lexical editor + * - lexical editor has SlateToLexicalFeature added + * - saved field data is in Slate format + * + * @param payload + */ +export async function migrateSlateToLexical({ payload }: { payload: Payload }) { + const collections = payload.config.collections + + const allLocales = payload.config.localization ? payload.config.localization.localeCodes : [null] + + const totalCollections = collections.length + for (const locale of allLocales) { + let curCollection = 0 + for (const collection of collections) { + curCollection++ + await migrateCollection({ + collection, + cur: curCollection, + locale, + max: totalCollections, + payload, + }) + } + for (const global of payload.config.globals) { + await migrateGlobal({ + global, + locale, + payload, + }) + } + } +} + +async function migrateGlobal({ + global, + locale, + payload, +}: { + global: GlobalConfig + locale: null | string + payload: Payload +}) { + console.log(`SlateToLexical: ${locale}: Migrating global:`, global.slug) + + const document = await payload.findGlobal({ + slug: global.slug, + depth: 0, + locale: locale || undefined, + overrideAccess: true, + }) + + const found = migrateDocument({ + document, + fields: global.fields, + }) + + if (found) { + await payload.updateGlobal({ + slug: global.slug, + data: document, + depth: 0, + locale: locale || undefined, + }) + } +} + +async function migrateCollection({ + collection, + cur, + locale, + max, + payload, +}: { + collection: CollectionConfig + cur: number + locale: null | string + max: number + payload: Payload +}) { + console.log( + `SlateToLexical: ${locale}: Migrating collection:`, + collection.slug, + '(' + cur + '/' + max + ')', + ) + + const documentCount = ( + await payload.count({ + collection: collection.slug, + depth: 0, + locale: locale || undefined, + }) + ).totalDocs + + let page = 1 + let migrated = 0 + + while (migrated < documentCount) { + const documents = await payload.find({ + collection: collection.slug, + depth: 0, + locale: locale || undefined, + overrideAccess: true, + page, + pagination: true, + }) + + for (const document of documents.docs) { + migrated++ + console.log( + `SlateToLexical: ${locale}: Migrating collection:`, + collection.slug, + '(' + + cur + + '/' + + max + + ') - Migrating Document: ' + + document.id + + ' (' + + migrated + + '/' + + documentCount + + ')', + ) + const found = migrateDocument({ + document, + fields: collection.fields, + }) + + if (found) { + await payload.update({ + id: document.id, + collection: collection.slug, + data: document, + depth: 0, + locale: locale || undefined, + }) + } + } + page++ + } +} + +function migrateDocument({ + document, + fields, +}: { + document: Record + fields: Field[] +}): boolean { + return !!migrateDocumentFields({ + data: document, + fields, + found: 0, + }) +} diff --git a/packages/richtext-lexical/src/utilities/migrateSlateToLexical/recurse.ts b/packages/richtext-lexical/src/utilities/migrateSlateToLexical/recurse.ts new file mode 100644 index 000000000..a03612227 --- /dev/null +++ b/packages/richtext-lexical/src/utilities/migrateSlateToLexical/recurse.ts @@ -0,0 +1,112 @@ +import type { Field } from 'payload' + +import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload/shared' + +import type { + SlateNodeConverter, + SlateNodeConverterProvider, +} from '../../features/migrations/slateToLexical/converter/types.js' +import type { LexicalRichTextAdapter } from '../../types.js' + +import { convertSlateToLexical } from '../../features/migrations/slateToLexical/converter/index.js' + +type NestedRichTextFieldsArgs = { + data: unknown + + fields: Field[] + found: number +} + +export const migrateDocumentFields = ({ + data, + fields, + found, +}: NestedRichTextFieldsArgs): number => { + for (const field of fields) { + if (fieldHasSubFields(field) && !fieldIsArrayType(field)) { + if (fieldAffectsData(field) && typeof data[field.name] === 'object') { + migrateDocumentFields({ + data: data[field.name], + fields: field.fields, + found, + }) + } else { + migrateDocumentFields({ + data, + found, + + fields: field.fields, + }) + } + } else if (field.type === 'tabs') { + field.tabs.forEach((tab) => { + migrateDocumentFields({ + data, + found, + + fields: tab.fields, + }) + }) + } else if (Array.isArray(data[field.name])) { + if (field.type === 'blocks') { + data[field.name].forEach((row, i) => { + const block = field.blocks.find(({ slug }) => slug === row?.blockType) + if (block) { + migrateDocumentFields({ + data: data[field.name][i], + found, + + fields: block.fields, + }) + } + }) + } + + if (field.type === 'array') { + data[field.name].forEach((_, i) => { + migrateDocumentFields({ + data: data[field.name][i], + found, + + fields: field.fields, + }) + }) + } + } + + if (field.type === 'richText' && Array.isArray(data[field.name])) { + // Slate richText + const editor: LexicalRichTextAdapter = field.editor as LexicalRichTextAdapter + if (editor && typeof editor === 'object') { + if ('features' in editor && editor.features?.length) { + // find slatetolexical feature + const slateToLexicalFeature = editor.editorConfig.resolvedFeatureMap.get('slateToLexical') + if (slateToLexicalFeature) { + // DO CONVERSION + + const converterProviders = ( + slateToLexicalFeature.sanitizedServerFeatureProps as { + converters?: SlateNodeConverterProvider[] + } + ).converters + + const converters: SlateNodeConverter[] = [] + + for (const converter of converterProviders) { + converters.push(converter.converter) + } + + data[field.name] = convertSlateToLexical({ + converters, + slateData: data[field.name], + }) + + found++ + } + } + } + } + } + + return found +}