From 762d5b9adf59c3d8f4b2284ebba46d4f99ce966e Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Mon, 1 Apr 2024 12:48:32 +0100 Subject: [PATCH] Issue #3364884 by codebymikey, Dylan Donkersgoed, sivakarthik229, DamienMcKenna, _utsavsharma, ramprassad, Znak, nod_, pradhumanjain2311, Wim Leers, smustgrave, kevinvanhove: [DrupalHtmlEngine] HTML-reserved characters (>, <, &) in <script> and <style> tag are converted to HTML entities --- .../ckeditor5/js/build/drupalHtmlEngine.js | 2 +- .../drupalHtmlEngine/src/drupalhtmlbuilder.js | 12 +- .../FunctionalJavascript/CKEditor5Test.php | 146 ++++++++++++++++++ .../Nightwatch/Tests/drupalHtmlBuilderTest.js | 31 ++++ 4 files changed, 189 insertions(+), 2 deletions(-) diff --git a/core/modules/ckeditor5/js/build/drupalHtmlEngine.js b/core/modules/ckeditor5/js/build/drupalHtmlEngine.js index 5d926e5e2862..600d0c0850ad 100644 --- a/core/modules/ckeditor5/js/build/drupalHtmlEngine.js +++ b/core/modules/ckeditor5/js/build/drupalHtmlEngine.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalHtmlEngine=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,n)=>{e.exports=n("dll-reference CKEditor5.dll")("./src/core.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function n(p){var r=t[p];if(void 0!==r)return r.exports;var s=t[p]={exports:{}};return e[p](s,s.exports,n),s.exports}n.d=(e,t)=>{for(var p in t)n.o(t,p)&&!n.o(e,p)&&Object.defineProperty(e,p,{enumerable:!0,get:t[p]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var p={};return(()=>{"use strict";n.d(p,{default:()=>o});var e=n("ckeditor5/src/core.js");class t{constructor(){this.chunks=[],this.selfClosingTags=["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]}build(){return this.chunks.join("")}appendNode(e){e.nodeType===Node.TEXT_NODE?this._appendText(e):e.nodeType===Node.ELEMENT_NODE?this._appendElement(e):e.nodeType===Node.DOCUMENT_FRAGMENT_NODE?this._appendChildren(e):e.nodeType===Node.COMMENT_NODE&&this._appendComment(e)}_appendElement(e){const t=e.nodeName.toLowerCase();this._append("<"),this._append(t),this._appendAttributes(e),this._append(">"),this.selfClosingTags.includes(t)||(this._appendChildren(e),this._append("</"),this._append(t),this._append(">"))}_appendChildren(e){Object.keys(e.childNodes).forEach((t=>{this.appendNode(e.childNodes[t])}))}_appendAttributes(e){Object.keys(e.attributes).forEach((t=>{this._append(" "),this._append(e.attributes[t].name),this._append('="'),this._append(this.constructor._escapeAttribute(e.attributes[t].value)),this._append('"')}))}_appendText(e){const t=document.implementation.createHTMLDocument("").createElement("p");t.textContent=e.textContent,this._append(t.innerHTML)}_appendComment(e){this._append("\x3c!--"),this._append(e.textContent),this._append("--\x3e")}_append(e){this.chunks.push(e)}static _escapeAttribute(e){return e.replace(/&/g,"&").replace(/'/g,"'").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">").replace(/\r\n/g," ").replace(/[\r\n]/g," ")}}class r{getHtml(e){const n=new t;return n.appendNode(e),n.build()}}class s extends e.Plugin{init(){this.editor.data.processor.htmlWriter=new r}static get pluginName(){return"DrupalHtmlEngine"}}const o={DrupalHtmlEngine:s}})(),p=p.default})())); \ No newline at end of file +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalHtmlEngine=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,n)=>{e.exports=n("dll-reference CKEditor5.dll")("./src/core.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function n(p){var r=t[p];if(void 0!==r)return r.exports;var s=t[p]={exports:{}};return e[p](s,s.exports,n),s.exports}n.d=(e,t)=>{for(var p in t)n.o(t,p)&&!n.o(e,p)&&Object.defineProperty(e,p,{enumerable:!0,get:t[p]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var p={};return(()=>{"use strict";n.d(p,{default:()=>a});var e=n("ckeditor5/src/core.js");class t{constructor(){this.chunks=[],this.selfClosingTags=["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"],this.rawTags=["script","style"]}build(){return this.chunks.join("")}appendNode(e){e.nodeType===Node.TEXT_NODE?this._appendText(e):e.nodeType===Node.ELEMENT_NODE?this._appendElement(e):e.nodeType===Node.DOCUMENT_FRAGMENT_NODE?this._appendChildren(e):e.nodeType===Node.COMMENT_NODE&&this._appendComment(e)}_appendElement(e){const t=e.nodeName.toLowerCase();this._append("<"),this._append(t),this._appendAttributes(e),this._append(">"),this.selfClosingTags.includes(t)||(this._appendChildren(e),this._append("</"),this._append(t),this._append(">"))}_appendChildren(e){Object.keys(e.childNodes).forEach((t=>{this.appendNode(e.childNodes[t])}))}_appendAttributes(e){Object.keys(e.attributes).forEach((t=>{this._append(" "),this._append(e.attributes[t].name),this._append('="'),this._append(this.constructor._escapeAttribute(e.attributes[t].value)),this._append('"')}))}_appendText(e){const t=document.implementation.createHTMLDocument("").createElement("p");t.textContent=e.textContent,e.parentElement&&this.rawTags.includes(e.parentElement.tagName.toLowerCase())?this._append(t.textContent):this._append(t.innerHTML)}_appendComment(e){this._append("\x3c!--"),this._append(e.textContent),this._append("--\x3e")}_append(e){this.chunks.push(e)}static _escapeAttribute(e){return e.replace(/&/g,"&").replace(/'/g,"'").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">").replace(/\r\n/g," ").replace(/[\r\n]/g," ")}}class r{getHtml(e){const n=new t;return n.appendNode(e),n.build()}}class s extends e.Plugin{init(){this.editor.data.processor.htmlWriter=new r}static get pluginName(){return"DrupalHtmlEngine"}}const a={DrupalHtmlEngine:s}})(),p=p.default})())); \ No newline at end of file diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalHtmlEngine/src/drupalhtmlbuilder.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalHtmlEngine/src/drupalhtmlbuilder.js index 5e0321664c8f..a1052b698044 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalHtmlEngine/src/drupalhtmlbuilder.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalHtmlEngine/src/drupalhtmlbuilder.js @@ -36,6 +36,9 @@ export default class DrupalHtmlBuilder { 'track', 'wbr', ]; + + // @see https://html.spec.whatwg.org/multipage/syntax.html#raw-text-elements + this.rawTags = ['script', 'style']; } /** @@ -141,7 +144,14 @@ export default class DrupalHtmlBuilder { const container = doc.createElement('p'); container.textContent = node.textContent; - this._append(container.innerHTML); + if ( + node.parentElement && + this.rawTags.includes(node.parentElement.tagName.toLowerCase()) + ) { + this._append(container.textContent); + } else { + this._append(container.innerHTML); + } } /** diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php index 90111da112a0..c6feb3b2b70e 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php @@ -797,6 +797,152 @@ function (ConstraintViolation $v) { $assert_session->responseContains('<!-- Hamsters, alpacas, llamas, and kittens are cute! --><p>This is a <em>test!</em></p>'); } + /** + * Ensures that HTML scripts and styles are properly preserved in CKEditor 5. + */ + public function testStylesAndScripts(): void { + $test_cases = [ + // Test cases taken from the HTML documentation. + // @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements + 'script' => [ + '<script>(function() { let x = 10, y = 5; if( y <--x ) { console.log("run me!"); }})()</script>', + '<script>(function() { let x = 10, y = 5; if( y <--x ) { console.log("run me!"); }})()</script>', + ], + 'script like tag' => [ + '<script>(function() { let player = 5, script = 10; if (player<script) { console.log("run me!"); }})()</script>', + '<script>(function() { let player = 5, script = 10; if (player<script) { console.log("run me!"); }})()</script>', + ], + 'script to escape' => [ + "<script>const example = 'Consider this string: <!-- <script>';</script>", + "<script>const example = 'Consider this string: <!-- <script>';</script>", + ], + 'unescaped script tag' => [ + <<<HTML + <script> + const example = 'Consider this string: <!-- <script>'; + console.log(example); + </script> + <!-- despite appearances, this is actually part of the script still! --> + <script> + let a = 1 + 2; // this is the same script block still... + </script> + HTML, + <<<HTML + <script> + const example = 'Consider this string: <!-- <script>'; + console.log(example); + </script> + <!-- despite appearances, this is actually part of the script still! --> + <script> + let a = 1 + 2; // this is the same script block still... + </script> + HTML, + ], + 'style' => [ + <<<HTML + <style> + a > span { + /* Important comment. */ + color: red !important; + } + </style> + HTML, + <<<HTML + <style> + a > span { + /* Important comment. */ + color: red !important; + } + </style> + HTML, + ], + 'script and style' => [ + <<<HTML + <script type="text/javascript"> + let x = 10; + let y = 5; + if(y < x){ + console.log('is smaller') + } + </script> + <style type="text/css"> + :root { + --main-bg-color: brown; + } + .sections > .section { + background: var(--main-bg-color); + } + </style> + HTML, + <<<HTML + <script type="text/javascript"> + let x = 10; + let y = 5; + if(y < x){ + console.log('is smaller') + } + </script><style type="text/css"> + :root { + --main-bg-color: brown; + } + .sections > .section { + background: var(--main-bg-color); + } + </style> + HTML, + ], + ]; + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + // Create filter. + FilterFormat::create([ + 'format' => 'ckeditor5', + 'name' => 'CKEditor 5 HTML', + 'roles' => [RoleInterface::AUTHENTICATED_ID], + ])->save(); + Editor::create([ + 'format' => 'ckeditor5', + 'editor' => 'ckeditor5', + 'settings' => [ + 'toolbar' => [ + 'items' => [ + 'sourceEditing', + ], + ], + 'plugins' => [ + 'ckeditor5_sourceEditing' => [ + 'allowed_tags' => [], + ], + ], + ], + ])->save(); + $this->assertSame([], array_map( + function (ConstraintViolation $v) { + return (string) $v->getMessage(); + }, + iterator_to_array(CKEditor5::validatePair( + Editor::load('ckeditor5'), + FilterFormat::load('ckeditor5') + )) + )); + + // Add a node with text rendered via the CKEditor 5 HTML format. + foreach ($test_cases as $test_case_name => $test_case) { + [$markup, $expected_content] = $test_case; + $this->drupalGet('node/add'); + $page->fillField('title[0][value]', "Style and script test - $test_case_name"); + $this->waitForEditor(); + $this->pressEditorButton('Source'); + $editor = $page->find('css', '.ck-source-editing-area textarea'); + $editor->setValue($markup); + $page->pressButton('Save'); + + $assert_session->responseContains($expected_content); + } + } + /** * Ensures that changes are saved in CKEditor 5. */ diff --git a/core/modules/ckeditor5/tests/src/Nightwatch/Tests/drupalHtmlBuilderTest.js b/core/modules/ckeditor5/tests/src/Nightwatch/Tests/drupalHtmlBuilderTest.js index 0a051e5d8ad6..f4f8f3bd13a9 100644 --- a/core/modules/ckeditor5/tests/src/Nightwatch/Tests/drupalHtmlBuilderTest.js +++ b/core/modules/ckeditor5/tests/src/Nightwatch/Tests/drupalHtmlBuilderTest.js @@ -72,6 +72,37 @@ module.exports = { 'foo bar<p>foo</p><div>bar</div>', ); }, + 'should return correct HTML scripts and styles': function () { + const drupalHtmlBuilder = new DrupalHtmlBuilder(); + const fragment = document.createDocumentFragment(); + const script = document.createElement('script'); + script.textContent = `let x = 10; +let y = 5; +if (y < x) { +console.log('is smaller') +}`; + const style = document.createElement('style'); + style.setAttribute('type', 'text/css'); + style.appendChild( + document.createTextNode(':root .sections > h2 { background: red}'), + ); + + fragment.appendChild(style); + fragment.appendChild(document.createTextNode('\n')); + fragment.appendChild(script); + + drupalHtmlBuilder.appendNode(fragment); + + assert.equal( + drupalHtmlBuilder.build(), + `<style type="text/css">:root .sections > h2 { background: red}</style> +<script>let x = 10; +let y = 5; +if (y < x) { +console.log('is smaller') +}</script>`, + ); + }, 'should return correct HTML from fragment with comment': function () { const drupalHtmlBuilder = new DrupalHtmlBuilder(); const fragment = document.createDocumentFragment(); -- GitLab