Compare commits

..

10 Commits

Author SHA1 Message Date
93efef50a5 general updates 2023-06-13 18:50:13 +02:00
b01f6444d0 smol updates even mor 2023-06-07 22:07:47 +02:00
cde1e58a47 smol update 2023-06-07 18:51:18 +02:00
eadca78080 temp mid-work backup commit 2023-06-07 00:45:16 +02:00
3d348710e0 added a few loading utilities 2023-05-18 23:33:33 +02:00
55b59bfd2c minor refactoring 2023-05-13 17:58:34 +02:00
9b3b3e15b6 tickle functionality restored 2023-05-13 17:26:48 +02:00
7d3f5b67cf refactor 2023-05-09 04:00:53 +02:00
8e7ce406ff made all files conform; simplified template loading 2022-08-11 22:29:46 +02:00
b2a4f8d4e0 testing moving the functionality into small modules 2022-08-11 14:38:08 +02:00
89 changed files with 2455 additions and 426 deletions

34
components/index.mjs Normal file
View File

@ -0,0 +1,34 @@
//@ts-check
import {fetchText} from '../modules/fetchText.mjs'
import {parseFileList} from '../modules/parseFileList.mjs'
import {html} from '../modules/html.mjs'
const indexListTemplate = html`<p><slot>Hello World!</slot></p>`
class IndexList extends HTMLElement {
static template = indexListTemplate
static observedAttributes = ["src"];
static define(tag = "index-list") {
customElements.define(tag, this)
}
shadowRoot = this.shadowRoot || this.attachShadow({ mode: "open" })
src = ""
connectedCallback() {
this.shadowRoot.replaceChildren(IndexList.template.content.cloneNode(true))
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) {
return;
}
switch (name) {
case "src":
this.src = newValue
fetchText(newValue).then(parseFileList).then()
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,28 @@
---
date: 2002-07-13T21:30:09Z
title: An Armenian at a French School in Saudi Arabia
author: Sally Émerzian
hero_image: "armenian-french-school"
---
Back in the mid 1980s there was a distinct racial hierarchy at the French school in Saudi Arabia.
It was most noticeable at lunchtime when the bell rang shrilly through the desert heat, liberating about 700 souls from under the austere stares of their French teachers.
Like a river from behind a collapsing dam, kids exploded onto the playground in waves of black, brown, and blond, rolling towards the delta of the huge blue tent. Under the long stretches of shade, they formed tight circles of belonging. And invariably, the same outcasts stared at the proud little backs from behind the tent poles.
There were the Africans from the old French colonies. Probably the most fortunate group in their obliviousness. Their circle was irregular, original, indifferent to the rest of the world. With their fancy braids, and the brightly patterned fabrics of their dresses, they seemed to be perpetually giggling or dancing without as much as a furtive glance wasted on envy or concern for the thoughts of other kids. Their laughter was occasionally accompanied by the sounds of their melodious names as they called each other in their games: Mammadou, Issiakka, Uhmu… They smelled of amber and flashed the blinding whiteness of their carefree smiles on lonely serfs and royalty alike. Genuine and inclusive, they sometimes tugged at the sleeves of random outcasts, inviting them in. But where the circles were not fenced by pigmentation, it was culture that drew the line. And so, the outcasts remained behind the bars of their phantom exile like atheists at a Gospel choir.
There were the Levantines and the Maghrébins, separate from each other but similar in their implicit slightly more rightful claim on our host country. They were not at home per say, but close enough. In hindsight, they should have represented the group that my skin tone allowed me to infiltrate most easily but their sense of identity was so much crisper than my own and I could never help but feel like an impostor. It would become painfully obvious every year in Arabic class when the teacher would ask "Mirza? Sally Mirza?", and I would answer: "No Madame, A-mirza".
\-"Amirza, I've never heard that name. Where's your family from?"
\- "It was Émerzian." I would mumble every year without answering the actual question...
\- "Ah... Armenian".
Then there was the royal circle and its annexed courts spread out around it on thrones of multicolored lunchboxes. The French kids watched over their kingdom from behind their golden curls. Once their clear eyes had scanned the ground to make sure they were watched, admired and envied, they proceeded to the display of their belongings: Golden Dolls cold and beautiful, Little Ponys of plastic with flowing hair and sparkling eyes. They moved them with an air of importance, brushing the nylon with intentional slowness. This was their school, we were all allowed to exist on their grounds thanks to the goodness of their heart. They had little care for anyone, their pretty little heads were always protected. So, patrolling between palace and court, were the guards of failing little boys whose accelerated growth had miraculously slowed their ability to read and write.
When the bell rang again, all the circles quickly dissolved like separate streams feeding one big canal that reunified everyone under the banner of French education. Lunchtime after lunchtime, year after year, the cycle looped over and over again, like a merry-go-round long since forgotten by an old and senile operator.
Then one day, silently and without preparation, one of the lonely wooden horses broke free and galloped away to the World Wide Web where I met me and began to live.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -0,0 +1,4 @@
an-armenian-at-a-french-school-in-saudi-arabia.md
thanks-for-the-piano-lessons.md
to-the-cannibals-of-mar-mikhail.md
une-histoire-de-portes-et-de-fenetres.md

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>Tickle</title>
<meta charset="UTF-8" />
<link href="../../modules/templates/blog.css" rel="stylesheet" />
</head>
<body>
<main></main>
<pre id="Source"></pre>
<div id="Loading">
<p></p>
</div>
<script type="module" src="../../modules/templates/blog.mjs"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
examples/musings/piano.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -0,0 +1,29 @@
---
date: 2022-11-07T09:21:21.000+00:00
hero_image: "piano.jpg"
title: Thank you for the piano lessons
author: Sally Émerzian
---
You're downstairs and you can't play,
But you're playing "Strangers In The Night".
For me, it's a school night.
Tomorrow is another day on the front line
Another bloody battle against the world you put me in, then shielded me from.
The awkward notes are far apart
Guesses echoing in the hallway
Against bare walls and marble floors
Like lonely people.
Tonight if I sleep early, tomorrow if I listen carefully,
I might learn all the things you'd have liked to, for you, for me, for me... for you
The burden's heavy on my little heart.
It's counting time, breaking apart
With every new skill you gift me with your time, with my time
Now it's bedtime already but hey, at at least I hear you.
And now I'm your age already but I still see you,
Under that spotlight, behind the piano, below the staircase
The outline of your kind shoulders cast in a mahogany glow
One well-meaning hand figuring out the next note
Til I grow up to be all the things you'd guessed for me.
All those great things that should have made it all worth it
For all the strangers in the night.

View File

@ -0,0 +1,65 @@
---
date: 2018-02-01T20:40:38.000+00:00
title: To the Cannibals of Mar Mikhael
author: Sally Émerzian
hero_image: "marmikhael-village-copy.jpg"
---
Dear Lebanon,
"Where is \[your\] mind? Oh where is \[your\] mind..."
It seems it's time for another love letter... Deep down, you must know they're love letters.
So, I see that you're boasting a new residential building in Mar Mikhael. Every day, someone pays Facebook to inform me that Bernard Khoury's latest post-apocalyptic structure "Mar Mikhael Village" is the newest addition to Beirut's "most charming" neighborhood.
For about a million American Dollars, (because the amount in Lebanese pounds would eat up my screen and my brain's ability to quantify), any one of us Almond-Milk-sipping hipsters can own a prestigious flat completely devoid of walls and tiles.
![](marmikhael-village.jpg)
All windows. Just endless stretches of glass panes and concrete slabs. The Emperor's New Flat.
Have you seen this building, Beirut? Is it just me?
Does it not remind you even a little bit of the ravaged buildings you had to tear down not long ago? The ones hollowed out and partially collapsed by your 25 years of civil war?
It stands there, a post-modern mangled façade with missing teeth and gaping holes... In a neighborhood that in any other self-respecting nation, would be protected by Urban Planning regulations.
![](marmikhael-newproject.jpg)
Do you recall what attracted you to the neighborhood of Mar Mikhael in the first place? It was barely a decade ago that your hip and trendy began to flock to it to become hip and trendy.
You don't have to look very far. It's in the ad campaign for the development. It's everything the project is not.
You came to it looking for the last crumbs of your aesthetic identity, the last lines of your national memory. It was Mar Mikhael's characteristic Lebanese architecture that drew you, its old inhabitants, its humble shopkeepers, its quaint residential buildings with ornate little balconies, plain staircases, arched windows and doors...
![](shitadvertising.jpg)
How did you fall in love with the charm of Mar Mikhael for what it is, only to slowly try to turn it into downtown Blade Runner?
How have you become a society built on top of the vestiges of what it once was, both a lover and a destroyer of the faded glory of your golden age? How is it sane to tear down your heritage, to use the space to build something new in which you proceed to hang photos of the old and marvel at how nice it used to be before you tore it down. So you spend hundreds of thousands of dollars celebrating weddings and events in the ruins of your retired train stations while your SUV's herd each other on potholed roads and your sidewalks end before they begin.
Then you buy into this olive oil, pottery jar, ex-ZaatarWZeit version of yourself and you sell it to the world as your national image, only to progressively turn it into a fast-food joint everywhere it is not owned by the malevolent ghost of Solidere.
![](artisanmarmikhael.jpg)
> What has happened to you as a Nation, as a Society, that has led you to become a walking, breathing contradiction, your heart in one place and your choices in another? A Cannibal of your own civilization.
You find a neighborhood "authentic" and a charming reminder of your roots, you tear it down, you build structures that look nothing like what you like about it, and then you proceed to advertise the thing you built using images of what the neighborhood used to be. I am not sure what the medical word for this condition is. Something between schizophrenia and dementia?
Tell you what... Let's start over one step at a time. I will begin with myself. Let's restore sanity one brick at a time.
We can start healing by making peace with walls again.
Yes walls... Nothing too ambitions. Not talking about big changes, like cleaning up the shores, recycling, not voting for evil baboons. Just simple walls. Let's celebrate walls. Next time a developer tries to sell you a project or a new flat, next time you build a home, expect to see some walls. Just enough to help you locate the windows from amongst other windows. What do you say we make walls trendy again... Think about it, it might even be "avant-garde" and a good business venture. Soon enough, walls will have become so rare that people might begin to miss them.
After all, our homes were supposed to be regular shelters from regular elements.
Remember if you can...
Before parents tucked their children to sleep between the walls of narrow corridors because someone told them it was the safest place to hide from shells. Before street war and shrapnel forced a generation to steer clear of windows. Our walls were just walls and our windows just windows.
Our souls and our memories cannot possibly subsist on the occasional watercolor painting or boutique hotel repackaging of all that we once called home.
Yours always for better or worse,
Sally

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1,53 @@
---
date: 2020-08-13T23:30:09.000+00:00
title: Une Histoire de Portes et de Fenêtres
author: Sally Émerzian
hero_image: "anastase-maragos-kqkzdnujczq-unsplash.jpg"
---
Lorsque nous étions petits Marc et moi, en Arabie, une de ces fins dannées scolaires, maman nous avait dit que nous allions passer lété au Liban, mais cette fois, dans la vieille maison de ses parents à Kahaleh. Je devais être en CM2 (7ème) et Marc en CE2 (9ème).
Pourquoi pas Jounieh? avions-nous demandé, c'est pourtant là bas que se trouvaient ma grand-mère et mes oncles. Qui y avait-il à Kahaleh?
Personne. Mais aussi tout le monde avant la guerre, nous expliquait-elle: “Vous ne voulez pas voir là où vivait maman?”
Pour nos yeux d'enfants habitués aux tons sépia du désert de Jeddah, la maison de Kahaleh se dressait comme un domaine enchanté entouré dun merveilleux jardin secret. Des herbes folles poussaient ici et là, partout entre les marches, à travers les fentes des volets… cétait un royaume de vignes et de vieux rosiers qui pensaient que jamais personne ne reviendrait.
![](julia-joppien-w-7h8oxrawc-unsplash.jpg)
Au rez-de-chaussée, il y avait le vieil atelier de menuiserie de mon grand-père Georges. “Cest Geddo lui-même qui a fabriqué toutes les portes, toutes les armoires, et tous les lits” nous racontait-elle.
“Tiens, regardez là-bas, il a même fait un cerf-volant. Tu le veux, Marc?”
“Il est à moi?” demandait-il en écartant les toiles daraignée. “Est-ce que Geddo savait que je viendrais le prendre?”
Mais qui savait à lépoque? Lorsque mes grands-parents avaient fermé la porte une première fois, avec quelques affaires jetées vite-fait dans des sacs, cétait pour fuir pour quelques nuits à Bzoummar chez la soeur de mon grand-père. “Juste le temps que les choses se calment un peu”.
Geddo reviendra encore plusieurs fois réparer des trous dans les portes et les volets. Téta laccompagnera parfois pour ranger quelques affaires supplémentaires durant des années de déplacement de sous le toit dune soeur à lautre. Et parfois, ils rentreront tous pour quelques semaines de paix, jusquà ce que les obus remplacent les balles et achèvent de détruire leur attachement aux murs quils aimaient.
Même couverte de poussière, la maison me semblait neuve. Criblée de trous mais visiblement neuve, dans la mesure où on ny avait habité que quelques années. Elle contenait encore dans ses placards et ses dressoirs, lodeur de lémotion de ses nouveaux habitants.
Nous suivions maman à travers le long couloir et je me sentais comme Boucle dOr.
“Ici cétait la chambre de Robert. Là cest la chambre de Téta et Geddo, et celle-ci la blanche, cétait ma chambre.”
Elle nous décrivait son bonheur lorsque ses parents avaient achevé la construction. Elle avait finalement sa propre chambre! Avant cela, ils habitaient tous dans la même maison que son oncle et sa tante à Furn-el-Chebbek. Entre frères, soeurs, cousins et cousines, ça faisait 12 enfants sous le même toit. On ne sennuyait pas mais cétait quand même une foule!
Nous passions des jours entiers à essuyer la poussière, des années de poussière. Notre grand ménage ne passait pas inaperçu dans le village. Quelque fois, une vieille voisine frappait à la porte. “Wli hayde ente ka Liliane?? Ma 3reftik!!! Wayyn hal ghaybi?? Kifa Angèle? Allah yer7am bayyik, shou te3ib bhal bayt, khallina sektin…” _(“Cest bien toi Liliane?? Je ne tai presque pas reconnue!!! Ça fait si longtemps! Comment va Angèle? Mes condoléances pour ton père... Il a tellement travaillé pour cette maison... laissons tomber…”)._
![](annie-spratt-fcwmnaarumk-unsplash.jpg)
À mon grand bonheur de rat de bibliothèque, je découvrais sur des étagères, les livres laissés par mes oncles et mes tantes. Robert langlophile ma ainsi accidentellement enseigné langlais. Sans comprendre grand chose, je rongeais les pages de “Montezumas Daughter”, plongée dans un vieux sofa en velours rose capitonné, au fond du salon vestige qui, même à son apogée, navait jamais accueilli grand monde.
Le soir au son des cigales, Marc, enfilant maladroitement un fil de métal dans la moustiquaire, tentait tant bien que mal de confectionner une antenne de fortune pour la grosse caisse de bois de la télévision. Rien narrivait à la hauteur de notre excitation lorsquon arrivait par hasard à entrevoir, entre les rayures noires et blanches, le visage de lorpheline Mexicaine Maria Mercedes.
Pourquoi tous ces souvenirs maintenant, en plein milieu de la nuit à Kiev?
Jai 38 ans. Jai quitté Beyrouth il y a quelques jours. Quatre jours avant la grande explosion.
Jai jeté vite-fait quelques affaires dans des sacs, et je suis partie chez mon frère à Kiev. Avant de partir, jai donné mes clés à ma tante Yolla. Elle les a ajoutées à un gros trousseau de clés et de promesses faites au fil des ans à tous ceux qui quittaient le pays: que oui bien sûr, “ma te3talo ham, ana w Joseph mendall ntell 3al Beit” _(“Ne vous en faites pas, Joseph et moi on passera souvent jeter un coup doeil à la maison”)._
Cest Joseph et elle bien-entendu qui ont réparé ma porte et redressé mes fenêtres, pendant que sous le choc, je contemplais le nombre de fois que ma tante et son mari se sont retrouvés en train de balayer le verre des fenêtres éclatées des maisons vides de ceux quils aiment.
Mais au diable mon appartement à Beyrouth.
Jai peur davoir des enfants si tard, et davoir un jour lidée de les emmener voir la maison magnifique de mes parents à Baabda, une autre maison abandonnée toute neuve, et de les entendre me suivre avec leurs petits pas et de mécouter dire: “cette salle de bain, cest Téta qui lavait conçue” et “cette balustrade, cest Geddo qui lavait fabriquée”, “celle-ci la chambre avec le lit à baldaquins, cétait ma chambre.”
![](une-histoire-de-portes-et-de-fenetres.jpg)

View File

@ -3,437 +3,18 @@
<head> <head>
<title>Tickle</title> <title>Tickle</title>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link href="./modules/templates/blog.css" rel="stylesheet" />
<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> </head>
<body> <body>
<nav class="left-nav"> <section>
<button id="Burger" class="burger">&#8801;</button> <button class="burger">&#8801;</button>
<div id="Menu" class="menu"></div> <nav class="menu"></nav>
</nav> </section>
<main id="Body"></main> <main></main>
<pre id="Source"></pre> <pre id="Source"></pre>
<div id="Loading"> <div id="Loading">
<p></p> <p></p>
</div> </div>
<script type="module"> <script type="module" src="./modules/templates/blog.mjs"></script>
//@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> </body>
</html> </html>

4
modules/index.mjs Normal file
View File

@ -0,0 +1,4 @@
//@ts-check
export * from './tickle/index.mjs'
export * from './utils/index.mjs'
export * as templates from './templates/index.mjs'

177
modules/templates/blog.css Normal file
View File

@ -0,0 +1,177 @@
@import url('https://fonts.bunny.net/css2?family=Lobster&family=Nunito+Sans:ital,wght@0,400;0,700;1,400&display=swap');
: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;
appearance: none;
-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);
}
.is-menu-open .menu {
transform: translateX(0);
transition-duration: 0.1s;
}
.is-menu-open .burger {
color: transparent;
}
.is-menu-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);
}
}

