diff --git a/components/index.mjs b/components/index.mjs new file mode 100644 index 0000000..81d4665 --- /dev/null +++ b/components/index.mjs @@ -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`

Hello World!

` + +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() + } + } +} \ No newline at end of file diff --git a/examples/musings/400016300497_6129063567043520548-1.jpg b/examples/musings/400016300497_6129063567043520548-1.jpg new file mode 100644 index 0000000..b78b6f0 Binary files /dev/null and b/examples/musings/400016300497_6129063567043520548-1.jpg differ diff --git a/examples/musings/an-armenian-at-a-french-school-in-saudi-arabia.md b/examples/musings/an-armenian-at-a-french-school-in-saudi-arabia.md new file mode 100644 index 0000000..91f4a98 --- /dev/null +++ b/examples/musings/an-armenian-at-a-french-school-in-saudi-arabia.md @@ -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 1980’s 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 Pony’s 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. \ No newline at end of file diff --git a/examples/musings/anastase-maragos-kqkzdnujczq-unsplash.jpg b/examples/musings/anastase-maragos-kqkzdnujczq-unsplash.jpg new file mode 100644 index 0000000..c6b74ac Binary files /dev/null and b/examples/musings/anastase-maragos-kqkzdnujczq-unsplash.jpg differ diff --git a/examples/musings/annie-spratt-fcwmnaarumk-unsplash.jpg b/examples/musings/annie-spratt-fcwmnaarumk-unsplash.jpg new file mode 100644 index 0000000..7498e0e Binary files /dev/null and b/examples/musings/annie-spratt-fcwmnaarumk-unsplash.jpg differ diff --git a/examples/musings/armenian-french-school.png b/examples/musings/armenian-french-school.png new file mode 100644 index 0000000..4ff614d Binary files /dev/null and b/examples/musings/armenian-french-school.png differ diff --git a/examples/musings/artisanmarmikhael.jpg b/examples/musings/artisanmarmikhael.jpg new file mode 100644 index 0000000..6220d7a Binary files /dev/null and b/examples/musings/artisanmarmikhael.jpg differ diff --git a/examples/musings/files.txt b/examples/musings/files.txt new file mode 100644 index 0000000..1789d88 --- /dev/null +++ b/examples/musings/files.txt @@ -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 \ No newline at end of file diff --git a/examples/musings/gregory-morit-gm0a9bae6gu-unsplash.jpg b/examples/musings/gregory-morit-gm0a9bae6gu-unsplash.jpg new file mode 100644 index 0000000..d10c133 Binary files /dev/null and b/examples/musings/gregory-morit-gm0a9bae6gu-unsplash.jpg differ diff --git a/examples/musings/index.html b/examples/musings/index.html new file mode 100644 index 0000000..43c2079 --- /dev/null +++ b/examples/musings/index.html @@ -0,0 +1,16 @@ + + + + Tickle + + + + +
+

+    
+

