Эх сурвалжийг харах

Add frontend modules, which could help in managment of the frontend of the website, for example autotranslating content, or languages chooser.

Cixo Develop 11 сар өмнө
parent
commit
cfdd960fe7

+ 2 - 1
package.json

@@ -4,7 +4,8 @@
   "description": "This is simple library for translating JS app.",
   "main": "source/core.js",
   "scripts": {
-    "test": "node test/backend.js",
+    "test-node": "node test/backend.js",
+    "test-browser": "esbuild source/core.js --drop-labels=NODE --bundle --outfile=test/browser_bundle.js --global-name=cx_libtranslate --watch --serve=9000 --servedir=test",
     "build": "npm run build-min && npm run build-full && npm run build-debug",
     "build-min": "esbuild source/core.js --drop-labels=NODE,DEBUG --bundle --outfile=dist/cx-libtranslate.min.js --minify --sourcemap --global-name=cx_libtranslate",
     "build-full": "esbuild source/core.js --drop-labels=NODE,DEBUG --bundle --outfile=dist/cx-libtranslate.full.js --sourcemap --global-name=cx_libtranslate",

+ 230 - 0
source/autotranslate.js

@@ -0,0 +1,230 @@
+const { phrasebook } = require("./phrasebook");
+
+/**
+ * This class is responsible for automatic translate site content of the 
+ * HTML elements. It could automatic translate content for all elements 
+ * which are in the "translate" class, and has "phrase" attribute. This 
+ * attrobite store phrase to translate, and insert into element innerText
+ * or placeholder (for inputs). It could also observate HTML Node, and
+ * automatic update transltions when any item had been changed.
+ */
+class autotranslate {
+    /**
+     * @var {?phrasebook}
+     * This store phrasebook to get translates from, or store null, to get
+     * translate content from global translate function.
+     */
+    #phrasebook;
+
+    /**
+     * @var {?MutationObserver}
+     * This store observer object, when it is connecter and waiting for 
+     * changaes, or store null, when observer currently not working.
+     */
+    #observer;
+
+    /**
+     * This create new autotranslator. It require phrasebook, to loads 
+     * translations for phrases from. When null had been given, the it 
+     * use global translate function.
+     * 
+     * @throws {Error} - When trying to use it in the NodeJS.
+     * 
+     * @param {?phrasebook} phrasebook 
+     */
+    constructor(phrasebook = null) {
+        NODE: throw new Error("It is not avairable in the NodeJS.");
+        this.#observer = null;
+        this.#phrasebook = phrasebook;
+    }
+
+    /**
+     * It return class name for elements, which would be translated by 
+     * autotranslator.
+     * 
+     * @returns {string} - Class name for autotranslating elements.
+     */
+    static get_class_name() {
+        return "translate";   
+    }
+
+    /**
+     * This return selector for choose elements which must be autotranslated.
+     * 
+     * @returns {string} - Selector of the elements to translate.
+     */
+    get #class_selector() {
+        return "." + autotranslate.get_class_name();
+    }
+
+    /**
+     * This return name of attribute which store phrase to translate.
+     * 
+     * @returns {string} - Name of attribute which store phrase.
+     */
+    static get_attribute_name() {
+        return "phrase";
+    }
+
+    /**
+     * This check that autotranslator is connected and waiting to changes.
+     * 
+     * @returns {bool} - True when observer is connected, fakse when not.
+     */
+    get is_connected() {
+        return this.#observer !== null;
+    }
+
+    /**
+     * This search elements which could be translated in the element given
+     * in the parameter. When null given, then it search elements in the 
+     * all document.
+     * 
+     * @param {?HTMLElement} where - Item to load items from or null.
+     * @returns {Array} - Array of elements to translate.
+     */
+    #get_all_items(where = null) {
+        if (where === null) {
+            where = document;
+        }
+
+        return Array.from(
+            where.querySelectorAll(this.#class_selector)
+        );
+    }
+
+    /**
+     * It translate given phrase, baseed on loaded phrasebook, or when not
+     * loaded any, then use global translate function. When it also not 
+     * exists, then throws error in debug mode, or return not translated
+     * phrase on production.
+     * 
+     * @throws {Error} - When any option to translate not exists.
+     * 
+     * @param {string} content - Phrase to translate.
+     * @returns {string} - Translated content.
+     */
+    #translate(content) {
+        if (this.#phrasebook !== null) {
+            return this.#phrasebook.translate(content);
+        }
+
+        if (_ === undefined) {
+            DEBUG: throw new Error("All translate options are unavairable.");
+            return content;
+        }
+
+        return _(content);
+    }
+
+    /**
+     * This add mutable observer to the body. It wait for DOM modifications,
+     * and when any new node had been adder, or any phrase attribute had 
+     * been changed, then it trying to translate it.
+     * 
+     * @returns {autotranslate} - This object to chain load.
+     */
+    connect() {
+        if (this.is_connected) {
+            return this;
+        }
+
+        const body = document.querySelector("body");
+        const callback = (targets) => { this.#process(targets); };
+        const options = {
+            childList: true,
+            attributes: true,
+            characterData: false,
+            subtree: true,
+            attributeFilter: [ autotranslate.get_attribute_name() ],
+            attributeOldValue: false,
+            characterDataOldValue: false
+        };
+
+        this.#observer = new MutationObserver(callback);
+        this.#observer.observe(body, options);
+
+        return this;
+    }
+
+    /**
+     * This prcoess all given in the array mutable records. 
+     * 
+     * @param {Array} targets - Array with mutable records. 
+     */
+    #process(targets) {
+        targets.forEach(count => {
+            if (count.type === "attributes") {
+                this.#update_single(count.target);
+                return;
+            }
+
+            this.#get_all_items(count.target).forEach(count => {
+                this.#update_single(count);
+            });
+        });
+    }
+
+    /**
+     * This disconnect observer, and remove it.
+     * 
+     * @returns {autotranslate} - This object to chain loading.
+     */
+    disconnect() {
+        if (!this.is_connected) {
+            return this;
+        }
+
+        this.#observer.disconnect();
+        this.#observer = null;
+        return this;
+    }
+
+    /**
+     * This update single element, based on phrase attribute. When element 
+     * is standard HTMLElement, then it place translated content into 
+     * innerText, but when element is input, like HTMLInputElement or
+     * HTMLTextAreaElement, then it place result into placeholder. When
+     * input is button, or submit, then it put content into value.
+     * 
+     * @param {HTMLElement} target - Element to translate 
+     */
+    #update_single(target) {
+        const attrobute_name = autotranslate.get_attribute_name();
+        const phrase = target.getAttribute(attrobute_name);
+        const result = this.#translate(phrase);
+
+        if (target instanceof HTMLInputElement) {
+            if (target.type === "button" || target.type === "submit") {
+                target.value = result;
+                return;
+            }
+            
+            target.placeholder = result;
+            return;
+        }
+
+        if (target instanceof HTMLTextAreaElement) {
+            target.placeholder = result;
+            return;
+        }
+
+        target.innerText = result
+    }
+
+    /**
+     * This update translation of all elements in the document. It is useable
+     * when new autotranslator is created. 
+     * 
+     * @returns {autotranslate} - Instance of object to chain loading.
+     */
+    update() {
+        this.#get_all_items().forEach(count => {
+            this.#update_single(count);
+        });
+
+        return this;
+    }
+}
+
+exports.autotranslate = autotranslate;

