diff --git a/package-lock.json b/package-lock.json index 8c539f2aa..a6109891e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lint": "^6.2.1", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@reduxjs/toolkit": "^1.8.1", @@ -31,7 +32,8 @@ "reselect": "^4.1.5", "tinymce": "^5.10.4", "video-react": "^0.15.0", - "video.js": "^7.18.1" + "video.js": "^7.18.1", + "xmlchecker": "^0.1.0" }, "devDependencies": { "@edx/frontend-build": "^11.0.2", @@ -2824,9 +2826,9 @@ "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==" }, "node_modules/@codemirror/lint": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.1.0.tgz", - "integrity": "sha512-mdvDQrjRmYPvQ3WrzF6Ewaao+NWERYtpthJvoQ3tK3t/44Ynhk8ZGjTSL9jMEv8CgSMogmt75X8ceOZRDSXHtQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.2.1.tgz", + "integrity": "sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -34451,6 +34453,14 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/xmlchecker": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xmlchecker/-/xmlchecker-0.1.0.tgz", + "integrity": "sha512-ElfXP7Fse9G37go0mjD5a7zY9fnE19BHk1qbH02uXX/O6Gi5Be2TPCg3zHG7ZIzV2yoGr/Gybfw/Z2Cs62tDxw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -36532,9 +36542,9 @@ } }, "@codemirror/lint": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.1.0.tgz", - "integrity": "sha512-mdvDQrjRmYPvQ3WrzF6Ewaao+NWERYtpthJvoQ3tK3t/44Ynhk8ZGjTSL9jMEv8CgSMogmt75X8ceOZRDSXHtQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.2.1.tgz", + "integrity": "sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==", "requires": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -60979,6 +60989,11 @@ } } }, + "xmlchecker": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xmlchecker/-/xmlchecker-0.1.0.tgz", + "integrity": "sha512-ElfXP7Fse9G37go0mjD5a7zY9fnE19BHk1qbH02uXX/O6Gi5Be2TPCg3zHG7ZIzV2yoGr/Gybfw/Z2Cs62tDxw==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 90d38f099..5be2b045c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lint": "^6.2.1", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@reduxjs/toolkit": "^1.8.1", @@ -79,7 +80,8 @@ "reselect": "^4.1.5", "tinymce": "^5.10.4", "video-react": "^0.15.0", - "video.js": "^7.18.1" + "video.js": "^7.18.1", + "xmlchecker": "^0.1.0" }, "peerDependencies": { "@edx/frontend-platform": ">1.15.0", diff --git a/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap index 5cf3fdc4d..9ef9eddf0 100644 --- a/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/EditorContainer/components/EditorFooter/__snapshots__/index.test.jsx.snap @@ -115,7 +115,7 @@ exports[`EditorFooter render snapshot: save failed. Show error message 1`] = ` show={true} > diff --git a/src/editors/containers/EditorContainer/components/EditorFooter/messages.js b/src/editors/containers/EditorContainer/components/EditorFooter/messages.js index f8203a9d8..ce7503ff1 100644 --- a/src/editors/containers/EditorContainer/components/EditorFooter/messages.js +++ b/src/editors/containers/EditorContainer/components/EditorFooter/messages.js @@ -4,7 +4,7 @@ const messages = defineMessages({ contentSaveFailed: { id: 'authoring.editorfooter.save.error', - defaultMessage: 'Error: Content save failed. Try again later.', + defaultMessage: 'Error: Content save failed. Please check recent changes and try again later.', description: 'Error message displayed when content fails to save.', }, cancelButtonAriaLabel: { diff --git a/src/editors/sharedComponents/CodeEditor/hooks.js b/src/editors/sharedComponents/CodeEditor/hooks.js index 3c99b0f1e..376edff00 100644 --- a/src/editors/sharedComponents/CodeEditor/hooks.js +++ b/src/editors/sharedComponents/CodeEditor/hooks.js @@ -1,11 +1,12 @@ import React, { useEffect } from 'react'; +import xmlChecker from 'xmlchecker'; import { basicSetup } from 'codemirror'; import { EditorState } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; import { html } from '@codemirror/lang-html'; import { xml } from '@codemirror/lang-xml'; - +import { linter } from '@codemirror/lint'; import alphanumericMap from './constants'; import './index.scss'; @@ -27,6 +28,32 @@ export const cleanHTML = ({ initialText }) => { return initialText.replace(translateRegex, translator); }; +export const syntaxChecker = ({ textArr, lang }) => { + const diagnostics = []; + if (lang === 'xml') { + const docString = textArr.join('\n'); + const xmlDoc = ` ${docString}`; + + try { + xmlChecker.check(xmlDoc); + } catch (error) { + let errorStart = 0; + for (let i = 0; i < error.line - 1; i++) { + errorStart += textArr[i].length; + } + const errorLine = error.line; + const errorEnd = errorStart + textArr[errorLine - 1].length; + diagnostics.push({ + from: errorStart, + to: errorEnd, + severity: 'error', + message: `${error.name}: ${error.message}`, + }); + } + } + return diagnostics; +}; + export const createCodeMirrorDomNode = ({ ref, initialText, @@ -38,7 +65,15 @@ export const createCodeMirrorDomNode = ({ const cleanText = cleanHTML({ initialText }); const newState = EditorState.create({ doc: cleanText, - extensions: [basicSetup, languageExtension, EditorView.lineWrapping], + extensions: [ + basicSetup, + languageExtension, + EditorView.lineWrapping, + linter((view) => { + const textArr = view.docView.view.viewState.state.doc.text; + return syntaxChecker({ textArr, lang }); + }), + ], }); const view = new EditorView({ state: newState, parent: ref.current }); // eslint-disable-next-line no-param-reassign diff --git a/src/editors/sharedComponents/CodeEditor/index.test.jsx b/src/editors/sharedComponents/CodeEditor/index.test.jsx index 7004a25ab..9f7d7c94e 100644 --- a/src/editors/sharedComponents/CodeEditor/index.test.jsx +++ b/src/editors/sharedComponents/CodeEditor/index.test.jsx @@ -114,6 +114,35 @@ describe('CodeEditor', () => { }); }); }); + + describe('xmlSyntaxChecker', () => { + describe('lang equals html', () => { + it('returns empty array', () => { + const textArr = ['', '

', 'this is some text', '

', '
']; + const diagnostics = hooks.syntaxChecker({ textArr, lang: 'html' }); + expect(diagnostics).toEqual([]); + }); + }); + describe('lang equals xml', () => { + it('returns empty array', () => { + const textArr = ['', '

', 'this is some text', '

', '
']; + const diagnostics = hooks.syntaxChecker({ textArr, lang: 'xml' }); + expect(diagnostics).toEqual([]); + }); + it('returns an array with error object', () => { + const textArr = ['', '

', '

', 'this is some text', '

', '
']; + const expectedDiagnostics = hooks.syntaxChecker({ textArr, lang: 'xml' }); + const diagnostics = [{ + from: 9, + to: 12, + severity: 'error', + message: 'SyntaxError: Expected that start and end tag must be identical but "<" found.', + }]; + expect(expectedDiagnostics).toEqual(diagnostics); + }); + }); + }); + describe('Component', () => { describe('Snapshots', () => { const mockHideBtn = jest.fn().mockName('mockHidebtn');