diff --git a/packages/fields-document/package.json b/packages/fields-document/package.json index 14200a658cb..eebb89e908a 100644 --- a/packages/fields-document/package.json +++ b/packages/fields-document/package.json @@ -63,8 +63,6 @@ "@types/react": "catalog:", "apply-ref": "^1.0.0", "graphql": "catalog:", - "io-ts": "^2.2.16", - "io-ts-excess": "^1.0.1", "is-hotkey": "^0.2.0", "match-sorter": "^6.3.1", "mdast-util-from-markdown": "^0.8.5", @@ -77,7 +75,8 @@ "scroll-into-view-if-needed": "^3.0.0", "slate": "^0.103.0", "slate-history": "^0.100.0", - "slate-react": "^0.107.0" + "slate-react": "^0.107.0", + "zod": "^3.23.8" }, "devDependencies": { "@keystone-6/core": "workspace:^", diff --git a/packages/fields-document/src/DocumentEditor/component-blocks/api-shared.ts b/packages/fields-document/src/DocumentEditor/component-blocks/api-shared.ts index aff440c03cc..452b8d82928 100644 --- a/packages/fields-document/src/DocumentEditor/component-blocks/api-shared.ts +++ b/packages/fields-document/src/DocumentEditor/component-blocks/api-shared.ts @@ -304,8 +304,8 @@ export type HydratedRelationshipData = { export type RelationshipData = { id: string - label: string | undefined - data: Record | undefined + label?: string + data?: Record } type ValueForRenderingFromComponentPropField = diff --git a/packages/fields-document/src/structure-validation.ts b/packages/fields-document/src/structure-validation.ts index 79301ce8700..bb9c799465f 100644 --- a/packages/fields-document/src/structure-validation.ts +++ b/packages/fields-document/src/structure-validation.ts @@ -1,232 +1,146 @@ -import * as t from 'io-ts' -import excess from 'io-ts-excess' +import { z } from 'zod' import { type RelationshipData } from './DocumentEditor/component-blocks/api-shared' -import { type Mark } from './DocumentEditor/utils' import { isValidURL } from './DocumentEditor/isValidURL' -// note that this validation isn't about ensuring that a document has nodes in the right positions and things -// it's just about validating that it's a valid slate structure -// we'll then run normalize on it which will enforce more things -const markValue = t.union([t.undefined, t.literal(true)]) - -const text: t.Type = excess( - t.type({ - text: t.string, - bold: markValue, - italic: markValue, - underline: markValue, - strikethrough: markValue, - code: markValue, - superscript: markValue, - subscript: markValue, - keyboard: markValue, - insertMenu: markValue, - }) -) -export type TextWithMarks = { type?: never, text: string } & { - [Key in Mark | 'insertMenu']: true | undefined; -} - -type Inline = TextWithMarks | Link | Relationship - -type Link = { type: 'link', href: string, children: Children } - -class URLType extends t.Type { - readonly _tag: 'URLType' = 'URLType' as const - constructor () { - super( - 'string', - (u): u is string => typeof u === 'string' && isValidURL(u), - (u, c) => (this.is(u) ? t.success(u) : t.failure(u, c)), - t.identity - ) - } -} - -const urlType = new URLType() - -const link: t.Type = t.recursion('Link', () => - excess( - t.type({ - type: t.literal('link'), - href: urlType, - children, - }) - ) -) - -type Relationship = { - type: 'relationship' - relationship: string - data: RelationshipData | null - children: Children -} - -const relationship: t.Type = t.recursion('Relationship', () => - excess( - t.type({ - type: t.literal('relationship'), - relationship: t.string, - data: t.union([t.null, relationshipData]), - children, - }) - ) -) - -const inline = t.union([text, link, relationship]) - -type Children = (Block | Inline)[] - -const layoutArea: t.Type = t.recursion('Layout', () => - excess( - t.type({ - type: t.literal('layout'), - layout: t.array(t.number), - children, - }) - ) -) - -type Layout = { - type: 'layout' - layout: number[] - children: Children -} - -const onlyChildrenElements: t.Type = t.recursion('OnlyChildrenElements', () => - excess( - t.type({ - type: t.union([ - t.literal('blockquote'), - t.literal('layout-area'), - t.literal('code'), - t.literal('divider'), - t.literal('list-item'), - t.literal('list-item-content'), - t.literal('ordered-list'), - t.literal('unordered-list'), - ]), - children, - }) - ) -) - -type OnlyChildrenElements = { - type: - | 'blockquote' - | 'layout-area' - | 'code' - | 'divider' - | 'list-item' - | 'list-item-content' - | 'ordered-list' - | 'unordered-list' - children: Children -} - -const textAlign = t.union([t.undefined, t.literal('center'), t.literal('end')]) - -const heading: t.Type = t.recursion('Heading', () => - excess( - t.type({ - type: t.literal('heading'), - textAlign, - level: t.union([ - t.literal(1), - t.literal(2), - t.literal(3), - t.literal(4), - t.literal(5), - t.literal(6), - ]), - children, - }) - ) -) - -type Heading = { - type: 'heading' - level: 1 | 2 | 3 | 4 | 5 | 6 - textAlign: 'center' | 'end' | undefined - children: Children -} - -type Paragraph = { - type: 'paragraph' - textAlign: 'center' | 'end' | undefined - children: Children -} - -const paragraph: t.Type = t.recursion('Paragraph', () => - excess( - t.type({ - type: t.literal('paragraph'), - textAlign, - children, - }) - ) -) - -const relationshipData: t.Type = excess( - t.type({ - id: t.string, - label: t.union([t.undefined, t.string]), - data: t.union([t.undefined, t.record(t.string, t.any)]), - }) -) - -type ComponentBlock = { - type: 'component-block' - component: string - props: Record - children: Children -} - -const componentBlock: t.Type = t.recursion('ComponentBlock', () => - excess( - t.type({ - type: t.literal('component-block'), - component: t.string, - props: t.record(t.string, t.any), - children, - }) - ) -) - -type ComponentProp = { - type: 'component-inline-prop' | 'component-block-prop' - propPath: (string | number)[] | undefined - children: Children -} - -const componentProp: t.Type = t.recursion('ComponentProp', () => - excess( - t.type({ - type: t.union([t.literal('component-inline-prop'), t.literal('component-block-prop')]), - propPath: t.union([t.array(t.union([t.string, t.number])), t.undefined]), - children, - }) - ) -) - -type Block = Layout | OnlyChildrenElements | Heading | ComponentBlock | ComponentProp | Paragraph - -const block: t.Type = t.recursion('Element', () => - t.union([layoutArea, onlyChildrenElements, heading, componentBlock, componentProp, paragraph]) -) - -export type ElementFromValidation = Block | Inline - -const children: t.Type = t.recursion('Children', () => t.array(t.union([block, inline]))) -export const editorCodec = t.array(block) +// leaf types +const zMarkValue = z.union([ + z.literal(true), + z.undefined(), +]) + +const zText = z.object({ + text: z.string(), + bold: zMarkValue, + italic: zMarkValue, + underline: zMarkValue, + strikethrough: zMarkValue, + code: zMarkValue, + superscript: zMarkValue, + subscript: zMarkValue, + keyboard: zMarkValue, + insertMenu: zMarkValue, +}).strict() + +const zTextAlign = z.union([ + z.undefined(), + z.literal('center'), + z.literal('end') +]) + +// recursive types +const zLink = z.object({ + type: z.literal('link'), + href: z.string().refine(isValidURL), +}).strict() + +const zHeading = z.object({ + type: z.literal('heading'), + textAlign: zTextAlign, + level: z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + ]), +}).strict() + +const zParagraph = z.object({ + type: z.literal('paragraph'), + textAlign: zTextAlign, +}).strict() + +const zElements = z.object({ + type: z.union([ + z.literal('blockquote'), + z.literal('layout-area'), + z.literal('code'), + z.literal('divider'), + z.literal('list-item'), + z.literal('list-item-content'), + z.literal('ordered-list'), + z.literal('unordered-list'), + ]), +}).strict() + +const zLayout = z.object({ + type: z.literal('layout'), + layout: z.array(z.number()), +}).strict() + +const zRelationshipData = z.object({ + id: z.string(), + label: z.string().optional(), + data: z.record(z.string(), z.any()).optional(), +}).strict() + +const zRelationship = z.object({ + type: z.literal('relationship'), + relationship: z.string(), + data: z.union([zRelationshipData, z.null()]), +}).strict() + +const zComponentBlock = z.object({ + type: z.literal('component-block'), + component: z.string(), + props: z.record(z.string(), z.any()), +}).strict() + +const zComponentProp = z.object({ + type: z.union([ + z.literal('component-block-prop'), + z.literal('component-inline-prop'), + ]), + propPath: z.array(z.union([z.string(), z.number()])).optional(), +}).strict() + +type Children = + // inline + | (z.infer) + | (z.infer & { children: Children[] }) + | (z.infer & { children: Children[] }) + // block + | (z.infer & { children: Children[] }) + | (z.infer & { children: Children[] }) + | (z.infer & { children: Children[] }) + | (z.infer & { children: Children[] }) + | (z.infer & { children: Children[] }) + | (z.infer & { children: Children[] }) + +const zBlock: z.ZodType = z.union([ + zComponentBlock.extend({ children: z.lazy(() => zChildren) }), + zComponentProp.extend({ children: z.lazy(() => zChildren) }), + zElements.extend({ children: z.lazy(() => zChildren) }), + zHeading.extend({ children: z.lazy(() => zChildren) }), + zLayout.extend({ children: z.lazy(() => zChildren) }), + zParagraph.extend({ children: z.lazy(() => zChildren) }), +]) + +const zInline: z.ZodType = z.union([ + zText, + zLink.extend({ children: z.lazy(() => zChildren) }), + zRelationship.extend({ children: z.lazy(() => zChildren) }), +]) + +const zChildren: z.ZodType = z.array(z.union([ + zBlock, + zInline, +])) + +const zEditorCodec = z.array(zBlock) + +// exports +export type TextWithMarks = z.infer +export type ElementFromValidation = Children export function isRelationshipData (val: unknown): val is RelationshipData { - return relationshipData.validate(val, [])._tag === 'Right' + return zRelationshipData.safeParse(val).success } export function validateDocumentStructure (val: unknown): asserts val is ElementFromValidation[] { - const result = editorCodec.validate(val, []) - if (result._tag === 'Left') { + const result = zEditorCodec.safeParse(val) + if (!result.success) { throw new Error('Invalid document structure') } } diff --git a/packages/fields-document/src/validation.test.tsx b/packages/fields-document/src/validation.test.tsx index 283485bcb58..5c947e0a371 100644 --- a/packages/fields-document/src/validation.test.tsx +++ b/packages/fields-document/src/validation.test.tsx @@ -52,7 +52,7 @@ const componentBlocks: Record = { }), } -const validate = (val: unknown) => { +function validate (val: unknown) { try { const node = validateAndNormalizeDocument( val, diff --git a/packages/fields-document/src/validation.ts b/packages/fields-document/src/validation.ts index 6cfe643169b..bc609c0d3b5 100644 --- a/packages/fields-document/src/validation.ts +++ b/packages/fields-document/src/validation.ts @@ -1,4 +1,7 @@ -import { Text, Editor } from 'slate' +import { + Text, + Editor +} from 'slate' import { type ComponentBlock, type ComponentSchema @@ -144,9 +147,7 @@ export function getValidatedNodeWithNormalizedComponentFormProps ( componentBlocks: Record, relationships: Relationships ): ElementFromValidation { - if (isText(node)) { - return node - } + if (isText(node)) return node if (node.type === 'component-block') { if (Object.prototype.hasOwnProperty.call(componentBlocks, node.component)) { const componentBlock = componentBlocks[node.component] @@ -173,6 +174,7 @@ export function getValidatedNodeWithNormalizedComponentFormProps ( children: node.children, } } + return { ...node, children: node.children.map(x => @@ -188,9 +190,7 @@ export function validateAndNormalizeDocument ( relationships: Relationships ) { validateDocumentStructure(value) - const children = value.map(x => - getValidatedNodeWithNormalizedComponentFormProps(x, componentBlocks, relationships) - ) + const children = value.map(x => getValidatedNodeWithNormalizedComponentFormProps(x, componentBlocks, relationships)) const editor = createDocumentEditor(documentFeatures, componentBlocks, relationships) editor.children = children Editor.normalize(editor, { force: true }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e87f42685..6eb695a0e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2322,12 +2322,6 @@ importers: graphql: specifier: 'catalog:' version: 16.9.0 - io-ts: - specifier: ^2.2.16 - version: 2.2.21(fp-ts@2.16.9) - io-ts-excess: - specifier: ^1.0.1 - version: 1.0.1(fp-ts@2.16.9) is-hotkey: specifier: ^0.2.0 version: 0.2.0 @@ -2367,6 +2361,9 @@ importers: slate-react: specifier: ^0.107.0 version: 0.107.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate@0.103.0) + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@keystone-6/core': specifier: workspace:^ @@ -8098,9 +8095,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fp-ts@2.16.9: - resolution: {integrity: sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==} - fragment-cache@0.2.1: resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==} engines: {node: '>=0.10.0'} @@ -8607,16 +8601,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - io-ts-excess@1.0.1: - resolution: {integrity: sha512-yJQ+pGztBMIQmfsKfSAeQ1w7UJywvj37NIFriMAZ2tMLTpp1IngUvtxqI+QlW+RlXDn7cthMxrpJ0CnOx6Dn+w==} - peerDependencies: - fp-ts: ^2.0.0 - - io-ts@2.2.21: - resolution: {integrity: sha512-zz2Z69v9ZIC3mMLYWIeoUcwWD6f+O7yP92FMVVaXEOSZH1jnVBmET/urd/uoarD1WGBY4rCj8TAyMPzsGNzMFQ==} - peerDependencies: - fp-ts: ^2.5.0 - ip-address@9.0.5: resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} engines: {node: '>= 12'} @@ -19872,8 +19856,6 @@ snapshots: forwarded@0.2.0: {} - fp-ts@2.16.9: {} - fragment-cache@0.2.1: dependencies: map-cache: 0.2.2 @@ -20493,15 +20475,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - io-ts-excess@1.0.1(fp-ts@2.16.9): - dependencies: - fp-ts: 2.16.9 - io-ts: 2.2.21(fp-ts@2.16.9) - - io-ts@2.2.21(fp-ts@2.16.9): - dependencies: - fp-ts: 2.16.9 - ip-address@9.0.5: dependencies: jsbn: 1.1.0