+ 7 - 1
source/core.js

@@ -4,12 +4,18 @@ if (typeof(module) !== "undefined" && module.exports) {
     module.exports.loader = require("./loader.js").loader;
     module.exports.languages = require("./languages.js").languages;
     module.exports.loader = require("./loader.js").loader;
+    module.exports.preferences = require("./preferences.js").preferences;
+    module.exports.selector = require("./selector.js").selector;
+    module.exports.autotranslate = require("./autotranslate.js").autotranslate;
 } else {
     /* Load for web browser */
     window.cx_libtranslate = {
         phrasebook: require("./phrasebook.js").phrasebook,
         loader: require("./loader.js").loader,
         languages: require("./languages.js").languages,
-        translation: require("./translation.js").translation
+        translation: require("./translation.js").translation,
+        preferences: require("./preferences.js").preferences,
+        selector: require("./selector.js").selector,
+        autotranslate: require("./autotranslate.js").autotranslate
     };
 }

+ 13 - 0
source/languages.js

@@ -189,6 +189,19 @@ class languages {
         return alls;
     }
 
+    /**
+     * @returns {string} - Default 
+     */
+    get default() {
+        const avairable = this.avairable;
+
+        if (avairable.length === 0) {
+            throw new Error("Languages list is empty. Can not load default.");
+        }
+
+        return avairable[0];
+    }
+
     /**
      * This load phrasebook with give name.
      * 

+ 19 - 0
source/phrasebook.js

@@ -118,6 +118,25 @@ class phrasebook {
         return new translation(current, true);
     }
 
+    /**
+     * This set phrasebook as default. That mean, it would add own translate
+     * function to global scope, and create _({string}) function in the global
+     * scope. It is useable to minify code with translations. It is not 
+     * avairable in the NodeJS, only in the browser.
+     * 
+     * @returns {phrasebook} - Object itself to chain loading.
+     */
+    set_as_default() {
+        NODE: throw new Error("Could not create global function in NodeJS.");
+        
+        const translate = (content) => {
+            return this.translate(content);
+        };
+
+        window._ = translate;
+        window.translate = translate;
+    }
+
     /**
      * This translate flat phrase.
      * 

+ 201 - 0
source/preferences.js

@@ -0,0 +1,201 @@
+
+const languages = require("./languages.js").languages;
+const phrasebook = require("./phrasebook.js").phrasebook;
+const selector = require("./selector.js").selector;
+const autotranslate = require("./autotranslate.js").autotranslate;
+
+/**
+ * This class is responsible for saving and loading user language preferences
+ * in the browser localStorage. It is not avairable, when using library in
+ * the NodeJS enviroment.
+ */
+class preferences {
+    /**
+     * @var {languages}
+     * This store loaded languages object.
+     */
+    #languages;
+
+    /**
+     * @var {?selector}
+     * This store selector for preferences.
+     */
+    #selector;
+
+    /**
+     * @var {?autotranslate}
+     * This store autotranslator, or null.
+     */
+    #autotranslate;
+
+    /**
+     * This create new language preferences manager from loaded languages
+     * object.
+     *
+     * @throws {Error} - When trying to use it in NodeJS.
+     * 
+     * @param {languages} target - loaded languages object.
+     */
+    constructor(target) {
+        NODE: throw new Error("It could be used only in browser.");
+        this.#selector = null;
+        this.#autotranslate = null;
+        this.#languages = target;
+    }
+
+    /**
+     * This return name of language key in localStorage.
+     * 
+     * @returns {string} - Name of key in localStorage.
+     */
+    get #setting_name() {
+        return "cx_libtranslate_lang";
+    }
+
+    /**
+     * This return current saved language name, or null if any language
+     * is not selected yet.
+     * 
+     * @returns {?string} - Saved language or null.
+     */
+    get #state() {
+        return localStorage.getItem(this.#setting_name);
+    }
+
+    /**
+     * This return selector, which could being used in the ui.
+     * 
+     * @returns {selector} - New UI selector of the languages.
+     */
+    get selector() {
+        if (this.#selector !== null) {
+            return this.#selector;
+        }
+
+        this.#selector = new selector(this.#languages)
+        .set_selection(this.current)
+        .add_listener(target => {
+            this.update(target); 
+        })
+        .add_listener(async target => { 
+            if (this.#autotranslate === null) {
+                return;
+            }
+
+            this.#reload_autotranslate();
+        });
+
+        return this.#selector;
+    }
+
+    async #reload_autotranslate() {
+        const connected = this.#autotranslate.is_connected;
+
+        if (connected) {
+            this.#autotranslate.disconnect();
+        }
+
+        this.#autotranslate = null;
+        const created = await this.get_autotranslate();
+        
+        if (connected) {
+            created.connect();
+        }
+    }
+
+    /**
+     * This load phrasebook for selected language, create autotranslate
+     * for it, and returns it. Autotranslate is cached in this object.
+     * 
+     * @returns {autotranslate} - Autotranslate for phrasebook.
+     */
+    async get_autotranslate() {
+        if (this.#autotranslate !== null) {
+            return this.#autotranslate;
+        }
+
+        const phrasebook = await this.load_choosen_phrasebook();
+        this.#autotranslate = new autotranslate(phrasebook);
+        this.#autotranslate.update();
+
+        return this.#autotranslate;
+    }
+
+    /**
+     * This save new selected language name to localStorage, or remove it 
+     * from there if null given.
+     * 
+     * @param {?string} content - Name of selected language or null.
+     */
+    set #state(content) {
+        if (content === null) {
+            localStorage.removeItem(this.#setting_name);
+            return;
+        }
+
+        localStorage.setItem(this.#setting_name, content);
+    }
+
+    /**
+     * This return current language from localStorage. When any language is
+     * not loaded yet, then return default language. It also check that value
+     * value from localStorage, and if it not avairable in languages storage,
+     * then also return default language.
+     * 
+     * @return {string} - Name of the current language.
+     */
+    get current() {
+        const saved = this.#state;
+
+        if (saved === null) {
+            return this.#languages.default;
+        }
+
+        if (this.#languages.has(saved)) {
+            return saved;
+        }
+
+        return this.#languages.default;
+    }
+
+    /**
+     * This load phrasebook for current selected language.
+     * 
+     * @returns {phrasebook} - Phrasebook for current language.
+     */
+    async load_choosen_phrasebook() {
+        return await this.#languages.select(this.current);
+    }
+
+    /**
+     * This return loaded languages container.
+     * 
+     * @returns {languages} - Languages container.
+     */
+    get languages() {
+        return this.#languages;
+    }
+
+    /**
+     * This set new language for user. It also check that language exists 
+     * in the language container. When not exists, throws error.
+     * 
+     * @throws {Error} - When language not exists in container.
+     * 
+     * @param {string} name - New language to select.
+     * @returns {preferences} - This object to chain.
+     */
+    update(name) {
+        DEBUG: if (!this.#languages.has(name)) {
+            let error = "Can not set language \"" + name + "\" ";
+            error += "because not exists in languages container.";
+
+            throw new Error(error);
+        }
+
+        this.#state = name;
+        return this;
+    }
+}
+
+exports.preferences = preferences;

