From bf4aa0289b671c6061da6d83d49b668f60b57f49 Mon Sep 17 00:00:00 2001 From: "Florine W. Dekker" Date: Sun, 20 Nov 2022 16:21:38 +0100 Subject: [PATCH] Rewrite `template.js` to TypeScript --- Gruntfile.js | 2 +- README.md | 3 +- package.json | 1 + src/main/js/Storage.ts | 9 +- src/main/js/Template.ts | 259 ++++++++++++++++++++++++++++++++++++++++ src/main/js/template.js | 228 ----------------------------------- src/test/index.html | 2 +- 7 files changed, 267 insertions(+), 237 deletions(-) create mode 100644 src/main/js/Template.ts delete mode 100644 src/main/js/template.js diff --git a/Gruntfile.js b/Gruntfile.js index 3aac6fe..a1f332f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -40,7 +40,7 @@ module.exports = grunt => { mode: "production", }, template: { - entry: "./src/main/js/template.js", + entry: "./src/main/js/Template.ts", module: { rules: [ { diff --git a/README.md b/README.md index 3b32b6a..8b3bd44 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ are used on nearly all pages anyway. The main functionality is provided in `template.js` and `template.css`. There also exist optional modules for easily reusing common code. -Modules should be loaded after `template.js` and `template.css`. +Modules can be used stand-alone. +If `template.js` is used, modules should be loaded after `template.js`. Currently, the only module is `storage.js` for interfacing with local storage. diff --git a/package.json b/package.json index ff24a65..7840445 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "browser": "template.js", "files": [ + "dist/storage.js", "dist/template.js", "dist/template.css" ], diff --git a/src/main/js/Storage.ts b/src/main/js/Storage.ts index 65121c0..6095907 100644 --- a/src/main/js/Storage.ts +++ b/src/main/js/Storage.ts @@ -178,9 +178,6 @@ export class MemoryStorage implements Storage { } -// @ts-ignore -if (typeof window.fwdekker === "undefined") - // @ts-ignore - window.fwdekker = {}; -// @ts-ignore -window.fwdekker.storage = {Storage, LocalStorage, MemoryStorage}; +// Export to `window` +(window as any).fwdekker = (window as any).fwdekker ?? {}; +(window as any).fwdekker.storage = {Storage, LocalStorage, MemoryStorage}; diff --git a/src/main/js/Template.ts b/src/main/js/Template.ts new file mode 100644 index 0000000..7f747ed --- /dev/null +++ b/src/main/js/Template.ts @@ -0,0 +1,259 @@ +/** + * Converts the given string to an HTML element. + * + * @param string the string to convert to an HTML element + * @param query the type of element to return + * @returns the HTML element described by the given string, or `null` if `query` did not match any element + */ +function stringToHtml(string: string, query: string): HTMLElement | null { + return (new DOMParser()).parseFromString(string, "text/html").body.querySelector(query); +} + +/** + * Alias for `root.querySelector(query)`. + * + * @param query the query string + * @param root the element to start searching in, or `undefined` if searching should start in `document` + * @returns the element identified by `query` in `root`, or `null` if that element could not be found + */ +function $(query: string, root?: HTMLElement): HTMLElement | null { + return root === undefined ? document.querySelector(query) : root.querySelector(query); +} + +/** + * Alias for `root.querySelectorAll(query)`. + * + * @param query the query string + * @param root the element to start searching in, or `undefined` if searching should start in `document` + * @returns the elements identified by `query` in `root` + */ +function $a(query: string, root?: HTMLElement): NodeListOf { + return root === undefined ? document.querySelectorAll(query) : root.querySelectorAll(query); +} + +/** + * Runs the given function once the page is loaded. + * + * This function can be used multiple times. It does not overwrite existing callbacks for the page load event. If the + * page has already loaded when this function is invoked, `fun` is invoked immediately inside this function. + * + * @param fun the function to run + */ +function doAfterLoad(fun: () => void): void { + if (document.readyState === "complete") { + fun(); + return; + } + + const oldOnLoad = onload || (() => { + }); + + onload = (() => { + // @ts-ignore Works fine + oldOnLoad(); + fun(); + }); +} + +/** + * Returns the `content` attribute of the `` tag identified by `name`. + * + * @param name the name of the meta tag of which to return the `content` + * @return the `content` attribute of the `` tag identified by `name`, or `null` if the meta tag has no content, + * or `undefined` if the meta tag does not exist + */ +function getMetaProperty(name: string): string | null | undefined { + const metaTag = $(`meta[name="${name}"]`); + return metaTag == null ? undefined : metaTag.getAttribute("content"); +} + + +/** + * Creates a navigation element for navigating through the website. + * + * Fetches entries asynchronously from the website's API. + * + * @param highlightPath the path to highlight together with its parents, or `undefined` if no element should be + * highlighted + * @param cb the callback to execute on the fetched entries, to prevent the need to re-fetch elsewhere, or `undefined` + * if no callback should be invoked + * @returns a base navigation element that will eventually be filled with contents + */ +function nav(highlightPath?: string, cb?: (json: any) => void): HTMLElement { + const base = stringToHtml( + ``, + "ul" + )!; + + fetch("https://fwdekker.com/api/nav/") + .then(it => it.json()) + .then(json => { + if (cb !== undefined) + cb(json); + + json.entries.forEach( + (entry: any) => + base.appendChild(stringToHtml(unpackEntry(entry, "/", highlightPath), "li")!) + ); + }) + .catch(error => console.error("Failed to fetch navigation elements", error)); + + const nav = stringToHtml( + ``, + "nav" + )!; + nav.appendChild(base); + return nav; +} + +/** + * Unpacks a navigation entry returned from the navigation API into an HTML element. + * + * @param entry the entry to unpack + * @param path the current path traversed, found by joining the names of the entries with `/`s; always starts and ends + * with a `/` + * @param highlightPath the path to highlight together with its parents, or `undefined` if no path should be highlighted + * @returns the navigation list entry as HTML, described by its children + */ +function unpackEntry(entry: any, path: string = "/", highlightPath?: string): string { + const shouldHighlight = highlightPath?.startsWith(`${path + entry.name}/`) ?? false; + const isExternalLink = !(/^https:\/\/.*fwdekker.com/i.test(entry.link)) && entry.link !== "#"; + + if (entry.entries.length === 0) + return "" + + `
  • ` + + `${entry.name}` + + `
  • `; + + const depth = path.split("/").length - 2; // -1 because count parts, then another -1 because of leading `/` + const arrow = depth === 0 ? "▾" : "▸"; + + return "" + + `
  • ` + + `${entry.name} ${arrow}` + + `
      ${entry.entries.map((it: any) => unpackEntry(it, `${path + entry.name}/`, highlightPath)).join("")}
    ` + + `
  • `; +} + +/** + * Creates a footer element with the given data. + * + * Setting an argument to `undefined` or not giving that argument will cause the default value to be used. Setting an + * argument to `null` will result in a footer without the corresponding element. + * + * @param author the author + * @param authorURL the URL to link the author's name to + * @param license the type of license + * @param licenseURL the URL to the license file + * @param vcs the type of version control + * @param vcsURL the URL to the repository + * @param version the page version + * @param privacyPolicyURL the URL to the privacy policy + * @returns a footer element + */ +function footer( + { + author = undefined, + authorURL = undefined, + license = undefined, + licenseURL = undefined, + vcs = undefined, + vcsURL = undefined, + version = undefined, + privacyPolicyURL = undefined + }: { + author: string | null | undefined, + authorURL: string | null | undefined, + license: string | null | undefined, + licenseURL: string | null | undefined, + vcs: string | null | undefined, + vcsURL: string | null | undefined, + version: string | null | undefined, + privacyPolicyURL: string | null | undefined + } +): HTMLElement { + if (author === undefined) author = "Florine W. Dekker"; + if (authorURL === undefined) authorURL = "https://fwdekker.com/"; + if (license === undefined) license = "MIT"; + if (licenseURL === undefined && vcsURL !== undefined) licenseURL = `${vcsURL}src/branch/master/LICENSE`; + if (vcs === undefined && vcsURL !== undefined) vcs = "git"; + if (privacyPolicyURL === undefined) privacyPolicyURL = "https://fwdekker.com/privacy/"; + + return stringToHtml( + `

    ` + + footerLink("Made by ", author, authorURL, ". ") + + footerLink("Licensed ", license, licenseURL, ". ") + + footerLink("Source and support on ", vcs, vcsURL, ". ") + + footerLink("Read the ", privacyPolicyURL && "privacy policy", privacyPolicyURL, ". ") + + `
    `, + "footer" + )!; +} + +/** + * Constructs a link that is used in footers. + * + * @param prefix the text to display before the text if the text is not `null` or `undefined` + * @param text the text to display, or `null` or `undefined` if the returned element should be the empty string + * @param url the URL to link the text to, or `null` or `undefined` if the text should not be a link + * @param suffix the text to display after the text if the text is not `null` or `undefined` + * @returns a footer link element + */ +function footerLink(prefix: string, text: string | null | undefined, url: string | null | undefined, + suffix: string): string { + if (text == null) return ""; + + return `${prefix}${url != null ? `${text}` : text}${suffix}`; +} + +/** + * Runs the functions `nav` and `footer` after the page has loaded using properties defined in the meta tags. + * + * Meta tags are read as interpreted as `` from the + * document's head. Given a `function` of `nav` or `footer`, if the `value` of `fwd::target` is the ID of an + * element in the page, that element is replaced by the output of `function`. The `function` is invoked with parameters + * also read from meta elements, where each `property` is the same as the name of the parameter of that function, except + * that instead of camelcase words are separated by dashes. For example, `vcsURL` becomes `vcs-url`. + * + * If a meta tag is missing, its value is considered `undefined`. If a meta tag exists but has no `content` attribute, + * its value is considered `null`. Otherwise, the value is the string contents of the `content` attribute. If `null` is + * not a valid value as a parameter for that function, its value is considered `undefined`. + */ +doAfterLoad(() => { + const navTargetQuery = getMetaProperty("fwd:nav:target"); + if (navTargetQuery != null) { + const navTarget = $(navTargetQuery); + navTarget?.parentElement?.replaceChild( + nav(getMetaProperty("fwd:nav:highlight-path") ?? undefined), + navTarget + ); + } + + const footerTargetQuery = getMetaProperty("fwd:footer:target"); + if (footerTargetQuery != null) { + const footerTarget = $(footerTargetQuery); + footerTarget?.parentElement?.replaceChild( + footer({ + author: getMetaProperty("fwd:footer:author"), + authorURL: getMetaProperty("fwd:footer:author-url"), + license: getMetaProperty("fwd:footer:license"), + licenseURL: getMetaProperty("fwd:footer:license-url"), + vcs: getMetaProperty("fwd:footer:vcs"), + vcsURL: getMetaProperty("fwd:footer:vcs-url"), + version: getMetaProperty("fwd:footer:version"), + privacyPolicyURL: getMetaProperty("fwd:footer:privacy-policy-url"), + }), + footerTarget + ); + } +}); + + +// Export to `window` +(window as any).fwdekker = {stringToHtml, $, $a, doAfterLoad}; diff --git a/src/main/js/template.js b/src/main/js/template.js deleted file mode 100644 index 96894cd..0000000 --- a/src/main/js/template.js +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Converts the given string to an HTML element. - * - * @param string the string to convert to an HTML element - * @param query the type of element to return - * @returns {HTMLElement} the HTML element described by the given string - */ -const stringToHtml = function(string, query) { - return (new DOMParser()).parseFromString(string, "text/html").body.querySelector(query); -} - -/** - * Alias for `root.querySelector(query)`. - * - * @param query {string} the query string - * @param root {HTMLElement} the element to start searching in, or `undefined` if searching should start in `document` - * @returns {HTMLElement} the element identified by `query` in `root` - */ -const $ = (query, root) => root === undefined ? document.querySelector(query) : root.querySelector(query); - -/** - * Alias for `root.querySelectorAll(query)`. - * - * @param query {string} the query string - * @param root {HTMLElement} the element to start searching in, or `undefined` if searching should start in `document` - * @returns {NodeListOf} the elements identified by `query` in `root` - */ -const $a = (query, root) => root === undefined ? document.querySelectorAll(query) : root.querySelectorAll(query); - -/** - * Runs the given function once the page is loaded. - * - * This function can be used multiple times. It does not overwrite existing callbacks for the page load event. If the - * page has already loaded when this function is invoked, `fun` is invoked immediately inside this function. - * - * @param fun {function(...*): *} the function to run - */ -const doAfterLoad = function(fun) { - if (document.readyState === "complete") { - fun(); - return; - } - - const oldOnLoad = onload || (() => { - }); - - onload = (() => { - oldOnLoad(); - fun(); - }); -}; - - -/** - * Creates a navigation element for navigating through the website. - * - * Fetches entries asynchronously from the website's API. - * - * @param [highlightPath] {String} the path to highlight together with its parents - * @param [cb] {Function} the callback to execute on the fetched entries, to prevent the need to re-fetch elsewhere - * @returns {HTMLElement} a base navigation element that will eventually be filled with contents - */ -const nav = function(highlightPath = "", cb = undefined) { - const base = stringToHtml( - ``, - "ul" - ); - - fetch("https://fwdekker.com/api/nav/") - .then(it => it.json()) - .then(json => { - if (cb !== undefined) cb(json); - - json.entries.forEach(entry => base.appendChild(stringToHtml(unpackEntry(entry, "/", highlightPath), "li"))); - }) - .catch(e => { - console.error("Failed to fetch navigation elements", e); - return []; - }); - - const nav = stringToHtml( - ``, - "nav" - ); - nav.appendChild(base); - return nav; -}; - -/** - * Unpacks a navigation entry returned from the navigation API into an HTML element. - * - * @param entry {Object} the entry to unpack - * @param [path] {number} the current path traversed, found by joining the names of the entries with `/`s; always starts - * and ends with a `/` - * @param [highlightPath] {String} the path to highlight together with its parents - * @returns {string} the navigation list entry as HTML, described by its children - */ -const unpackEntry = function(entry, path = "/", highlightPath = "") { - const shouldHighlight = highlightPath.startsWith(`${path + entry.name}/`); - const isExternalLink = !(/^https:\/\/.*fwdekker.com/i.test(entry.link)) && entry.link !== "#"; - - if (entry.entries.length === 0) - return "" + - `
  • ` + - `${entry.name}` + - `
  • `; - - const depth = path.split("/").length - 2; // -1 because count parts, then another -1 because of leading `/` - const arrow = depth === 0 ? "▾" : "▸"; - - return "" + - `
  • ` + - `${entry.name} ${arrow}` + - `
      ${entry.entries.map(it => unpackEntry(it, `${path + entry.name}/`, highlightPath)).join("")}
    ` + - `
  • `; -}; - - -/** - * Creates a footer element with the given data. - * - * Setting an argument to `undefined` or not giving that argument will cause the default value to be used. Setting an - * argument to `null` will result in a footer without the corresponding element. - * - * @param [author] {string|null|undefined} the author - * @param [authorURL] {string|null|undefined} the URL to link the author's name to - * @param [license] {string|null|undefined} the type of license - * @param [licenseURL] {string|null|undefined} the URL to the license file - * @param [vcs] {string|null|undefined} the type of version control - * @param [vcsURL] {string|null|undefined} the URL to the repository - * @param [version] {string|null|undefined} the page version - * @param [privacyPolicyURL] {string|null|undefined} the URL to the privacy policy - * @returns {HTMLElement} a footer element - */ -const footer = function( - { - author = undefined, - authorURL = undefined, - license = undefined, - licenseURL = undefined, - vcs = undefined, - vcsURL = undefined, - version = undefined, - privacyPolicyURL = undefined - }) { - if (author === undefined) author = "Florine W. Dekker"; - if (authorURL === undefined) authorURL = "https://fwdekker.com/"; - if (license === undefined) license = "MIT"; - if (licenseURL === undefined && vcsURL !== undefined) licenseURL = `${vcsURL}src/branch/master/LICENSE`; - if (vcs === undefined && vcsURL !== undefined) vcs = "git"; - if (privacyPolicyURL === undefined) privacyPolicyURL = "https://fwdekker.com/privacy/"; - - return stringToHtml( - `

    ` + - footerLink("Made by ", author, authorURL, ". ") + - footerLink("Licensed ", license, licenseURL, ". ") + - footerLink("Source and support on ", vcs, vcsURL, ". ") + - footerLink("Read the ", privacyPolicyURL && "privacy policy", privacyPolicyURL, ". ") + - `
    `, - "footer"); -}; - -/** - * Constructs a link that is used in footers. - * - * @param prefix {string} the text to display before the text if the text is not undefined - * @param text {string|null} the text to display, or `null` if the returned element should be empty - * @param url {string|null} the URL to link the text to, or `null` if the text should not be a link - * @param suffix {string} the text to display after the text if the text is not undefined - * @returns {string} a footer link element - */ -const footerLink = function(prefix, text, url, suffix) { - if (text === null) return ""; - - return `${prefix}${url !== null ? `${text}` : text}${suffix}`; -}; - - -/** - * Runs the functions `nav` and `footer` after the page has loaded using properties defined in the meta tags. - * - * Meta tags are read as interpreted as `` from the - * document's head. Given a `function` of `nav` or `footer`, if the `value` of `fwd::target` is the ID of an - * element in the page, that element is replaced by the output of `function`. The `function` is invoked with parameters - * also read from meta elements, where each `property` is the same as the name of the parameter of that function, except - * that instead of camelcase words are separated by dashes. For example, `vcsURL` becomes `vcs-url`. - */ -doAfterLoad(() => { - const getMetaProperty = (name) => { - const element = $(`meta[name="${name}"]`); - return element === null ? undefined : element.getAttribute("content"); - }; - - const navTarget = $(getMetaProperty("fwd:nav:target")); - if (navTarget !== null) { - navTarget.parentElement.replaceChild( - nav(getMetaProperty("fwd:nav:highlight-path")), - navTarget - ); - } - - const footerTarget = $(getMetaProperty("fwd:footer:target")); - if (footerTarget !== null) { - footerTarget.parentElement.replaceChild( - footer({ - author: getMetaProperty("fwd:footer:author"), - authorURL: getMetaProperty("fwd:footer:author-url"), - license: getMetaProperty("fwd:footer:license"), - licenseURL: getMetaProperty("fwd:footer:license-url"), - vcs: getMetaProperty("fwd:footer:vcs"), - vcsURL: getMetaProperty("fwd:footer:vcs-url"), - version: getMetaProperty("fwd:footer:version"), - privacyPolicyURL: getMetaProperty("fwd:footer:privacy-policy-url"), - }), - footerTarget - ); - } -}); - - -// Export to namespace -window.fwdekker = {stringToHtml, $, $a, doAfterLoad}; diff --git a/src/test/index.html b/src/test/index.html index c5e24fd..1d48677 100644 --- a/src/test/index.html +++ b/src/test/index.html @@ -92,7 +92,7 @@