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).
Replace isolated-block-editor with @wordpress/block-editor directly
Compose SkyEditor from the Gutenberg block-editor packages directly
instead of wrapping @automattic/isolated-block-editor, and upgrade the
whole @wordpress/* tree from IBE's frozen line to the current release.
IBE is effectively maintenance-only (Dependabot-only commits, README
self-describes as "experimental", pins Gutenberg 16.9) and it forced
the entire @wordpress/* tree to be version-pinned via a ~60-package
overrides map — what Decision 0003 called the project's biggest
maintenance liability. That override map only existed to reconcile
IBE's old pinned line against transitive caret ranges floating to a
newer one. Depending on @wordpress/block-editor directly at one
current line removes that collision: the tree resolves to a single
coherent copy of every store singleton with no overrides, so upgrading
becomes a normal version bump instead of regenerating the map.
SkyEditor now wires BlockEditorProvider over a header toolbar (Inserter
+ a fixed BlockToolbar + undo/redo + a BlockInspector cog popover) and
the canvas (BlockTools / WritingFlow / ObserveTyping / BlockList), with
app-level undo via useStateWithHistory. The prop contract, curated
allowlist, @-mention format/autocompleter, and media-upload filter are
unchanged. The reader/render split (Decision 0003, Finding 1) is
untouched — reading pages still use the dependency-free render.ts and
the render-fidelity test-lock still passes against the new packages.
Two sharp edges, both recorded in Decision 0021 and AGENTS.md:
- core-data/notices/date install as nested copies with no hoisted
top-level one, so each registers its store ("Store 'core' is already
registered"). Fixed with npm dedupe + an expanded resolve.dedupe in
both astro.config and vitest.config (deduping before the hoist breaks
the build).
- In vitest, @wordpress/* must be Vite-inlined (Node rejects
@wordpress/blocks' attribute-less JSON import) while moment stays
external, or moment-timezone's augmentation of moment breaks.
The floating block toolbar needs iframe/content-ref plumbing a bespoke
inline canvas doesn't provide, so a fixed BlockToolbar is placed in the
header per the framework's guidance for custom editors.
Verified: npm run check (0 errors), npm test (592 pass incl. the render
fidelity lock), npm run build, and an in-browser smoke test of /write
on the production preview (boot, insert, type, draft-save, undo/redo,
publish-enable, allowlist, clean console).