Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ export type HydratedRelationshipData = {

export type RelationshipData = {
id: string
label: string | undefined
data: Record<string, any> | undefined
label?: string
data?: Record<string, any>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required as a result of zod assuming that | undefined is equivalent to ?:, which it isn't.

Reference colinhacks/zod#2464 and colinhacks/zod#2598

}

type ValueForRenderingFromComponentPropField<Schema extends ComponentSchema> =
Expand Down
356 changes: 135 additions & 221 deletions packages/fields-document/src/structure-validation.ts
Original file line number Diff line number Diff line change
@@ -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<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.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<typeof zText>)
| (z.infer<typeof zLink> & { children: Children[] })
| (z.infer<typeof zRelationship> & { children: Children[] })
// block
| (z.infer<typeof zComponentBlock> & { children: Children[] })
| (z.infer<typeof zComponentProp> & { children: Children[] })
| (z.infer<typeof zElements> & { children: Children[] })
| (z.infer<typeof zHeading> & { children: Children[] })
| (z.infer<typeof zLayout> & { children: Children[] })
| (z.infer<typeof zParagraph> & { children: Children[] })

const zBlock: z.ZodType<Children> = 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<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)

// exports
export type TextWithMarks = z.infer<typeof zText>
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')
}
}
Loading