View File

@ -0,0 +1,36 @@
//@ts-check
import { fetchText } from "../utils/fetchText.mjs";
import { getElementByCSSSelector } from "../utils/getElementByCSSSelector.mjs";
import { onDocumentKeyUp } from "../utils/onDocumentKey.mjs";
import { parseFileList } from "../tickle/parseFileList.mjs";
import { createMenuEntriesFromFileList } from "../tickle/createMenuEntriesFromFileList.mjs";
import { sortFileListLines } from "../tickle/sortFileListLines.mjs";
import { mode } from "../tickle/mode.mjs";
import { bootstrapRouter } from "../tickle/bootstrapRouter.mjs";
export const bootstrap = async () => {
const [Body, Source] = [
"main",
"#Source",
].map(getElementByCSSSelector);
const lines = await fetchText("files.txt");
const links = parseFileList(lines);
const firstHref = links[0].href;
sortFileListLines(links);
Body.appendChild(createMenuEntriesFromFileList(links));
bootstrapRouter(firstHref, (content, raw) => {
Body.innerHTML = "";
Source.innerHTML = raw;
Body.appendChild(content);
});
onDocumentKeyUp("?", mode.sourceEnabled.toggle);
};
export default bootstrap;
if (!new URL(import.meta.url).searchParams.has("load")) {
bootstrap();
}