+ 223 - 0
source/selector.js

@@ -0,0 +1,223 @@
+const languages = require("./languages.js").languages;
+
+/**
+ * This could be used to setup language selector. This require languages
+ * container, and could create functional languages selector from it. This 
+ * add options to select all avaoidable languages in the container.
+ */
+class selector {
+    /**
+     * @var {languages}
+     * This store languages container.
+     */
+    #languages;
+
+    /**
+     * @var {?HTMLElement}
+     * This store selector HTML container, or null when not created.
+     */
+    #container;
+
+    /**
+     * @var {HTMLElement}
+     * This store languages HTML selector object.
+     */
+    #selector;
+
+    /**
+     * @var {Array}
+     * This is array of callbacks which must being called after change.
+     */
+    #callbacks;
+
+    /**
+     * This create new selector object, from languages container.
+     * 
+     * @param {languages} languages - Languages container to work on.
+     */
+    constructor(languages) {
+        NODE: throw new Error("This module could not beind used in NodeJS.");
+        
+        this.#container = null;
+        this.#languages = languages;
+        this.#callbacks = new Array();
+        this.#selector = this.#create_selector();
+    }
+
+    /**
+     * This check that selector is currently inserted anywhere.
+     * 
+     * @returns {bool} - True when selector is inserted anywhere.
+     */
+    get is_inserted() {
+        return this.#container !== null;
+    }
+
+    /**
+     * This inserts selector into given HTML element, or directly into
+     * document body, when any element is not specified. It returns 
+     * itselt, to call more functions inline.
+     * 
+     * @param {?HTMLElement} where - Place to insert selector, or null.
+     * @returns {selector} - This object to chain loading.
+     */
+    insert(where = null) {
+        if (this.is_inserted) {
+            return this;
+        }
+
+        if (where === null) {
+            where = document.querySelector("body");
+        }
+
+        this.#container = this.#create_container();
+        where.appendChild(this.#container);
+
+        return this;
+    }
+
+    /**
+     * This run all functions which was registered as onChange callback.
+     */
+    #on_change() {
+        this.#callbacks.forEach(count => {
+            count(this.current);
+        });
+    }
+
+    /**
+     * This function remove selector from HTML element, if it is already
+     * inserted anywhere.
+     * 
+     * @returns {selector} - This object to chain loading.
+     */
+    remove() {
+        if (!this.is_inserted) {
+            return this;
+        }
+
+        this.#container.remove();
+        this.#container = null;
+
+        return this;
+    }
+
+    /**
+     * This create new container with selector inside.
+     * 
+     * @returns {HTMLElement} - New container with selector.
+     */
+    #create_container() {
+        const container = document.createElement("div");
+        
+        container.classList.add(this.class_name);
+        container.appendChild(this.#selector);
+        
+        return container;
+    }
+
+    /**
+     * This add new callback to selector. All callbacks would be called
+     * after language change. Callback would get one parameter, which is
+     * name of the location.
+     * 
+     * @param {CallableFunction} callback - Function to call on change.
+     * @returns 
+     */
+    add_listener(callback) {
+        this.#callbacks.push(callback);
+        return this;
+    }
+
+    /**
+     * This return HTML class name of selector container.
+     * 
+     * @returns {string} - HTML class name of selector container.
+     */
+    get class_name() {
+        return 'cx-libtranslate-language-selector';
+    }
+
+    /**
+     * This create HTML option element from language name.
+     * 
+     * @param {string} location - Name of single language. 
+     * @returns {HTMLElement} - New option element.
+     */
+    #create_option(location) {
+        const name = location.split("_").pop();
+        const option = document.createElement("option");
+
+        option.innerText = name;
+        option.value = location;
+
+        return option;
+    }
+
+    /**
+     * This return current selected language name.
+     * 
+     * @returns {string} - Current selected language.
+     */
+    get current() {
+        return this.#selector.value;
+    }
+
+    /**
+     * This set current selected language for the selector.
+     * 
+     * @param {string} name - Name of the language to set.
+     * @returns {selector} - This to chain loading.
+     */
+    set_selection(name) {
+        if (!this.#languages.has(name)) {
+            DEBUG: throw new Error(
+                "Selector has not \"" + name + "\" " +
+                "language in the container."
+            );
+        }
+
+        this.#selector.value = name;
+        return this;
+    }
+
+    /**
+     * This reload languages list in the selector. It could be used
+     * after change in the languages container.
+     * 
+     * @returns {selector} - Itself to chain loading.
+     */
+    reload() {
+        while (this.#selector.lastChild) {
+            this.#selector.lastChild.remove();
+        }
+
+        this.#languages.avairable.forEach(count => {
+            this.#selector.appendChild(this.#create_option(count));
+        });
+
+        return this;
+    }
+
+    /**
+     * This create new HTML selector object, witch all languages
+     * from container inserted as options.
+     * 
+     * @returns {HTMLElement} - New selector object.
+     */
+    #create_selector() {
+        const selector = document.createElement("select");
+
+        selector.addEventListener("change", () => {
+            this.#on_change();
+        });
+
+        this.#languages.avairable.forEach(count => {
+            selector.appendChild(this.#create_option(count));
+        });        
+
+        return selector;
+    }
+}
+
+exports.selector = selector;

