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');