From da1ae7f916649076bcf951acb80bb166e8a9bc6a Mon Sep 17 00:00:00 2001
From: Dave Liu <7david12liu@gmail.com>
Date: Thu, 2 Jul 2026 09:23:06 -0700
Subject: [PATCH] blog: Atom feed at /blog/feed.xml + index discovery link
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Top backlog item from the blog-enhancement loop. build_blog.py now emits
blog/feed.xml (Atom 1.0, newest-first, escaped HTML content, tags as
categories) alongside the post pages, and the blog index
carries
for feed discovery —
injected idempotently into existing chrome by splice_index, so no full
index regeneration was needed.
Deterministic on purpose: feed-level is the newest post's date
(fixed epoch when empty), so unchanged sources rebuild byte-identical and
--check stays quiet.
Includes the rebuilt output (feed.xml, index head line) and the sitemap
re-merge — the blog URLs had dropped out of master's sitemap.xml; running
the build restored them via the existing additive merge.
tests/test_build_blog.py: +15 tests (feed structure via ElementTree,
escaping, categories, determinism, discovery-link injection + idempotency,
build outputs). Full suite: 159 passed.
Co-Authored-By: Claude Opus 4.8
---
blog/feed.xml | 38 ++++++++++++++
blog/index.html | 1 +
scripts/build_blog.py | 100 ++++++++++++++++++++++++++++++++---
sitemap.xml | 11 ++++
tests/test_build_blog.py | 109 ++++++++++++++++++++++++++++++++++++++-
5 files changed, 251 insertions(+), 8 deletions(-)
create mode 100644 blog/feed.xml
diff --git a/blog/feed.xml b/blog/feed.xml
new file mode 100644
index 0000000..166060f
--- /dev/null
+++ b/blog/feed.xml
@@ -0,0 +1,38 @@
+
+
+ Dave Liu — Blog
+ Short, honest notes on building software, working with people, and navigating a career.
+
+
+ https://daliu.github.io/blog/
+ 2026-06-28T00:00:00Z
+
+ Dave Liu
+
+
+ Why I'm Keeping a Public Work Journal
+
+ https://daliu.github.io/blog/welcome.html
+ 2026-06-28T00:00:00Z
+ Starting a small public blog to think out loud about engineering, building, and career.
+
+
+ <blockquote>
+<p><strong>Placeholder / example post.</strong> This exists so the blog build pipeline has a real
+<code>publish: true</code> post to render end-to-end. Dave: edit or replace this with your real
+first post before merging the blog to the live site.</p>
+</blockquote>
+<p>I've kept private notes for years. This is an experiment in publishing a thin, curated
+slice of them — the thoughts on building software, working with people, and navigating a
+career that I'd be comfortable saying out loud.</p>
+<p>The rules I'm setting for myself:</p>
+<ul>
+<li><strong>Opt-in, not opt-out.</strong> A note only becomes a post when I explicitly mark it
+ <code>publish: true</code>. Everything else stays private by default.</li>
+<li><strong>Synced from where I already write.</strong> Posts live in my notes vault, so publishing is a
+ side effect of thinking, not a separate chore.</li>
+<li><strong>Short and honest over polished and frequent.</strong></li>
+</ul>
+<p>If you're reading this, the pipeline works.</p>
+
+
diff --git a/blog/index.html b/blog/index.html
index 3c44ab4..aa59526 100644
--- a/blog/index.html
+++ b/blog/index.html
@@ -12,6 +12,7 @@
+
diff --git a/scripts/build_blog.py b/scripts/build_blog.py
index bd1e44e..168ba51 100644
--- a/scripts/build_blog.py
+++ b/scripts/build_blog.py
@@ -10,6 +10,8 @@
* blog/.html — one page per published post
* blog/index.html — dated cards, newest first, spliced between the
/ markers
+ * blog/feed.xml — Atom 1.0 feed of every published post (linked from
+ the index head via )
* sitemap.xml — blog index + every post URL
THE PUBLISH GATE (hard safety requirement). A post is published if and ONLY if
@@ -65,6 +67,16 @@
# + autotrader daily pages); we merge additively so neither clobbers the other.
BLOG_INDEX_LOC = f"{SITE}/blog/"
+# Atom feed (blog/feed.xml). The tag below is inserted
+# into the index — both when generating a fresh index and when splicing
+# into an existing one whose chrome predates the feed.
+FEED_LOC = f"{SITE}/blog/feed.xml"
+FEED_TITLE = "Dave Liu — Blog"
+FEED_LINK_TAG = (
+ f''
+)
+
# --------------------------------------------------------------------------- #
# Frontmatter parsing (no PyYAML — mirror build_graph.parse_frontmatter)
@@ -341,11 +353,13 @@ def _nav(active_blog=True):
"""
-def _head(title, description, canonical, og_type):
+def _head(title, description, canonical, og_type, feed_link=False):
"""Shared : title, meta description, canonical, OG/twitter tags,
- GA4, favicon, bootstrap, font-awesome, shared style."""
+ GA4, favicon, bootstrap, font-awesome, shared style. `feed_link=True`
+ (index only) adds the Atom for feed discovery."""
desc = html.escape(description or "")
t = html.escape(title)
+ feed = f"\n {FEED_LINK_TAG}" if feed_link else ""
return f"""
@@ -357,7 +371,7 @@ def _head(title, description, canonical, og_type):
-
+ {feed}
@@ -459,7 +473,7 @@ def generate_index_page(posts):
cards = render_index_cards(posts)
return f"""
-{_head("Blog — Dave Liu", description, canonical, "website")}
+{_head("Blog — Dave Liu", description, canonical, "website", feed_link=True)}
{_nav(active_blog=True)}
@@ -482,17 +496,88 @@ def generate_index_page(posts):
"""
+def ensure_feed_link(index_html):
+ """Insert the Atom into an existing index if
+ it's missing (chrome written before the feed existed). Idempotent — a head
+ that already has the tag is returned unchanged."""
+ if FEED_LINK_TAG in index_html:
+ return index_html
+ canonical_tag = f''
+ if canonical_tag in index_html:
+ return index_html.replace(
+ canonical_tag, f"{canonical_tag}\n {FEED_LINK_TAG}", 1
+ )
+ if "" in index_html:
+ return index_html.replace("", f" {FEED_LINK_TAG}\n", 1)
+ return index_html
+
+
def splice_index(existing_html, posts):
"""Re-splice only the card block inside an existing index.html, preserving
the hand-written chrome around it (mirrors build_patterns_program.splice).
- Falls back to a full regenerate if markers are absent."""
+ Also ensures the head carries the Atom feed link. Falls back to a full
+ regenerate if markers are absent."""
if BEGIN_MARK not in existing_html or END_MARK not in existing_html:
return generate_index_page(posts)
b = existing_html.index(BEGIN_MARK)
e = existing_html.index(END_MARK) + len(END_MARK)
cards = render_index_cards(posts)
block = f"{BEGIN_MARK}\n{cards}\n {END_MARK}"
- return existing_html[:b] + block + existing_html[e:]
+ return ensure_feed_link(existing_html[:b] + block + existing_html[e:])
+
+
+# --------------------------------------------------------------------------- #
+# Atom feed
+# --------------------------------------------------------------------------- #
+
+def _rfc3339(date_str):
+ """Atom timestamps are RFC 3339; posts only carry a date, so midnight UTC."""
+ return f"{date_str}T00:00:00Z"
+
+
+def generate_feed(posts):
+ """Render blog/feed.xml as Atom 1.0, newest entry first.
+
+ Deliberately deterministic — no wall-clock: the feed-level is the
+ newest post's date (fixed epoch when there are no posts), so rebuilding
+ with unchanged sources yields no diff and --check stays quiet.
+ Post bodies ship escaped inside ."""
+ updated = _rfc3339(posts[0]["date"]) if posts else "1970-01-01T00:00:00Z"
+ entries = []
+ for p in posts:
+ url = f"{SITE}/blog/{p['slug']}.html"
+ summary = ""
+ if p["description"]:
+ summary = f"\n {html.escape(p['description'])}"
+ categories = "".join(
+ f'\n '
+ for t in p["tags"]
+ )
+ entries.append(f"""
+ {html.escape(p['title'])}
+
+ {url}
+ {_rfc3339(p['date'])}{summary}{categories}
+ {html.escape(p['body_html'])}
+ """)
+ entries_xml = ("\n" + "\n".join(entries)) if entries else ""
+ subtitle = (
+ "Short, honest notes on building software, working with people, "
+ "and navigating a career."
+ )
+ return f"""
+
+ {html.escape(FEED_TITLE)}
+ {subtitle}
+
+
+ {BLOG_INDEX_LOC}
+ {updated}
+
+ Dave Liu
+ {entries_xml}
+
+"""
# --------------------------------------------------------------------------- #
@@ -608,6 +693,9 @@ def build(vault_path):
else:
outputs[index_path] = generate_index_page(posts)
+ # Atom feed.
+ outputs[os.path.join(BLOG_DIR, "feed.xml")] = generate_feed(posts)
+
# Sitemap: merge blog URLs into the existing file additively, preserving
# all non-blog entries (publish_daily.py owns those).
existing_sitemap = None
diff --git a/sitemap.xml b/sitemap.xml
index bad10cd..46aa8f0 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -563,4 +563,15 @@
2026-02-180.5
+
+ https://daliu.github.io/blog/
+ 2026-07-02
+ weekly
+ 0.7
+
+
+ https://daliu.github.io/blog/welcome.html
+ 2026-06-28
+ 0.6
+
diff --git a/tests/test_build_blog.py b/tests/test_build_blog.py
index a58a4f5..9047fbf 100644
--- a/tests/test_build_blog.py
+++ b/tests/test_build_blog.py
@@ -2,8 +2,10 @@
Covers: frontmatter parsing, the publish-gate filter (the hard safety
requirement — a publish: false / missing-flag post must NOT appear in output),
-slug derivation, post-page + index generation, the additive sitemap merge
-(must preserve non-blog entries), the overwrite guard, and idempotency.
+slug derivation, post-page + index generation, the Atom feed (blog/feed.xml
++ the discovery tag in the index head), the additive
+sitemap merge (must preserve non-blog entries), the overwrite guard, and
+idempotency.
Mirrors the style of tests/test_publish_daily.py.
"""
@@ -278,6 +280,107 @@ def test_splice_idempotent(self):
assert once == twice
+# ---------------------------------------------------------------------------
+# Atom feed (blog/feed.xml + index discovery link)
+# ---------------------------------------------------------------------------
+
+class TestGenerateFeed:
+ def _parse(self, xml_text):
+ import xml.etree.ElementTree as ET
+ return ET.fromstring(xml_text)
+
+ def test_well_formed_atom(self):
+ root = self._parse(build_blog.generate_feed([_sample_post()]))
+ assert root.tag == "{http://www.w3.org/2005/Atom}feed"
+
+ def test_entry_fields(self):
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ root = self._parse(build_blog.generate_feed([_sample_post()]))
+ entries = root.findall("a:entry", ns)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.find("a:title", ns).text == "My First Post"
+ assert e.find("a:id", ns).text == "https://daliu.github.io/blog/welcome.html"
+ assert e.find("a:link", ns).get("href") == "https://daliu.github.io/blog/welcome.html"
+ assert e.find("a:updated", ns).text == "2026-06-28T00:00:00Z"
+ assert e.find("a:summary", ns).text == "An intro post."
+
+ def test_content_is_escaped_html(self):
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ root = self._parse(build_blog.generate_feed([_sample_post()]))
+ content = root.find("a:entry/a:content", ns)
+ assert content.get("type") == "html"
+ # ElementTree unescapes on parse — the round-tripped text is the HTML.
+ assert content.text == "
Hello world.
"
+
+ def test_tags_become_categories(self):
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ root = self._parse(build_blog.generate_feed([_sample_post()]))
+ terms = [c.get("term") for c in root.findall("a:entry/a:category", ns)]
+ assert terms == ["career", "meta"]
+
+ def test_feed_updated_is_newest_post_date(self):
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ root = self._parse(build_blog.generate_feed([_sample_post()]))
+ assert root.find("a:updated", ns).text == "2026-06-28T00:00:00Z"
+
+ def test_self_and_alternate_links(self):
+ feed = build_blog.generate_feed([_sample_post()])
+ assert 'rel="self"' in feed
+ assert "https://daliu.github.io/blog/feed.xml" in feed
+ assert 'href="https://daliu.github.io/blog/"' in feed
+
+ def test_empty_feed_no_entries_still_valid(self):
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ root = self._parse(build_blog.generate_feed([]))
+ assert root.findall("a:entry", ns) == []
+ assert root.find("a:updated", ns).text is not None
+
+ def test_deterministic(self):
+ # No wall-clock anywhere — two builds are byte-identical.
+ assert build_blog.generate_feed([_sample_post()]) == \
+ build_blog.generate_feed([_sample_post()])
+
+ def test_title_escaped(self):
+ post = dict(_sample_post(), title="Ampersands & ")
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ root = self._parse(build_blog.generate_feed([post]))
+ assert root.find("a:entry/a:title", ns).text == "Ampersands & "
+
+
+class TestFeedDiscoveryLink:
+ def test_generated_index_has_alternate_link(self):
+ html = build_blog.generate_index_page([_sample_post()])
+ assert build_blog.FEED_LINK_TAG in html
+ # In the head, before the body starts.
+ assert html.index(build_blog.FEED_LINK_TAG) < html.index("")
+
+ def test_post_pages_do_not_get_the_link(self):
+ # The backlog item scopes discovery to the index head only.
+ assert build_blog.FEED_LINK_TAG not in build_blog.generate_post_page(_sample_post())
+
+ def test_splice_injects_link_into_legacy_chrome(self):
+ legacy = build_blog.generate_index_page([]).replace(
+ "\n " + build_blog.FEED_LINK_TAG, "")
+ assert build_blog.FEED_LINK_TAG not in legacy
+ spliced = build_blog.splice_index(legacy, [_sample_post()])
+ assert spliced.count(build_blog.FEED_LINK_TAG) == 1
+ assert spliced.index(build_blog.FEED_LINK_TAG) < spliced.index("")
+
+ def test_splice_idempotent_with_link(self):
+ once = build_blog.splice_index(
+ build_blog.generate_index_page([_sample_post()]), [_sample_post()])
+ twice = build_blog.splice_index(once, [_sample_post()])
+ assert once == twice
+ assert twice.count(build_blog.FEED_LINK_TAG) == 1
+
+ def test_ensure_feed_link_no_canonical_falls_back_to_head_close(self):
+ html = "x"
+ out = build_blog.ensure_feed_link(html)
+ assert build_blog.FEED_LINK_TAG in out
+ assert out.index(build_blog.FEED_LINK_TAG) < out.index("")
+
+
# ---------------------------------------------------------------------------
# Sitemap merge (additive — must NOT clobber non-blog entries)
# ---------------------------------------------------------------------------
@@ -361,6 +464,8 @@ def test_build_writes_pages(self, tmp_path, monkeypatch):
assert len(posts) == 1
assert os.path.join(blog_dir, "hi.html") in outputs
assert os.path.join(blog_dir, "index.html") in outputs
+ assert os.path.join(blog_dir, "feed.xml") in outputs
+ assert "Hi" in outputs[os.path.join(blog_dir, "feed.xml")]
def test_build_idempotent(self, tmp_path, monkeypatch):
blog_dir, _ = self._point_at_tmp(tmp_path, monkeypatch)