feed: absolute URLs in syndicated HTML (RSS + JSON Feed) #21

Merged
michalvankodev merged 6 commits from feed/absolute-urls into main 2026-09-14 16:10:26 +02:00

Split out of the PR #13 review. Spec + full evidence chain: specs/feed-image-url.md (Appendix A).

Problem

build_feed_items() renders post bodies with the template-shared parse_markdown filter, so /feed.xml + /feed.json went out with root-relative URLs. Measured on the live feed: 38 src / 8 href / 169 srcset root-relative occurrences across 41 items.

Readers render content_html inside their own document context, so those URLs resolve against the reader's origin → broken images/links by construction. The W3C feed validator flags the live feed today:

warning ContainsRelRef  content:encoded should not contain relative URL references
warning ContainsRelRef  description should not contain relative URL references

(FreshRSS looks fine only because SimplePie rewrites URL attributes against the channel link — and even SimplePie never rewrites srcset, so all 169 responsive candidates 404'd silently there. Details in spec Appendix A.3.)

Fix

Attribute-aware rewrite with lol_html at the single feed choke point (build_feed_items, covers RSS + JSON):

  • a[href], img[src], iframe|video|audio|source[src] — prefix root-relative /… (not //) with https://michalvanko.dev
  • img[srcset], source[srcset] — per-candidate rewrite, descriptors kept

Code samples are safe by construction: only real attribute values are rewritten, never text content. (17/41 posts contain code blocks — naive replace was rejected for exactly this reason.)

Tests (9 in src/feed.rs, suite 43/43)

  • full-content audits over all 41 posts on both feeds: 0 root-relative src/href/srcset
  • absolute + protocol-relative URLs pass through byte-identical
  • syntect-style code-sample fixture survives byte-identical
  • markdown fixture with inline code span + code block + real link: exactly one anchor rewritten, code text untouched
  • served both feeds locally: src 0 rel / 53 abs, href 0 rel / 352 abs, srcset 0 rel / 171 abs (was 38 / 8 / 169 root-relative)

Notes

  • New dependency: lol_html 3 (~40 lines of rewrite logic).
  • The spec's JSON Feed 1.1 citation from the review was corrected — the "absolute URLs" clause doesn't exist in the spec or validator; the normative anchor is the RSS Best Practices Profile (§3.4, §4.1.1.20.4) + RFC 3986 §5.
  • Validator run also surfaced unrelated findings, recorded in the spec for follow-ups: enclosure length="0", truncated description HTML, <iframe> in content:encoded, missing atom:link rel="self".
Split out of the PR #13 review. Spec + full evidence chain: [`specs/feed-image-url.md`](../blob/feed/absolute-urls/specs/feed-image-url.md) (Appendix A). ## Problem `build_feed_items()` renders post bodies with the template-shared `parse_markdown` filter, so `/feed.xml` + `/feed.json` went out with **root-relative URLs**. Measured on the live feed: **38 `src` / 8 `href` / 169 `srcset`** root-relative occurrences across 41 items. Readers render `content_html` inside **their own document context**, so those URLs resolve against the reader's origin → broken images/links by construction. The W3C feed validator flags the live feed today: ``` warning ContainsRelRef content:encoded should not contain relative URL references warning ContainsRelRef description should not contain relative URL references ``` (FreshRSS *looks* fine only because SimplePie rewrites URL attributes against the channel link — and even SimplePie never rewrites `srcset`, so all 169 responsive candidates 404'd silently there. Details in spec Appendix A.3.) ## Fix Attribute-aware rewrite with `lol_html` at the single feed choke point (`build_feed_items`, covers RSS + JSON): - `a[href]`, `img[src]`, `iframe|video|audio|source[src]` — prefix root-relative `/…` (not `//`) with `https://michalvanko.dev` - `img[srcset]`, `source[srcset]` — per-candidate rewrite, descriptors kept Code samples are safe by construction: only real attribute values are rewritten, never text content. (17/41 posts contain code blocks — naive replace was rejected for exactly this reason.) ## Tests (9 in `src/feed.rs`, suite 43/43) - full-content audits over all 41 posts on both feeds: **0 root-relative `src`/`href`/`srcset`** - absolute + protocol-relative URLs pass through **byte-identical** - syntect-style code-sample fixture survives **byte-identical** - markdown fixture with inline code span + code block + real link: exactly one anchor rewritten, code text untouched - served both feeds locally: `src` 0 rel / 53 abs, `href` 0 rel / 352 abs, `srcset` 0 rel / 171 abs (was 38 / 8 / 169 root-relative) ## Notes - New dependency: `lol_html 3` (~40 lines of rewrite logic). - The spec's JSON Feed 1.1 citation from the review was corrected — the "absolute URLs" clause doesn't exist in the spec or validator; the normative anchor is the RSS Best Practices Profile (§3.4, §4.1.1.20.4) + RFC 3986 §5. - Validator run also surfaced unrelated findings, recorded in the spec for follow-ups: enclosure `length="0"`, truncated `description` HTML, `<iframe>` in `content:encoded`, missing `atom:link rel="self"`.
feed: absolutize syndicated HTML — readers render it on their own origin
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / build + deploy preview (push) Has been skipped
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 2m10s
preview / preview hostname (pull_request) Successful in 0s
preview / teardown preview (pull_request) Has been skipped
preview / build + deploy preview (pull_request) Successful in 3m23s
d8933490a3
Root-relative URLs in feed content resolve against the reader's document,
not ours: 38 src / 8 href / 169 srcset occurrences in the live feed were
broken by construction. FreshRSS only survives because SimplePie rewrites
URL attributes against the channel link — and even it never rewrites
srcset, so every responsive candidate 404'd silently. The W3C feed
validator flagged the feed (ContainsRelRef on content:encoded and
description).

Fix: attribute-aware rewrite with lol_html at the single feed choke point
(build_feed_items) — a[href], img[src], img|source[srcset],
iframe|video|audio|source[src]. Code samples are safe by construction:
only real attribute values are rewritten, never text content.

Proof chain + evidence: specs/feed-image-url.md (Appendix A). Tests: 9
feed tests incl. full-content audits over both feeds (0 root-relative
URLs, absolute/protocol-relative pass through byte-identical, code
samples untouched).

🪦 Live preview

removed (PR closed)

<!-- preview-link --> ## 🪦 Live preview removed (PR closed)
- 2022-05-07 treasure-hunt weekly: [HackKosice hackathon]() and
  [Bevy game engine]() had empty URLs — the W3C feed validator counts
  href="" as a relative reference (ContainsRelRef). Gave them the
  real destinations (hackkosice.com, bevyengine.org — same URL the
  post already uses later).
- delete _posts/blog/dev-2019-08-09-ide-to copy.md: an accidental
  leftover draft copy (" copy" filename, test tags another-one/another,
  thumbnail /images/uploads/screenshot.gif that 404s) that was
  published: true and live in the feed. Surfaced by the new
  enclosure-length stat logging.
feed: clear the remaining W3C validator findings
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m17s
preview / preview hostname (pull_request) Successful in 0s
preview / teardown preview (pull_request) Has been skipped
preview / build + deploy preview (push) Successful in 1m18s
preview / build + deploy preview (pull_request) Successful in 1m18s
bfbcf5b486
Re-ran the W3C feed validator against this PR's preview after the
absolutization landed; it still flagged one error and five warnings.
All addressed at the same build_feed_items/build_rss_channel choke
point:

- enclosure length (ERROR "must be a positive integer", was empty on
  all 20): real byte size via tokio::fs::metadata on the
  static/-relative path; unstattable targets drop the enclosure with
  a warn! rather than emit a dishonest value
- ContainsRelRef x25: all fragment-only href="#anchor" TOC links —
  absolutize_html now takes the post's canonical URL and rewrites
  them to <post-url>#anchor so they work in readers; the feed audits
  now also fail on fragment-only and empty hrefs (mirroring the
  validator)
- MissingAtomSelfLink: atom:link rel=self via the rss crate's atom
  feature (Cargo.toml features=["atom"])
- MissingRealName: webMaster is now 'email (Michal Vanko)' per the
  RSS Profile
- channel pubDate bug (pre-existing): feed_items.last() on a
  newest-first list put the OLDEST post date in the channel; now
  .first()
- byte-identity hardening: absolutize_srcset returns its input
  unchanged when no candidate is root-relative (no separator
  re-serialization of already-absolute srcsets)

Left open (own decisions, see spec): <p><figure> stray-</p> HTML from
the markdown image handler (site-wide), iframe policy in feed
content, SVG og:image, hex-escape style in titles.

Spec: specs/feed-image-url.md — new 'Validator follow-up round'
section; the old 'feeds never land in dist/' finding is resolved
(base.html discovery links). Tests: 46 pass, incl. new
rss_channel_validates_against_w3c_findings (atom link, webMaster,
pubDate = newest, positive-integer lengths, serialize round-trip),
fragment_hrefs_resolve_against_post_url,
multi_candidate_absolute_srcset_is_byte_identical.
Author
Owner

Review + follow-up round (agent session, 2026-09-14)

Core fix verified end-to-end: root-relative src/href/srcset went 38/8/169 → 0 locally and on the preview; code samples byte-identical; srcset descriptors intact; separator format matches the picture generator exactly. Merge-worthy.

Re-running the W3C validator against this PR's preview surfaced a second wave, now fixed in 3c61928 + bfbcf5b:

Finding Fix
ERROR enclosure length="" ×20 real byte size via fs::metadata; unstattable → enclosure dropped with warn!
ContainsRelRef ×25 all were fragment-only href="#anchor" TOC links → now rewritten to <post-url>#anchor; + 2 empty [text]() links fixed in post content
MissingAtomSelfLink atom:link rel=self (rss crate atom feature)
MissingRealName webMaster → email (Michal Vanko)
channel pubDate = oldest post (bug) .first() — newest item

Result: validity: true (cache-busted validator run against the refreshed preview). Remaining findings are documented opens in the spec: <p><figure> stray </p> HTML from the markdown image handler (site-wide, affects the website too — own PR), iframe policy in feed content, SVG og:image, hex-escape style in titles.

Bonus finds: the new enclosure-length logging immediately caught _posts/blog/dev-2019-08-09-ide-to copy.md — an accidental draft copy (published: true, test tags, 404 thumbnail) that was live in the feed — deleted. Also confirmed the old "feeds never land in dist/" finding is already resolved (base.html discovery links).

CI: cargo test green on bfbcf5b (46 tests, incl. 3 new feed audits).

## Review + follow-up round (agent session, 2026-09-14) **Core fix verified end-to-end**: root-relative `src`/`href`/`srcset` went 38/8/169 → 0 locally and on the preview; code samples byte-identical; srcset descriptors intact; separator format matches the picture generator exactly. Merge-worthy. Re-running the W3C validator against this PR's preview surfaced a second wave, now fixed in 3c61928 + bfbcf5b: | Finding | Fix | |---|---| | **ERROR** enclosure `length=""` ×20 | real byte size via `fs::metadata`; unstattable → enclosure dropped with `warn!` | | `ContainsRelRef` ×25 | all were fragment-only `href="#anchor"` TOC links → now rewritten to `<post-url>#anchor`; + 2 empty `[text]()` links fixed in post content | | `MissingAtomSelfLink` | `atom:link rel=self` (rss crate `atom` feature) | | `MissingRealName` | `webMaster` → `email (Michal Vanko)` | | channel `pubDate` = oldest post (bug) | `.first()` — newest item | **Result: `validity: true`** (cache-busted validator run against the refreshed preview). Remaining findings are documented opens in the spec: `<p><figure>` stray `</p>` HTML from the markdown image handler (site-wide, affects the website too — own PR), iframe policy in feed content, SVG og:image, hex-escape style in titles. Bonus finds: the new enclosure-length logging immediately caught `_posts/blog/dev-2019-08-09-ide-to copy.md` — an accidental draft copy (`published: true`, test tags, 404 thumbnail) that was live in the feed — deleted. Also confirmed the old "feeds never land in dist/" finding is already resolved (base.html discovery links). CI: cargo test green on bfbcf5b (46 tests, incl. 3 new feed audits).
just: W3C validation recipes — raw-content POST, no deploy needed
Some checks failed
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
preview / build + deploy preview (push) Has been cancelled
preview / preview hostname (pull_request) Has been cancelled
preview / build + deploy preview (pull_request) Has been cancelled
preview / teardown preview (pull_request) Has been cancelled
test / cargo test (push) Has been cancelled
b9153ab056
validate-feed / validate-html / validate (umbrella) + scripts/validate_feed.py
+ scripts/validate_html.py.

Both scripts POST the generated markup directly to the W3C services:
- feed.xml → validator.w3.org/feed/check.cgi rawdata mode (SOAP output):
  no public URL needed, results always fresh — the url= interface caches
  per URL and served us a stale verdict after a preview redeploy
- pages → validator.w3.org/nu/ direct body POST (JSON output)

Recipe targets: local (default — auto-starts a dev server on $PORT if
none is running, kills only what it started), prod, or any base URL.
STRICT=1 fails feed warnings too (SelfDoesntMatchLocation allowlisted:
rawdata submissions carry no location to compare against).

HTML mode normalizations, both documented in-flag:
- trims content after </html> (the debug-only livereload injection —
  prod/SSG output never has it); --no-trim disables
- hides CSS-checker errors (Nu's CSS knowledge lags Tailwind v4's
  view-transition-name etc.); KEEP_CSS=1 includes them

Verified: just validate-feed → VALID (exit 0), known warning backlog
(NotHtml x20 = the <p><figure> stray-</p> issue, SecurityRisk x3 =
Twitch iframes, CharacterData). just validate-html → real backlog
(<time datetime> not machine-readable, SVG sprite carrying <?xml?>/-
doctype/invalid ids, inkscape/sodipodi attrs on inlined SVGs, heading
level skips) — fixes are follow-ups, the recipes report honestly.

Services are shared public infrastructure — manual use (pre-deploy /
review), not per-push CI. AGENTS.md + spec updated.
michalvankodev force-pushed feed/absolute-urls from b9153ab056
Some checks failed
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
preview / build + deploy preview (push) Has been cancelled
preview / preview hostname (pull_request) Has been cancelled
preview / build + deploy preview (pull_request) Has been cancelled
preview / teardown preview (pull_request) Has been cancelled
test / cargo test (push) Has been cancelled
to e48a10b029
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m18s
preview / preview hostname (pull_request) Successful in 0s
preview / teardown preview (pull_request) Has been skipped
preview / build + deploy preview (push) Successful in 1m32s
preview / build + deploy preview (pull_request) Successful in 1m13s
2026-09-14 15:34:36 +02:00
Compare
specs: W3C validation findings inventory + fix specifications
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m14s
preview / preview hostname (pull_request) Successful in 0s
preview / teardown preview (pull_request) Has been skipped
preview / build + deploy preview (push) Successful in 1m20s
preview / build + deploy preview (pull_request) Successful in 1m13s
4cfcba3215
specs/w3c-validation.md — handoff doc for a separate PR/session:
- tooling recap (just validate-feed / validate-html, rawdata/body POST,
  normalizations, manual-use policy)
- current verdicts: feed VALID (3 warning types), pages INVALID with
  per-page error counts
- findings F1-F10 traced to exact source lines (sprite prolog + invalid
  dots-@ id, raw Inkscape m-logo-animated.svg + style-in-aside, bare
  view-transition-name attrs on header/footer, xmlns:cc/dct license
  markup, <time datetime> using chrono Display, heading level skips,
  <p><figure> stray-</p> nesting, iframe feed policy, title hex escapes)
- fix specs S1-S10 with files, approach, verification per finding
- suggested batching into 6 independently shippable chunks + strict-mode
  ratchet plan for the end state

All findings reproduced with just validate-feed / validate-html against
local output (2026-09-14).
content: restore dev-2019-08-09-ide-to copy.md — intentional dev article
All checks were successful
preview / preview hostname (push) Successful in 0s
preview / teardown preview (push) Has been skipped
test / cargo test (push) Successful in 1m23s
preview / build + deploy preview (push) Successful in 1m15s
preview / preview hostname (pull_request) Successful in 0s
preview / build + deploy preview (pull_request) Has been skipped
preview / teardown preview (pull_request) Successful in 0s
d1b142c24f
Reverts the deletion from 3c61928: this published post is a
development/test article, not an accident. Kept change: its thumbnail
line is commented out (/images/uploads/screenshot.gif 404s — the cause
of the enclosure-drop warning that flagged it; sibling
dev-2019-08-09-ide-to.md has it commented the same way). Spec passage
corrected to match.
Author
Owner

Correction to my earlier comment: _posts/blog/dev-2019-08-09-ide-to copy.md is an intentionally published development article — restored in d1b142c (it was deleted in 3c61928 as a presumed accident). The only kept change: its thumbnail: line stays commented out because /images/uploads/screenshot.gif 404s and trips the enclosure-length warning. Spec updated to match.

Correction to my earlier comment: `_posts/blog/dev-2019-08-09-ide-to copy.md` is an **intentionally published development article** — restored in d1b142c (it was deleted in 3c61928 as a presumed accident). The only kept change: its `thumbnail:` line stays commented out because `/images/uploads/screenshot.gif` 404s and trips the enclosure-length warning. Spec updated to match.
michalvankodev deleted branch feed/absolute-urls 2026-09-14 16:10:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
michalvankodev/michalvankodev-site!21
No description provided.