View File

@ -0,0 +1,2 @@
//@ts-check
export { default as blog } from './blog.mjs'

View File

@ -0,0 +1,45 @@
//@ts-check
import { changeTitle } from "../utils/changeTitle.mjs";
import { getCurrentHashUrl } from "../utils/getCurrentHashUrl.mjs";
import { fetchMarkdown } from "../utils/fetchMarkdown.mjs";
import { rewriteLocalUrls } from "../utils/rewriteLocalUrls.mjs";
import { hasNoHashUrl } from "../utils/getCurrentHashUrl.mjs";
import { mode } from "./mode.mjs";
/**
* Sets up a listener for hashchange events.
* Loads the provided default Href if none is set by the user
* @param {string} defaultHref
* @param {(content: DocumentFragment, raw: string) => void} onPageLoaded
*/
export const bootstrapRouter = async (defaultHref, onPageLoaded) => {
/**
* Listens to hashchange event, and attempts to read the url.
* @param {HashChangeEvent} [_evt]
*/
const onHashChange = async (_evt) => {
const { path } = getCurrentHashUrl();
if (!path) {
return false;
}
mode.loading.on();
const { title, raw, content } = await fetchMarkdown(path);
changeTitle(title);
rewriteLocalUrls(content);
onPageLoaded(content, raw);
mode.loading.off();
};
window.addEventListener("hashchange", onHashChange);
const load = () => {
if (hasNoHashUrl()) {
window.location.hash = `#${defaultHref}`;
} else {
onHashChange();
}
};
return load
};

View File

@ -0,0 +1,18 @@
//@ts-check
import { makeTemplate } from "../utils/print.mjs";
import { generateDomFromString } from "../utils/generateDomFromString.mjs";
/**
* @typedef {import("./parseFileList.mjs").ParsedLine} ParsedLine
*/
const defaultTemplate = `<a data-link href="#{{href}}">{{title}}</a>`;
/**
* Creates menu entries from a list of links.
* @param {ParsedLine[]} links
* @param {string} template a template for each link, where `{{href}}` is replaced by the href
* and `{{title}}` is replaced by the title.
* all the keys parsed in `parseFileList.mjs` can be used.
*/
export const createMenuEntriesFromFileList = (links, template = defaultTemplate) =>
generateDomFromString(links.map(makeTemplate(template)).join("\n"));

13
modules/tickle/index.mjs Normal file
View File

@ -0,0 +1,13 @@
//@ts-check
import { bootstrapRouter } from "./bootstrapRouter.mjs";
import { createMenuEntriesFromFileList } from "./createMenuEntriesFromFileList.mjs";
import { parseFileList, parseFileListLine } from "./parseFileList.mjs";
import { sortFileListLines } from "./sortFileListLines.mjs";
export {
bootstrapRouter,
createMenuEntriesFromFileList,
parseFileList,
parseFileListLine,
sortFileListLines,
};

8
modules/tickle/mode.mjs Normal file
View File

@ -0,0 +1,8 @@
//@ts-check
import { documentMode } from "../utils/documentMode.mjs";
export const mode = {
loading: documentMode("loading"),
sourceEnabled: documentMode("source-enabled"),
menuOpen: documentMode("menu-open"),
};

View File

@ -0,0 +1,59 @@
//@ts-check
import { today } from "../utils/today.mjs";
/**
* @typedef {Exclude<ReturnType<typeof parseFileListLine>, null>} ParsedLine
*/
/**
* Parses a single line of a file list
* @param {string} line
* @param {number} index
* @returns
*/
export const parseFileListLine = (line, index) => {
const result = line.match(
/(?<name>.+)\.(?<ext>\w{2,3})(?:\s+(?<date>[\d-]+)?(?<title>.+))?/
);
if (!result) {
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 };
};
/**
* 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
* ```
* This is very specific to Tickle and unlikely to be very useful anywhere else.
* @param {string} lines
*/
export const parseFileList = (lines) =>
// @ts-ignore
lines
.trim()
.split(`\n`)
.reduce((/** @type {ParsedLine[]}*/ arr, line, index) => {
const result = parseFileListLine(line.trim(), index);
if (!result) {
console.error(`could not parse line number ${index}`);
return arr;
}
arr.push(result);
return arr;
}, []);
parseFileList.parseLine = parseFileListLine;
export default parseFileList;

View File

@ -0,0 +1,15 @@
//@ts-check
/**
* @typedef {import("./parseFileList.mjs").ParsedLine} ParsedLine
*/
/**
* Sorts a list of parsed lines by a given property in-place
* @param {ParsedLine[]} lines
* @param {keyof ParsedLine} property
*/
export const sortFileListLines = (lines, property = 'date_unix') =>
lines.sort(({ [property]: a }, { [property]: b }) => a - b);
export default sortFileListLines

View File

@ -0,0 +1,32 @@
//@ts-check
/**
* Utility: use it at the end of switch statements to make sure all matches are covered.
* Only useful in an editor that supports JSDOC strong typing.
*
* Use it like so
* ```js
* switch(data):
* case A:
* doThing();
* break;
* case B:
* doOtherThing();
* break;
* default:
* throw new UnreachableCaseError(state);
* ```
* It `data` may be more options, then `state` will be underlined with the error
* ```
* Argument of type 'T' is not assignable to parameter of type 'never'
* ```
* Where `T` is the type of `data`.
* To remove the error, handle all cases.
*/
export class UnreachableCaseError extends Error {
constructor(/** @type {never} */ value) {
super(`Unreachable case: ${JSON.stringify(value)}`);
}
}
export default UnreachableCaseError

View File

