~docbibi/memos

5d455402574fd6d84d2ef129f1f51587c917833c — Borjan Tchakaloff 11 months ago 1d1e4ec
Compile content into an HTML file
4 files changed, 247 insertions(+), 1 deletions(-)

M .build.yml
M memos/readthememo.app.toml
A readthememo.app/readthememo.css
A src/compile.py
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)