summaryrefslogtreecommitdiff
path: root/bin/compile
diff options
context:
space:
mode:
Diffstat (limited to 'bin/compile')
-rwxr-xr-xbin/compile225
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} &nbsp; {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}")