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