#!/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}")