diff options
| author | Alejandro W. Sior <aho@sior.be> | 2026-09-05 20:36:47 +0200 |
|---|---|---|
| committer | Alejandro W. Sior <aho@sior.be> | 2026-09-05 20:36:47 +0200 |
| commit | ce14ceb07252ebf5f3fce15681a272f2790c1da6 (patch) | |
| tree | 43486999bfa66df3d84cbd608d539bf4b9e99a2b /bin | |
compile: static site generator
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/compile | 225 |
1 files changed, 225 insertions, 0 deletions
diff --git a/bin/compile b/bin/compile new file mode 100755 index 0000000..6c6d067 --- /dev/null +++ b/bin/compile @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Build the site from flat Obsidian Markdown notes.""" + +from __future__ import annotations + +import datetime as dt +import html +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote +import markdown as md + + +ROOT = Path(__file__).resolve().parent.parent +POSTS = ROOT / "posts" +PAGES = ROOT / "pages" +BUILD = ROOT / "bld" + + +@dataclass +class Note: + path: Path + slug: str + title: str + display: str + order: int + link: str | None + date: dt.datetime | None + tags: list[str] + body: str + + +def clean_tag(tag: str) -> str: + return tag.strip().lstrip("#").replace("/", "-").replace(" ", "-").lower() + + +def parse_front_matter(text: str) -> tuple[dict[str, object], str]: + if not text.startswith("---\n"): + return {}, text + end = re.search(r"^---\s*$", text[4:], re.M) + if not end: + return {}, text + raw = text[4 : 4 + end.start()] + data: dict[str, object] = {} + for line in raw.splitlines(): + if not line or line.lstrip().startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip().strip("\"'") + if value.startswith("[") and value.endswith("]"): + data[key.strip().lower()] = [v.strip().strip("\"'") for v in value[1:-1].split(",") if v.strip()] + elif value: + data[key.strip().lower()] = value + return data, text[4 + end.end() :].lstrip("\n") + + +def parse_date(value: object) -> dt.datetime | None: + if not value: + return None + text = str(value).strip().replace("Z", "+00:00") + for pattern in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y/%m/%d %H:%M", "%Y/%m/%d"): + try: + return dt.datetime.strptime(text, pattern) + except ValueError: + pass + try: + return dt.datetime.fromisoformat(text) + except ValueError: + raise ValueError(f"Invalid date: {value!r}. Use YYYY-MM-DD or YYYY-MM-DD HH:MM.") + + +def parse_note(path: Path, post: bool = False) -> Note: + meta, body = parse_front_matter(path.read_text(encoding="utf-8")) + heading = re.search(r"^#\s+(.+?)\s*$", body, re.M) + title = str(meta.get("title") or (heading.group(1) if heading else path.stem.replace("-", " "))) + display = str(meta.get("display") or title) + order = int(meta.get("order") or 0) + link = str(meta.get("link")) if meta.get("link") else None + if heading and not meta.get("title"): + body = body[: heading.start()] + body[heading.end() :].lstrip("\n") + raw_tags = meta.get("tags", []) + if isinstance(raw_tags, str): + raw_tags = [piece.strip() for piece in raw_tags.split(",")] + tags = [clean_tag(str(tag)) for tag in raw_tags] + if post: + tags.extend(clean_tag(match) for match in re.findall(r"(?<![\w/])#([\w][\w/-]*)", body)) + tags = list(dict.fromkeys(tag for tag in tags if tag)) + return Note(path, path.stem, title, display, order, link, parse_date(meta.get("date")), tags, body) + + +def inline(text: str) -> str: + text = html.escape(text, quote=False) + text = re.sub(r"!\[([^]]*)\]\(([^ )]+)(?:\s+[^)]*)?\)", r'<img src="\2" alt="\1">', text) + text = re.sub(r"\[([^]]+)\]\(([^ )]+)(?:\s+[^)]*)?\)", r'<a href="\2">\1</a>', text) + text = re.sub(r"\[\[([^]|]+)(?:\|([^]]+))?\]\]", lambda m: f'<a href="{quote(m.group(1).replace(" ", "-") + ".html")}">{m.group(2) or m.group(1)}</a>', text) + text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text) + text = re.sub(r"\*\*([^*]+)\*\*|__([^_]+)__", lambda m: f"<strong>{m.group(1) or m.group(2)}</strong>", text) + text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)", lambda m: f"<em>{m.group(1) or m.group(2)}</em>", text) + return text + + +def markdown(text: str) -> str: + return md.markdown(text) + lines, output, paragraph, in_code, list_type = text.splitlines(), [], [], False, None + + def flush_paragraph() -> None: + nonlocal paragraph + if paragraph: + output.append(f"<p>{inline(' '.join(part.strip() for part in paragraph))}</p>") + paragraph = [] + + def close_list() -> None: + nonlocal list_type + if list_type: + output.append(f"</{list_type}>") + list_type = None + + for line in lines: + if line.startswith("```"): + flush_paragraph(); close_list() + output.append("</code></pre>" if in_code else "<pre><code>") + in_code = not in_code + elif in_code: + output.append(html.escape(line)) + elif not line.strip(): + flush_paragraph(); close_list() + elif match := re.match(r"^(#{1,6})\s+(.+)$", line): + flush_paragraph(); close_list() + level = len(match.group(1)); output.append(f"<h{level}>{inline(match.group(2))}</h{level}>") + elif line.startswith("> "): + flush_paragraph(); close_list(); output.append(f"<blockquote><p>{inline(line[2:])}</p></blockquote>") + elif line.strip() in ("---", "***", "___"): + flush_paragraph(); close_list(); output.append("<hr>") + elif match := re.match(r"^\s*([-+*]|\d+\.)\s+(.+)$", line): + flush_paragraph() + kind = "ol" if match.group(1)[0].isdigit() else "ul" + if list_type != kind: + close_list(); output.append(f"<{kind}>"); list_type = kind + output.append(f"<li>{inline(match.group(2))}</li>") + else: + paragraph.append(line) + flush_paragraph(); close_list() + return "\n".join(output) + + +def tag_links(tags: list[str], prefix: str = "") -> str: + return " ".join(f'<a class="tag" href="{prefix}tags/{quote(tag)}.html">#{html.escape(tag)}</a>' for tag in tags) + + +def page(title: str, content: str, nav: str = "") -> str: + return f"""<!doctype html> +<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{html.escape(title)}</title><link rel="stylesheet" href="{nav}style.css"></head> +<body><main>{content}</main></body></html>\n""" + + +def post_html(note: Note) -> str: + date = note.date.strftime("%Y/%m/%d %H:%M") if note.date else "Undated" + return page(note.title, f'<a class="back" href="index.html">โ index</a><article><header><h1>{html.escape(note.display)}</h1><p class="metadata">{date} {tag_links(note.tags)}</p></header>{markdown(note.body)}</article>') + + +def list_html(posts: list[Note], heading: str, static_pages: list[Note], tag: str | None = None) -> str: + rows = [] + for note in posts: + if tag is None or tag in note.tags: + date = note.date.strftime("%Y/%m/%d %H:%M") if note.date else "Undated" + prefix = "../" if tag else "" + tags = tag_links(note.tags, prefix) + rows.append(f'<li><time>{date}</time><a class="post-link" href="{prefix}{quote(note.slug)}.html">{html.escape(note.title)}</a><span class="tags">{tags}</span></li>') + extras = "" + if static_pages: + page_prefix = "../" if tag else "" + links = " ยท ".join(f'<a href="{note.link or f"{page_prefix}{quote(note.slug)}.html"}">{html.escape(note.title)}</a>' for note in static_pages) + extras = f'<nav class="pages">{links}</nav>' + back = '<a class="back" href="../index.html">โ index</a>' if tag else "" + empty = "<p class=\"empty\">No posts yet.</p>" if not rows else f"<ol class=\"post-list\">{''.join(rows)}</ol>" + return page(heading, f'{back}<header class="list-header"><h1>{html.escape(heading)}</h1>{extras}</header>{empty}', "../" if tag else "") + + +def copy_assets() -> None: + for source in POSTS.iterdir(): + if not source.is_file() or source.suffix.lower() == ".md": + continue + shutil.copy2(source, BUILD / source.name) + for source in PAGES.iterdir(): + if not source.is_file() or source.suffix.lower() == ".md": + continue + shutil.copy2(source, BUILD / source.name) + + +def main() -> None: + for directory in (POSTS, PAGES): + directory.mkdir(exist_ok=True) + if BUILD.exists(): + shutil.rmtree(BUILD) + (BUILD / "tags").mkdir(parents=True) + posts = [parse_note(path, True) for path in POSTS.glob("*.md")] + for note in posts: + if note.date is None: + raise ValueError(f"{note.path.relative_to(ROOT)} needs a date in its front matter") + posts.sort(key=lambda note: note.date or dt.datetime.min, reverse=True) + static_pages = [parse_note(path) for path in PAGES.glob("*.md")] + static_pages.sort(key=lambda note: note.order or 0) + for note in posts: + (BUILD / f"{note.slug}.html").write_text(post_html(note), encoding="utf-8") + for note in static_pages: + body = f'<a class="back" href="index.html">โ index</a><article><h1>{html.escape(note.display)}</h1>{markdown(note.body)}</article>' + (BUILD / f"{note.slug}.html").write_text(page(note.title, body), encoding="utf-8") + (BUILD / "index.html").write_text(list_html(posts, "Alejandro's blog", static_pages), encoding="utf-8") + tags = sorted({tag for note in posts for tag in note.tags}) + for tag in tags: + (BUILD / "tags" / f"{tag}.html").write_text(list_html(posts, f"#{tag}", static_pages, tag), encoding="utf-8") + shutil.copy2(ROOT / "style.css", BUILD / "style.css") + copy_assets() + print(f"Built {len(posts)} post(s), {len(tags)} tag page(s), and {len(static_pages)} page(s) in {BUILD.relative_to(ROOT)}/") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError) as error: + sys.exit(f"compile: {error}") |
