Commit
Author: Kevin Schoon [me@kevinschoon.com]
Hash: 2c1502f0a2bbb47893ba6c85d7c2408f91c482fb
Timestamp: Sat, 24 Aug 2024 10:40:29 +0000 (2 months ago)

+831 -0 +/-17 browse
init
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 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
12new file mode 100644
13index 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
25new file mode 100644
26index 0000000..9783bf0
27 Binary files /dev/null and b/assets/contact.png differ
28 diff --git a/assets/favicon.ico b/assets/favicon.ico
29new file mode 100644
30index 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
33new file mode 100644
34index 0000000..0123974
35 Binary files /dev/null and b/assets/ks_stylized.png differ
36 diff --git a/build.py b/build.py
37new file mode 100755
38index 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
123new file mode 100644
124index 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
133new file mode 100644
134index 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
315new file mode 100644
316index 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+ ### Email
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
336new file mode 100644
337index 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&amp;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
386new file mode 100644
387index 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
426new file mode 100644
427index 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
619new file mode 100755
620index 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
779new file mode 100755
780index 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
788new file mode 100755
789index 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
849new file mode 100755
850index 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
912new file mode 100644
913index 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