@ -0,0 +1,21 @@
//@ts-check
/**
* Changes the document's title. Instead of passing the main title, you can also
* change the functions `title` member:
* ```js
* changeTitle.title = "My Site"
* changeTitle("Home") // produces "Home | My Site"
* ```
* if not `title` is passed, the document title (as found when running the function
* the first time) will be used.
* @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;

View File

@ -0,0 +1,98 @@
//@ts-check
const createOptions = () => ({
name: "my-custom-element",
css: ":host{}",
html: "",
ParentClass: HTMLElement,
observedAttributes: /** @type {string[]}*/ ([]),
});
/**
* WIP: minimal API for custom elements. Do not use!
* @typedef {ReturnType<typeof createOptions>} Options
* @param {Partial<Options>} [options]
*/
export const createCustomElement = (options) => {
const {
name,
css,
html,
observedAttributes: attrs,
ParentClass,
} = { ...createOptions(), ...options };
class CustomClass extends ParentClass {
static template = document.createElement("template");
static stylesheet = new CSSStyleSheet();
constructor(){
super()
}
/**
* Registers the custom element. If it was already registered, this is a no-op.
* @param {string} tag
*/
static define(tag = name) {
if (!customElements.get(tag)) {
customElements.define(tag, this);
}
}
static get observedAttributes() {
return attrs;
}
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) {
return;
}
this[property] = newValue;
}
/** @type {AbortController|null} */
_abortController = null;
/**
* If no <template> tag is provided in the page, this uses the parent classe's
* template and css to create markup
* Use this in the constructor
*/
_autoCreateShadow(){
if (!this.shadowRoot) {
const { stylesheet, template } =
Object.getPrototypeOf(this).constructor;
this.shadowRoot = this.attachShadow({ mode: "open" });
this.shadowRoot.adoptedStyleSheets = [stylesheet];
this.shadowRoot.replaceChildren(template.content.cloneNode(true));
}
}
_getAbortSignal(){
if(!this._abortController){
this._abortController = new AbortController()
}
return this._abortController.signal
}
/**
* Aborts any event that used the AbortSignal.
* Use this in the `disconnectedCallback` call
*/
_abort(){
this._abortController && this._abortController.abort();
}
}
const x = new CustomClass();
CustomClass.template.innerHTML = html;
CustomClass.stylesheet.replaceSync(css);
Object.defineProperty(CustomClass, "name", { value: name.replace(/-/s,'') });
return CustomClass;
};
export default createCustomElement

View File

@ -0,0 +1,51 @@
//@ts-check
/**
*
* Creates exclusive states for an HTML element. These states are added as classes
* and can be used to drive CSS changes.
* @param {string[]} allModes A string list of all possible modes. It's advised
* to prepend (`state-` or `mode-` to each for clarity)
* @param {Element} [element] the element to add the classes to. Defaults to the
* document's body
*/
export const createElementStatusModes = (allModes, element = document.body) => {
/**
* @param {any} mode
* @returns {mode is number}
*/
const isValidIndex = (mode) =>
typeof mode === "number" && mode >= 0 && mode < allModes.length;
/**
* Sets a status mode (class name) on the element.
* Pass a falsy value to clear all modes
* @param {number|null|undefined|false} mode
*/
const set = (mode = false) => {
mode = isValidIndex(mode) ? mode : -1;
const modeClass = allModes[mode];
if (modeClass && element.classList.contains(modeClass)) {
return;
}
element.classList.remove(...allModes);
element.classList.add(modeClass);
};
/**
* Verifies which of the given classes is set.
* @returns {string|undefined} the class if there is one
*/
const get = () =>
allModes.find((className) => element.classList.contains(className));
/**
* @param {number} state
*/
const is = (state) =>
isValidIndex(state) && document.body.classList.contains(allModes[state]);
return { set, get, is };
};
export default createElementStatusModes;

View File

@ -0,0 +1,102 @@
//@ts-check
import { deferredPromise } from "./deferredPromise.mjs";
/**
* Creates a response object that can dispatch progress.
* @param {(received: number) => void} onProgress
* @param {AbortSignal} [signal]
* @param {number} [maxPackets] a maximum amount of packets to receive
*/
export const createTrackedResponse = (
onProgress,
signal,
maxPackets = 99999
) => {
/** @type {DeferredPromise<ReadableStreamDefaultReader<Uint8Array>>} */
let readerPromise = deferredPromise();
/** @type {DeferredPromise<Response>} */
const successPromise = deferredPromise();
let received = 0;
let started = false;
let failed = false;
let success = false;
const response = new Response(
new ReadableStream({
async start(controller) {
const onError = (/** @type {Error} */ error) => {
failed = true;
success = false;
controller.close();
controller.error(error);
successPromise.reject(error);
};
const onSuccess = () => {
failed = false;
success = true;
successPromise.resolve(response);
};
signal &&
signal.addEventListener("abort", () =>
onError(new Error(`Stream aborted`))
);
try {
const reader = await readerPromise;
started = true;
try {
while (true && maxPackets-- > 0) {
const { done, value } = await reader.read();
if (done) {
controller.close();
onProgress(received);
break;
}
received += value.byteLength;
controller.enqueue(value);
onProgress(received);
}
onSuccess();
} catch (error) {
onError(error);
}
} catch (readerError) {
onError(readerError);
}
},
})
);
const start = readerPromise.resolve;
/** @type {Promise<Response>} */
return {
start,
/** @type {Promise<Response>["then"]} */
then: successPromise.then.bind(successPromise),
/** @type {Promise["catch"]} */
catch: successPromise.catch.bind(successPromise),
/** @type {Promise["finally"]} */
finally: successPromise.finally.bind(successPromise),
get response() {
return response;
},
get received() {
return received;
},
get isStarted() {
return started;
},
get isFailed() {
return failed;
},
get isSuccess() {
return success;
},
get isUnknown() {
return success === false && failed === false;
},
};
};
export default createTrackedResponse;

View File

@ -0,0 +1,20 @@
//@ts-check
/**
* Does a best guess attempt at finding out the size of a file from a request headers.
* To access headers, server must send CORS header
* `Access-Control-Expose-Headers: content-encoding, content-length x-file-size`
* server must send the custom `x-file-size` header if gzip or other content-encoding is used.
* @param {Headers} headers
*/
export const decodeContentLength = (headers) => {
const contentEncoding = headers.get("content-encoding");
const contentLength =
headers.get(contentEncoding ? "x-file-size" : "content-length") ||
(headers.has("content-range") &&
/**@type {string}*/ (headers.get("content-range")).split("/")[1]) ||
"0";
return parseInt(contentLength, 10);
};
export default decodeContentLength;

View File

@ -0,0 +1,34 @@
//@ts-check
/**
* @template {any} T
* @typedef {{
* resolve: (value: T) => void
* reject: (reason?: any) => void
* } & Promise<T>} DeferredPromise<T> A promise that can be resolved externally
*/
/**
* Returns a promise that can be resolved externally.
* @template {any} DeferredPromiseType
* @returns {DeferredPromise<DeferredPromiseType>}
*/
export const deferredPromise = () => {
/** @type {(value: DeferredPromiseType) => void} */
let resolve
/** @type {(reason?: any) => void} */
let reject;
/**
* @type {Promise<DeferredPromiseType>}
*/
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
// @ts-ignore
return Object.assign(promise, {resolve, reject});
}
export default deferredPromise

View File

@ -0,0 +1,16 @@
//@ts-check
import {createElementStatusModes} from "./createElementStatusModes.mjs"
/**
* Creates a document state object that can toggle between exclusive states.
* All passed states' css classnames will be prepended with `mode-`.
* @see {createElementStatusModes}
* @param {string[]} states
*/
export const documentState = (states) => {
const all = states.map((state) => `mode-${state}`);
return createElementStatusModes(all, document.body)
};
export default documentState;

View File

@ -0,0 +1,15 @@
//@ts-check
/**
* Creates a helper to add or remove global classes that begin with `is-`
* @param {string} name name of the state
* @param {Element} [element] defaults to the document body
*/
export const elementMode = (name, element = document.body) => ({
on: () => element.classList.add(`is-${name}`),
off: () => element.classList.remove(`is-${name}`),
toggle: () => element.classList.toggle(`is-${name}`),
has: () => element.classList.contains(`is-${name}`),
});
export default elementMode;

View File

@ -0,0 +1,11 @@
//@ts-check
/**
* Escapes a string so it can be used in a regular expression
* @param {string} text
*/
export function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
export default escapeRegExp

View File

@ -0,0 +1,14 @@
//@ts-check
import {decodeContentLength} from './decodeContentLength.mjs'
import {fetchHeaders} from './fetchHeaders.mjs'
/**
* Attempts to retrieve the size of an object represented by a URL with a
* limited fetch request.
* @see {decodeContentLength}
* @param {string} path
*/
export const fetchContentLength = (path) =>
fetchHeaders(path).then(decodeContentLength);
export default fetchContentLength

View File

@ -0,0 +1,22 @@
//@ts-check
/**
* Limited fetch request that retrieves only headers.
* @param {string} path
*/
export const fetchHeaders = async (path) => {
const response = await fetch(path, {
method: "HEAD",
headers: {
Range: "bytes=0-0",
"X-HTTP-Method-Override": "HEAD",
},
});
if (!response.ok) {
throw new Error(`Failed loading file '${path}'`);
}
return response.headers;
};
export default fetchHeaders;

View File

