|
1 | | -import * as t from 'io-ts' |
2 | | -import excess from 'io-ts-excess' |
| 1 | +import { z } from 'zod' |
3 | 2 | import { type RelationshipData } from './DocumentEditor/component-blocks/api-shared' |
4 | | -import { type Mark } from './DocumentEditor/utils' |
5 | 3 | import { isValidURL } from './DocumentEditor/isValidURL' |
6 | | -// note that this validation isn't about ensuring that a document has nodes in the right positions and things |
7 | | -// it's just about validating that it's a valid slate structure |
8 | | -// we'll then run normalize on it which will enforce more things |
9 | | -const markValue = t.union([t.undefined, t.literal(true)]) |
10 | | - |
11 | | -const text: t.Type<TextWithMarks> = excess( |
12 | | - t.type({ |
13 | | - text: t.string, |
14 | | - bold: markValue, |
15 | | - italic: markValue, |
16 | | - underline: markValue, |
17 | | - strikethrough: markValue, |
18 | | - code: markValue, |
19 | | - superscript: markValue, |
20 | | - subscript: markValue, |
21 | | - keyboard: markValue, |
22 | | - insertMenu: markValue, |
23 | | - }) |
24 | | -) |
25 | | -export type TextWithMarks = { type?: never, text: string } & { |
26 | | - [Key in Mark | 'insertMenu']: true | undefined; |
27 | | -} |
28 | | - |
29 | | -type Inline = TextWithMarks | Link | Relationship |
30 | | - |
31 | | -type Link = { type: 'link', href: string, children: Children } |
32 | | - |
33 | | -class URLType extends t.Type<string> { |
34 | | - readonly _tag: 'URLType' = 'URLType' as const |
35 | | - constructor () { |
36 | | - super( |
37 | | - 'string', |
38 | | - (u): u is string => typeof u === 'string' && isValidURL(u), |
39 | | - (u, c) => (this.is(u) ? t.success(u) : t.failure(u, c)), |
40 | | - t.identity |
41 | | - ) |
42 | | - } |
43 | | -} |
44 | | - |
45 | | -const urlType = new URLType() |
46 | | - |
47 | | -const link: t.Type<Link> = t.recursion('Link', () => |
48 | | - excess( |
49 | | - t.type({ |
50 | | - type: t.literal('link'), |
51 | | - href: urlType, |
52 | | - children, |
53 | | - }) |
54 | | - ) |
55 | | -) |
56 | | - |
57 | | -type Relationship = { |
58 | | - type: 'relationship' |
59 | | - relationship: string |
60 | | - data: RelationshipData | null |
61 | | - children: Children |
62 | | -} |
63 | | - |
64 | | -const relationship: t.Type<Relationship> = t.recursion('Relationship', () => |
65 | | - excess( |
66 | | - t.type({ |
67 | | - type: t.literal('relationship'), |
68 | | - relationship: t.string, |
69 | | - data: t.union([t.null, relationshipData]), |
70 | | - children, |
71 | | - }) |
72 | | - ) |
73 | | -) |
74 | | - |
75 | | -const inline = t.union([text, link, relationship]) |
76 | | - |
77 | | -type Children = (Block | Inline)[] |
78 | | - |
79 | | -const layoutArea: t.Type<Layout> = t.recursion('Layout', () => |
80 | | - excess( |
81 | | - t.type({ |
82 | | - type: t.literal('layout'), |
83 | | - layout: t.array(t.number), |
84 | | - children, |
85 | | - }) |
86 | | - ) |
87 | | -) |
88 | | - |
89 | | -type Layout = { |
90 | | - type: 'layout' |
91 | | - layout: number[] |
92 | | - children: Children |
93 | | -} |
94 | | - |
95 | | -const onlyChildrenElements: t.Type<OnlyChildrenElements> = t.recursion('OnlyChildrenElements', () => |
96 | | - excess( |
97 | | - t.type({ |
98 | | - type: t.union([ |
99 | | - t.literal('blockquote'), |
100 | | - t.literal('layout-area'), |
101 | | - t.literal('code'), |
102 | | - t.literal('divider'), |
103 | | - t.literal('list-item'), |
104 | | - t.literal('list-item-content'), |
105 | | - t.literal('ordered-list'), |
106 | | - t.literal('unordered-list'), |
107 | | - ]), |
108 | | - children, |
109 | | - }) |
110 | | - ) |
111 | | -) |
112 | | - |
113 | | -type OnlyChildrenElements = { |
114 | | - type: |
115 | | - | 'blockquote' |
116 | | - | 'layout-area' |
117 | | - | 'code' |
118 | | - | 'divider' |
119 | | - | 'list-item' |
120 | | - | 'list-item-content' |
121 | | - | 'ordered-list' |
122 | | - | 'unordered-list' |
123 | | - children: Children |
124 | | -} |
125 | | - |
126 | | -const textAlign = t.union([t.undefined, t.literal('center'), t.literal('end')]) |
127 | | - |
128 | | -const heading: t.Type<Heading> = t.recursion('Heading', () => |
129 | | - excess( |
130 | | - t.type({ |
131 | | - type: t.literal('heading'), |
132 | | - textAlign, |
133 | | - level: t.union([ |
134 | | - t.literal(1), |
135 | | - t.literal(2), |
136 | | - t.literal(3), |
137 | | - t.literal(4), |
138 | | - t.literal(5), |
139 | | - t.literal(6), |
140 | | - ]), |
141 | | - children, |
142 | | - }) |
143 | | - ) |
144 | | -) |
145 | | - |
146 | | -type Heading = { |
147 | | - type: 'heading' |
148 | | - level: 1 | 2 | 3 | 4 | 5 | 6 |
149 | | - textAlign: 'center' | 'end' | undefined |
150 | | - children: Children |
151 | | -} |
152 | | - |
153 | | -type Paragraph = { |
154 | | - type: 'paragraph' |
155 | | - textAlign: 'center' | 'end' | undefined |
156 | | - children: Children |
157 | | -} |
158 | | - |
159 | | -const paragraph: t.Type<Paragraph> = t.recursion('Paragraph', () => |
160 | | - excess( |
161 | | - t.type({ |
162 | | - type: t.literal('paragraph'), |
163 | | - textAlign, |
164 | | - children, |
165 | | - }) |
166 | | - ) |
167 | | -) |
168 | | - |
169 | | -const relationshipData: t.Type<RelationshipData> = excess( |
170 | | - t.type({ |
171 | | - id: t.string, |
172 | | - label: t.union([t.undefined, t.string]), |
173 | | - data: t.union([t.undefined, t.record(t.string, t.any)]), |
174 | | - }) |
175 | | -) |
176 | | - |
177 | | -type ComponentBlock = { |
178 | | - type: 'component-block' |
179 | | - component: string |
180 | | - props: Record<string, any> |
181 | | - children: Children |
182 | | -} |
183 | | - |
184 | | -const componentBlock: t.Type<ComponentBlock> = t.recursion('ComponentBlock', () => |
185 | | - excess( |
186 | | - t.type({ |
187 | | - type: t.literal('component-block'), |
188 | | - component: t.string, |
189 | | - props: t.record(t.string, t.any), |
190 | | - children, |
191 | | - }) |
192 | | - ) |
193 | | -) |
194 | | - |
195 | | -type ComponentProp = { |
196 | | - type: 'component-inline-prop' | 'component-block-prop' |
197 | | - propPath: (string | number)[] | undefined |
198 | | - children: Children |
199 | | -} |
200 | | - |
201 | | -const componentProp: t.Type<ComponentProp> = t.recursion('ComponentProp', () => |
202 | | - excess( |
203 | | - t.type({ |
204 | | - type: t.union([t.literal('component-inline-prop'), t.literal('component-block-prop')]), |
205 | | - propPath: t.union([t.array(t.union([t.string, t.number])), t.undefined]), |
206 | | - children, |
207 | | - }) |
208 | | - ) |
209 | | -) |
210 | | - |
211 | | -type Block = Layout | OnlyChildrenElements | Heading | ComponentBlock | ComponentProp | Paragraph |
212 | | - |
213 | | -const block: t.Type<Block> = t.recursion('Element', () => |
214 | | - t.union([layoutArea, onlyChildrenElements, heading, componentBlock, componentProp, paragraph]) |
215 | | -) |
216 | | - |
217 | | -export type ElementFromValidation = Block | Inline |
218 | | - |
219 | | -const children: t.Type<Children> = t.recursion('Children', () => t.array(t.union([block, inline]))) |
220 | 4 |
|
221 | | -export const editorCodec = t.array(block) |
| 5 | +// leaf types |
| 6 | +const zMarkValue = z.union([ |
| 7 | + z.undefined(), |
| 8 | + z.literal(true) |
| 9 | +]) |
| 10 | + |
| 11 | +const zText = z.object({ |
| 12 | + text: z.string(), |
| 13 | + bold: zMarkValue, |
| 14 | + italic: zMarkValue, |
| 15 | + underline: zMarkValue, |
| 16 | + strikethrough: zMarkValue, |
| 17 | + code: zMarkValue, |
| 18 | + superscript: zMarkValue, |
| 19 | + subscript: zMarkValue, |
| 20 | + keyboard: zMarkValue, |
| 21 | + insertMenu: zMarkValue, |
| 22 | +}).strict() |
| 23 | + |
| 24 | +const zTextAlign = z.union([ |
| 25 | + z.undefined(), |
| 26 | + z.literal('center'), |
| 27 | + z.literal('end') |
| 28 | +]) |
| 29 | + |
| 30 | +// recursive types |
| 31 | +const zLink = z.object({ |
| 32 | + type: z.literal('link'), |
| 33 | + href: z.string().refine(isValidURL), |
| 34 | +}).strict() |
| 35 | + |
| 36 | +const zHeading = z.object({ |
| 37 | + type: z.literal('heading'), |
| 38 | + textAlign: zTextAlign, |
| 39 | + level: z.union([ |
| 40 | + z.literal(1), |
| 41 | + z.literal(2), |
| 42 | + z.literal(3), |
| 43 | + z.literal(4), |
| 44 | + z.literal(5), |
| 45 | + z.literal(6), |
| 46 | + ]), |
| 47 | +}).strict() |
| 48 | + |
| 49 | +const zParagraph = z.object({ |
| 50 | + type: z.literal('paragraph'), |
| 51 | + textAlign: zTextAlign, |
| 52 | +}).strict() |
| 53 | + |
| 54 | +const zElements = z.object({ |
| 55 | + type: z.union([ |
| 56 | + z.literal('blockquote'), |
| 57 | + z.literal('layout-area'), |
| 58 | + z.literal('code'), |
| 59 | + z.literal('divider'), |
| 60 | + z.literal('list-item'), |
| 61 | + z.literal('list-item-content'), |
| 62 | + z.literal('ordered-list'), |
| 63 | + z.literal('unordered-list'), |
| 64 | + ]), |
| 65 | +}).strict() |
| 66 | + |
| 67 | +const zLayout = z.object({ |
| 68 | + type: z.literal('layout'), |
| 69 | + layout: z.array(z.number()), |
| 70 | +}).strict() |
| 71 | + |
| 72 | +const zRelationshipData = z.object({ |
| 73 | + id: z.string(), |
| 74 | + label: z.union([z.undefined(), z.string()]), |
| 75 | + data: z.union([z.undefined(), z.record(z.string(), z.any())]), |
| 76 | +}).strict() |
| 77 | + |
| 78 | +const zRelationship = z.object({ |
| 79 | + type: z.literal('relationship'), |
| 80 | + relationship: z.string(), |
| 81 | + data: z.union([z.null(), zRelationshipData]), |
| 82 | +}).strict() |
| 83 | + |
| 84 | +const zComponentBlock = z.object({ |
| 85 | + type: z.literal('component-block'), |
| 86 | + component: z.string(), |
| 87 | + props: z.record(z.string(), z.any()), |
| 88 | +}).strict() |
| 89 | + |
| 90 | +const zComponentProp = z.object({ |
| 91 | + type: z.union([z.literal('component-inline-prop'), z.literal('component-block-prop')]), |
| 92 | + propPath: z.union([z.array(z.union([z.string(), z.number()])), z.undefined()]), |
| 93 | +}).strict() |
| 94 | + |
| 95 | +type Children = |
| 96 | + | z.infer<typeof zMarkValue> |
| 97 | + | z.infer<typeof zText> |
| 98 | + | z.infer<typeof zTextAlign> |
| 99 | + | z.infer<typeof zHeading> & { children: Children[] } |
| 100 | + | z.infer<typeof zParagraph> & { children: Children[] } |
| 101 | + | z.infer<typeof zLink> & { children: Children[] } |
| 102 | + | z.infer<typeof zElements & { children: Children[] }> |
| 103 | + | z.infer<typeof zLayout & { children: Children[] } > |
| 104 | + | z.infer<typeof zRelationship> & { children: Children[] } |
| 105 | + | z.infer<typeof zComponentBlock> & { children: Children[] } |
| 106 | + | z.infer<typeof zComponentProp> & { children: Children[] } |
| 107 | + |
| 108 | +const zBlock: z.ZodType<Children> = z.union([ |
| 109 | + zLayout.extend({ children: z.lazy(() => zChildren) }), |
| 110 | + zElements.extend({ children: z.lazy(() => zChildren) }), |
| 111 | + zHeading.extend({ children: z.lazy(() => zChildren) }), |
| 112 | + zComponentBlock.extend({ children: z.lazy(() => zChildren) }), |
| 113 | + zComponentProp.extend({ children: z.lazy(() => zChildren) }), |
| 114 | + zParagraph.extend({ children: z.lazy(() => zChildren) }), |
| 115 | +]) |
| 116 | + |
| 117 | +const zInline: z.ZodType<Children> = z.union([ |
| 118 | + zText, |
| 119 | + zLink.extend({ children: z.lazy(() => zChildren) }), |
| 120 | + zRelationship.extend({ children: z.lazy(() => zChildren) }), |
| 121 | +]) |
| 122 | + |
| 123 | +const zChildren: z.ZodType<Children[]> = z.array(z.union([ |
| 124 | + zBlock, |
| 125 | + zInline, |
| 126 | +])) |
| 127 | + |
| 128 | +const zEditorCodec = z.array(zBlock) |
| 129 | + |
| 130 | +export type ElementFromValidation = z.infer<typeof zChildren>[number] |
222 | 131 |
|
223 | 132 | export function isRelationshipData (val: unknown): val is RelationshipData { |
224 | | - return relationshipData.validate(val, [])._tag === 'Right' |
| 133 | + return zRelationshipData.safeParse(val).success |
225 | 134 | } |
226 | 135 |
|
227 | 136 | export function validateDocumentStructure (val: unknown): asserts val is ElementFromValidation[] { |
228 | | - const result = editorCodec.validate(val, []) |
229 | | - if (result._tag === 'Left') { |
| 137 | + const result = zEditorCodec.safeParse(val) |
| 138 | + if (!result.success) { |
230 | 139 | throw new Error('Invalid document structure') |
231 | 140 | } |
232 | 141 | } |
0 commit comments