├── .gitignore
├── LICENSE.md
├── README.md
├── build.sh
├── escape.nix
├── flake.lock
└── flake.nix
/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 |
3 | !.gitignore
4 | !flake.lock
5 |
6 | !*.md
7 | !*.nix
8 | !*.sh
9 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | Copyright (c) 2024-present RGBCube
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HTMNIX
2 |
3 | Write composeable HTML with Nix!
4 |
5 | Here is an example snippet:
6 |
7 | ```nix
8 |
9 |
10 | "Hello, Internet!"<.title>
11 | <.head>
12 |
13 | "Yep, this is 100% Nix!"<.p>
14 |
15 | {src="/foo.png"; alt="Attributes also work!";}
16 | <.body>
17 | <.html>
18 | ```
19 |
20 | [Here is an example site created using HTMNIX and the `build.sh` in
21 | the current directory that you are supposed to vendor](https://github.com/RGBCube/NixSite)
22 |
23 | You might be wondering, _How?_
24 |
25 | If you are, go read my [blog post](https://rgbcu.be/blog/htmnix)!
26 |
27 | But before that you may want to try it for yourself!
28 | You can! Just enter the REPL by running this command:
29 |
30 | ```sh
31 | nix repl github:RGBCube/HTMNIX
32 | ```
33 |
34 | ## Provided Functions
35 |
36 | These are the functions and variables provided by HTMNIX
37 | which will be available in every HTMNIX file and the HTMNIX REPL
38 | (You can enter it by running `nix repl github:RGBCube/HTMNIX`!).
39 |
40 | - `lib`: Just the nixpkgs lib. Pretty useful to have.
41 |
42 | - `raw`: Used for a string to be included in the HTML without escaping.
43 | Just pass the string into this and it will not be escaped.
44 |
45 | - `call`: Calls another HTMNIX file and brings this list of provided
46 | variables into its scope before evaulation. Basically the same as `import`
47 | if you disregard the bringing-into-scope it does.
48 |
49 | - `DOCTYPE`: Equivalent to a `` tag, this exists because you can't
50 | express it in Nix syntax and have to resort to calling `__findFile` with the
51 | string you want instead.
52 |
53 | - `__findFile`: Where the magic happens. This overrides the default `__findFile`
54 | allowing for us to return magic functor sets for `` expressions.
55 | The `` expression however is propagated into the builtin one so it does
56 | not interfere with your workflow.
57 |
58 | ## More Examples
59 |
60 | > All of the examples here can be rendered with the following
61 | > command (assuming `html.nix` has the example content):
62 | >
63 | > ```sh
64 | > TARGET_FILE=$(realpath html.nix) nix eval github:RGBCube/HTMNIX#result --raw --impure
65 | > ```
66 |
67 | > Also keep in mind that everything is passed as an argument to the
68 | > first HTML tag's functor. So you will need to surrond some things with
69 | > parens for it to evaulate properly.
70 | >
71 | > Some notable things that require parens include:
72 | > - Function calls.
73 | > - `let in`'s.
74 | > - Expressions that have spaces in them.
75 |
76 | > Adding onto the previous note, if you want to see how your
77 | > HTMNIX file is **actually** evaluated, you can run the following command:
78 | >
79 | > ```sh
80 | > nix-instantiate --parse html.nix
81 | > ```
82 | >
83 | > This will insert a ton of parens everywhere, thus un-ambiguating the parsing.
84 | > Here is an example:
85 | >
86 | > ```nix
87 | > nix-instantiate --parse - << :end
88 | >
89 | > - "That's all!"<.li>
90 | > <.ul>
91 | > :end
92 | > ```
93 | >
94 | > This produces the following output:
95 | >
96 | > ```nix
97 | > (__findFile __nixPath "ul" (__findFile __nixPath "li") "That's all!" (__findFile __nixPath ".li") (__findFile __nixPath ".ul"))
98 | > ```
99 |
100 | Create a directory listing:
101 |
102 | ```nix
103 |
104 | (lib.mapAttrsToList
105 | (name: type: - "${name} (${type})"<.li>)
106 | (builtins.readDir ./.))
107 | <.ul>
108 | ```
109 |
110 | List metadata about a derivation:
111 |
112 | ```nix
113 | let
114 | pkg = (import {}).youtube-dl;
115 | in
116 |
117 |
{class="package";}
118 |
"Name: ${pkg.pname}"<.p>
119 |
120 | "See metadata"<.summary>
121 |
122 | - "Full name: ${pkg.name}"
123 |
- "Version: ${pkg.version}"
124 |
- (let
125 | license = if lib.isList pkg.meta.license then
126 | lib.elemAt pkg.meta.license 0
127 | else
128 | pkg.meta.license;
129 | in "License: ${license.fullName}")
130 | <.ul>
131 | <.details>
132 | <.div>
133 | ```
134 |
135 | Insert a raw unescaped string into your HTML:
136 |
137 | ```nix
138 |
139 | "Look ma! So unsafe!"<.title>
140 | <.head>
141 | (raw ''
142 |
143 | '')<.body>
144 | ```
145 |
146 | Call another Nix file as a HTMNIX file, with all the magic:
147 |
148 | ```nix
149 | # -- inside comment.nix --
150 | { name, comment }:
151 |
152 |
{class="review";}
153 |
154 | {src="/assets/${lib.replaceStrings [ " " ] [ "-" ] name}-headshot.webp";}
155 | name<.h2>
156 | <.figcaption>
157 |
158 |
comment<.p>
159 | <.div>
160 |
161 | # -- inside html.nix --
162 | let
163 | comments = [
164 | { name = "John Doe"; comment = "Very nice service, reversed my hair loss!"; }
165 | { name = "Joe"; comment = "Didn't work for me. 0/10."; }
166 | { name = "Skid"; comment = ""; } # Does not work as all strings are escaped by default.
167 | ];
168 | in
169 |
170 |
171 | (map
172 | (comment: - (call ./comment.nix comment)<.li>)
173 | comments)
174 | <.ul>
175 | ```
176 |
177 | ## License
178 |
179 | ```
180 | MIT License
181 |
182 | Copyright (c) 2024-present RGBCube
183 |
184 | Permission is hereby granted, free of charge, to any person obtaining a copy
185 | of this software and associated documentation files (the "Software"), to deal
186 | in the Software without restriction, including without limitation the rights
187 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
188 | copies of the Software, and to permit persons to whom the Software is
189 | furnished to do so, subject to the following conditions:
190 |
191 | The above copyright notice and this permission notice shall be included in all
192 | copies or substantial portions of the Software.
193 |
194 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
195 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
196 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
197 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
198 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
199 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
200 | SOFTWARE.
201 | ```
202 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e # Fail fast.
4 |
5 | rm -rf _site
6 |
7 | # Creates all dirs needed.
8 | for dir in $(find site -type d); do
9 | mkdir -p "_$dir"
10 | done
11 |
12 | for file in $(find site -type f); do
13 | if [[ ! "${file##*/}" =~ ^_ ]]; then
14 | if [[ "$file" =~ .nix$ ]]; then
15 | echo "Processing file $file to _${file%.nix}.html..."
16 | TARGET_FILE=$(realpath "$file") nix eval "${HTMNIX_REF:-github:RGBCube/HTMNIX}#result" --impure --raw > "_${file%.nix}.html"
17 | else
18 | echo "Copying file $file to _$file..."
19 | cp "$file" "_$file"
20 | fi
21 | fi
22 | done
23 |
--------------------------------------------------------------------------------
/escape.nix:
--------------------------------------------------------------------------------
1 | # (Mostly) Taken from https://github.com/nrabulinski/cursed-nix. Huge thanks!
2 |
3 | lib: let
4 | startMarker = "__START__";
5 | endMarker = "__END__";
6 |
7 | dropFirst = lib.drop 1;
8 | dropLast = list: lib.sublist 0 (lib.length list - 1) list;
9 |
10 | yeet = string: builtins.unsafeDiscardStringContext (builtins.unsafeDiscardOutputDependency string);
11 |
12 | getContent = drvPath: let
13 | drv = lib.readFile drvPath;
14 | isValid = builtins.match ".*${lib.escapeRegex startMarker}.*${lib.escapeRegex endMarker}.*" drv != null;
15 | content = lib.pipe drv [
16 | (lib.splitString startMarker)
17 | dropFirst
18 | (lib.concatStringsSep startMarker)
19 | (lib.splitString endMarker)
20 | dropLast
21 | (lib.concatStringsSep endMarker)
22 | ];
23 | in if isValid then content else toString (import drvPath);
24 |
25 | escape = string: let
26 | ctx = builtins.getContext string;
27 | ctxNames = lib.attrNames ctx;
28 |
29 | recurseSubDrv = restNames: strings: let
30 | head = lib.head restNames;
31 | headDrv = import head;
32 | last = (lib.length restNames) == 1;
33 | headContent = getContent head;
34 | in map (string: let
35 | m = lib.splitString (yeet headDrv.outPath) string;
36 | __m = if last then map lib.strings.escapeXML else recurseSubDrv (lib.tail restNames);
37 | out = __m m;
38 | in lib.concatStringsSep headContent out) strings;
39 |
40 | final = recurseSubDrv ctxNames [ string ];
41 | final' = assert (lib.length final == 1); lib.head final;
42 | in if builtins.hasContext string then
43 | lib.replaceStrings [ ''\"'' ''\n'' ] [ ''"'' "\n" ] (yeet final')
44 | else
45 | lib.strings.escapeXML string;
46 |
47 | raw = string: toString (derivation {
48 | name = "_";
49 | system = "_";
50 | builder = "_";
51 | rawContent = "${startMarker}${yeet string}${endMarker}";
52 | });
53 | in {
54 | inherit escape raw;
55 | }
56 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "nixpkgslib": {
4 | "locked": {
5 | "lastModified": 1708821942,
6 | "narHash": "sha256-jd+E1SD59qty65pwqad2mftzkT6vW5nNFWVuvayh4Zw=",
7 | "owner": "nix-community",
8 | "repo": "nixpkgs.lib",
9 | "rev": "479831ed8b3c9c7b80533999f880c7d0bf6a491b",
10 | "type": "github"
11 | },
12 | "original": {
13 | "owner": "nix-community",
14 | "repo": "nixpkgs.lib",
15 | "type": "github"
16 | }
17 | },
18 | "root": {
19 | "inputs": {
20 | "nixpkgslib": "nixpkgslib"
21 | }
22 | }
23 | },
24 | "root": "root",
25 | "version": 7
26 | }
27 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "Write composeable HTML with Nix!";
3 |
4 | inputs.nixpkgslib.url = "github:nix-community/nixpkgs.lib";
5 |
6 | outputs = { self, nixpkgslib }: let
7 | inherit (nixpkgslib) lib;
8 |
9 | first = n: lib.substring 0 n;
10 | dropFirst = n: string: lib.substring n (lib.stringLength string - n) string;
11 |
12 | last = n: string: lib.substring (lib.stringLength string - n) n string;
13 | dropLast = n: string: lib.substring 0 (lib.stringLength string - n) string;
14 |
15 | escapix = import ./escape.nix lib;
16 | inherit (escapix) escape;
17 |
18 | attrsetToHtmlAttrs = attrs:
19 | lib.concatStringsSep " "
20 | (lib.mapAttrsToList (k: v: ''${k}="${escape (toString v)}"'') attrs);
21 |
22 | dottedNameToTag = name:
23 | if first 1 name == "."
24 | then "${dropFirst 1 name}>"
25 |
26 | else if last 1 name == "."
27 | then "<${dropLast 1 name}/>"
28 |
29 | else "<${name}>";
30 |
31 | # When doing , these won't return HTML tags.
32 | propagatedFindFiles = [ "nixpkgs" ];
33 | in {
34 | inherit (escapix) raw;
35 | inherit lib;
36 |
37 | call = builtins.scopedImport self;
38 |
39 | DOCTYPE = self.__findFile __nixPath "!DOCTYPE html";
40 |
41 | result = self.call /${builtins.getEnv "TARGET_FILE"};
42 |
43 | __findFile = nixPath: name: if builtins.elem name propagatedFindFiles then __findFile nixPath name else {
44 | outPath = dottedNameToTag name;
45 |
46 | __functor = this: next:
47 | # Is a list. Consume each item. Treat it as if it was passed in one by one.
48 | if lib.isList next
49 | then lib.foldl' (this: this) (this (lib.head next)) (lib.tail next)
50 |
51 | # We are passed in a functor/function that doesn't have an outPath meaning
52 | # it is not a HTML tag. This means the user forgot to call it.
53 | else if lib.isFunction next && !(next ? outPath)
54 | then throw ''
55 | You probably didn't mean to pass a function into the tag,
56 | and forgot some parenthesis to actually call the function.
57 |
58 | This is a common mistake, which usually looks like this:
59 |
60 |
raw "Foo Bar Baz"<.p>
61 |
62 | You probably meant to write something like this:
63 |
64 |
(raw "Foo Bar Baz")<.p>
65 | ''
66 |
67 | # Not an attrset, list or a function.
68 | # Just add it onto the HTML after stringifying it.
69 | else if !lib.isAttrs next
70 | then this // {
71 | outPath = (toString this) + escape (toString next);
72 | }
73 |
74 | # An attrset. But not a tag. This means it must be HTML attributes.
75 | # We need to insert it right before the '>' or '/>' at the end of our string
76 | # and error if it doesn't end with a tag.
77 | #
78 | # Due to how it is implemented, passing multiple attrsets to a single
79 | # tag to combine them works. Here is an example:
80 | #
81 | # {bar="baz";}{fizz="fuzz";}
82 | #
83 | # This will output the following HTML:
84 | #
85 | #
86 | else if lib.isAttrs next && !(next ? outPath)
87 | then let
88 | lastElementIsTag = last 1 (toString this) == ">";
89 | lastElementIsSelfClosing = last 2 (toString this) == "/>";
90 | in this // {
91 | outPath = let
92 | attrs = attrsetToHtmlAttrs next;
93 | in if !lastElementIsTag then
94 | throw "Attributes must come right after a tag: '${if attrs != "" then attrs else ""}'"
95 | else
96 | (dropLast (if lastElementIsSelfClosing then 2 else 1) (toString this))
97 | + (if attrs != "" then " " else "") # Keep it pretty.
98 | + attrs
99 | + (if lastElementIsSelfClosing then "/>" else ">");
100 | }
101 |
102 | # The next element is a tag with the `outPath` attribute which means it's a
103 | # start, closing or self closing tag. Just append it onto our string.
104 | else this // {
105 | outPath = "${this}${next}";
106 | };
107 | };
108 | };
109 | }
110 |
--------------------------------------------------------------------------------