@ -0,0 +1,16 @@
//@ts-check
import { fetchText } from "./fetchText.mjs";
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
import { markupToDom } from "./markupToDom.mjs"
/**
* 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 => markupToDom(raw, path));
export default fetchMarkdown;

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,42 @@
//@ts-check
/**
* Generates valid dom elements from a string
* @param {string} htmlString
*/
export const generateDomFromString = (htmlString) => {
htmlString = htmlString.trim();
const dom = new DOMParser().parseFromString('<template>'+ htmlString +'</template>','text/html')
const content = /** @type {HTMLTemplateElement} */(dom.head.firstElementChild).content
const fragment = document.createDocumentFragment();
fragment.append(content);
return fragment;
};
export default generateDomFromString;
/**
* @param {String} html representing a single element
* @return {Element|null}
*/
function htmlToElement(html) {
html = html.trim();
var template = document.createElement("template");
template.innerHTML = html;
if (template.content.childNodes.length) {
}
return /** @type {Element} */ (template.content.firstChild);
}
/**
* @param {String} html representing any number of sibling elements
* @return {NodeList}
*/
function htmlToElements(html) {
var template = document.createElement("template");
template.innerHTML = html;
return template.content.childNodes;
}

View File

@ -0,0 +1,22 @@
//@ts-check
/**
* @typedef {{width: number;height: number}} Size
*/
/**
* Calculates an ideal ratio
* @param {Size} initial the initial size
* @param {Size} current the current size
* @returns
*/
export const getAspectRatio = (initial, current) => {
const ratioW = current.width / initial.width;
const ratioH = current.height / initial.height;
const ratio = Math.min(ratioW, ratioH);
const width = initial.width * ratio;
const height = initial.height * ratio;
return { width, height, ratio };
};
export default getAspectRatio

View File

@ -0,0 +1,23 @@
//@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 const hasCurrentHashUrl = () => getCurrentHashUrl().path !== "";
export const hasNoHashUrl = () => getCurrentHashUrl().path === "";
getCurrentHashUrl.hasCurrentHashUrl = hasCurrentHashUrl;
getCurrentHashUrl.hasNoHashUrl = hasNoHashUrl;
export default getCurrentHashUrl;

View File

@ -0,0 +1,19 @@
//@ts-check
import {isElement} from "./isElement.mjs";
/**
* Little utility so people can pass css selectors or elements in initialization
* options.
* A minimal replacement to a more full fledged selector engine like jQuery
* @param {string|HTMLElement} elementOrString
* @returns {HTMLElement | null}
*/
export const getElement = (elementOrString) => {
const element =
elementOrString && typeof elementOrString === "string"
? /** @type {HTMLElement}*/ (document.querySelector(elementOrString))
: elementOrString;
return isElement(element) ? element : null;
};
export default getElement

View File

@ -0,0 +1,19 @@
//@ts-check
import { isLocalHost } from "./isLocalHost.mjs";
/**
* Gets an element by a valid selector 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} selector
* @return {HTMLElement}
*/
export const getElementByCSSSelector = (selector) => {
const element = document && document.querySelector(selector);
if (isLocalHost && !element) {
throw new Error(`Element "#${selector}" was not found`);
}
// @ts-ignore
return element;
};
export default getElementByCSSSelector

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;

View File

@ -0,0 +1,12 @@
/**
* Returns the first title content in the document, if there is one.
* @param {Node} content
* @returns
*/
export const getFirstTitleContent = (content = document) => {
/** @type {HTMLHeadElement} */
const firstTitleElement = content.querySelector("h1");
return firstTitleElement ? firstTitleElement.textContent || "" : "";
};
export default getFirstTitleContent;

View File

@ -0,0 +1,16 @@
//@ts-check
/**
* Retrieves the current locale. Only works if `navigator` is available.
* Otherwise, returns the `defaultLang` passed property
* @param {string} [defaultLang] defaults to an empty string
* @returns The browser locale, formatted as `xx_XX`
*/
export const getLocale = (defaultLang = "") =>
(typeof navigator !== "undefined" &&
(navigator.languages ? navigator.languages[0] : navigator.language)
.split(".")[0]
.replace("-", "_")) ||
defaultLang;
export default getLocale;

View File

@ -0,0 +1,25 @@
//@ts-check
import {getReasonableUuid} from "./getReasonableUuid.mjs"
/**
* Returns an id that is guaranteed to not exist in the current loaded page.
* Only usable in the browser, after the page has loaded.
* Using it outside the browser or prior to loading will yield a pseudo-unique id,
* but not guaranteed unique.
* Ids are deterministic, so calling this function in the same order will always return
* the same set of ids.
* @see {uuid}
* @param {string} prefix any prefix you like, it helps discriminate ids
*/
export const getPageUniqueId = (prefix = "") => {
let id = prefix;
let limit = 99999;
while (
typeof document !== "undefined" &&
document.getElementById((id = `${prefix}${getReasonableUuid()}`)) &&
limit-- > 0
);
return id;
};
export default getPageUniqueId

View File

@ -0,0 +1,14 @@
//@ts-check
/**
* short random string for ids - not guaranteed to be unique
* @see https://www.codemzy.com/blog/random-unique-id-javascript
* @param {number} length the length of the id
*/
export const getRandomId = function (length = 6) {
return Math.random()
.toString(36)
.substring(2, length + 2);
};
export default getRandomId

View File

@ -0,0 +1,12 @@
//@ts-check
/**
* A reasonably unique id.
* Deterministic, it will always return the same id for the same call order
*/
export const getReasonableUuid = (() => {
let start = 100000000;
return () => (start++).toString(36);
})();
export default getReasonableUuid

17
modules/utils/html.mjs Normal file
View File

@ -0,0 +1,17 @@
/**
* Does exactly the same as simply using ``, but allows to
* neatly highlight the html string in editors that support it.
* Packages the resulting string as a template
* @param {TemplateStringsArray} strings
* @param {...any} expressions
*/
export function html(strings, ...expressions){
const parsed = strings.reduce((previous, current, i) => {
return previous + current + (expressions[i] ? expressions[i] : '')
}, '')
const template = document.createElement("template")
template.innerHTML = parsed
return template
}
export default html

View File

@ -0,0 +1,17 @@
/**
* @template T
* @param {T} [value]
* @returns {Promise<T>}
*/
export const awaitedIdentity = (value) => Promise.resolve(value);
/**
* @template T
* @param {T} [value]
* @returns {T}
*/
export const identity = (value) => value
identity.awaited = awaitedIdentity;
export default identity;

149
modules/utils/index.mjs Normal file
View File

