├── .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 | <body> 13 | <p>"Yep, this is 100% Nix!"<.p> 14 | 15 | <img.>{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 `<!DOCTYPE html>` 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 `<whatever.here>` expressions. 55 | The `<nixpkgs>` 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 | > <ul> 89 | > <li>"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 | <ul> 104 | (lib.mapAttrsToList 105 | (name: type: <li>"${name} (${type})"<.li>) 106 | (builtins.readDir ./.)) 107 | <.ul> 108 | ``` 109 | 110 | List metadata about a derivation: 111 | 112 | ```nix 113 | let 114 | pkg = (import <nixpkgs> {}).youtube-dl; 115 | in 116 | 117 | <div>{class="package";} 118 | <p>"Name: ${pkg.pname}"<.p> 119 | <details> 120 | <summary>"See metadata"<.summary> 121 | <ul> 122 | <li>"Full name: ${pkg.name}" 123 | <li>"Version: ${pkg.version}" 124 | <li>(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 | <head> 139 | <title>"Look ma! So unsafe!"<.title> 140 | <.head> 141 | <body>(raw '' 142 | <blink>Please don't do this at home...</blink> 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 | <div>{class="review";} 153 | <figcaption> 154 | <img.>{src="/assets/${lib.replaceStrings [ " " ] [ "-" ] name}-headshot.webp";} 155 | <h2>name<.h2> 156 | <.figcaption> 157 | 158 | <p>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 = "<script>alert('Got you!')</script>"; } # Does not work as all strings are escaped by default. 167 | ]; 168 | in 169 | 170 | <ul> 171 | (map 172 | (comment: <li>(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 <name>, 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 | <p>raw "Foo Bar Baz"<.p> 61 | 62 | You probably meant to write something like this: 63 | 64 | <p>(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 | # <foo>{bar="baz";}{fizz="fuzz";} 82 | # 83 | # This will output the following HTML: 84 | # 85 | # <foo bar="baz" fizz="fuzz"> 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 "<empty attrs>"}'" 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 | --------------------------------------------------------------------------------