Reader: seal the article render pipeline behind one render-article module
Turning a stored block tree into safe HTML takes three calls in one
load-bearing order — resolveBlobImageUrls → renderBlocks →
sanitizeArticleHtml — plus the plain-text fallback (textContent ||
blocksToText). That ordering is the AGENTS.md #6b invariant (sanitise
is the last step before injection), but no module owned it: the
article page and the feed builder each assembled it by hand, and a
third read surface (AMP, email, embeds) would have had to re-learn
the order — getting it wrong ships unsanitised PDS HTML.
Add src/lib/reader/render-article.ts, one deep interface:
renderArticle( doc, { pdsUrl, did } ) → { html, text }
The pipeline-order invariant gains locality — it lives once, behind
the interface, behaviourally tested in render-article.test.ts (a
<script> in block content never survives; blob image URLs are rebuilt
against the author's current PDS before render). Future read surfaces
get sanitisation for free by construction instead of by convention.
The module accepts the document-value slice rather than raw blocks so
the text fallback concentrates there too; SkyDocumentValue and the
feed's FeedDocumentValue both satisfy it structurally. Like render.ts
underneath it is dependency-free (no @wordpress/*, Decision 0003) and
render fidelity stays locked by the untouched render.test.ts.
The article page and buildPublicationFeedXml become callers; the feed
stays a pure transform with its behavioural tests unchanged. AGENTS.md
rule 6(b) now points at the module, and Decision 0017 records the
seam (closing the deepening Decision 0016 deferred).
Smoke-tested against trunk on the dev server: the article page, the
RSS body, and the 404 paths render identically (modulo dev-only
source-loc attributes and the random astro-island uid).
Reader: seal the article render pipeline behind one render-article module
Turning a stored block tree into safe HTML takes three calls in one
load-bearing order — resolveBlobImageUrls → renderBlocks →
sanitizeArticleHtml — plus the plain-text fallback (textContent ||
blocksToText). That ordering is the AGENTS.md #6b invariant (sanitise
is the last step before injection), but no module owned it: the
article page and the feed builder each assembled it by hand, and a
third read surface (AMP, email, embeds) would have had to re-learn
the order — getting it wrong ships unsanitised PDS HTML.
Add src/lib/reader/render-article.ts, one deep interface:
renderArticle( doc, { pdsUrl, did } ) → { html, text }
The pipeline-order invariant gains locality — it lives once, behind
the interface, behaviourally tested in render-article.test.ts (a
<script> in block content never survives; blob image URLs are rebuilt
against the author's current PDS before render). Future read surfaces
get sanitisation for free by construction instead of by convention.
The module accepts the document-value slice rather than raw blocks so
the text fallback concentrates there too; SkyDocumentValue and the
feed's FeedDocumentValue both satisfy it structurally. Like render.ts
underneath it is dependency-free (no @wordpress/*, Decision 0003) and
render fidelity stays locked by the untouched render.test.ts.
The article page and buildPublicationFeedXml become callers; the feed
stays a pure transform with its behavioural tests unchanged. AGENTS.md
rule 6(b) now points at the module, and Decision 0017 records the
seam (closing the deepening Decision 0016 deferred).
Smoke-tested against trunk on the dev server: the article page, the
RSS body, and the 404 paths render identically (modulo dev-only
source-loc attributes and the random astro-island uid).