+
+ + + diff --git a/examples/musings/julia-joppien-w-7h8oxrawc-unsplash.jpg b/examples/musings/julia-joppien-w-7h8oxrawc-unsplash.jpg new file mode 100644 index 0000000..3cb3d09 Binary files /dev/null and b/examples/musings/julia-joppien-w-7h8oxrawc-unsplash.jpg differ diff --git a/examples/musings/marmikhael-newproject.jpg b/examples/musings/marmikhael-newproject.jpg new file mode 100644 index 0000000..0001382 Binary files /dev/null and b/examples/musings/marmikhael-newproject.jpg differ diff --git a/examples/musings/marmikhael-village-copy.jpg b/examples/musings/marmikhael-village-copy.jpg new file mode 100644 index 0000000..5660238 Binary files /dev/null and b/examples/musings/marmikhael-village-copy.jpg differ diff --git a/examples/musings/marmikhael-village.jpg b/examples/musings/marmikhael-village.jpg new file mode 100644 index 0000000..ee69e1e Binary files /dev/null and b/examples/musings/marmikhael-village.jpg differ diff --git a/examples/musings/piano.jpg b/examples/musings/piano.jpg new file mode 100644 index 0000000..b56aaf2 Binary files /dev/null and b/examples/musings/piano.jpg differ diff --git a/examples/musings/portrait.jpg b/examples/musings/portrait.jpg new file mode 100644 index 0000000..b99b0ad Binary files /dev/null and b/examples/musings/portrait.jpg differ diff --git a/examples/musings/shitadvertising.jpg b/examples/musings/shitadvertising.jpg new file mode 100644 index 0000000..ff209d6 Binary files /dev/null and b/examples/musings/shitadvertising.jpg differ diff --git a/examples/musings/thanks-for-the-piano-lessons.md b/examples/musings/thanks-for-the-piano-lessons.md new file mode 100644 index 0000000..7b7c1e9 --- /dev/null +++ b/examples/musings/thanks-for-the-piano-lessons.md @@ -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. \ No newline at end of file diff --git a/examples/musings/to-the-cannibals-of-mar-mikhail.md b/examples/musings/to-the-cannibals-of-mar-mikhail.md new file mode 100644 index 0000000..e643ecf --- /dev/null +++ b/examples/musings/to-the-cannibals-of-mar-mikhail.md @@ -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 \ No newline at end of file diff --git a/examples/musings/une-histoire-de-portes-et-de-fenetres.jpg b/examples/musings/une-histoire-de-portes-et-de-fenetres.jpg new file mode 100644 index 0000000..7d0fccb Binary files /dev/null and b/examples/musings/une-histoire-de-portes-et-de-fenetres.jpg differ diff --git a/examples/musings/une-histoire-de-portes-et-de-fenetres.md b/examples/musings/une-histoire-de-portes-et-de-fenetres.md new file mode 100644 index 0000000..eb08e3e --- /dev/null +++ b/examples/musings/une-histoire-de-portes-et-de-fenetres.md @@ -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 d’anné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é d’un 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. “C’est 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 d’araigné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 l’accompagnera parfois pour ranger quelques affaires supplémentaires durant des années de déplacement de sous le toit d’une soeur à l’autre. 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 qu’ils aimaient. + +Même couverte de poussière, la maison me semblait neuve. Criblée de trous mais visiblement neuve, dans la mesure où on n’y avait habité que quelques années. Elle contenait encore dans ses placards et ses dressoirs, l’odeur de l’émotion de ses nouveaux habitants. + +Nous suivions maman à travers le long couloir et je me sentais comme Boucle d’Or. +“Ici c’était la chambre de Robert. Là c’est 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 s’ennuyait 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…” _(“C’est bien toi Liliane?? Je ne t’ai 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 l’anglophile m’a ainsi accidentellement enseigné l’anglais. Sans comprendre grand chose, je rongeais les pages de “Montezuma’s Daughter”, plongée dans un vieux sofa en velours rose capitonné, au fond du salon vestige qui, même à son apogée, n’avait 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 n’arrivait à la hauteur de notre excitation lorsqu’on arrivait par hasard à entrevoir, entre les rayures noires et blanches, le visage de l’orpheline Mexicaine Maria Mercedes. + +Pourquoi tous ces souvenirs maintenant, en plein milieu de la nuit à Kiev? + +J’ai 38 ans. J’ai quitté Beyrouth il y a quelques jours. Quatre jours avant la grande explosion. + +J’ai jeté vite-fait quelques affaires dans des sacs, et je suis partie chez mon frère à Kiev. Avant de partir, j’ai 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 d’oeil à la maison”)._ + +C’est 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 qu’ils aiment. + +Mais au diable mon appartement à Beyrouth. + +J’ai peur d’avoir des enfants si tard, et d’avoir un jour l’idé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, c’est Téta qui l’avait conçue” et “cette balustrade, c’est Geddo qui l’avait fabriquée”, “celle-ci la chambre avec le lit à baldaquins, c’était ma chambre.” + +![](une-histoire-de-portes-et-de-fenetres.jpg) \ No newline at end of file diff --git a/index.html b/index.html index cd2d05d..f0368a2 100644 --- a/index.html +++ b/index.html @@ -3,437 +3,18 @@ Tickle - - - - + - -
+
+ + +
+

     

