├── .editorconfig
├── .gitattributes
├── flake.nix
├── .github
└── workflows
│ └── demo.yml
├── demo
├── assets
│ └── style.css
└── site.nix
├── examples
├── single-page.nix
├── assets
│ └── styles.css
└── multi-page.nix
├── README.md
├── lib
└── default.nix
└── LICENSE
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
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/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/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 |
--------------------------------------------------------------------------------
/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}${tagName}>" # 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}${tagName}>"
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}${tagName}>"
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}>${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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------