Skip to content

Commit

Permalink
remove io-ts, use zod
Browse files Browse the repository at this point in the history
  • Loading branch information
dcousens committed Aug 6, 2024
1 parent 27207a1 commit f116d7e
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 255 deletions.
5 changes: 2 additions & 3 deletions packages/fields-document/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:^",
Expand Down
351 changes: 130 additions & 221 deletions packages/fields-document/src/structure-validation.ts
Original file line number Diff line number Diff line change
@@ -1,232 +1,141 @@
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<TextWithMarks> = 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<string> {
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<Link> = 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<Relationship> = 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<Layout> = 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<OnlyChildrenElements> = 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<Heading> = 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<Paragraph> = t.recursion('Paragraph', () =>
excess(
t.type({
type: t.literal('paragraph'),
textAlign,
children,
})
)
)

const relationshipData: t.Type<RelationshipData> = 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<string, any>
children: Children
}

const componentBlock: t.Type<ComponentBlock> = 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<ComponentProp> = 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<Block> = t.recursion('Element', () =>
t.union([layoutArea, onlyChildrenElements, heading, componentBlock, componentProp, paragraph])
)

export type ElementFromValidation = Block | Inline

const children: t.Type<Children> = t.recursion('Children', () => t.array(t.union([block, inline])))

export const editorCodec = t.array(block)
// leaf types
const zMarkValue = z.union([
z.undefined(),
z.literal(true)
])

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.union([z.undefined(), z.string()]),
data: z.union([z.undefined(), z.record(z.string(), z.any())]),
}).strict()

const zRelationship = z.object({
type: z.literal('relationship'),
relationship: z.string(),
data: z.union([z.null(), zRelationshipData]),
}).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-inline-prop'), z.literal('component-block-prop')]),
propPath: z.union([z.array(z.union([z.string(), z.number()])), z.undefined()]),
}).strict()

type Children =
| z.infer<typeof zMarkValue>
| z.infer<typeof zText>
| z.infer<typeof zTextAlign>
| z.infer<typeof zHeading> & { children: Children[] }
| z.infer<typeof zParagraph> & { children: Children[] }
| z.infer<typeof zLink> & { children: Children[] }
| z.infer<typeof zElements & { children: Children[] }>
| z.infer<typeof zLayout & { children: Children[] } >
| z.infer<typeof zRelationship> & { children: Children[] }
| z.infer<typeof zComponentBlock> & { children: Children[] }
| z.infer<typeof zComponentProp> & { children: Children[] }

const zBlock: z.ZodType<Children> = z.union([
zLayout.extend({ children: z.lazy(() => zChildren) }),
zElements.extend({ children: z.lazy(() => zChildren) }),
zHeading.extend({ children: z.lazy(() => zChildren) }),
zComponentBlock.extend({ children: z.lazy(() => zChildren) }),
zComponentProp.extend({ children: z.lazy(() => zChildren) }),
zParagraph.extend({ children: z.lazy(() => zChildren) }),
])

const zInline: z.ZodType<Children> = z.union([
zText,
zLink.extend({ children: z.lazy(() => zChildren) }),
zRelationship.extend({ children: z.lazy(() => zChildren) }),
])

const zChildren: z.ZodType<Children[]> = z.array(z.union([
zBlock,
zInline,
]))

const zEditorCodec = z.array(zBlock)

export type ElementFromValidation = z.infer<typeof zChildren>[number]

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')
}
}
2 changes: 1 addition & 1 deletion packages/fields-document/src/validation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const componentBlocks: Record<string, ComponentBlock> = {
}),
}

const validate = (val: unknown) => {
function validate (val: unknown) {
try {
const node = validateAndNormalizeDocument(
val,
Expand Down
Loading

0 comments on commit f116d7e

Please sign in to comment.