浏览代码

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

Cixo Develop 4 月之前
父节点
当前提交
cfdd960fe7
共有 11 个文件被更改,包括 735 次插入12 次删除
  1. 2 1
      package.json
  2. 230 0
      source/autotranslate.js
  3. 7 1
      source/core.js
  4. 13 0
      source/languages.js
  5. 19 0
      source/phrasebook.js
  6. 201 0
      source/preferences.js
  7. 223 0
      source/selector.js
  8. 2 1
      test/flat.json
  9. 6 8
      test/frontend.html
  10. 30 0
      test/frontend.js
  11. 2 1
      test/objects.json

+ 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!"
     }
 }