From ce14ceb07252ebf5f3fce15681a272f2790c1da6 Mon Sep 17 00:00:00 2001 From: "Alejandro W. Sior" Date: Sat, 5 Sep 2026 20:36:47 +0200 Subject: compile: static site generator --- bin/compile | 225 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100755 bin/compile (limited to 'bin/compile') 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"(? str: + text = html.escape(text, quote=False) + text = re.sub(r"!\[([^]]*)\]\(([^ )]+)(?:\s+[^)]*)?\)", r'\1', text) + text = re.sub(r"\[([^]]+)\]\(([^ )]+)(?:\s+[^)]*)?\)", r'\1', text) + text = re.sub(r"\[\[([^]|]+)(?:\|([^]]+))?\]\]", lambda m: f'{m.group(2) or m.group(1)}', text) + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*|__([^_]+)__", lambda m: f"{m.group(1) or m.group(2)}", text) + text = re.sub(r"(?{m.group(1) or m.group(2)}", 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"

{inline(' '.join(part.strip() for part in paragraph))}

") + paragraph = [] + + def close_list() -> None: + nonlocal list_type + if list_type: + output.append(f"") + list_type = None + + for line in lines: + if line.startswith("```"): + flush_paragraph(); close_list() + output.append("" if in_code else "
")
+            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"{inline(match.group(2))}")
+        elif line.startswith("> "):
+            flush_paragraph(); close_list(); output.append(f"

{inline(line[2:])}

") + elif line.strip() in ("---", "***", "___"): + flush_paragraph(); close_list(); output.append("
") + 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"
  • {inline(match.group(2))}
  • ") + else: + paragraph.append(line) + flush_paragraph(); close_list() + return "\n".join(output) + + +def tag_links(tags: list[str], prefix: str = "") -> str: + return " ".join(f'#{html.escape(tag)}' for tag in tags) + + +def page(title: str, content: str, nav: str = "") -> str: + return f""" + +{html.escape(title)} +
    {content}
    \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'โ† index

    {html.escape(note.display)}

    {markdown(note.body)}
    ') + + +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'
  • {html.escape(note.title)}{tags}
  • ') + extras = "" + if static_pages: + page_prefix = "../" if tag else "" + links = " ยท ".join(f'{html.escape(note.title)}' for note in static_pages) + extras = f'' + back = 'โ† index' if tag else "" + empty = "

    No posts yet.

    " if not rows else f"
      {''.join(rows)}
    " + return page(heading, f'{back}

    {html.escape(heading)}

    {extras}
    {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'โ† index

    {html.escape(note.display)}

    {markdown(note.body)}
    ' + (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}") -- cgit v1.2.3