/** * 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 */ export 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, or `undefined` or `null` if `null` should be returned * @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 */ export function $(query: string | null | undefined, root?: HTMLElement): HTMLElement | null { if (query == null) return 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` */ export 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 */ export 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 */ export 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}` + `` + `
  • `; } /** * 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( ``, "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 navTarget = $(getMetaProperty("fwd:nav:target")); if (navTarget != null) { navTarget.parentElement?.replaceChild( nav(getMetaProperty("fwd:nav:highlight-path") ?? undefined), 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 ); } });