feat(articles): 0002-confgen-solving-config-files

This commit is contained in:
LordMZTE 2024-08-21 23:30:52 +02:00
parent 893ffee3bb
commit 6bf4b97108
Signed by: LordMZTE
GPG key ID: B64802DC33A64FF6
5 changed files with 391 additions and 18 deletions

View file

@ -18,25 +18,20 @@ cg.opt.renderMarkdown = function(src)
if node_type == cmark.NODE_CODE_BLOCK then
-- Syntax highlighting with Bat and AHA
local lang = cmark.node_get_fence_info(cur)
if #lang > 0 then
local tmpfp = os.tmpname()
local proc = io.popen(
"bat --color always --style plain --language " .. lang .. " | aha --no-header >" .. tmpfp,
"w"
)
proc:write(cmark.node_get_literal(cur))
proc:close()
local tmpfp = os.tmpname()
local proc = #lang > 0 and io.popen(
"bat --color always --style plain --language " .. lang .. " | aha --no-header >" .. tmpfp,
"w"
) or io.popen("aha --no-header >" .. tmpfp, "w")
proc:write(cmark.node_get_literal(cur))
proc:close()
local tmpf = io.open(tmpfp, "r")
local new = cmark.node_new(cmark.NODE_HTML_BLOCK)
cmark.node_set_literal(
new,
[[<pre class="codeblock"><code>]] .. tmpf:read "*a" .. [[</code></pre>]]
)
cmark.node_replace(cur, new)
tmpf:close()
os.remove(tmpfp)
end
local tmpf = io.open(tmpfp, "r")
local new = cmark.node_new(cmark.NODE_HTML_BLOCK)
cmark.node_set_literal(new, [[<pre class="codeblock"><code>]] .. tmpf:read "*a" .. [[</code></pre>]])
cmark.node_replace(cur, new)
tmpf:close()
os.remove(tmpfp)
elseif node_type == cmark.NODE_LINK then
-- This basically reimplements links in order to make them open in a new tab.

View file

