diff --git a/xExtension-WordHighlighter/extension.php b/xExtension-WordHighlighter/extension.php
new file mode 100644
index 0000000..29b85e2
--- /dev/null
+++ b/xExtension-WordHighlighter/extension.php
@@ -0,0 +1,102 @@
+registerTranslates();
+
+ // register CSS for WordHighlighter:
+ Minz_View::appendStyle($this->getFileUrl('style.css', 'css'));
+
+ Minz_View::appendScript($this->getFileUrl('mark.min.js', 'js'), false, false, false);
+
+ $current_user = Minz_Session::paramString('currentUser');
+
+ $staticPath = join_path($this->getPath(), 'static');
+ $configFileJs = join_path($staticPath, ('config.' . $current_user . '.js'));
+
+ if (file_exists($configFileJs)) {
+ Minz_View::appendScript($this->getFileUrl(('config.' . $current_user . '.js'), 'js'));
+ }
+
+ Minz_View::appendScript($this->getFileUrl('word-highlighter.js', 'js'));
+ }
+
+ #[\Override]
+ public function handleConfigureAction(): void
+ {
+ $this->registerTranslates();
+
+ $current_user = Minz_Session::paramString('currentUser');
+ $staticPath = join_path($this->getPath(), 'static');
+
+ $configFileJson = join_path($staticPath, ('config.' . $current_user . '.json'));
+
+ if (!file_exists($configFileJson) && !is_writable($staticPath)) {
+ $tmpPath = explode(EXTENSIONS_PATH . '/', $staticPath);
+ $this->permission_problem = $tmpPath[1] . '/';
+
+ } elseif (file_exists($configFileJson) && !is_writable($configFileJson)) {
+ $tmpPath = explode(EXTENSIONS_PATH . '/', $configFileJson);
+ $this->permission_problem = $tmpPath[1];
+
+ } elseif (Minz_Request::isPost()) {
+ $configWordList = html_entity_decode(Minz_Request::paramString('words_list'));
+
+ $this->word_highlighter_conf = $configWordList;
+ $this->enable_in_article = (bool) Minz_Request::paramString('enable-in-article');
+ $this->enable_logs = (bool) Minz_Request::paramString('enable_logs');
+ $this->case_sensitive = (bool) Minz_Request::paramString('case_sensitive');
+ $this->separate_word_search = (bool) Minz_Request::paramString('separate_word_search');
+
+ $configObj = [
+ 'enable_in_article' => $this->enable_in_article,
+ 'enable_logs' => $this->enable_logs,
+ 'case_sensitive' => $this->case_sensitive,
+ 'separate_word_search' => $this->separate_word_search,
+ 'words' => preg_split("/\r\n|\n|\r/", $configWordList),
+ ];
+ $configJson = json_encode($configObj, WordHighlighterExtension::JSON_ENCODE_CONF);
+ file_put_contents(join_path($staticPath, ('config.' . $current_user . '.json')), $configJson . PHP_EOL);
+ file_put_contents(join_path($staticPath, ('config.' . $current_user . '.js')), $this->jsonToJs($configJson) . PHP_EOL);
+ }
+
+ if (file_exists($configFileJson)) {
+ try {
+ $confJson = json_decode(file_get_contents($configFileJson) ?: '', true, 8, JSON_THROW_ON_ERROR);
+ if (json_last_error() !== JSON_ERROR_NONE || !is_array($confJson)) {
+ return;
+ }
+ $this->enable_in_article = (bool) ($confJson['enable_in_article'] ?? false);
+ $this->enable_logs = (bool) ($confJson['enable_logs'] ?? false);
+ $this->case_sensitive = (bool) ($confJson['case_sensitive'] ?? false);
+ $this->separate_word_search = (bool) ($confJson['separate_word_search'] ?? false);
+ $this->word_highlighter_conf = implode("\n", (array) ($confJson['words'] ?? []));
+
+ } catch (Exception $exception) {
+ // probably nothing to do needed
+ }
+ }
+ }
+
+ private function jsonToJs(string $jsonStr): string
+ {
+ $js = "window.WordHighlighterConf = " .
+ $jsonStr . ";\n" .
+ "window.WordHighlighterConf.enable_logs && console.log('WordHighlighter: loaded user config:', window.WordHighlighterConf);";
+ return $js;
+ }
+}
diff --git a/xExtension-WordHighlighter/i18n/en/ext.php b/xExtension-WordHighlighter/i18n/en/ext.php
new file mode 100644
index 0000000..9954541
--- /dev/null
+++ b/xExtension-WordHighlighter/i18n/en/ext.php
@@ -0,0 +1,15 @@
+ array(
+ 'write_words' => 'Words to highlight',
+ 'write_words_more' => '(separated by newline)',
+ 'enable_in_article' => 'Enable highlighting also in article',
+ 'enable_in_article_more' => '(⚠️ may be slower with a lot of words)',
+ 'enable_logs' => 'Enable logs',
+ 'case_sensitive' => 'Case sensitive',
+ 'separate_word_search' => 'Separate word search',
+ 'test_highlighting_word' => 'highlight',
+ 'permission_problem' => 'Your config file is not writable, please change the file permissions for %s',
+ ),
+);
diff --git a/xExtension-WordHighlighter/i18n/fr/ext.php b/xExtension-WordHighlighter/i18n/fr/ext.php
new file mode 100644
index 0000000..a78e090
--- /dev/null
+++ b/xExtension-WordHighlighter/i18n/fr/ext.php
@@ -0,0 +1,15 @@
+ array(
+ 'write_words' => 'Mots à surligner',
+ 'write_words_more' => '(séparés par une nouvelle ligne)',
+ 'enable_in_article' => 'Activer la mise en évidence également dans l’article',
+ 'enable_in_article_more' => '(⚠️ peut être plus lent avec beaucoup de mots)',
+ 'enable_logs' => 'Activer les journaux',
+ 'case_sensitive' => 'Sensible à la casse',
+ 'separate_word_search' => 'Recherche de mots séparés',
+ 'test_highlighting_word' => 'surligner',
+ 'permission_problem' => 'Votre fichier de configuration n’est pas accessible en écriture, veuillez modifier les permissions du fichier %s',
+ ),
+);
diff --git a/xExtension-WordHighlighter/metadata.json b/xExtension-WordHighlighter/metadata.json
new file mode 100644
index 0000000..ab88d27
--- /dev/null
+++ b/xExtension-WordHighlighter/metadata.json
@@ -0,0 +1,8 @@
+{
+ "name": "Word highlighter",
+ "author": "Lukas Melega",
+ "description": "Highlight specific words",
+ "version": "0.0.2",
+ "entrypoint": "WordHighlighter",
+ "type": "user"
+}
diff --git a/xExtension-WordHighlighter/snapshot-dark.png b/xExtension-WordHighlighter/snapshot-dark.png
new file mode 100644
index 0000000..01dbd11
Binary files /dev/null and b/xExtension-WordHighlighter/snapshot-dark.png differ
diff --git a/xExtension-WordHighlighter/snapshot.png b/xExtension-WordHighlighter/snapshot.png
new file mode 100644
index 0000000..822084e
Binary files /dev/null and b/xExtension-WordHighlighter/snapshot.png differ
diff --git a/xExtension-WordHighlighter/static/.gitignore b/xExtension-WordHighlighter/static/.gitignore
new file mode 100644
index 0000000..6d6756d
--- /dev/null
+++ b/xExtension-WordHighlighter/static/.gitignore
@@ -0,0 +1,2 @@
+config-words.*.js
+config-words.*.txt
diff --git a/xExtension-WordHighlighter/static/mark.min.js b/xExtension-WordHighlighter/static/mark.min.js
new file mode 100644
index 0000000..c725bc4
--- /dev/null
+++ b/xExtension-WordHighlighter/static/mark.min.js
@@ -0,0 +1,13 @@
+/**
+ * mark.js (Library for highlighting words)
+ * https://github.com/julkue/mark.js/blob/master/dist/mark.es6.min.js
+ */
+/* eslint-disable */
+
+/*!***************************************************
+* mark.js v9.0.0
+* https://markjs.io/
+* Copyright (c) 2014–2018, Julian Kühnel
+* Released under the MIT license https://git.io/vwTVl
+*****************************************************/
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.Mark=t()}(this,function(){"use strict";class e{constructor(e,t=!0,s=[],r=5e3){this.ctx=e,this.iframes=t,this.exclude=s,this.iframesTimeout=r}static matches(e,t){const s="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){let t=!1;return s.every(s=>!r.call(e,s)||(t=!0,!1)),t}return!1}getContexts(){let e,t=[];return(e=void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(e=>{const s=t.filter(t=>t.contains(e)).length>0;-1!==t.indexOf(e)||s||t.push(e)}),t}getIframeContents(e,t,s=(()=>{})){let r;try{const t=e.contentWindow;if(r=t.document,!t||!r)throw new Error("iframe inaccessible")}catch(e){s()}r&&t(r)}isIframeBlank(e){const t="about:blank",s=e.getAttribute("src").trim();return e.contentWindow.location.href===t&&s!==t&&s}observeIframeLoad(e,t,s){let r=!1,i=null;const n=()=>{if(!r){r=!0,clearTimeout(i);try{this.isIframeBlank(e)||(e.removeEventListener("load",n),this.getIframeContents(e,t,s))}catch(e){s()}}};e.addEventListener("load",n),i=setTimeout(n,this.iframesTimeout)}onIframeReady(e,t,s){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,s):this.getIframeContents(e,t,s):this.observeIframeLoad(e,t,s)}catch(e){s()}}waitForIframes(e,t){let s=0;this.forEachIframe(e,()=>!0,e=>{s++,this.waitForIframes(e.querySelector("html"),()=>{--s||t()})},e=>{e||t()})}forEachIframe(t,s,r,i=(()=>{})){let n=t.querySelectorAll("iframe"),o=n.length,a=0;n=Array.prototype.slice.call(n);const h=()=>{--o<=0&&i(a)};o||h(),n.forEach(t=>{e.matches(t,this.exclude)?h():this.onIframeReady(t,e=>{s(t)&&(a++,r(e)),h()},h)})}createIterator(e,t,s){return document.createNodeIterator(e,t,s,!1)}createInstanceOnIframe(t){return new e(t.querySelector("html"),this.iframes)}compareNodeIframe(e,t,s){if(e.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_PRECEDING){if(null===t)return!0;if(t.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING)return!0}return!1}getIteratorNode(e){const t=e.previousNode();let s;return{prevNode:t,node:s=null===t?e.nextNode():e.nextNode()&&e.nextNode()}}checkIframeFilter(e,t,s,r){let i=!1,n=!1;return r.forEach((e,t)=>{e.val===s&&(i=t,n=e.handled)}),this.compareNodeIframe(e,t,s)?(!1!==i||n?!1===i||n||(r[i].handled=!0):r.push({val:s,handled:!0}),!0):(!1===i&&r.push({val:s,handled:!1}),!1)}handleOpenIframes(e,t,s,r){e.forEach(e=>{e.handled||this.getIframeContents(e.val,e=>{this.createInstanceOnIframe(e).forEachNode(t,s,r)})})}iterateThroughNodes(e,t,s,r,i){const n=this.createIterator(t,e,r);let o,a,h=[],c=[],l=()=>(({prevNode:a,node:o}=this.getIteratorNode(n)),o);for(;l();)this.iframes&&this.forEachIframe(t,e=>this.checkIframeFilter(o,a,e,h),t=>{this.createInstanceOnIframe(t).forEachNode(e,e=>c.push(e),r)}),c.push(o);c.forEach(e=>{s(e)}),this.iframes&&this.handleOpenIframes(h,e,s,r),i()}forEachNode(e,t,s,r=(()=>{})){const i=this.getContexts();let n=i.length;n||r(),i.forEach(i=>{const o=()=>{this.iterateThroughNodes(e,i,t,s,()=>{--n<=0&&r()})};this.iframes?this.waitForIframes(i,o):o()})}}class t{constructor(e){this.opt=Object.assign({},{diacritics:!0,synonyms:{},accuracy:"partially",caseSensitive:!1,ignoreJoiners:!1,ignorePunctuation:[],wildcards:"disabled"},e)}create(e){return"disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e),new RegExp(e,`gm${this.opt.caseSensitive?"":"i"}`)}sortByLength(e){return e.sort((e,t)=>e.length===t.length?e>t?1:-1:t.length-e.length)}escapeStr(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}createSynonymsRegExp(e){const t=this.opt.synonyms,s=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(let i in t)if(t.hasOwnProperty(i)){let n=Array.isArray(t[i])?t[i]:[t[i]];n.unshift(i),(n=this.sortByLength(n).map(e=>("disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e))).filter(e=>""!==e)).length>1&&(e=e.replace(new RegExp(`(${n.map(e=>this.escapeStr(e)).join("|")})`,`gm${s}`),r+`(${n.map(e=>this.processSynonyms(e)).join("|")})`+r))}return e}processSynonyms(e){return(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}setupWildcardsRegExp(e){return(e=e.replace(/(?:\\)*\?/g,e=>"\\"===e.charAt(0)?"?":"")).replace(/(?:\\)*\*/g,e=>"\\"===e.charAt(0)?"*":"")}createWildcardsRegExp(e){let t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}setupIgnoreJoinersRegExp(e){return e.replace(/[^(|)\\]/g,(e,t,s)=>{let r=s.charAt(t+1);return/[(|)\\]/.test(r)||""===r?e:e+"\0"})}createJoinersRegExp(e){let t=[];const s=this.opt.ignorePunctuation;return Array.isArray(s)&&s.length&&t.push(this.escapeStr(s.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join(`[${t.join("")}]*`):e}createDiacriticsRegExp(e){const t=this.opt.caseSensitive?"":"i",s=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"];let r=[];return e.split("").forEach(i=>{s.every(s=>{if(-1!==s.indexOf(i)){if(r.indexOf(s)>-1)return!1;e=e.replace(new RegExp(`[${s}]`,`gm${t}`),`[${s}]`),r.push(s)}return!0})}),e}createMergedBlanksRegExp(e){return e.replace(/[\s]+/gim,"[\\s]+")}createAccuracyRegExp(e){let t=this.opt.accuracy,s="string"==typeof t?t:t.value,r="string"==typeof t?[]:t.limiters,i="";switch(r.forEach(e=>{i+=`|${this.escapeStr(e)}`}),s){case"partially":default:return`()(${e})`;case"complementary":return`()([^${i="\\s"+(i||this.escapeStr("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿"))}]*${e}[^${i}]*)`;case"exactly":return`(^|\\s${i})(${e})(?=$|\\s${i})`}}}class s{constructor(e){this.ctx=e,this.ie=!1;const t=window.navigator.userAgent;(t.indexOf("MSIE")>-1||t.indexOf("Trident")>-1)&&(this.ie=!0)}set opt(e){this._opt=Object.assign({},{element:"",className:"",exclude:[],iframes:!1,iframesTimeout:5e3,separateWordSearch:!0,acrossElements:!1,ignoreGroups:0,each:()=>{},noMatch:()=>{},filter:()=>!0,done:()=>{},debug:!1,log:window.console},e)}get opt(){return this._opt}get iterator(){return new e(this.ctx,this.opt.iframes,this.opt.exclude,this.opt.iframesTimeout)}log(e,t="debug"){const s=this.opt.log;this.opt.debug&&"object"==typeof s&&"function"==typeof s[t]&&s[t](`mark.js: ${e}`)}getSeparatedKeywords(e){let t=[];return e.forEach(e=>{this.opt.separateWordSearch?e.split(" ").forEach(e=>{e.trim()&&-1===t.indexOf(e)&&t.push(e)}):e.trim()&&-1===t.indexOf(e)&&t.push(e)}),{keywords:t.sort((e,t)=>t.length-e.length),length:t.length}}isNumeric(e){return Number(parseFloat(e))==e}checkRanges(e){if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];const t=[];let s=0;return e.sort((e,t)=>e.start-t.start).forEach(e=>{let{start:r,end:i,valid:n}=this.callNoMatchOnInvalidRanges(e,s);n&&(e.start=r,e.length=i-r,t.push(e),s=i)}),t}callNoMatchOnInvalidRanges(e,t){let s,r,i=!1;return e&&void 0!==e.start?(r=(s=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-s>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+`${JSON.stringify(e)}`),this.opt.noMatch(e))):(this.log(`Ignoring invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)),{start:s,end:r,valid:i}}checkWhitespaceRanges(e,t,s){let r,i=!0,n=s.length,o=t-n,a=parseInt(e.start,10)-o;return(r=(a=a>n?n:a)+parseInt(e.length,10))>n&&(r=n,this.log(`End range automatically set to the max value of ${n}`)),a<0||r-a<0||a>n||r>n?(i=!1,this.log(`Invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)):""===s.substring(a,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:a,end:r,valid:i}}getTextNodes(e){let t="",s=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,e=>{s.push({start:t.length,end:(t+=e.textContent).length,node:e})},e=>this.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT,()=>{e({value:t,nodes:s})})}matchesExclude(t){return e.matches(t,this.opt.exclude.concat(["script","style","title","head","html"]))}wrapRangeInTextNode(e,t,s){const r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),n=i.splitText(s-t);let o=document.createElement(r);return o.setAttribute("data-markjs","true"),this.opt.className&&o.setAttribute("class",this.opt.className),o.textContent=i.textContent,i.parentNode.replaceChild(o,i),n}wrapRangeInMappedTextNode(e,t,s,r,i){e.nodes.every((n,o)=>{const a=e.nodes[o+1];if(void 0===a||a.start>t){if(!r(n.node))return!1;const a=t-n.start,h=(s>n.end?n.end:s)-n.start,c=e.value.substr(0,n.start),l=e.value.substr(h+n.start);if(n.node=this.wrapRangeInTextNode(n.node,a,h),e.value=c+l,e.nodes.forEach((t,s)=>{s>=o&&(e.nodes[s].start>0&&s!==o&&(e.nodes[s].start-=h),e.nodes[s].end-=h)}),s-=h,i(n.node.previousSibling,n.start),!(s>n.end))return!1;t=n.end}return!0})}wrapGroups(e,t,s,r){return r((e=this.wrapRangeInTextNode(e,t,t+s)).previousSibling),e}separateGroups(e,t,s,r,i){let n=t.length;for(let s=1;s