From fcd3a277952f53eb3ae6ebb559ae6a02f5553c87 Mon Sep 17 00:00:00 2001 From: "Ahmed T. Ali" Date: Tue, 4 Jan 2022 10:51:57 +0100 Subject: [PATCH] fix(rich-text-types): remove RT validation helpers (#302) Fixes #295 Related #274 --- package-lock.json | 23 +- packages/rich-text-types/package.json | 3 - .../src/__test__/validation.test.ts | 734 ------------------ packages/rich-text-types/src/index.ts | 1 - packages/rich-text-types/src/validation.ts | 157 ---- 5 files changed, 15 insertions(+), 903 deletions(-) delete mode 100644 packages/rich-text-types/src/__test__/validation.test.ts delete mode 100644 packages/rich-text-types/src/validation.ts diff --git a/package-lock.json b/package-lock.json index df493cc4..b1ca6a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@types/react": "^16.8.15", "@types/react-dom": "^16.8.4", "@types/rollup-plugin-json": "^3.0.3", - "ajv": "^8.8.2", "colors": "^1.1.2", "coveralls": "^3.0.0", "cross-env": "^5.0.1", @@ -6456,6 +6455,8 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==", + "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -22480,7 +22481,9 @@ "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "peer": true }, "node_modules/json-stable-stringify": { "version": "1.0.1", @@ -29582,6 +29585,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -36618,9 +36623,6 @@ "name": "@contentful/rich-text-types", "version": "15.11.0", "license": "MIT", - "dependencies": { - "ajv": "^8.8.2" - }, "devDependencies": { "@types/jest": "^27.0.1", "@types/node": "^14.17.14", @@ -38452,7 +38454,6 @@ "requires": { "@types/jest": "^27.0.1", "@types/node": "^14.17.14", - "ajv": "^8.8.2", "faker": "^4.1.0", "jest": "^27.1.0", "rimraf": "^2.6.3", @@ -42161,6 +42162,8 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==", + "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -54602,7 +54605,9 @@ "json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "peer": true }, "json-stable-stringify": { "version": "1.0.1", @@ -60274,7 +60279,9 @@ "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "peer": true }, "require-main-filename": { "version": "2.0.0", diff --git a/packages/rich-text-types/package.json b/packages/rich-text-types/package.json index 2b7d5be5..4853423f 100644 --- a/packages/rich-text-types/package.json +++ b/packages/rich-text-types/package.json @@ -24,9 +24,6 @@ "lint": "tslint -t codeFrame '@(src|bin)/*.ts'", "generate-json-schema": "ts-node -O '{\"module\": \"commonjs\"}' ./tools/jsonSchemaGen" }, - "dependencies": { - "ajv": "^8.8.2" - }, "devDependencies": { "@types/jest": "^27.0.1", "@types/node": "^14.17.14", diff --git a/packages/rich-text-types/src/__test__/validation.test.ts b/packages/rich-text-types/src/__test__/validation.test.ts deleted file mode 100644 index 6fa09bc6..00000000 --- a/packages/rich-text-types/src/__test__/validation.test.ts +++ /dev/null @@ -1,734 +0,0 @@ -import { BLOCKS } from '../blocks'; -import { INLINES } from '../inlines'; -import { validateRichTextDocument } from '../validation'; - -const document = (args: any, ...content: any) => ({ - nodeType: BLOCKS.DOCUMENT, - data: {}, - content, - ...args, -}); - -const node = (nodeType: string, args?: any, ...content: any) => ({ - nodeType, - data: {}, - content, - ...args, -}); - -const text = (value = '', args?: any) => ({ - nodeType: 'text', - data: {}, - marks: [], - value, - ...args, -}); - -const topLevelBlocks = [ - BLOCKS.EMBEDDED_ASSET, - BLOCKS.EMBEDDED_ENTRY, - BLOCKS.HEADING_1, - BLOCKS.HEADING_2, - BLOCKS.HEADING_3, - BLOCKS.HEADING_4, - BLOCKS.HEADING_5, - BLOCKS.HEADING_6, - BLOCKS.HR, - BLOCKS.OL_LIST, - BLOCKS.PARAGRAPH, - BLOCKS.QUOTE, - BLOCKS.TABLE, - BLOCKS.UL_LIST, -].sort(); - -const listBlocks = [ - BLOCKS.EMBEDDED_ASSET, - BLOCKS.EMBEDDED_ENTRY, - BLOCKS.HEADING_1, - BLOCKS.HEADING_2, - BLOCKS.HEADING_3, - BLOCKS.HEADING_4, - BLOCKS.HEADING_5, - BLOCKS.HEADING_6, - BLOCKS.HR, - BLOCKS.OL_LIST, - BLOCKS.PARAGRAPH, - BLOCKS.QUOTE, - BLOCKS.UL_LIST, -].sort(); - -describe('validateRichTextDocument', () => { - describe('root node', () => { - it('fails if it is not document node', () => { - const value = node(BLOCKS.PARAGRAPH); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/nodeType', - message: 'must be equal to one of the allowed values', - params: { - allowedValues: ['document'], - }, - data: BLOCKS.PARAGRAPH, - }), - ]); - }); - - it('does not allow invalid root document shape', () => { - const value: any = { nodeType: BLOCKS.DOCUMENT }; - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - instancePath: '', - message: "must have required property 'content'", - }), - expect.objectContaining({ - keyword: 'required', - instancePath: '', - message: "must have required property 'data'", - }), - ]); - }); - - it('does not allow nested documents', () => { - const value = document( - {}, - node(BLOCKS.PARAGRAPH), - node(BLOCKS.UL_LIST, {}, node(BLOCKS.LIST_ITEM, {}, node(BLOCKS.DOCUMENT))), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - message: 'must be equal to one of the allowed values', - keyword: 'enum', - instancePath: '/content/0/nodeType', - params: { - allowedValues: listBlocks, - }, - data: 'document', - }), - ]); - }); - - it('does not allow custom nodeTypes', () => { - const value = document( - {}, - node(BLOCKS.PARAGRAPH, {}, node('custom-type', {}, node(BLOCKS.PARAGRAPH))), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: 'must be equal to one of the allowed values', - params: { - allowedValues: Object.values(INLINES).sort(), - }, - data: 'custom-type', - }), - ]), - ); - }); - }); - - describe('direct children of root node', () => { - it('validate with blocks as direct children of the root node', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, {})); - const errorsResult = validateRichTextDocument(value); - expect(errorsResult).toEqual([]); - }); - - it(`fails with ${BLOCKS.LIST_ITEM} as immediate child of root node`, () => { - const value = document({}, node(BLOCKS.LIST_ITEM, {}, node(BLOCKS.PARAGRAPH, {}, text('')))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - message: 'must be equal to one of the allowed values', - instancePath: '/content/0/nodeType', - params: { - allowedValues: topLevelBlocks, - }, - data: BLOCKS.LIST_ITEM, - }), - ]); - }); - - for (const dependentNode of [BLOCKS.TABLE_ROW, BLOCKS.TABLE_CELL, BLOCKS.TABLE_HEADER_CELL]) { - it(`fails with ${dependentNode} as immediate child of root node`, () => { - const value = document({}, node(dependentNode, {}, node(BLOCKS.PARAGRAPH, {}, text('')))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - message: 'must be equal to one of the allowed values', - instancePath: '/content/0/nodeType', - params: { - allowedValues: topLevelBlocks, - }, - data: dependentNode, - }), - ]); - }); - } - - it('fail with inlines as direct children', () => { - const value = document({}, node(INLINES.HYPERLINK, { data: { uri: '' } })); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: 'must be equal to one of the allowed values', - params: { - allowedValues: topLevelBlocks, - }, - data: INLINES.HYPERLINK, - }), - ]); - }); - - it('fail with text as direct children', () => { - const value = document({}, text()); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: topLevelBlocks, - }, - data: 'text', - }), - ]), - ); - }); - - it('fail with text and inline as direct children', () => { - const value = document( - {}, - text(), - node(BLOCKS.PARAGRAPH), - node(INLINES.ASSET_HYPERLINK, { data: { target: {} } }), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: topLevelBlocks, - }, - data: 'text', - }), - ]), - ); - }); - }); - - describe('children constraints', () => { - for (const listNode of [BLOCKS.UL_LIST, BLOCKS.OL_LIST]) { - it(`allows only ${BLOCKS.LIST_ITEM} as immediate children of list nodes (${listNode})`, () => { - const value = document({}, node(listNode, {}, node(BLOCKS.PARAGRAPH, {}, text('')))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: [BLOCKS.LIST_ITEM], - }, - data: BLOCKS.PARAGRAPH, - }), - ]); - }); - } - - it(`allows only ${BLOCKS.TABLE_ROW} as immediate children of table nodes`, () => { - const value = document({}, node(BLOCKS.TABLE, {}, node(BLOCKS.PARAGRAPH, {}, text('')))); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: [BLOCKS.TABLE_ROW], - }, - data: BLOCKS.PARAGRAPH, - }), - ]); - }); - - it(`allows only table cell nodes as immediate children of a ${BLOCKS.TABLE_ROW} nodes`, () => { - const value = document( - {}, - node(BLOCKS.TABLE, {}, node(BLOCKS.TABLE_ROW, {}, node(BLOCKS.PARAGRAPH, {}, text('')))), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: [BLOCKS.TABLE_CELL, BLOCKS.TABLE_HEADER_CELL], - }, - data: BLOCKS.PARAGRAPH, - }), - ]); - }); - - it(`allows only block nodes as direct children of ${BLOCKS.LIST_ITEM} nodes`, () => { - const value = document({}, node(BLOCKS.UL_LIST, {}, node(BLOCKS.LIST_ITEM, {}, text('')))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: listBlocks, - }, - data: 'text', - }), - ]), - ); - }); - - it(`allows only paragraphs as direct children of ${BLOCKS.TABLE_CELL} nodes`, () => { - const value = document( - {}, - node(BLOCKS.TABLE, {}, node(BLOCKS.TABLE_ROW, {}, node(BLOCKS.TABLE_CELL, {}, text('')))), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: ['paragraph'], - }, - data: 'text', - }), - ]), - ); - }); - - it('allows inlines to contain only inline or text nodes', () => { - const value = document( - {}, - node( - BLOCKS.PARAGRAPH, - {}, - node(INLINES.HYPERLINK, { data: { uri: '' } }, node(BLOCKS.PARAGRAPH, {}, text(''))), - ), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: `must be equal to one of the allowed values`, - params: { - allowedValues: ['text'], - }, - data: BLOCKS.PARAGRAPH, - }), - ]), - ); - }); - - it(`allows only ${BLOCKS.PARAGRAPH} as children of ${BLOCKS.QUOTE}`, () => { - const value = document( - {}, - node(BLOCKS.QUOTE, {}, node(INLINES.HYPERLINK, { data: { uri: '' } })), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'enum', - message: `must be equal to one of the allowed values`, - instancePath: '/content/0/nodeType', - data: INLINES.HYPERLINK, - params: { - allowedValues: [BLOCKS.PARAGRAPH], - }, - }), - ]); - }); - }); - - describe('node properties', () => { - describe('blocks and inlines', () => { - it('validate with required properties', () => { - const value: any = { - nodeType: BLOCKS.DOCUMENT, - data: {}, - content: [], - }; - - const errorsResult = validateRichTextDocument(value); - expect(errorsResult).toEqual([]); - }); - - it('fail without required `nodeType` property', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, { data: {}, nodeType: null }, text(''))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - keyword: 'enum', - instancePath: '/content/0/nodeType', - message: 'must be equal to one of the allowed values', - params: { - allowedValues: topLevelBlocks, - }, - data: null, - }), - ]), - ); - }); - - it('fail without required `content` property', () => { - const value = document( - {}, - node( - BLOCKS.OL_LIST, - {}, - node(BLOCKS.LIST_ITEM, {}, { nodeType: BLOCKS.PARAGRAPH, data: {} }), - ), - ); - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - instancePath: '', - message: "must have required property 'content'", - }), - ]); - }); - - it('fail with invalid `content` property', () => { - // We already test `undefined` value above (that would throw a "required" error) - // that's why it's not included in the list. - ['hello', 123, true, null].forEach(content => { - const value: any = { - nodeType: 'document', - data: {}, - content, - }; - - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'type', - instancePath: '/content', - message: 'must be array', - schema: 'array', - data: content, - }), - ]); - }); - }); - - it('fail with unknown/custom properties', () => { - const value = document({ - data: {}, - content: [], - customProp: 1, - }); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'additionalProperties', - instancePath: '', - params: { - additionalProperty: 'customProp', - }, - message: 'must NOT have additional properties', - }), - ]); - }); - - it('fail with missing & unknown/custom properties', () => { - const value = document({ - data: {}, - customProp: 1, - content: null, - }); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'additionalProperties', - instancePath: '', - params: { - additionalProperty: 'customProp', - }, - message: `must NOT have additional properties`, - }), - expect.objectContaining({ - keyword: 'type', - instancePath: '/content', - message: 'must be array', - data: null, - schema: 'array', - }), - ]); - }); - }); - - describe('text nodes', () => { - it('validate with required properties', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, {}, text(''))); - const errorResults = validateRichTextDocument(value); - expect(errorResults).toEqual([]); - }); - - it('fail without required properties', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, {}, text('', { data: null }))); - const errorsResult = validateRichTextDocument(value); - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'type', - instancePath: '/data', - message: 'must be object', - schema: 'object', - data: null, - }), - ]); - }); - - it('fail without required `value` property', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, {}, text(null))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'type', - instancePath: '/value', - message: 'must be string', - schema: 'string', - data: null, - }), - ]); - }); - - it('fail with unknown/custom properties', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, {}, text('', { customProp: true }))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'additionalProperties', - instancePath: '', - params: { - additionalProperty: 'customProp', - }, - message: `must NOT have additional properties`, - }), - ]); - }); - - it('fail with missing & unknown/custom properties', () => { - const value = document({}, node(BLOCKS.PARAGRAPH, {}, text(null))); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'type', - instancePath: '/value', - message: 'must be string', - schema: 'string', - data: null, - }), - ]); - }); - }); - }); - - describe('properties shape', () => { - describe('`data` property', () => { - it('fails with missing `data` property', () => { - const value = document( - {}, - node(BLOCKS.PARAGRAPH, {}, { nodeType: INLINES.HYPERLINK, content: [] }), - ); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - message: "must have required property 'data'", - }), - ]); - }); - - it(`fails with invalid properties for ${INLINES.HYPERLINK} nodeTypes`, () => { - const value = document( - {}, - node( - BLOCKS.PARAGRAPH, - {}, - { - nodeType: INLINES.HYPERLINK, - data: {}, - content: [text('')], - }, - ), - ); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - instancePath: '/data', - message: "must have required property 'uri'", - }), - ]); - }); - - it(`fails with invalid properties for ${INLINES.ASSET_HYPERLINK} nodeTypes`, () => { - const value = document( - {}, - node( - BLOCKS.PARAGRAPH, - {}, - { nodeType: INLINES.ASSET_HYPERLINK, data: {}, content: [text()] }, - ), - ); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - instancePath: '/data', - message: "must have required property 'target'", - }), - ]); - }); - - it(`fails with invalid properties for ${INLINES.ENTRY_HYPERLINK} nodeTypes`, () => { - const value = document( - {}, - node( - BLOCKS.PARAGRAPH, - {}, - { nodeType: INLINES.ENTRY_HYPERLINK, data: {}, content: [text()] }, - ), - ); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - instancePath: '/data', - message: "must have required property 'target'", - }), - ]); - }); - - it(`fails with invalid properties of ${INLINES.EMBEDDED_ENTRY} nodeTypes`, () => { - const value = document( - {}, - node( - BLOCKS.PARAGRAPH, - {}, - { nodeType: INLINES.EMBEDDED_ENTRY, data: {}, content: [text()] }, - ), - ); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'required', - instancePath: '/data', - message: "must have required property 'target'", - }), - expect.objectContaining({ - keyword: 'maxItems', - instancePath: '/content', - message: 'must NOT have more than 0 items', - data: [ - { - data: {}, - marks: [], - nodeType: 'text', - value: '', - }, - ], - }), - ]); - }); - - it('fails with unknown/custom properties', () => { - const value = document( - {}, - node( - BLOCKS.PARAGRAPH, - {}, - { - nodeType: INLINES.HYPERLINK, - data: { foo: true, uri: 'https://world.com' }, - content: [text()], - }, - ), - ); - const errorsResult = validateRichTextDocument(value); - - expect(errorsResult).toEqual([ - expect.objectContaining({ - keyword: 'additionalProperties', - instancePath: '/data', - params: { - additionalProperty: 'foo', - }, - message: 'must NOT have additional properties', - }), - ]); - }); - }); - }); -}); diff --git a/packages/rich-text-types/src/index.ts b/packages/rich-text-types/src/index.ts index d095af33..65cede1b 100644 --- a/packages/rich-text-types/src/index.ts +++ b/packages/rich-text-types/src/index.ts @@ -11,4 +11,3 @@ export { default as EMPTY_DOCUMENT } from './emptyDocument'; import * as helpers from './helpers'; export { helpers }; -export { validateRichTextDocument } from './validation'; diff --git a/packages/rich-text-types/src/validation.ts b/packages/rich-text-types/src/validation.ts deleted file mode 100644 index c9ed5483..00000000 --- a/packages/rich-text-types/src/validation.ts +++ /dev/null @@ -1,157 +0,0 @@ -import Ajv, { ErrorObject, ValidateFunction } from 'ajv'; - -import { Node } from './types'; -import { BLOCKS } from './blocks'; -import { isText } from './helpers'; -import { getSchemaWithNodeType } from './schemas'; - -const ajv = new Ajv({ allErrors: true, verbose: true }); - -type AnyNode = Node & { - content?: AnyNode[]; - value?: string; - marks?: string[]; -}; - -type Path = (string | number)[]; - -type ErrorTransformer = (error: ErrorObject, path: Path) => T; - -export type ValidationOptions = { - transformError?: ErrorTransformer; -}; - -/** - * Validates a rich text document against our JSON schemas using AJV. - * - * We need to reduce the validation scope to keep AJV from returning error - * messages with obscure code paths. - * - * Example: - * - * Given a node that accepts children which should match one of multiple - * schemas, having an invalid child node (e.g., with a missing property), AJV - * tries to validate the child node against one of the other schemas. This - * results in misleading / cryptic error messages. - * - * This function runs AJV validations against nodes whose children have had - * their properties reset, so that AJV validates only against properties of the - * parent node's nodeType. This is the reasoning behind the `removeChildNodes` - * and `removeGrandChildNodes` helpers. - */ -export function validateRichTextDocument( - document: AnyNode, - options?: ValidationOptions, -): T[] { - const validateRootNode = getValidator(BLOCKS.DOCUMENT); - const rootNodeIsValid = validateRootNode(removeGrandChildNodes(document)); - - const transformError: ErrorTransformer = options?.transformError ?? (error => error); - - /** - * Note that this is not the most beautiful / functional implementation - * possible, but since we are validating what could potentially be a - * substantially lengthy (hence: computationally complex) tree, we need to - * constrain both space _and_ memory usage. This is the reasoning behind using - * imperative logic with passed references and in-line mutation. - */ - const errors: T[] = []; - - if (rootNodeIsValid) { - validateChildNodes(document, ['content'], errors, transformError); - } else { - buildSchemaErrors(validateRootNode, [], errors, transformError); - } - - return errors; -} - -/** - * Validates each child of a root node, continually (recursively) passing down - * the path from the originating root node. - */ -function validateChildNodes( - node: AnyNode, - path: Path, - errors: unknown[], - transform: ErrorTransformer, -) { - for (let i = 0; i < node.content.length; i++) { - validateNode(node.content[i], [...path, i], errors, transform); - } -} - -function validateNode(node: AnyNode, path: Path, errors: unknown[], transform: ErrorTransformer) { - const validateSchema = getValidator(node.nodeType); - const isValid = validateSchema(removeGrandChildNodes(resetChildNodes(node))); - if (!isValid) { - buildSchemaErrors(validateSchema, path, errors, transform); - return; - } - - if (!isLeafNode(node)) { - validateChildNodes(node, [...path, 'content'], errors, transform); - } -} - -/** - * Gets the validating function for the schema from the AJV instance. Note that - * AJV caches the schema under the hood, while `getSchemaWithNodeType` is - * returning JSON objects from a Webpack-ified dictionary object, so there is no - * way to further optimize here (even though it may look otherwise). - */ -function getValidator(nodeType: string): ValidateFunction { - const schema = getSchemaWithNodeType(nodeType); - - return ajv.compile(schema); -} - -function buildSchemaErrors( - validateSchema: ValidateFunction, - path: Path, - errors: unknown[], - transform: ErrorTransformer, -) { - const schemaErrors = (validateSchema.errors || []).map(error => transform(error, path)); - - errors.push(...schemaErrors); -} - -function resetChildNodes(node: AnyNode) { - const { content } = node; - if (isLeafNode(node)) { - return node; - } - - return Object.assign({}, node, { content: content.map(resetNode) }); -} - -function resetNode(node: AnyNode): AnyNode { - const { nodeType } = node; - if (isText(node)) { - return { nodeType, data: {}, value: '', marks: [] }; - } - - return { nodeType, data: {}, content: [] }; -} - -function removeGrandChildNodes(node: AnyNode) { - const { content } = node; - if (isLeafNode(node)) { - return node; - } - - return Object.assign({}, node, { content: content.map(removeChildNodes) }); -} - -function removeChildNodes(node: AnyNode) { - if (isText(node)) { - return node; - } - - return Object.assign({}, node, { content: [] }); -} - -function isLeafNode(node: AnyNode) { - return isText(node) || !Array.isArray(node.content); -}