diff --git a/lib/editor/tiny/amd/build/editor.min.js b/lib/editor/tiny/amd/build/editor.min.js
index 09de3bd28bb..60ebfb3a3e1 100644
--- a/lib/editor/tiny/amd/build/editor.min.js
+++ b/lib/editor/tiny/amd/build/editor.min.js
@@ -1,3 +1,3 @@
-define("editor_tiny/editor",["exports","./loader","core/pending"],(function(_exports,_loader,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.remove(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);return setupForTarget(target,options)};const getPlugins=options=>options.plugins?options.plugins:defaultOptions.plugins?defaultOptions.plugins:{},getStandardConfig=(target,tinyMCE,options,plugins)=>({target:target,language:document.querySelector("html").lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,toolbar_mode:"sliding",toolbar:[{name:"history",items:["undo","redo"]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],menu:{},plugins:[...plugins],skin:"oxide",promotion:!1}),setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]),{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);pluginConfig.forEach((pluginConfig=>{"function"==typeof pluginConfig.configure&&Object.assign(instanceConfig,pluginConfig.configure(instanceConfig))}));const[editor]=await tinyMCE.init(instanceConfig);return instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm)})),editor.moodleOptions=options,pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}}));
+define("editor_tiny/editor",["exports","./loader","core/pending"],(function(_exports,_loader,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.remove(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);return setupForTarget(target,options)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=options=>options.plugins?options.plugins:defaultOptions.plugins?defaultOptions.plugins:{},getStandardConfig=(target,tinyMCE,options,plugins)=>({target:target,language:document.querySelector("html").lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,toolbar_mode:"sliding",toolbar:[{name:"history",items:["undo","redo"]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],menu:{},plugins:[...plugins],skin:"oxide",promotion:!1}),setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]),{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);pluginConfig.forEach((pluginConfig=>{"function"==typeof pluginConfig.configure&&Object.assign(instanceConfig,pluginConfig.configure(instanceConfig))}));const[editor]=await tinyMCE.init(instanceConfig);return instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm)})),editor.moodleOptions=options,pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}}));
//# sourceMappingURL=editor.min.js.map
\ No newline at end of file
diff --git a/lib/editor/tiny/amd/build/editor.min.js.map b/lib/editor/tiny/amd/build/editor.min.js.map
index 354f2f7e534..99d01e2c6e6 100644
--- a/lib/editor/tiny/amd/build/editor.min.js.map
+++ b/lib/editor/tiny/amd/build/editor.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"editor.min.js","sources":["../src/editor.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module editor_tiny/editor\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {\n getTinyMCE,\n} from './loader';\nimport Pending from 'core/pending';\n\n/**\n * Storage for the TinyMCE instances on the page.\n * @type {Map}\n */\nconst instanceMap = new Map();\n\n/**\n * The default editor configuration.\n * @type {Object}\n */\nlet defaultOptions = {};\n\n/**\n * Require the modules for the named set of TinyMCE plugins.\n *\n * @param {string[]} pluginList The list of plugins\n * @return {Promise[]} A matching set of Promises relating to the requested plugins\n */\nconst importPluginList = async(pluginList) => {\n const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {\n if (pluginPath.indexOf('/') === -1) {\n // A standard TinyMCE Plugin.\n return Promise.resolve(pluginPath);\n }\n\n return import(pluginPath);\n }));\n\n const pluginNames = pluginHandlers.map((pluginConfig) => {\n if (typeof pluginConfig === 'string') {\n return pluginConfig;\n }\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[0];\n }\n return null;\n }).filter((value) => value);\n\n const pluginConfig = pluginHandlers.map((pluginConfig) => {\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[1];\n }\n return null;\n }).filter((value) => value);\n\n return {\n pluginNames,\n pluginConfig,\n };\n};\n\nexport const getAllInstances = () => new Map(instanceMap.entries());\n\n/**\n * Get the TinyMCE instance for the specified Node ID.\n *\n * @param {string} elementId\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));\n\n/*\n * Get the TinyMCE instance for the specified HTMLElement.\n *\n * @param {HTMLElement} element\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElement = element => {\n const instance = instanceMap.get(element);\n if (instance && instance.removed) {\n instanceMap.remove(element);\n return undefined;\n }\n return instance;\n};\n\n/**\n * Set up TinyMCE for the selector at the specified HTML Node id.\n *\n * @param {object} config The configuration required to setup the editor\n * @param {string} config.elementId The HTML Node ID\n * @param {Object} config.options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForElementId = ({elementId, options}) => {\n const target = document.getElementById(elementId);\n return setupForTarget(target, options);\n};\n\nconst getPlugins = (options) => {\n if (options.plugins) {\n return options.plugins;\n }\n\n if (defaultOptions.plugins) {\n return defaultOptions.plugins;\n }\n\n return {};\n};\n\nconst getStandardConfig = (target, tinyMCE, options, plugins) => {\n const lang = document.querySelector('html').lang;\n return {\n // Set the editor target.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target\n target,\n\n // Set the language.\n // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language\n language: lang,\n\n // Load the editor stylesheet into the editor iframe.\n // https://www.tiny.cloud/docs/tinymce/6/add-css-options/\n content_css: [\n options.css,\n ],\n\n // Do not convert URLs to relative URLs.\n // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls\n // eslint-disable-next-line camelcase\n convert_urls: false,\n\n // Enabled 'advanced' a11y options.\n // This includes allowing role=\"presentation\" from the image uploader.\n // https://www.tiny.cloud/docs/tinymce/6/accessibility/\n // eslint-disable-next-line camelcase\n a11y_advanced_options: true,\n\n // Toolbar configuration.\n // https://www.tiny.cloud/docs/tinymce/6/toolbar-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n // eslint-disable-next-line camelcase\n toolbar_mode: 'sliding',\n toolbar: [\n {\n name: 'history',\n items: [\n 'undo',\n 'redo'\n ]\n },\n {\n name: 'styles',\n items: ['styles']\n },\n {\n name: 'formatting',\n items: [\n 'bold',\n 'italic'\n ]\n },\n {\n name: 'alignment',\n items: [\n 'alignleft',\n 'aligncenter',\n 'alignright',\n 'alignjustify'\n ]\n },\n {\n name: 'indentation',\n items: [\n 'outdent',\n 'indent'\n ]\n },\n {\n name: 'comments',\n items: ['addcomment']\n },\n ],\n\n // Menu configuration.\n // https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n menu: {\n },\n\n // The list of plugins to include in the instance.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins\n plugins: [\n ...plugins,\n ],\n\n // TODO Add mobile configuration.\n // Mobile configuration.\n // https://www.tiny.cloud/docs/tinymce/6/tinymce-for-mobile/\n // This will include mobile-specific toolbar, and menu options.\n\n // Skins\n skin: 'oxide',\n\n // Remove the \"Upgrade\" link for Tiny.\n // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/\n promotion: false,\n };\n};\n\n/**\n * Set up TinyMCE for the HTML Element.\n *\n * @param {HTMLElement} target\n * @param {Object} options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForTarget = async(target, options = {}) => {\n const instance = getInstanceForElement(target);\n if (instance) {\n return Promise.resolve(instance);\n }\n\n const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');\n\n const plugins = getPlugins(options);\n const [tinyMCE, pluginValues] = await Promise.all([\n getTinyMCE(),\n importPluginList(Object.keys(plugins)),\n ]);\n const {pluginNames, pluginConfig} = pluginValues;\n\n const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);\n pluginConfig.forEach((pluginConfig) => {\n if (typeof pluginConfig.configure === 'function') {\n Object.assign(instanceConfig, pluginConfig.configure(instanceConfig));\n }\n });\n const [editor] = await tinyMCE.init(instanceConfig);\n\n // Store the editor instance in the instanceMap and register its removal to remove it.\n instanceMap.set(target, editor);\n editor.on('remove', ({target}) => {\n // Handle removal of the editor from the map on destruction.\n instanceMap.delete(target.targetElm);\n });\n\n // Store the Moodle-specific options in the TinyMCE instance.\n // TODO: See if there is a more appropriate location for this config.\n // TinyMCE does support custom configuration options in its EditorOptions but these must be registered and spec'd.\n editor.moodleOptions = options;\n\n pendingPromise.resolve();\n return editor;\n};\n\nexport const configureDefaultEditor = (options = {}) => {\n defaultOptions = options;\n};\n"],"names":["instanceMap","Map","defaultOptions","importPluginList","async","pluginHandlers","Promise","all","pluginList","map","pluginPath","indexOf","resolve","pluginNames","pluginConfig","Array","isArray","filter","value","entries","elementId","getInstanceForElement","document","getElementById","element","instance","get","removed","remove","_ref","options","target","setupForTarget","getPlugins","plugins","getStandardConfig","tinyMCE","language","querySelector","lang","content_css","css","convert_urls","a11y_advanced_options","toolbar_mode","toolbar","name","items","menu","skin","promotion","pendingPromise","Pending","pluginValues","Object","keys","instanceConfig","forEach","configure","assign","editor","init","set","on","_ref2","delete","targetElm","moodleOptions"],"mappings":"4iBA+BMA,YAAc,IAAIC,QAMpBC,eAAiB,SAQfC,iBAAmBC,MAAAA,mBACfC,qBAAuBC,QAAQC,IAAIC,WAAWC,KAAIC,aACnB,IAA7BA,WAAWC,QAAQ,KAEZL,QAAQM,QAAQF,4NAGbA,4WAAAA,gBAGZG,YAAcR,eAAeI,KAAKK,cACR,iBAAjBA,aACAA,aAEPC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,cASd,CACHL,YAAAA,YACAC,aATiBT,eAAeI,KAAKK,cACjCC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,mCAQM,IAAM,IAAIjB,IAAID,YAAYmB,4CAQlBC,WAAaC,sBAAsBC,SAASC,eAAeH,kBAQrFC,sBAAwBG,gBAC3BC,SAAWzB,YAAY0B,IAAIF,aAC7BC,WAAYA,SAASE,eAIlBF,SAHHzB,YAAY4B,OAAOJ,0FAcMK,WAACT,UAACA,UAADU,QAAYA,oBACpCC,OAAST,SAASC,eAAeH,kBAChCY,eAAeD,OAAQD,gBAG5BG,WAAcH,SACZA,QAAQI,QACDJ,QAAQI,QAGfhC,eAAegC,QACRhC,eAAegC,QAGnB,GAGLC,kBAAoB,CAACJ,OAAQK,QAASN,QAASI,WAE1C,CAGHH,OAAAA,OAIAM,SARSf,SAASgB,cAAc,QAAQC,KAYxCC,YAAa,CACTV,QAAQW,KAMZC,cAAc,EAMdC,uBAAuB,EAMvBC,aAAc,UACdC,QAAS,CACL,CACIC,KAAM,UACNC,MAAO,CACH,OACA,SAGR,CACID,KAAM,SACNC,MAAO,CAAC,WAEZ,CACID,KAAM,aACNC,MAAO,CACH,OACA,WAGR,CACID,KAAM,YACNC,MAAO,CACH,YACA,cACA,aACA,iBAGR,CACID,KAAM,cACNC,MAAO,CACH,UACA,WAGR,CACID,KAAM,WACNC,MAAO,CAAC,gBAOhBC,KAAM,GAKNd,QAAS,IACFA,SASPe,KAAM,QAINC,WAAW,IAWNlB,eAAiB5B,eAAM2B,YAAQD,+DAAU,SAC5CL,SAAWJ,sBAAsBU,WACnCN,gBACOnB,QAAQM,QAAQa,gBAGrB0B,eAAiB,IAAIC,iBAAQ,qCAE7BlB,QAAUD,WAAWH,UACpBM,QAASiB,oBAAsB/C,QAAQC,IAAI,EAC9C,wBACAJ,iBAAiBmD,OAAOC,KAAKrB,aAE3BrB,YAACA,YAADC,aAAcA,cAAgBuC,aAE9BG,eAAiBrB,kBAAkBJ,OAAQK,EAASN,QAASjB,aACnEC,aAAa2C,SAAS3C,eACoB,mBAA3BA,aAAa4C,WACpBJ,OAAOK,OAAOH,eAAgB1C,aAAa4C,UAAUF,0BAGtDI,cAAgBxB,QAAQyB,KAAKL,uBAGpCxD,YAAY8D,IAAI/B,OAAQ6B,QACxBA,OAAOG,GAAG,UAAUC,YAACjC,OAACA,cAElB/B,YAAYiE,OAAOlC,OAAOmC,cAM9BN,OAAOO,cAAgBrC,QAEvBqB,eAAevC,UACRgD,+EAG2B,eAAC9B,+DAAU,GAC7C5B,eAAiB4B"}
\ No newline at end of file
+{"version":3,"file":"editor.min.js","sources":["../src/editor.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module editor_tiny/editor\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {\n getTinyMCE,\n} from './loader';\nimport Pending from 'core/pending';\n\n/**\n * Storage for the TinyMCE instances on the page.\n * @type {Map}\n */\nconst instanceMap = new Map();\n\n/**\n * The default editor configuration.\n * @type {Object}\n */\nlet defaultOptions = {};\n\n/**\n * Require the modules for the named set of TinyMCE plugins.\n *\n * @param {string[]} pluginList The list of plugins\n * @return {Promise[]} A matching set of Promises relating to the requested plugins\n */\nconst importPluginList = async(pluginList) => {\n const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {\n if (pluginPath.indexOf('/') === -1) {\n // A standard TinyMCE Plugin.\n return Promise.resolve(pluginPath);\n }\n\n return import(pluginPath);\n }));\n\n const pluginNames = pluginHandlers.map((pluginConfig) => {\n if (typeof pluginConfig === 'string') {\n return pluginConfig;\n }\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[0];\n }\n return null;\n }).filter((value) => value);\n\n const pluginConfig = pluginHandlers.map((pluginConfig) => {\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[1];\n }\n return null;\n }).filter((value) => value);\n\n return {\n pluginNames,\n pluginConfig,\n };\n};\n\nconst fetchLanguage = (language) => fetch(\n `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`\n).then(response => response.json());\n\nexport const getAllInstances = () => new Map(instanceMap.entries());\n\n/**\n * Get the TinyMCE instance for the specified Node ID.\n *\n * @param {string} elementId\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));\n\n/*\n * Get the TinyMCE instance for the specified HTMLElement.\n *\n * @param {HTMLElement} element\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElement = element => {\n const instance = instanceMap.get(element);\n if (instance && instance.removed) {\n instanceMap.remove(element);\n return undefined;\n }\n return instance;\n};\n\n/**\n * Set up TinyMCE for the selector at the specified HTML Node id.\n *\n * @param {object} config The configuration required to setup the editor\n * @param {string} config.elementId The HTML Node ID\n * @param {Object} config.options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForElementId = ({elementId, options}) => {\n const target = document.getElementById(elementId);\n return setupForTarget(target, options);\n};\n\nconst initialisePage = async() => {\n const lang = document.querySelector('html').lang;\n\n const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(lang)]);\n tinyMCE.addI18n(lang, langData);\n};\ninitialisePage();\n\nconst getPlugins = (options) => {\n if (options.plugins) {\n return options.plugins;\n }\n\n if (defaultOptions.plugins) {\n return defaultOptions.plugins;\n }\n\n return {};\n};\n\nconst getStandardConfig = (target, tinyMCE, options, plugins) => {\n const lang = document.querySelector('html').lang;\n return {\n // Set the editor target.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target\n target,\n\n // Set the language.\n // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language\n language: lang,\n\n // Load the editor stylesheet into the editor iframe.\n // https://www.tiny.cloud/docs/tinymce/6/add-css-options/\n content_css: [\n options.css,\n ],\n\n // Do not convert URLs to relative URLs.\n // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls\n // eslint-disable-next-line camelcase\n convert_urls: false,\n\n // Enabled 'advanced' a11y options.\n // This includes allowing role=\"presentation\" from the image uploader.\n // https://www.tiny.cloud/docs/tinymce/6/accessibility/\n // eslint-disable-next-line camelcase\n a11y_advanced_options: true,\n\n // Toolbar configuration.\n // https://www.tiny.cloud/docs/tinymce/6/toolbar-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n // eslint-disable-next-line camelcase\n toolbar_mode: 'sliding',\n toolbar: [\n {\n name: 'history',\n items: [\n 'undo',\n 'redo'\n ]\n },\n {\n name: 'styles',\n items: ['styles']\n },\n {\n name: 'formatting',\n items: [\n 'bold',\n 'italic'\n ]\n },\n {\n name: 'alignment',\n items: [\n 'alignleft',\n 'aligncenter',\n 'alignright',\n 'alignjustify'\n ]\n },\n {\n name: 'indentation',\n items: [\n 'outdent',\n 'indent'\n ]\n },\n {\n name: 'comments',\n items: ['addcomment']\n },\n ],\n\n // Menu configuration.\n // https://www.tiny.cloud/docs/tinymce/6/menus-configuration-options/\n // TODO: Move this configuration to a passed-in option.\n menu: {\n },\n\n // The list of plugins to include in the instance.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins\n plugins: [\n ...plugins,\n ],\n\n // TODO Add mobile configuration.\n // Mobile configuration.\n // https://www.tiny.cloud/docs/tinymce/6/tinymce-for-mobile/\n // This will include mobile-specific toolbar, and menu options.\n\n // Skins\n skin: 'oxide',\n\n // Remove the \"Upgrade\" link for Tiny.\n // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/\n promotion: false,\n };\n};\n\n/**\n * Set up TinyMCE for the HTML Element.\n *\n * @param {HTMLElement} target\n * @param {Object} options The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForTarget = async(target, options = {}) => {\n const instance = getInstanceForElement(target);\n if (instance) {\n return Promise.resolve(instance);\n }\n\n const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');\n\n const plugins = getPlugins(options);\n const [tinyMCE, pluginValues] = await Promise.all([\n getTinyMCE(),\n importPluginList(Object.keys(plugins)),\n ]);\n const {pluginNames, pluginConfig} = pluginValues;\n\n const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);\n pluginConfig.forEach((pluginConfig) => {\n if (typeof pluginConfig.configure === 'function') {\n Object.assign(instanceConfig, pluginConfig.configure(instanceConfig));\n }\n });\n const [editor] = await tinyMCE.init(instanceConfig);\n\n // Store the editor instance in the instanceMap and register its removal to remove it.\n instanceMap.set(target, editor);\n editor.on('remove', ({target}) => {\n // Handle removal of the editor from the map on destruction.\n instanceMap.delete(target.targetElm);\n });\n\n // Store the Moodle-specific options in the TinyMCE instance.\n // TODO: See if there is a more appropriate location for this config.\n // TinyMCE does support custom configuration options in its EditorOptions but these must be registered and spec'd.\n editor.moodleOptions = options;\n\n pendingPromise.resolve();\n return editor;\n};\n\nexport const configureDefaultEditor = (options = {}) => {\n defaultOptions = options;\n};\n"],"names":["instanceMap","Map","defaultOptions","importPluginList","async","pluginHandlers","Promise","all","pluginList","map","pluginPath","indexOf","resolve","pluginNames","pluginConfig","Array","isArray","filter","value","entries","elementId","getInstanceForElement","document","getElementById","element","instance","get","removed","remove","_ref","options","target","setupForTarget","lang","querySelector","tinyMCE","langData","language","fetch","M","cfg","wwwroot","langrev","then","response","json","addI18n","initialisePage","getPlugins","plugins","getStandardConfig","content_css","css","convert_urls","a11y_advanced_options","toolbar_mode","toolbar","name","items","menu","skin","promotion","pendingPromise","Pending","pluginValues","Object","keys","instanceConfig","forEach","configure","assign","editor","init","set","on","_ref2","delete","targetElm","moodleOptions"],"mappings":"4iBA+BMA,YAAc,IAAIC,QAMpBC,eAAiB,SAQfC,iBAAmBC,MAAAA,mBACfC,qBAAuBC,QAAQC,IAAIC,WAAWC,KAAIC,aACnB,IAA7BA,WAAWC,QAAQ,KAEZL,QAAQM,QAAQF,4NAGbA,4WAAAA,gBAGZG,YAAcR,eAAeI,KAAKK,cACR,iBAAjBA,aACAA,aAEPC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,cASd,CACHL,YAAAA,YACAC,aATiBT,eAAeI,KAAKK,cACjCC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,mCAYM,IAAM,IAAIjB,IAAID,YAAYmB,4CAQlBC,WAAaC,sBAAsBC,SAASC,eAAeH,kBAQrFC,sBAAwBG,gBAC3BC,SAAWzB,YAAY0B,IAAIF,aAC7BC,WAAYA,SAASE,eAIlBF,SAHHzB,YAAY4B,OAAOJ,0FAcMK,WAACT,UAACA,UAADU,QAAYA,oBACpCC,OAAST,SAASC,eAAeH,kBAChCY,eAAeD,OAAQD,UAGX1B,iBACb6B,KAAOX,SAASY,cAAc,QAAQD,MAErCE,QAASC,gBAAkB9B,QAAQC,IAAI,EAAC,yBA7C5B8B,SA6CwDJ,KA7C3CK,gBAC7BC,EAAEC,IAAIC,6CAAoCF,EAAEC,IAAIE,oBAAWL,WAChEM,MAAKC,UAAYA,SAASC,YAFLR,IAAAA,SA8CnBF,QAAQW,QAAQb,KAAMG,WAE1BW,SAEMC,WAAclB,SACZA,QAAQmB,QACDnB,QAAQmB,QAGf/C,eAAe+C,QACR/C,eAAe+C,QAGnB,GAGLC,kBAAoB,CAACnB,OAAQI,QAASL,QAASmB,WAE1C,CAGHlB,OAAAA,OAIAM,SARSf,SAASY,cAAc,QAAQD,KAYxCkB,YAAa,CACTrB,QAAQsB,KAMZC,cAAc,EAMdC,uBAAuB,EAMvBC,aAAc,UACdC,QAAS,CACL,CACIC,KAAM,UACNC,MAAO,CACH,OACA,SAGR,CACID,KAAM,SACNC,MAAO,CAAC,WAEZ,CACID,KAAM,aACNC,MAAO,CACH,OACA,WAGR,CACID,KAAM,YACNC,MAAO,CACH,YACA,cACA,aACA,iBAGR,CACID,KAAM,cACNC,MAAO,CACH,UACA,WAGR,CACID,KAAM,WACNC,MAAO,CAAC,gBAOhBC,KAAM,GAKNV,QAAS,IACFA,SASPW,KAAM,QAINC,WAAW,IAWN7B,eAAiB5B,eAAM2B,YAAQD,+DAAU,SAC5CL,SAAWJ,sBAAsBU,WACnCN,gBACOnB,QAAQM,QAAQa,gBAGrBqC,eAAiB,IAAIC,iBAAQ,qCAE7Bd,QAAUD,WAAWlB,UACpBK,QAAS6B,oBAAsB1D,QAAQC,IAAI,EAC9C,wBACAJ,iBAAiB8D,OAAOC,KAAKjB,aAE3BpC,YAACA,YAADC,aAAcA,cAAgBkD,aAE9BG,eAAiBjB,kBAAkBnB,OAAQI,EAASL,QAASjB,aACnEC,aAAasD,SAAStD,eACoB,mBAA3BA,aAAauD,WACpBJ,OAAOK,OAAOH,eAAgBrD,aAAauD,UAAUF,0BAGtDI,cAAgBpC,QAAQqC,KAAKL,uBAGpCnE,YAAYyE,IAAI1C,OAAQwC,QACxBA,OAAOG,GAAG,UAAUC,YAAC5C,OAACA,cAElB/B,YAAY4E,OAAO7C,OAAO8C,cAM9BN,OAAOO,cAAgBhD,QAEvBgC,eAAelD,UACR2D,+EAG2B,eAACzC,+DAAU,GAC7C5B,eAAiB4B"}
\ No newline at end of file
diff --git a/lib/editor/tiny/amd/src/editor.js b/lib/editor/tiny/amd/src/editor.js
index 0cbb9ea8e60..65044bd23d4 100644
--- a/lib/editor/tiny/amd/src/editor.js
+++ b/lib/editor/tiny/amd/src/editor.js
@@ -76,6 +76,10 @@ const importPluginList = async(pluginList) => {
};
};
+const fetchLanguage = (language) => fetch(
+ `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`
+).then(response => response.json());
+
export const getAllInstances = () => new Map(instanceMap.entries());
/**
@@ -114,6 +118,14 @@ export const setupForElementId = ({elementId, options}) => {
return setupForTarget(target, options);
};
+const initialisePage = async() => {
+ const lang = document.querySelector('html').lang;
+
+ const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(lang)]);
+ tinyMCE.addI18n(lang, langData);
+};
+initialisePage();
+
const getPlugins = (options) => {
if (options.plugins) {
return options.plugins;
diff --git a/lib/editor/tiny/lang.php b/lib/editor/tiny/lang.php
new file mode 100644
index 00000000000..0ea6ad2239a
--- /dev/null
+++ b/lib/editor/tiny/lang.php
@@ -0,0 +1,347 @@
+.
+
+/**
+ * Tiny text editor integration - Language Producer.
+ *
+ * @package editor_tiny
+ * @copyright 2021 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace editor_tiny;
+
+// Disable moodle specific debug messages and any errors in output,
+// comment out when debugging or better look into error log!
+define('NO_DEBUG_DISPLAY', true);
+
+// We need just the values from config.php and minlib.php.
+define('ABORT_AFTER_CONFIG', true);
+
+// This stops immediately at the beginning of lib/setup.php.
+require('../../../config.php');
+
+/**
+ * An anonymous class to handle loading and serving lang files for TinyMCE.
+ *
+ * @copyright 2021 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class lang {
+ /** @var string The language code to load */
+ protected $lang;
+
+ /** @var int The revision requested */
+ protected $rev;
+
+ /** @var bool Whether Moodle is fully loaded or not */
+ protected $fullyloaded = false;
+
+ /**
+ * Constructor to load and serve the langfile.
+ */
+ public function __construct() {
+ $this->parse_file_information_from_url();
+ $this->serve_file();
+ }
+
+ /**
+ * Parse the file information from the URL.
+ */
+ protected function parse_file_information_from_url(): void {
+ global $CFG;
+
+ // The URL format is /[revision]/[lang].
+ // The revision is an integer with negative values meaning the file is not cached.
+ // The lang is a simple word with no directory separators or special characters.
+ if ($slashargument = min_get_slash_argument()) {
+ $slashargument = ltrim($slashargument, '/');
+ if (substr_count($slashargument, '/') < 1) {
+ css_send_css_not_found();
+ }
+
+ [$rev, $lang] = explode('/', $slashargument, 2);
+ $rev = min_clean_param($rev, 'RAW');
+ $lang = min_clean_param($lang, 'SAFEDIR');
+ } else {
+ $rev = min_optional_param('rev', 0, 'RAW');
+ $lang = min_optional_param('lang', 'standard', 'SAFEDIR');
+ }
+
+ $this->lang = $lang;
+ $this->rev = $rev;
+ $this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/lang/{$this->lang}/lang.json";
+ }
+
+ /**
+ * Serve the language pack content.
+ */
+ protected function serve_file(): void {
+ // Attempt to send the cached langpack.
+ if ($this->rev > 0) {
+ if ($this->is_candidate_file_available()) {
+ // The send_cached_file_if_available function will exit if successful.
+ // In theory the file could become unavailable after checking that the file exists.
+ // Whilst this is unlikely, fall back to caching the content below.
+ $this->send_cached_pack();
+ }
+
+ // The file isn't cached yet.
+ // Load the content. store it in the cache, and serve it.
+ $strings = $this->load_language_pack();
+ $this->store_lang_file($strings);
+ $this->send_cached();
+ } else {
+ // If the revision is less than 0, then do not cache anything.
+ $strings = $this->load_language_pack();
+ $this->send_uncached($strings);
+ }
+ }
+
+ /**
+ * Load the full Moodle Framework.
+ */
+ protected function load_full_moodle(): void {
+ global $CFG, $DB, $SESSION, $OUTPUT, $PAGE;
+
+ if ($this->is_full_moodle_loaded()) {
+ return;
+ }
+
+ // Ok, now we need to start normal moodle script, we need to load all libs and $DB.
+ define('ABORT_AFTER_CONFIG_CANCEL', true);
+
+ // Session not used here.
+ define('NO_MOODLE_COOKIES', true);
+
+ // Ignore upgrade check.
+ define('NO_UPGRADE_CHECK', true);
+
+ require("{$CFG->dirroot}/lib/setup.php");
+ $this->fullyloaded = true;
+ }
+
+ /**
+ * Check whether Moodle is fully loaded.
+ *
+ * @return bool
+ */
+ public function is_full_moodle_loaded(): bool {
+ return $this->fullyloaded;
+ }
+
+ /**
+ * Load the language pack strings.
+ *
+ * @return string[]
+ */
+ protected function load_language_pack(): array {
+ // We need to load the full moodle API to use the string manager.
+ $this->load_full_moodle();
+
+ // We maintain a list of string identifier to original TinyMCE string.
+ // TinyMCE uses English language strings to perform translations.
+ $stringlist = file_get_contents(__DIR__ . "/tinystrings.json");
+ if (empty($stringlist)) {
+ $this->send_not_found("Failed to load strings from tinystrings.json");
+ }
+
+ $stringlist = json_decode($stringlist, true);
+ if (empty($stringlist)) {
+ $this->send_not_found("Failed to load strings from tinystrings.json");
+ }
+
+ // Load all strings for the TinyMCE Editor which have a prefix of `tiny:` from the Moodle String Manager.
+ $stringmanager = get_string_manager();
+ $translatedvalues = array_filter(
+ $stringmanager->load_component_strings('editor_tiny', $this->lang),
+ function(string $value, string $key): bool {
+ return strpos($key, 'tiny:') === 0;
+ },
+ ARRAY_FILTER_USE_BOTH
+ );
+
+ // We will associate the _original_ TinyMCE string to its translation, but only where it is different.
+ // Where the original TinyMCE string matches the Moodle translation of it, we do not supply the string.
+ $strings = [];
+ foreach ($stringlist as $key => $value) {
+ if (array_key_exists($key, $translatedvalues)) {
+ if ($translatedvalues[$key] !== $value) {
+ $strings[$value] = $translatedvalues[$key];
+ }
+ }
+ }
+
+ return $strings;
+ }
+
+ /**
+ * Send a cached language pack.
+ */
+ protected function send_cached_pack(): void {
+ global $CFG;
+
+ if (file_exists($this->candidatefile)) {
+ if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
+ // We do not actually need to verify the etag value because our files
+ // never change in cache because we increment the rev counter.
+ $this->send_unmodified_headers(filemtime($this->candidatefile));
+ }
+ $this->send_cached($this->candidatefile);
+ }
+ }
+
+ /**
+ * Store a langauge cache file containing all of the processed strings.
+ *
+ * @param string[] $strings The strings to store
+ */
+ protected function store_lang_file(array $strings): void {
+ global $CFG;
+
+ clearstatcache();
+ if (!file_exists(dirname($this->candidatefile))) {
+ @mkdir(dirname($this->candidatefile), $CFG->directorypermissions, true);
+ }
+
+ // Prevent serving of incomplete file from concurrent request,
+ // the rename() should be more atomic than fwrite().
+ ignore_user_abort(true);
+
+ // First up write out the single file for all those using decent browsers.
+ $content = json_encode($strings, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES);
+
+ $filename = $this->candidatefile;
+ if ($fp = fopen($filename . '.tmp', 'xb')) {
+ fwrite($fp, $content);
+ fclose($fp);
+ rename($filename . '.tmp', $filename);
+ @chmod($filename, $CFG->filepermissions);
+ @unlink($filename . '.tmp'); // Just in case anything fails.
+ }
+
+ ignore_user_abort(false);
+ if (connection_aborted()) {
+ die;
+ }
+ }
+
+ /**
+ * Check whether the candidate file exists.
+ *
+ * @return bool
+ */
+ protected function is_candidate_file_available(): bool {
+ return file_exists($this->candidatefile);
+ }
+
+ /**
+ * Get the eTag for the candidate file.
+ *
+ * This is a unique hash based on the file arguments.
+ * It does not need to consider the file content because we use a cache busting URL.
+ *
+ * @return string The eTag content
+ */
+ protected function get_etag(): string {
+ $etag = [
+ $this->lang,
+ $this->rev,
+ ];
+
+ return sha1(implode('/', $etag));
+ }
+
+ /**
+ * Send the candidate file, with aggressive cachign headers.
+ *
+ * This includdes eTags, a last-modified, and expiry approximately 90 days in the future.
+ */
+ protected function send_cached(): void {
+ $path = $this->candidatefile;
+
+ // 90 days only - based on Moodle point release cadence being every 3 months.
+ $lifetime = 60 * 60 * 24 * 90;
+
+ header('Etag: "' . $this->get_etag() . '"');
+ header('Content-Disposition: inline; filename="lang.php"');
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT');
+ header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
+ header('Pragma: ');
+ header('Cache-Control: public, max-age=' . $lifetime . ', immutable');
+ header('Accept-Ranges: none');
+ header('Content-Type: application/json; charset=utf-8');
+ if (!min_enable_zlib_compression()) {
+ header('Content-Length: ' . filesize($path));
+ }
+
+ readfile($path);
+ die;
+ }
+
+ /**
+ * Sends the content directly without caching it.
+ *
+ * @param string[] $strings
+ */
+ protected function send_uncached(array $strings): void {
+ header('Content-Disposition: inline; filename="styles_debug.php"');
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
+ header('Expires: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
+ header('Pragma: ');
+ header('Accept-Ranges: none');
+ header('Content-Type: application/json; charset=utf-8');
+
+ echo json_encode($strings, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES);
+ die;
+ }
+
+ /**
+ * Send file not modified headers.
+ *
+ * @param int $lastmodified
+ */
+ protected function send_unmodified_headers($lastmodified): void {
+ // 90 days only - based on Moodle point release cadence being every 3 months.
+ $lifetime = 60 * 60 * 24 * 90;
+ header('HTTP/1.1 304 Not Modified');
+ header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
+ header('Cache-Control: public, max-age=' . $lifetime);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Etag: "' . $this->get_etag() . '"');
+ if ($lastmodified) {
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastmodified) . ' GMT');
+ }
+ die;
+ }
+
+ /**
+ * Sends a 404 message to indicate that the content was not found.
+ *
+ * @param null|string $message An optional informative message to include to help debugging
+ */
+ protected function send_not_found(?string $message = null): void {
+ header('HTTP/1.0 404 not found');
+
+ if ($message) {
+ die($message);
+ } else {
+ die('Language data was not found, sorry.');
+ }
+ }
+};
+
+$loader = new lang();
diff --git a/lib/editor/tiny/lang/en/editor_tiny.php b/lib/editor/tiny/lang/en/editor_tiny.php
index 67fca5d77b9..8037339d8e3 100644
--- a/lib/editor/tiny/lang/en/editor_tiny.php
+++ b/lib/editor/tiny/lang/en/editor_tiny.php
@@ -24,3 +24,407 @@
$string['pluginname'] = 'TinyMCE editor';
$string['privacy:reason'] = 'The TinyMCE Editor does not store any preferences or user data.';
+$string['tiny:hash'] = '#';
+$string['tiny:accessibility'] = 'Accessibility';
+$string['tiny:action'] = 'Action';
+$string['tiny:activity'] = 'Activity';
+$string['tiny:address'] = 'Address';
+$string['tiny:advanced'] = 'Advanced';
+$string['tiny:align'] = 'Align';
+$string['tiny:align_center'] = 'Align center';
+$string['tiny:align_left'] = 'Align left';
+$string['tiny:align_right'] = 'Align right';
+$string['tiny:alignment'] = 'Alignment';
+$string['tiny:all'] = 'All';
+$string['tiny:alternative_description'] = 'Alternative description';
+$string['tiny:alternative_source'] = 'Alternative source';
+$string['tiny:alternative_source_url'] = 'Alternative source URL';
+$string['tiny:anchor'] = 'Anchor';
+$string['tiny:anchor...'] = 'Anchor...';
+$string['tiny:anchors'] = 'Anchors';
+$string['tiny:animals_and_nature'] = 'Animals and Nature';
+$string['tiny:arrows'] = 'Arrows';
+$string['tiny:b'] = 'B';
+$string['tiny:background_color'] = 'Background color';
+$string['tiny:black'] = 'Black';
+$string['tiny:block'] = 'Block';
+$string['tiny:blockquote'] = 'Blockquote';
+$string['tiny:blocks'] = 'Blocks';
+$string['tiny:blue'] = 'Blue';
+$string['tiny:blue_component'] = 'Blue component';
+$string['tiny:body'] = 'Body';
+$string['tiny:bold'] = 'Bold';
+$string['tiny:border'] = 'Border';
+$string['tiny:border_color'] = 'Border color';
+$string['tiny:border_style'] = 'Border style';
+$string['tiny:border_width'] = 'Border width';
+$string['tiny:bottom'] = 'Bottom';
+$string['tiny:browse_for_an_image'] = 'Browse for an image';
+$string['tiny:bullet_list'] = 'Bullet list';
+$string['tiny:cancel'] = 'Cancel';
+$string['tiny:caption'] = 'Caption';
+$string['tiny:cell'] = 'Cell';
+$string['tiny:cell_padding'] = 'Cell padding';
+$string['tiny:cell_properties'] = 'Cell properties';
+$string['tiny:cell_spacing'] = 'Cell spacing';
+$string['tiny:cell_styles'] = 'Cell styles';
+$string['tiny:cell_type'] = 'Cell type';
+$string['tiny:center'] = 'Center';
+$string['tiny:characters'] = 'Characters';
+$string['tiny:characters_no_spaces'] = 'Characters (no spaces)';
+$string['tiny:circle'] = 'Circle';
+$string['tiny:class'] = 'Class';
+$string['tiny:clear_formatting'] = 'Clear formatting';
+$string['tiny:close'] = 'Close';
+$string['tiny:code'] = 'Code';
+$string['tiny:code_sample...'] = 'Code sample...';
+$string['tiny:code_view'] = 'Code view';
+$string['tiny:color_picker'] = 'Color Picker';
+$string['tiny:color_swatch'] = 'Color swatch';
+$string['tiny:cols'] = 'Cols';
+$string['tiny:column'] = 'Column';
+$string['tiny:column_clipboard_actions'] = 'Column clipboard actions';
+$string['tiny:column_group'] = 'Column group';
+$string['tiny:column_header'] = 'Column header';
+$string['tiny:constrain_proportions'] = 'Constrain proportions';
+$string['tiny:copy'] = 'Copy';
+$string['tiny:copy_column'] = 'Copy column';
+$string['tiny:copy_row'] = 'Copy row';
+$string['tiny:could_not_find_the_specified_string.'] = 'Could not find the specified string.';
+$string['tiny:could_not_load_emojis'] = 'Could not load emojis';
+$string['tiny:count'] = 'Count';
+$string['tiny:currency'] = 'Currency';
+$string['tiny:current_window'] = 'Current window';
+$string['tiny:custom_color'] = 'Custom color';
+$string['tiny:custom...'] = 'Custom...';
+$string['tiny:cut'] = 'Cut';
+$string['tiny:cut_column'] = 'Cut column';
+$string['tiny:cut_row'] = 'Cut row';
+$string['tiny:dark_blue'] = 'Dark Blue';
+$string['tiny:dark_gray'] = 'Dark Gray';
+$string['tiny:dark_green'] = 'Dark Green';
+$string['tiny:dark_orange'] = 'Dark Orange';
+$string['tiny:dark_purple'] = 'Dark Purple';
+$string['tiny:dark_red'] = 'Dark Red';
+$string['tiny:dark_turquoise'] = 'Dark Turquoise';
+$string['tiny:dark_yellow'] = 'Dark Yellow';
+$string['tiny:dashed'] = 'Dashed';
+$string['tiny:datetime'] = 'Date/time';
+$string['tiny:decrease_indent'] = 'Decrease indent';
+$string['tiny:default'] = 'Default';
+$string['tiny:delete_column'] = 'Delete column';
+$string['tiny:delete_row'] = 'Delete row';
+$string['tiny:delete_table'] = 'Delete table';
+$string['tiny:dimensions'] = 'Dimensions';
+$string['tiny:disc'] = 'Disc';
+$string['tiny:div'] = 'Div';
+$string['tiny:document'] = 'Document';
+$string['tiny:dotted'] = 'Dotted';
+$string['tiny:double'] = 'Double';
+$string['tiny:drop_an_image_here'] = 'Drop an image here';
+$string['tiny:dropped_file_type_is_not_supported'] = 'Dropped file type is not supported';
+$string['tiny:edit'] = 'Edit';
+$string['tiny:embed'] = 'Embed';
+$string['tiny:emojis'] = 'Emojis';
+$string['tiny:emojis...'] = 'Emojis...';
+$string['tiny:error'] = 'Error';
+$string['tiny:error_form_submit_field_collision.'] = 'Error: Form submit field collision.';
+$string['tiny:error_no_form_element_found.'] = 'Error: No form element found.';
+$string['tiny:extended_latin'] = 'Extended Latin';
+$string['tiny:failed_to_initialize_plugin_0'] = 'Failed to initialize plugin: {0}';
+$string['tiny:failed_to_load_plugin_url_0'] = 'Failed to load plugin url: {0}';
+$string['tiny:failed_to_load_plugin_0_from_url_1'] = 'Failed to load plugin: {0} from url {1}';
+$string['tiny:failed_to_upload_image_0'] = 'Failed to upload image: {0}';
+$string['tiny:file'] = 'File';
+$string['tiny:find'] = 'Find';
+$string['tiny:find_if_searchreplace_plugin_activated'] = 'Find (if searchreplace plugin activated)';
+$string['tiny:find_and_replace'] = 'Find and Replace';
+$string['tiny:find_and_replace...'] = 'Find and replace...';
+$string['tiny:find_in_selection'] = 'Find in selection';
+$string['tiny:find_whole_words_only'] = 'Find whole words only';
+$string['tiny:flags'] = 'Flags';
+$string['tiny:focus_to_contextual_toolbar'] = 'Focus to contextual toolbar';
+$string['tiny:focus_to_element_path'] = 'Focus to element path';
+$string['tiny:focus_to_menubar'] = 'Focus to menubar';
+$string['tiny:focus_to_toolbar'] = 'Focus to toolbar';
+$string['tiny:font'] = 'Font';
+$string['tiny:font_sizes'] = 'Font sizes';
+$string['tiny:fonts'] = 'Fonts';
+$string['tiny:food_and_drink'] = 'Food and Drink';
+$string['tiny:footer'] = 'Footer';
+$string['tiny:format'] = 'Format';
+$string['tiny:formats'] = 'Formats';
+$string['tiny:fullscreen'] = 'Fullscreen';
+$string['tiny:g'] = 'G';
+$string['tiny:general'] = 'General';
+$string['tiny:gray'] = 'Gray';
+$string['tiny:green'] = 'Green';
+$string['tiny:green_component'] = 'Green component';
+$string['tiny:groove'] = 'Groove';
+$string['tiny:handy_shortcuts'] = 'Handy Shortcuts';
+$string['tiny:header'] = 'Header';
+$string['tiny:header_cell'] = 'Header cell';
+$string['tiny:heading_1'] = 'Heading 1';
+$string['tiny:heading_2'] = 'Heading 2';
+$string['tiny:heading_3'] = 'Heading 3';
+$string['tiny:heading_4'] = 'Heading 4';
+$string['tiny:heading_5'] = 'Heading 5';
+$string['tiny:heading_6'] = 'Heading 6';
+$string['tiny:headings'] = 'Headings';
+$string['tiny:height'] = 'Height';
+$string['tiny:help'] = 'Help';
+$string['tiny:hex_color_code'] = 'Hex color code';
+$string['tiny:hidden'] = 'Hidden';
+$string['tiny:horizontal_align'] = 'Horizontal align';
+$string['tiny:horizontal_line'] = 'Horizontal line';
+$string['tiny:horizontal_space'] = 'Horizontal space';
+$string['tiny:id'] = 'ID';
+$string['tiny:id_should_start_with_a_letter_followed_only_by_letters_numbers_dashes_dots_colons_or_underscores.'] = 'ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.';
+$string['tiny:image_is_decorative'] = 'Image is decorative';
+$string['tiny:image_list'] = 'Image list';
+$string['tiny:image_title'] = 'Image title';
+$string['tiny:image...'] = 'Image...';
+$string['tiny:imageproxy_http_error_could_not_find_image_proxy'] = 'ImageProxy HTTP error: Could not find Image Proxy';
+$string['tiny:imageproxy_http_error_incorrect_image_proxy_url'] = 'ImageProxy HTTP error: Incorrect Image Proxy URL';
+$string['tiny:imageproxy_http_error_rejected_request'] = 'ImageProxy HTTP error: Rejected request';
+$string['tiny:imageproxy_http_error_unknown_imageproxy_error'] = 'ImageProxy HTTP error: Unknown ImageProxy error';
+$string['tiny:increase_indent'] = 'Increase indent';
+$string['tiny:inline'] = 'Inline';
+$string['tiny:insert'] = 'Insert';
+$string['tiny:insert_template'] = 'Insert Template';
+$string['tiny:insert_column_after'] = 'Insert column after';
+$string['tiny:insert_column_before'] = 'Insert column before';
+$string['tiny:insert_datetime'] = 'Insert date/time';
+$string['tiny:insert_image'] = 'Insert image';
+$string['tiny:insert_link_if_link_plugin_activated'] = 'Insert link (if link plugin activated)';
+$string['tiny:insert_row_after'] = 'Insert row after';
+$string['tiny:insert_row_before'] = 'Insert row before';
+$string['tiny:insert_table'] = 'Insert table';
+$string['tiny:insert_template...'] = 'Insert template...';
+$string['tiny:insert_video'] = 'Insert video';
+$string['tiny:insertedit_code_sample'] = 'Insert/Edit code sample';
+$string['tiny:insertedit_image'] = 'Insert/edit image';
+$string['tiny:insertedit_link'] = 'Insert/edit link';
+$string['tiny:insertedit_media'] = 'Insert/edit media';
+$string['tiny:insertedit_video'] = 'Insert/edit video';
+$string['tiny:inset'] = 'Inset';
+$string['tiny:invalid_hex_color_code_0'] = 'Invalid hex color code: {0}';
+$string['tiny:invalid_input'] = 'Invalid input';
+$string['tiny:italic'] = 'Italic';
+$string['tiny:justify'] = 'Justify';
+$string['tiny:keyboard_navigation'] = 'Keyboard Navigation';
+$string['tiny:language'] = 'Language';
+$string['tiny:learn_more...'] = 'Learn more...';
+$string['tiny:left'] = 'Left';
+$string['tiny:left_to_right'] = 'Left to right';
+$string['tiny:light_blue'] = 'Light Blue';
+$string['tiny:light_gray'] = 'Light Gray';
+$string['tiny:light_green'] = 'Light Green';
+$string['tiny:light_purple'] = 'Light Purple';
+$string['tiny:light_red'] = 'Light Red';
+$string['tiny:light_yellow'] = 'Light Yellow';
+$string['tiny:line_height'] = 'Line height';
+$string['tiny:link_list'] = 'Link list';
+$string['tiny:link...'] = 'Link...';
+$string['tiny:list_properties'] = 'List Properties';
+$string['tiny:list_properties...'] = 'List properties...';
+$string['tiny:loading_emojis...'] = 'Loading emojis...';
+$string['tiny:loading...'] = 'Loading...';
+$string['tiny:lower_alpha'] = 'Lower Alpha';
+$string['tiny:lower_greek'] = 'Lower Greek';
+$string['tiny:lower_roman'] = 'Lower Roman';
+$string['tiny:match_case'] = 'Match case';
+$string['tiny:mathematical'] = 'Mathematical';
+$string['tiny:media_poster_image_url'] = 'Media poster (Image URL)';
+$string['tiny:media...'] = 'Media...';
+$string['tiny:medium_blue'] = 'Medium Blue';
+$string['tiny:medium_gray'] = 'Medium Gray';
+$string['tiny:medium_purple'] = 'Medium Purple';
+$string['tiny:merge_cells'] = 'Merge cells';
+$string['tiny:middle'] = 'Middle';
+$string['tiny:midnight_blue'] = 'Midnight Blue';
+$string['tiny:more...'] = 'More...';
+$string['tiny:name'] = 'Name';
+$string['tiny:navy_blue'] = 'Navy Blue';
+$string['tiny:new_document'] = 'New document';
+$string['tiny:new_window'] = 'New window';
+$string['tiny:next'] = 'Next';
+$string['tiny:no'] = 'No';
+$string['tiny:no_alignment'] = 'No alignment';
+$string['tiny:no_color'] = 'No color';
+$string['tiny:nonbreaking_space'] = 'Nonbreaking space';
+$string['tiny:none'] = 'None';
+$string['tiny:numbered_list'] = 'Numbered list';
+$string['tiny:or'] = 'OR';
+$string['tiny:objects'] = 'Objects';
+$string['tiny:ok'] = 'Ok';
+$string['tiny:open_help_dialog'] = 'Open help dialog';
+$string['tiny:open_link'] = 'Open link';
+$string['tiny:open_link_in...'] = 'Open link in...';
+$string['tiny:open_popup_menu_for_split_buttons'] = 'Open popup menu for split buttons';
+$string['tiny:orange'] = 'Orange';
+$string['tiny:outset'] = 'Outset';
+$string['tiny:page_break'] = 'Page break';
+$string['tiny:paragraph'] = 'Paragraph';
+$string['tiny:paste'] = 'Paste';
+$string['tiny:paste_as_text'] = 'Paste as text';
+$string['tiny:paste_column_after'] = 'Paste column after';
+$string['tiny:paste_column_before'] = 'Paste column before';
+$string['tiny:paste_is_now_in_plain_text_mode._contents_will_now_be_pasted_as_plain_text_until_you_toggle_this_option_off.'] = 'Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.';
+$string['tiny:paste_or_type_a_link'] = 'Paste or type a link';
+$string['tiny:paste_row_after'] = 'Paste row after';
+$string['tiny:paste_row_before'] = 'Paste row before';
+$string['tiny:paste_your_embed_code_below'] = 'Paste your embed code below:';
+$string['tiny:people'] = 'People';
+$string['tiny:plugins'] = 'Plugins';
+$string['tiny:plugins_installed_0'] = 'Plugins installed ({0}):';
+$string['tiny:powered_by_0'] = 'Powered by {0}';
+$string['tiny:pre'] = 'Pre';
+$string['tiny:preferences'] = 'Preferences';
+$string['tiny:preformatted'] = 'Preformatted';
+$string['tiny:premium_plugins'] = 'Premium plugins:';
+$string['tiny:preview'] = 'Preview';
+$string['tiny:previous'] = 'Previous';
+$string['tiny:print'] = 'Print';
+$string['tiny:print...'] = 'Print...';
+$string['tiny:purple'] = 'Purple';
+$string['tiny:quotations'] = 'Quotations';
+$string['tiny:r'] = 'R';
+$string['tiny:range_0_to_255'] = 'Range 0 to 255';
+$string['tiny:red'] = 'Red';
+$string['tiny:red_component'] = 'Red component';
+$string['tiny:redo'] = 'Redo';
+$string['tiny:remove'] = 'Remove';
+$string['tiny:remove_color'] = 'Remove color';
+$string['tiny:remove_link'] = 'Remove link';
+$string['tiny:replace'] = 'Replace';
+$string['tiny:replace_all'] = 'Replace all';
+$string['tiny:replace_with'] = 'Replace with';
+$string['tiny:resize'] = 'Resize';
+$string['tiny:restore_last_draft'] = 'Restore last draft';
+$string['tiny:rich_text_area'] = 'Rich Text Area';
+$string['tiny:rich_text_area._press_alt-0_for_help.'] = 'Rich Text Area. Press ALT-0 for help.';
+$string['tiny:rich_text_area._press_alt-f9_for_menu._press_alt-f10_for_toolbar._press_alt-0_for_help'] = 'Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help';
+$string['tiny:ridge'] = 'Ridge';
+$string['tiny:right'] = 'Right';
+$string['tiny:right_to_left'] = 'Right to left';
+$string['tiny:row'] = 'Row';
+$string['tiny:row_clipboard_actions'] = 'Row clipboard actions';
+$string['tiny:row_group'] = 'Row group';
+$string['tiny:row_header'] = 'Row header';
+$string['tiny:row_properties'] = 'Row properties';
+$string['tiny:row_type'] = 'Row type';
+$string['tiny:rows'] = 'Rows';
+$string['tiny:save'] = 'Save';
+$string['tiny:save_if_save_plugin_activated'] = 'Save (if save plugin activated)';
+$string['tiny:scope'] = 'Scope';
+$string['tiny:search'] = 'Search';
+$string['tiny:select_all'] = 'Select all';
+$string['tiny:select...'] = 'Select...';
+$string['tiny:selection'] = 'Selection';
+$string['tiny:shortcut'] = 'Shortcut';
+$string['tiny:show_blocks'] = 'Show blocks';
+$string['tiny:show_caption'] = 'Show caption';
+$string['tiny:show_invisible_characters'] = 'Show invisible characters';
+$string['tiny:size'] = 'Size';
+$string['tiny:solid'] = 'Solid';
+$string['tiny:source'] = 'Source';
+$string['tiny:source_code'] = 'Source code';
+$string['tiny:special_character'] = 'Special Character';
+$string['tiny:special_character...'] = 'Special character...';
+$string['tiny:split_cell'] = 'Split cell';
+$string['tiny:square'] = 'Square';
+$string['tiny:start_list_at_number'] = 'Start list at number';
+$string['tiny:strikethrough'] = 'Strikethrough';
+$string['tiny:style'] = 'Style';
+$string['tiny:subscript'] = 'Subscript';
+$string['tiny:superscript'] = 'Superscript';
+$string['tiny:switch_to_or_from_fullscreen_mode'] = 'Switch to or from fullscreen mode';
+$string['tiny:symbols'] = 'Symbols';
+$string['tiny:system_font'] = 'System Font';
+$string['tiny:table'] = 'Table';
+$string['tiny:table_caption'] = 'Table caption';
+$string['tiny:table_properties'] = 'Table properties';
+$string['tiny:table_styles'] = 'Table styles';
+$string['tiny:template'] = 'Template';
+$string['tiny:templates'] = 'Templates';
+$string['tiny:text'] = 'Text';
+$string['tiny:text_color'] = 'Text color';
+$string['tiny:text_to_display'] = 'Text to display';
+$string['tiny:the_url_you_entered_seems_to_be_an_email_address._do_you_want_to_add_the_required_mailto_prefix'] = 'The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?';
+$string['tiny:the_url_you_entered_seems_to_be_an_external_link._do_you_want_to_add_the_required_http_prefix'] = 'The URL you entered seems to be an external link. Do you want to add the required http:// prefix?';
+$string['tiny:the_url_you_entered_seems_to_be_an_external_link._do_you_want_to_add_the_required_https_prefix'] = 'The URL you entered seems to be an external link. Do you want to add the required https:// prefix?';
+$string['tiny:title'] = 'Title';
+$string['tiny:to_open_the_popup_press_shiftenter'] = 'To open the popup, press Shift+Enter';
+$string['tiny:tools'] = 'Tools';
+$string['tiny:top'] = 'Top';
+$string['tiny:travel_and_places'] = 'Travel and Places';
+$string['tiny:turquoise'] = 'Turquoise';
+$string['tiny:underline'] = 'Underline';
+$string['tiny:undo'] = 'Undo';
+$string['tiny:upload'] = 'Upload';
+$string['tiny:uploading_image'] = 'Uploading image';
+$string['tiny:upper_alpha'] = 'Upper Alpha';
+$string['tiny:upper_roman'] = 'Upper Roman';
+$string['tiny:url'] = 'Url';
+$string['tiny:user_defined'] = 'User Defined';
+$string['tiny:valid'] = 'Valid';
+$string['tiny:version'] = 'Version';
+$string['tiny:vertical_align'] = 'Vertical align';
+$string['tiny:vertical_space'] = 'Vertical space';
+$string['tiny:view'] = 'View';
+$string['tiny:visual_aids'] = 'Visual aids';
+$string['tiny:warn'] = 'Warn';
+$string['tiny:white'] = 'White';
+$string['tiny:width'] = 'Width';
+$string['tiny:word_count'] = 'Word count';
+$string['tiny:words'] = 'Words';
+$string['tiny:words_0'] = 'Words: {0}';
+$string['tiny:yellow'] = 'Yellow';
+$string['tiny:yes'] = 'Yes';
+$string['tiny:you_are_using_0'] = 'You are using {0}';
+$string['tiny:you_have_unsaved_changes_are_you_sure_you_want_to_navigate_away'] = 'You have unsaved changes are you sure you want to navigate away?';
+$string['tiny:your_browser_doesnt_support_direct_access_to_the_clipboard._please_use_the_ctrlxcv_keyboard_shortcuts_instead.'] = 'Your browser doesn\'t support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.';
+$string['tiny:alignment1'] = 'alignment';
+$string['tiny:austral_sign'] = 'austral sign';
+$string['tiny:cedi_sign'] = 'cedi sign';
+$string['tiny:colon_sign'] = 'colon sign';
+$string['tiny:cruzeiro_sign'] = 'cruzeiro sign';
+$string['tiny:currency_sign'] = 'currency sign';
+$string['tiny:dollar_sign'] = 'dollar sign';
+$string['tiny:dong_sign'] = 'dong sign';
+$string['tiny:drachma_sign'] = 'drachma sign';
+$string['tiny:euro-currency_sign'] = 'euro-currency sign';
+$string['tiny:example'] = 'example';
+$string['tiny:formatting'] = 'formatting';
+$string['tiny:french_franc_sign'] = 'french franc sign';
+$string['tiny:german_penny_symbol'] = 'german penny symbol';
+$string['tiny:guarani_sign'] = 'guarani sign';
+$string['tiny:history'] = 'history';
+$string['tiny:hryvnia_sign'] = 'hryvnia sign';
+$string['tiny:indentation'] = 'indentation';
+$string['tiny:indian_rupee_sign'] = 'indian rupee sign';
+$string['tiny:kip_sign'] = 'kip sign';
+$string['tiny:lira_sign'] = 'lira sign';
+$string['tiny:livre_tournois_sign'] = 'livre tournois sign';
+$string['tiny:manat_sign'] = 'manat sign';
+$string['tiny:mill_sign'] = 'mill sign';
+$string['tiny:naira_sign'] = 'naira sign';
+$string['tiny:new_sheqel_sign'] = 'new sheqel sign';
+$string['tiny:nordic_mark_sign'] = 'nordic mark sign';
+$string['tiny:peseta_sign'] = 'peseta sign';
+$string['tiny:peso_sign'] = 'peso sign';
+$string['tiny:ruble_sign'] = 'ruble sign';
+$string['tiny:rupee_sign'] = 'rupee sign';
+$string['tiny:spesmilo_sign'] = 'spesmilo sign';
+$string['tiny:styles'] = 'styles';
+$string['tiny:tenge_sign'] = 'tenge sign';
+$string['tiny:tugrik_sign'] = 'tugrik sign';
+$string['tiny:turkish_lira_sign'] = 'turkish lira sign';
+$string['tiny:won_sign'] = 'won sign';
+$string['tiny:yen_character'] = 'yen character';
+$string['tiny:yenyuan_character_variant_one'] = 'yen/yuan character variant one';
+$string['tiny:yuan_character'] = 'yuan character';
+$string['tiny:yuan_character_in_hong_kong_and_taiwan'] = 'yuan character, in hong kong and taiwan';
+$string['tiny:0_characters'] = '{0} characters';
+$string['tiny:0_words'] = '{0} words';
diff --git a/lib/editor/tiny/readme_moodle.md b/lib/editor/tiny/readme_moodle.md
index 58559e4434d..07dff6205f5 100644
--- a/lib/editor/tiny/readme_moodle.md
+++ b/lib/editor/tiny/readme_moodle.md
@@ -40,3 +40,44 @@
```
6. Check the (Release notes)[https://www.tiny.cloud/docs/tinymce/6/release-notes/] for any new plugins, premium plugins, menu items, or buttons and add them to classes/manager.php
+
+## Update procedure for included TinyMCE translations
+
+1. Visit https://www.tiny.cloud/get-tiny/language-packages/ and download a translation which has been fully translated, for example the German translation.
+2. If you did not download the German translation, update the final line of `tools/getOriginals.mjs` to the language code for the relevant translation.
+3. Unzip the translation into a new directory:
+
+ ```
+ langdir=`mktemp -d`
+ cd "${langdir}"
+ unzip path/to/de.zip
+ ```
+
+4. Run the translation tool:
+
+ ```
+ node "${MOODLEDIR}/tools/getOriginals.mjs"
+ ```
+
+ This will generate two files
+
+5. Copy the `tinystrings.json` file into the Moodle directory
+
+ ```
+ cp tinystrings.json "${MOODLEDIR}/tinystrings.json"
+ ```
+
+6. Copy the content of the `strings.php` file over the existing tiny strings:
+
+ ```
+ sed -i "/string\['tiny:/d" "${MOODLEDIR}/lang/en/editor_tiny.php"
+ cat strings.php >> "${MOODLEDIR}/lang/en/editor_tiny.php"
+ ```
+
+7. Commit changes
+
+---
+
+**Note:** You will need to manually check for any Moodle-updated language strings as part of this change (for example any from the en_fixes).
+
+---
diff --git a/lib/editor/tiny/tinystrings.json b/lib/editor/tiny/tinystrings.json
new file mode 100644
index 00000000000..88b54fac2f5
--- /dev/null
+++ b/lib/editor/tiny/tinystrings.json
@@ -0,0 +1,406 @@
+{
+ "tiny:hash": "#",
+ "tiny:accessibility": "Accessibility",
+ "tiny:action": "Action",
+ "tiny:activity": "Activity",
+ "tiny:address": "Address",
+ "tiny:advanced": "Advanced",
+ "tiny:align": "Align",
+ "tiny:align_center": "Align center",
+ "tiny:align_left": "Align left",
+ "tiny:align_right": "Align right",
+ "tiny:alignment": "Alignment",
+ "tiny:all": "All",
+ "tiny:alternative_description": "Alternative description",
+ "tiny:alternative_source": "Alternative source",
+ "tiny:alternative_source_url": "Alternative source URL",
+ "tiny:anchor": "Anchor",
+ "tiny:anchor...": "Anchor...",
+ "tiny:anchors": "Anchors",
+ "tiny:animals_and_nature": "Animals and Nature",
+ "tiny:arrows": "Arrows",
+ "tiny:b": "B",
+ "tiny:background_color": "Background color",
+ "tiny:black": "Black",
+ "tiny:block": "Block",
+ "tiny:blockquote": "Blockquote",
+ "tiny:blocks": "Blocks",
+ "tiny:blue": "Blue",
+ "tiny:blue_component": "Blue component",
+ "tiny:body": "Body",
+ "tiny:bold": "Bold",
+ "tiny:border": "Border",
+ "tiny:border_color": "Border color",
+ "tiny:border_style": "Border style",
+ "tiny:border_width": "Border width",
+ "tiny:bottom": "Bottom",
+ "tiny:browse_for_an_image": "Browse for an image",
+ "tiny:bullet_list": "Bullet list",
+ "tiny:cancel": "Cancel",
+ "tiny:caption": "Caption",
+ "tiny:cell": "Cell",
+ "tiny:cell_padding": "Cell padding",
+ "tiny:cell_properties": "Cell properties",
+ "tiny:cell_spacing": "Cell spacing",
+ "tiny:cell_styles": "Cell styles",
+ "tiny:cell_type": "Cell type",
+ "tiny:center": "Center",
+ "tiny:characters": "Characters",
+ "tiny:characters_no_spaces": "Characters (no spaces)",
+ "tiny:circle": "Circle",
+ "tiny:class": "Class",
+ "tiny:clear_formatting": "Clear formatting",
+ "tiny:close": "Close",
+ "tiny:code": "Code",
+ "tiny:code_sample...": "Code sample...",
+ "tiny:code_view": "Code view",
+ "tiny:color_picker": "Color Picker",
+ "tiny:color_swatch": "Color swatch",
+ "tiny:cols": "Cols",
+ "tiny:column": "Column",
+ "tiny:column_clipboard_actions": "Column clipboard actions",
+ "tiny:column_group": "Column group",
+ "tiny:column_header": "Column header",
+ "tiny:constrain_proportions": "Constrain proportions",
+ "tiny:copy": "Copy",
+ "tiny:copy_column": "Copy column",
+ "tiny:copy_row": "Copy row",
+ "tiny:could_not_find_the_specified_string.": "Could not find the specified string.",
+ "tiny:could_not_load_emojis": "Could not load emojis",
+ "tiny:count": "Count",
+ "tiny:currency": "Currency",
+ "tiny:current_window": "Current window",
+ "tiny:custom_color": "Custom color",
+ "tiny:custom...": "Custom...",
+ "tiny:cut": "Cut",
+ "tiny:cut_column": "Cut column",
+ "tiny:cut_row": "Cut row",
+ "tiny:dark_blue": "Dark Blue",
+ "tiny:dark_gray": "Dark Gray",
+ "tiny:dark_green": "Dark Green",
+ "tiny:dark_orange": "Dark Orange",
+ "tiny:dark_purple": "Dark Purple",
+ "tiny:dark_red": "Dark Red",
+ "tiny:dark_turquoise": "Dark Turquoise",
+ "tiny:dark_yellow": "Dark Yellow",
+ "tiny:dashed": "Dashed",
+ "tiny:datetime": "Date/time",
+ "tiny:decrease_indent": "Decrease indent",
+ "tiny:default": "Default",
+ "tiny:delete_column": "Delete column",
+ "tiny:delete_row": "Delete row",
+ "tiny:delete_table": "Delete table",
+ "tiny:dimensions": "Dimensions",
+ "tiny:disc": "Disc",
+ "tiny:div": "Div",
+ "tiny:document": "Document",
+ "tiny:dotted": "Dotted",
+ "tiny:double": "Double",
+ "tiny:drop_an_image_here": "Drop an image here",
+ "tiny:dropped_file_type_is_not_supported": "Dropped file type is not supported",
+ "tiny:edit": "Edit",
+ "tiny:embed": "Embed",
+ "tiny:emojis": "Emojis",
+ "tiny:emojis...": "Emojis...",
+ "tiny:error": "Error",
+ "tiny:error_form_submit_field_collision.": "Error: Form submit field collision.",
+ "tiny:error_no_form_element_found.": "Error: No form element found.",
+ "tiny:extended_latin": "Extended Latin",
+ "tiny:failed_to_initialize_plugin_0": "Failed to initialize plugin: {0}",
+ "tiny:failed_to_load_plugin_url_0": "Failed to load plugin url: {0}",
+ "tiny:failed_to_load_plugin_0_from_url_1": "Failed to load plugin: {0} from url {1}",
+ "tiny:failed_to_upload_image_0": "Failed to upload image: {0}",
+ "tiny:file": "File",
+ "tiny:find": "Find",
+ "tiny:find_if_searchreplace_plugin_activated": "Find (if searchreplace plugin activated)",
+ "tiny:find_and_replace": "Find and Replace",
+ "tiny:find_and_replace...": "Find and replace...",
+ "tiny:find_in_selection": "Find in selection",
+ "tiny:find_whole_words_only": "Find whole words only",
+ "tiny:flags": "Flags",
+ "tiny:focus_to_contextual_toolbar": "Focus to contextual toolbar",
+ "tiny:focus_to_element_path": "Focus to element path",
+ "tiny:focus_to_menubar": "Focus to menubar",
+ "tiny:focus_to_toolbar": "Focus to toolbar",
+ "tiny:font": "Font",
+ "tiny:font_sizes": "Font sizes",
+ "tiny:fonts": "Fonts",
+ "tiny:food_and_drink": "Food and Drink",
+ "tiny:footer": "Footer",
+ "tiny:format": "Format",
+ "tiny:formats": "Formats",
+ "tiny:fullscreen": "Fullscreen",
+ "tiny:g": "G",
+ "tiny:general": "General",
+ "tiny:gray": "Gray",
+ "tiny:green": "Green",
+ "tiny:green_component": "Green component",
+ "tiny:groove": "Groove",
+ "tiny:handy_shortcuts": "Handy Shortcuts",
+ "tiny:header": "Header",
+ "tiny:header_cell": "Header cell",
+ "tiny:heading_1": "Heading 1",
+ "tiny:heading_2": "Heading 2",
+ "tiny:heading_3": "Heading 3",
+ "tiny:heading_4": "Heading 4",
+ "tiny:heading_5": "Heading 5",
+ "tiny:heading_6": "Heading 6",
+ "tiny:headings": "Headings",
+ "tiny:height": "Height",
+ "tiny:help": "Help",
+ "tiny:hex_color_code": "Hex color code",
+ "tiny:hidden": "Hidden",
+ "tiny:horizontal_align": "Horizontal align",
+ "tiny:horizontal_line": "Horizontal line",
+ "tiny:horizontal_space": "Horizontal space",
+ "tiny:id": "ID",
+ "tiny:id_should_start_with_a_letter_followed_only_by_letters_numbers_dashes_dots_colons_or_underscores.": "ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.",
+ "tiny:image_is_decorative": "Image is decorative",
+ "tiny:image_list": "Image list",
+ "tiny:image_title": "Image title",
+ "tiny:image...": "Image...",
+ "tiny:imageproxy_http_error_could_not_find_image_proxy": "ImageProxy HTTP error: Could not find Image Proxy",
+ "tiny:imageproxy_http_error_incorrect_image_proxy_url": "ImageProxy HTTP error: Incorrect Image Proxy URL",
+ "tiny:imageproxy_http_error_rejected_request": "ImageProxy HTTP error: Rejected request",
+ "tiny:imageproxy_http_error_unknown_imageproxy_error": "ImageProxy HTTP error: Unknown ImageProxy error",
+ "tiny:increase_indent": "Increase indent",
+ "tiny:inline": "Inline",
+ "tiny:insert": "Insert",
+ "tiny:insert_template": "Insert Template",
+ "tiny:insert_column_after": "Insert column after",
+ "tiny:insert_column_before": "Insert column before",
+ "tiny:insert_datetime": "Insert date/time",
+ "tiny:insert_image": "Insert image",
+ "tiny:insert_link_if_link_plugin_activated": "Insert link (if link plugin activated)",
+ "tiny:insert_row_after": "Insert row after",
+ "tiny:insert_row_before": "Insert row before",
+ "tiny:insert_table": "Insert table",
+ "tiny:insert_template...": "Insert template...",
+ "tiny:insert_video": "Insert video",
+ "tiny:insertedit_code_sample": "Insert/Edit code sample",
+ "tiny:insertedit_image": "Insert/edit image",
+ "tiny:insertedit_link": "Insert/edit link",
+ "tiny:insertedit_media": "Insert/edit media",
+ "tiny:insertedit_video": "Insert/edit video",
+ "tiny:inset": "Inset",
+ "tiny:invalid_hex_color_code_0": "Invalid hex color code: {0}",
+ "tiny:invalid_input": "Invalid input",
+ "tiny:italic": "Italic",
+ "tiny:justify": "Justify",
+ "tiny:keyboard_navigation": "Keyboard Navigation",
+ "tiny:language": "Language",
+ "tiny:learn_more...": "Learn more...",
+ "tiny:left": "Left",
+ "tiny:left_to_right": "Left to right",
+ "tiny:light_blue": "Light Blue",
+ "tiny:light_gray": "Light Gray",
+ "tiny:light_green": "Light Green",
+ "tiny:light_purple": "Light Purple",
+ "tiny:light_red": "Light Red",
+ "tiny:light_yellow": "Light Yellow",
+ "tiny:line_height": "Line height",
+ "tiny:link_list": "Link list",
+ "tiny:link...": "Link...",
+ "tiny:list_properties": "List Properties",
+ "tiny:list_properties...": "List properties...",
+ "tiny:loading_emojis...": "Loading emojis...",
+ "tiny:loading...": "Loading...",
+ "tiny:lower_alpha": "Lower Alpha",
+ "tiny:lower_greek": "Lower Greek",
+ "tiny:lower_roman": "Lower Roman",
+ "tiny:match_case": "Match case",
+ "tiny:mathematical": "Mathematical",
+ "tiny:media_poster_image_url": "Media poster (Image URL)",
+ "tiny:media...": "Media...",
+ "tiny:medium_blue": "Medium Blue",
+ "tiny:medium_gray": "Medium Gray",
+ "tiny:medium_purple": "Medium Purple",
+ "tiny:merge_cells": "Merge cells",
+ "tiny:middle": "Middle",
+ "tiny:midnight_blue": "Midnight Blue",
+ "tiny:more...": "More...",
+ "tiny:name": "Name",
+ "tiny:navy_blue": "Navy Blue",
+ "tiny:new_document": "New document",
+ "tiny:new_window": "New window",
+ "tiny:next": "Next",
+ "tiny:no": "No",
+ "tiny:no_alignment": "No alignment",
+ "tiny:no_color": "No color",
+ "tiny:nonbreaking_space": "Nonbreaking space",
+ "tiny:none": "None",
+ "tiny:numbered_list": "Numbered list",
+ "tiny:or": "OR",
+ "tiny:objects": "Objects",
+ "tiny:ok": "Ok",
+ "tiny:open_help_dialog": "Open help dialog",
+ "tiny:open_link": "Open link",
+ "tiny:open_link_in...": "Open link in...",
+ "tiny:open_popup_menu_for_split_buttons": "Open popup menu for split buttons",
+ "tiny:orange": "Orange",
+ "tiny:outset": "Outset",
+ "tiny:page_break": "Page break",
+ "tiny:paragraph": "Paragraph",
+ "tiny:paste": "Paste",
+ "tiny:paste_as_text": "Paste as text",
+ "tiny:paste_column_after": "Paste column after",
+ "tiny:paste_column_before": "Paste column before",
+ "tiny:paste_is_now_in_plain_text_mode._contents_will_now_be_pasted_as_plain_text_until_you_toggle_this_option_off.": "Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.",
+ "tiny:paste_or_type_a_link": "Paste or type a link",
+ "tiny:paste_row_after": "Paste row after",
+ "tiny:paste_row_before": "Paste row before",
+ "tiny:paste_your_embed_code_below": "Paste your embed code below:",
+ "tiny:people": "People",
+ "tiny:plugins": "Plugins",
+ "tiny:plugins_installed_0": "Plugins installed ({0}):",
+ "tiny:powered_by_0": "Powered by {0}",
+ "tiny:pre": "Pre",
+ "tiny:preferences": "Preferences",
+ "tiny:preformatted": "Preformatted",
+ "tiny:premium_plugins": "Premium plugins:",
+ "tiny:preview": "Preview",
+ "tiny:previous": "Previous",
+ "tiny:print": "Print",
+ "tiny:print...": "Print...",
+ "tiny:purple": "Purple",
+ "tiny:quotations": "Quotations",
+ "tiny:r": "R",
+ "tiny:range_0_to_255": "Range 0 to 255",
+ "tiny:red": "Red",
+ "tiny:red_component": "Red component",
+ "tiny:redo": "Redo",
+ "tiny:remove": "Remove",
+ "tiny:remove_color": "Remove color",
+ "tiny:remove_link": "Remove link",
+ "tiny:replace": "Replace",
+ "tiny:replace_all": "Replace all",
+ "tiny:replace_with": "Replace with",
+ "tiny:resize": "Resize",
+ "tiny:restore_last_draft": "Restore last draft",
+ "tiny:rich_text_area": "Rich Text Area",
+ "tiny:rich_text_area._press_alt-0_for_help.": "Rich Text Area. Press ALT-0 for help.",
+ "tiny:rich_text_area._press_alt-f9_for_menu._press_alt-f10_for_toolbar._press_alt-0_for_help": "Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help",
+ "tiny:ridge": "Ridge",
+ "tiny:right": "Right",
+ "tiny:right_to_left": "Right to left",
+ "tiny:row": "Row",
+ "tiny:row_clipboard_actions": "Row clipboard actions",
+ "tiny:row_group": "Row group",
+ "tiny:row_header": "Row header",
+ "tiny:row_properties": "Row properties",
+ "tiny:row_type": "Row type",
+ "tiny:rows": "Rows",
+ "tiny:save": "Save",
+ "tiny:save_if_save_plugin_activated": "Save (if save plugin activated)",
+ "tiny:scope": "Scope",
+ "tiny:search": "Search",
+ "tiny:select_all": "Select all",
+ "tiny:select...": "Select...",
+ "tiny:selection": "Selection",
+ "tiny:shortcut": "Shortcut",
+ "tiny:show_blocks": "Show blocks",
+ "tiny:show_caption": "Show caption",
+ "tiny:show_invisible_characters": "Show invisible characters",
+ "tiny:size": "Size",
+ "tiny:solid": "Solid",
+ "tiny:source": "Source",
+ "tiny:source_code": "Source code",
+ "tiny:special_character": "Special Character",
+ "tiny:special_character...": "Special character...",
+ "tiny:split_cell": "Split cell",
+ "tiny:square": "Square",
+ "tiny:start_list_at_number": "Start list at number",
+ "tiny:strikethrough": "Strikethrough",
+ "tiny:style": "Style",
+ "tiny:subscript": "Subscript",
+ "tiny:superscript": "Superscript",
+ "tiny:switch_to_or_from_fullscreen_mode": "Switch to or from fullscreen mode",
+ "tiny:symbols": "Symbols",
+ "tiny:system_font": "System Font",
+ "tiny:table": "Table",
+ "tiny:table_caption": "Table caption",
+ "tiny:table_properties": "Table properties",
+ "tiny:table_styles": "Table styles",
+ "tiny:template": "Template",
+ "tiny:templates": "Templates",
+ "tiny:text": "Text",
+ "tiny:text_color": "Text color",
+ "tiny:text_to_display": "Text to display",
+ "tiny:the_url_you_entered_seems_to_be_an_email_address._do_you_want_to_add_the_required_mailto_prefix": "The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?",
+ "tiny:the_url_you_entered_seems_to_be_an_external_link._do_you_want_to_add_the_required_http_prefix": "The URL you entered seems to be an external link. Do you want to add the required http:// prefix?",
+ "tiny:the_url_you_entered_seems_to_be_an_external_link._do_you_want_to_add_the_required_https_prefix": "The URL you entered seems to be an external link. Do you want to add the required https:// prefix?",
+ "tiny:title": "Title",
+ "tiny:to_open_the_popup_press_shiftenter": "To open the popup, press Shift+Enter",
+ "tiny:tools": "Tools",
+ "tiny:top": "Top",
+ "tiny:travel_and_places": "Travel and Places",
+ "tiny:turquoise": "Turquoise",
+ "tiny:underline": "Underline",
+ "tiny:undo": "Undo",
+ "tiny:upload": "Upload",
+ "tiny:uploading_image": "Uploading image",
+ "tiny:upper_alpha": "Upper Alpha",
+ "tiny:upper_roman": "Upper Roman",
+ "tiny:url": "Url",
+ "tiny:user_defined": "User Defined",
+ "tiny:valid": "Valid",
+ "tiny:version": "Version",
+ "tiny:vertical_align": "Vertical align",
+ "tiny:vertical_space": "Vertical space",
+ "tiny:view": "View",
+ "tiny:visual_aids": "Visual aids",
+ "tiny:warn": "Warn",
+ "tiny:white": "White",
+ "tiny:width": "Width",
+ "tiny:word_count": "Word count",
+ "tiny:words": "Words",
+ "tiny:words_0": "Words: {0}",
+ "tiny:yellow": "Yellow",
+ "tiny:yes": "Yes",
+ "tiny:you_are_using_0": "You are using {0}",
+ "tiny:you_have_unsaved_changes_are_you_sure_you_want_to_navigate_away": "You have unsaved changes are you sure you want to navigate away?",
+ "tiny:your_browser_doesnt_support_direct_access_to_the_clipboard._please_use_the_ctrlxcv_keyboard_shortcuts_instead.": "Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.",
+ "tiny:alignment1": "alignment",
+ "tiny:austral_sign": "austral sign",
+ "tiny:cedi_sign": "cedi sign",
+ "tiny:colon_sign": "colon sign",
+ "tiny:cruzeiro_sign": "cruzeiro sign",
+ "tiny:currency_sign": "currency sign",
+ "tiny:dollar_sign": "dollar sign",
+ "tiny:dong_sign": "dong sign",
+ "tiny:drachma_sign": "drachma sign",
+ "tiny:euro-currency_sign": "euro-currency sign",
+ "tiny:example": "example",
+ "tiny:formatting": "formatting",
+ "tiny:french_franc_sign": "french franc sign",
+ "tiny:german_penny_symbol": "german penny symbol",
+ "tiny:guarani_sign": "guarani sign",
+ "tiny:history": "history",
+ "tiny:hryvnia_sign": "hryvnia sign",
+ "tiny:indentation": "indentation",
+ "tiny:indian_rupee_sign": "indian rupee sign",
+ "tiny:kip_sign": "kip sign",
+ "tiny:lira_sign": "lira sign",
+ "tiny:livre_tournois_sign": "livre tournois sign",
+ "tiny:manat_sign": "manat sign",
+ "tiny:mill_sign": "mill sign",
+ "tiny:naira_sign": "naira sign",
+ "tiny:new_sheqel_sign": "new sheqel sign",
+ "tiny:nordic_mark_sign": "nordic mark sign",
+ "tiny:peseta_sign": "peseta sign",
+ "tiny:peso_sign": "peso sign",
+ "tiny:ruble_sign": "ruble sign",
+ "tiny:rupee_sign": "rupee sign",
+ "tiny:spesmilo_sign": "spesmilo sign",
+ "tiny:styles": "styles",
+ "tiny:tenge_sign": "tenge sign",
+ "tiny:tugrik_sign": "tugrik sign",
+ "tiny:turkish_lira_sign": "turkish lira sign",
+ "tiny:won_sign": "won sign",
+ "tiny:yen_character": "yen character",
+ "tiny:yenyuan_character_variant_one": "yen/yuan character variant one",
+ "tiny:yuan_character": "yuan character",
+ "tiny:yuan_character_in_hong_kong_and_taiwan": "yuan character, in hong kong and taiwan",
+ "tiny:0_characters": "{0} characters",
+ "tiny:0_words": "{0} words"
+}
\ No newline at end of file
diff --git a/lib/editor/tiny/tools/getOriginals.mjs b/lib/editor/tiny/tools/getOriginals.mjs
new file mode 100644
index 00000000000..e2a028b5a8c
--- /dev/null
+++ b/lib/editor/tiny/tools/getOriginals.mjs
@@ -0,0 +1,86 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+import {readFile, writeFile} from 'fs/promises';
+
+const readStringsFromLanguages = async (language) => {
+ const fileContent = await readFile(`./langs/${language}.js`, 'utf-8');
+
+ const translations = [];
+ const tinymce = {
+ addI18n: (language, strings) => {
+ translations.push(...(Object.keys(strings)));
+ },
+ };
+
+ eval(fileContent);
+
+ return translations.sort();
+};
+
+const getStringMap = (strings) => {
+ const stringMap = {};
+
+ const getUniqueKeyForString = (string, modifier = 0) => {
+ let stringKey = string.toLowerCase()
+ .replaceAll(' ', '_')
+ .replaceAll(/\{(\d)\}/g, '$1')
+ .replaceAll('#', 'hash')
+ .replaceAll(/[^a-z0-9_\-\.]/g, '')
+ ;
+
+ if (stringKey === '') {
+ throw new Error(`The calculated key for '${string}' was empty`);
+ }
+
+ stringKey = `tiny:${stringKey}`;
+
+ if (modifier > 0) {
+ stringKey = `${stringKey}${modifier}`;
+ }
+
+ if (typeof stringMap[stringKey] !== 'undefined') {
+ return getUniqueKeyForString(string, ++modifier);
+ }
+
+ return stringKey;
+ };
+
+ strings.forEach((string) => {
+ const stringKey = getUniqueKeyForString(string);
+ if (typeof stringMap[stringKey] !== 'undefined') {
+ throw new Error(`Found existing key ${stringKey}`);
+ }
+
+ stringMap[stringKey] = string;
+ });
+
+ return stringMap;
+};
+
+const getPhpStrings = (stringMap) => Object.entries(stringMap).map(([stringKey, stringValue]) => {
+ return `$string['${stringKey}'] = '${stringValue.replace("'", "\\\'")}';`
+}).join("\n");
+
+
+const constructTranslationFile = async(language) => {
+ const strings = await readStringsFromLanguages(language);
+ const stringMap = getStringMap(strings);
+
+ await writeFile('./strings.php', getPhpStrings(stringMap) + "\n");
+ await writeFile('./tinystrings.json', JSON.stringify(stringMap, null, ' '));
+};
+
+constructTranslationFile('de');