Compare commits

...

No commits in common. "posts" and "ssg" have entirely different histories.
posts ... ssg

39 changed files with 721 additions and 1198 deletions

2
.editorconfig Normal file
View file

@ -0,0 +1,2 @@
[*]
indent_style = tab

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
lua_modules
posts
blog
.tup
*.skooma

3
.luarc.json Normal file
View file

@ -0,0 +1,3 @@
{
"workspace.checkThirdParty": false
}

View file

@ -1,222 +0,0 @@
---
title: An Introduction to Coroutines
description: "A language-agnostic introduction to the concept of Coroutines"
published: true
tags: coroutines parallelism
date: 2021-11-20
---
## Preamble
The aim of this article is to be a mostly language-agnostic introduction to coroutines. All code used will be a pseudocode, which will somewhat resemble JavaScript.
* Subroutines are defined as `arguments ⇒ body`
* Subroutines without arguments will be shortened to `⇒ body`
* Multi-statement subroutines use braces `{}`
## What are Coroutines?
In describing coroutines, the introductory sentence of the [wikipedia article](https://en.wikipedia.org/wiki/Coroutine) already gets very close to the way I would describe it:
> **Coroutines** are [computer program](https://en.wikipedia.org/wiki/Computer_program "Computer program") components that generalize [subroutines](https://en.wikipedia.org/wiki/Subroutine "Subroutine") for [non-preemptive multitasking](https://en.wikipedia.org/wiki/Non-preemptive_multitasking "Non-preemptive multitasking"), by allowing execution to be suspended and resumed.
Put even more simply: A coroutine is *like a function* that can pause itself.
If we think of a normal function, the way it works is that we **call** the function, the function **executes** and at some point, it **returns** back to where it was called.
In a similar way, a very simple coroutine will do the same thing: We will **create** the coroutine, it will **execute** and at some point it will end and (implicitly) **yield** back to the calling code.
Here is a simple example of how this looks in practice.
First we define a new coroutine that yields three times and finally prints "Done"
```js
my_coro = coroutine(=> {
yield(1)
yield(2)
yield(3)
print("Done")
})
```
Now we can resume the coroutine up to 4 times, and each time it will resume from the last `yield` and stop at the next one, or when it reaches the end of the subroutine.
```
first_yield = resume(coro)
// At this point the coroutine is paused on its first line
print(first_yield) // prints 1
second_yield = resume(coro)
// At this point the coroutine is paused on its second line
print(second_yield) // prints 2
third_yield = resume(coro)
// At this point the coroutine is paused on its third line
print(third_yield) // prints 3
resume(coro) // prints "Done"
// At this point the coroutine has completed its execution.
```
The big difference is: A coroutine can **yield** more than once, and will be paused in between. And that is really all there is to them, from a technical level. A simple example of this would look like this.
## Classifying Coroutines
The 2004 paper [Revisiting Coroutines](http://www.inf.puc-rio.br/~roberto/docs/MCC15-04.pdf) classifies coroutines in two important ways: *Symmetric vs. Asymmetric* and *Stackful vs. Stackless*. The paper also distinguishes on whether coroutines are handled as values by the language, but that distinction is less important to understanding how they fundamentally work.
### Control Transfer Mechanism
The way coroutines transfer control can happen in two ways.
Asymmetric coroutines are more similar to how functions work. When a coroutine *A* resumes a coroutine *B*, *B* will at some point yield back to *A*, just like how any function will eventually return to its caller. We can think of these coroutines as organised in a stack, just like how functions are, but this is not the same as being *stackful*.
An important implication of this type of control transfer is that once a coroutine hands over control to another by resuming it, it can only ever be handed back control from this coroutine. In other words, it cannot be *resumed* from the outside, as it is already "running", only *yielded* to.
To illustrate this: "When you lend a pencil to Bob, you know you will eventually get the pencil back from Bob and nobody else."
This will be represented in pseudocode by the functions `resume` ("hand control down") and `yield` ("return control back up")
In the previous section, the example represented this kind of coroutine. It handed over control three times using the `yield` function without specifying where to hand control back to. It simply went back to wherever the coroutine was resumed from.
---
Symmetric coroutines work a bit differently. Coroutines can freely transfer control to any other coroutine, instead of just "up and down".
Unlike asymmetric coroutines, this one-way control transfer means a coroutine can hand over control to another and be handed back control by a completely different one.
Continuing the pencil analogy: "When you lend a pencil to Bob, you may later get it back from Steve, Larry, or never get it back at all."
In pseudocode, this will be represented by the `transfer` function.
---
The main advantage of asymmetric coroutines is that they offer more structure. Symmetric coroutines let the user freely jump back and forth between coroutines, in a similar way to `goto` statements, which grants a much higher degree of freedom but can also make code hard to follow.
### Stackfulness
Another important way to categorize coroutines is whether every coroutine has its own stack. This distinction is much harder to explain in theory, but will become clear in examples later on.
Stackless coroutines, as the name implies, don't have their own call stack. What this means in practice is that the program has no way of tracking their call stack once they yield control, so this is only possible from the function on the bottom of the stack.
Stackful coroutines, on the other hand, have a separate stack for every coroutine, so they can be paused from anywhere inside the coroutine.
A complete explanation of why this is and how it works could easily be its own article, so I will be skipping it for now. The important part to remember here is that Stacful is "better" in that it lets you do more, but also harder to implement in a language, specially if it was added later on and not part of the initial language design.
---
Many programming languages actually have *stackless* *asymmetric* coroutines; JavaScript, for example, calls them *generators*.
Some languages even have a mix of both: Ruby *fibers* can both use `resume`/`yield` semantics, but they can also freely transfer control freely with the `transfer` method.
Windows even provides an OS-level API for coroutines: It calls them Fibers, and they are *stackful* and *symmetric*. Linux does not provide any coroutine API yet.
## Why are they useful?
On an abstract level, the strength of coroutines is to manage state. Since they remember where they left off for the next time they're resumed, they can use control-flow to save state that would otherwise have to be stored in variables.
### Animation
As a simple example, imagine an object in a game. The object has an `update` function that will be called repeatedly by the engine, and as an argument, it will receive the time (in seconds) since the last time it was called. This is a very typical setup for simpler games.
Implementing a simple animation, for example, along the edges of a square, would require storing the animation state in some sort of data-structure so the `update` function knows where to continue the animation. Although in this case one *might* be able to get away with just `x` and `y` coordinates (which are likely already present in the object) and some convoluted if/else logic, this code would still look unintuitive.
**Coroutines to the rescue!** Now consider extending the setup like this:
Along with the `x` and `y` attributes, the object also has a `behavior` coroutine. The only thing the `update` method does, is to resume this coroutine every time.
```
object.update = delta_time ⇒ resume(object.behavior, delta_time)
```
`resume`, in this pseudocode language, is the function that resumes a suspended coroutine asymmetrically, meaning it will pause the code here until the resumed coroutine yields.
For this to work, a bit of extra semantics has to be introduced: Just how functions can have *arguments*, it is common that *yielding* and *resuming* coroutines can also pass arguments along. In the above pseudocode, this will be represented as an extra argument to both `resume` and `yield`, that would be *returned* by the matching `yield` and `resume` calls. This is a very common way to handle passing around data between asymmetric coroutines.
Now, with this setup, the `behavior` coroutine could look something like this:
```
object.behavior = coroutine( ⇒ {
while true {
while object.x < 10
object.x += yield() * object.speed
while object.y < 10
object.y += yield() * object.speed
while object.x > 0
object.x -= yield() * object.speed
while object.y > 0
object.y -= yield() * object.speed
}
)
```
where `coroutine` is a function that takes a subroutine and turns it into a coroutine, without actually starting it and `yield` is a function that suspends the current coroutine and yields back to the "parent" coroutine that resumed it.
It looks a bit like magic. The animation code *looks* like it should simply block the game in an endless loop, but it doesn't, because it runs inside a coroutine that yields after every step. But the state of the animation is still represented as a simple nested loop.
This can be taken a step further though. Consider the following example:
```
object.behavior = coroutine ( ⇒ {
while true {
object.move_right(10)
object.move_down(10)
object.move_left(10)
object.move_up(10)
}
})
object.move_right = distance ⇒ {
while distance > 0
delta_x = yield() * object.speed
object.x += delta_x
distance -= delta_x
}
```
This could be refactored into a single `move(axis, distance)` function, of course; but the additional code to figure out the direction would clutter the code a bit too much for this example.
The important thing here is, that the top-level function of the coroutine never `yield`s; instead, it calls a `move_*` function that takes care of yielding itself. This is where **stackfulnes** comes into play again: Only **stackful** coroutines can do this. In languages with stackless coroutines, like javascript, code like this would likely be rejected by the compiler.
Put very simply: the reason for this is that when `move_right` yields, it needs to remember where it needs to return to after it resumes. This information is what's on the stack, so a coroutine without its own stack cannot remember from nested functions.
### I/O Event Loops
Another application of coroutines is handling the complexity if asynchronous code. Lua has done this for years now, Ruby recently adopted the same idea, and languages like Elixir have been doing a very similar thing as part of the language for ages.
But how exactly can coroutines help with this? Simple: by yielding to an event-loop, which will resume them once a certain event happens. While in practice this is a bit more complicated, the core idea is this:
* All code runs inside coroutines (In some languages this is always the case, in others it would be up to a framework to run any user code in a coroutine)
* Functions that need to await asynchronous tasks simply start an I/O operation, then yield some object describing when to resume them back to the top level coroutine
* When a coroutine yields, a scheduler will decide what coroutine to resume next, or simply sleep until any new "event" is available.
This should sound very familiar to anybody who has worked with `async`/`await` before. It is a very similar concept. Then only difference is that functions are always synchronous, and all functions are implicitly awaited.
This has one significant advantage: While the program itself either uses non-blocking IO or APIs that resume it whenever any of the awaited inputs is available, the user writes codes that *looks* like blocking code. And it is in fact "blocking", on the level of the coroutine, but will never block other parts of the same program.
At the same time, this is still cooperative multi-threading, so no section of code will be interrupted from the outside. Only operations that yield can lead to race-conditions, but two consecutive non-yielding operations will never have their state messed with in between.
This makes unsafe code much easier to spot:
```
c = global(counter)
c = c + 1
global(counter) = c
```
The above code is very obviously safe, because the code will never be suspended for other code to run in the meantime.
In a multi-threaded environment, this code could lead to errors: the scheduler could suspend this thread after the first line, and some other thread could increment the counter. Then, when this thread was resumed, the third line would overwrite the `counter` variable with an old value.
---
With coroutines, it is still possible to write buggy code susceptible to race-conditions, but only by explicitly yielding, or calling a function that does so.
```
c = global(counter)
c = c + 1
sleep(3)
global(counter) = c
```
This code is obviously unsafe: in the 3 seconds that this coroutine is sleeping, some other code may also increment the counter, which would then be overwritten by this coroutine after it resumes.
However, we can safely assume that the code will *never* be interrupted between the first and the second line, or even worse, in between two steps of the same line.
---
## Conclusion
There is, of course, lots more to be said about coroutines, and how they a reintegrated in different languages. But this introduction should give a good enough idea of how they fundamentally work.

View file

@ -1,175 +0,0 @@
---
title: Why I prefer Lua to Ruby
description: "A short post describing the main reasons why after years of using
both languages, I still prefer Lua to Ruby for most general situations."
published: true
tags: Rant, Lua, Ruby
date: 2019-04-01
---
So Lua and Ruby are two rather similar languages in many aspects. There's a lot of articles explaining what makes them so similar and many of the more superficial differences.
Today I'd like to point out some reasons that have shaped my opinion on the two languages.
I will risk sounding like a hater, but I'd like to point out that I don't particularly dislike Ruby. There's many positive things about it, but since I do prefer Lua, I just chose to point out some of its problems today.
## Speed
It'd be easy to just say "Lua fast, Lua good" and be done with it. In reality though, things aren't always that easy.
### File Size
The thing about Lua is, it's very small. Not only does this mean that it fits almost everywhere, but also that it loads very fast
```bash
> time ruby -e 'puts "hello"'
hello
real 0m0.059s
user 0m0.054s
sys 0m0.005s
> time lua -e 'print "hello"'
hello
real 0m0.003s
user 0m0.003s
sys 0m0.001s
```
This only makes a difference when you're calling the executable many times in a row, but the difference does add up over time.
### Functions vs. Methods
The next thing to consider is that, in Lua, functions are stored in variables, and are therefore easy to look up.
Ruby, on the other hand, has to look up methods for everything. For short methods (let's say, adding two numbers) this can mean a significant overhead.
```bash
> ruby -e 'puts self.class.ancestors'
Object
Kernel
BasicObject
```
Even the main object every ruby script runs in inherits from 3 ancestors.
It should be noted that Lua isn't immune to this problem. When writing object-oriented code, this also ends up happening. The difference is that in Lua the programmer must do this explicitly, while in Ruby it happens all the time.
### Strings / Symbols
~~This may surprise some people, but Lua actually has a huge disadvantage to Ruby in terms of speed: It has no Strings in the way Ruby has them. All strings in Lua are automatically interned. Ruby has interned strings as well; it calls them *Symbols*.~~
~~This has its upsides and, overall, was probably a smart decision, but it also means reading in a long text file takes much longer in Lua. Imagine calling `to_sym` on every line in Ruby.~~
`<edit>`
Since writing this post, I have double-checked this and discovered that I apparently misunderstood this or just remembered it incorrectly: Lua *does* intern all **short** strings by default, however, above a certain length, this doesn't happen anymore. Strings that are too long for interning don't get hashed immediately, but they do get hashed once it becomes necessary (for example when comparing them to another string or indexing a table) and will from then on use just use that hash.
This pretty much means that, in most cases, Lua wins over Ruby even when working with long (but immutable) strings.
However, the downside that Lua strings are immutable remains, and modifying a string means creating a new, modified copy of the string.
`</edit>`
### Vararg functions
If I was asked which of the two languages had the powerful implementation of variadic functions, I'd 100% say Ruby. You can mix and match many kinds of syntactic constructs that capture additional arguments into both arrays and hashes.
But, with great strength comes... not so great performance.
Let's consider the following piece of code:
```ruby
def foo(bar, *rest)
do_something(bar)
do_something(*rest)
end
```
Every time the foo method is called, ruby needs to instantiate a new array `rest` and collect the excess arguments into it. This ends up happening a lot and makes variadic methods very unsuited for code that needs to perform well.
Consider the equivalent code in Lua:
```lua
function foo(bar, ...)
do_something(bar)
do_something(...)
end
```
Here, there's no array involved. The function can just leave bar on the stack, call `do_something` telling it it has 1 argument, then pop bar from the stack and call `do_something` again, telling it how many items are left on the stack.
This means writing functions like this is way more viable even when your code needs to run as fast as possible.
### Mentality
One difference that probably makes a way larger difference than most people would assume is the difference in mentality between the two communities.
When asked why Ruby is so great, the general response from its community will be something along the lines of "Because it's fun to write!". Many Ruby examples out there seem to throw performance out the window before even starting. This isn't necessarily a bad thing, but it ultimately leads to people writing libraries that run way slower than they could.
My experience with the Lua community has been vastly different. A simple google search brings up way more relevant and detailed information on how to improve performance in Lua than in Ruby, even though the latter has a much larger community that also seems to write way more about it.
### LuaJIT
Lua is already very fast on its own. LuaJIT though, that's a completely different level. There have been examples where JITed Lua code can even run faster than equivalent C code, because the compiler sometimes has more awareness of the code it's optimizing (After all, it keeps track of the code as it's being executed)
Ruby has made a huge step in the right direction with its own JIT Compiler, but that's still nowhere near the performance improvements of LuaJIT when compared to PUC Lua, the "reference" Lua implementation.
## Simplicity
Leaving performance aside now, there's another reason why I consider simplicity a very positive feature of Lua: It's not only easy to learn, but also easy to master.
### Modularity
This is probably one of the things I hate the most about Ruby. No matter how hard you try, you will never get rid of global state. At the very least you will have some modules and classes polluting the global environment, and there is no way to load a library into a contained space.
This means, for example, that it's not possible to load two different versions of one library without having to go through its source code and renaming things everywhere.
```ruby
require 'some_class'
foo = SomeClass.new # Where did this come from?!
```
In Lua, on the other hand, you can just rename the file and load it into another local. How things are called internally doesn't matter to the host program.
```lua
local someclass = require 'someclass'
local foo = someclass.new()
```
### Side effects
Ruby tries to be as comfortable as possible for the developer. I often find myself wishing it didn't
Consider the following example:
```ruby
require 'missile_cruiser'
MissileCruiser.new.last_fired
```
What happens there? One would assume, the variable `last_fired` will return the last missile that was fired. Since we didn't fire one, it would be reasonable for this to be `nil`.
But wait, what if it's a method?
Maybe it will raise an error because we didn't fire any missile yet?
Even worse, maybe some developer thought if we wanted to know about the last missile, we probably wanted to fire one, so the method just fires a missile and returns that one?
This kind of thing happens often in Ruby. The lines between what is a value and what is code that gets executed are blurred.
### (in)Consistencies
Try the following code in `irb`:
```ruby
puts 1/0
puts 0/0
puts 1.0/0
puts 0.0/0
```
Things like that make a language (and, by extension, programs written in it) harder to understand at first glance, specially to newcomers. No language will ever be 100% consistent, but these inconsistencies should still be reduced whenever possible.

View file

@ -1,20 +0,0 @@
---
title: Stop using frameworks for your portfolio!
published: false
description: or "why, in my humble opinion, you should refrain from using a framework intended for highly interactive web applications for a simple portfolio site"
tags:
date: 1994-05-03
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
---
So I will admit, that title leans slightly towards click-bait.
I am not primarily a web developer nor looking for a job as one and my software philosophy is one of simplicity and minimalism.
With that being said, I have seen many developer portfolios reaching for modern frameworks built primarily for big, highly interactive web applications, and I believe that might not always be the best approach.
## Advertise core skills
<!-- TODO: Write -->
## Let your projects speak for themselves
<!-- TOOD: Write -->

View file

@ -1,76 +0,0 @@
---
title: A rant on change, and the good old times.
published: true
description: A long and poorly structured rant about a series of harmful phenomena I've observed in the world of programming.
tags: rant, programming, community
date: 2020-11-25
cover_image: https://dev-to-uploads.s3.amazonaws.com/i/5f05h9bj7sda2erlf2uc.JPG
---
## Preamble
Over the last decade or so, I've had a peek into quite a few developer communities. I do most of my private programming in Lua, and interact with that community almost daily. At work I use Ruby, and often get a good glimpse of that community as well. Through Hacker News, Reddit and a bunch of other sites I get a good impression of many different "general" programming communities. I am only human, so my opinions will of course be biased, but for what it's worth, I do believe that I have at least some degree of objective perspective on this topic.
## The Problem
Lately, I've noticed a bit of a trend within the world of software development. Put bluntly, it seems to me like the field of programming is slowly but surely drifting down the hillside, spearheaded but not exclusively caused by the web community in particular.
## The Symptomatology
What do I mean by this though? Well, starting with the a parallel, but mostly positive process: Programming is becoming more and more inclusive and easy to get into. Elitism is slowly becoming more of a meme than an honest feeling of superiority, and more and more people are picking up programming or closely related skills.
However, in the process of wiping away elitism and gatekeeping in the software world, it seems to me, that in our excitement we are also throwing out valuable barriers that keep our world stable and nice
One of the big changes I have noticed is a general shift from "hobby that you can even get paid for" to "easy way to land a comfortable job". Discussions about fancy things you can do with software for the sake of experimentation are being replaced with top ten interview questions for whatever the hottest new technology is at a given moment. Enthusiasm for technologies despite its economic viability is being replaced with amusement at getting something to work as quickly (read: wasting as little paid hours) as possible.
The result is that these days, there's a much bigger emphasis on learning products over theories. The "brand" is slowly sneaking into the world of programming, and while specific technologies have always been something you'd learn, it used to be more about languages than single platforms. Maybe this started with Java and the JVM platform, but I'm not old enough to judge that.
At the same time, some of the blame might fall on the internet. In the "good old days", when "everything was better™", the lack of a centralised knowledge-base meant people had to learn their IT skills though manuals and text-books. One could consult a manual for certain questions, but given the limited space, this would only lay out the components and tools you'd need to fix a problem. These days, one can consult the internet with a very specific question and is likely to get a solution for that exact problem spoonfed by the collective knowledge of the entire programming community.
Is this bad? No. But it leads people to rely on it, and *that* certainly *is* bad. One of the reasons I have stopped visiting the ProgrammerHumor subreddit despite enjoying most of the bad and repetitive jokes there, is that a certain category of self-deprecating humor has gotten so overused, that it's impossible to scroll though it for 5 minutes without seeing at least one post where the punchline falls along the lines of "all programmers do is google" or "if stackoverflow is offline, all development just stops worldwide".
By itself, each one of those jokes would be funny. Programming *does* sometimes feel like most of what one does is googling, specially when just learning a new technology. But the obsession with this narrative, over time, has started to make me wonder if this is what some people actually believe, and, by constantly repeating and repeating and repeating this same punchline, if newcomers and outsiders will sooner or later get the impression that this is the unironic reality of programming.
Why does this matter? Consider the public image of programming. Currently, most people, when I tell them I do programming, react with at least some degree of respect. "He's a programmer, he must be smart" is what some people might think, not unlike someone calling themself a "scientist" or a "mathematician". Programming is considered an intellectual discipline. This directly translates into how much worth is associated with the job: if you have to be smart to do something, you will get paid well, because you're "in demand". What, did you believe we're getting paid more than a janitor, who spends most of their day doing physically demanding work, because our job is so much harder? You probably know that that's not how it is.
So from that perspective, constantly reiterating the idea that programmers are just code-monkeys that only google things is, to put it bluntly, strategically stupid.
## The Mythology
The idea that programmers just copy together all of their code is far from the only harmful idea out there though. Parallel to it, a much more harmful myth is that "everyone can code [on the same level]". Now, just as with drawing, music and many others, I do believe that everyone, without exceptions, is capable of learning the basics of programming.
However, it is simply a fact that not everyone has the talent to program something like the linux kernel. And this is probably one of the hardest truths to properly internalize: we don't want to be elitists, we want everybody to have a chance at joining our community. We want others to discover and enjoy our hobby, just as we did. And that's fine. I won't stop telling people that they can *learn to code*. But I don't want to keep spreading the lie that everybody can become just as good as everyone else. Some people don't have what it takes, and some people have talent that I just won't be able to reach myself.
So how does that persistent myth do any real harm? In the same way the Google and StackOverflow jokes do: by devaluing our skills and our work. If everyone can do a thing, then there's nothing valuable about it. If anybody can go to a boot camp and become a super-duper good programmer within a year or two of job experience, then there's nothing worth respecting about anything one can achieve.
On the other side, it sometimes seems that employers still haven't given up on the mythical plug-n-play developer that can be employed today and start being 100% effective tomorrow. It used to be that OOP promised developers who could be interchanged without any effort, and instead of just disappearing, this supersticion seems to just have shifted to the ridiculous belief that a developer can specialise in a specific tech stack to the degree where they can just be sat down before a project and they'll instantly know how to make the product better.
## The End Game
Is all of this coincidence? Well, it probably is. I don't believe there's any mastermind about us programmers shooting our own leg and devaluing our own skills. However, there is someone who profits from it, and certainly has good enough reasons to help the process along occasionally.
I am, of course, talking about employers. There used to be a myth of software systems that don't need programming. Where anybody could just drag a few boxes around and the result would be a program that does exactly what it needs to, no matter how complex the task. Unfortunately for employers, and very fortunately for us employed and employment-seeking programmers, this never happened, and we've somewhat accepted that it just won't happen anytime soon.
What other hope is there to not have to pay well-trained and experienced professionals for their expensive time? The simplest answer is: make their time less expensive.
While observing the community discuss employment practices, specially in the USA, I'm routinely baffled at how comically and artificially imbalanced this whole process is.
More than enough people have written lengthy articles about how pointless and arbitrary the distinction between "junior" and "senior" developers often is, and have done a much better job than I could, considering this is not all that common in my country. I also often read about interview processes that just seem utterly lacking in any sort of respect for the applicant and their time.
The strategy seems obvious: gasslighting us all into believing we're of very low, if any, worth to the employers who so generously offer to grant us shelter from our own lack of practical skills. We're not corporate simps, we're just grateful that we don't have to sleep under a bridge. I am exaggerating, of course, but it does seem very clear that this is the general direction things are going. It's not a problem specific to our field either, but it might be more pronounced because of the rapid influx of newcomers, and the arcane image to the outsider.
While everybody can estimate how much skill and effort goes into, say, building furniture, it is very hard if not impossible to the uninitiated to get a feeling for how difficult it is to build a software product. It seems almost like black magic to the outsider, and this is another thing that people often joke about (sadly, the punchline is often that ultimately, we're not doing anything at all difficult).
## The Remedy
It is hard to say what could be done to push programming back up the hill. There seems to be a strong culture of self-deprecation (probably adopted from broader nerd-culture), and a tendency to unironically under-value ones own skills in this community. The amount of posts I find about impostor syndrome every week on its own already indicates that we have a serious problem that needs to be addressed.
At the same time, our attempts to be kind to everyone might be pushing us further into the corner. Maybe we should develop a bit more of a "git gud" (`git: 'gud' is not a git command. See 'git --help'`) attitude as the gaming world (sans the insults and trash-talking, if possible), that puts more emphasis on showing someone how to improve than to defuse their insecurities and feelings of insufficiency.
As for the focus on finding employment, I really think that needs to be toned down. Yes, it is easy to be excited about potentially turning your hobby into your job, or switch careers to something you found to be much more enjoyable. For some, programming might even be a unique chance for a better life. But for many of us it is also a hobby, and for me personally, it is as much an art as is music or drawing. I don't care about top 10 javascript interview questions and would much rather talk about all the totally useless and impractical but technologically cool and creative things you guys have built.
And please, don't sell yourselves under value. Programming is hard. Everyone can learn it, but few can master it. We're not just code-monkeys copying from google. And if you're learning: Try having fun and don't worry too much about maximising your employability.
## In Conclusion
It's not like I think the world will end. Programming won't stop being my hobby, nor do I fear I some day won't find a job (at least not before someone develops an AI that does all the programming for us and understands human language). I want the world of programming to stay this quirky place, where people build cool stuff, share what they've built and discuss it with others. Ultimately I hope we can all have fun programming 🧡

View file

@ -1,63 +0,0 @@
---
title: Tabs are objectively better than spaces, and here's why
published: true
description: Here's why I think tabs are objectively better than spaces
tags: indentation, tabs, spaces
date: 2021-02-15
---
## The Debate
This is probably one of the longest ongoing bikeshedding debates in the programming community: Should we indent our code with **Tabs** or with **Spaces**?
In this post, I will do my best to explain why **tabs** are the right choice, not only in my personal opinion, but objectively.
## Indentation
First of all, I want to define the scope of my argumentation: I am referring to **indentation**, not **alignment**.
The former depends on semantics, the latter on word lengths. For obvious reasons, using tabs for alignment is not possible; whether alignment should be a thing at all (hint: it shouldn't) or how it should be achieved is a different debate and irrelevant for this post.
## Semantics
The most puritan argument for tabs is probably the semantic information they add to the code. I have never seen a real-world example where this matters, but as programmers we often like to obsess over using the right "thing", be it a HTML element or an ASCII character.
## Consistency
The main argument I've heard defending spaces is that code looks "consistent" everywhere. Whether you post your code on Stack-Overflow, GitHub Gists, or on your blog; it will always be indented by the same width.
Code is not visual art. The indentation width doesn't alter the codes meaning in any way and, unlike with alignment, changing it doesn't break the visual layout of the code.
**There is no reason whatsoever why someone else reading my code should experience it with the same indentation width that I wrote it in.**
## Customizability
I personally prefer a tab-width of three spaces. Two is just barely too short to follow through deeper nested blocks of code. Four is one more space-width than I need.
So the question is: Why would *you* have to read my code with such an awkward tab width, just because to my uncommon taste <!-- unintentional skyrim reference --> that seems like the right value?
The answer is, of course, you shouldn't. You should be able to read *my* code the way *you* prefer to read it.
**Everyone should be able to read code with their own preferred settings.**
## Accessibility
So far I've looked at customizability as a convenience feature. I *like* 3-space indentation more, so I *want* to read code that way.
But for some people it goes beyond just preference.
I've seen posts and comments of quite a few developers with poor eyesight. For some, 2 spaces is just not enough indentation, making it unnecessarily hard to read code, others might need to use very large font sizes, and prefer shorter indentations to save screen space.
## Consistency (again, but differently)
This is by far the most ridiculous reason, or group of reasons people make to argue for spaces:
Projects indented with tabs—so the claim—cause additional work when people contribute code that is indented with spaces, requiring additional moderator intervention.
Needless to say, this argument works exactly the same both ways, and if anything, says more about the typical space-user than the typical tab-user.
Regardless of preference, in the age of linting tools and CI pipelines, this is just not an issue any more. We can automate the process of checking indentation, or even have it fixed automatically.
## Conclusion
There is not a single good reason to prefer spaces over tabs. The whole space-indentation mythology is nothing but ridiculous non sequiturs and false claims.

0
Tupfile Normal file
View file

0
Tupfile.ini Normal file
View file

1
Tuprules.tup Normal file
View file

@ -0,0 +1 @@
: foreach *.skooma.yue |> yue %f -o %o |> %B

View file

@ -1,41 +0,0 @@
---
title: Upcoming CSS features in 2023
published: false
description: "A short explanation of the upcoming CSS features I'm the most
excited about in early 2023, and why I can't wait for them to be official."
tags: css,scope,components,vanilla
date: 2023-02-27
---
## CSS Scoping
> The `@scope` block at-rule allows authors to scope style rules in CSS, with
> the application of weak scoping proximity between the scoping root and the
> subject of each style rule.
>
> <cite>CSS Cascading and Inheritance Level 6 (W3C First Public Working Draft, 21 December 2021)</cite>
## Relative Colors
> Within a relative color syntax `color()` function using <custom-params>, the
> number and name of the allowed channel keywords are:
>
> <cite>CSS Color Module Level 5 (W3C Working Draft, 28 June 2022)</cite>
## Contrast-Color
> The `contrast-color()` functional notation identifies a sufficiently
> contrasting color against a specified background or foreground color without
> requiring manual computation.
>
> <cite>CSS Color Module Level 6 (Editors Draft, 15 February 2023)</cite>
## CSS Nesting
> Style rules can be nested inside of other styles rules. These nested style
> rules act exactly like ordinary style rules—associating properties with
> elements via selectors—but they "inherit" their parent rules selector
> context, allowing them to further build on the parents selector without
> having to repeat it, possibly multiple times.
>
> <cite>CSS Nesting Module (W3C Working Draft, 14 February 2023)</cite>

View file

@ -1,116 +0,0 @@
---
title: What is CSS @scope and why should I care?
published: true
description: "A short introduction to the new CSS @scope rule, the history of CSS scoping and what makes it useful for different areas of web-development"
tags: css,scope,components,vanilla
date: 2022-03-16
---
## A brief history of Scoping and CSS
Scoping CSS to certain regions of the DOM is not a new idea.
The `scope` attribute for `<style>` tags was one attempt at addressing it. Nowadays, it is sadly deprecated though.
```css
/* ⚠ This is deprecated. It doesn't and won't work ⚠ */
<div>
<style scope>p { color: red; }</style>
<p>Red Text</p>
</div>
<p>Normal Text</p>
```
Many front-end frameworks implement their own scoping by prefixing CSS rules with IDs and classes and adding those to their HTML output. However, this requires a lot of complexity in the framework and is still brittle.
Then components came into the browser, in the form of custom elements and shadow-DOM. In fact, one part of shadow-DOM is that all the CSS inside it is scoped. However, it doesn't permit outside CSS to leak inside either.
## Native Scoping is still on the table
The exact reason why `scope` was originally abandoned seems a bit fuzzy. Some will tell you it was because browsers didn't want to implement it, others say that it was just about letting web components become a thing, then re-evaluate the need for pure CSS scoping.
Whatever the case may be, CSS authors still seem to have an interest in scoping being a thing, for a variety of reasons.
## CSS Scoping Revived: `@scope`
The `@scope` rule is the newest attempt at bringing CSS scoping to the browser. It is described in the [Working Draft](https://www.w3.org/TR/css-cascade-6/#scoped-styles) of the *CSS Cascading and Inheritance Level 6* specification.
In other words: it's far from being usable. But there's still plenty of reasons to be hyped about it! 😁
-----
The way this would work is simple: we would first define where we want our rules to apply. We can use any CSS selector here, but to avoid distractions, I will be using the `outer-component` and `inner-component` custom elements for the rest of this article.
```css
@scope (outer-component) {
p { color: red; }
}
```
Any rules written inside this scope block will only apply inside an element described by the selector.
```html
<p>This text is black</p>
<outer-component>
<p>This text is red</p>
</outer-component>
```
And we can also describe a lower boundary to this scope; another selector telling the browser that the scope should *not* apply to a certain sub-tree.
```css
@scope (outer-component) to (inner-component) {
p { color: red; }
}
```
```html
<outer-component>
<p>This text is red</p>
<inner-component>
<p>This text is black</p>
</inner-component>
</outer-component>
```
## Why should we be hyped about it?
The example with two nested custom elements already shows one possible use-case. Having styles apply only inside specific components without prefixing every selector with the component name is already useful.
But the addition of a lower boundary to prevent styles from leeking into nested components makes this incredibly useful, specialy in the modern front-end landscape is constantly moving away from monolithic structures and towards small, portable and interchangeable components.
If you're writing plain CSS for some vanilla JS components, you will be able to write CSS that's much more similar to what frameworks like svelte allow you to do: Write a bunch of rules and they will only apply inside the component. Imagine doing that with direct-child selectors 😵‍💫
here's an example of where this could be useful without any components or custom elements:
```css
th { background: black; color: white; }
@scope (table.vertical) to (table) {
th::after { content: ':' }
th { all: initial; }
}
```
-----
Meanwhile, for authors of such component frameworks, native CSS scoping will vastly reduce the complexity they have to deal with as they will no longer have to automatically prefix selectors nor add IDs or classes to the elements they should apply to.
A somewhat simplified version of what such a framework could do:
```js
component_css = `
@scope ([data-component="${component.name}"]) to ([data-component]) {
${component.styles}
}
`
```
-----
Even if you just use these frameworks, which already implement CSS scoping, there might be some benefits for you. Most importantly: since the scoping happens in the browser at runtime, frameworks don't need to know about your elements in order to style them, so there will be much less friction between frameworks and manually generated content. Inserting some HTML via `.appendChild()` would "just work".
```js
// No code example for this one, because these problems only tend
// to surface once the project becomes a little bit more complex
// and several libraries trying to work together.
```

View file

@ -1,279 +0,0 @@
---
title: "Type-Writer Component: Magic and Asynchronicity"
description: "A detailed description of the development process of an HTML TypeWriter element in plain JavaScript using the custom-element API, meta-programming and some recursion magic."
published: true
tags: javascript, html, webdev, async, metaprogramming
date: 2021-09-11
---
## Backstory
A few weeks ago, I built a simple type-writer web component: a user would load a JavaScript module and define a `<type-writer>` element with several lines of text. The component would then type out the first line of text, delete it again and rotate it to the back of the list.
```html
<type-writer>
Here's a first line
And here's another
Only plain text supported
</type-writer>
```
This worked well for simple cases, but it was obviously very limited. What if you wanted one word to be emphazised? Or have a line-break? The obvious solution was to extend this component to reproduce an entire DOM-Subtree.
Fast-forward to today, and I find this very nice [article](https://dev.to/auroratide/a-typewriter-but-using-a-new-html-tag-60i) on Dev.to showing off a custom component that does just this. This made it clear: I couldn't just leave my component as it was; it had to be brought to the same level. And so I started coding...
My aim for this article is to use this example to explain some of the nicer things one can do with web components without using any frameworks or libraries. Just one file of JS that runs out of the box in any modern browser.
I'll be trying to reproduce my thought process to some extent, but won't be following the same timeline of when I built this, since features got added piece by piece as it tends to be with programming, and I'd much rather group them by the concepts they rely on.
## Async
One of the first things I figured out while building this component was that object methods can be `async`. Of course they can, they're just functions, after all. But for whatever reason, I didn't expect to be able to just put `async` in front of a normal method definition, but apparently that works perfectly well.
So why is this important? Well to slowly type out some text, one needs some sort of loop that can suspend its execution. Yes, one could also work with callbacks or promises, but it's 2021 and I just don't see a reason not to write blocking-style code instead using `async` and `await`.
So that's the starting point: an `async run()` method that contains the main animation loop.
Since the only thing worth `await`ing in this case is a timeout, I needed a wrapper around `window.setTimeout`, which I put at the top of my file:
```js
const sleep = time => new Promise(done => setTimeout(done, time*1e3))
```
No magic here: simply create a new promise and resolve it after `time` seconds. Yes, seconds, the SI unit for time. We're writing code in the 21st century and floating point numbers have been around for long enough that there's no benefit in using milliseconds whatsoever other than being used to it. Even POSIX uses seconds for sleep timeouts, so that's what I'm doing.
With that out of the way, here's what a very basic `run()` method would look like, before adding further features:
```js
async run() {
while (true) {
// Next item to type out
let subject = this.children[0]
// Make a copy in case the original changes while we're typing it
let content = subject.cloneNode(true)
// Rotate the subject to the back
subject.remove()
this.append(subject)
await this.typeElement(this.shadowRoot, content)
await this.emptyElement(this.shadowRoot)
}
}
```
The important part here are the two functions at the end: These will take care of typing out our cloned element recursively, then deleting it again. The target will be a shadow DOM attached to the custom property in its `connectedCallback`.
## Optional Looping
So now we have an infinite loop, which might not be what the user wants. We need an option to turn the looping on or off. This is actually quite easy though! After all, the loop is just an asynchronous function.
```js
if (!this.loop) return
```
So we can simply return from it at the end of the iteration. What's more, this means that we can disable looping at any moment, and the type-writer will finish the current iteration and then stop.
To resume typing, we'd simply have to call the `run()` method again.
But this leads us to another problem: what if we call `run()` while the loop is already running? To avoid this, I simply added a state variable to the class, and for user convenience, exposed it as read-only:
```js
#running
get running() { return this.#running }
// ...
async run() {
while (true) {
if (this.running) return
this.#running = true
// The rest of the code
if (!this.loop) {
this.#running=false
return
}
}
}
```
Now users can query the read-only property `TypeWriter.running` and they call `run()` a second time, it will simply return immediately.
## Typing
The central part of the element is, of course, the part where it types out the contents. The biggest difficulty here is, that we don't deal with text, but a (potentially very deep) DOM subtree, and we need to type out characters one by one while reproducing the surrounding HTML structure; but we can't type out the HTML text because that's not visible to the user.
A simple solution to this problem is a set of functions, one that loops over the HTML elements recursively and one that handles the actual text content of these elements.
Starting with a function that handles HTML elements, the basic structure would look something like this:
```js
async typeElement(target, elem) {
for (let child of elem.childNodes) {
if ("data" in child) {
await this.typeText(target, child.textContent.replace(/\s+/g, ' '))
} else {
let copy = child.cloneNode(false)
target.append(copy)
await this.typeElement(copy, child)
}
}
}
```
The only two things worth pointing out here are that, before passing the content of a text node to `typeText`, all clusters of whitespace are replaced with a single space to mimic how HTML is rendered anyway. This would mean `<pre>` nodes don't work, so that's something to put on a list of future improvements. The other noteworthy thing is that `"data" in child` is probably not the best way to check for text nodes, but it does work.
For non-text nodes, the function inserts a shallow copy of the given node, appends it to the target and recursively calls itself.
Of course, all function calls need an `await` keyword, as both `typeText` and `typeElement` are asynchronous.
The `typeText` function is even simpler:
```js
async typeText(target, text) {
let node = document.createTextNode('')
target.append(node)
for (let char of text.split('')) {
node.appendData(char)
await sleep(this.type)
}
}
```
all it needs to do is iterate over the string character-by-character and append them to the target node, sleeping for a certain time in between each character.
## Deleting
Deleting of elements looks very similar to typing, except it has to be mirrored. For example, the `emptyText` method will have to remove characters from the back:
```js
async emptyText(target) {
while (target.data.length) {
target.data = target.data.slice(0, -1)
await sleep(this.back)
}
}
```
and the `emptyElement` function will have to iterate over child elements backwards, deleting them after they have been emptied:
```js
async emptyElement(target) {
let children = target.childNodes
while (children.length) {
let child = children[children.length-1]
if ("data" in child) {
await this.emptyText(child)
} else {
await this.emptyElement(child)
}
child.remove()
}
}
```
Other than this reversal, both methods look very similar to their typing counterparts; they recursively traverse the DOM subtree and act on each node.
## Events
With the visible parts of the element being implemented now, what's left is to add a usable JavaScript interface. JavaScript has several mechanisms of handling events, but the ones most widely used nowadays are events and promises, each with their own strengths and weaknesses.
Ideally, the API for the TypeWriter might look something like this:
- The Element has a series of Events describing state changes like "finished typing" or "started erasing"
- Each of these events dispatches an actual DOM Event that can be interacted with as usual.
- For convenience, for each of these events, the user can get a Promise that resolves the next time the event is emitted.
- For simpler cases, a user can embed event handling code in the HTML as with other events like `click` (via the `onclick` attribute) and many others.
Since all of these features will be using variants of the same event names, that's the best place to start:
```js
static eventNames = ["Typing", "Typed", "Erasing", "Erased", "Connected", "Resumed"]
```
Static Initialisation Blocks could be used to make looping over this static array and extending the class more readable; but since those aren't a thing yet, the code looks a bit more esoteric:
```js
static eventNames = ["Typing", "Typed", "Erasing", "Erased", "Connected", "Resumed"].map(name => {
// do stuff with "name"
return name
})
```
It's worth pointing out that this creates a new array containing the same elements as the first one, which is wasteful. Since this happens only once in a class definition, and the array isn't long, it won't effectively have any performance impact.
Inside this loop, two things will happen:
### Event Promises
For each of the known events, the class should have an attribute that returns a promise. Given the event name, this can be implemented quite easily with a bit of metaprogramming magic:
```js
Object.defineProperty(
TypeWriter.prototype, name.toLowerCase(),
{get() { return new Promise(resolve =>this.addEventListener(
name.toLowerCase(),
(event) => resolve(event.detail), {once: true})
)}}
)
```
Simply define a new property on the classes prototype with no setter and a getter that returns a new promise. It becomes a bit hard to read because the event-listener is added in the callback to the Promise constructor, but there's not really much magic going on other than the property definition.
### HTML Attributes
This feature has two parts, only the first of which happens inside the event-name loop:
#### Getting a Function
```js
Object.defineProperty(
TypeWriter.prototype,
`on${name}`,
{ get() {
return Function(this.getAttribute(`on${name}`))
}}
)
```
Once again, a new property is defined on the class, but this time there's another bit of meta-programming going on: the getter for the property accesses one of the element's attributes and turns the string into a function.
If, for example, a type-writer tag looks like this:
```html
<type-writer onTyped="console.log(event, this)"></type-writer>
```
then accessing the `onTyped` property on the object would return a function that's equivalent to the following:
```js
function() {
console.log(event, this)
}
```
#### Calling the Handler
The second part is to call this html-defined event handler, which can easily be done by adding an event listener in the object's constructor:
```js
constructor() {
super()
TypeWriter.eventNames.forEach(name => {
this.addEventListener(name.toLowerCase(), event => this[`on${name}`](event))
})
}
```
It simply loops over all the event names and adds a listener that makes use of the property to get a function and calls it. The `event` parameter passed to the event callback is just there as a hint that this is an event handler: it doesn't actually get uesd and can be turned into `_event` according to taste.
### Emitting Events
Now the last part is to actually emit some events. For this, I used a simple helper method to reduce boilerplate:
```js
emit(description, detail=undefined) {
this.dispatchEvent(new CustomEvent(description.toLowerCase(), {detail}))
}
```
There's not much to say about this; it's a very straight-forward helper method that can be called, for example, as `this.emit("typing", content)` right before the call to `typeElement` to signal that the type-writer is about to start typing some text.
-----
## Conclusion
So that's about it. There is, of course, a bunch more code dealing with the more boring technicalities, like skipping `<style>` tags when typing (as they are invisible anyway, and would appear as a random pause to the user if they were typed out), getters for delay-properties like `wait`, the time in seconds to wait after typing an element before starting to delete it, etc.
I hope this article was of some use, specially in illustrating how meta-programming can be used to shorten repetitive code like the adding of event-handlers, which would otherwise have been a long list of `get onTyped() {...}` and `addEventListener("typed", ...)` lines in the class definition, as well as how a set of recursive functions can be used to very easily traverse a DOM subtree.
I am aware that this example doesn't really target only one skill-level, as something like recursively traversing a tree is a lot more "basic" than dynamic property definitions and other meta-programming, but I hope that everyone can find at least one or two things they find helpful.
And last but not least, [here's the actual code](https://github.com/DarkWiiPlayer/components/blob/master/TypeWriter.js), but keep in mind that I changed and ommitted a few things to make the code easier to follow in the article.

View file

@ -1,206 +0,0 @@
---
title: Making the case for Skooma
description: In this short post I present a simple personal library I wrote for myself to handle HTML generation in JavaScript, compare it to many of its alternatives, and evaluate whether it was worth the time investment of not just picking an existing library.
published: true
tags: html, javascript, templating, metaprogramming, webdev
date: 2023-08-06
---
## Introduction
Skooma.js is a small library I built for myself to solve the problem of generating dynamic HTML from vanilla JavaScript. It's an adaptation of a Lua library of the same name to JS, with some additional quality of life improvements that only work because it runs in the browser.
The point of this post is not to convince anyone to use it. The API is relatively stable and it seems relatively bug-free by now, but this is still primarily intended as my personal helper library. Instead I want to make an argument for why I think it was a good idea to build it.
## History
Skooma, as mentioned above, started out as a Lua library to generate HTML on the server side. The core concept is simple: for every HTML (or XML) tag, there is a function that takes the tags contents and returns some representation of the tag. While the library allows users to mutate the returned elements (represented as a reeeeeally simple DOM structure), the library itself is free of side-effects (with the exception of certain helpers that are explicitly about side effects), so it works well with a functional approach.
You can map an array of strings with the `span` function to turn it into an array of `span` elements containing the strings as their text. Complex structures ("components") can easily be composed as new functions.
The primary motivation for this library was a general dissatisfaction with existing ways of writing HTML. Plain HTML and most templating languages are cumbersome to write. And while modern editors make the experience a lot less painful, with features like auto-closing tags, it still seems a bit backwards to need such heavy tooling to just write it.
Some of the more DLS-like templating languages like HAML get a lot closer to what I want, but I am really not a fan of having a separate DSL that is not quite its host language, not quite HTML and also not quite its own language.
A better approach, in my mind, was the one found in the [lapis](http://leafo.net/lapis/) framework, where html is generated from Lua functions (usually moonscript compiled to Lua, which makes for cleaner code) that can be nested. I also wrote my own iteration of this concept in the form of MoonHTML, but eventually abandoned the project because emitting the HTML as a side-effect instead of returning it came with a variety of scalability problems that made it easier for smaller templates but more complex in bigger projects.
The result was eventually Skooma (named after a fictional drug in the Elder Scrolls universe, made from a substance called *moon sugar*), which I still use to this day whenever I have the need to generate some HTML programmatically or just don't feel like typing out the actual HTML.
Given the many similarities between Lua and JavaScript, it was only a matter of time for me to decide to port the concept from one language to another, and the fact that browsers already have a DOM API means that whole part of the library can be removed and the result is still more powerful than working with my custom mini DOM.
## Features
Skooma is best explained by example, as a big part of the point is the (relatively) clean-looking code that looks *somewhat* like a purpose-built DSL.
```js
import {html} from '/skooma.js'
const user = ({name, email, picture}) =>
html.div({class: 'user'},
html.img({src: picture}),
html.h2(name),
html.a(email, {href: `mailto:${email}`})
)
fetch_users_from_somewhere()
.map(user)
.each(document.body.append)
```
In this example, I define a simple `user` component that takes an object with some attributes and generates a DOM structure to represent it. The HTML object is a proxy that generates functions for generating DOM nodes as writing `html.div(content)` is a lot nicer than `html.tag("div", content)`. `Proxy` really is a vastly underrated JavaScript feature.
The outermost `div` tag is given three other tags as its children, and an object representing its HTML attributes. This API is very flexible; one can pass several child elements and objects in whatever order and even nest them in arrays (which can then be re-used).
### Event Handlers
Since passing functions into HTML attributes makes no sense, this case is used for setting event handlers instead:
```js
html.button("Click me!", {
click: event => alert("Button has been clicked!")
})
```
This internally uses `addEventListener` instead of setting an `onclick` attribute, so this even lets one add several handlers of the same type, albeit in separate objects.
### Initialisers
Just like with attributes, putting a function in the child-list of a DOM element makes no sense, so this case is used to simply pass arbitrary initialisers to the element. These get called as soon as they're found instead of deferred, so any arguments that follow will not yet be applied. I have no strong opinion on whether deferring them would make more sense and might implement this if I ever find a good reason to prefer it.
```js
const register = element => { my_element_registry.add(element) }
// ^ pretend this gets used somewhere else to do something useful
html.button("Click me?", register)
```
### Dataset
Data-attributes can instead be set by passing a special `dataset` object key to an element constructor:
```js
const form_children = [/* ... */]
html.form({
'data-id': '0001', // this is ugly
dataset: { id: '0001' }, // this is nicer
}, form_children)
// Excessively hacky and ugly:
html.form(
Object.fromEntries(Object.entries(user).map(
([key, value]) => ["data-"+key, value])
),
form_children
)
// This is how things should be:
html.form({dataset: user}, form_children)
```
### Shadow-Root
Likewise, the key `shadowRoot` is also special, in that its value is added to the new object's shadow root, which is created if it doesn't exist yet. This follows the same logic as the function's arguments, so it can be a DOM node, a string, or a (possibly nested) array of child elements.
```js
html.div({
shadowRoot: [
html.h2("Greetings from the shadow DOM!"),
html.slot(),
html.span("It's very shadowy in here...")
]
}, html.i("Wait, where am I?!"))
```
### Styling
Similar to `dataset`, the `style` attribute can be used to pass an object and have its values assigned to the DOM node's `style` attribute.
```js
html.span({
style: {
textDecoration: 'underline',
// gets transformed to kebab-case
}
})
```
### Custom Elements
Custom elements, which have hyphens in their names, don't have to be created using square braces (although you can, if you hate yourself) `html['my-component']("inner text")`; instead, camelCase tag names are converted to kebab-case just like style properties and html attributes, so you can just write `html.myComponent("inner text")` instead.
```js
html.typeWriter({
customProperty: "I have a property!",
}, [
html.span("Greetings, I am a custom element"),
])
```
## As a Learning Experience
All in all, this was a really fun project to implement. `Proxy` objects are really nice, and porting the code from Lua, which has a completely different way of doing a very similar thing, was ultimately still really easy. And even though I only used a small part of the `Proxy` API, I still used the chance to read up on some of the other possibilities it offers. `Proxy` is cool!
## Comparing to Alternatives
### Interpolation++
Comparing skooma.js to anything from `String ${'templates'}` to traditional PHP, where you still write HTML but can interpolate content into your output and sometimes even insert blocks of logic into it, skooma does as good a job as all the alternatives below at getting rid of my primary problem: HTMLs annoying syntax.
### HAML & Co.
These templating "languages" honestly aren't bad. I am perfectly happy writing HAML templates whenever I'm having to work with rails, and any of its alternatives in other languages would work as well.
My main problems with these are the context-switching between languages, and the fact that it's not nearly as easy to refactor by extracting common structures into sub-components, as you can't just draw them out into a function and use it later on.
### VanJS
Had I found this library before writing Skooma, I probably would have just used it instead. It does basically the same thing, albeit with less convenience features, and will most likely still use this for work projects, as it has the benefit of being a "proper" framework (i.e. I didn't write it myself and it has a fancy website), so it will simply seem more legitimate to coworkers. Gotta love workplace politics.
I will say though, that I am not at all a fan of how it encourages importing all the tag functions into the current scope. That just screams scalability nightmare, and in any bigger project this will inevitably lead to a) lots of unused tag functions still being declared and b) constant "why isn't it wor— oh I haven't imported `ul` yet"
But that's just a style choice and the library doesn't force you to do things that way.
### Skooma (Lua)
This is, to me, the gold standard of syntax. Lua has some small advantages in its syntax that make it easier to make code look nice:
1. Ommitting braces when calling a function with a table literal as its only argument
2. Tables acting both as arrays and maps
3. Semicolons being allowed instead of commas (I hate commas for multi-line things)
4. Overriding `_ENV` and loading code with custom environments
So the `user` component from my first example could instead be written like this:
```lua
function user(u)
return div{
class = "user";
img { src = u.picture };
h2 { name };
a {
email;
href: "mailto:" .. u.email
}
}
end
```
And if I want to instead write it in moonscript or yuescript, a language (and a dialect of it) that compiles to Lua, I could even write my component like this:
```moon
user ==>
div class: "user"
* img src: @picture
* h2 name
* a email, href: "mailto:#{@email}"
```
which is starting to look almost like an actual DSL, except it's not, and I can put as much "real programming logic" like loops, function calls, etc. right in my HTML code and the syntax is the exact same between logic and template.
Admittedly, I could *probably* achieve the same in JS if I used something like CoffeeScript.
## Conclusions
1. Writing this library was definitely worth it. It was fun, I got to practise using `Proxy` and the result is definitely quite usable. I would absolutely do this again and encourage anyone to try projects like this one even if just for the fun aspect alone.
2. I already am and will continue to use skooma.js for my personal projects. Once I got used to it, it just feels weird to imagine using things like JSX or even assembling DOM nodes by hand using browser APIs.
3. I can't really be bothered to lobby people to use my cute little project and turn it into a "real thing", but if you're interested, or maybe even use VanJS and want to see how it compares, by all means, use Skooma. It's as production-ready as a single dev can make it, and due to its simplicity, bugs happen rarely and can usually be fixed within half an hour. Documentation is available at [darkwiiplayer.github.io/js/skooma.html](https://darkwiiplayer.github.io/js/skooma.html)

26
blog-dev-1.rockspec Normal file
View file

@ -0,0 +1,26 @@
package = "blog"
version = "dev-1"
source = {
url = ""
}
description = {
homepage = "https://darkwiiplayer.github.io/blog",
license = "Proprietary"
}
dependencies = {
"arrr ~> 2.2",
"cmark ~> 0.29",
"fun ~> 0.1",
"glass ~> 1.3",
"lua-cjson ~> 2.1",
"restia",
"rgbstr",
"scaffold ~> 1.3.1",
"shapeshift ~> 1.1",
"skooma",
"streamcsv ~> 1.1",
}
build = {
type = "builtin",
modules = { }
}

99
build.lua Normal file
View file

@ -0,0 +1,99 @@
local fun = require 'fun'
local json = require 'cjson'
local restia = require 'restia'
local scaffold = require 'scaffold'
local shapeshift = require 'shapeshift'
-- Project-specific stuff
local rss = require 'feed.rss'
local atom = require 'feed.atom'
local paramparser = require 'paramparser'
local params = paramparser(...)
package.loaded.params = params
local pages = require 'pages'
local templates = require 'templates'
local posts = require 'posts'
local output_tree = {}
for name, content in pairs(scaffold.readdir("static")) do
scaffold.deep(output_tree, name, content)
end
local function is_image(name)
if name:downcase():match("^.jpg$") then
return true
else
return false
end
end
local function render(name, data)
return templates.main(templates[name], data)
end
local function page(name, data)
return templates.main(pages[name], data)
end
-- Render Posts
for _, post in ipairs(posts) do
local body = tostring(render("post", post))
scaffold.deep(output_tree, post.path, body)
end
scaffold.deep(output_tree, "feeds/all.rss.xml", rss(posts))
scaffold.deep(output_tree, "feeds/all.atom.xml", atom(posts))
if params.delete then
restia.utils.delete(params.output)
end
do -- Copy blog images
local function iter_files(tree, callback)
if getmetatable(tree) == scaffold.lazy then
callback(tree)
else
for _, node in pairs(tree) do
iter_files(node, callback)
end
end
end
iter_files(scaffold.readdir("posts", {files = "lazy"}), function(file)
local name = file.path:match("[^/]+$")
if name:find(".jpg$") or name:find(".svg$") then
local path = "/images/" .. name
scaffold.deep(output_tree, path, file)
end
end)
end
local function transform(tab)
return function(data)
local success, result = shapeshift.table(tab, "keep")(data)
return result
end
end
local function drop() return true, nil end
-- Generate Post Metadata
output_tree["posts.json"] = json.encode(
fun
.iter(posts)
:map(transform {
body = drop;
head = shapeshift.table({ file = drop }, 'keep');
})
:totable()
)
output_tree["index.html"] = tostring(page("index", output_tree["posts.json"]))
if params.cname then
output_tree.CNAME = params.cname
end
scaffold.builddir(params.output, output_tree)

1
config/description Normal file
View file

@ -0,0 +1 @@
This is my personal blog with a focus on IT and Programming content unless I have something else to say.

2
config/me.yaml Normal file
View file

@ -0,0 +1,2 @@
name: Talia
link: https://tech.lgbt/@darkwiiplayer

3
config/modules.csv Normal file
View file

@ -0,0 +1,3 @@
name,preload,url
skooma,false,https://cdn.jsdelivr.net/gh/darkwiiplayer/js@cdaeac1/skooma.js
element,false,https://cdn.jsdelivr.net/gh/darkwiiplayer/js@3724b3e/element.js
1 name preload url
2 skooma false https://cdn.jsdelivr.net/gh/darkwiiplayer/js@cdaeac1/skooma.js
3 element false https://cdn.jsdelivr.net/gh/darkwiiplayer/js@3724b3e/element.js

1
config/title Normal file
View file

@ -0,0 +1 @@
Talia's Blog

7
lib/config.lua Normal file
View file

@ -0,0 +1,7 @@
local glass = require 'glass'
return glass.bind('config', {
(require 'glass.raw');
(require 'glass.lua');
(require 'glass.yaml');
(require 'glass.csv');
})

60
lib/feed/atom.lua Normal file
View file

@ -0,0 +1,60 @@
local skooma = require 'skooma'
local config = require 'config'
local xml = skooma.env()
local rfc3339 = "%Y-%m-%dT%H:%M:%SZ"
local function map(sequence, fun)
local new = {}
for key, value in ipairs(sequence) do
new[key] = fun(value)
end
return new
end
return function(posts)
return [[<?xml version="1.0" encoding="utf-8"?>]] .. tostring(xml.feed{
xmlns="http://www.w3.org/2005/Atom";
xml.id("https://blog.but.gay/");
xml.title(config.title:gsub("\n$", ""));
xml.link { rel="alternate", href = "https://blog.but.gay/", type="text/html" };
xml.link { rel="self", href = "https://blog.but.gay/feeds/all.atom.xml" };
xml.updated(os.date(rfc3339));
xml.author(
xml.name(config.me.name),
xml.uri(config.me.link)
);
xml.generator {
uri = "https://github.com/darkwiiplayer/blog";
"Home-grown SSG"
};
--xml.description(config.description);
map(posts, function(post)
local link = "https://blog.but.gay"..post.head.uri
return xml.entry {
xml.id(link);
xml.title(post.head.title);
function()
if post.head.updates then
return xml.updated(os.date(rfc3339, post.head.updates[#post.head.updates]));
else
return xml.updated(os.date(rfc3339, post.head.timestamp));
end
end;
--
xml.summary(post.head.description);
xml.content {
type="html";
post.body;
};
xml.link { href = link };
--
xml.published(os.date(rfc3339, post.head.timestamp));
map(post.head.tags, function(tag)
return xml.category { term = tag }
end)
}
end)
})
end

35
lib/feed/rss.lua Normal file
View file

@ -0,0 +1,35 @@
local skooma = require 'skooma'
local config = require 'config'
local xml = skooma.env()
local function map(sequence, fun)
local new = {}
for key, value in ipairs(sequence) do
new[key] = fun(value)
end
return new
end
return function(posts)
return tostring(xml.rss{
version="2.0";
xml.channel {
xml.title(config.title);
xml.link "https://blog.but.gay/";
xml.description(config.description);
xml.language "en-uk";
xml.lastBuildDate(os.date());
map(posts, function(post)
local link = "https://blog.but.gay"..post.head.uri
return xml.item {
xml.title(post.head.title);
xml.description(post.head.description);
xml.link(link);
xml.guid(link);
xml.pubDate(os.date("%d %b %Y", post.head.timestamp));
}
end)
}
})
end

25
lib/paramparser.lua Normal file
View file

@ -0,0 +1,25 @@
local arrr = require 'arrr'
local shapeshift = require 'shapeshift'
return function(...)
local is = shapeshift.is
local parse = arrr {
{ "Output directory", "--output", "-o", 'directory' };
{ "Input directory", "--input", "-i", 'directory' };
{ "Include unpublished posts", "--unpublished", "-u", nil };
{ "Set the github pages CNAME", "--cname", nil, 'domain' };
{ "Delete everything first", "--delete", "-d" };
}
local validate = shapeshift.table {
output = shapeshift.default("output", is.string);
input = shapeshift.default(".", is.string);
copy = shapeshift.default({}, shapeshift.all{
is.table,
shapeshift.each(is.string)
});
cname = shapeshift.any(is.Nil, is.string);
unpublished = shapeshift.default(false, shapeshift.is.boolean);
delete = shapeshift.default(false, shapeshift.is.boolean);
}
return select(2, assert(validate(parse{...})))
end

79
lib/posts.lua Normal file
View file

@ -0,0 +1,79 @@
local cmark = require 'cmark'
local restia = require 'restia'
local params = require 'params'
local yaml = require 'lyaml'
local shapeshift = require 'shapeshift'
local string = require 'stringplus'
local function parsedate(date)
local year, month, day = date:match("(%d+)%-(%d+)%-(%d+)")
return os.time {
year = tonumber(year) or error("Invalid date string: " .. date);
month = tonumber(month) or error("Invalid date string: " .. date);
day = tonumber(day) or error("Invalid date string: " .. date);
}
end
local validate_head do
local is = shapeshift.is
validate_head = shapeshift.table {
__extra = 'keep';
title = is.string;
date = shapeshift.matches("%d%d%d%d%-%d%d%-%d%d");
file = is.string;
published = shapeshift.default(false, shapeshift.matches("^true$"))
}
end
local function read_post(file)
local content = io.open(file):read("*a")
local head_text, body_text = restia.utils.frontmatter(content)
local head = head_text and yaml.load(head_text) or {}
local body = cmark.render_html(cmark.parse_document(body_text, #body_text, cmark.OPT_DEFAULT), cmark.OPT_DEFAULT + cmark.OPT_UNSAFE)
local cover_image = file:gsub("md$", "jpg")
if io.open(cover_image) then
head.cover_image = "/images/" .. cover_image:match("[^/]+$"):gsub(" ", "%%20")
end
return { head = head, body = body }
end
local posts = {}
for file in restia.utils.files(params.input, "^./posts/.*%.md$") do
print("Reading post "..file.."...")
local post = read_post(file)
post.head.file = file
assert(validate_head(post.head))
post.head.timestamp = parsedate(post.head.date)
if "string" == type(post.head.tags) then
post.head.tags = string.split(post.head.tags, "[%a-]+")
end
for key, tag in ipairs(post.head.tags or {}) do
post.head.tags[key] = string.lower(tag)
end
post.head.slug = post.head.title
:gsub(' ', '_')
:lower()
:gsub('[^a-z0-9-_]', '')
post.head.uri = string.format("/%s/%s.html", post.head.date:gsub("%-", "/"), post.head.slug)
post.path = post.head.uri
if post.head.published or params.unpublished then
table.insert(posts, post)
end
end
table.sort(posts, function(a, b)
return a.head.timestamp > b.head.timestamp
end)
return posts

11
lib/stringplus.lua Normal file
View file

@ -0,0 +1,11 @@
local _string = {}
function _string.split(str, pattern)
local result = {}
for item in str:gmatch(pattern) do
table.insert(result, item)
end
return result
end
return setmetatable(_string, {__index=_G.string})

24
lib/tags.lua Normal file
View file

@ -0,0 +1,24 @@
local html = require 'skooma.html'
local rgbstr = require 'rgbstr'
local function tag(name)
local colour = { rgbstr.bytes(name, 16, .3, .5) }
return html.postTag(html.a {
tabindex = 0;
name;
href = "/?tag="..name;
style = "--color: rgb("..table.concat(colour, ', ')..")";
})
end
return function(tags)
if tags then
local list = { gap=".4", style="justify-content: flex-start" }
for _, name in ipairs(tags) do
table.insert(list, tag(name))
end
return {
html.flexRow(list);
}
end
end

1
pages/Tupfile Normal file
View file

@ -0,0 +1 @@
include_rules

View file

@ -0,0 +1,29 @@
rgbstr = require 'rgbstr'
slots, json = select 1, ...
posts = require 'posts'
tags = require 'tags'
post = =>
color = table.concat({rgbstr.bytes(@head.tags[1], 16, .3, .5)}, " ")
flexColumn class: "info box", style: "--color: rgb(#{color})"
* h2 a(@head.title, href: @head.uri), style: "view-transition-name: #{@head.slug}"
* small time is: 'local-date', datetime: @head.date, @head.date
* p @head.description
* tags(@head.tags)
slots.head title "Talia's Blog"
slots.head
* meta property: "og:title", content: "Index"
* meta property: "og:site_name", content: "Talia's Blog"
* meta property: "og:description", content: "This is my personal blog with a focus on IT and Programming content unless I have something else to say."
* meta property: "og:type", content: "website"
* meta property: "og:article:author", content: "Talia"
slots.head script type: 'module', src: "/javascript/LocalDate.js"
slots.head script type: 'module', src: "/javascript/BlogPost.js"
return article
class: "content-width"
* h1 "Blog Posts"
* [blogPost post p for p in *posts]

4
pages/init.lua Normal file
View file

@ -0,0 +1,4 @@
local glass = require 'glass'
return glass.bind('pages', {
(require 'glass.skooma.html');
})

73
static/css/site.css Normal file
View file

@ -0,0 +1,73 @@
@import url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@74e5799/all.css') layer(framework);
@import url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@74e5799/drop-in.css') layer(framework);
blog-post[hidden] {
display: none;
}
.summary {
font-size: .8em;
font-style: italic;
opacity: .6;
transition: opacity .3s;
tab-stop: 0;
}
.summary:is(:hover, :focus) {
opacity: 1;
}
blog-post {
display: block;
}
blog-post + blog-post {
margin-top: 2em;
}
@layer post-tags {
post-tag > a {
position: relative;
text-decoration: none;
color: inherit;
padding: .2em;
line-height: 1em;
padding-left: 1.2em;
transition: all .3s;
display: inline-block;
}
post-tag > a::before, post-tag > a::after {
z-index: -1;
display: block;
content: '';
border-radius: .2em;
top: .4em;
left: .2em;
width: .6em;
height: .6em;
position: absolute;
background: var(--color);
transition: all .3s;
}
post-tag > a:hover {
color: white;
}
post-tag > a:hover::before {
display: block;
content: '';
border-radius: .2em;
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
background: var(--color);
}
post-tag > a:hover::after {
background: color-mix(in srgb, var(--color) 60%, white);
}
}

View file

@ -0,0 +1,18 @@
import element from 'https://cdn.jsdelivr.net/gh/darkwiiplayer/js@3724b3e/element.js'
element(class BlogPost extends HTMLElement {
connectedCallback() {
this.filter()
}
get tags() {
return new Set(Array.from(this.querySelectorAll("post-tag")).map(e => e.innerText))
}
filter() {
const search = new URL(document.location).searchParams
if (search.has("tag")) {
this.hidden = !this.tags.has(search.get("tag"))
}
}
})

View file

@ -0,0 +1,14 @@
import element from 'https://cdn.jsdelivr.net/gh/darkwiiplayer/js@3724b3e/element.js'
element(class LocalDate extends HTMLTimeElement {
static is = 'time'
static attributes = { datetime: { get: date => new Date(date) } }
constructor() {
super()
}
datetimeChanged() {
this.innerText = this.datetime.toDateString()
}
})

60
tasks.lua Normal file
View file

@ -0,0 +1,60 @@
local task = require'spooder'.task
local path = table.concat({
"lib/?.lua",
"lib/?/init.lua",
"lua_modules/share/lua/5.4/?.lua",
"lua_modules/share/lua/5.4/?/init.lua",
";",
}, ";")
local cpath = table.concat({
"lua_modules/lib/lua/5.4/?.so",
"lua_modules/lib/lua/5.4/?/init.so",
";",
}, ";")
task.setup {
description = "Sets up directories and dependencies";
"mkdir -p lua_modules .luarocks";
"luarocks install --only-deps *.rockspec";
}
task.clean {
description = "Removes local rocks";
'rm -r lua_modules';
}
task.build {
description = "Builds the page";
'mkdir -p .luarocks lua_modules';
'luarocks install --only-deps *.rockspec';
'tup';
'rm -rf blog/*';
string.format(
[[
export LUA_PATH='%s'
export LUA_CPATH='%s'
lua build.lua --output blog --cname blog.but.gay
]],
path, cpath
)
}
task.deploy {
description = "Deploys the blog to latest version";
depends = "build";
[[
hash=$(git log -1 --format=%h)
cd blog
find . | treh -c
git add --all
if git log -1 --format=%s | grep "$hash$"
then git commit --no-verify --amend --no-edit
else git commit --no-verify -m "Update blog to $hash"
fi
git push --force origin page
cd ../
git stash pop || true
]];
}

1
templates/Tupfile Normal file
View file

@ -0,0 +1 @@
include_rules

4
templates/init.lua Normal file
View file

@ -0,0 +1,4 @@
local glass = require 'glass'
return glass.bind('templates', {
(require 'glass.skooma.html');
})

View file

@ -0,0 +1,64 @@
import output from require 'params'
import slotty from require 'skooma'
import 'config'
slots = slotty!
styles = [[
@import url('https://fonts.googleapis.com/css2?family=Raleway&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Open+Sans&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&display=swap');
@view-transition { navigation: auto; }
:root { container: style; }
:is(h1, h2, h3, h4, h5, h6) { font-family: "Raleway", sans-serif; }
:is(code, kbd, var, samp) { font-family: "Fira Code", monospace; }
.badge { font-family: "Open Sans", sans-serif }
]]
slots.footer aside id: "contact"
* h2 "Social"
* p
* "Got feedback? — Tag me on fedi!"
* br
* a "@darkwiiplayer@tech.lgbt", href: 'https://tech.lgbt/@darkwiiplayer', rel: 'me'
slots.footer aside id: "git"
* h2 "Git"
* ul
* li a "Github", href: "https://github.com/darkwiiplayer"
* li a "Forgejo", href: "https://git.but.gay/darkwiiplayer"
slots.footer aside id: "platforms"
* h2 "Federated cloud"
* p "darkwiiplayer@cloud.but.gay"
css = =>
link rel: 'stylesheet', href: @
content, data = select 1, ...
html
lang: "english"
* head
* link rel: 'alternate', type: 'application/rss+xml', title: 'RSS 2.0 feed', href: "/feeds/all.rss.xml"
* link rel: 'alternate', type: 'application/atom+xml', title: 'Atom feed', href: "/feeds/all.atom.xml"
* meta name: "view-transition", content: "same-origin"
* meta charset: "UTF-8"
* meta name: "viewport", content: "width=device-width"
* css "/css/site.css"
* style styles
* slots.head
* [ link rel: "modulepreload", href: module.url for module in *config.modules when module.preload ]
* body
* slots.top
* header class: 'sticky', style: "view-transition-name: header"
* h1 "Talia's Blog"
* nav { class: 'right underlined bar' }
* ul li a "Home", href: "/"
* main
* content slots, data
* ->
footer class: "box"
* gridBox columns: math.min(#slots.footer, 3), class: 'content-padding', center: true
* slots.footer

View file

@ -0,0 +1,69 @@
tags = require 'tags'
import output from require 'params'
slots, post = select 1, ...
url = (path) -> "/"..output..path
slots.head title post.head.title
-- Generic shenanigans
slots.head
* meta name: "author", content: "Talia"
-- Search shenanigans
slots.head
* meta property: "article:published_time", content: post.head.date
* meta property: "article:author", content: "https://tech.lgbt/@darkwiiplayer"
-- OpenGraph shenanigans
slots.head
* meta property: "og:title", content: post.head.title
* meta property: "og:site_name", content: "Talia's Blog"
* meta property: "og:description", content: post.head.description
* meta property: "og:type", content: "article"
* meta property: "og:article:author", content: "Talia"
* meta property: "fediverse:creator", content: "@darkwiiplayer@tech.lgbt"
* [ meta property: "og:article:tag", content: tag for tag in *post.head.tags ]
positions = (input, character) ->
((last) => @find(character, last+1, true)), input, 1
breadcrumb = (href) -> li a :href, href\match('[^/]+$'), tabindex: 0
if src := post.head.cover_image
slots.top pageHero cover: 60
* img :src, style: 'opacity: .4', class: "parallax"
* flexColumn
* class: "parallax"
* h1(post.head.title, style: "view-transition-name: #{post.head.slug}")
slots.head
* meta property: "og:image", content: src
else
slots.title h1(post.head.title, style: "view-transition-name: #{post.head.slug}")
slots.summary div post.head.description, class: "summary", tabindex: 0
slots.summary verticalSpacer
slots.banner aside class: { 'floating box' }, stripe: "rebeccapurple"
* b "Hey there!"
* p raw [[
This blog doesn't use any tracking. When you open this page, I don't see
a number going up anywhere, so if you like this post, please consider
letting me know on <a href="https://tech.lgbt/@darkwiiplayer">the fediverse</a>!
This is also the best way of giving any other feedback on my blog posts.
]]
* p i "Thank you."
return (=>@)
* article
* slots.title
* slots.summary
* tags(post.head.tags)
-- * nav { class: "breadcrumbs" }
-- * ul
-- * li a "Blog", tabindex: 0, href: url "/"
-- * [ breadcrumb post.head.uri\sub(1, number-1) for number in positions(post.head.uri, "/") ]
-- * li post.head.title, class: 'active'
* verticalSpacer
* slots.banner
* raw post.body