@ -9,6 +9,7 @@ function main() {
.fetch("/articles.json")
.then(r -> r.json())
.then((j:Array<Article>) -> {
j.reverse();
filter.articles = j;
for (a in j) {
filter.articleDiv.appendChild(new ArticleElement(a));

View file

@ -0,0 +1,7 @@
return {
id = "0002-confgen-solving-config-files",
title = "Confgen: Solving Config Files",
summary = "The hassle of managing config files and other text-based content is no more!",
date = "2024-08-21",
tags = { "code", "confgen", "config", "lua", "template-engine" },
}

View file

@ -0,0 +1,358 @@
<!
-- vim: ft=markdown
local meta = opt.articles[2]
tmpl:setPostProcessor(opt.mkArticle(meta))
local oe, ce = "<" .. "%", "%" .. ">"
local oc, cc = "<" .. "!", "!" .. ">"
!>
If you've known me for any amount of time, you'll probably have witnessed me bragging about this,
only to be disappointed when I ultimately had to answer that it's kind of a steep learning curve.
This is what I'm trying to solve here by giving you an introduction as gentle as I'm able to make it
to [Confgen](https://git.mzte.de/LordMZTE/confgen), the last template engine you will ever need.
Despite its namesake, Confgen is good at far more than generating config files. It ultimately
evolved into a universal template engine for generating... anything you could think of.
## What even is this?
Confgen is a template engine. If you've never heard that term before, it's basically a tool that
takes in a file with placeholders in it, and then inserts some data in place of them. This might
sound unspectacular at first, but that's also a gross oversimplification. Confgen, for one, doesn't
only let you have placeholders (expression blocks), but also lets you have control flow (code
blocks). `for`s, `if`s and functions are all fair game. What makes Confgen even more special is
that, unlike most template engines, it doesn't implement all these daunting concepts itself, but
instead harnesses the full power of the [Lua](https://lua.org) programming language (basic knowledge
of which will be considered a prerequisite for the rest of this article, but it's an easy language
to learn!).
If you're still not convinced, the page you are reading right now is, in fact, generated by Confgen,
from Markdown. Syntax highlighting and all.
> Markdown, for those uninitiated, is a very easy to read markup language, which is fun to write in!
> Think of it as HTML in easy mode.
This is roughly what the source of this page looks:
```markdown
## Header here!
This paragraph contains a **bold** statement.
> I'm a quote!
```
..and so on.
All that is turned into HTML by Confgen, so your browser can show it while I can still keep the last
remaining bit of my sanity while writing. So now that you're convinced, let's get you started!
> **Note**: Bring some form of brain coolant :D
## Installation
Please refer to [the Confgen repository README](https://git.mzte.de/LordMZTE/confgen) for
instructions. If you're on NixOS, a simple `nix shell git+https://git.mzte.de/LordMZTE/confgen` will
suffice. You can also find Linux binaries built on a Debian CI on the
[releases tab](https://git.mzte.de/LordMZTE/confgen/releases), but those may be outdated.
On other platforms (except Windows, which it wasn't worth having **backwards**-compatibility for),
you'll need to build from source using a [Zig](https://ziglang.org) compiler.
## Your first Confgenfile
Now that you've got Confgen ready to roll, let's experiment with it! You can do this in a blank
directory, your dotfiles, or, if you're feeling adventurous, your website's source code.
Confgen is centered around the so-called *Confgenfile*, commonly named `confgen.lua`. It describes
the layout of the project, declares common values, functions and tells confgen what files to
process. Assuming that you want to generate your configuration files, this is what it might look
like.
> **Note**: This file would be placed in your dotfiles *source*, not your home directory. The `.config`
> here is not `~/.config`!
```lua
-- confgen.lua
cg.addPath(".config")
cg.opt = {
font = "MyFavoriteFont",
font_size = "11",
}
```
Alright, now what does this actually mean? Let's start with the basic order of operations Confgen
works in.
- Confgen starts off by evaluating the *Confgenfile*, like a normal Lua program. Here, you may
declare functions, options by adding to the `cg.opt` table, or execute any arbitrary Lua code, for
example to load machine-local options.
- During this evaluation, Confgen builds a list of input files. As the user, it's your job to add to
this list. Here, `cg.addPath` will recursively add all files inside the given directory to the list.
There are more functions available, such as `cg.addFile`. For a full documentation of the API,
please refer to `man 3 confgen`.
- Then, Confgen will iterate through the file list built during the evaluation phase. Each file
ending in `.cgt` (Confgen Template) will be evaluated as a template (more on that in a second),
while other files will be copied to the output directory.
## The first template (of many)
If you now run `confgen confgen.lua output` (specifying your Confgenfile and an output directory),
you will end up with a disappointingly empty directory. Let's fix that by creating a configuration
file template, in this case for a hypothetical terminal emulator called `coolterm`.
```ini
# .config/coolterm/config.ini.cgt
font = "<% oe %> opt.font <% ce %>:<% oe %> opt.font_size <% ce %>"
[keybinds]
zoom-in = "ctrl+plus"
zoom-out = "ctrl+minus"
```
First, note the `.cgt` we added to that filename (since coolterm expects a file called
`~/.config/coolterm/config.ini`). This is how Confgen knows that it should not just copy the file.
Confgen will strip this second extension during evaluation.
If we now run `confgen confgen.lua output` again, we'll find a fully generated configuration file:
```ini
# output/.config/coolterm/config.ini
font = "MyFavoriteFont:11"
[keybinds]
zoom-in = "ctrl+plus"
zoom-out = "ctrl+minus"
```
Now, this might seem like a massively overcomplicated way of declaring a font in a terminal
configuration, but hear me out. Let's say we also want to configure GTK (or anything else that wants
to know a font). We can simply add this to our files:
```ini
# .config/gtk-3.0/settings.ini.cgt
[Settings]
gtk-font-name=<% oe %> opt.font <% ce %> <% oe %> opt.font_size <% ce %>
```
We've just deduplicated our font name and size! Whereas previously, if you changed your mind about
your favorite font, you'd had to have to change two files, you now only need to adjust it in your
`confgen.lua`! Sure, sounds unspectacular at first, after all, why go to all of this trouble for a
slight gain in convenience in a very rare situation, but just look at my (admittedly
mind-bogglingly gigantic) config files:
```bash
$ rg --hidden 'opt.font' | wc -l
30
```
Not so insignificant anymore, is it? And this is also far from all Confgen has to offer!
## But wait, there's more (*way* more)!
Say that you'd like to use [Waybar](https://github.com/Alexays/Waybar), but you're also not sure
if you want to use Hyprland or River as your wayland compositor of choice, but you want workspaces
and the title of the focused window in your bar no matter, which one you're using. Let's create a
config!
```json
// .config/waybar/config.jsonc.cgt
{
"modules-left": [
<% oc %> if opt.compositor == "river" then <% cc %>
"river/tags",
<% oc %> elseif opt.compositor == "hyprland" then <% cc %>
"hyprland/workspaces",
<% oc %> end <% cc %>
"<% oe %> opt.compositor <% ce %>/window"
],
// ...
}
```
Now, it's just a matter of adding a `cg.opt.compositor = "X"` to your Confgenfile and rebuilding!
And of course, this is not limited to just `if`s! `for`s as well as anything else Lua allows is
possible! Sky's the limit!
> **Note**: Seems inconvenient to rebuild your config every time you switch compositors, doesn't it?
> This problem is solved through ConfgenFS, where the config file is able to automagically change
> each time either compositor starts. You can even have non-deterministic config files (sorry for
> destroying your hopes and dreams, dear NixOS user reading). Article coming soon!
This is still not optimal, though. After all, it's JSON and that's inconvenient to work with.
Wouldn't it be nice if this were Lua too? Well, this is where post-processors come into play!
```lua
-- .config/waybar/config.jsonc.cgt
<% oc %> tmpl:setPostProcessor(function(prev)
local value = loadstring(prev)() -- Load and evaluate the Lua code this template generates.
return cg.fmt.json.serialize(value) -- Serialize it into JSON using Confgen's builtin serializer.
end) <% cc %>
return {
["modules-left"] = {
<% oc %> if opt.compositor == "river" then <% cc %>
"river/tags",
<% oc %> elseif opt.compositor == "hyprland" then <% cc %>
"hyprland/workspaces",
<% oc %> end <% cc %>
"<% oe %> opt.compositor <% ce %>/window"
},
-- ...
}
```
While this snippet will probably prevent any experienced Lua programmer from sleeping well for a
week, it's still kinda cool, don't you agree? Since this might be a little hard to digest, I'll
break it down.
Templates (referenced through the `tmpl` global variable inside the template's code) may have a
post-processor. A post-processor is nothing more than a function that will be called by Confgen once
the template itself is already done generating. The function will be passed the result of the
template and return a modified version. In this case, the content before the post-processor is the
Lua code under the "header", and the modified version is the JSON value Waybar will see. This
post-processor is set by calling the `setPostProcessor` function on the template. In this case, it
has a rather simple implementation.
And this is precisely how the source code of this article has been converted from Markdown to HTML.
It's a post-processor, albeit one with a far more complicated implementation.
## How is this sorcery possible?!
As you write more complex templates, it's always important to remember the gist of how they work
under the hood. While a Lua runtime certainly is happy evaluating single statements as those you
might know from `<% oe %> ... <% ce %>` blocks, it definetely won't swallow partial statements and
unclosed delimiters such as those you'll often have in `<% oc %> ... <% cc %>` blocks. The trick
Confgen uses to get around this is to **compile the template to Lua**, which is then evaluated in
one go per template. For debugging your templates, Confgen provides a command to compile a template
to this intermediate Lua, `confgen --compile`. Let's try it on our waybar config by invoking
`confgen -c .config/waybar/config.jsonc.cgt` (cleaned up a bit for ease of reading):
```lua
tmpl:pushLitIdx(tmplcode, 0)
tmpl:setPostProcessor(function(prev)
local value = loadstring(prev)() -- Load and evaluate the Lua code this template generates.
return cg.fmt.json.serialize(value) -- Serialize it into JSON using Confgen's builtin serializer.
end)
tmpl:pushLitIdx(tmplcode, 1)
if opt.compositor == "river" then
tmpl:pushLitIdx(tmplcode, 2)
elseif opt.compositor == "hyprland" then
tmpl:pushLitIdx(tmplcode, 3)
end
tmpl:pushLitIdx(tmplcode, 4)
tmpl:pushValue(opt.compositor)
tmpl:pushLitIdx(tmplcode, 5)
```
This should look familiar. All our `<% oc %> ... <% cc %>` blocks have been inserted here verbatim,
like our post-processor! This is what makes arbitrary control flow possible. Contrarily, our
`<% oe %> ... <% ce %>` blocks have been replaced with `tmpl:pushValue(...)`. `pushValue` is simply a
mostly invisible function from Confgen that turns the given value to a string and appends it to the
output. Something else that should be jumps out here are all those calls to `pushLitIdx` (push
literal (at) index). In short, Confgen uses this number to look up a span from your template's
source code, stored in the opaque `tmplcode` object to append to the output.
> `tmplcode` is a separate object from `tmpl`, which is required when templates either don't have
> their own source or use the code from another template file. You'll (possibly) understand in a
> second.
## Subtemplates
That's right, as if this wasn't complicated enough already, you can nest them.
> *"We were so preoccupied with asking if we could, that we didn't stop to think if we should."*
The reason for the existance of subtemplates is only hard, and not impossible to justify. Let me
try.
Say you had an HTML file you want to generate. It contains some boring enclosing tags on the
outside, and a huge chunk of text in the middle that also contained some template expressions, that
you'd love to write in Markdown because HTML is just a pain to work with after all. The Markdown to
HTML Lua function is also already written (or pulled in from an external Lua library because that
does in fact work) and ready to rock, but if you just use that as a post-processor, all your
surrounding HTML breaks. Whatever shall you do? Subtemplates to the rescue!
With subtemplates, you can run a *region* of your template file through a separate post-processor,
or even get the resulting, templated string value returned to your outer template's Lua code.
Here's an example:
```html
<% oc %> -- my_page.html.cgt
-- This is a comment in Confgen, by the way. You can probably guess how it works. <% cc %>
<html>
<head>
...
</head>
<body>
<% oc %> tmpl:pushSubtmpl(function(tmpl) tmpl:setPostProcessor(opt.myMarkdownRenderer) <% cc %>
# Welcome to my awesome website!
May I introduce you to *Confgen*, this absolutely amazing template engine I learnt about recently!
Also, two times two is <% oe %> 2 * 2 <% ce %>.
<% oc %> end) <% cc %>
</body>
</html>
```
"Alright, now he's gone completely nuts" I hear you say. First, you are probably completely
corrent, and second, if you don't understand this on a syntactical level, please re-read the
previous section (repeat this procedure until you have internalized how expression blocks and code
blocks work :P).
A subtemplate is nothing but another instance of a template (`tmpl`) that is passed to the function
we wrote here. This changes all the generated statements inside our function to refer to that new,
inner template in place of the outer one. This inner template is then fully evaluated by Confgen,
and its output is pushed to the outer template's output.
> **Note**: If you want the result of the inner template returned as a string rather than have it
> pushed to the outer template, use `tmpl:subtmpl` instead of `tmpl:pushSubtmpl`.
## Library Templates
What if you wanted to share a snippet between templates, and didn't want to write it all in Lua?
Well, Confgen's got you covered, as always! The `opt` (or `cg.opt` table as it's called in the
Confgenfile) is always writable. You just shouldn't modify it in regular template file, as the order
they're evaluated in is unspecified. However, you can use Confgen's powerful API to evaluate a
template during the Confgenfile evaluation phase. Let's adjust our Confgenfile accordingly:
```lua
-- confgen.lua
cg.addPath(".config")
-- Evaluate the template `lib.cgt` and return it's output as a string,
-- which we don't care about here.
cg.doTemplateFile("lib.cgt")
cg.opt = {
font = "MyFavoriteFont",
font_size = "11",
}
```
Unlike the `cg.add*` family of functions, the `cg.doTemplate*` family immediately evaluates
templates rather than adding them to the file list. They also return the resulting code, which is
also handy. Let's get to the interesting file, though.
```
<% oc %> -- lib.cgt <% cc %>
<% oc %> opt.greet = function(tmpl, name) <% cc %>
Hello <% oe %> name <% ce %>!
<% oc %> end <% cc %>
```
Note the `tmpl` argument this function takes. This works similarly to subtemplates, except that we
now append to another file's (the caller's) template rather than a subtemplate.
We could now use this in another template:
```
<% oc %> -- message.txt.cgt <% cc %>
<% oc %> opt.greet(tmpl, "my Friend") <% cc %>
```
> **Note**: We use a code block here rather than an expression block, because the `greet` function
> pushes to our template, not returning any value.
Alright, I think that's probably enough insanity for one article, but there are more to come!
This also isn't all that Confgen is packing, far from it. There are many API functions (such as
`onDone` callbacks, file iterators and more), a whole assortment of handy CLI flags, and most
importantly the previously hinted at **ConfgenFS** that didn't fit in here. Thank you for reading,
and feel free to reach out if you have any questions, remarks or *concerns*.
## Found a bug?
Sorry about that! Confgen is still a little rough around the edges sometimes. Please report it on the
[issue tracker](https://git.mzte.de/LordMZTE/confgen/issues).

View file

@ -51,3 +51,15 @@ code,
color: #a6adc8;
font-style: italic;
}
blockquote {
margin: 0;
padding-left: 12px;
border-left: 8px solid;
border-left-color: #b4befe;
background-color: #b4befe30;
}
blockquote p {
padding: 4px;
}