about summary refs log tree commit diff
path: root/users/sterni/nix/html
diff options
context:
space:
mode:
authorsterni <sternenseemann@systemli.org>2021-08-25T00·45+0200
committersterni <sternenseemann@systemli.org>2021-08-26T15·34+0000
commit9ed439bfbddee7915c4011f8a6ba7562b3375ac8 (patch)
tree6343c44bb40676d684aceec57bab7b26388f7914 /users/sterni/nix/html
parent17d78867bbef8a3df1271137a2db18b3584cdc39 (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/nix/html')
-rw-r--r--users/sterni/nix/html/README.md148
-rw-r--r--users/sterni/nix/html/default.nix119
-rw-r--r--users/sterni/nix/html/tests/default.nix84
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 =&gt; 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>"
+     => "&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;
+}
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 "<> && \" \'"}" = "&lt;&gt; &amp;&amp; &quot; &#039;"
+
+  # slow as hell unfortunately
+  html5validator "$exampleDocumentPath"
+
+  mv "$exampleDocumentPath" "$out"
+
+  set +x
+''