- + diff --git a/modules/index.mjs b/modules/index.mjs new file mode 100644 index 0000000..2d79388 --- /dev/null +++ b/modules/index.mjs @@ -0,0 +1,4 @@ +//@ts-check +export * from './tickle/index.mjs' +export * from './utils/index.mjs' +export * as templates from './templates/index.mjs' \ No newline at end of file diff --git a/modules/templates/blog.css b/modules/templates/blog.css new file mode 100644 index 0000000..18679a1 --- /dev/null +++ b/modules/templates/blog.css @@ -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); + } +} diff --git a/modules/templates/blog.mjs b/modules/templates/blog.mjs new file mode 100644 index 0000000..ecac531 --- /dev/null +++ b/modules/templates/blog.mjs @@ -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(); +} diff --git a/modules/templates/index.mjs b/modules/templates/index.mjs new file mode 100644 index 0000000..77c4894 --- /dev/null +++ b/modules/templates/index.mjs @@ -0,0 +1,2 @@ +//@ts-check +export { default as blog } from './blog.mjs' \ No newline at end of file diff --git a/modules/tickle/bootstrapRouter.mjs b/modules/tickle/bootstrapRouter.mjs new file mode 100644 index 0000000..6dbbc68 --- /dev/null +++ b/modules/tickle/bootstrapRouter.mjs @@ -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 +}; diff --git a/modules/tickle/createMenuEntriesFromFileList.mjs b/modules/tickle/createMenuEntriesFromFileList.mjs new file mode 100644 index 0000000..6836203 --- /dev/null +++ b/modules/tickle/createMenuEntriesFromFileList.mjs @@ -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 = `{{title}}`; + +/** + * 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")); diff --git a/modules/tickle/index.mjs b/modules/tickle/index.mjs new file mode 100644 index 0000000..d6fa8a1 --- /dev/null +++ b/modules/tickle/index.mjs @@ -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, +}; diff --git a/modules/tickle/mode.mjs b/modules/tickle/mode.mjs new file mode 100644 index 0000000..aa192d6 --- /dev/null +++ b/modules/tickle/mode.mjs @@ -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"), +}; diff --git a/modules/tickle/parseFileList.mjs b/modules/tickle/parseFileList.mjs new file mode 100644 index 0000000..b2c1228 --- /dev/null +++ b/modules/tickle/parseFileList.mjs @@ -0,0 +1,59 @@ +//@ts-check + +import { today } from "../utils/today.mjs"; + +/** + * @typedef {Exclude, 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( + /(?.+)\.(?\w{2,3})(?:\s+(?[\d-]+)?(?.+))?/ + ); + 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; diff --git a/modules/tickle/sortFileListLines.mjs b/modules/tickle/sortFileListLines.mjs new file mode 100644 index 0000000..4d086e7 --- /dev/null +++ b/modules/tickle/sortFileListLines.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/UnreachableCaseError.mjs b/modules/utils/UnreachableCaseError.mjs new file mode 100644 index 0000000..901faf6 --- /dev/null +++ b/modules/utils/UnreachableCaseError.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/changeTitle.mjs b/modules/utils/changeTitle.mjs new file mode 100644 index 0000000..0434918 --- /dev/null +++ b/modules/utils/changeTitle.mjs @@ -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; diff --git a/modules/utils/createCustomElement.mjs b/modules/utils/createCustomElement.mjs new file mode 100644 index 0000000..764c2a9 --- /dev/null +++ b/modules/utils/createCustomElement.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/createElementStatusModes.mjs b/modules/utils/createElementStatusModes.mjs new file mode 100644 index 0000000..29f93e4 --- /dev/null +++ b/modules/utils/createElementStatusModes.mjs @@ -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; diff --git a/modules/utils/createTrackedResponse.mjs b/modules/utils/createTrackedResponse.mjs new file mode 100644 index 0000000..9b2cae7 --- /dev/null +++ b/modules/utils/createTrackedResponse.mjs @@ -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; diff --git a/modules/utils/decodeContentLength.mjs b/modules/utils/decodeContentLength.mjs new file mode 100644 index 0000000..deee6ef --- /dev/null +++ b/modules/utils/decodeContentLength.mjs @@ -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; diff --git a/modules/utils/deferredPromise.mjs b/modules/utils/deferredPromise.mjs new file mode 100644 index 0000000..b9151bb --- /dev/null +++ b/modules/utils/deferredPromise.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/documentState.mjs b/modules/utils/documentState.mjs new file mode 100644 index 0000000..61d5438 --- /dev/null +++ b/modules/utils/documentState.mjs @@ -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; diff --git a/modules/utils/elementMode.mjs b/modules/utils/elementMode.mjs new file mode 100644 index 0000000..c5700da --- /dev/null +++ b/modules/utils/elementMode.mjs @@ -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; diff --git a/modules/utils/escapeRegExp.mjs b/modules/utils/escapeRegExp.mjs new file mode 100644 index 0000000..a2079bd --- /dev/null +++ b/modules/utils/escapeRegExp.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/fetchContentLength.mjs b/modules/utils/fetchContentLength.mjs new file mode 100644 index 0000000..35a89f8 --- /dev/null +++ b/modules/utils/fetchContentLength.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/fetchHeaders.mjs b/modules/utils/fetchHeaders.mjs new file mode 100644 index 0000000..8b6b54b --- /dev/null +++ b/modules/utils/fetchHeaders.mjs @@ -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; diff --git a/modules/utils/fetchMarkdown.mjs b/modules/utils/fetchMarkdown.mjs new file mode 100644 index 0000000..0f4275c --- /dev/null +++ b/modules/utils/fetchMarkdown.mjs @@ -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; diff --git a/modules/utils/fetchText.mjs b/modules/utils/fetchText.mjs new file mode 100644 index 0000000..684b578 --- /dev/null +++ b/modules/utils/fetchText.mjs @@ -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; diff --git a/modules/utils/generateDomFromString.mjs b/modules/utils/generateDomFromString.mjs new file mode 100644 index 0000000..9579b59 --- /dev/null +++ b/modules/utils/generateDomFromString.mjs @@ -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; +} diff --git a/modules/utils/getAspectRatio.mjs b/modules/utils/getAspectRatio.mjs new file mode 100644 index 0000000..affe32b --- /dev/null +++ b/modules/utils/getAspectRatio.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/getCurrentHashUrl.mjs b/modules/utils/getCurrentHashUrl.mjs new file mode 100644 index 0000000..d9ffe18 --- /dev/null +++ b/modules/utils/getCurrentHashUrl.mjs @@ -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; diff --git a/modules/utils/getElement.mjs b/modules/utils/getElement.mjs new file mode 100644 index 0000000..f21867b --- /dev/null +++ b/modules/utils/getElement.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/getElementByCSSSelector.mjs b/modules/utils/getElementByCSSSelector.mjs new file mode 100644 index 0000000..983bac4 --- /dev/null +++ b/modules/utils/getElementByCSSSelector.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/getElementById.mjs b/modules/utils/getElementById.mjs new file mode 100644 index 0000000..d32489d --- /dev/null +++ b/modules/utils/getElementById.mjs @@ -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; diff --git a/modules/utils/getFirstTitleContent.mjs b/modules/utils/getFirstTitleContent.mjs new file mode 100644 index 0000000..5f08b33 --- /dev/null +++ b/modules/utils/getFirstTitleContent.mjs @@ -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; diff --git a/modules/utils/getLocale.mjs b/modules/utils/getLocale.mjs new file mode 100644 index 0000000..c6d226f --- /dev/null +++ b/modules/utils/getLocale.mjs @@ -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; diff --git a/modules/utils/getPageUniqueId.mjs b/modules/utils/getPageUniqueId.mjs new file mode 100644 index 0000000..b31a2fb --- /dev/null +++ b/modules/utils/getPageUniqueId.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/getRandomId.mjs b/modules/utils/getRandomId.mjs new file mode 100644 index 0000000..a51ea22 --- /dev/null +++ b/modules/utils/getRandomId.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/getReasonableUuid.mjs b/modules/utils/getReasonableUuid.mjs new file mode 100644 index 0000000..2dac6a3 --- /dev/null +++ b/modules/utils/getReasonableUuid.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/html.mjs b/modules/utils/html.mjs new file mode 100644 index 0000000..afa6f22 --- /dev/null +++ b/modules/utils/html.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/identity.mjs b/modules/utils/identity.mjs new file mode 100644 index 0000000..7dcd8d0 --- /dev/null +++ b/modules/utils/identity.mjs @@ -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; \ No newline at end of file diff --git a/modules/utils/index.mjs b/modules/utils/index.mjs new file mode 100644 index 0000000..14b8fa9 --- /dev/null +++ b/modules/utils/index.mjs @@ -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, +}; diff --git a/modules/utils/isElement.mjs b/modules/utils/isElement.mjs new file mode 100644 index 0000000..0838fd8 --- /dev/null +++ b/modules/utils/isElement.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/isExternalUrl.mjs b/modules/utils/isExternalUrl.mjs new file mode 100644 index 0000000..45c6b0e --- /dev/null +++ b/modules/utils/isExternalUrl.mjs @@ -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; diff --git a/modules/utils/isLocalHost.mjs b/modules/utils/isLocalHost.mjs new file mode 100644 index 0000000..2425133 --- /dev/null +++ b/modules/utils/isLocalHost.mjs @@ -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; diff --git a/modules/utils/isNotNull.mjs b/modules/utils/isNotNull.mjs new file mode 100644 index 0000000..fed06fc --- /dev/null +++ b/modules/utils/isNotNull.mjs @@ -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; diff --git a/modules/utils/makeBoundConsole.mjs b/modules/utils/makeBoundConsole.mjs new file mode 100644 index 0000000..d7e10ae --- /dev/null +++ b/modules/utils/makeBoundConsole.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/makeEventEmitter.d.ts b/modules/utils/makeEventEmitter.d.ts new file mode 100644 index 0000000..7b5e04d --- /dev/null +++ b/modules/utils/makeEventEmitter.d.ts @@ -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"]; +} \ No newline at end of file diff --git a/modules/utils/makeEventEmitter.mjs b/modules/utils/makeEventEmitter.mjs new file mode 100644 index 0000000..2b38ffe --- /dev/null +++ b/modules/utils/makeEventEmitter.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/makeFileLoader.mjs b/modules/utils/makeFileLoader.mjs new file mode 100644 index 0000000..290a31b --- /dev/null +++ b/modules/utils/makeFileLoader.mjs @@ -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; diff --git a/modules/utils/makeFileSizeFetcher.mjs b/modules/utils/makeFileSizeFetcher.mjs new file mode 100644 index 0000000..d32dd1c --- /dev/null +++ b/modules/utils/makeFileSizeFetcher.mjs @@ -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; diff --git a/modules/utils/makeSignal.mjs b/modules/utils/makeSignal.mjs new file mode 100644 index 0000000..731219a --- /dev/null +++ b/modules/utils/makeSignal.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/makeStyleSheet.mjs b/modules/utils/makeStyleSheet.mjs new file mode 100644 index 0000000..6059317 --- /dev/null +++ b/modules/utils/makeStyleSheet.mjs @@ -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); +} \ No newline at end of file diff --git a/modules/utils/makeTemplate.mjs b/modules/utils/makeTemplate.mjs new file mode 100644 index 0000000..a2c04fe --- /dev/null +++ b/modules/utils/makeTemplate.mjs @@ -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); +} \ No newline at end of file diff --git a/modules/utils/markdownToMarkup.mjs b/modules/utils/markdownToMarkup.mjs new file mode 100644 index 0000000..41c8b09 --- /dev/null +++ b/modules/utils/markdownToMarkup.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/markupToDom.mjs b/modules/utils/markupToDom.mjs new file mode 100644 index 0000000..c206bcb --- /dev/null +++ b/modules/utils/markupToDom.mjs @@ -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 }; +}; diff --git a/modules/utils/memoize.mjs b/modules/utils/memoize.mjs new file mode 100644 index 0000000..ac85f4a --- /dev/null +++ b/modules/utils/memoize.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/noOp.mjs b/modules/utils/noOp.mjs new file mode 100644 index 0000000..8c185b1 --- /dev/null +++ b/modules/utils/noOp.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/not.mjs b/modules/utils/not.mjs new file mode 100644 index 0000000..b7dcdc1 --- /dev/null +++ b/modules/utils/not.mjs @@ -0,0 +1,9 @@ +/** + * Inverter. + * Easier to read than "!" + * @param {any} a + * @returns + */ +export const not = a => !a + +export default not \ No newline at end of file diff --git a/modules/utils/onDocumentKey.mjs b/modules/utils/onDocumentKey.mjs new file mode 100644 index 0000000..43f8d9b --- /dev/null +++ b/modules/utils/onDocumentKey.mjs @@ -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; diff --git a/modules/utils/path.mjs b/modules/utils/path.mjs new file mode 100644 index 0000000..c83f09b --- /dev/null +++ b/modules/utils/path.mjs @@ -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 } \ No newline at end of file diff --git a/modules/utils/percentFromProgress.mjs b/modules/utils/percentFromProgress.mjs new file mode 100644 index 0000000..2248447 --- /dev/null +++ b/modules/utils/percentFromProgress.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/print.mjs b/modules/utils/print.mjs new file mode 100644 index 0000000..5d1bdc0 --- /dev/null +++ b/modules/utils/print.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/querySelectorAll.mjs b/modules/utils/querySelectorAll.mjs new file mode 100644 index 0000000..d4e2b20 --- /dev/null +++ b/modules/utils/querySelectorAll.mjs @@ -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; diff --git a/modules/utils/retryPromise.mjs b/modules/utils/retryPromise.mjs new file mode 100644 index 0000000..d52b6b2 --- /dev/null +++ b/modules/utils/retryPromise.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/rewriteLocalUrls.mjs b/modules/utils/rewriteLocalUrls.mjs new file mode 100644 index 0000000..e4ad048 --- /dev/null +++ b/modules/utils/rewriteLocalUrls.mjs @@ -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; diff --git a/modules/utils/throttle.mjs b/modules/utils/throttle.mjs new file mode 100644 index 0000000..dffd692 --- /dev/null +++ b/modules/utils/throttle.mjs @@ -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 \ No newline at end of file diff --git a/modules/utils/today.mjs b/modules/utils/today.mjs new file mode 100644 index 0000000..313387c --- /dev/null +++ b/modules/utils/today.mjs @@ -0,0 +1,3 @@ +export const today = new Date().toISOString().split("T")[0]; + +export default today \ No newline at end of file diff --git a/modules/utils/trackProgressWithCSS.mjs b/modules/utils/trackProgressWithCSS.mjs new file mode 100644 index 0000000..5d55465 --- /dev/null +++ b/modules/utils/trackProgressWithCSS.mjs @@ -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; diff --git a/modules/utils/wait.mjs b/modules/utils/wait.mjs new file mode 100644 index 0000000..c43602b --- /dev/null +++ b/modules/utils/wait.mjs @@ -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; diff --git a/modules/utils/waitIfLocalHost.mjs b/modules/utils/waitIfLocalHost.mjs new file mode 100644 index 0000000..844007a --- /dev/null +++ b/modules/utils/waitIfLocalHost.mjs @@ -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;