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.
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="" & < >">'';
};
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>& < ></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