Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions blog/feed.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Dave Liu — Blog</title>
<subtitle>Short, honest notes on building software, working with people, and navigating a career.</subtitle>
<link rel="self" type="application/atom+xml" href="https://daliu.github.io/blog/feed.xml"/>
<link rel="alternate" type="text/html" href="https://daliu.github.io/blog/"/>
<id>https://daliu.github.io/blog/</id>
<updated>2026-06-28T00:00:00Z</updated>
<author>
<name>Dave Liu</name>
</author>
<entry>
<title>Why I&#x27;m Keeping a Public Work Journal</title>
<link rel="alternate" type="text/html" href="https://daliu.github.io/blog/welcome.html"/>
<id>https://daliu.github.io/blog/welcome.html</id>
<updated>2026-06-28T00:00:00Z</updated>
<summary>Starting a small public blog to think out loud about engineering, building, and career.</summary>
<category term="career"/>
<category term="meta"/>
<content type="html">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Placeholder / example post.&lt;/strong&gt; This exists so the blog build pipeline has a real
&lt;code&gt;publish: true&lt;/code&gt; post to render end-to-end. Dave: edit or replace this with your real
first post before merging the blog to the live site.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&#x27;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&#x27;d be comfortable saying out loud.&lt;/p&gt;
&lt;p&gt;The rules I&#x27;m setting for myself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Opt-in, not opt-out.&lt;/strong&gt; A note only becomes a post when I explicitly mark it
&lt;code&gt;publish: true&lt;/code&gt;. Everything else stays private by default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Synced from where I already write.&lt;/strong&gt; Posts live in my notes vault, so publishing is a
side effect of thinking, not a separate chore.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Short and honest over polished and frequent.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&#x27;re reading this, the pipeline works.&lt;/p&gt;</content>
</entry>
</feed>
1 change: 1 addition & 0 deletions blog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<meta property="og:image" content="https://daliu.github.io/images/og-card.png">
<meta name="twitter:card" content="summary">
<link rel="canonical" href="https://daliu.github.io/blog/">
<link rel="alternate" type="application/atom+xml" title="Dave Liu — Blog" href="https://daliu.github.io/blog/feed.xml">
<link rel="icon" type="image/svg+xml" href="../favicon.svg">
<!-- Google Analytics (GA4) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-GR5Z815VXW"></script>
Expand Down
100 changes: 94 additions & 6 deletions scripts/build_blog.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* blog/<slug>.html — one page per published post
* blog/index.html — dated cards, newest first, spliced between the
<!-- BEGIN auto:blog-index --> / <!-- END ... --> markers
* blog/feed.xml — Atom 1.0 feed of every published post (linked from
the index head via <link rel="alternate">)
* sitemap.xml — blog index + every post URL

THE PUBLISH GATE (hard safety requirement). A post is published if and ONLY if
Expand Down Expand Up @@ -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 <link rel="alternate"> tag below is inserted
# into the index <head> — 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'<link rel="alternate" type="application/atom+xml" '
f'title="{FEED_TITLE}" href="{FEED_LOC}">'
)


# --------------------------------------------------------------------------- #
# Frontmatter parsing (no PyYAML — mirror build_graph.parse_frontmatter)
Expand Down Expand Up @@ -341,11 +353,13 @@ def _nav(active_blog=True):
</style>"""


def _head(title, description, canonical, og_type):
def _head(title, description, canonical, og_type, feed_link=False):
"""Shared <head>: 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 <link rel="alternate"> for feed discovery."""
desc = html.escape(description or "")
t = html.escape(title)
feed = f"\n {FEED_LINK_TAG}" if feed_link else ""
return f"""<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
Expand All @@ -357,7 +371,7 @@ def _head(title, description, canonical, og_type):
<meta property="og:url" content="{canonical}">
<meta property="og:image" content="{OG_IMAGE}">
<meta name="twitter:card" content="summary">
<link rel="canonical" href="{canonical}">
<link rel="canonical" href="{canonical}">{feed}
<link rel="icon" type="image/svg+xml" href="../favicon.svg">
<!-- Google Analytics (GA4) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={GA4_ID}"></script>
Expand Down Expand Up @@ -459,7 +473,7 @@ def generate_index_page(posts):
cards = render_index_cards(posts)
return f"""<!DOCTYPE html>
<html lang="en">
{_head("Blog — Dave Liu", description, canonical, "website")}
{_head("Blog — Dave Liu", description, canonical, "website", feed_link=True)}
<body>

{_nav(active_blog=True)}
Expand All @@ -482,17 +496,88 @@ def generate_index_page(posts):
"""


def ensure_feed_link(index_html):
"""Insert the Atom <link rel="alternate"> into an existing index <head> 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'<link rel="canonical" href="{BLOG_INDEX_LOC}">'
if canonical_tag in index_html:
return index_html.replace(
canonical_tag, f"{canonical_tag}\n {FEED_LINK_TAG}", 1
)
if "</head>" in index_html:
return index_html.replace("</head>", f" {FEED_LINK_TAG}\n</head>", 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 <updated> 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 <content type="html">."""
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 <summary>{html.escape(p['description'])}</summary>"
categories = "".join(
f'\n <category term="{html.escape(t, quote=True)}"/>'
for t in p["tags"]
)
entries.append(f""" <entry>
<title>{html.escape(p['title'])}</title>
<link rel="alternate" type="text/html" href="{url}"/>
<id>{url}</id>
<updated>{_rfc3339(p['date'])}</updated>{summary}{categories}
<content type="html">{html.escape(p['body_html'])}</content>
</entry>""")
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"""<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{html.escape(FEED_TITLE)}</title>
<subtitle>{subtitle}</subtitle>
<link rel="self" type="application/atom+xml" href="{FEED_LOC}"/>
<link rel="alternate" type="text/html" href="{BLOG_INDEX_LOC}"/>
<id>{BLOG_INDEX_LOC}</id>
<updated>{updated}</updated>
<author>
<name>Dave Liu</name>
</author>{entries_xml}
</feed>
"""


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -563,4 +563,15 @@
<lastmod>2026-02-18</lastmod>
<priority>0.5</priority>
</url>
<url>
<loc>https://daliu.github.io/blog/</loc>
<lastmod>2026-07-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://daliu.github.io/blog/welcome.html</loc>
<lastmod>2026-06-28</lastmod>
<priority>0.6</priority>
</url>
</urlset>
109 changes: 107 additions & 2 deletions tests/test_build_blog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <link rel="alternate"> 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.
"""
Expand Down Expand Up @@ -278,6 +280,107 @@ def test_splice_idempotent(self):
assert once == twice


# ---------------------------------------------------------------------------
# Atom feed (blog/feed.xml + index <head> 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 == "<p>Hello <strong>world</strong>.</p>"

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 & <angles>")
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 & <angles>"


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("<body>")

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("<body>")

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 = "<html><head><title>x</title></head><body></body></html>"
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("</head>")


# ---------------------------------------------------------------------------
# Sitemap merge (additive — must NOT clobber non-blog entries)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 "<title>Hi</title>" 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)
Expand Down