Commit
+831 -0 +/-17 browse
1 | diff --git a/.gitignore b/.gitignore |
2 | new file mode 100644 |
3 | index 0000000..2f8975d |
4 | --- /dev/null |
5 | +++ b/.gitignore |
6 | @@ -0,0 +1,4 @@ |
7 | + __pycache__ |
8 | + gen |
9 | + .ninja_log |
10 | + build.ninja |
11 | diff --git a/README.md b/README.md |
12 | new file mode 100644 |
13 | index 0000000..9d0fdc8 |
14 | --- /dev/null |
15 | +++ b/README.md |
16 | @@ -0,0 +1,7 @@ |
17 | + # kevinschoon-dot-com |
18 | + |
19 | + This is source code for my personal website. |
20 | + |
21 | + # Development |
22 | + |
23 | + scripts/watch.sh ./build.py |
24 | diff --git a/assets/contact.png b/assets/contact.png |
25 | new file mode 100644 |
26 | index 0000000..9783bf0 |
27 | Binary files /dev/null and b/assets/contact.png differ |
28 | diff --git a/assets/favicon.ico b/assets/favicon.ico |
29 | new file mode 100644 |
30 | index 0000000..59a4e28 |
31 | Binary files /dev/null and b/assets/favicon.ico differ |
32 | diff --git a/assets/ks_stylized.png b/assets/ks_stylized.png |
33 | new file mode 100644 |
34 | index 0000000..0123974 |
35 | Binary files /dev/null and b/assets/ks_stylized.png differ |
36 | diff --git a/build.py b/build.py |
37 | new file mode 100755 |
38 | index 0000000..a15b4c8 |
39 | --- /dev/null |
40 | +++ b/build.py |
41 | @@ -0,0 +1,80 @@ |
42 | + #!/usr/bin/env python |
43 | + |
44 | + import pathlib |
45 | + import subprocess |
46 | + |
47 | + import yaml |
48 | + |
49 | + import ninja_syntax |
50 | + |
51 | + |
52 | + def _nth(n): |
53 | + return f"$$(echo $in | cut -d' ' -f{n})" |
54 | + |
55 | + |
56 | + def _render_rule(w: ninja_syntax.Writer, name, link): |
57 | + w.rule(f"render_{name}", " ".join([ |
58 | + "./render.py", |
59 | + "-stylesheet", |
60 | + "gen/main.css", |
61 | + "-template", |
62 | + _nth(1), |
63 | + "-sitemap", |
64 | + "sitemap.yaml", |
65 | + "-link", link, |
66 | + "-content", _nth(2) + "| htmlmin > $out", |
67 | + ])) |
68 | + return f"render_{name}" |
69 | + |
70 | + |
71 | + def _compile_sm(w: ninja_syntax.Writer, entries: list, path: pathlib.Path): |
72 | + entries = list(filter( |
73 | + lambda entry: "enabled" not in entry or entry["enabled"], entries)) |
74 | + for entry in entries: |
75 | + next_path = path.joinpath(entry["name"]) |
76 | + if "others" in entry and entry["others"]: # its a dir |
77 | + w.build(str(next_path), "mkdir") |
78 | + _compile_sm(w, entry["others"], next_path) |
79 | + else: |
80 | + source = list(next_path.glob("README.md")) |
81 | + if len(source) == 1: |
82 | + source = str(source[0]) |
83 | + link = source.replace("content/", "", 1) |
84 | + link = source.replace("/README.md", "", 1) |
85 | + target = source.replace("content/", "", 1) |
86 | + target_dir = target.replace("/README.md", "", 1) |
87 | + w.build(target_dir, "mkdir") |
88 | + target = target.replace("README.md", "index.html", 1) |
89 | + target = "gen/" + target |
90 | + cmd = _render_rule(w, source.replace("/", "_", -1), link) |
91 | + w.build(target, cmd, inputs=[ |
92 | + "index.jinja", source, "sitemap.yaml", "render.py"]) |
93 | + |
94 | + |
95 | + if __name__ == "__main__": |
96 | + with open("sitemap.yaml", "r") as fp: |
97 | + sitemap = yaml.safe_load(fp.read()) |
98 | + with open("build.ninja", "w") as fp: |
99 | + w = ninja_syntax.Writer(fp) |
100 | + w.comment("automatically generated, do not edit.") |
101 | + w.variable("sass_flags", "--style compressed") |
102 | + w.rule("mkdir", command="mkdir -p $out") |
103 | + w.rule("compile_sass", command="sassc $sass_flags $in $out") |
104 | + w.rule("make_qr_code", command="cat $in | qrencode -o $out") |
105 | + w.rule("copy", command="cp $in $out") |
106 | + w.build("index.jinja", "phony") |
107 | + w.build("render.py", "phony") |
108 | + w.build("sitemap.yaml", "phony") |
109 | + w.build("gen", "mkdir") |
110 | + w.build("gen/contact.png", "make_qr_code", inputs=["contact.txt"]) |
111 | + w.build("gen/ks_stylized.png", "copy", inputs=["assets/ks_stylized.png"]) |
112 | + w.build("gen/favicon.ico", "copy", inputs=["assets/favicon.ico"]) |
113 | + w.build("gen/main.css", "compile_sass", inputs=["main.scss"]) |
114 | + cmd = _render_rule(w, "index", "/") |
115 | + w.build("gen/index.html", cmd, inputs=[ |
116 | + "index.jinja", "content/index.md", "sitemap.yaml", "render.py"]) |
117 | + if sitemap["enabled"]: |
118 | + _compile_sm(w, sitemap["entries"], pathlib.Path("./content")) |
119 | + w.close() |
120 | + |
121 | + subprocess.call(args=["ninja"]) |
122 | diff --git a/contact.txt b/contact.txt |
123 | new file mode 100644 |
124 | index 0000000..9db14db |
125 | --- /dev/null |
126 | +++ b/contact.txt |
127 | @@ -0,0 +1,4 @@ |
128 | + Email: me@kevinschoon.com |
129 | + Software: https://ayllu-forge.org |
130 | + |
131 | + PGP Fingerprint: 9B3E 2D8C 5D17 6C1F 4979 1264 C9E7 4F0B C441 5118 |
132 | diff --git a/content/blog/building-this-website/README.md b/content/blog/building-this-website/README.md |
133 | new file mode 100644 |
134 | index 0000000..55af1a0 |
135 | --- /dev/null |
136 | +++ b/content/blog/building-this-website/README.md |
137 | @@ -0,0 +1,176 @@ |
138 | + # Building This Website |
139 | + |
140 | + There is no deficiency of static website generators these days and so naturally |
141 | + I had to write my own. |
142 | + |
143 | + ## Overview |
144 | + |
145 | + The main functionality of the tool will be encapsulated in a Python script |
146 | + called `render.py`. Even though |
147 | + [string based templates are the wrong solution](https://www.devever.net/~hl/stringtemplates#narrow) |
148 | + we're going to use a combination of |
149 | + [Jinja](https://jinja.palletsprojects.com/) and markdown to generate HTML |
150 | + output. Each page will be generated by a single call to the script which will |
151 | + allow the whole thing to be ran in parallel across multiple CPUs by wiring |
152 | + it together using [ninja](https://ninja-build.org/). |
153 | + |
154 | + Here you can see an example invocation of the `render.py` script with some |
155 | + annotated flags. |
156 | + |
157 | + ``` |
158 | + ./render.py \ |
159 | + # the HTML template file |
160 | + --template=index.jinja \ |
161 | + # this contains a map of each webpage which is available in the renderer |
162 | + # as well as page level configuration options. |
163 | + --sitemap sitemap.yaml \ |
164 | + # markdown file, this one contains the text I'm currently typing! |
165 | + --content=blog/building-this-website/README.md \ |
166 | + # output of the rendered HTML page |
167 | + gen/blog/building-this-website/index.html |
168 | + ``` |
169 | + |
170 | + ### Builtin Preview Server |
171 | + |
172 | + In "production" we'll serve all of the static content of an Nginx server but |
173 | + locally we want a way to browse the current state. This will be really simple, |
174 | + just add a Make target that starts up Python's builtin |
175 | + [http.server](https://docs.python.org/3/library/http.server.html#module-http.server). |
176 | + |
177 | + ``` |
178 | + BIND_ARGS := 127.0.0.1 9000 |
179 | + |
180 | + serve: |
181 | + python -m http.server --bind ${BIND_ARGS} --directory gen |
182 | + ``` |
183 | + |
184 | + ### Templating |
185 | + |
186 | + Each page and it's content will be written as a markdown file that can include |
187 | + Jinja syntax. |
188 | + |
189 | + Python + Markdown + Jinja |
190 | + |
191 | + ### Hierarchical Sitemap |
192 | + |
193 | + I want to generate a "tree" sitemap similar to a file browser like NERDTree. |
194 | + We'll pass in a YAML document to the `render.py` script and then transform |
195 | + it into an XML tree. |
196 | + |
197 | + ``` |
198 | + def _load_sitemap(path, current_path): |
199 | + by_name = dict() |
200 | + flattened = [] |
201 | + with open(path, "r") as fp: |
202 | + links = yaml.safe_load(fp.read()) |
203 | + |
204 | + def _link(links, parent=None): |
205 | + for link in links: |
206 | + flattened.append(link) |
207 | + link["parent"] = parent |
208 | + if "others" in link: |
209 | + _link(link["others"], parent=link) |
210 | + |
211 | + def _make_url(link): |
212 | + path = [link["name"]] |
213 | + parent = link["parent"] |
214 | + while parent is not None: |
215 | + path.append(parent["name"]) |
216 | + parent = parent["parent"] |
217 | + path.reverse() |
218 | + return "/".join(path) |
219 | + |
220 | + _link(links, parent=None) |
221 | + |
222 | + for link in flattened: |
223 | + target = "/" + _make_url(link) |
224 | + if target == current_path: |
225 | + link["active"] = True |
226 | + else: |
227 | + link["active"] = False |
228 | + link["url"] = target |
229 | + by_name[target] = link |
230 | + |
231 | + return dict(by_name=by_name, links=links) |
232 | + |
233 | + |
234 | + def _make_tree(links): |
235 | + def _populate(root, links): |
236 | + for link in links: |
237 | + elm = ET.Element("li") |
238 | + if "directory" in link and link["directory"]: |
239 | + elm.text = link["name"] |
240 | + else: |
241 | + lref = ET.Element("a", href=link["url"]) |
242 | + lref.text = link["name"] |
243 | + if link["active"]: |
244 | + lref.text = lref.text + " <" |
245 | + elm.append(lref) |
246 | + if "others" in link: |
247 | + others = link["others"] |
248 | + ul = ET.Element("ul") |
249 | + elm.append(ul) |
250 | + _populate(root=ul, links=others) |
251 | + root.append(elm) |
252 | + |
253 | + params = {"class": "tree"} |
254 | + |
255 | + root = ET.Element("ul", **params) |
256 | + _populate(root=root, links=links) |
257 | + return ET.tostring(root).decode() |
258 | + ``` |
259 | + |
260 | + ### Dithering Engine |
261 | + |
262 | + Images can be reduced in size with <a href="https://en.wikipedia.org/wiki/Dither">dithering algorithms</a> |
263 | + and they also look very cool. The idea for this was inspired by <a href="https://solar.lowtechmagazine.com/">LOW←TECH MAGAZINE</a>. |
264 | + |
265 | + ### Spell & Grammar Checking |
266 | + |
267 | + I can implement some basic spell checking against the website with [aspell](http://aspell.net/). |
268 | + If I was really ambitious (I'm not) it could be integrated into some kind of |
269 | + automated testing. For now a simple Make target should suffice. |
270 | + |
271 | + ``` |
272 | + spell: |
273 | + find content -name '*.md' -exec aspell --mode=markdown check {} \; |
274 | + ``` |
275 | + |
276 | + ### Filesystem "Watch" Support |
277 | + |
278 | + We'll use the native [inotify](https://en.wikipedia.org/wiki/Inotify) via the |
279 | + [inotifywatch](https://man7.org/linux/man-pages/man1/inotifywatch.1.html) tool |
280 | + which is available on all Linux distributions. |
281 | + |
282 | + ```sh |
283 | + #!/bin/sh |
284 | + |
285 | + # the actual command we want to run each time any file changes in the project |
286 | + BUILD_CMD="$@" |
287 | + |
288 | + # some pretty colorized output |
289 | + _do_build() { |
290 | + $BUILD_CMD && { |
291 | + echo -e "\033[32;1;4mSuccess\033[0m" |
292 | + } || { |
293 | + echo -e "\033[31;1;4mFailure\033[0m" |
294 | + } |
295 | + } |
296 | + |
297 | + # build the project the first time you start up the watch |
298 | + _do_build |
299 | + |
300 | + # watch all the files in the project except for the output directory |
301 | + find . -type d -not -path './gen*' -printf '%p ' | xargs inotifywait \ |
302 | + -m -e close_write \ |
303 | + --format %e/%f | |
304 | + while IFS=/ read -r events file; do |
305 | + echo "file $file modified, rebuilding" |
306 | + _do_build |
307 | + done |
308 | + ``` |
309 | + |
310 | + Now we can test the script by launching it `scripts/watch.sh build.py`. |
311 | + |
312 | + You can checkout the source for the whole site |
313 | + [here](https://ayllu-forge.org/web/kevinschoon-dot-com). |
314 | diff --git a/content/index.md b/content/index.md |
315 | new file mode 100644 |
316 | index 0000000..7e6a726 |
317 | --- /dev/null |
318 | +++ b/content/index.md |
319 | @@ -0,0 +1,15 @@ |
320 | + <a href="/"><img alt="dithered photo of me" class="me" src="/ks_stylized.png"></a> |
321 | + # Hi, thanks for stopping by! |
322 | + I'm a programmer interested in free software. |
323 | + {%- if sitemap -%} |
324 | + ### Sitemap |
325 | + {{ tree }} |
326 | + {%- endif -%} |
327 | |
328 | + You can contact me at [me@kevinschoon.com](mailto:me@kevinschoon.com). |
329 | + ### Software |
330 | + Check out some of my past and present projects [here](https://ayllu-forge.org/browse). |
331 | + ### Contact (QR) |
332 | + Contact info (same as above, but a QR code): |
333 | + |
334 | + <img alt="qr code" class="contact" src="/contact.png"/> |
335 | diff --git a/content/projects/air-quality-torino/README.md b/content/projects/air-quality-torino/README.md |
336 | new file mode 100644 |
337 | index 0000000..2306a92 |
338 | --- /dev/null |
339 | +++ b/content/projects/air-quality-torino/README.md |
340 | @@ -0,0 +1,44 @@ |
341 | + # Torino Air Quality |
342 | + |
343 | + Northern Italy is home to some of the worst air pollution in Europe due to |
344 | + factors such as it's rapid industrialization in the 20th century, pollution |
345 | + from automobiles, and it's lack of wind due to the region's position beside |
346 | + the Alps. [1](#ref) [2](#ref) [3](#ref) |
347 | + |
348 | + ## Real Time Data |
349 | + |
350 | + Below is real time data that is collected from an [Air Gradient](https://www.airgradient.com/) |
351 | + air quality monitor. It is positioned outside my apartment in the Vanchiglietta |
352 | + neighborhood of Torino, Italy. |
353 | + |
354 | + <iframe width="425" height="350" src="https://www.openstreetmap.org/export/embed.html?bbox=7.695815563201904%2C45.05788017528605%2C7.731091976165772%2C45.086717679634475&layer=mapnik" style="border: 1px solid black"></iframe><br/><small><a href="https://www.openstreetmap.org/#map=16/45.07230/7.71345">View Larger Map</a></small> |
355 | + |
356 | + ### Particulate Matter |
357 | + |
358 | + #### EU Index Guidelines |
359 | + |
360 | + | Index | Good | Fair | Moderate | Poor | Very poor | Extremely poor | |
361 | + |---------------------------|------|-------|----------|--------|------------|-----------------| |
362 | + | PM2,5 | 0–10 μg/m³ | 10–20 μg/m³ | 20–25 μg/m³ | 25–50 μg/m³ | 50–75 μg/m³ | 75–800 μg/m³ | |
363 | + | PM10 | 0–20 μg/m³ | 20–40 μg/m³ | 40–50 μg/m³ | 50–100 μg/m³ | 100–150 μg/m³ | 150–1200 μg/m³ | |
364 | + |
365 | + <img src="http://127.0.0.1:8142/chart.svg?query=%7B__name__%3D~%22airgradient_(pm1_ugm3%7Cpm2d5_ugm3%7Cpm10_ugm3)%22%7D&label=%7B%7B.__name__%7D%7D&width=8&height=3&background-color=%23333333&foreground-color=%23e4e4e4&title=Particulates" /> |
366 | + |
367 | + ### Nitrous Oxide (NOx) |
368 | + |
369 | + <img src="http://127.0.0.1:8142/chart.svg?query=airgradient_nox_raw&label=%7B%7B.__name__%7D%7D&width=8&height=3&background-color=%23333333&foreground-color=%23e4e4e4&title=NOx" /> |
370 | + |
371 | + ### CO2 |
372 | + |
373 | + <img src="http://127.0.0.1:8142/chart.svg?query=airgradient_co2_ppm&label=%7B%7B.__name__%7D%7D&width=8&height=3&background-color=%23333333&foreground-color=%23e4e4e4&title=CO2" /> |
374 | + |
375 | + ### Temperature & Humidity |
376 | + |
377 | + <img src="http://127.0.0.1:8142/chart.svg?query=%7B__name__%3D~%22(airgradient_humidity_percent%7Cairgradient_temperature_celsius)%22%7D&width=8&height=3&label=%7B%7B.__name__%7D%7D&background-color=%23333333&foreground-color=%23e4e4e4&min=0&title=Temperature" /> |
378 | + |
379 | + |
380 | + <h2 id="ref"> Refs </h2> |
381 | + |
382 | + 1. [Northern Italy Pollution (Wikipedia)](https://en.wikipedia.org/wiki/Northern_Italy#Pollution) |
383 | + 2. [Nitrogen Dioxide over northern Italy](https://www.esa.int/ESA_Multimedia/Images/2019/05/Nitrogen_dioxide_over_northern_Italy) |
384 | + 3. [Air Pollution in Europe](https://aqli.epic.uchicago.edu/news/air-pollution-hotspots-in-europe/) |
385 | diff --git a/index.jinja b/index.jinja |
386 | new file mode 100644 |
387 | index 0000000..3ae4697 |
388 | --- /dev/null |
389 | +++ b/index.jinja |
390 | @@ -0,0 +1,34 @@ |
391 | + <!DOCTYPE html> |
392 | + <html lang="en"> |
393 | + <head> |
394 | + <title>kevinschoon-dot-com</title> |
395 | + <meta charset="UTF-8"> |
396 | + <meta name="viewport" content="width=device-width, initial-scale=1"> |
397 | + <link rel="stylesheet" href="/main.css"/> |
398 | + </head> |
399 | + <body> |
400 | + <section> |
401 | + <div class="wrapper"> |
402 | + <header class="page-header"> |
403 | + {% if not link == "/" %}<a href="/">Home</a>{% endif %} |
404 | + </header> |
405 | + <main class="page-body"> |
406 | + {{ content }} |
407 | + </main> |
408 | + <footer class="page-footer"></footer> |
409 | + </div> |
410 | + </section> |
411 | + </body> |
412 | + {% if devmode %} |
413 | + <script> |
414 | + const socket = new WebSocket("ws://localhost:8080/ws") |
415 | + socket.addEventListener("open", (event) => { |
416 | + console.log("connected to server", event); |
417 | + }); |
418 | + socket.addEventListener("message", (event) => { |
419 | + console.log("reloading page", event); |
420 | + window.location.reload(); |
421 | + }) |
422 | + </script> |
423 | + {% endif %} |
424 | + </html> |
425 | diff --git a/main.scss b/main.scss |
426 | new file mode 100644 |
427 | index 0000000..4b6e8d0 |
428 | --- /dev/null |
429 | +++ b/main.scss |
430 | @@ -0,0 +1,187 @@ |
431 | + :root { |
432 | + --accent: #b1675d; |
433 | + --light-gray: #eee; |
434 | + --dark-gray: #c2c2c2; |
435 | + --white: #f8f9fa; |
436 | + --black: #2d2d2d; |
437 | + --border-width: 5px; |
438 | + } |
439 | + |
440 | + table { |
441 | + text-align: justify; |
442 | + width: 100%; |
443 | + border-collapse: collapse; |
444 | + margin-bottom: 2rem; |
445 | + } |
446 | + |
447 | + td, th { |
448 | + padding: 0.5em; |
449 | + border-bottom: 1px solid #4a4a4a; |
450 | + } |
451 | + |
452 | + html, body { |
453 | + box-sizing: border-box; |
454 | + font-size: 16px; |
455 | + /*font-family: monospace;*/ |
456 | + height: 100%; |
457 | + margin: 0; |
458 | + padding: 0; |
459 | + max-width: 800px; |
460 | + } |
461 | + |
462 | + section { |
463 | + height: 100%; |
464 | + } |
465 | + |
466 | + .wrapper { |
467 | + min-height: 100%; |
468 | + display: grid; |
469 | + grid-template-rows: auto 1fr auto; |
470 | + } |
471 | + |
472 | + .page-body { |
473 | + padding: 20px; |
474 | + } |
475 | + |
476 | + footer.page-footer { |
477 | + } |
478 | + |
479 | + *, |
480 | + *:before, |
481 | + *:after { |
482 | + box-sizing: inherit; |
483 | + } |
484 | + |
485 | + ol, |
486 | + ul { |
487 | + list-style: none; |
488 | + } |
489 | + |
490 | + p { |
491 | + padding: 1px; |
492 | + margin: 10px; |
493 | + } |
494 | + |
495 | + span { |
496 | + background: white; |
497 | + border: 1px solid black; |
498 | + } |
499 | + |
500 | + pre { |
501 | + background: #e2e2e2; |
502 | + font-family: monospace; |
503 | + } |
504 | + |
505 | + img.me { |
506 | + border-radius: 2em; |
507 | + max-width: 350px; |
508 | + } |
509 | + |
510 | + @media (prefers-color-scheme: dark) { |
511 | + :root { |
512 | + background-color: #2c2c2c; |
513 | + color: white; |
514 | + } |
515 | + |
516 | + pre { |
517 | + color: white; |
518 | + background: #3f3f3f; |
519 | + } |
520 | + |
521 | + a:link { |
522 | + color: white; |
523 | + text-decoration: underline; |
524 | + } |
525 | + |
526 | + a:visited { |
527 | + color: white; |
528 | + text-decoration: none; |
529 | + } |
530 | + |
531 | + .notice { |
532 | + border-color: white; |
533 | + } |
534 | + } |
535 | + |
536 | + @media (prefers-color-scheme: light) { |
537 | + :root { |
538 | + background-color: #f5f5f5; |
539 | + color: black; |
540 | + } |
541 | + |
542 | + pre { |
543 | + color: black; |
544 | + background: #e2e2e2; |
545 | + } |
546 | + |
547 | + a:link { |
548 | + color: black; |
549 | + text-decoration: underline; |
550 | + } |
551 | + |
552 | + a:visited { |
553 | + color: black; |
554 | + text-decoration: none; |
555 | + } |
556 | + |
557 | + .notice { |
558 | + border-color: black; |
559 | + } |
560 | + } |
561 | + |
562 | + |
563 | + |
564 | + /* |
565 | + Tree structure using CSS: |
566 | + http://stackoverflow.com/questions/14922247/how-to-get-a-tree-in-html-using-pure-css |
567 | + */ |
568 | + |
569 | + .tree, |
570 | + .tree ul { |
571 | + font-size: 18px; |
572 | + font: normal normal 14px/20px Helvetica, Arial, sans-serif; |
573 | + list-style-type: none; |
574 | + margin-left: 0 0 0 10px; |
575 | + padding: 0; |
576 | + position: relative; |
577 | + overflow: hidden; |
578 | + } |
579 | + |
580 | + .tree li { |
581 | + margin: 0; |
582 | + padding: 0 12px; |
583 | + position: relative; |
584 | + } |
585 | + |
586 | + .tree li::before, |
587 | + .tree li::after { |
588 | + content: ""; |
589 | + position: absolute; |
590 | + left: 0; |
591 | + } |
592 | + |
593 | + /* horizontal line on inner list items */ |
594 | + .tree li::before { |
595 | + border-top: 2px solid #999; |
596 | + top: 10px; |
597 | + width: 10px; |
598 | + height: 0; |
599 | + } |
600 | + |
601 | + /* vertical line on list items */ |
602 | + .tree li:after { |
603 | + border-left: 2px solid #999; |
604 | + height: 100%; |
605 | + width: 0px; |
606 | + top: -10px; |
607 | + } |
608 | + |
609 | + /* lower line on list items from the first level because they don't have parents */ |
610 | + .tree > li::after { |
611 | + top: 10px; |
612 | + } |
613 | + |
614 | + /* hide line from the last of the first level list items */ |
615 | + .tree > li:last-child::after { |
616 | + display: none; |
617 | + } |
618 | diff --git a/render.py b/render.py |
619 | new file mode 100755 |
620 | index 0000000..e746f49 |
621 | --- /dev/null |
622 | +++ b/render.py |
623 | @@ -0,0 +1,154 @@ |
624 | + #!/usr/bin/env python |
625 | + import argparse |
626 | + import os |
627 | + import sys |
628 | + import xml.etree.ElementTree as ET |
629 | + |
630 | + from datetime import datetime |
631 | + |
632 | + import markdown |
633 | + import yaml |
634 | + from jinja2 import Environment, FunctionLoader |
635 | + |
636 | + |
637 | + def _load_sitemap(path, current_path): |
638 | + by_name = dict() |
639 | + flattened = [] |
640 | + with open(path, "r") as fp: |
641 | + _sitemap = yaml.safe_load(fp.read()) |
642 | + |
643 | + if not _sitemap["enabled"]: |
644 | + return None |
645 | + |
646 | + def _filter_entries(entries): |
647 | + entries = list( |
648 | + filter(lambda entry: "enabled" |
649 | + not in entry or entry["enabled"] is True, entries)) |
650 | + for entry in entries: |
651 | + if "others" in entry: |
652 | + entry["others"] = _filter_entries(entry["others"]) |
653 | + return entries |
654 | + |
655 | + entries = _filter_entries(_sitemap["entries"]) |
656 | + |
657 | + def _link(entries, parent=None): |
658 | + for entry in entries: |
659 | + flattened.append(entry) |
660 | + entry["parent"] = parent |
661 | + if "others" in entry: |
662 | + _link(entry["others"], parent=entry) |
663 | + |
664 | + def _make_url(link): |
665 | + path = [link["name"]] |
666 | + parent = link["parent"] |
667 | + while parent is not None: |
668 | + path.append(parent["name"]) |
669 | + parent = parent["parent"] |
670 | + path.reverse() |
671 | + return "/".join(path) |
672 | + |
673 | + _link(entries, parent=None) |
674 | + |
675 | + for entry in flattened: |
676 | + target = "/" + _make_url(entry) |
677 | + if target == current_path: |
678 | + entry["active"] = True |
679 | + else: |
680 | + entry["active"] = False |
681 | + entry["url"] = target |
682 | + by_name[target] = entry |
683 | + |
684 | + return dict(by_name=by_name, links=entries) |
685 | + |
686 | + |
687 | + def _make_tree(links): |
688 | + def _populate(root, links): |
689 | + for link in links: |
690 | + elm = ET.Element("li") |
691 | + if "directory" in link and link["directory"]: |
692 | + elm.text = link["name"] |
693 | + else: |
694 | + lref = ET.Element("a", href=link["url"]) |
695 | + link_text = link["name"] |
696 | + if "date" in link: |
697 | + link_text = link_text + " [" + str(link["date"]) + "]" |
698 | + lref.text = link_text |
699 | + if link["active"]: |
700 | + lref.text = lref.text + " <" |
701 | + elm.append(lref) |
702 | + if "others" in link: |
703 | + others = link["others"] |
704 | + ul = ET.Element("ul") |
705 | + elm.append(ul) |
706 | + _populate(root=ul, links=others) |
707 | + root.append(elm) |
708 | + |
709 | + params = {"class": "tree"} |
710 | + |
711 | + root = ET.Element("ul", **params) |
712 | + _populate(root=root, links=links) |
713 | + return ET.tostring(root).decode() |
714 | + |
715 | + |
716 | + def generate(template, content_path, stylesheet, link, sitemap, devmode=False): |
717 | + sm, tree = None, None |
718 | + if sitemap: |
719 | + sm = _load_sitemap(sitemap, current_path=link) |
720 | + if sm: |
721 | + tree = _make_tree(sm["links"]) |
722 | + with open(template, "r") as fp: |
723 | + template = fp.read() |
724 | + env = Environment(loader=FunctionLoader(lambda name: template)) |
725 | + base = env.get_template(template) |
726 | + with open(stylesheet) as fp: |
727 | + style = fp.read() |
728 | + with open(content_path) as fp: |
729 | + text = fp.read() |
730 | + content = markdown.markdown( |
731 | + text, |
732 | + extensions=["fenced_code", "tables"], |
733 | + ) |
734 | + env_content = Environment(loader=FunctionLoader(lambda name: content)) |
735 | + out = base.render({ |
736 | + "content": env_content.get_template("").render({ |
737 | + "sitemap": sm, |
738 | + "tree": tree, |
739 | + }), |
740 | + "is_index": False, |
741 | + "link": link, |
742 | + "style": style, |
743 | + "sitemap": sm, |
744 | + "tree": tree, |
745 | + "devmode": devmode, |
746 | + "current_time": datetime.now(), |
747 | + }) |
748 | + print(out) |
749 | + |
750 | + |
751 | + if __name__ == "__main__": |
752 | + parser = argparse.ArgumentParser(description="static renderer") |
753 | + parser.add_argument("-content", |
754 | + required=True, |
755 | + help="markdown file with content") |
756 | + parser.add_argument("-template", |
757 | + default="index.jinja", |
758 | + help="jinja template file") |
759 | + parser.add_argument("-stylesheet", |
760 | + default="./assets/main.css", |
761 | + help="style sheet") |
762 | + parser.add_argument("-sitemap", |
763 | + help="sitemap") |
764 | + parser.add_argument("-link", |
765 | + default="/", |
766 | + help="current url") |
767 | + parser.add_argument("-devmode", |
768 | + action="store_true", |
769 | + help="developer mode (websocket page refresh)") |
770 | + args = parser.parse_args() |
771 | + generate( |
772 | + template=args.template, |
773 | + content_path=args.content, |
774 | + stylesheet=args.stylesheet, |
775 | + link=args.link, |
776 | + sitemap=args.sitemap, |
777 | + devmode=args.devmode) |
778 | diff --git a/scripts/spellcheck b/scripts/spellcheck |
779 | new file mode 100755 |
780 | index 0000000..c6a234d |
781 | --- /dev/null |
782 | +++ b/scripts/spellcheck |
783 | @@ -0,0 +1,3 @@ |
784 | + #!/bin/sh |
785 | + |
786 | + find content -name '*.md' -exec aspell --mode=markdown check {} \; |
787 | diff --git a/scripts/watch.sh b/scripts/watch.sh |
788 | new file mode 100755 |
789 | index 0000000..f3ea585 |
790 | --- /dev/null |
791 | +++ b/scripts/watch.sh |
792 | @@ -0,0 +1,55 @@ |
793 | + #!/bin/sh |
794 | + |
795 | + # the actual command we want to run each time any file changes in the project |
796 | + BUILD_CMD="$@" |
797 | + |
798 | + ./server.py & |
799 | + |
800 | + server_pid="$!" |
801 | + |
802 | + cleanup() { |
803 | + echo "killing server" |
804 | + kill -TERM "$server_pid" |
805 | + } |
806 | + |
807 | + _do_reload() { |
808 | + curl -d '' http://localhost:8888/reload |
809 | + } |
810 | + |
811 | + # some pretty colorized output |
812 | + _do_build() { |
813 | + $BUILD_CMD && { |
814 | + echo -e "\033[32;1;4mSuccess\033[0m" |
815 | + _do_reload |
816 | + } || { |
817 | + echo -e "\033[31;1;4mFailure\033[0m" |
818 | + } |
819 | + } |
820 | + |
821 | + echo "server pid is $server_pid" |
822 | + |
823 | + trap 'cleanup' INT |
824 | + |
825 | + # build the project the first time you start up the watch |
826 | + $BUILD_CMD |
827 | + |
828 | + # watch all the files in the project except for the output directory |
829 | + find . -type d -not -path '*gen*' -not -path '*.git*' -print | \ |
830 | + xargs inotifywait -q -m -e close_write --format %e/%f | |
831 | + while IFS=/ read -r events file; do |
832 | + if [[ "$file" == "4913" ]] ; then |
833 | + # magical vim test file! |
834 | + continue |
835 | + fi |
836 | + [[ "$file" == "build.ninja" ]] && { |
837 | + continue |
838 | + } |
839 | + [[ "$file" == ".ninja_log" ]] && { |
840 | + continue |
841 | + } |
842 | + [[ "$file" == ".ninja_deps" ]] && { |
843 | + continue |
844 | + } |
845 | + echo "$file modified ($event)" |
846 | + _do_build |
847 | + done |
848 | diff --git a/server.py b/server.py |
849 | new file mode 100755 |
850 | index 0000000..0f6784c |
851 | --- /dev/null |
852 | +++ b/server.py |
853 | @@ -0,0 +1,57 @@ |
854 | + #!/usr/bin/env python |
855 | + import sys |
856 | + import os |
857 | + import signal |
858 | + import pathlib |
859 | + import contextvars |
860 | + |
861 | + from aiohttp import web |
862 | + |
863 | + |
864 | + _ws = "_websockets" |
865 | + |
866 | + |
867 | + async def websocket(request): |
868 | + ws = web.WebSocketResponse() |
869 | + await ws.prepare(request) |
870 | + request.app[_ws].append(ws) |
871 | + try: |
872 | + async for msg in ws: |
873 | + print("echo: ", msg.data) |
874 | + ws.send_str(msg.data) |
875 | + finally: |
876 | + request.app[_ws].pop() |
877 | + return ws |
878 | + |
879 | + |
880 | + async def reloader(request): |
881 | + for socket in request.app[_ws]: |
882 | + await socket.send_str("reload") |
883 | + return web.Response() |
884 | + |
885 | + |
886 | + def load_middleware(static_path="gen"): |
887 | + async def serve_indexes(request: web.Request, handler): |
888 | + if request.path == "/ws": |
889 | + return await handler(request) |
890 | + if request.path == "/reload": |
891 | + return await handler(request) |
892 | + relative_file_path = pathlib.Path(request.path).relative_to('/') |
893 | + file_path = static_path / relative_file_path |
894 | + if not file_path.exists(): |
895 | + return web.HTTPNotFound() |
896 | + if file_path.is_dir(): |
897 | + file_path /= "index.html" |
898 | + if not file_path.exists(): |
899 | + return web.HTTPNotFound() |
900 | + return web.FileResponse(file_path) |
901 | + return [web.middleware(serve_indexes)] |
902 | + |
903 | + |
904 | + app = web.Application(middlewares=load_middleware(static_path="gen")) |
905 | + app.add_routes([web.get("/ws", websocket)]) |
906 | + app.add_routes([web.post("/reload", reloader)]) |
907 | + app.add_routes([web.static("/", "gen", show_index=False)]) |
908 | + app[_ws] = [] |
909 | + |
910 | + web.run_app(app, port=8888) |
911 | diff --git a/sitemap.yaml b/sitemap.yaml |
912 | new file mode 100644 |
913 | index 0000000..e772827 |
914 | --- /dev/null |
915 | +++ b/sitemap.yaml |
916 | @@ -0,0 +1,11 @@ |
917 | + enabled: true |
918 | + entries: |
919 | + - name: blog |
920 | + enabled: true |
921 | + others: |
922 | + - name: building-this-website |
923 | + date: 2023-04-04 |
924 | + - name: projects |
925 | + enabled: true |
926 | + others: |
927 | + - name: air-quality-torino |