@ -0,0 +1,149 @@
//@ts-check
import { changeTitle } from "./changeTitle.mjs";
//import { createCustomElement } from "./createCustomElement.mjs";
import { createElementStatusModes } from "./createElementStatusModes.mjs";
import { createTrackedResponse } from "./createTrackedResponse.mjs";
import { decodeContentLength } from "./decodeContentLength.mjs";
import { deferredPromise } from "./deferredPromise.mjs";
import { documentState } from "./documentState.mjs";
import { elementMode } from "./elementMode.mjs";
import { escapeRegExp } from "./escapeRegExp.mjs";
import { fetchContentLength } from "./fetchContentLength.mjs";
import { fetchHeaders } from "./fetchHeaders.mjs";
import { fetchMarkdown } from "./fetchMarkdown.mjs";
import { fetchText } from "./fetchText.mjs";
import { generateDomFromString } from "./generateDomFromString.mjs";
import { getAspectRatio } from "./getAspectRatio.mjs";
import {
getCurrentHashUrl,
hasCurrentHashUrl,
hasNoHashUrl,
} from "./getCurrentHashUrl.mjs";
import { getElement } from "./getElement.mjs";
import { getElementByCSSSelector } from "./getElementByCSSSelector.mjs";
import { getElementById } from "./getElementById.mjs";
import { getFirstTitleContent } from "./getFirstTitleContent.mjs";
import { getLocale } from "./getLocale.mjs";
import { getPageUniqueId } from "./getPageUniqueId.mjs";
import { getRandomId } from "./getRandomId.mjs";
import { getReasonableUuid } from "./getReasonableUuid.mjs";
import { identity, awaitedIdentity } from "./identity.mjs";
import { isElement } from "./isElement.mjs";
import { html } from "./html.mjs";
import { isExternalUrl } from "./isExternalUrl.mjs";
import { isLocalHost } from "./isLocalHost.mjs";
import { isNotNull } from "./isNotNull.mjs";
import { makeBoundConsole } from "./makeBoundConsole.mjs";
import { makeEventEmitter } from "./makeEventEmitter.mjs";
import { makeFileLoader, makeFileLoadersTracker } from "./makeFileLoader.mjs";
import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs";
import { makeSignal } from "./makeSignal.mjs";
import { makeStyleSheet, css } from "./makeStyleSheet.mjs";
import { makeTemplate, tmpl } from "./makeTemplate.mjs";
import { markdownToMarkup } from "./markdownToMarkup.mjs";
import { markupToDom } from "./markupToDom.mjs";
import { memoize } from "./memoize.mjs";
import { noOp } from "./noOp.mjs";
import { not } from "./not.mjs";
import {
onDocumentKeyUp,
onDocumentKeyDown,
onDocumentKey,
} from "./onDocumentKey.mjs";
import {
basename,
filename,
stripExtension,
dirName,
extension,
metadata,
} from "./path.mjs";
import { percentFromProgress } from "./percentFromProgress.mjs";
import { print, makeMiniStringTemplate } from "./print.mjs";
import {
querySelectorDoc,
querySelectorParent,
querySelectorAll,
} from "./querySelectorAll.mjs";
import { retryPromise } from "./retryPromise.mjs";
import { rewriteLocalUrls } from "./rewriteLocalUrls.mjs";
import { throttle } from "./throttle.mjs";
import { today } from "./today.mjs";
import { trackProgressWithCSS } from "./trackProgressWithCSS.mjs";
import { UnreachableCaseError } from "./UnreachableCaseError.mjs";
import { wait } from "./wait.mjs";
import { waitIfLocalHost } from "./waitIfLocalHost.mjs";
export {
changeTitle,
createElementStatusModes,
createTrackedResponse,
decodeContentLength,
deferredPromise,
elementMode as documentMode,
documentState,
escapeRegExp,
fetchContentLength,
fetchHeaders,
fetchMarkdown,
fetchText,
generateDomFromString,
getAspectRatio,
getCurrentHashUrl,
hasCurrentHashUrl,
hasNoHashUrl,
getElement,
getElementByCSSSelector,
getElementById,
getFirstTitleContent,
getLocale,
getPageUniqueId,
getRandomId,
getReasonableUuid,
html,
identity,
awaitedIdentity,
isElement,
isExternalUrl,
isLocalHost,
isNotNull,
makeBoundConsole,
makeEventEmitter,
makeFileLoader,
makeFileLoadersTracker,
makeFileSizeFetcher,
makeSignal,
makeStyleSheet,
css,
makeTemplate,
tmpl,
markdownToMarkup,
markupToDom,
memoize,
not,
noOp,
onDocumentKeyUp,
onDocumentKeyDown,
onDocumentKey,
basename,
filename,
stripExtension,
dirName,
extension,
metadata,
percentFromProgress,
print,
makeMiniStringTemplate,
querySelectorDoc,
querySelectorParent,
querySelectorAll,
retryPromise,
rewriteLocalUrls,
throttle,
today,
trackProgressWithCSS,
UnreachableCaseError,
wait,
waitIfLocalHost,
};

View File

@ -0,0 +1,12 @@
//@ts-check
/**
* Verifies an element is actually an element.
* @param {any} element
* @returns {element is HTMLElement}
*/
export const isElement = (element) => {
return element instanceof Element || element instanceof Document;
};
export default isElement

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;

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;

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;

View File

@ -0,0 +1,19 @@
//@ts-check
const methods = /** @type {('warn' & keyof typeof console)[]} */(["log", "warn", "error"])
/**
* Returns console methods that can be used standalone (without requiring `console`).
* Optional prefix will prepend every call with the provided prefix
* @param {any} prefix
*/
export const makeBoundConsole = (prefix = "") => {
const [log, warn, error] = methods.map(
(fn) =>
(/** @type {any} */ msg) =>
console[fn](prefix, msg)
);
return { log, warn, error };
};
export default makeBoundConsole

47
modules/utils/makeEventEmitter.d.ts vendored Normal file
View File

@ -0,0 +1,47 @@
/**
* Base type that a CustomEventEmitter may receive as a generic type
* to discriminate possible events.
*/
type CustomEventMap = Record<string, unknown>;
/**
* Extracts the keys from a CustomEventMap so they can be used as event types.
* For example, for the CustomEventMap
* ```ts
* {
* "longpress": {position: number[]}
* "shortpress": {position: number[]}
* }
* ```
* This type will infer `"longpress" | "shortpress"`.
*/
type CustomEventKey<EvtMap extends CustomEventMap> = string & keyof EvtMap;
/**
* Any function or object that can be used to listen to an event.
*/
type CustomEventListenerOrEventListenerObject<T> =
| { handleEvent(event: CustomEvent<T>): void }
| ((event: CustomEvent<T>) => void);
/**
* An event emitter that can be mixed in to other objects.
*/
interface CustomEventEmitter<EvtMap extends CustomEventMap> {
addEventListener<K extends CustomEventKey<EvtMap>>(
eventName: K,
fn: CustomEventListenerOrEventListenerObject<EvtMap[K]>,
options?: AddEventListenerOptions | boolean
): void;
removeEventListener<K extends CustomEventKey<EvtMap>>(
eventName: K,
fn: CustomEventListenerOrEventListenerObject<EvtMap[K]>,
options?: EventListenerOptions | boolean
): void;
dispatchEvent<K extends CustomEventKey<EvtMap>>(
eventName: K,
detail: EvtMap[K]
): void;
signal: AbortSignal;
abort: AbortController["abort"];
}

View File

@ -0,0 +1,55 @@
//@ts-check
/// <reference path="makeEventEmitter.d.ts"/>
/**
* Returns a native browser event target that is properly typed.
* Three major differences with a classical event target:
*
* 1. The emitter's methods are bound and can be passed to other objects
* 2. The emitter has an `abort` property and a `signal` property that can be
* used to abort all listeners (you have to explicitely pass it though, it's
* not automatic)
* 3. `dispatchEvent` has a different signature `(type, event)` rather than just
* `event`. This is because there is no way to enforce a string & details
* tuple on a CustomEvent using Typescript or JSDocs.
* @template {CustomEventMap} EvtMap
* @returns {CustomEventEmitter<EvtMap>}
*/
export const makeEventEmitter = () => {
let abortController = new AbortController();
const eventEmitter = new EventTarget();
const addEventListener = eventEmitter.addEventListener.bind(eventEmitter);
const removeEventListener =
eventEmitter.removeEventListener.bind(eventEmitter);
/**
* Dispatches a custom event to all listeners of that event.
* @type {CustomEventEmitter<EvtMap>["dispatchEvent"]}
*/
const dispatchEvent = (type, detail) => {
const event = new CustomEvent(type, { detail });
eventEmitter.dispatchEvent(event);
};
/**
* Aborts any eventListener, fetch, or other process that received the signal.
* resets the abort controller and signal (they are new instances)
* @param {any} reason
*/
const abort = (reason) => {
abortController.abort(reason);
abortController = new AbortController();
};
return {
dispatchEvent,
addEventListener,
removeEventListener,
abort,
get signal() {
return abortController.signal;
},
};
};
export default makeEventEmitter

View File

