diff --git a/v2/content/posts/2024/json-feed-zola.md b/v2/content/posts/2024/json-feed-zola.md new file mode 100644 index 0000000..43ed4d5 --- /dev/null +++ b/v2/content/posts/2024/json-feed-zola.md @@ -0,0 +1,104 @@ ++++ +title = "Generate a JSON Feed for a Zola Website" +date = 2024-09-28T20:50:30+10:00 + +#[extra] +#updated = 2024-06-06T08:24:45+10:00 ++++ + +[JSON Feed] is a specification for representing an RSS-style feed in JSON. I +wanted to add one as an alternative alongside the Atom feed on a new website +I'm building. The website is built with [Zola], which unfortunately [doesn't +support the format][zola-issue], so this is how I went about adding one. + + + +My first idea was to add a Tera template `json.feed` and try to generate JSON +in the template. This was foiled on multiple fronts: + +1. Zola only loads files matching `*.{*ml,md}` and `robots.txt` as templates, + so it didn't render my `json.feed` template. +2. Tera has a `json_encode` filter but [does not support object literals][tera-issue], + which makes building well formed JSON tricky. + +My next thought was to write a tool to convert the Atom feed to JSON. Rust is +my scripting language of choice these days (only a tiny bit joking) but I +didn't feel like adding a Rust project and build step for this. I then wondered +if it could be done with [jq], or in my case [jaq]—a `jq` implementation in +Rust. Short answer it can. Here's what I came up with: + +I created `templates/dump.xml` with these contents: + +```tera +{{ __tera_context }} +``` + +This dumps the whole Tera context as JSON when rendered into `public/dump.xml`. +It's `.xml` so that Zola will render it as a template. I added it to +`feed_filenames` in the Zola `config.toml`: + +```toml +feed_filenames = [ + "atom.xml", + "dump.xml", # HACK: This just dumps the Tera context as JSON +] +``` + +Then I wrote this `jaq` filter (`json-feed.jaq`) to generate a JSON Feed +`feed.json` from the rendered version of the template. + +```jq +# vim: ft=jq +# Generate a JSON feed from context of a Zola feed template +{ + "version": "https://jsonfeed.org/version/1.1", + "title": .config.title, + "home_page_url": .config.base_url, + "feed_url": .feed_url | sub("dump\\.xml$"; "feed.json"), + "authors": [ { "name": .config.author } ], + "language": "en-AU", + "items": .pages | map({ + "id": .permalink, + "url": .permalink, + "title": .title, + "content_html": .content, + "date_published": .date, + "tags": .taxonomies.tags, + } + if .updated then {"date_modified": .updated} else {} end + + if .extra.link then {"external_url": .extra.link} else {} end) +} +``` + +It turned out quite nice. The main challenge with the `jaq` filter was working +out how to conditionally include a key only if the value was non-null. I ended +up with the trailing [if-then-else-end] expressions. If there's a better way +I'd be keen to hear about it. + +Finally, to coordinate all this I added a `Makefile` that deletes `dump.xml` +afterwards: + +```make +help: + @echo "Available tasks:" + @echo + @echo "- build" + @echo "- deploy" + +build: + zola build + jaq --from-file json-feed.jaq public/dump.xml > public/json.feed + rm public/dump.xml + +deploy: + @echo todo + +.PHONY: build depoly +``` + +[JSON Feed]: https://www.jsonfeed.org/ +[tera-issue]: https://github.com/Keats/tera/issues/898 +[zola-issue]: https://github.com/getzola/zola/issues/311 +[Zola]: https://www.getzola.org/ +[jq]: https://jqlang.github.io/jq/ +[jaq]: https://github.com/01mf02/jaq +[if-then-else-end]: https://jqlang.github.io/jq/manual/#if-then-else-end diff --git a/v2/sass/screen.scss b/v2/sass/screen.scss index 45b2bd9..76d8372 100644 --- a/v2/sass/screen.scss +++ b/v2/sass/screen.scss @@ -45,78 +45,16 @@ pre { color: #fcfcfc; position: relative; } -.language-asm::after, -.language-bash::after, -.language-c::after, -.language-css::after, -.language-dockerfile::after, -.language-gleam::after, -.language-ini::after, -.language-javascript::after, -.language-json::after, -.language-rust::after, -.language-sh::after, -.language-tcl::after, -.language-toml::after, -.language-vim::after, -.language-xml::after, -.language-yaml::after -{ - position: absolute; - top: 0px; - right: 0px; - color: black; - background-color: #FFFA; - padding: 0 0.5em 1px 0.5em; - border-bottom-left-radius: 8px; -} -.language-asm::after { - content: 'Assembly'; - } -.language-bash::after { - content: 'Bash'; - } -.language-c::after { - content: 'C'; - } -.language-css::after { - content: 'CSS'; - } -.language-dockerfile::after { - content: 'Dockerfile'; - } -.language-gleam::after { - content: 'Gleam'; - } -.language-ini::after { - content: 'INI'; - } -.language-javascript::after { - content: 'JavaScript'; - } -.language-json::after { - content: 'JSON'; - } -.language-rust::after { - content: 'Rust'; - } -.language-sh::after { - content: 'Shell'; - } -.language-tcl::after { - content: 'Tcl'; - } -.language-toml::after { - content: 'TOML'; - } -.language-vim::after { - content: 'Vim'; - } -.language-xml::after { - content: 'XML'; - } -.language-yaml::after { - content: 'YAML'; +pre[data-lang]:after { + position: absolute; + top: 0px; + right: 0px; + color: black; + background-color: #fffa; + padding: 0 0.5em 1px 0.5em; + border-bottom-left-radius: 8px; + content: attr(data-lang); + text-transform: capitalize; } h1,h2,h3,h4 { font-family: $heading-family;