You've been hand-writing HTML string literals in Nix-lang—admit it.

It's tedious. It's unsafe. Stop it.

we are Nixers, we can make things. here is a Nix library for making websites.

Banner

arg-validation.nix:

{ htnl, ... }:
let
  h = htnl.polymorphic.element;
in
{
  children = {
    testWrongType = {
      expr = h "p" [ 0 ];
      expectedError = {
        type = "ThrownError";
        msg = "invalid child";
      };
    };
  };
}

asset.txt:

some content

attributes.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  h = htnl.polymorphic.element;
in
{
  testEscaping = {
    expr = h "br" { class = ''" & < >''; } |> serialize;
    expected = ''<br class="&quot; &amp; &lt; &gt;">'';
  };
  invalid = {
    testNonExistent = {
      expr = h "div" { "no-such-attr" = "nope"; } [ ];
      expectedError = {
        type = "ThrownError";
        msg = "attribute no-such-attr not allowed on tag div";
      };
    };
    testNotAllowed = {
      expr = h "p" { href = "https://fulltimenix.com"; } [ ];
      expectedError = {
        type = "ThrownError";
        msg = "attribute href not allowed on tag p";
      };
    };
  };

  testMultiple = {
    expr =
      h "img" {
        src = "photo.jpg";
        alt = "Photograph";
      }
      |> serialize;
    expected = ''<img alt="Photograph" src="photo.jpg">'';
  };

  boolean = {
    testNonTrue = {
      expr = h "br" { inert = false; };
      expectedError = {
        type = "ThrownError";
        msg = "non-true value for boolean attribute `inert` of tag `br`";
      };
    };
    test = {
      expr = h "hr" { autofocus = true; } |> serialize;
      expected = "<hr autofocus>";
    };
  };
}

base-path.nix:

{ htnl, lib, ... }:
let
  inherit (htnl) process raw;
  h = htnl.polymorphic.element;
in
{
  testBasePath = {
    expr =
      h "div" [
        (h "a" { href = ./asset.txt; } "Download asset")
        (raw { file = ./asset.txt; } (assets: ''<a href="${assets.file}">Download asset</a>''))
      ]
      |> process { basePath = "/non-default/"; }
      |> lib.getAttr "html";
    expected =
      [
        "<div>"
        ''<a href="/non-default${./asset.txt}">Download asset</a>''
        ''<a href="/non-default${./asset.txt}">Download asset</a>''
        "</div>"
      ]
      |> lib.concatStrings;
  };
}

collect-assets.nix:

{ htnl, lib, ... }:
let
  inherit (htnl) process raw;
  h = htnl.polymorphic.element;
in
{
  test = {
    expr =
      [
        (h "a" { href = ./asset.txt; } "Download")
        (raw { file = ./asset.txt; } (assets: ''<a href="${assets.file}"''))
      ]
      |> process
      |> lib.getAttr "assets"
      |> lib.attrNames; # the values are the assets themselves

    expected = [
      (builtins.unsafeDiscardStringContext ./asset.txt)
    ];
  };
}

context-forbidden.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize raw;
  h = htnl.polymorphic.element;
in
{
  testInText = {
    expr = "${./context-forbidden.nix}" |> serialize;
    expectedError = {
      type = "ThrownError";
      msg = "non-asset context detected";
    };
  };
  testInElement = {
    expr = h "p" "${./context-forbidden.nix}" |> serialize;
    expectedError = {
      type = "ThrownError";
      msg = "non-asset context detected";
    };
  };
  testInRaw = {
    expr = raw "${./context-forbidden.nix}" |> serialize;
    expectedError = {
      type = "ThrownError";
      msg = "non-asset context detected";
    };
  };
}

document.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize document;
  h = htnl.polymorphic.element;
in
{
  testToDocument = {
    expr = h "p" "I am in a document" |> document |> serialize;
    expected = "<!DOCTYPE html><p>I am in a document</p>";
  };
}

fragments.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  h = htnl.polymorphic.element;
in
{
  testFragments = {
    expr =
      [
        "Check out "
        (h "a" { href = "https://molybdenum.software"; } "The Molybdenum Software Show")
      ]
      |> serialize;
    expected = ''Check out <a href="https://molybdenum.software">The Molybdenum Software Show</a>'';
  };
}

headings.nix:

{ htnl, lib, ... }:
let
  inherit (htnl) process;
  h = htnl.polymorphic.element;
in
{
  testFlat = {
    expr =
      [
        (h "h1" "Topic")
        (h "h2" { id = "intro"; } "Introduction")
        (h "h3" { id = "background"; } "Background")
        (h "h2" [
          (h "em" "Missing")
          " id"
        ])
      ]
      |> process
      |> lib.getAttrFromPath [
        "headings"
        "flat"
      ];

    expected = [
      {
        level = 1;
        content = "Topic";
      }
      {
        level = 2;
        id = "intro";
        content = "Introduction";
      }
      {
        level = 3;
        id = "background";
        content = "Background";
      }
      {
        level = 2;
        content = "<em>Missing</em> id";
      }
    ];
  };
  testTree = {
    expr =
      [
        (h "h2" "non-h1 root")
        (h "h1" "a")
        (h "h2" { id = "heading-bee"; } "b")
        (h "h3" "c")
        (h "h4" "d")
        (h "h2" "e")
        (h "h3" "f")
        (h "h1" "g")
        (h "h3" "h")
      ]
      |> process
      |> lib.getAttrFromPath [
        "headings"
        "nested"
      ];

    expected = [
      {
        level = 2;
        content = "non-h1 root";
      }
      {
        level = 1;
        content = "a";
        subHeadings = [
          {
            level = 2;
            content = "b";
            id = "heading-bee";
            subHeadings = [
              {
                level = 3;
                content = "c";
                subHeadings = [
                  {
                    level = 4;
                    content = "d";
                  }
                ];
              }
            ];
          }
          {
            level = 2;
            content = "e";
            subHeadings = [
              {
                level = 3;
                content = "f";
              }
            ];
          }
        ];
      }
      {
        level = 1;
        content = "g";
        subHeadings = [
          {
            level = 3;
            content = "h";
          }
        ];
      }
    ];
  };
}

ir.nix:

{ htnl, lib, ... }:
let
  inherit (htnl) raw document;
  h = htnl.polymorphic.element;
in
{
  typeAttribute = {
    testElement = {
      expr = h "p" "hi" |> lib.getAttr "type";
      expected = "htnl-element";
    };
    testRaw = {
      expr = raw "content" |> lib.getAttr "type";
      expected = "htnl-raw";
    };
    testDocument = {
      expr = h "html" [ ] |> document |> lib.getAttr "type";
      expected = "htnl-document";
    };
  };
}

partials.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  inherit (htnl.polymorphic.partials) p a span;
in
{
  testSingleText = {
    expr = p "foo" |> serialize;
    expected = "<p>foo</p>";
  };
  testFull = {
    expr = a { href = "/"; } [ (span "text") ] |> serialize;
    expected = ''<a href="/"><span>text</span></a>'';
  };
}

raw.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize raw;
  h = htnl.polymorphic.element;
in
{
  # raw content? Yes, but be careful, okay?
  testNonString = {
    expr = raw 0;
    expectedError = {
      type = "ThrownError";
      msg = "first argument to `raw` must be string or attrset";
    };
  };
  testSingle = {
    expr = h "div" (raw " < ") |> serialize;
    expected = "<div> < </div>";
  };
  testList = {
    expr =
      h "div" [
        (raw " < ")
        (raw " & ")
      ]
      |> serialize;
    expected = "<div> <  & </div>";
  };
  testAsset = {
    expr = raw { file = ./asset.txt; } (assets: ''<a href="${assets.file}">Download</a>'') |> serialize;
    expected = ''<a href="${./asset.txt}">Download</a>'';
  };
  testInvalidAsset = {
    expr = raw { invalid = "string"; } (assets: assets.invalid) |> serialize;
    expectedError = {
      type = "ThrownError";
      msg = "`raw` received invalid asset";
    };
  };
  testNonFunctionSecondArg = {
    expr = raw { } "";
    expectedError = {
      type = "ThrownError";
      msg = "second argument to `raw` must be a function";
    };
  };
}

tag-validation.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  h = htnl.polymorphic.element;
in
{
  invalid = {
    testEmpty = {
      expr = h "" [ ];
      expectedError.type = "AssertionError";
    };
    testNonString = {
      expr = h 100 [ ];
      expectedError.type = "AssertionError";
    };
    testNonAlphaNum = {
      expr = h "has-hyphen" [ ];
      expectedError.type = "AssertionError";
    };
    testOutOfRange = {
      expr = h "¢" [ ];
      expectedError.type = "AssertionError";
    };
  };
  valid = {
    testLowerAlpha = {
      expr = h "a" [ ] |> serialize;
      expected = "<a></a>";
    };
    testUpperAlpha = {
      expr = h "A" [ ] |> serialize;
      expected = "<A></A>";
    };
    testNum = {
      expr = h "0" [ ] |> serialize;
      expected = "<0></0>";
    };
    testCombo = {
      expr = h "aA0" [ ] |> serialize;
      expected = "<aA0></aA0>";
    };
    testComboReverse = {
      expr = h "0Aa" [ ] |> serialize;
      expected = "<0Aa></0Aa>";
    };
  };
}

text-nodes.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  h = htnl.polymorphic.element;
in
{
  testEscaping = {
    expr = h "p" "& < >" |> serialize;
    expected = "<p>&amp; &lt; &gt;</p>";
  };
}

various-signatures.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  h = htnl.polymorphic.element;
in
{
  testBlank = {
    expr = h "p" [ ] |> serialize;
    expected = "<p></p>";
  };
  testSingleAttr = {
    expr = h "a" { href = "/"; } [ ] |> serialize;
    expected = ''<a href="/"></a>'';
  };
  testAttrs = {
    expr =
      h "img" {
        src = "l.png";
        alt = "logo";
      }
      |> serialize;
    expected = ''<img alt="logo" src="l.png">'';
  };
  testWithChildElem = {
    expr = h "span" [ (h "br" { }) ] |> serialize;
    expected = "<span><br></span>";
  };
  testWithChildText = {
    expr = h "span" [ "foo" ] |> serialize;
    expected = "<span>foo</span>";
  };
  testWithSingleChildElem = {
    expr = h "span" (h "br" { }) |> serialize;
    expected = "<span><br></span>";
  };
  testWithSingleChildText = {
    expr = h "span" "hi" |> serialize;
    expected = "<span>hi</span>";
  };
  testwithMultipleTextChildren = {
    expr =
      h "span" [
        "a"
        "b"
      ]
      |> serialize;
    expected = "<span>ab</span>";
  };
  testWithMixedChildren = {
    expr =
      h "span" [
        (h "br" { })
        "text"
      ]
      |> serialize;
    expected = "<span><br>text</span>";
  };
  testWithAttrAndChildren = {
    expr = h "a" { href = "/"; } [ "foo" ] |> serialize;
    expected = ''<a href="/">foo</a>'';
  };
  testWithAttrsAndChild = {
    expr =
      h "a" {
        href = "/";
        target = "_blank";
      } [ "foo" ]
      |> serialize;
    expected = ''<a href="/" target="_blank">foo</a>'';
  };
  testWithAttrAndSingleChild = {
    expr = h "a" { href = "/"; } "foo" |> serialize;
    expected = ''<a href="/">foo</a>'';
  };
  testFlatteningOfChildren = {
    expr =
      h "div" [
        [
          [
            (h "p" "a")
          ]
        ]
        (h "p" "b")
      ]
      |> serialize;
    expected = ''<div><p>a</p><p>b</p></div>'';
  };
}

void-elements.nix:

{ htnl, ... }:
let
  inherit (htnl) serialize;
  h = htnl.polymorphic.element;
in
{
  testEmptyAttrs = {
    expr = h "br" { } |> serialize;
    expected = "<br>";
  };
  testWithAttr = {
    expr = h "br" { class = "foo"; } |> serialize;
    expected = ''<br class="foo">'';
  };
  childrenErrors = {
    testList = {
      expr = h "hr" { } [ ];
      expectedError = {
        type = "ThrownError";
        msg = "attempt to pass children to void element hr";
      };
    };
    testElement = {
      expr = h "br" { } (h "p" "foo");
      expectedError = {
        type = "ThrownError";
        msg = "attempt to pass children to void element br";
      };
    };
    testText = {
      expr = h "img" { } "text";
      expectedError = {
        type = "ThrownError";
        msg = "attempt to pass children to void element img";
      };
    };
  };

}

It even bundles for you 🫢

# Bundling tests, a flake-parts module

{
  lib,
  config,
  inputs,
  ...
}:
{
  perSystem =
    { pkgs, ... }:
    let
      inherit (config.lib) document bundle raw;
      inherit (config.utils) assertEq readFilesRecursive;
      h = config.lib.polymorphic.element;
      assetDrv = pkgs.writeText "file.txt" "some text";
      assetPath = ./asset.txt;
      assetFlakeInput = inputs.asset-for-testing;
    in
    {
      checks = {
        "tests:bundling:assets:derivation" =
          {
            htmlDocuments = {
              "index.html" =
                h "html" [
                  (h "body" [
                    (h "a" { href = assetDrv; } "Download derivation asset")
                  ])
                ]
                |> document;
            };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" =
                [
                  "<!DOCTYPE html>"
                  "<html>"
                  "<body>"
                  ''<a href="${assetDrv}">Download derivation asset</a>''
                  "</body>"
                  "</html>"
                ]
                |> lib.concatStrings;
              ${builtins.unsafeDiscardStringContext assetDrv} = "some text";
            }) (pkgs.writeText "" "")
          );

        "tests:bundling:assets:path" =
          {
            htmlDocuments = {
              "index.html" =
                h "html" [
                  (h "body" [
                    (h "a" { href = assetPath; } "Download path asset")
                  ])
                ]
                |> document;
            };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" =
                [
                  "<!DOCTYPE html>"
                  "<html>"
                  "<body>"
                  ''<a href="${assetPath}">Download path asset</a>''
                  "</body>"
                  "</html>"
                ]
                |> lib.concatStrings;
              ${builtins.unsafeDiscardStringContext assetPath} = "asset has content\n";
            }) (pkgs.writeText "" "")
          );

        "tests:bundling:assets:flake-input" =
          {
            htmlDocuments = {
              "index.html" =
                h "html" [
                  (h "body" [
                    (h "a" { href = assetFlakeInput; } "Download flake input asset")
                  ])
                ]
                |> document;
            };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" =
                [
                  "<!DOCTYPE html>"
                  "<html>"
                  "<body>"
                  ''<a href="${assetFlakeInput}">Download flake input asset</a>''
                  "</body>"
                  "</html>"
                ]
                |> lib.concatStrings;
              ${builtins.unsafeDiscardStringContext assetFlakeInput} = ''
                User-agent: *
                Disallow: /deny
              '';
            }) (pkgs.writeText "" "")
          );

        "tests:bundling:assets:in-raw" =
          {
            htmlDocuments = {
              "index.html" =
                h "html" [
                  (h "head" [
                    (h "script" (
                      raw { inherit assetDrv assetPath assetFlakeInput; } (
                        {
                          assetDrv,
                          assetPath,
                          assetFlakeInput,
                        }:
                        ''
                          console.log({
                            drv: '${assetDrv}',
                            path: '${assetPath}',
                            flakeInput: '${assetFlakeInput}',
                          })
                        ''
                      )
                    ))
                  ])
                ]
                |> document;
            };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" =
                [
                  "<!DOCTYPE html>"
                  "<html>"
                  "<head>"
                  "<script>"
                  ''
                    console.log({
                      drv: '${assetDrv}',
                      path: '${assetPath}',
                      flakeInput: '${assetFlakeInput}',
                    })
                  ''
                  "</script>"
                  "</head>"
                  "</html>"
                ]
                |> lib.concatStrings;
              ${builtins.unsafeDiscardStringContext assetDrv} = "some text";
              ${builtins.unsafeDiscardStringContext assetPath} = "asset has content\n";
              ${builtins.unsafeDiscardStringContext assetFlakeInput} = ''
                User-agent: *
                Disallow: /deny
              '';
            }) (pkgs.writeText "" "")
          );

        "tests:bundling:without-assets" =
          {
            htmlDocuments."index.html" = h "html" [ ] |> document;
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" = "<!DOCTYPE html><html></html>";
            }) (pkgs.writeText "" "")
          );

        "tests:bundling:assets:same-drv-multiple-positions" =
          {
            htmlDocuments = {
              "index.html" =
                h "html" [
                  (h "body" [
                    (h "a" { href = assetDrv; } "Download derivation asset")
                    (h "a" { href = assetDrv; } "Download same derivation asset")
                  ])
                ]
                |> document;
            };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" =
                [
                  "<!DOCTYPE html>"
                  "<html>"
                  "<body>"
                  ''<a href="${assetDrv}">Download derivation asset</a>''
                  ''<a href="${assetDrv}">Download same derivation asset</a>''
                  "</body>"
                  "</html>"
                ]
                |> lib.concatStrings;
              ${builtins.unsafeDiscardStringContext assetDrv} = "some text";
            }) (pkgs.writeText "" "")
          );

        "tests:bundling:empty" =
          {
            htmlDocuments = { };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (actual: lib.seq (assertEq actual { }) (pkgs.writeText "" ""));

        "tests:bundling:name:default" =
          {
            htmlDocuments = { };
          }
          |> bundle pkgs
          |> (actual: lib.seq (assertEq actual.name "htnl-bundle") (pkgs.writeText "" ""));

        "tests:bundling:name:provided" =
          {
            htmlDocuments = { };
            name = "some-name";
          }
          |> bundle pkgs
          |> (actual: lib.seq (assertEq actual.name "some-name") (pkgs.writeText "" ""));

        "tests:bundling:base-path" =
          {
            processing.basePath = "/website/";
            htmlDocuments = {
              "index.html" =
                h "html" [
                  (h "body" [
                    (h "a" { href = assetPath; } "Download path asset")
                  ])
                ]
                |> document;
            };
          }
          |> bundle pkgs
          |> readFilesRecursive
          |> (
            actual:
            lib.seq (assertEq actual {
              "/index.html" =
                [
                  "<!DOCTYPE html>"
                  "<html>"
                  "<body>"
                  ''<a href="/website${assetPath}">Download path asset</a>''
                  "</body>"
                  "</html>"
                ]
                |> lib.concatStrings;
              ${builtins.unsafeDiscardStringContext assetPath} = "asset has content\n";
            }) (pkgs.writeText "" "")
          );
      };

      treefmt.settings.global.excludes = [ "dev/modules/tests/bundling/asset.txt" ];
    };
}

This library produces HTML intermediate representation values (hereinafter IR). Attributes of IR that are documented are, of course, public. Undocumented ones are private and considered unstable.

Enforces correct tag hierarchy? No

CSS specific features? No

SVG support? No

Requires IFD? No, this is pure lib

Available as both flake and bare Nix

Go and rebuild the web in Nix!

Requires experimental feature pipe-operators

Sorry

(not sorry)

https://github.com/molybdenumsoftware/htnl