1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
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}")
|