<!DOCTYPE html> <html> <head> <title>Tickle</title> <meta charset="UTF-8" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Lobster&family=Nunito+Sans:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" /> <style> :root { --accent: hotpink; --background: #fdfdfd; font-family: "Nunito Sans", sans-serif; } body, html { width: 100%; height: 100%; margin: 0; padding: 0; } body { padding: 3em; max-width: 52em; margin: 0 auto; } pre { margin: 0; padding: 3em; max-width: inherit; max-height: 100vh; overflow: scroll; background: rgb(192, 192, 192); position: absolute; top: 0; left: 50%; transition: all 1s cubic-bezier(0.68, -0.55, 0.265, 1.55); transform: translate(-50%, -100%); } .is-source-enabled pre { transform: translate(-50%, 0); } button { display: inline-block; border: none; margin: 0; text-decoration: none; font-family: inherit; font-size: 1rem; display: flex; justify-content: center; align-content: center; text-align: center; vertical-align: middle; background: var(--accent); color: var(--background); cursor: pointer; padding: 0.5em 1em; border-radius: 0 0 0.2em 0; box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px; -webkit-appearance: none; -moz-appearance: none; } h1, h2, h3, h4 { color: var(--accent); font-family: "Lobster", serif; } a { color: var(--accent); position: relative; text-decoration: none; padding: 0.1em; } a::after { content: ""; position: absolute; background-color: var(--accent); position: absolute; left: 0; bottom: 3px; width: 100%; height: 1px; z-index: -1; transition: all 0.1s ease-in; } a:hover { color: var(--background); transition: all 0.3s ease-in-out; } a:hover::after { bottom: 0; height: 100%; transition: all 0.3s ease-in-out; } .menu, .burger { position: fixed; top: 0; left: 0; } .menu { padding: 1em; transform: translateX(-100%); display: flex; flex-direction: column; top: 0; bottom: 0; transition: all 0.3s ease-out; gap: 1em; } .menu a { text-decoration: none; background: var(--background); color: var(--accent); box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; padding: 0.2em 0.4em; } .menu a:hover { background: var(--accent); color: var(--background); } .menu-is-open .menu { transform: translateX(0); transition-duration: 0.1s; } .menu-is-open .burger { color: transparent; } .menu-is-open .burger, #Loading { top: 0; right: 0; width: 100%; height: 100%; border-radius: 0; background: rgba(17, 17, 17, 0.2); cursor: default; } #Loading { position: fixed; text-align: center; align-items: center; justify-content: center; font-size: 8rem; color: var(--accent); opacity: 0; transition: opacity 0.3s ease-in, height 0s linear 0.3s; height: 0; display: flex; overflow: hidden; } #Loading > * { animation: load 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); } .is-loading #Loading { opacity: 1; height: 100%; transition: opacity 1s ease-in, height 0 linear; } @keyframes load { 0% { transform: scale(0.95); } 5% { transform: scale(1.1); } 39% { transform: scale(0.85); } 45% { transform: scale(1); } 60% { transform: scale(0.95); } 100% { transform: scale(0.9); } } </style> </head> <body> <nav class="left-nav"> <button id="Burger" class="burger">≡</button> <div id="Menu" class="menu"></div> </nav> <main id="Body"></main> <pre id="Source"></pre> <div id="Loading"> <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") ); })(); </script> </body> </html>