feat(users/sterni/nix): cursed nix html DSL
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>
This commit is contained in:
parent
17d78867bb
commit
9ed439bfbd
3 changed files with 351 additions and 0 deletions
148
users/sterni/nix/html/README.md
Normal file
148
users/sterni/nix/html/README.md
Normal file
|
@ -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
|
119
users/sterni/nix/html/default.nix
Normal file
119
users/sterni/nix/html/default.nix
Normal file
|
@ -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;
|
||||
}
|
84
users/sterni/nix/html/tests/default.nix
Normal file
84
users/sterni/nix/html/tests/default.nix
Normal file
|
@ -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
|
||||
''
|
Loading…
Reference in a new issue