diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 33 | ||||
| -rwxr-xr-x | bin/compile | 225 | ||||
| -rw-r--r-- | style.css | 35 |
4 files changed, 295 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0de5977 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +bld/*
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..08d3f1b --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Minimal Obsidian blog + +Write posts as flat Markdown files directly in `posts/`, then build the static site: + +```sh +./bin/compile +``` + +The generated site is placed in `bld/`; open `bld/index.html` in any browser or publish that directory. No JavaScript or third-party build dependency is used. + +## Posts + +Use Obsidian-compatible YAML front matter. `title` is optional when the note starts with a level-one heading. `date` and `tags` are used to make the lists. Inline Obsidian tags such as `#writing` are collected too. + +```markdown +--- +title: A small beginning +date: 2026-08-29 09:30 +tags: [notes, writing] +--- + +The post body, which may also contain #ideas. +``` + +Post filenames become URLs, for example `posts/a-small-beginning.md` produces `bld/a-small-beginning.html`. Keep `posts/` flat. Files in `posts/` that are not Markdown (images, PDFs, and so on) are copied unchanged to `bld/`. + +## Constant pages + +Put Markdown files such as `about.md` or `contact.md` in `pages/`. They build to the root of `bld/` and appear as small links on every index/tag list page. This keeps permanent information available without mixing it into post chronology. + +## Typography + +The stylesheet uses **Libre Baskerville**, the serif typeface used by the requested Commoncog reference page, with Baskerville/Georgia fallbacks. It is loaded from Google Fonts; browsers without network access retain the local fallbacks. 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} {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}") diff --git a/style.css b/style.css new file mode 100644 index 0000000..9e65344 --- /dev/null +++ b/style.css @@ -0,0 +1,35 @@ +@import url('https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap'); + +:root { color: #29251f; background: #fdfbf6; font-family: Times, "Libre Baskerville", Baskerville, Georgia, serif; font-size: 16px; line-height: 1.75; } +* { box-sizing: border-box; } +body { margin: 0; padding: 11vh 2rem 15vh; } +main { width: min(35vw, 44rem); min-width: 0; margin: auto; } +a { color: inherit; text-decoration-color: #b9b0a0; text-underline-offset: .16em; } +a:hover { text-decoration-color: currentColor; } +h1, h2, h3 { line-height: 1.25; font-weight: 400; } +h1 { margin: 0 0 1.4rem; font-size: 1.75rem; } +h2 { margin-top: 2.5rem; font-size: 1.25rem; } +h3 { margin-top: 2rem; font-size: 1rem; } +p, li { overflow-wrap: break-word; } +article { text-align: justify; hyphens: auto; } +article header { text-align: left; } +.metadata, .back, .pages, .post-list, .empty { font-size: .78rem; } +.metadata { color: #756e63; margin: -1rem 0 3rem; } +.back { color: #8b8377; display: inline-block; margin-bottom: 3rem; text-decoration: none; } +.back:hover { color: #29251f; } +.post-list { list-style: none; padding: 0; margin: 2.5rem 0 0; } +.post-list li { display: grid; grid-template-columns: 9.7rem minmax(0, 1fr) auto; gap: .7rem; align-items: baseline; padding: .45rem 0; } +time { color: #756e63; font-variant-numeric: tabular-nums; white-space: nowrap; } +.post-link { text-decoration: none; } +.post-link:hover { text-decoration: underline; text-decoration-color: #b9b0a0; text-underline-offset: .16em; } +.tags { text-align: right; white-space: nowrap; } +.tag { color: #756e63; text-decoration: none; } +.tag:hover { color: #29251f; text-decoration: underline; } +.pages { color: #756e63; margin-top: -.8rem; } +blockquote { margin: 1.5rem 0; padding-left: 1.2rem; border-left: 1px solid #d7d0c4; color: #514b42; } +pre { overflow-x: auto; padding: 1rem; background: #f6f2e9; font-size: .82rem; line-height: 1.5; } +code { font-size: .85em; } +img { display: block; max-width: 100%; height: auto; } +hr { border: 0; border-top: 1px solid #e5dfd4; margin: 2.5rem 0; } +@media (max-width: 900px) { main { width: min(100%, 44rem); } } +@media (max-width: 620px) { body { padding: 7vh 1.25rem 10vh; } .post-list li { grid-template-columns: 1fr auto; gap: .15rem .7rem; } .post-list time { grid-column: 1 / -1; } } |