@ -0,0 +1,224 @@
//@ts-check
import { createTrackedResponse } from "./createTrackedResponse.mjs";
import { decodeContentLength } from "./decodeContentLength.mjs";
import { retryPromise } from "./retryPromise.mjs";
import { makeSignal } from "./makeSignal.mjs";
import { fetchContentLength } from "./fetchContentLength.mjs";
import { metadata } from "./path.mjs";
/**
* @typedef {{
* total?: number,
* onProgress?:(data:ProgressData)=>void,
* signal?: AbortSignal
* }} FileLoaderOptions
*/
/**
* @template T
* @typedef {import("./makeSignal.mjs").Signal<T>} Signal<T>
*/
/**
* @typedef {ReturnType<typeof makeFileLoader>} FileLoader
*/
/**
* Creates an object that downloads files and allows you to track progress.
* You can optionally provide an initial file size; if not provided, the size
* will be attempt to be acquired through reading the headers when download starts.
*
* Downloads can be aborted with an AbortSignal, and the response can be obtained on
* `response`.
*
* `response` can be awaited even before the download starts.
*
* @typedef {{received: number, total:number}} ProgressData
* @param {string} path
* @param {FileLoaderOptions} options
*/
export const makeFileLoader = (
path,
{ total = 0, onProgress, signal } = {}
) => {
const progress = makeSignal({ path, received: 0, total: 0 });
/** @type {Signal<Response>} */
const done = makeSignal();
/** @type {Signal<Error>} */
const failed = makeSignal();
if (onProgress) {
progress.connect(onProgress, { signal });
}
const createResponse = () => {
const response = createTrackedResponse(
(received) => progress.emit({ path, received, total }),
signal
);
response.then(done.emit);
response.catch(failed.emit);
return response;
};
let responsePromise = createResponse();
/**
* Retrieves the file size if `total` was not provided.
*/
const fetchSize = (() => {
/**
* @type {Promise<number> | null}
*/
let totalPromise;
const fetchSize = () =>
(totalPromise = total
? Promise.resolve(total)
: fetchContentLength(path).then(
(fetchedTotal) => (total = fetchedTotal)
));
return fetchSize;
})();
/**
* Fetches a file, dispatching progress events while doing so.
*/
const start = () =>
fetch(path, { signal }).then((initialResponse) => {
if (!initialResponse.body || !initialResponse.ok) {
throw new Error(`Failed loading file '${path}'`);
}
total = total || decodeContentLength(initialResponse.headers);
const reader = initialResponse.body.getReader();
// this triggers the deferred promise and starts the loading process
responsePromise.start(reader);
return responsePromise.response;
});
/**
* Loads a file, _if it isn't already loaded_.
* If it is loaded, or if the loading has started, then this results in a
* no-op.
* It's therefore safe to use multiple times.
* This function returns as soon as the download is triggered. To await the
* download, await the `response` property and/or listen to the success event.
*
* @param {number} retries amount of time to try again in case of failure.
* Defaults to 4.
* @returns {Promise<Response>}
*/
const load = (retries = 4) =>
new Promise((ok, no) => {
if (responsePromise.isStarted) {
return ok(responsePromise.response);
}
if (responsePromise.isFailed) {
return no();
}
return retryPromise(start, retries).then(ok).catch(no);
});
/**
* clears loaded file from memory (if there are no more references to it)
*/
const unload = () => {
responsePromise = createResponse();
};
return {
...metadata(path),
fetchSize,
load,
unload,
done,
failed,
progress,
get path() {
return path;
},
get total() {
return total;
},
set total(newTotal) {
total = newTotal;
},
get received() {
return responsePromise.received;
},
get isComplete() {
return total > 0 && responsePromise.received === total;
},
get response() {
return responsePromise.response;
},
};
};
/**
* Allows to track a group of file loaders
* @param {FileLoader[]} files
*/
export const makeFileLoadersTracker = (files) => {
const done = makeSignal();
const progress = makeSignal({ received: 0, total: 0 });
let total = 0;
let received = 0;
let amount = files.length;
const decrease = () => {
if (--amount === 0) {
done.emit();
}
};
/**
* Called every time any file's status changes.
* Updates the total and received amounts.
*/
const update = (props) => {
let _total = 0;
let _received = 0;
files.forEach((fileLoader) => {
_total += fileLoader.total;
_received += fileLoader.received;
});
if (total != _total || received != _received) {
total = _total;
received = _received;
progress.emit({ total, received });
}
};
files.forEach((fileLoader) => {
fileLoader.done.connect(decrease);
fileLoader.progress.connect(update);
});
/**
* Runs `fetchSize` on all files to ensure all have a size
*/
const ensureSize = () =>
Promise.all(files.map(({ fetchSize }) => fetchSize()));
/**
* Loads all files. Loading happens once per file, so if it was called before, this is a
* no-op (for each particular file).
* If a file does not have a total set, then a small header fetch can optionally happen
* to fetch the initial size.
* @param {boolean} [andEnsureSize] set it to `false` to skip fetching sizes for files
* without a specified total
* @returns
*/
const loadAll = (andEnsureSize = true) =>
(andEnsureSize ? ensureSize() : Promise.resolve()).then(() =>
Promise.all(files.map(({ load }) => load()))
);
return { done, progress, ensureSize, loadAll };
};
export default makeFileLoader;

View File

@ -0,0 +1,39 @@
//@ts-check
import { fetchContentLength } from "./fetchContentLength.mjs";
/**
* Conditional size fetcher, which can be skipped by providing an intial size.
* This function is only useful when used as part of a larger loading framework, when
* files loading may or may not have access to size information, and you want a
* consistent behavior regardless.
*
* If `estimatedTotal` is passed, this is a no-op.
* If `estimatedTotal` is not passed, the created function does a limited `fetch`
* to attempt to retrieve the file size.
* Repeated calls to the function will not repeat the fetch request.
* The function is not guaranteed to succeed, the server has to play along by
* sending the correct headers.
* Ideally, `total` is passed instead to avoid this.
* @see {fetchContentLength} decodeContentLength
* @param {string} filePath
* @param {number} estimatedTotal
* @returns {()=>Promise<number>} a function that always returns the same promise
*/
export const makeFileSizeFetcher = (filePath, estimatedTotal = 0) => {
/**
* @type {Promise<number> | null}
*/
let totalPromise = estimatedTotal ? Promise.resolve(estimatedTotal) : null;
const fetchSize = () =>
(totalPromise =
totalPromise ||
fetchContentLength(filePath).then(
(fetchedTotal) => (estimatedTotal = fetchedTotal)
));
return fetchSize;
};
export default makeFileSizeFetcher;

View File

@ -0,0 +1,69 @@
//@ts-check
/**
* @template T
* @typedef {(args:T) => void} Listener<T>
*/
/**
* @typedef {{signal?: AbortSignal, once?: boolean}} ListenerOptions
*/
/**
* @template T
* @typedef {{
* connect(listener: Listener<T>, options?: ListenerOptions): () => boolean,
* disconnect(listener: Listener<T>): boolean,
* emit(args: T): void
* disable(): void
* }} Signal<T>
*/
/**
* Returns an event emitter for a specific signal
* The initial passed value is optional, discarded, and only used to provide
* automatic typing where applicable.
* @template T
* @param {T} [_initial]
* @returns {Signal<T>}
*/
export const makeSignal = (_initial) => {
/** @type {Set<Listener<T>>} */
const listeners = new Set();
let enabled = true
/**
*
* @param {Listener<T>} fn
* @param {ListenerOptions} [options]
*/
const connect = (fn, { once, signal } = {}) => {
if (once) {
const _bound = fn;
fn = (args) => {
listeners.delete(fn);
_bound(args);
};
}
listeners.add(fn);
const _disconnect = () => disconnect(fn);
signal && signal.addEventListener("abort", _disconnect);
return _disconnect;
};
/**
* @param {Listener<T>} fn
* @returns
*/
const disconnect = (fn) => listeners.delete(fn);
/**
* @param {T} [args]
* @returns
*/
// @ts-ignore
const emit = (args) => enabled && listeners.forEach((fn) => fn(args));
const disable = () => {enabled = false}
return { connect, disconnect, emit, disable };
};
export default makeSignal

View File

@ -0,0 +1,24 @@
//@ts-check
/**
* Returns a stylesheet
* @param {string} css_string
* @returns
*/
export const makeStyleSheet = (css_string) => {
const stylesheet = new CSSStyleSheet();
stylesheet.replaceSync(css_string);
return stylesheet
}
/**
* Convenience literal to create DOM CSSStyleSheet instances
* @param {TemplateStringsArray} strings
* @param {...any} substitutions
*/
export const css = (strings, ...substitutions) => {
const formattedString = strings.reduce((acc, curr, index) => acc + curr + (substitutions[index]||''), '');
console.log(formattedString)
return makeStyleSheet(formattedString);
}

View File

@ -0,0 +1,23 @@
//@ts-check
/**
* Returns a template
* @param {string} template_string
* @returns
*/
export const makeTemplate = (template_string) => {
const template = document.createElement("template")
template.innerHTML = template_string
return template
}
/**
* Convenience literal to create DOM template elements
* @param {TemplateStringsArray} strings
* @param {...any} substitutions
*/
export const tmpl = (strings, ...substitutions) => {
const formattedString = strings.reduce((acc, curr, index) => acc + curr + (substitutions[index]||''), '');
return makeTemplate(formattedString);
}

View File

@ -0,0 +1,28 @@
//@ts-check
//@ts-ignore
import { micromark } from "https://esm.sh/micromark@3?bundle";
//@ts-ignore
import {frontmatter, frontmatterHtml} from 'https://esm.sh/micromark-extension-frontmatter@1?bundle'
//@ts-ignore
import Yaml from 'https://esm.sh/yaml@2?bundle'
/**
* Transforms a markup string into a valid markup.
* If there's a YAML frontmatter, will parse it too
* @param {string} markdownStr
* @returns
*/
export const markdownToMarkup = (markdownStr) => {
/** @type {string} */
const markup = micromark(markdownStr, {
extensions: [frontmatter()],
htmlExtensions: [frontmatterHtml()]
})
const header = Yaml.parseAllDocuments(markdownStr)[0]
/** @type {Record<string, unknown>} */
const frontMatter = header ? header.toJS() : {}
return { markup, frontMatter }
}
export default markdownToMarkup

View File

