Rich text / foldkit-richtext

Migrations

Migrations move semantic data forward when a deployment changes its vocabulary — a preserved node that is now implemented, a prop renamed, a kind replaced:

ts
const Callout = Schema.Struct({ tone: Schema.Literals(['info', 'warning', 'critical']) })

RichText.migrate(document, [
  RichText.promoteUnknown('EmbedToCallout', 'Embed', 'Callout', Callout),
  RichText.migration('DangerToCritical', 'Callout', block =>
    block.type === 'Node' && block.props.tone === 'danger'
      ? { ...block, props: { ...block.props, tone: 'critical' } }
      : undefined,
  ),
])
// → { document, applied: [{ name, node }], unused: ['…'] }

They operate on blocks, never on DOM, and run at a boundary the application chooses — loading, publishing, or an explicit upgrade — never automatically on every read. The list order is the chain: a later migration sees what an earlier one produced. A migration descends into containers, so a preserved block nested in a list is rewritten where it sits.

Three rules are enforced rather than documented and hoped for:

  • Identity survives. A migration returns the same id, so positions, references, and selections keep addressing the same node; changing it throws instead of silently breaking every reference. That holds for a nested block too.
  • The result is still content. Returned blocks must decode, and the complete document is validated after each changed migration pass. Non-JSON props, unknown shapes, and identities duplicated across blocks are rejected.
  • Declining is allowed. Returning undefined keeps the block as it is — which is what promoteUnknown does when legacy data does not decode against the target's schema, rather than half-converting it. promoteUnknown takes the target's content mode, so promoting into a container kind yields a valid block (blocks: []) rather than one validate would call a mismatch.