tvl-depot/users/sterni/nix/html/default.nix
sterni 9ed439bfbd 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>
2021-08-26 15:34:58 +00:00

119 lines
3.2 KiB
Nix

# 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>"
=> "&lt;hello&gt;"
*/
escapeMinimal = builtins.replaceStrings
[ "<" ">" "&" "\"" "'" ]
[ "&lt;" "&gt;" "&amp;" "&quot;" "&#039;" ];
/* 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;
}