diff options
author | sterni <sternenseemann@systemli.org> | 2021-08-25T00·45+0200 |
---|---|---|
committer | sterni <sternenseemann@systemli.org> | 2021-08-26T15·34+0000 |
commit | 9ed439bfbddee7915c4011f8a6ba7562b3375ac8 (patch) | |
tree | 6343c44bb40676d684aceec57bab7b26388f7914 /users/sterni | |
parent | 17d78867bbef8a3df1271137a2db18b3584cdc39 (diff) |
feat(users/sterni/nix): cursed nix html DSL r/2784
Couldn't sleep, so I made a surprisingly neat way to render HTML documents in Nix using our favorite feature __findFile: let inherit (depot.users.sterni.nix.html) __findFile esc; in <html> {} [ (<head> {} [ (<meta> { charset = "utf-8"; } null) (<title> {} (esc "hello")) ]) (<body> {} [ (<h1> {} (esc "hello world")) ]) ] => "<html><head><meta charset=\"utf-8\"/><title>hello</title></head><body><h1>hello world</h1></body></html>" Change-Id: Id36808a56ae3da3b5263c06f29342fc22d105c21 Reviewed-on: https://cl.tvl.fyi/c/depot/+/3410 Tested-by: BuildkiteCI Reviewed-by: tazjin <mail@tazj.in>
Diffstat (limited to 'users/sterni')
-rw-r--r-- | users/sterni/nix/html/README.md | 148 | ||||
-rw-r--r-- | users/sterni/nix/html/default.nix | 119 | ||||
-rw-r--r-- | users/sterni/nix/html/tests/default.nix | 84 |
3 files changed, 351 insertions, 0 deletions
diff --git a/users/sterni/nix/html/README.md b/users/sterni/nix/html/README.md new file mode 100644 index 000000000000..0349e466a166 --- /dev/null +++ b/users/sterni/nix/html/README.md @@ -0,0 +1,148 @@ +# html.nix — _the_ most cursed Nix HTML DSL + +A quick example to show you what it looks like: + +```nix +# Note: this example is for standalone usage out of depot +{ pkgs ? import <nixpkgs> {} }: + +let + # zero dependency, one file implementation + htmlNix = import ./path/to/html.nix { }; + + # make the magic work + inherit (htmlNix) __findFile esc withDoctype; +in + +pkgs.writeText "example.html" (withDoctype (<html> {} [ + (<head> {} [ + (<meta> { charset = "utf-8"; } null) + (<title> {} (esc "hello world")) + ]) + (<body> {} [ + (<h1> {} (esc "hello world")) + (<p> { class = "intro"; } (esc '' + welcome to the land of sillyness! + '')) + (<ul> {} [ + (<li> {} [ + (esc "check out ") + (<a> { href = "https://code.tvl.fyi"; } "depot") + ]) + (<li> {} [ + (esc "find ") + (<a> { href = "https://cl.tvl.fyi/q/hashtag:cursed"; } "cursed things") + ]) + ]) + ]) +])) +``` + +Convince yourself it works: + +```console +$ $BROWSER $(nix-build example.nix) +``` + +Alternatively, in depot: + +```console +$ $BROWSER $(nix-build -A users.sterni.nix.html.tests) +``` + +## Creating tags + +An empty tag is passed `null` as its content argument: + +```nix +<link> { + rel = "stylesheet"; + href = "/main.css"; + type = "text/css"; +} null + +# => "<link href=\"/main.css\" rel=\"stylesheet\" type=\"text/css\"/>" +``` + +Content is expected to be HTML: + +```nix +<div> { class = "foo"; } "<strong>hi</strong>" + +# => "<div class=\"foo\"><strong>hi</strong></div>" +``` + +If it's not, be sure to escape it: + +```nix +<p> {} (esc "A => B") + +# => "<p>A => B</p>" +``` + +Nesting tags works of course: + +```nix +<div> {} (<strong> {} (<em> {} "hi")) + +# => "<div><strong><em>hi</em></strong></div>" +``` + +If the content of a tag is a list, it's concatenated: + +```nix +<h1> {} [ + (esc "The ") + (<strong> {} "Nix") + (esc " ") + (<em> {} "Expression") + (esc " Language") +] + +# => "<h1>The <strong>Nix</strong> <em>Expression</em> Language</h1>" +``` + +More detailed documentation can be found in `nixdoc`-compatible +comments in the source file (`default.nix` in this directory). + +## How does this work? + +*Theoretically* expressions like `<nixpkgs>` are just ordinary paths — +their actual value is determined from `NIX_PATH`. `html.nix` works +because of how this is actually implemented: At [parse time][spath-parsing] +Nix transparently translates an expression like `<foo>` into +`__findFile __nixPath "foo"`: + +``` +nix-repl> <nixpkgs> +/nix/var/nix/profiles/per-user/root/channels/vuizvui/nixpkgs + +nix-repl> __findFile __nixPath "nixpkgs" +/nix/var/nix/profiles/per-user/root/channels/vuizvui/nixpkgs +``` + +This translation doesn't take any scoping issues into account -- +so we can just shadow `__findFile` and make it return anything, +even a function: + +``` +nix-repl> __findFile = nixPath: str: + /**/ if str == "double" then x: x * 2 + else if str == "triple" then x: x * 3 + else throw "what?" + +nix-repl> <double> 2 +4 + +nix-repl> <triple> 3 +9 + +nix-repl> <quadruple> 4 +error: what? +``` + +Exactly this is what we are doing in `html.nix`: +Using `let inherit (htmlNix) __findFile; in` we shadow the builtin `__findFile` +with a function which returns a function rendering a particular HTML tag. + +[spath-parsing]: https://github.com/NixOS/nix/blob/293220bed5a75efc963e33c183787e87e55e28d9/src/libexpr/parser.y#L410-L416 diff --git a/users/sterni/nix/html/default.nix b/users/sterni/nix/html/default.nix new file mode 100644 index 000000000000..2498d832aadf --- /dev/null +++ b/users/sterni/nix/html/default.nix @@ -0,0 +1,119 @@ +# Copyright © 2021 sterni +# SPDX-License-Identifier: MIT +# +# This file provides a cursed HTML DSL for nix which works by overloading +# the NIX_PATH lookup operation via angle bracket operations, e. g. `<nixpkgs>`. + +{ ... }: + +let + /* Escape everything we have to escape in an HTML document if either + in a normal context or an attribute string (`<>&"'`). + + A shorthand for this function called `esc` is also provided. + + Type: string -> string + + Example: + + escapeMinimal "<hello>" + => "<hello>" + */ + escapeMinimal = builtins.replaceStrings + [ "<" ">" "&" "\"" "'" ] + [ "<" ">" "&" """ "'" ]; + + /* Return a string with a correctly rendered tag of the given name, + with the given attributes which are automatically escaped. + + If the content argument is `null`, the tag will have no children nor a + closing element. If the content argument is a string it is used as the + content as is (unescaped). If the content argument is a list, its + elements are concatenated. + + `renderTag` is only an internal function which is reexposed as `__findFile` + to allow for much neater syntax than calling `renderTag` everywhere: + + ```nix + { depot, ... }: + let + inherit (depot.users.sterni.nix.html) __findFile esc; + in + + <html> {} [ + (<head> {} (<title> {} (esc "hello world"))) + (<body> {} [ + (<h1> {} (esc "hello world")) + (<p> {} (esc "foo bar")) + ]) + ] + + ``` + + As you can see, the need to call a function disappears, instead the + `NIX_PATH` lookup operation via `<foo>` is overloaded, so it becomes + `renderTag "foo"` automatically. + + Since the content argument may contain the result of other `renderTag` + calls, we can't escape it automatically. Instead this must be done manually + using `esc`. + + Type: string -> attrs<string> -> (list<string> | string | null) -> string + + Example: + + <link> { + rel = "stylesheet"; + href = "/css/main.css"; + type = "text/css"; + } null + + renderTag "link" { + rel = "stylesheet"; + href = "/css/main.css"; + type = "text/css"; + } null + + => "<link href=\"/css/main.css\" rel=\"stylesheet\" type=\"text/css\"/>" + + <p> {} [ + "foo " + (<strong> {} "bar") + ] + + renderTag "p" {} "foo <strong>bar</strong>" + => "<p>foo <strong>bar</strong></p>" + */ + renderTag = tag: attrs: content: + let + attrs' = builtins.concatStringsSep "" ( + builtins.map (n: + " ${escapeMinimal n}=\"${escapeMinimal (toString attrs.${n})}\"" + ) (builtins.attrNames attrs) + ); + content' = + if builtins.isList content + then builtins.concatStringsSep "" content + else content; + in + if content == null + then "<${tag}${attrs'}/>" + else "<${tag}${attrs'}>${content'}</${tag}>"; + + /* Prepend "<!DOCTYPE html>" to a string. + + Type: string -> string + + Example: + + withDoctype (<body> {} (esc "hello")) + => "<!DOCTYPE html><body>hello</body>" + */ + withDoctype = doc: "<!DOCTYPE html>" + doc; + +in { + inherit escapeMinimal renderTag withDoctype; + + __findFile = _: renderTag; + esc = escapeMinimal; +} diff --git a/users/sterni/nix/html/tests/default.nix b/users/sterni/nix/html/tests/default.nix new file mode 100644 index 000000000000..8688b6937130 --- /dev/null +++ b/users/sterni/nix/html/tests/default.nix @@ -0,0 +1,84 @@ +{ depot, pkgs, ... }: + +let + inherit (depot.users.sterni.nix.html) + __findFile + esc + withDoctype + ; + + exampleDocument = withDoctype (<html> { lang = "en"; } [ + (<head> {} [ + (<meta> { charset = "utf-8"; } null) + (<title> {} "html.nix example document") + (<link> { + rel = "license"; + href = "https://code.tvl.fyi/about/LICENSE"; + type = "text/html"; + } null) + (<style> {} (esc '' + hgroup h2 { + font-weight: normal; + } + + dd { + margin: 0; + } + '')) + ]) + (<body> {} [ + (<main> {} [ + (<hgroup> {} [ + (<h1> {} (esc "html.nix")) + (<h2> {} [ + (<em> {} "the") + (esc " most cursed HTML DSL ever!") + ]) + ]) + (<dl> {} [ + (<dt> {} [ + (esc "Q: Wait, it's all ") + (<a> { + href = "https://cl.tvl.fyi/q/hashtag:cursed"; + } (esc "cursed")) + (esc " nix hacks?") + ]) + (<dd> {} (esc "A: Always has been. 🔫")) + (<dt> {} (esc "Q: Why does this work?")) + (<dd> {} [ + (esc "Because nix ") + (<a> { + href = "https://github.com/NixOS/nix/blob/293220bed5a75efc963e33c183787e87e55e28d9/src/libexpr/parser.y#L410-L416"; + } (esc "translates ")) + (<a> { + href = "https://github.com/NixOS/nix/blob/293220bed5a75efc963e33c183787e87e55e28d9/src/libexpr/lexer.l#L100"; + } (esc "SPATH tokens")) + (esc " like ") + (<code> {} (esc "<nixpkgs>")) + (esc " into calls to ") + (<code> {} (esc "__findFile")) + (esc " in the ") + (<em> {} (esc "current")) + (esc " scope.") + ]) + ]) + ]) + ]) + ]); +in + +pkgs.runCommandNoCC "html.nix.html" { + passAsFile = [ "exampleDocument" ]; + inherit exampleDocument; + nativeBuildInputs = [ pkgs.html5validator ]; +} '' + set -x + test "${esc "<> && \" \'"}" = "<> && " '" + + # slow as hell unfortunately + html5validator "$exampleDocumentPath" + + mv "$exampleDocumentPath" "$out" + + set +x +'' |