Skip to content

Add opt-in ~repair to recover almost-XHTML feeds - #17

Open
cuihtlauac wants to merge 1 commit into
tarides:masterfrom
cuihtlauac:optional-void-element-repair
Open

Add opt-in ~repair to recover almost-XHTML feeds#17
cuihtlauac wants to merge 1 commit into
tarides:masterfrom
cuihtlauac:optional-void-element-repair

Conversation

@cuihtlauac

Copy link
Copy Markdown
Member

Problem

Some feeds declare <content type="xhtml"> — which RFC 4287 requires to be well-formed XML — but embed HTML void elements that aren't self-closed. A real example is https://mirage.io/feed.xml, which mixes three <img> shapes:

  • <img ... /> — correct ✅
  • <img ...> — bare/unclosed ❌
  • <img ...></img> — redundant end tag (valid XML, but common)

xmlm (and therefore Syndic) rejects the entire feed on the unclosed ones, so River.fetch fails with Failure "Neither Atom nor RSS2 feed" even though the feed is otherwise perfectly good Atom. (Context: ocaml/ocaml.org#3753.)

Change

Add an opt-in ?repair flag to River.fetch (default false). When set, and only if the document fails to parse as-is, River retries after rewriting HTML void elements into self-closing form. Feeds that already parse are never modified, so default behaviour is unchanged and the fast path pays nothing.

  • sanitize_void_elements — two passes: collapse <v ...></v><v ... />, then self-close bare <v ...>, for the 13 HTML void elements. Conservative: only raw occurrences are touched; escaped type="html" content has no literal <, so it's never affected.
  • River.of_string ?repair source xml — parse an in-memory document with no HTTP request (used by the tests; handy for callers that fetch themselves).
  • sanitize_void_elements is exposed for testing/reuse.

Tests

test/test_repair.ml covers the unclosed / redundantly-closed / already-self-closed shapes, asserts the raw feed is rejected without ~repair and recovered with it, and checks a well-formed feed is left byte-for-byte untouched. Verified locally against the real mirage.io feed (78 entries recovered).

Notes

  • No new dependencies (str was already a lib dependency).
  • Hand-formatted; I couldn't run the repo's pinned ocamlformat, but make fmt uses --auto-promote so CI reconciles it.

Some feeds declare <content type="xhtml"> (which RFC 4287 requires to be
well-formed XML) but embed HTML void elements that are not self-closed
(<img ...>) or are closed with a redundant end tag (<img ...></img>).
xmlm, and therefore Syndic, then rejects the entire feed and River.fetch
fails with "Neither Atom nor RSS2 feed".

Add an opt-in `?repair` flag to `River.fetch`. When set, and only when the
document fails to parse as-is, River retries after rewriting HTML void
elements into self-closing form (`sanitize_void_elements`). Feeds that
already parse are never touched, so the default behaviour is unchanged.

Also add `River.of_string` (parse an in-memory document, no HTTP — useful
for testing and callers that fetch themselves) and expose
`River.sanitize_void_elements`. Includes tests covering the unclosed,
redundantly-closed and already-self-closed shapes, plus a well-formed
feed left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cuihtlauac added a commit to ocaml/ocaml.org that referenced this pull request Aug 26, 2026
The MirageOS feed is valid Atom but its type="xhtml" content embeds
unclosed HTML void elements (<img>), which strict XML parsing rejects,
so the whole feed fails with "Neither Atom nor RSS2 feed".

river gained an opt-in ~repair flag (tarides/river#17) that self-closes
void elements before parsing, only when the feed fails to parse as-is.
Wire it through the planet scraper:

- planet-sources.yml: new per-source `repair:` key; set on mirage.
- blog_scraper: parse the key and pass ~repair to River.fetch (alongside
  the existing ~timeout/~user_agent). The flag is a scrape-time concern,
  so it is kept out of the published Blog.source data model (carried in a
  local `resolved` record instead).
- blog_parser: accept and ignore the new key so the site build still reads
  the file.
- repoint the existing river git pin to a fork branch that is river 0.5
  (timeout) plus ~repair, until tarides/river#17 is released.

repair is opt-in per feed, so it only affects feeds we have verified are
recoverable this way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant