280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
|
* 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, or `undefined` to return the first element
|
|
* @returns the HTML element described by the given string
|
|
*/
|
|
export function stringToHtml(string: string, query: string = "*"): HTMLElement {
|
|
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<Element> {
|
|
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 `<meta>` tag identified by `name`.
|
|
*
|
|
* @param name the name of the meta tag of which to return the `content`
|
|
* @return the `content` attribute of the `<meta>` 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 nav = stringToHtml(`<nav class="fwd-nav"></nav>`);
|
|
|
|
const checkbox = stringToHtml(`<input id="fwd-nav-hamburger-checkbox" type="checkbox" hidden />`);
|
|
nav.appendChild(checkbox);
|
|
|
|
const base = stringToHtml(
|
|
`<ul><li ${highlightPath === "/" ? `class="fwd-nav-highlighted" aria-current="page"` : ""}>` +
|
|
`<a id="logo" href="https://fwdekker.com/">FWDekker</a>` +
|
|
`</li></ul>`
|
|
);
|
|
fetch("https://fwdekker.com/api/nav/")
|
|
.then(it => it.json())
|
|
.then(json => {
|
|
if (cb !== undefined)
|
|
cb(json);
|
|
|
|
json.entries.forEach((it: any) => base.appendChild(unpackEntry(it, "/", highlightPath)));
|
|
|
|
document.body.addEventListener(
|
|
"click",
|
|
() => $a("li", base).forEach(it => it.classList.remove("fwd-nav-active")),
|
|
{capture: true}
|
|
);
|
|
})
|
|
.catch(error => console.error("Failed to fetch navigation elements", error));
|
|
nav.appendChild(base);
|
|
|
|
const label = stringToHtml(`<label id="fwd-nav-hamburger-label" for="fwd-nav-hamburger-checkbox">☰</label>`);
|
|
nav.appendChild(label);
|
|
|
|
return nav;
|
|
}
|
|
|
|
/**
|
|
* Unpacks a navigation entry returned from the navigation API into an HTML element.
|
|
*
|
|
* @param entry the entry to unpack
|
|
* @param parentPath 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
|
|
*/
|
|
function unpackEntry(entry: any, parentPath: string = "/", highlightPath?: string): HTMLLIElement {
|
|
const path = `${parentPath + entry.name}/`;
|
|
const hasChildren = entry.entries.length !== 0;
|
|
|
|
const li = document.createElement("li");
|
|
if (highlightPath === path) li.setAttribute("aria-current", "page");
|
|
if (highlightPath?.startsWith(path) ?? false) li.classList.add("fwd-nav-highlighted");
|
|
if (entry.border) li.classList.add("fwd-nav-separator");
|
|
|
|
const a = document.createElement("a");
|
|
a.innerText = entry.name;
|
|
a.tabIndex = 0;
|
|
if (hasChildren) {
|
|
const depth = parentPath.split("/").length - 2; // -1 because count parts, then another -1 because of leading /
|
|
a.innerText += ` ${depth === 0 ? "▾" : "▸"}`;
|
|
}
|
|
if (entry.link != null) {
|
|
a.href = entry.link;
|
|
|
|
if (entry.link !== "#" && !/^https:\/\/.*fwdekker.com/i.test(entry.link))
|
|
a.target = "_blank";
|
|
}
|
|
li.addEventListener("click", () => li.classList.add("fwd-nav-active"));
|
|
li.appendChild(a);
|
|
|
|
if (hasChildren) {
|
|
const ul = document.createElement("ul");
|
|
entry.entries
|
|
.map((it: any) => unpackEntry(it, path, highlightPath))
|
|
.forEach((it: HTMLLIElement) => ul.appendChild(it));
|
|
li.appendChild(ul);
|
|
}
|
|
|
|
return li;
|
|
}
|
|
|
|
/**
|
|
* 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 class="fwd-footer"><hr /><small>` +
|
|
footerLink("Made by ", author, authorURL, ". ") +
|
|
footerLink("Licensed ", license, licenseURL, ". ") +
|
|
footerLink("Source and support on ", vcs, vcsURL, ". ") +
|
|
footerLink("Read the ", privacyPolicyURL && "privacy policy", privacyPolicyURL, ". ") +
|
|
`</small><div id="fwd-footer-version"><small>${version || ""}</small></div></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 ? `<a href="${url}">${text}</a>` : 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 `<meta name="fwd:<function>:<property>" content="<value>" />` from the
|
|
* document's head. Given a `function` of `nav` or `footer`, if the `value` of `fwd:<function>: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 html = $("html")!;
|
|
if (html.dataset["theme"] == null) html.dataset["theme"] = "light";
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
if (getMetaProperty("fwd:auto:show-main") !== undefined)
|
|
$("main")?.classList?.remove("hidden");
|
|
|
|
if (getMetaProperty("fwd:auto:autofocus") !== undefined)
|
|
$("[autofocus]")?.focus();
|
|
});
|