@ -0,0 +1,20 @@
//@ts-check
import { generateDomFromString } from "./generateDomFromString.mjs";
import { getFirstTitleContent } from "./getFirstTitleContent.mjs";
import { markdownToMarkup } from './markdownToMarkup.mjs';
/**
* Takes a markdown string and transforms it into dom that can be slotted in a
* document.
* Additionally, it parses the frontmatter, and attempts to extract a title
* by finding either the first title in the doc, or the filename (if provided).
* @param {string} markdownStr
* @param {string} [path] the file path
*/
export const markupToDom = (markdownStr, path = '') => {
const { frontMatter, markup } = markdownToMarkup(markdownStr);
const content = generateDomFromString(markup);
const title = getFirstTitleContent(content) || path.replace(/\.\w{2, 4}$/, "");
return { title, raw: markdownStr, content, frontMatter };
};

34
modules/utils/memoize.mjs Normal file
View File

@ -0,0 +1,34 @@
//@ts-check
const pickFirstArg = (fst, ..._none) => JSON.stringify(fst)
/**
* Caches the result of a function.
* The cache is available as `.cache` in case there's a need to clear anything.
* Uses the first parameter as a key by default, you can change this behavior by passing a custom
* hash function.
* @template {(...args: any[]) => any} T
* @param {T} functionToMemoize
* @param {(...args: Parameters<T>) => string|number} [hashFunction]
* @returns
*/
export const memoize = (functionToMemoize, hashFunction = pickFirstArg) => {
/** @type {Map<string|number, ReturnType<T>>} */
const cache = new Map()
/**
*
* @param {Parameters<T>} args
* @returns {ReturnType<T>}
*/
const memoized = (...args) => {
const key = hashFunction(...args)
if(!cache.has(key)){
cache.set(key, functionToMemoize(...args))
}
return cache.get(key)
}
return Object.assign(memoized, { cache })
}
export default memoize

8
modules/utils/noOp.mjs Normal file
View File

@ -0,0 +1,8 @@
//@ts-check
/**
* Does absolutely nothing. Use it when you need a function that does nothing
* at all.
*/
export const noOp = () => {}
export default noOp

9
modules/utils/not.mjs Normal file
View File

@ -0,0 +1,9 @@
/**
* Inverter.
* Easier to read than "!"
* @param {any} a
* @returns
*/
export const not = a => !a
export default not

View File

@ -0,0 +1,43 @@
//@ts-check
/**
*
* @param {string} keyToListen
* @param {()=>void} callback
*/
export const onDocumentKeyUp = (keyToListen, callback) => {
document.addEventListener(
"keyup",
({ key }) => key === keyToListen && callback()
);
};
/**
*
* @param {string} keyToListen
* @param {()=>void} callback
*/
export const onDocumentKeyDown = (keyToListen, callback) => {
document.addEventListener(
"keydown",
({ key }) => key === keyToListen && callback()
);
};
/**
*
* @param {string} keyToListen
* @param {(down:boolean)=>void} callback
*/
export const onDocumentKey = (keyToListen, callback) => {
document.addEventListener(
"keyup",
({ key }) => key === keyToListen && callback(false)
);
document.addEventListener(
"keydown",
({ key }) => key === keyToListen && callback(true)
);
};
export default onDocumentKey;

40
modules/utils/path.mjs Normal file
View File

@ -0,0 +1,40 @@
/**
*
* @param {string} str
* @param {string} [sep] defaults to `/`
*/
export const filename = (str, sep = '/') => str.slice(str.lastIndexOf(sep) + 1)
/**
* @param {string} str
*/
export const stripExtension = (str) => str.slice(0,str.lastIndexOf('.'))
/**
*
* @param {string} str
* @param {string} [sep] defaults to `/`
*/
export const basename = (str, sep = '/') => stripExtension(filename(str, sep))
/**
* @param {string} str
*/
export const extension = (str) => str.slice(str.lastIndexOf('.') + 1)
/**
* @param {string} str
* @param {string} [sep] defaults to `/`
*/
export const dirName = (str, sep = '/') => str.slice(0, str.lastIndexOf(sep) + 1)
export const metadata = (str, sep = '/') => ({
basename: basename(str, sep),
filename: filename(str, sep),
extension: extension(str),
dirName: dirName(str),
fullPath: str
})
export default { basename, filename, stripExtension, dirName, extension, metadata }

View File

@ -0,0 +1,11 @@
//@ts-check
/**
* Returns a formatted percent from a given fraction.
* @param {number} fraction any fractional number, e.g, 5/10
* @param {boolean} [pad]
*/
export const percentFromProgress = (fraction, pad = false) =>
/** @type {`${string}%`} */ (Math.round(fraction * 100) + "%").padStart(pad? 3 : 0, "0");
export default percentFromProgress

19
modules/utils/print.mjs Normal file
View File

@ -0,0 +1,19 @@
/**
* Mini-mustache templating system. Simply replaces all occurrences of {{key}} with the value of the key.
* @param {string} str
* @param {Record<string, any>} replacements
*/
export const print = (str, replacements) =>
str.replace(/{{(.*?)}}/g, (_match, key) => replacements[key] || '')
/**
*
* @param {string} str
* @returns {(replacements: Record<string, any>) => string}
*/
export const makeMiniStringTemplate = (str) => print.bind(null, str)
print.makeTemplate = makeMiniStringTemplate
export default print

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,24 @@
//@ts-check
/**
* Retries a promise N times, allowing it to fail by silently swallowing
* errors, until `N` has run out.
* @template {any} T
* @param {()=>Promise<T>} promiseProviderFunc
* @param {number} [max]
* @returns {Promise<T>}
*/
export const retryPromise = (promiseProviderFunc, max = 5) => {
if(max <= 0){
return promiseProviderFunc()
}
/** @type {Promise<T>} */
let promise = Promise.reject();
for (let i = 0; i < max; i++) {
promise = promise.catch(promiseProviderFunc);
}
return promise;
};
export default retryPromise

View File

@ -0,0 +1,20 @@
//@ts-check
import isExternalUrl from "./isExternalUrl.mjs";
import { querySelectorParent } from "./querySelectorAll.mjs";
/**
* Makes sure urls to local pages are prepended with #/
* @param {ParentNode} 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) && !href.startsWith("#")) {
a.setAttribute("href", "#/" + href.replace(/^\.?\//, ""));
}
});
return container;
};
export default rewriteLocalUrls;

View File

@ -0,0 +1,52 @@
//@ts-check
/**
*
* Creates a throttled function that only invokes the provided function at most
* once per within a given number of milliseconds.
*
* @template {(...args: any) => any } F
* @template {ReturnType<F>} R
* @template {Parameters<F>} P
* @param {F} func
* @param {number} [firingRateMs] Firing rate (50ms by default)
*/
export const throttle = (func, firingRateMs = 50) => {
/** @type {R} */
let lastResult;
/** @type {number} */
let last = 0;
/** @type {null|P} */
let funcArguments;
/** @type {number} */
let timeoutID = 0;
const call = () => {
timeoutID = 0;
last = +new Date();
lastResult = func.apply(null, funcArguments);
funcArguments = null;
};
/***
* @param {P} args
*/
const throttled = (...args) => {
funcArguments = args;
const delta = new Date().getTime() - last;
if (!timeoutID)
if (delta >= firingRateMs) {
call();
} else {
timeoutID = setTimeout(call, firingRateMs - delta);
}
return lastResult;
};
return throttled;
};
export default throttle

3
modules/utils/today.mjs Normal file
View File

@ -0,0 +1,3 @@
export const today = new Date().toISOString().split("T")[0];
export default today

View File

@ -0,0 +1,35 @@
//@ts-check
import { percentFromProgress } from "./percentFromProgress.mjs";
/**
* Returns a function that can update an HTML Element.
* The function, when called with `received` and `total`, set 3 custom css vars
* on the element, which can be used in CSS to reflect the state of the object
* @param {HTMLElement} element
*/
export const trackProgressWithCSS = (element, prefix = "load-") => {
const keyFract = `--${prefix}fraction`;
const keyProgress = `--${prefix}progress`;
const keyProgressStr = `--${prefix}progress-str`;
const classComplete = `${prefix}complete`;
/**
* @param {{received: number, total: number}} progress
*/
const setProgress = ({ received, total }) => {
const final = received == total;
const fraction = final ? 1 : received / total;
const percent = final ? "100%" : percentFromProgress(fraction, true);
console.log(keyProgress, percent);
element.style.setProperty(keyFract, `${fraction}`);
element.style.setProperty(keyProgress, percent);
element.style.setProperty(keyProgressStr, `"${percent}"`);
if (final) {
console.log("all done!", element, classComplete)
requestAnimationFrame(() => element.classList.add(classComplete));
}
};
return setProgress;
};
export default trackProgressWithCSS;

18
modules/utils/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,24 @@
//@ts-check
import wait from "./wait.mjs";
import { awaitedIdentity } from "./identity.mjs";
import isLocalHost from "./isLocalHost.mjs";
/**
* 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) => awaitedIdentity;
/**
* 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;