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-18 0.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)