testing moving the functionality into small modules #5

Open
xananax wants to merge 10 commits from split-into-modules into main
19 changed files with 428 additions and 240 deletions
Showing only changes of commit b2a4f8d4e0 - Show all commits

View File

@ -125,15 +125,15 @@
background: var(--accent);
color: var(--background);
}
.menu-is-open .menu {
.is-menu-open .menu {
transform: translateX(0);
transition-duration: 0.1s;
}
.menu-is-open .burger {
.is-menu-open .burger {
color: transparent;
}
.menu-is-open .burger,
.is-menu-open .burger,
#Loading {
top: 0;
right: 0;
@ -197,243 +197,8 @@
<p></p>
</div>
<script type="module">
//@ts-check
/*****************************************************************
*
* "THE FRAMEWORK"
* a small set of utilities that help
*
****************************************************************/
/**
* markdown parser. Remove if you don't use markdown
*/
// @ts-ignore
import { micromark } from "https://esm.sh/micromark@3?bundle";
const is_debug_mode = /^localhost|127.0.0.1/.test(
window.location.hostname
);
/**
* A small utility to query elements and get back an array
*/
const $ = (parent, selector) => [
...(!selector
? document.querySelectorAll(parent)
: parent.querySelectorAll(selector)),
];
/**
* Assumes a provided url is external if it begins by a known protocol
* @param {string} url
*/
const isExternal = (url) =>
url && /^(https?|mailto|tel|ftp|ipfs|dat):/.test(url);
/**
* Makes sure urls to local pages get passed through the routing system
* @param {HTMLElement} container the element containing links to find
*/
const rewriteLocalUrls = (container) => {
$(container, "a").forEach((a) => {
const href = a.getAttribute("href");
if (href && !isExternal(href)) {
a.setAttribute("href", "#/" + href.replace(/^\.?\//, ""));
}
});
return container;
};
/**
* Returns the hash part of the url, but only if it starts with a `/`
* This allows regular hashes to continue to work
* It reads also query parameters
*/
const getCurrentHashUrl = () => {
const [path, searchStr] = (
window.location.hash[1] === "/" ? window.location.hash.slice(2) : ""
).split("?");
const params = new URLSearchParams(searchStr);
return { path, params };
};
/**
* useful to check for transitions while developing styles, if the loading screen disappears too fast
* uses micromark. You can plug a different parser if you prefer
*/
const wait = is_debug_mode
? (val) => new Promise((ok) => setTimeout(ok, 1000, val))
: (val) => val;
/**
* @param {string} htmlString
*/
const generateDOM = (htmlString) =>
/** @type {HTMLElement} */ (
new DOMParser().parseFromString(
`<div>${htmlString}</div>`,
"text/html"
).firstChild
);
/**
* Loads and parses a markdown document. Makes use of micromark
* @param {string} path the path to load
*/
const loadMarkdown = (path) =>
fetch(is_debug_mode ? `./${path}?rand=${Math.random()}` : `./${path}`)
.then((response) => response.text())
.then(wait)
.then((raw) => {
const content = rewriteLocalUrls(generateDOM(micromark(raw)));
const firstTitleElement = content.querySelector("h1");
const title = firstTitleElement
? firstTitleElement.textContent
: path.replace(/\.\w{2, 4}$/, "");
return { title, raw, content };
});
/**
* parses a filelist string. That's a string that looks like
* ```
* name dd-mm-yyyy link name
* name dd-mm-yyyy
* name linkname
* name
* ```
* @param {string} lines
*/
const parseFileList = (lines) =>
lines
.trim()
.split(`\n`)
.map((line, index) => {
const today = new Date().toISOString().split("T")[0];
const result = line.match(
/(?<name>.+)\.(?<ext>\w{2,3})(?:\s+(?<date>[\d-]+)?(?<title>.+))?/
);
if (!result) {
console.error(`could not parse line ${index}: [${line}]`);
return null;
}
const {
groups: { name, ext, date = today, title = name },
} = result;
const href = `/${name}.${ext}`;
const date_unix = new Date(date).getTime();
return { name, href, date, title, date_unix, index };
})
.filter(Boolean)
.sort(({ date_unix: a }, { date_unix: b }) => a - b);
/*****************************************************************
*
* Creating references to the important stuff
*
****************************************************************/
/**
* The elements we will need
*/
const [Menu, Body, Loading, Source, Burger] = [
"Menu",
"Body",
"Loading",
"Source",
"Burger",
].map((id) => document.getElementById(id));
/**
* cache the original title to append it to the page title
*/
const mainTitle = document.title;
const showLoadingOverlay = () =>
document.body.classList.add("is-loading");
const hideLoadingOverlay = () =>
document.body.classList.remove("is-loading");
/*****************************************************************
*
* Router
*
* Things related to main navigation and to loading pages
*
****************************************************************/
/**
* Listens to hashchange event, and attempts to read the url.
* If the url is set,
*/
const onHashChange = async (evt) => {
const { path, params } = getCurrentHashUrl();
if (!path) {
return false;
}
showLoadingOverlay();
const { title, raw, content } = await loadMarkdown(path);
document.title = `${title} | ${mainTitle}`;
Body.innerHTML = "";
Source.innerHTML = raw;
Body.appendChild(content);
hideLoadingOverlay();
};
/**
* Called when the file list is obtained (presumably through loading)
* parses the file list, then fills the side navigation
* If there's no page loaded, it also loads the first page in the list
* (the list gets sorted by date, but the first line is the one that gets used)
* @param {string} lines
*/
const fillMenuFromFileList = (lines) => {
const links = parseFileList(lines);
Menu.innerHTML = links
.map(({ href, title }) => `<a data-link href="#${href}">${title}</a>`)
.join(`\n`);
if (!getCurrentHashUrl().path) {
const href = links.find(({ index }) => index === 0).href;
window.location.hash = `#${href}`;
} else {
onHashChange();
}
};
/*****************************************************************
*
* Bootstrapping
*
* this is where things actually happen
*
****************************************************************/
const loadFileList = () => {
showLoadingOverlay();
fetch("./files.txt")
.then((response) => response.text())
.then(fillMenuFromFileList);
};
/**
* Loads the article list, parses it, creates the menu items
*/
(function bootstrap() {
Burger.addEventListener("click", () =>
document.body.classList.toggle("menu-is-open")
);
window.addEventListener("hashchange", onHashChange);
loadFileList();
document.addEventListener(
"keyup",
({ key }) =>
key === "?" && document.body.classList.toggle("is-source-enabled")
);
})();
import blog from "./modules/templateBlog.mjs";
blog();
</script>
</body>
</html>

12
modules/changeTitle.mjs Normal file
View File

@ -0,0 +1,12 @@
/**
* Changes the document's title
* @param {string} title
* @param {string} mainTitle
* @returns
*/
export const changeTitle = (title, mainTitle = changeTitle.title) =>
document && (document.title = `${title} | ${mainTitle}`);
changeTitle.title = (document && document.title) || "";
export default changeTitle;

13
modules/documentMode.mjs Normal file
View File

@ -0,0 +1,13 @@
//@ts-check
/**
* Creates a helper to add or remove global classes that begin with `is-`
* @param {string} name
*/
export const documentMode = (name) => ({
on: () => document.body.classList.add(`is-${name}`),
off: () => document.body.classList.remove(`is-${name}`),
toggle: () => document.body.classList.toggle(`is-${name}`),
});
export default documentMode;

24
modules/fetchMarkdown.mjs Normal file
View File

@ -0,0 +1,24 @@
//@ts-check
import { fetchText } from "./fetchText.mjs";
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
import { rewriteLocalUrls } from "./rewriteLocalUrls.mjs";
import { generateDomFromString } from "./generateDomFromString.mjs";
// @ts-ignore
import { micromark } from "https://esm.sh/micromark@3?bundle";
/**
* Loads and parses a markdown document. Makes use of micromark
* @param {string} path the path to load
*/
export const fetchMarkdown = (path) =>
fetchText(path)
.then(waitIfLocalHost())
.then((raw) => {
const content = rewriteLocalUrls(generateDomFromString(micromark(raw)));
const firstTitleElement = content.querySelector("h1");
const title = firstTitleElement
? firstTitleElement.textContent
: path.replace(/\.\w{2, 4}$/, "");
return { title, raw, content };
});

16
modules/fetchText.mjs Normal file
View File

@ -0,0 +1,16 @@
//@ts-check
import isLocalHost from "./isLocalHost.mjs";
/**
* Loads a text file. The path provided will be appened with a random number
* when running on localhost to avoid caching problems
* @param {string} path
* @returns {Promise<string>} the loaded file
*/
export const fetchText = (path) =>
fetch(isLocalHost ? `./${path}?rand=${Math.random()}` : `./${path}`).then(
(response) => response.text()
);
export default fetchText;

View File

@ -0,0 +1,13 @@
//@ts-check
/**
* Generates valid dom elements from a string
* @param {string} htmlString
*/
export const generateDomFromString = (htmlString) =>
/** @type {HTMLElement} */ (
new DOMParser().parseFromString(`<div>${htmlString}</div>`, "text/html")
.firstChild
);
export default generateDomFromString;

View File

@ -0,0 +1,16 @@
//@ts-check
/**
* Returns the hash part of the url, but only if it starts with a `/`
* This allows regular hashes to continue to work
* It reads also query parameters
*/
export const getCurrentHashUrl = () => {
const [path, searchStr] = (
window.location.hash[1] === "/" ? window.location.hash.slice(2) : ""
).split("?");
const params = new URLSearchParams(searchStr);
return { path, params };
};
export default getCurrentHashUrl;

View File

@ -0,0 +1,19 @@
//@ts-check
import { isLocalHost } from "./isLocalHost.mjs";
/**
* Gets an element by id if the element exists, otherwise throws, but only if running in localhost environments.
* Use this in the initial setup to verify all elements exist
* @param {string} id
* @return {HTMLElement}
*/
export const getElementById = (id) => {
const element = document && document.getElementById(id);
if (isLocalHost && !element) {
throw new Error(`Element "#${id}" was not found`);
}
// @ts-ignore
return element;
};
export default getElementById;

15
modules/index.mjs Normal file
View File

@ -0,0 +1,15 @@
import { fetchText } from "./fetchText.mjs";
import { generateDomFromString } from "./generateDomFromString.mjs";
import { isExternalUrl } from "./isExternalUrl.mjs";
import { isLocalHost } from "./isLocalHost.mjs";
import { parseFileList } from "./parseFileList.mjs";
import {
querySelectorDoc,
querySelectorParent,
querySelectorAll,
} from "./querySelectorAll.mjs";
import { rewriteLocalUrls } from "./rewriteLocalUrls.mjs";
import { wait } from "./wait.mjs";
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
export { isExternalUrl, isLocalHost, rewriteLocalUrls, wait, waitIfLocalHost };

10
modules/isExternalUrl.mjs Normal file
View File

@ -0,0 +1,10 @@
//@ts-check
/**
* Assumes a provided url is external if it begins by a known protocol
* @param {string} url
*/
export const isExternalUrl = (url) =>
url && /^(https?|mailto|tel|ftp|ipfs|dat):/.test(url);
export default isExternalUrl;

8
modules/isLocalHost.mjs Normal file
View File

@ -0,0 +1,8 @@
//@ts-check
/** @type {boolean} returns true if the window global object exists and the domain is localhost of 127.0.0.1 */
export const isLocalHost =
typeof window !== "undefined" &&
/^localhost|127.0.0.1/.test(window.location.hostname);
export default isLocalHost;

10
modules/isNotNull.mjs Normal file
View File

@ -0,0 +1,10 @@
//@ts-check
/**
* @template T
* @param {T} value
* @returns {value is NonNullable<T>}
*/
export const isNotNull = (value) => value !== null;
export default isNotNull;

11
modules/onDocumentKey.mjs Normal file
View File

@ -0,0 +1,11 @@
/**
*
* @param {string} keyToListen
* @param {()=>void} callback
*/
export const onDocumentKeyUp = (keyToListen, callback) => {
document.addEventListener(
"keyup",
({ key }) => key === keyToListen && callback()
);
};

40
modules/parseFileList.mjs Normal file
View File

@ -0,0 +1,40 @@
//@ts-check
import { isNotNull } from "./isNotNull.mjs";
/**
* parses a filelist string. That's a string that looks like
* ```md
* name dd-mm-yyyy link name
* name dd-mm-yyyy
* name linkname
* name
* ```
*
* @param {string} lines
*/
export const parseFileList = (lines) =>
// @ts-ignore
lines
.trim()
.split(`\n`)
.map((line, index) => {
const today = new Date().toISOString().split("T")[0];
const result = line.match(
/(?<name>.+)\.(?<ext>\w{2,3})(?:\s+(?<date>[\d-]+)?(?<title>.+))?/
);
if (!result) {
console.error(`could not parse line number ${index}`);
return null;
}
const {
// @ts-ignore
groups: { name, ext, date = today, title = name },
} = result;
const href = `/${name}.${ext}`;
const date_unix = new Date(date).getTime();
return { name, href, date, title, date_unix, index };
})
.filter(isNotNull);
export default parseFileList;

View File

@ -0,0 +1,40 @@
//@ts-check
/**
* A small utility to query elements and get back an array
* @template {keyof HTMLElementTagNameMap} K
* @type {{
* (selector: K): HTMLElementTagNameMap[K][]
* (parent: ParentNode, selector: K): HTMLElementTagNameMap[K][]
* }}
*/
// @ts-ignore
export const querySelectorAll = (parent, selector) =>
// @ts-ignore
typeof selector === "undefined"
? // @ts-ignore
querySelectorDoc(/** @type {keyof HTMLElementTagNameMap} */ (parent))
: querySelectorParent(parent, selector);
/**
* A small utility to query elements in the document and get back an array
* @template {keyof HTMLElementTagNameMap} K
* @param {K} selector
* @returns {HTMLElementTagNameMap[K][]}
*/
export const querySelectorDoc = (selector) => [
...document.querySelectorAll(selector),
];
/**
* A small utility to query elements in a parent and get back an array
* @template {keyof HTMLElementTagNameMap} K
* @param {ParentNode} parent
* @param {K} selector
* @returns {HTMLElementTagNameMap[K][]}
*/
export const querySelectorParent = (parent, selector) => [
...parent.querySelectorAll(selector),
];
export default querySelectorAll;

View File

@ -0,0 +1,20 @@
//@ts-check
import isExternalUrl from "./isExternalUrl.mjs";
import { querySelectorParent } from "./querySelectorAll.mjs";
/**
* Makes sure urls to local pages get passed through the routing system
* @param {HTMLElement} container the element containing links to find
*/
export const rewriteLocalUrls = (container) => {
querySelectorParent(container, "a").forEach((a) => {
const href = a.getAttribute("href");
if (href && !isExternalUrl(href)) {
a.setAttribute("href", "#/" + href.replace(/^\.?\//, ""));
}
});
return container;
};
export default rewriteLocalUrls;

107
modules/templateBlog.mjs Normal file
View File

@ -0,0 +1,107 @@
//@ts-check
import { changeTitle } from "./changeTitle.mjs";
import { getCurrentHashUrl } from "./getCurrentHashUrl.mjs";
import { fetchMarkdown } from "./fetchMarkdown.mjs";
import { fetchText } from "./fetchText.mjs";
import { parseFileList } from "./parseFileList.mjs";
import { documentMode } from "./documentMode.mjs";
import { getElementById } from "./getElementById.mjs";
import { onDocumentKeyUp } from "./onDocumentKey.mjs";
/*****************************************************************
*
* Creating references to the important stuff
*
****************************************************************/
/**
* The elements we will need
*/
const [Menu, Body, Source, Burger] = ["Menu", "Body", "Source", "Burger"].map(
getElementById
);
const loadingMode = documentMode("loading");
const sourceEnableMode = documentMode("source-enabled");
const menuOpenMode = documentMode("menu-open");
/*****************************************************************
*
* Router
*
* Things related to main navigation and to loading pages
*
****************************************************************/
/**
* Listens to hashchange event, and attempts to read the url.
* @param {HashChangeEvent} [_evt]
*/
export const onHashChange = async (_evt) => {
const { path, params } = getCurrentHashUrl();
if (!path) {
return false;
}
loadingMode.on();
const { title, raw, content } = await fetchMarkdown(path);
changeTitle(title);
Body.innerHTML = "";
Source.innerHTML = raw;
Body.appendChild(content);
loadingMode.off();
};
const loadFileList = () => {
loadingMode.on();
fetchText("./files.txt").then(fillMenuFromFileList);
};
/**
* Called when the file list is obtained (presumably through loading)
* parses the file list, then fills the side navigation
* If there's no page loaded, it also loads the first page in the list
* (the list gets sorted by date, but the first line is the one that gets used)
* @param {string} lines
*/
export const fillMenuFromFileList = (lines) => {
const links = parseFileList(lines).sort(
({ date_unix: a }, { date_unix: b }) => a - b
);
if (links.length < 1) {
return;
}
Menu.innerHTML = links
.map(({ href, title }) => `<a data-link href="#${href}">${title}</a>`)
.join(`\n`);
if (!getCurrentHashUrl().path) {
// @ts-ignore
const href = links.find(({ index }) => index === 0).href;
window.location.hash = `#${href}`;
} else {
onHashChange();
}
};
/*****************************************************************
*
* Bootstrapping
*
* this is where things actually happen
*
****************************************************************/
/**
* Loads the article list, parses it, creates the menu items
*/
export const bootstrap = () => {
Burger.addEventListener("click", menuOpenMode.toggle);
window.addEventListener("hashchange", onHashChange);
loadFileList();
onDocumentKeyUp("?", sourceEnableMode.toggle);
};
export default bootstrap;

18
modules/wait.mjs Normal file
View File

@ -0,0 +1,18 @@
//@ts-check
/**
* Waits the specified amount of time before returning the value
* @param {number} durationMs Duration, in milliseconds. Defaults to 1 second
* @returns
*/
export const wait =
(durationMs = 1000) =>
/**
* @template T
* @param {T} [value]
* @returns {Promise<T>}
*/
(value) =>
new Promise((ok) => setTimeout(ok, durationMs, value));
export default wait;

View File

@ -0,0 +1,31 @@
//@ts-check
import wait from "./wait.mjs";
/**
* @template T
* @param {T} [value]
* @returns {Promise<T>}
*/
// @ts-ignore
const identity = (value) => Promise.resolve(value);
/**
* Waits the specified amount of time before returning the value
* @param {number} _durationMs Duration, in milliseconds. Defaults to 1 second
* @returns
*/
const fakeWait = (_durationMs = 1000) => identity;
import isLocalHost from "./isLocalHost.mjs";
/**
* useful to check for transitions while developing styles, if the loading screen
* disappears too fast for example.
* @template T
* @param {number} durationMs Duration, in milliseconds. Defaults to 1 second
* @returns {(value?: T | undefined) => Promise<T>}
*/
export const waitIfLocalHost = isLocalHost ? wait : fakeWait;
export default waitIfLocalHost;