Author:
Hash:
Timestamp:
+831 -0 +/-17 browse
Kevin Schoon [me@kevinschoon.com]
2c1502f0a2bbb47893ba6c85d7c2408f91c482fb
Sat, 24 Aug 2024 10:40:29 +0000 (1.2 years ago)
| 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 |