M .build.yml => .build.yml +3 -1
@@ 7,7 7,9 @@ environment:
site: readthememo.app
tasks:
- package: |
- cd $repo/$site
+ cd $repo
+ python src/compile.py memos/$site.toml > $site/index.html
+ cd $site
tar -cvz . > ~/site.tar.gz
- upload: |
hut pages publish -d $site site.tar.gz
M memos/readthememo.app.toml => memos/readthememo.app.toml +14 -0
@@ 120,3 120,17 @@ I don't often take much action to follow-up on these ideas. Today is different.
Today I am taking notes. Today I am actually doing something else than rambling
in my heads. Today it is on paper. (And now digital.)
"""
+
+
+[[articles]]
+date = 2023-10-29
+title = "Finally generated"
+content = """
+It took me a bit more than two hours of work but I came up with a simple engine.
+The data is read from a TOML file and spits out more-or-less the HTML I hand
+wrote.
+
+The real exercise here is to see if a single-file script can do what I want.
+I mean: no dependencies other than the standard library (Python 3.11 at the
+moment).
+"""
A readthememo.app/readthememo.css => readthememo.app/readthememo.css +75 -0
@@ 0,0 1,75 @@
+/* Amber Light scheme (Default) */
+/* Can be forced with data-theme="light" */
+[data-theme="light"],
+:root:not([data-theme="dark"]) {
+ --primary: #ffb300;
+ --primary-hover: #fb8c00;
+ --primary-focus: rgba(255, 179, 0, 0.125);
+ --primary-inverse: rgba(0, 0, 0, 0.75);
+}
+
+/* Amber Dark scheme (Auto) */
+/* Automatically enabled if user has Dark mode enabled */
+@media only screen and (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --primary: #ffb300;
+ --primary-hover: #fdd835;
+ --primary-focus: rgba(255, 179, 0, 0.25);
+ --primary-inverse: rgba(0, 0, 0, 0.75);
+ }
+}
+
+/* Amber Dark scheme (Forced) */
+/* Enabled if forced with data-theme="dark" */
+[data-theme="dark"] {
+ --primary: #ffb300;
+ --primary-hover: #fdd835;
+ --primary-focus: rgba(255, 179, 0, 0.25);
+ --primary-inverse: rgba(0, 0, 0, 0.75);
+}
+
+/* Amber (Common styles) */
+:root {
+ --form-element-active-border-color: var(--primary);
+ --form-element-focus-color: var(--primary-focus);
+ --switch-color: var(--primary-inverse);
+ --switch-checked-background-color: var(--primary);
+}
+
+a, a:hover, a:active, a:focus {
+ text-decoration: underline;
+}
+article header {
+ text-align: right;
+}
+article footer ul {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-left: calc(var(--spacing) * -0.5);
+ margin-bottom: 0;
+ padding: 0;
+ list-style: none;
+}
+article footer li {
+ display: inline-block;
+ margin: 0;
+ padding: var(--spacing) calc(var(--spacing) * 0.5);
+}
+article footer li + li:before {
+ content: '';
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0 calc(var(--spacing)) 0 0;
+ padding: 0;
+ width: 0.5ex;
+ height: 0.5ex;
+ border-radius: 50%;
+ background-color: currentColor;
+}
+article footer a {
+ display: inline-block;
+ margin: calc(var(--spacing) * -1) calc(var(--spacing) * -0.5);
+ padding: var(--spacing) calc(var(--spacing) * 0.5);
+ border-radius: var(--border-radius);
+}
A src/compile.py => src/compile.py +155 -0
@@ 0,0 1,155 @@
+#!/usr/bin/env python
+import argparse
+import datetime
+import pathlib
+import textwrap
+import tomllib
+from typing import NotRequired, TypedDict
+
+
+class Meta(TypedDict):
+ author: str
+
+
+class Header(TypedDict):
+ headline: str
+ moto: str
+
+
+class Article(TypedDict):
+ date: datetime.date
+ title: str
+ content: str
+ tags: NotRequired[list[str]]
+
+
+class Memos(TypedDict):
+ title: str
+ meta: Meta
+ header: Header
+ articles: list[Article]
+
+
+def main(*args: str) -> None:
+ assert len(args) >= 1, "At least one argument is needed: the program name."
+ parser = argparse.ArgumentParser(args[0])
+ parser.add_argument(
+ "source", type=pathlib.Path, help="The source memos (TOML).", nargs="?"
+ )
+ options = parser.parse_args(args[1:])
+ if options.source and options.source != "-":
+ with open(options.source, "rb") as fd:
+ memos = parse_memos(fd)
+ else:
+ memos = parse_memos(sys.stdin)
+ print(render(memos))
+
+
+def parse_memos(source) -> Memos:
+ return tomllib.load(source)
+
+
+def render(memos: Memos) -> str:
+ header = cleanup_tqs(render_header(memos))
+ main_content = cleanup_tqs(render_main_content(memos))
+ return cleanup_tqs(
+ """
+ <html lang="en" data-theme="dark">
+ <head>
+ <link rel="stylesheet" href="./pico-1.5.10.classless.min.css">
+ <link rel="stylesheet" href="./readthememo.css">
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="author" content="{author}">
+ <title>{title}</title>
+ </head>
+ <body>
+ {header}
+ {main}
+ </body>
+ </html>
+ """
+ ).format(
+ author=memos["meta"]["author"],
+ title=memos["title"],
+ header=textwrap.indent(header, " "),
+ main=textwrap.indent(main_content, " "),
+ )
+
+
+def render_header(memos: Memos) -> str:
+ bare_motos = (f"<div>{cleanup_tqs(moto)}</div>" for moto in memos["header"]["moto"])
+ motos = "\n".join(bare_motos)
+ return cleanup_tqs(
+ """
+ <header>
+ <h1 itemprop="headline">{headline}</h1>
+ {moto}
+ </header>
+ """
+ ).format(
+ headline=memos["header"]["headline"],
+ moto=textwrap.indent(motos, " "),
+ )
+
+
+def render_main_content(memos: Memos) -> str:
+ bare_articles = (
+ cleanup_tqs(render_article(article)) for article in reversed(memos["articles"])
+ )
+ articles = "\n".join(bare_articles)
+ return cleanup_tqs(
+ """
+ <main>
+ {articles}
+ </main>
+ """
+ ).format(
+ articles=textwrap.indent(articles, " "),
+ )
+
+
+def render_article(article: Article) -> str:
+ plain_wall_of_text = cleanup_tqs(article["content"])
+ paragraphs = plain_wall_of_text.split("\n\n")
+ content = "\n".join(f"<p>{paragraph}</p>" for paragraph in paragraphs)
+ tags = "".join(
+ f'<li><a href="#{tag}">#{tag}</a></li>' for tag in article.get("tags", ())
+ )
+ footer = f"\n<footer><ul>{tags}</ul></footer>" if tags else ""
+ return cleanup_tqs(
+ """
+ <article>
+ <header><time datetime="{date_iso}">{date_friendly}</time></header>
+ <h2>{title}</h2>
+ {content}
+ </article>
+ """
+ ).format(
+ date_iso=article["date"].isoformat(),
+ date_friendly=friendly_date(article["date"]),
+ title=article["title"],
+ content=textwrap.indent(content + footer, " "),
+ )
+
+
+def ordinal_suffix(n):
+ if 10 <= n <= 20:
+ return "th"
+ else:
+ return {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
+
+
+def friendly_date(date: datetime.date) -> str:
+ return date.strftime(f"%A, the %d{ordinal_suffix(date.day)} of %B, %Y")
+
+
+def cleanup_tqs(rendered: str) -> str:
+ """Clean up a triple-quoted string with potential indentation and extraneous newlines."""
+ return textwrap.dedent(rendered).strip("\n")
+
+
+if __name__ == "__main__":
+ import sys
+
+ main(*sys.argv)