├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── demo.yml ├── LICENSE ├── README.md ├── demo ├── assets │ └── style.css └── site.nix ├── examples ├── assets │ └── styles.css ├── multi-page.nix └── single-page.nix ├── flake.nix └── lib └── default.nix /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | tab_width = 2 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Always use LF line endings so that if a repo is accessed 2 | # in Unix via a file share from Windows, the scripts will 3 | # work as expected. 4 | *.sh text eol=lf 5 | 6 | # Hide CSS from the language overview. The implementation *is* pure Nix 7 | # The CSS comes from the example found in the examples. 8 | *.css linguist-detectable=false 9 | *.css linguist-documentation=false 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: Deploy GitHub Pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ["main"] 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | build-and-deploy: 19 | runs-on: ubuntu-latest 20 | 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Install Nix 30 | uses: cachix/install-nix-action@v25 31 | with: 32 | extra_nix_config: | 33 | experimental-features = nix-command flakes 34 | 35 | - name: Evaluate HTML path 36 | id: evaluate 37 | run: | 38 | html_path=$(nix eval .#demo --raw) 39 | echo "html_path=$html_path" >> $GITHUB_OUTPUT 40 | 41 | - name: Prepare Serve Directory 42 | run: | 43 | mkdir -p page 44 | cp ${{ steps.evaluate.outputs.html_path }} page/index.html 45 | 46 | # Copy assets 47 | cp demo/assets/style.css page/style.css 48 | 49 | - name: Setup Pages 50 | uses: actions/configure-pages@v4 51 | 52 | - name: Upload artifact 53 | uses: actions/upload-pages-artifact@v3 54 | with: 55 | path: "./page" 56 | 57 | - name: Deploy to GitHub Pages 58 | id: deployment 59 | uses: actions/deploy-pages@v4 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. “Contributor” 6 | means each individual or legal entity that creates, contributes to the 7 | creation of, or owns Covered Software. 8 | 9 | 1.2. “Contributor Version” 10 | means the combination of the Contributions of others (if any) used by a 11 | Contributor and that particular Contributor’s Contribution. 12 | 13 | 1.3. “Contribution” 14 | means Covered Software of a particular Contributor. 15 | 16 | 1.4. “Covered Software” 17 | means Source Code Form to which the initial Contributor has attached the 18 | notice in Exhibit A, the Executable Form of such Source Code Form, 19 | and Modifications of such Source Code Form, in each case 20 | including portions thereof. 21 | 22 | 1.5. “Incompatible With Secondary Licenses” 23 | means 24 | 25 | a. that the initial Contributor has attached the notice described 26 | in Exhibit B to the Covered Software; or 27 | 28 | b. that the Covered Software was made available under the terms of 29 | version 1.1 or earlier of the License, but not also under the terms 30 | of a Secondary License. 31 | 32 | 1.6. “Executable Form” 33 | means any form of the work other than Source Code Form. 34 | 35 | 1.7. “Larger Work” 36 | means a work that combines Covered Software with other material, 37 | in a separate file or files, that is not Covered Software. 38 | 39 | 1.8. “License” 40 | means this document. 41 | 42 | 1.9. “Licensable” 43 | means having the right to grant, to the maximum extent possible, 44 | whether at the time of the initial grant or subsequently, 45 | any and all of the rights conveyed by this License. 46 | 47 | 1.10. “Modifications” 48 | means any of the following: 49 | 50 | a. any file in Source Code Form that results from an addition to, 51 | deletion from, or modification of the contents of Covered Software; or 52 | 53 | b. any new file in Source Code Form that contains any Covered Software. 54 | 55 | 1.11. “Patent Claims” of a Contributor 56 | means any patent claim(s), including without limitation, method, process, 57 | and apparatus claims, in any patent Licensable by such Contributor that 58 | would be infringed, but for the grant of the License, by the making, 59 | using, selling, offering for sale, having made, import, or transfer of 60 | either its Contributions or its Contributor Version. 61 | 62 | 1.12. “Secondary License” 63 | means either the GNU General Public License, Version 2.0, the 64 | GNU Lesser General Public License, Version 2.1, the GNU Affero General 65 | Public License, Version 3.0, or any later versions of those licenses. 66 | 67 | 1.13. “Source Code Form” 68 | means the form of the work preferred for making modifications. 69 | 70 | 1.14. “You” (or “Your”) 71 | means an individual or a legal entity exercising rights under this License. 72 | For legal entities, “You” includes any entity that controls, 73 | is controlled by, or is under common control with You. For purposes of 74 | this definition, “control” means (a) the power, direct or indirect, 75 | to cause the direction or management of such entity, whether by contract 76 | or otherwise, or (b) ownership of more than fifty percent (50%) of the 77 | outstanding shares or beneficial ownership of such entity. 78 | 79 | 2. License Grants and Conditions 80 | 81 | 2.1. Grants 82 | Each Contributor hereby grants You a world-wide, royalty-free, 83 | non-exclusive license: 84 | 85 | a. under intellectual property rights (other than patent or trademark) 86 | Licensable by such Contributor to use, reproduce, make available, 87 | modify, display, perform, distribute, and otherwise exploit its 88 | Contributions, either on an unmodified basis, with Modifications, 89 | or as part of a Larger Work; and 90 | 91 | b. under Patent Claims of such Contributor to make, use, sell, 92 | offer for sale, have made, import, and otherwise transfer either 93 | its Contributions or its Contributor Version. 94 | 95 | 2.2. Effective Date 96 | The licenses granted in Section 2.1 with respect to any Contribution 97 | become effective for each Contribution on the date the Contributor 98 | first distributes such Contribution. 99 | 100 | 2.3. Limitations on Grant Scope 101 | The licenses granted in this Section 2 are the only rights granted 102 | under this License. No additional rights or licenses will be implied 103 | from the distribution or licensing of Covered Software under this License. 104 | Notwithstanding Section 2.1(b) above, no patent license is granted 105 | by a Contributor: 106 | 107 | a. for any code that a Contributor has removed from 108 | Covered Software; or 109 | 110 | b. for infringements caused by: (i) Your and any other third party’s 111 | modifications of Covered Software, or (ii) the combination of its 112 | Contributions with other software (except as part of its 113 | Contributor Version); or 114 | 115 | c. under Patent Claims infringed by Covered Software in the 116 | absence of its Contributions. 117 | 118 | This License does not grant any rights in the trademarks, service marks, 119 | or logos of any Contributor (except as may be necessary to comply with 120 | the notice requirements in Section 3.4). 121 | 122 | 2.4. Subsequent Licenses 123 | No Contributor makes additional grants as a result of Your choice to 124 | distribute the Covered Software under a subsequent version of this 125 | License (see Section 10.2) or under the terms of a Secondary License 126 | (if permitted under the terms of Section 3.3). 127 | 128 | 2.5. Representation 129 | Each Contributor represents that the Contributor believes its 130 | Contributions are its original creation(s) or it has sufficient rights 131 | to grant the rights to its Contributions conveyed by this License. 132 | 133 | 2.6. Fair Use 134 | This License is not intended to limit any rights You have under 135 | applicable copyright doctrines of fair use, fair dealing, 136 | or other equivalents. 137 | 138 | 2.7. Conditions 139 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the 140 | licenses granted in Section 2.1. 141 | 142 | 3. Responsibilities 143 | 144 | 3.1. Distribution of Source Form 145 | All distribution of Covered Software in Source Code Form, including 146 | any Modifications that You create or to which You contribute, must be 147 | under the terms of this License. You must inform recipients that the 148 | Source Code Form of the Covered Software is governed by the terms 149 | of this License, and how they can obtain a copy of this License. 150 | You may not attempt to alter or restrict the recipients’ rights 151 | in the Source Code Form. 152 | 153 | 3.2. Distribution of Executable Form 154 | If You distribute Covered Software in Executable Form then: 155 | 156 | a. such Covered Software must also be made available in Source Code 157 | Form, as described in Section 3.1, and You must inform recipients of 158 | the Executable Form how they can obtain a copy of such Source Code 159 | Form by reasonable means in a timely manner, at a charge no more than 160 | the cost of distribution to the recipient; and 161 | 162 | b. You may distribute such Executable Form under the terms of this 163 | License, or sublicense it under different terms, provided that the 164 | license for the Executable Form does not attempt to limit or alter 165 | the recipients’ rights in the Source Code Form under this License. 166 | 167 | 3.3. Distribution of a Larger Work 168 | You may create and distribute a Larger Work under terms of Your choice, 169 | provided that You also comply with the requirements of this License for 170 | the Covered Software. If the Larger Work is a combination of 171 | Covered Software with a work governed by one or more Secondary Licenses, 172 | and the Covered Software is not Incompatible With Secondary Licenses, 173 | this License permits You to additionally distribute such Covered Software 174 | under the terms of such Secondary License(s), so that the recipient of 175 | the Larger Work may, at their option, further distribute the 176 | Covered Software under the terms of either this License or such 177 | Secondary License(s). 178 | 179 | 3.4. Notices 180 | You may not remove or alter the substance of any license notices 181 | (including copyright notices, patent notices, disclaimers of warranty, 182 | or limitations of liability) contained within the Source Code Form of 183 | the Covered Software, except that You may alter any license notices to 184 | the extent required to remedy known factual inaccuracies. 185 | 186 | 3.5. Application of Additional Terms 187 | You may choose to offer, and to charge a fee for, warranty, support, 188 | indemnity or liability obligations to one or more recipients of 189 | Covered Software. However, You may do so only on Your own behalf, 190 | and not on behalf of any Contributor. You must make it absolutely clear 191 | that any such warranty, support, indemnity, or liability obligation is 192 | offered by You alone, and You hereby agree to indemnify every Contributor 193 | for any liability incurred by such Contributor as a result of warranty, 194 | support, indemnity or liability terms You offer. You may include 195 | additional disclaimers of warranty and limitations of liability 196 | specific to any jurisdiction. 197 | 198 | 4. Inability to Comply Due to Statute or Regulation 199 | 200 | If it is impossible for You to comply with any of the terms of this License 201 | with respect to some or all of the Covered Software due to statute, 202 | judicial order, or regulation then You must: (a) comply with the terms of 203 | this License to the maximum extent possible; and (b) describe the limitations 204 | and the code they affect. Such description must be placed in a text file 205 | included with all distributions of the Covered Software under this License. 206 | Except to the extent prohibited by statute or regulation, such description 207 | must be sufficiently detailed for a recipient of ordinary skill 208 | to be able to understand it. 209 | 210 | 5. Termination 211 | 212 | 5.1. The rights granted under this License will terminate automatically 213 | if You fail to comply with any of its terms. However, if You become 214 | compliant, then the rights granted under this License from a particular 215 | Contributor are reinstated (a) provisionally, unless and until such 216 | Contributor explicitly and finally terminates Your grants, and (b) on an 217 | ongoing basis, if such Contributor fails to notify You of the 218 | non-compliance by some reasonable means prior to 60 days after You have 219 | come back into compliance. Moreover, Your grants from a particular 220 | Contributor are reinstated on an ongoing basis if such Contributor 221 | notifies You of the non-compliance by some reasonable means, 222 | this is the first time You have received notice of non-compliance with 223 | this License from such Contributor, and You become compliant prior to 224 | 30 days after Your receipt of the notice. 225 | 226 | 5.2. If You initiate litigation against any entity by asserting a patent 227 | infringement claim (excluding declaratory judgment actions, 228 | counter-claims, and cross-claims) alleging that a Contributor Version 229 | directly or indirectly infringes any patent, then the rights granted 230 | to You by any and all Contributors for the Covered Software under 231 | Section 2.1 of this License shall terminate. 232 | 233 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 234 | end user license agreements (excluding distributors and resellers) which 235 | have been validly granted by You or Your distributors under this License 236 | prior to termination shall survive termination. 237 | 238 | 6. Disclaimer of Warranty 239 | 240 | Covered Software is provided under this License on an “as is” basis, without 241 | warranty of any kind, either expressed, implied, or statutory, including, 242 | without limitation, warranties that the Covered Software is free of defects, 243 | merchantable, fit for a particular purpose or non-infringing. The entire risk 244 | as to the quality and performance of the Covered Software is with You. 245 | Should any Covered Software prove defective in any respect, You 246 | (not any Contributor) assume the cost of any necessary servicing, repair, 247 | or correction. This disclaimer of warranty constitutes an essential part of 248 | this License. No use of any Covered Software is authorized under this 249 | License except under this disclaimer. 250 | 251 | 7. Limitation of Liability 252 | 253 | Under no circumstances and under no legal theory, whether tort 254 | (including negligence), contract, or otherwise, shall any Contributor, or 255 | anyone who distributes Covered Software as permitted above, be liable to 256 | You for any direct, indirect, special, incidental, or consequential damages 257 | of any character including, without limitation, damages for lost profits, 258 | loss of goodwill, work stoppage, computer failure or malfunction, or any and 259 | all other commercial damages or losses, even if such party shall have been 260 | informed of the possibility of such damages. This limitation of liability 261 | shall not apply to liability for death or personal injury resulting from 262 | such party’s negligence to the extent applicable law prohibits such 263 | limitation. Some jurisdictions do not allow the exclusion or limitation of 264 | incidental or consequential damages, so this exclusion and limitation may 265 | not apply to You. 266 | 267 | 8. Litigation 268 | 269 | Any litigation relating to this License may be brought only in the courts of 270 | a jurisdiction where the defendant maintains its principal place of business 271 | and such litigation shall be governed by laws of that jurisdiction, without 272 | reference to its conflict-of-law provisions. Nothing in this Section shall 273 | prevent a party’s ability to bring cross-claims or counter-claims. 274 | 275 | 9. Miscellaneous 276 | 277 | This License represents the complete agreement concerning the subject matter 278 | hereof. If any provision of this License is held to be unenforceable, 279 | such provision shall be reformed only to the extent necessary to make it 280 | enforceable. Any law or regulation which provides that the language of a 281 | contract shall be construed against the drafter shall not be used to construe 282 | this License against a Contributor. 283 | 284 | 10. Versions of the License 285 | 286 | 10.1. New Versions 287 | Mozilla Foundation is the license steward. Except as provided in 288 | Section 10.3, no one other than the license steward has the right to 289 | modify or publish new versions of this License. Each version will be 290 | given a distinguishing version number. 291 | 292 | 10.2. Effect of New Versions 293 | You may distribute the Covered Software under the terms of the version 294 | of the License under which You originally received the Covered Software, 295 | or under the terms of any subsequent version published 296 | by the license steward. 297 | 298 | 10.3. Modified Versions 299 | If you create software not governed by this License, and you want to 300 | create a new license for such software, you may create and use a modified 301 | version of this License if you rename the license and remove any 302 | references to the name of the license steward (except to note that such 303 | modified license differs from this License). 304 | 305 | 10.4. Distributing Source Code Form that is 306 | Incompatible With Secondary Licenses 307 | If You choose to distribute Source Code Form that is 308 | Incompatible With Secondary Licenses under the terms of this version of 309 | the License, the notice described in Exhibit B of this 310 | License must be attached. 311 | 312 | Exhibit A - Source Code Form License Notice 313 | 314 | This Source Code Form is subject to the terms of the 315 | Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed 316 | with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 317 | 318 | If it is not possible or desirable to put the notice in a particular file, 319 | then You may include the notice in a location (such as a LICENSE file in a 320 | relevant directory) where a recipient would be likely to 321 | look for such a notice. 322 | 323 | You may add additional accurate notices of copyright ownership. 324 | 325 | Exhibit B - “Incompatible With Secondary Licenses” Notice 326 | 327 | This Source Code Form is “Incompatible With Secondary Licenses”, 328 | as defined by the Mozilla Public License, v. 2.0. 329 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | niXhtml 3 |