+ 2 - 1
test/flat.json

@@ -3,5 +3,6 @@
     "this is simple phrase.": "To jest prosta fraza.",
     "other phrase": "Inna fraza.",
     "And much more...": "I o wiele więcej...",
-    "test a": "Test A"
+    "test a": "Test A",
+    "this_is_simple": "To jest proste!"
 }

+ 6 - 8
test/frontend.html

@@ -3,13 +3,11 @@
 <html lang="en">
     <head>
         <meta charset="UTF-8">
-        <script type="text/javascript" src="../dist/cx-libtranslate.min.js"></script>
-        <script type="text/javascript">
-            const dict = new Map();
-            dict.set("sample", "przykład");
-
-            const book = new cx_libtranslate.phrasebook(dict);
-            console.log(book.get("sample"));
-        </script>
+        <script type="text/javascript" src="browser_bundle.js"></script>
+        <script type="text/javascript" src="frontend.js"></script>
     </head>
+
+    <body>
+        <p class="translate" phrase="this_is_simple"></p>
+    </body>
 </html>

+ 30 - 0
test/frontend.js

@@ -0,0 +1,30 @@
+document.addEventListener("DOMContentLoaded", async () => {
+    const langs = await new cx_libtranslate
+    .languages("/")
+    .load("index.json");
+
+    console.log("Languages container:");
+    console.log(langs);
+
+    const preferences = new cx_libtranslate.preferences(langs);
+ 
+    console.log("Preferences: ");
+    console.log(preferences);
+   
+    const selector = preferences.selector.insert().add_listener(name => {
+        alert(name);   
+    });
+
+    console.log("Selector:");
+    console.log(selector);
+
+    console.log("Seting phrasebook as default.");
+
+    const phrasebook = await preferences.load_choosen_phrasebook();
+    phrasebook.set_as_default();
+
+    console.log("Loading autotranslator.");
+    
+    const autotranslate = await preferences.get_autotranslate()
+    autotranslate.connect();
+});

+ 2 - 1
test/objects.json

@@ -6,6 +6,7 @@
         }
     },
     "phrases": {
-        "test a": "Test A"
+        "test a": "Test A",
+        "this_is_simple": "This is simple!"
     }
 }