4 | 5 | Have you ever wanted to write your own website using Nix, and nothing but Nix? 6 | Maybe some CSS and JS, but rest in full Nix. Have you? 7 | 8 | If you have answered yes, I have good and bad news for you. Good news is that 9 | this project is exactly that. It produces XHTML documents entirely from Nix code 10 | with some degree of customization for your weirdest fantasies. Bad news, which I 11 | am sorry to report, are that you are in dire need of some professional help. 12 | What kind of a psycho wants to website in _Nix_? Just write HTML for heavens' 13 | sake. 14 | 15 | A very good question would be "why did you do this?" Well, honestly, I don't 16 | really know but it sounded funny at the time. Now I'm debating if I can re-write 17 | my own personal webpage using just Nix through **niXhtml**. Can I? Yeah, 18 | probably. 19 | 20 | ## Usage 21 | 22 | 1. Write Nix 23 | 2. Pass it to `makePage` or `makeSite` functions 24 | 3. Watch the fireworks (they're in your head) 25 | 26 | > [!NOTE] 27 | > The standard `toXML` doesn't really do what we want here, and XHTML doesn't 28 | > _appear_ to be fully structable using just that. For this reason, I've created 29 | > a standalone function (which doesn't depend on `nixpkgs.lib`) that takes a set 30 | > and creates structured XHTML. You can write the file somewhere with `toFile` 31 | > (or using `nixpkgs.lib`) to serve the created files, I recommend linking 32 | > created files in one directory to avoid messing up relative pages. 33 | 34 | There is no need to use something like `callPackage`, because there is no 35 | package. I tried really hard to avoid relying on nixpkgs, be it for packages or 36 | for `lib` and thus it's unironically fast and minimal. Though the code is a bit 37 | unmanagable. Oh well! 38 | 39 | ### Single-Page 40 | 41 | A **makePage** function is provided to create _a single document_ from given Nix 42 | code. You may evaluate it as you see fit. 43 | 44 | In the REPL: 45 | 46 | ```nix 47 | nix-repl> import ./examples/single-page.nix {inherit makePage;} 48 | "/nix/store/8qcbh99c2v0d43zrpdd50wrhgd8k9yjq-index.html" 49 | ``` 50 | 51 | Using the example in the CLI: 52 | 53 | ```bash 54 | $ nix eval .#examples.singlepage --raw 55 | /nix/store/8qcbh99c2v0d43zrpdd50wrhgd8k9yjq-index.html 56 | ``` 57 | 58 | The example should serve to give you an idea how of you may create your own 59 | static pages with niXhtml. CSS and JS are optional, although fully supported. 60 | 61 | ### Multi-Page 62 | 63 | **makeSite** function is a convenient wrapper around `makePage` to reduce the 64 | need for further wrappers. 65 | 66 | In the REPL: 67 | 68 | ```nix 69 | nix-repl> import ./examples/multi-page.nix {inherit makeSite;} 70 | { 71 | about = "/nix/store/cp1y1190963vd7zz66r40hlzk75hpq86-about.xhtml"; 72 | contact = "/nix/store/dgzgcm17kdf0mzs9zgw3b8mjpj8yyjll-contact.xhtml"; 73 | index = "/nix/store/rm94f1msidxixa2slvp6dky12fcw6wv5-index.xhtml"; 74 | } 75 | ``` 76 | 77 | Using the example in the CLI: 78 | 79 | ```bash 80 | $ nix eval .#examples.multipage --json | jq # use --json for a structured result 81 | { 82 | "about": "/nix/store/cp1y1190963vd7zz66r40hlzk75hpq86-about.xhtml", 83 | "contact": "/nix/store/dgzgcm17kdf0mzs9zgw3b8mjpj8yyjll-contact.xhtml", 84 | "index": "/nix/store/rm94f1msidxixa2slvp6dky12fcw6wv5-index.xhtml" 85 | } 86 | ``` 87 | 88 | Using `--json` is not necessary, but it should make your life easier while using 89 | this in CI/CD situations. You can also use `toJSON` inside, e.g., a NixOS 90 | configuration if you plan to deploy on baremetal. 91 | 92 | ### API 93 | 94 | I would encourage you to check out the function sources in `./lib`. The API 95 | might be prone to change, though not too likely. While you're there, perhaps 96 | help me with some documentation? 97 | 98 | #### makePage 99 | 100 | ```nix 101 | makePage { 102 | title, # Page title 103 | body, # Page content structure 104 | lang ? "en", # HTML language attribute 105 | doctype ? "xhtml", # Document type (xhtml or html5) 106 | stylesheets ? [], # List of stylesheet paths 107 | scripts ? [], # List of script paths 108 | meta ? {}, # Meta tags as attribute set 109 | favicon ? null, # Path to favicon 110 | } 111 | ``` 112 | 113 | A very basic example would be 114 | 115 | ```nix 116 | makePage { 117 | title = "My Page"; 118 | lang = "en"; 119 | doctype = "xhtml"; 120 | stylesheets = [./styles.cs]; 121 | scripts = [./script.js]; 122 | meta = { 123 | description = "A page generated with nsg"; 124 | viewport = "width=device-width, initial-scale=1.0"; 125 | }; 126 | favicon = "favicon.ico"; 127 | body = { 128 | div = { 129 | "@class" = "container"; 130 | h1 = "Hello, world!"; 131 | p = "This page was generated with Nix."; 132 | }; 133 | }; 134 | } 135 | ``` 136 | 137 | #### makeSite 138 | 139 | ```nix 140 | makeSite { 141 | pages, # Attribute set of pages 142 | siteConfig ? {}, # Site-wide configurations 143 | assets ? {}, # Asset files to include 144 | } 145 | ``` 146 | 147 | Which you can use as 148 | 149 | ```nix 150 | makeSite { 151 | pages = { 152 | index = { 153 | title = "Home"; 154 | body = { /* ... */ }; 155 | }; 156 | about = { 157 | title = "About"; 158 | body = { /* ... */ }; 159 | }; 160 | }; 161 | 162 | siteConfig = { 163 | siteName = "My Website"; 164 | commonStylesheets = [./common.css]; 165 | commonMeta = { 166 | author = "Site Author"; 167 | viewport = "width=device-width, initial-scale=1.0"; 168 | }; 169 | }; 170 | 171 | assets = { 172 | "styles.css" = ./path/to/styles.css; 173 | "favicon.ico" = ./path/to/favicon.ico; 174 | }; 175 | } 176 | ``` 177 | 178 | #### HTML Structure Specification 179 | 180 | ```nix 181 | { 182 | # Simple tag with text content 183 | tag = "content"; 184 | 185 | # Tag with attributes 186 | div = { 187 | "@class" = "container"; # Attribute with @ prefix 188 | p = "Paragraph text"; # Nested element 189 | }; 190 | 191 | # List of items 192 | ul = [ 193 | { li = "Item 1"; } 194 | { li = "Item 2"; } 195 | ]; 196 | } 197 | ``` 198 | 199 | #### Special Keys 200 | 201 | - `"@attribute"`: Any key starting with @ defines an HTML attribute 202 | - `_text`: Raw text content 203 | - `_raw`: Raw HTML content (unescaped) 204 | - `_comment`: HTML comment 205 | - `_fragment`: List of elements to be rendered in sequence 206 | 207 | An example of special keys 208 | 209 | ```nix 210 | { 211 | div = { 212 | "@class" = "content"; 213 | # Object style attributes 214 | "@style" = { 215 | "color" = "red"; 216 | "font-size" = "16px"; 217 | }; 218 | 219 | # Ordered sequence of elements 220 | _fragment = [ 221 | { h2 = "Section Title"; } 222 | { p = { _text = "Text with emphasis"; }; } 223 | { _comment = "This is an HTML comment"; } 224 | ]; 225 | }; 226 | } 227 | ``` 228 | 229 | ## FAQ 230 | 231 | 1. Why? 232 | 233 | Funny. 234 | 235 | 2. Buttons won't work if you're serving a file from the store! 236 | 237 | Unfortunately. Since we are doing this without `pkgs` (and I'd like to keep it 238 | that way) we cannot easily patch files to be able to reference each other. You 239 | can easily solve this by linking files in a target directory, where you _know_ 240 | they will be able to refer to each other through, e.g., `/about.xhtml`. 241 | 242 | ## Contributing 243 | 244 | Changes are welcome. This is mostly a self-imposed code-golf challenge, but I 245 | appreciate new ideas nevertheless. 246 | 247 | Make your changes, and open a pull request. I am not too picky on styling, but 248 | _please_ format your code with Alejandra. I find nixfmt (both variants) to be 249 | incredibly ugly and will not accept anything else. 250 | -------------------------------------------------------------------------------- /demo/assets/style.css: -------------------------------------------------------------------------------- 1 | /* Dark Theme Reset & Body Styles */ 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font-family: 6 | -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, 7 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 8 | line-height: 1.7; 9 | background-color: #1a1a1d; 10 | color: #c5c6c7; 11 | font-size: 16px; 12 | } 13 | 14 | .site-wrapper { 15 | padding: 25px 15px; 16 | } 17 | 18 | .container { 19 | max-width: 900px; 20 | margin: 0 auto; 21 | background-color: #2c2f33; 22 | border-radius: 8px; 23 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); 24 | overflow: hidden; 25 | } 26 | 27 | .site-header { 28 | text-align: center; 29 | padding: 40px 20px; 30 | background-color: #23272a; 31 | border-bottom: 1px solid #4f545c; 32 | } 33 | 34 | .site-header h1 { 35 | margin: 0 0 10px 0; 36 | color: #ffffff; 37 | font-size: 2.8em; 38 | font-weight: 600; 39 | letter-spacing: 1px; 40 | } 41 | 42 | .site-header p { 43 | margin: 0; 44 | color: #99aab5; 45 | font-size: 1.2em; 46 | } 47 | 48 | .site-content { 49 | padding: 30px 25px; 50 | } 51 | 52 | .feature-section { 53 | margin-bottom: 40px; 54 | padding-bottom: 30px; 55 | border-bottom: 1px solid #40444b; 56 | } 57 | 58 | .feature-section:last-child { 59 | margin-bottom: 0; 60 | padding-bottom: 10px; 61 | border-bottom: none; 62 | } 63 | 64 | .site-content h2 { 65 | color: #7289da; 66 | margin-top: 0; 67 | margin-bottom: 20px; 68 | border-bottom: 2px solid #7289da; 69 | padding-bottom: 8px; 70 | display: inline-block; 71 | font-size: 1.8em; 72 | font-weight: 500; 73 | } 74 | 75 | .site-content p { 76 | margin-bottom: 15px; 77 | color: #b9bbbe; 78 | } 79 | 80 | .site-content ul { 81 | list-style: none; 82 | padding-left: 0; 83 | } 84 | 85 | .site-content li { 86 | margin-bottom: 10px; 87 | padding-left: 20px; 88 | position: relative; 89 | } 90 | 91 | .site-content li::before { 92 | content: ">"; 93 | position: absolute; 94 | left: 0; 95 | top: 1px; 96 | color: #7289da; 97 | /* Accent color */ 98 | font-weight: bold; 99 | font-size: 0.9em; 100 | } 101 | 102 | strong { 103 | color: #ffffff; 104 | font-weight: 600; 105 | } 106 | 107 | em { 108 | color: #b9bbbe; 109 | font-style: italic; 110 | } 111 | 112 | code { 113 | background-color: #1e2124; 114 | padding: 4px 8px; 115 | border-radius: 5px; 116 | font-family: 117 | "Fira Code", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; 118 | font-size: 0.9em; 119 | color: #839496; 120 | border: 1px solid #40444b; 121 | } 122 | 123 | .card { 124 | background-color: #23272a; 125 | border: 1px solid #4f545c; 126 | padding: 20px; 127 | margin-top: 15px; 128 | border-radius: 6px; 129 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); 130 | } 131 | 132 | .card code { 133 | background-color: #2c2f33; 134 | border: 1px solid #5a5e63; 135 | } 136 | 137 | .site-content img { 138 | border: 1px solid #4f545c; 139 | margin-top: 15px; 140 | display: block; 141 | max-width: 100%; 142 | height: auto; 143 | border-radius: 4px; 144 | } 145 | 146 | .site-footer { 147 | text-align: center; 148 | padding: 25px 20px; 149 | border-top: 1px solid #4f545c; 150 | font-size: 0.95em; 151 | color: #99aab5; 152 | background-color: #23272a; 153 | } 154 | 155 | .site-footer p { 156 | margin: 5px 0; 157 | color: #99aab5; 158 | } 159 | 160 | a { 161 | color: #7289da; 162 | text-decoration: none; 163 | transition: color 0.2s ease-in-out; 164 | } 165 | 166 | a:hover { 167 | color: #ffffff; 168 | text-decoration: underline; 169 | } 170 | -------------------------------------------------------------------------------- /demo/site.nix: -------------------------------------------------------------------------------- 1 | {makePage}: 2 | builtins.toFile "index.html" (makePage { 3 | title = "niXhtml Feature Showcase"; 4 | doctype = "html5"; 5 | lang = "en"; 6 | meta = { 7 | viewport = "width=device-width, initial-scale=1.0"; 8 | description = "A single-page demonstration for niXhtml static site generator"; 9 | author = "NotAShelf"; 10 | keywords = "nix, static site, generator, ssg, single page"; 11 | }; 12 | stylesheets = ["style.css"]; 13 | scripts = []; 14 | favicon = null; 15 | 16 | body = { 17 | div = { 18 | "@class" = "site-wrapper"; 19 | div = { 20 | "@class" = "container"; 21 | _fragment = [ 22 | { 23 | header = { 24 | "@class" = "site-header"; 25 | "@id" = "top"; 26 | _fragment = [ 27 | {h1 = "niXhtml";} 28 | {p = "Feature showcase";} 29 | ]; 30 | }; 31 | } 32 | { 33 | main = { 34 | "@class" = "site-content"; 35 | _fragment = [ 36 | { 37 | section = { 38 | "@id" = "basics"; 39 | "@class" = "feature-section basic-elements"; 40 | _fragment = [ 41 | { 42 | p = '' 43 | niXhtml is a pure, reproducible Nix library for generating static documents using Nix and nothing 44 | but Nix; no Bash, no hacks and not even a dependency on nixpkgs.lib. 45 | 46 | The HTML documents (including in-line styles) can be created using ONLY Nix. The helper functions 47 | also allow using a stylesheet path if you wish to do yourself a favor and use a stylesheet written 48 | in CSS and not Nix. Though, the point remains that niXhtml is created using ONLY Nix builtins and 49 | allows for PURE nix websites. No takesies backsies. 50 | ''; 51 | } 52 | {h2 = "Basic Elements & Text";} 53 | {p = "Standard HTML tags like paragraphs (p) and headings (h1-h6) are generated from Nix attribute keys.";} 54 | { 55 | p = { 56 | _fragment = [ 57 | "Inline elements like " 58 | {strong = "strong text";} 59 | " and " 60 | {em = "emphasized text";} 61 | " can be nested using " 62 | {code = "_fragment";} 63 | "." 64 | ]; 65 | }; 66 | } 67 | ]; 68 | }; 69 | } 70 | { 71 | section = { 72 | "@id" = "lists"; 73 | "@class" = "feature-section lists-section"; 74 | _fragment = [ 75 | {h2 = "Lists";} 76 | {p = "Unordered (ul) and ordered (ol) lists are generated from Nix lists:";} 77 | { 78 | ul = [ 79 | {li = "List Item 1";} 80 | { 81 | li = { 82 | _fragment = [ 83 | "List Item 2 with a " 84 | {code = "code";} 85 | " snippet." 86 | ]; 87 | }; 88 | } 89 | {li = "List Item 3";} 90 | { 91 | ul = [ 92 | {li = "This is a nested list item";} 93 | { 94 | li._fragment = [ 95 | {code = {_raw = "Raw HTML inside a nested list!";};} 96 | ]; 97 | } 98 | ]; 99 | } 100 | ]; 101 | } 102 | ]; 103 | }; 104 | } 105 | { 106 | section = { 107 | "@id" = "attributes"; 108 | "@class" = "feature-section attributes-styling"; 109 | _fragment = [ 110 | {h2 = "Attributes & Styling";} 111 | { 112 | p = '' 113 | HTML attributes are added using keys prefixed with '@'. Styling is primarily handled via CSS classes linked externally. 114 | ''; 115 | } 116 | { 117 | div = { 118 | "@id" = "styled-div"; 119 | "@class" = "card"; 120 | _fragment = [ 121 | "This div uses the " 122 | {code = ".card";} 123 | " class for styling defined in " 124 | {code = "style.css";} 125 | "." 126 | ]; 127 | }; 128 | } 129 | ]; 130 | }; 131 | } 132 | { 133 | section = { 134 | "@id" = "special-keys"; 135 | "@class" = "feature-section special-keys"; 136 | _fragment = [ 137 | {h2 = "Special Keys";} 138 | { 139 | ul = [ 140 | { 141 | li = { 142 | _fragment = [ 143 | {code = "_text";} 144 | ": For simple string content, e.g., " 145 | { 146 | span = { 147 | "@style" = {color = "green";}; 148 | _text = "this span"; 149 | }; 150 | } 151 | "." 152 | ]; 153 | }; 154 | } 155 | { 156 | li = { 157 | _fragment = [ 158 | {code = "_raw";} 159 | ": Injects raw HTML without escaping: " 160 | {code = {_raw = "This is raw & unescaped";};} 161 | "." 162 | ]; 163 | }; 164 | } 165 | { 166 | li = { 167 | _fragment = [ 168 | {code = "_comment";} 169 | ": Adds an HTML comment: " 170 | {_comment = " This is a generated HTML comment ";} 171 | "(view source)." 172 | ]; 173 | }; 174 | } 175 | { 176 | li = { 177 | _fragment = [ 178 | {code = "_fragment";} 179 | ": Allows mixing text nodes and sibling elements within a parent." 180 | ]; 181 | }; 182 | } 183 | ]; 184 | } 185 | ]; 186 | }; 187 | } 188 | ]; # End main _fragment 189 | }; # End main 190 | } 191 | { 192 | footer = { 193 | "@class" = "site-footer"; 194 | _fragment = [ 195 | {p = "Generated with niXhtml";} 196 | { 197 | p = { 198 | _fragment = [ 199 | "© 2025 NotAShelf" 200 | ]; 201 | }; 202 | } 203 | ]; 204 | }; # End footer 205 | } 206 | ]; # End container _fragment 207 | }; # End container div 208 | }; # End site-wrapper div 209 | }; # End body 210 | }) 211 | -------------------------------------------------------------------------------- /examples/assets/styles.css: -------------------------------------------------------------------------------- 1 | /* Base styles and variables */ 2 | :root { 3 | --bg-color: #121212; 4 | --surface-color: #1e1e1e; 5 | --text-color: #e0e0e0; 6 | --primary-color: #7cb7ff; 7 | --secondary-color: #a0a0a0; 8 | --border-color: #333333; 9 | --accent-color: #59a5fa; 10 | --feature-bullet-color: #59a5fa; 11 | --shadow-color: rgba(0, 0, 0, 0.2); 12 | --blockquote-bg: #1a1a1a; 13 | --code-bg: #2a2a2a; 14 | } 15 | 16 | /* Reset and base styles */ 17 | *, 18 | *::before, 19 | *::after { 20 | box-sizing: border-box; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | html { 26 | font-size: 62.5%; 27 | } 28 | 29 | body { 30 | font-family: Roboto, sans-serif; 31 | font-size: 1.6rem; 32 | line-height: 1.6; 33 | background-color: var(--bg-color); 34 | color: var(--text-color); 35 | margin: 0; 36 | padding: 0; 37 | } 38 | 39 | /* Site structure */ 40 | .site-wrapper { 41 | display: flex; 42 | flex-direction: column; 43 | min-height: 100vh; 44 | width: 100%; 45 | } 46 | 47 | .container { 48 | width: 100%; 49 | max-width: 80rem; 50 | margin: 0 auto; 51 | padding: 0 2rem; 52 | } 53 | 54 | /* Header styles */ 55 | .site-header { 56 | padding: 3rem 0 2rem; 57 | text-align: center; 58 | border-bottom: 1px solid var(--border-color); 59 | margin-bottom: 4rem; 60 | } 61 | 62 | .site-title { 63 | font-size: 3rem; 64 | font-weight: 700; 65 | margin-bottom: 2rem; 66 | background: linear-gradient(135deg, var(--primary-color) 0%, #a7c6ff 100%); 67 | -webkit-background-clip: text; 68 | -webkit-text-fill-color: transparent; 69 | background-clip: text; 70 | } 71 | 72 | /* Navigation */ 73 | .main-nav ul { 74 | list-style-type: none; 75 | display: flex; 76 | justify-content: center; 77 | gap: 3rem; 78 | } 79 | 80 | .main-nav a { 81 | color: var(--text-color); 82 | text-decoration: none; 83 | font-weight: 500; 84 | font-size: 1.8rem; 85 | position: relative; 86 | padding: 0.5rem 0; 87 | transition: color 0.3s ease; 88 | } 89 | 90 | .main-nav a::after { 91 | content: ""; 92 | position: absolute; 93 | width: 0; 94 | height: 2px; 95 | bottom: 0; 96 | left: 0; 97 | background-color: var(--accent-color); 98 | transition: width 0.3s ease; 99 | } 100 | 101 | .main-nav a:hover, 102 | .main-nav a.active { 103 | color: var(--primary-color); 104 | } 105 | 106 | .main-nav a:hover::after, 107 | .main-nav a.active::after { 108 | width: 100%; 109 | } 110 | 111 | /* Main content */ 112 | .site-content { 113 | flex-grow: 1; 114 | margin-bottom: 4rem; 115 | } 116 | 117 | /* Section styling */ 118 | section { 119 | margin-bottom: 4rem; 120 | } 121 | 122 | h2 { 123 | font-size: 2.4rem; 124 | margin-bottom: 2rem; 125 | color: var(--primary-color); 126 | } 127 | 128 | h3 { 129 | font-size: 2rem; 130 | margin-top: 2.5rem; 131 | margin-bottom: 1.5rem; 132 | color: #a7c6ff; 133 | } 134 | 135 | p { 136 | margin-bottom: 1.5rem; 137 | text-align: left; 138 | line-height: 1.7; 139 | font-size: 1.6rem; 140 | } 141 | 142 | /* Features section */ 143 | .features-section { 144 | background-color: var(--surface-color); 145 | border-radius: 1rem; 146 | padding: 3rem; 147 | margin-bottom: 4rem; 148 | box-shadow: 0 0.5rem 1.5rem var(--shadow-color); 149 | } 150 | 151 | .features-section h2 { 152 | text-align: center; 153 | } 154 | 155 | .features-section ul { 156 | list-style-type: none; 157 | padding-left: 2rem; 158 | } 159 | 160 | .feature-item { 161 | padding: 0.8rem 0; 162 | font-size: 1.8rem; 163 | position: relative; 164 | padding-left: 2rem; 165 | text-align: left; 166 | } 167 | 168 | .feature-item::before { 169 | content: "→"; 170 | color: var(--feature-bullet-color); 171 | position: absolute; 172 | left: 0; 173 | } 174 | 175 | /* Text demo section */ 176 | .text-demo-section { 177 | background-color: var(--surface-color); 178 | border-radius: 1rem; 179 | padding: 3rem; 180 | box-shadow: 0 0.5rem 1.5rem var(--shadow-color); 181 | } 182 | 183 | em { 184 | color: #b8d4ff; 185 | font-style: italic; 186 | } 187 | 188 | strong { 189 | color: #ffffff; 190 | font-weight: 700; 191 | } 192 | 193 | blockquote { 194 | margin: 2rem 0; 195 | padding: 1.5rem 2rem; 196 | background-color: var(--blockquote-bg); 197 | border-left: 4px solid var(--primary-color); 198 | position: relative; 199 | } 200 | 201 | blockquote p { 202 | margin-bottom: 0.5rem; 203 | font-style: italic; 204 | } 205 | 206 | blockquote cite { 207 | display: block; 208 | text-align: right; 209 | font-size: 1.4rem; 210 | color: var(--secondary-color); 211 | } 212 | 213 | /* Code section */ 214 | .code-section { 215 | margin-top: 4rem; 216 | } 217 | 218 | pre { 219 | background-color: var(--code-bg); 220 | padding: 2rem; 221 | border-radius: 0.6rem; 222 | overflow-x: auto; 223 | margin: 2rem 0; 224 | border: 1px solid #333; 225 | } 226 | 227 | code { 228 | font-family: 229 | "SFMono-Regular", Consolas, Monaco, "Liberation Mono", Menlo, monospace; 230 | font-size: 1.4rem; 231 | color: #a7c6ff; 232 | } 233 | 234 | /* Footer */ 235 | .site-footer { 236 | text-align: center; 237 | border-top: 1px solid var(--border-color); 238 | padding: 2rem 0; 239 | color: var(--secondary-color); 240 | margin-top: 2rem; 241 | } 242 | 243 | /* Media queries for "responsiveness" */ 244 | @media (max-width: 768px) { 245 | html { 246 | font-size: 58%; 247 | } 248 | 249 | .container { 250 | padding: 0 1.5rem; 251 | } 252 | 253 | .main-nav ul { 254 | gap: 1.5rem; 255 | } 256 | } 257 | 258 | @media (max-width: 480px) { 259 | html { 260 | font-size: 55%; 261 | } 262 | 263 | .main-nav ul { 264 | flex-direction: column; 265 | gap: 1rem; 266 | align-items: center; 267 | } 268 | 269 | .site-title { 270 | font-size: 2.4rem; 271 | } 272 | 273 | section { 274 | padding: 2rem; 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /examples/multi-page.nix: -------------------------------------------------------------------------------- 1 | {makeSite}: 2 | makeSite { 3 | pages = { 4 | index = { 5 | title = "Home"; 6 | doctype = "xhtml"; 7 | stylesheets = [./assets/styles.css]; 8 | scripts = []; 9 | meta = { 10 | description = "nsg: nix site generator, or; not a site generator"; 11 | keywords = "nix, static site, generator"; 12 | author = "NotAShelf"; 13 | viewport = "width=device-width, initial-scale=1.0"; 14 | }; 15 | favicon = "favicon.ico"; 16 | body = { 17 | div = { 18 | "@class" = "site-wrapper"; 19 | div = { 20 | "@class" = "container"; 21 | _fragment = [ 22 | { 23 | header = { 24 | "@class" = "site-header"; 25 | h1 = { 26 | "@class" = "site-title"; 27 | _text = "Welcome to the Nix Site"; 28 | }; 29 | nav = { 30 | "@class" = "main-nav"; 31 | ul = [ 32 | { 33 | li = { 34 | a = { 35 | "@href" = "index.xhtml"; 36 | "@class" = "active"; 37 | _text = "Home"; 38 | }; 39 | }; 40 | } 41 | { 42 | li = { 43 | a = { 44 | "@href" = "about.xhtml"; 45 | _text = "About"; 46 | }; 47 | }; 48 | } 49 | { 50 | li = { 51 | a = { 52 | "@href" = "contact.xhtml"; 53 | _text = "Contact"; 54 | }; 55 | }; 56 | } 57 | ]; 58 | }; 59 | }; 60 | } 61 | { 62 | main = { 63 | "@class" = "site-content"; 64 | _fragment = [ 65 | { 66 | div = { 67 | "@class" = "intro-section"; 68 | p = '' 69 | This page was generated purely in Nix. I didn't know that was possible, but now you do. 70 | 71 | Honestly, you might even be able to inline some HTML here 72 | ''; 73 | }; 74 | } 75 | {_comment = "This is an HTML comment. You should see this in the site source";} 76 | { 77 | section = { 78 | "@class" = "features-section"; 79 | h2 = "Features"; 80 | ul = [ 81 | { 82 | li = { 83 | "@class" = "feature-item"; 84 | _text = "Fast (lie)"; 85 | }; 86 | } 87 | { 88 | li = { 89 | "@class" = "feature-item"; 90 | _text = "Reproducible"; 91 | }; 92 | } 93 | { 94 | li = { 95 | "@class" = "feature-item"; 96 | _text = "Minimalist"; 97 | }; 98 | } 99 | ]; 100 | }; 101 | } 102 | ]; 103 | }; 104 | } 105 | { 106 | footer = { 107 | "@class" = "site-footer"; 108 | p = { 109 | _text = "© 2025 nsg"; 110 | }; 111 | }; 112 | } 113 | ]; 114 | }; 115 | }; 116 | }; 117 | }; 118 | 119 | about = { 120 | title = "About"; 121 | doctype = "xhtml"; 122 | stylesheets = [./assets/styles.css]; 123 | meta = { 124 | description = "About nsg"; 125 | viewport = "width=device-width, initial-scale=1.0"; 126 | }; 127 | favicon = "favicon.ico"; 128 | body = { 129 | div = { 130 | "@class" = "site-wrapper"; 131 | div = { 132 | "@class" = "container"; 133 | _fragment = [ 134 | { 135 | header = { 136 | "@class" = "site-header"; 137 | h1 = { 138 | "@class" = "site-title"; 139 | _text = "About nsg"; 140 | }; 141 | nav = { 142 | "@class" = "main-nav"; 143 | ul = [ 144 | { 145 | li = { 146 | a = { 147 | "@href" = "index.xhtml"; 148 | _text = "Home"; 149 | }; 150 | }; 151 | } 152 | { 153 | li = { 154 | a = { 155 | "@href" = "about.xhtml"; 156 | "@class" = "active"; 157 | _text = "About"; 158 | }; 159 | }; 160 | } 161 | { 162 | li = { 163 | a = { 164 | "@href" = "contact.xhtml"; 165 | _text = "Contact"; 166 | }; 167 | }; 168 | } 169 | ]; 170 | }; 171 | }; 172 | } 173 | { 174 | main = { 175 | "@class" = "site-content"; 176 | _fragment = [ 177 | { 178 | div = { 179 | "@class" = "about-content"; 180 | h2 = "About This Project"; 181 | p = '' 182 | nsg is a minimal HTML generation library 183 | written purely in Nix. It demonstrates how Nix's expression 184 | language can be used beyond package management. 185 | ''; 186 | }; 187 | } 188 | { 189 | div = { 190 | "@class" = "tech-stack"; 191 | h3 = "Technology"; 192 | p = "Built with 100% pure Nix. No external dependencies."; 193 | }; 194 | } 195 | ]; 196 | }; 197 | } 198 | { 199 | footer = { 200 | "@class" = "site-footer"; 201 | p = { 202 | _text = "© 2025 nsg"; 203 | }; 204 | }; 205 | } 206 | ]; 207 | }; 208 | }; 209 | }; 210 | }; 211 | 212 | contact = { 213 | title = "Contact"; 214 | doctype = "xhtml"; 215 | stylesheets = [./assets/styles.css]; 216 | meta = { 217 | description = "Contact nsg"; 218 | viewport = "width=device-width, initial-scale=1.0"; 219 | }; 220 | favicon = "favicon.ico"; 221 | body = { 222 | div = { 223 | "@class" = "site-wrapper"; 224 | div = { 225 | "@class" = "container"; 226 | _fragment = [ 227 | { 228 | header = { 229 | "@class" = "site-header"; 230 | h1 = { 231 | "@class" = "site-title"; 232 | _text = "Contact Us"; 233 | }; 234 | nav = { 235 | "@class" = "main-nav"; 236 | ul = [ 237 | { 238 | li = { 239 | a = { 240 | "@href" = "index.xhtml"; 241 | _text = "Home"; 242 | }; 243 | }; 244 | } 245 | { 246 | li = { 247 | a = { 248 | "@href" = "about.xhtml"; 249 | _text = "About"; 250 | }; 251 | }; 252 | } 253 | { 254 | li = { 255 | a = { 256 | "@href" = "contact.xhtml"; 257 | "@class" = "active"; 258 | _text = "Contact"; 259 | }; 260 | }; 261 | } 262 | ]; 263 | }; 264 | }; 265 | } 266 | { 267 | main = { 268 | "@class" = "site-content"; 269 | _fragment = [ 270 | { 271 | div = { 272 | "@class" = "contact-form"; 273 | h2 = "Get In Touch"; 274 | form = { 275 | "@action" = "#"; 276 | "@method" = "post"; 277 | _fragment = [ 278 | { 279 | div = { 280 | "@class" = "form-group"; 281 | label = { 282 | "@for" = "name"; 283 | _text = "Name:"; 284 | }; 285 | input = { 286 | "@type" = "text"; 287 | "@id" = "name"; 288 | "@name" = "name"; 289 | "@required" = "required"; 290 | }; 291 | }; 292 | } 293 | { 294 | div = { 295 | "@class" = "form-group"; 296 | label = { 297 | "@for" = "email"; 298 | _text = "Email:"; 299 | }; 300 | input = { 301 | "@type" = "email"; 302 | "@id" = "email"; 303 | "@name" = "email"; 304 | "@required" = "required"; 305 | }; 306 | }; 307 | } 308 | { 309 | div = { 310 | "@class" = "form-group"; 311 | label = { 312 | "@for" = "message"; 313 | _text = "Message:"; 314 | }; 315 | textarea = { 316 | "@id" = "message"; 317 | "@name" = "message"; 318 | "@rows" = "5"; 319 | "@required" = "required"; 320 | _text = ""; 321 | }; 322 | }; 323 | } 324 | { 325 | button = { 326 | "@type" = "submit"; 327 | _text = "Send Message"; 328 | }; 329 | } 330 | ]; 331 | }; 332 | }; 333 | } 334 | ]; 335 | }; 336 | } 337 | { 338 | footer = { 339 | "@class" = "site-footer"; 340 | p = { 341 | _text = "© 2025 nsg"; 342 | }; 343 | }; 344 | } 345 | ]; 346 | }; 347 | }; 348 | }; 349 | }; 350 | }; 351 | } 352 | -------------------------------------------------------------------------------- /examples/single-page.nix: -------------------------------------------------------------------------------- 1 | {makePage, ...}: 2 | builtins.toFile "index.html" (makePage { 3 | title = "nsg"; 4 | lang = "en"; 5 | doctype = "xhtml"; 6 | stylesheets = [./assets/styles.css]; 7 | scripts = []; 8 | meta = { 9 | description = "nsg: nix site generator, or; not a site generator"; 10 | keywords = "nix, static site, generator"; 11 | author = "NotAShelf"; 12 | viewport = "width=device-width, initial-scale=1.0"; 13 | }; 14 | favicon = "favicon.ico"; 15 | body = { 16 | div = { 17 | "@class" = "site-wrapper"; 18 | 19 | div = { 20 | "@class" = "container"; 21 | 22 | _fragment = [ 23 | { 24 | header = { 25 | "@class" = "site-header"; 26 | h1 = { 27 | "@class" = "site-title"; 28 | _text = "Welcome to the Nix Site"; 29 | }; 30 | nav = { 31 | "@class" = "main-nav"; 32 | ul = [ 33 | { 34 | li = { 35 | a = { 36 | "@href" = "index.xhtml"; 37 | "@class" = "active"; 38 | _text = "Home"; 39 | }; 40 | }; 41 | } 42 | { 43 | li = { 44 | a = { 45 | "@href" = "about.xhtml"; 46 | _text = "About"; 47 | }; 48 | }; 49 | } 50 | { 51 | li = { 52 | a = { 53 | "@href" = "contact.xhtml"; 54 | _text = "Contact"; 55 | }; 56 | }; 57 | } 58 | ]; 59 | }; 60 | }; 61 | } 62 | 63 | { 64 | main = { 65 | "@class" = "site-content"; 66 | _fragment = [ 67 | { 68 | div = { 69 | "@class" = "intro-section"; 70 | p = '' 71 | This page was generated purely in Nix. I didn't know that was possible, but now you do. 72 | 73 | Honestly, you might even be able to inline some HTML here 74 | ''; 75 | }; 76 | } 77 | {_comment = "This is an HTML comment. You should see this in the site source";} 78 | { 79 | section = { 80 | "@class" = "features-section"; 81 | h2 = "Features"; 82 | ul = [ 83 | { 84 | li = { 85 | "@class" = "feature-item"; 86 | _text = "Fast (lie)"; 87 | }; 88 | } 89 | { 90 | li = { 91 | "@class" = "feature-item"; 92 | _text = "Reproducible"; 93 | }; 94 | } 95 | { 96 | li = { 97 | "@class" = "feature-item"; 98 | _text = "Minimalist"; 99 | }; 100 | } 101 | ]; 102 | }; 103 | } 104 | { 105 | div = { 106 | "@class" = "logo-container"; 107 | img = { 108 | "@src" = "nix-logo.png"; 109 | "@alt" = "Nix Logo"; 110 | "@width" = "200"; 111 | "@height" = "100"; 112 | }; 113 | }; 114 | } 115 | ]; 116 | }; 117 | } 118 | 119 | { 120 | footer = { 121 | "@class" = "site-footer"; 122 | p = { 123 | _text = "© 2025 nsg"; 124 | }; 125 | }; 126 | } 127 | ]; 128 | }; 129 | }; 130 | }; 131 | }) 132 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Static 'site generator' in pure Nix"; 3 | outputs = {self, ...}: { 4 | lib = import ./lib; 5 | examples = { 6 | # Single-page example containing one page with a minimal stylesheet. 7 | singlepage = import ./examples/single-page.nix {inherit (self.lib) makePage;}; 8 | 9 | multipage = import ./examples/multi-page.nix {inherit (self.lib) makeSite;}; 10 | }; 11 | 12 | demo = import ./demo/site.nix {inherit (self.lib) makePage;}; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /lib/default.nix: -------------------------------------------------------------------------------- 1 | let 2 | # List of void elements that shouldn't have closing tags 3 | voidElements = [ 4 | "area" 5 | "base" 6 | "br" 7 | "col" 8 | "embed" 9 | "hr" 10 | "img" 11 | "input" 12 | "link" 13 | "meta" 14 | "param" 15 | "source" 16 | "track" 17 | "wbr" 18 | ]; 19 | 20 | # Check if an element is a void element 21 | isVoidElement = name: builtins.elem name voidElements; 22 | 23 | # Format attributes for an element 24 | formatAttributes = attrs: let 25 | # Handle normal attrs (prefixed with @) 26 | attrKeys = 27 | builtins.filter (k: builtins.substring 0 1 k == "@") 28 | (builtins.attrNames attrs); 29 | 30 | # Special handling for style attributes as objects 31 | styleAttr = 32 | if attrs ? "@style" && builtins.isAttrs attrs."@style" 33 | then let 34 | styleKeys = builtins.attrNames attrs."@style"; 35 | formatStyle = k: "${k}: ${attrs."@style".${k}}"; 36 | styleString = builtins.concatStringsSep "; " (map formatStyle styleKeys); 37 | in [" style=\"${styleString}\""] 38 | else []; 39 | 40 | # Format regular atts 41 | regularAttrs = 42 | map ( 43 | k: let 44 | name = builtins.substring 1 (builtins.stringLength k) k; 45 | value = attrs.${k}; 46 | 47 | # Skip style if it's handled as an object 48 | skipStyle = name == "style" && builtins.isAttrs value; 49 | in 50 | if skipStyle 51 | then "" 52 | else " ${name}=\"${builtins.toString value}\"" 53 | ) 54 | attrKeys; 55 | 56 | # Combine all attribute strings 57 | allAttrs = regularAttrs ++ styleAttr; 58 | in 59 | builtins.concatStringsSep "" allAttrs; 60 | 61 | # Process mixed content (text and elements) 62 | processMixedContent = content: 63 | if builtins.isAttrs content 64 | then 65 | # Handle special _text, _raw, _comment, _fragment keys 66 | # Statix is providing false positives here, do not lint 67 | if content ? _text 68 | then content._text 69 | else if content ? _raw 70 | then content._raw 71 | else if content ? _comment 72 | then "" 73 | else if content ? _fragment # Handle fragments containing lists or mixed content 74 | then processMixedContent content._fragment # Recurse on fragment content 75 | else formatElements content # Treat as regular element set 76 | else if builtins.isList content 77 | then builtins.concatStringsSep "\n" (map processMixedContent content) # Process each item in the list 78 | else builtins.toString content; # Handle plain strings/numbers 79 | 80 | # Format a single element with its content and attributes 81 | formatElement = tagName: value: let 82 | # Handle void elements 83 | isVoid = isVoidElement tagName; 84 | 85 | # Direct string/number value as content 86 | simple = 87 | if builtins.isString value || builtins.isInt value 88 | then 89 | if isVoid 90 | then "<${tagName} />" # Void element, no content or closing tag 91 | else "<${tagName}>${builtins.toString value}" # Simple content 92 | else null; 93 | 94 | # Handle attribute sets (complex elements with attributes and/or nested content) 95 | complex = 96 | if builtins.isAttrs value 97 | then let 98 | attrs = formatAttributes value; 99 | 100 | # Extract content keys (non-attribute, non-special keys) 101 | contentKeys = builtins.filter ( 102 | k: 103 | (builtins.substring 0 1 k != "@") # Not an attribute 104 | && (k != "_text") 105 | && (k != "_raw") 106 | && (k != "_comment") 107 | && (k != "_fragment") # Handled separately 108 | ) (builtins.attrNames value); 109 | 110 | # Handle special content keys (_text, _raw, _comment, _fragment) 111 | # Statix false positive here. 112 | specialContent = 113 | if value ? _text 114 | then value._text 115 | else if value ? _raw 116 | then value._raw 117 | else if value ? _comment 118 | then "" 119 | else if value ? _fragment # Use processMixedContent for fragments 120 | then processMixedContent value._fragment 121 | else null; 122 | 123 | # Process nested elements defined by standard keys 124 | nestedContent = 125 | if contentKeys == [] 126 | then "" 127 | else 128 | formatElements (builtins.listToAttrs 129 | (map (k: { 130 | name = k; 131 | value = value.${k}; # Recursively format nested elements 132 | }) 133 | contentKeys)); 134 | 135 | # Combine special and nested content 136 | content = 137 | if specialContent != null 138 | then 139 | ( 140 | if nestedContent == "" 141 | then specialContent 142 | else "${specialContent}\n${nestedContent}" # Combine if both exist 143 | ) 144 | else nestedContent; 145 | in 146 | if isVoid 147 | # Void element with attributes 148 | then "<${tagName}${attrs} />" 149 | # Render tag with content, handles empty content correctly 150 | else "<${tagName}${attrs}>${content}" 151 | else null; 152 | 153 | # Process a list value directly associated with a tag name 154 | # Example: ul = [ { li = "Item 1"; } { li = "Item 2"; } ]; 155 | listContent = 156 | if builtins.isList value 157 | then let 158 | # Recursively format each item in the list using formatElements 159 | contents = builtins.concatStringsSep "\n" (map formatElements value); 160 | in 161 | # If the tag itself is a void element, it cannot contain list content. 162 | if isVoid 163 | then "<${tagName} />" 164 | # Otherwise, embed the formatted list content within the start and end tags. 165 | else "<${tagName}>${contents}" 166 | else null; 167 | in 168 | # Determine the correct handler based on the value type 169 | if simple != null 170 | then simple 171 | else if complex != null 172 | then complex 173 | else if listContent != null # Check for list content 174 | then listContent 175 | # Fallback for null or empty {} value 176 | else if isVoid 177 | then "<${tagName} />" 178 | else "<${tagName}>"; 179 | 180 | # Format all elements in an attribute set or list 181 | formatElements = data: 182 | if builtins.isAttrs data 183 | then let 184 | keys = builtins.attrNames data; 185 | # Special handling for top-level fragment (avoids wrapping element) 186 | isFragment = data ? _fragment; 187 | in 188 | if isFragment 189 | # Process fragment content directly 190 | then processMixedContent data._fragment 191 | # Format each key-value pair as an element 192 | else builtins.concatStringsSep "\n" (map (k: formatElement k data.${k}) keys) 193 | else if builtins.isList data 194 | # If data is a list, format each item in the list 195 | then builtins.concatStringsSep "\n" (map formatElements data) 196 | # Otherwise, treat as plain text 197 | else builtins.toString data; 198 | 199 | # Create a complete page 200 | makePage = { 201 | title, 202 | body, 203 | lang ? "en", 204 | doctype ? "xhtml", 205 | stylesheets ? [], 206 | scripts ? [], 207 | meta ? {}, 208 | favicon ? null, 209 | }: let 210 | # DOCTYPE options 211 | doctypes = { 212 | xhtml = '' 213 | 215 | ''; 216 | html5 = ""; 217 | }; 218 | 219 | # XML declaration 220 | xml = 221 | if doctype == "xhtml" 222 | then "" 223 | else ""; 224 | 225 | # DOCTYPE declaration 226 | doctypeDecl = doctypes.${doctype} or doctypes.html5; 227 | 228 | # Generate head content structure as a list of element definitions 229 | headContentList = 230 | [{inherit title;}] # title content 231 | ++ (map (k: { 232 | meta = { 233 | "@name" = k; 234 | "@content" = meta.${k}; 235 | }; 236 | }) (builtins.attrNames meta)) # 237 | ++ (map (href: { 238 | link = { 239 | "@rel" = "stylesheet"; 240 | "@type" = "text/css"; 241 | "@href" = href; 242 | }; 243 | }) 244 | stylesheets) # 245 | ++ (map (src: { 246 | script = { 247 | "@type" = "text/javascript"; 248 | "@src" = src; 249 | }; 250 | }) 251 | scripts) # 252 | ++ ( 253 | if favicon != null 254 | then [ 255 | { 256 | link = { 257 | "@rel" = "shortcut icon"; 258 | "@href" = favicon; 259 | "@type" = "image/x-icon"; 260 | }; 261 | } 262 | ] 263 | else [] 264 | ); # 265 | 266 | # Define the head element using _fragment to render the list of tags inside it 267 | headElement = {head = {_fragment = headContentList;};}; 268 | 269 | # Define HTML attributes based on doctype 270 | htmlAttrs = 271 | ( 272 | if doctype == "xhtml" 273 | then {"@xmlns" = "http://www.w3.org/1999/xhtml";} 274 | else {} 275 | ) 276 | // { 277 | "@lang" = lang; 278 | }; 279 | 280 | # Define the complete page structure as a Nix attribute set 281 | pageStructure = { 282 | # The top-level key is the tag name 'html' 283 | html = 284 | htmlAttrs 285 | // headElement 286 | // { 287 | # The body content is passed directly 288 | inherit body; 289 | }; 290 | }; 291 | 292 | # Generate the HTML content using formatElements 293 | pageContent = formatElements pageStructure; 294 | in 295 | # Combine XML declaration, DOCTYPE, and the generated HTML content 296 | builtins.concatStringsSep "\n" ( 297 | builtins.filter (x: x != "") [ 298 | ( 299 | if xml != "" 300 | then xml 301 | else "" 302 | ) 303 | doctypeDecl 304 | pageContent 305 | ] 306 | ); 307 | 308 | makeSite = { 309 | pages, # Attribute set of page specifications 310 | siteConfig ? {}, # Optional site-wide configurations 311 | assets ? {}, # Optional asset files to include 312 | pkgs ? null, # Optional nixpkgs reference for advanced functionality 313 | }: let 314 | defaultSiteConfig = { 315 | siteName = "My Site"; 316 | baseUrl = ""; 317 | lang = "en"; 318 | doctype = "xhtml"; 319 | commonMeta = { 320 | viewport = "width=device-width, initial-scale=1.0"; 321 | }; 322 | commonStylesheets = []; 323 | commonScripts = []; 324 | favicon = null; 325 | }; 326 | 327 | config = defaultSiteConfig // siteConfig; 328 | 329 | # Generate an individual page 330 | generatePage = name: spec: let 331 | # Combine common site config with page-specific config 332 | fullSpec = { 333 | title = spec.title or "${config.siteName} - ${name}"; 334 | lang = spec.lang or config.lang; 335 | doctype = spec.doctype or config.doctype; 336 | stylesheets = (config.commonStylesheets or []) ++ (spec.stylesheets or []); 337 | scripts = (config.commonScripts or []) ++ (spec.scripts or []); 338 | meta = (config.commonMeta or {}) // (spec.meta or {}); 339 | favicon = spec.favicon or config.favicon; 340 | body = spec.body; # Pass the body structure directly 341 | }; 342 | 343 | # Create the page content 344 | pageContent = makePage fullSpec; 345 | 346 | # Determine file extension based on doctype 347 | extension = 348 | if (fullSpec.doctype == "xhtml") 349 | then ".xhtml" 350 | else ".html"; 351 | in 352 | builtins.toFile "${name}${extension}" pageContent; 353 | 354 | # Generate all pages 355 | pageFiles = builtins.mapAttrs generatePage pages; 356 | 357 | # Handle assets if pkgs is provided 358 | assetFiles = 359 | if pkgs != null && assets != {} 360 | then 361 | pkgs.runCommandLocal "site-assets" {} ( 362 | let 363 | copyCommands = 364 | builtins.mapAttrs ( 365 | name: path: "mkdir -p $out/$(dirname ${name}) && cp -r ${path} $out/${name}" 366 | ) 367 | assets; 368 | in 369 | builtins.concatStringsSep "\n" (builtins.attrValues copyCommands) 370 | ) 371 | else {}; 372 | 373 | # Final result includes pages and optionally assets 374 | result = 375 | pageFiles 376 | // ( 377 | if pkgs != null && assets != {} 378 | then {_assets = assetFiles;} 379 | else {} 380 | ); 381 | in 382 | result; 383 | in { 384 | inherit makePage makeSite; 385 | } 386 | --------------------------------------------------------------------------------