A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

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).

+255 -25
+3 -1
AGENTS.md
··· 40 40 **sanitises** HTML before injecting it (`src/lib/reader/sanitize.ts`). Three standing 41 41 rules for the read path: (a) any server-side `fetch` to a host derived from user input 42 42 (a handle, `did:web`, a PDS `serviceEndpoint`) MUST go through `src/lib/net/safe-fetch.ts` 43 - (SSRF guard); (b) never inject PDS-sourced HTML without sanitising; (c) read routes 43 + (SSRF guard); (b) never inject PDS-sourced HTML without sanitising — turn document 44 + blocks into HTML through `src/lib/reader/render-article.ts`, which runs 45 + blob-resolve → render → sanitise in the one safe order (Decision 0017); (c) read routes 44 46 resolve author/publication/document through `src/lib/reader/read-context.ts` — don't 45 47 re-assemble the handle → DID → PDS → slug-match → site-join chain in page frontmatter. 46 48 (Decision 0016)
+1 -1
README.md
··· 134 134 auth/ oauth.ts · AuthProvider.tsx · config.ts · LoginForm.tsx · profile.ts · nav.ts · cta.ts 135 135 publish/ records.ts (pure builders) · publisher.ts (Agent orchestration) · publications.ts (publication CRUD) · themes.ts 136 136 media/ mediaUpload.ts · uploadImage.ts (logo) · blob.ts · pds.ts 137 - reader/ read-context.ts (read-route orchestration) · identity.ts · records.ts · publications.ts · profile.ts · sanitize.ts 137 + reader/ read-context.ts (read-route orchestration) · render-article.ts (blocks → safe HTML + text) · identity.ts · records.ts · publications.ts · profile.ts · sanitize.ts 138 138 feed/ rss.ts · publication-feed.ts (full-content RSS, hand-rolled) 139 139 seo/ meta.ts (Open Graph + Twitter card tags) 140 140 editor/ edit-link.ts (dashboard → editor edit links)
+5
docs/decisions/0007-read-through-renderer.md
··· 39 39 - `records.getRecord` / `listRecords` — public `com.atproto.repo` XRPC (no auth). 40 40 41 41 ### Rendering 42 + 43 + > **Amended by Decision 0017 (2026-06-10):** callers no longer assemble this pipeline 44 + > themselves — `src/lib/reader/render-article.ts` owns the ordering behind one 45 + > interface. The pipeline itself is unchanged. 46 + 42 47 `fetch document → resolveBlobImageUrls → renderBlocks → sanitizeArticleHtml → inject`. 43 48 - **Images:** `resolveBlobImageUrls` rebuilds each blob-backed image's `getBlob` URL from 44 49 the article's DID + the stored CID (portable across PDS migrations, Decision 0006);
+2 -1
docs/decisions/0011-full-content-rss-feeds.md
··· 42 42 `buildPublicationFeedXml` is a pure transform fed by the same `listRecords` call the 43 43 publication page already makes (`listRecords` returns each document's full `content.blocks`, 44 44 so there is **no per-article round-trip**). Each item runs through the exact reader pipeline: 45 - `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml`. The feed body is therefore 45 + `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml` (since Decision 0017, owned 46 + by `src/lib/reader/render-article.ts`). The feed body is therefore 46 47 the same sanitised HTML a browser sees (AGENTS.md #6b satisfied by reuse), and the route's 47 48 single PDS fetch goes through `safeFetch` (AGENTS.md #6a satisfied by reuse). 48 49
+2 -1
docs/decisions/0016-read-context-module.md
··· 30 30 in parallel inside the module; the old pages ran them serially. 31 31 - The article render pipeline (`resolveBlobImageUrls → renderBlocks → 32 32 sanitizeArticleHtml`) deliberately stays in the article page / feed builder — sealing 33 - it behind its own interface is a separate, orthogonal deepening. 33 + it behind its own interface is a separate, orthogonal deepening. *(Since done: 34 + Decision 0017 sealed it behind `src/lib/reader/render-article.ts`.)* 34 35 - The shallow wrappers this obsoleted (`listReaderPublications`, 35 36 `resolveReaderPublication`) were deleted; `listAllReaderPublications` remains the one 36 37 reader-side publication fetch.
+47
docs/decisions/0017-render-article-module.md
··· 1 + # 0017 — The article render pipeline lives in one render-article module 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-10 5 + - **Scope:** the article render pipeline (`src/lib/reader/render-article.ts`, the 6 + article page, `src/lib/feed/publication-feed.ts`) 7 + 8 + ## Context 9 + 10 + Turning a stored block tree into safe HTML takes three calls in one load-bearing 11 + order — `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml` — plus the 12 + plain-text fallback (`doc.textContent || blocksToText( blocks )`). The ordering is the 13 + AGENTS.md #6b invariant (sanitise is the LAST step before injection), but no module 14 + owned it: the article page and the feed builder each assembled it by hand. A third 15 + read surface (AMP, email, embeds) would have had to re-learn the order, and getting it 16 + wrong ships unsanitised PDS HTML. Decision 0016 sealed route *resolution* behind 17 + `read-context.ts` and explicitly deferred this orthogonal deepening. 18 + 19 + ## Decision 20 + 21 + One deep module, `src/lib/reader/render-article.ts`: 22 + 23 + ```ts 24 + renderArticle( doc, { pdsUrl, did } ) → { html, text } 25 + ``` 26 + 27 + - It accepts the document-value slice (`textContent` + `content.blocks`) rather than 28 + raw blocks, so the text fallback concentrates here too. `read-context.ts`'s 29 + `SkyDocumentValue` and the feed's `FeedDocumentValue` both satisfy the 30 + `RenderableDocument` parameter structurally — no adapter layer. 31 + - The pipeline-order invariant gains locality: it lives once, behaviourally tested in 32 + `render-article.test.ts` (a `<script>` in block content never survives; blob-backed 33 + image URLs are rebuilt against the author's current PDS before render). 34 + - Like `render.ts` underneath, the module is dependency-free — it must never import 35 + `@wordpress/*` (Decision 0003). It wraps `render.ts`, sitting beside it; render 36 + fidelity stays locked by `render.test.ts`, untouched. 37 + - The article page and `buildPublicationFeedXml` are now callers; 38 + `publication-feed.ts` stays a pure transform. New read surfaces get sanitisation for 39 + free by construction — AGENTS.md rule 6(b) now points here. 40 + 41 + ## Consequences 42 + 43 + - `resolveBlobImageUrls`, `renderBlocks`, and `sanitizeArticleHtml` keep their own 44 + unit suites; no page or transform should compose them by hand again 45 + (`_[rkey].meta.test.ts` pins the article page's delegation). 46 + - A surface needing different rendering rules (e.g. email's stricter tag set) extends 47 + this module's interface rather than re-assembling the pipeline at the call site.
+8 -14
src/lib/feed/publication-feed.ts
··· 1 1 /** 2 2 * Assemble a publication's full-content RSS feed (Decision 0011). 3 3 * 4 - * Pure transform — no network. It reuses the EXACT reader pipeline the article page uses 5 - * (`resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml`), so the feed body is 6 - * the same sanitised HTML a browser would see (AGENTS.md #6b), then hands off to the 7 - * test-locked `buildRssFeed`. The feed route fetches the records (SSRF-guarded) and calls 8 - * this; keeping the transform pure makes the sort/cap/filter rules unit-testable. 4 + * Pure transform — no network. It renders each item through `renderArticle` — the same 5 + * deep module the article page uses — so the feed body is the same sanitised HTML a 6 + * browser would see (AGENTS.md #6b), then hands off to the test-locked `buildRssFeed`. 7 + * The feed route fetches the records (SSRF-guarded) and calls this; keeping the 8 + * transform pure makes the sort/cap/filter rules unit-testable. 9 9 */ 10 - import { renderBlocks, blocksToText, type BlockNode } from '../blocks/render'; 11 - import { sanitizeArticleHtml } from '../reader/sanitize'; 12 - import { resolveBlobImageUrls } from '../media/blob'; 10 + import type { BlockNode } from '../blocks/render'; 11 + import { renderArticle } from '../reader/render-article'; 13 12 import { canonicalArticleUrl, publicationHomeUrl } from '../publish/records'; 14 13 import { buildRssFeed, type FeedChannel, type FeedItem } from './rss'; 15 14 import type { RepoRecord } from '../reader/records'; ··· 59 58 .slice( 0, FEED_ITEM_LIMIT ) 60 59 .map( ( record ) => { 61 60 const rkey = record.uri.split( '/' ).pop() as string; 62 - const blocks = resolveBlobImageUrls( record.value.content?.blocks ?? [], { 63 - pdsUrl, 64 - did, 65 - } ); 66 - const contentHtml = sanitizeArticleHtml( renderBlocks( blocks ) ); 67 - const text = record.value.textContent || blocksToText( blocks ); 61 + const { html: contentHtml, text } = renderArticle( record.value, { pdsUrl, did } ); 68 62 const description = 69 63 record.value.description?.trim() || 70 64 text.slice( 0, DESCRIPTION_FALLBACK_CHARS ).trim() ||
+124
src/lib/reader/render-article.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { renderArticle } from './render-article'; 3 + 4 + const AUTHOR = { pdsUrl: 'https://pds.example', did: 'did:plc:alice' }; 5 + 6 + describe( 'renderArticle', () => { 7 + it( 'renders block content to HTML', () => { 8 + const { html } = renderArticle( 9 + { 10 + content: { 11 + blocks: [ 12 + { name: 'core/heading', attributes: { level: 2, content: 'Section' } }, 13 + { name: 'core/paragraph', attributes: { content: 'A paragraph.' } }, 14 + ], 15 + }, 16 + }, 17 + AUTHOR 18 + ); 19 + expect( html ).toContain( '<h2 class="wp-block-heading">Section</h2>' ); 20 + expect( html ).toContain( '<p>A paragraph.</p>' ); 21 + } ); 22 + 23 + it( 'never lets a script in block content survive (sanitise is the last step)', () => { 24 + const { html } = renderArticle( 25 + { 26 + content: { 27 + blocks: [ 28 + { 29 + name: 'core/paragraph', 30 + attributes: { content: 'Hello <script>alert(1)</script>world' }, 31 + }, 32 + ], 33 + }, 34 + }, 35 + AUTHOR 36 + ); 37 + expect( html ).not.toContain( '<script' ); 38 + expect( html ).not.toContain( 'alert(1)' ); 39 + expect( html ).toContain( 'Hello' ); 40 + } ); 41 + 42 + it( 'strips event-handler attributes smuggled through rich text', () => { 43 + const { html } = renderArticle( 44 + { 45 + content: { 46 + blocks: [ 47 + { 48 + name: 'core/paragraph', 49 + attributes: { content: '<em onmouseover="steal()">hover me</em>' }, 50 + }, 51 + ], 52 + }, 53 + }, 54 + AUTHOR 55 + ); 56 + expect( html ).toContain( '<em>hover me</em>' ); 57 + expect( html ).not.toContain( 'onmouseover' ); 58 + } ); 59 + 60 + it( 'rewrites blob-backed image URLs to the author PDS before rendering', () => { 61 + const { html } = renderArticle( 62 + { 63 + content: { 64 + blocks: [ 65 + { 66 + name: 'core/image', 67 + attributes: { 68 + url: 'https://stale-pds.example/old-blob.png', 69 + alt: 'A photo', 70 + skypressBlob: { 71 + $type: 'blob', 72 + ref: { $link: 'bafyreidcid' }, 73 + mimeType: 'image/png', 74 + size: 1234, 75 + }, 76 + }, 77 + }, 78 + ], 79 + }, 80 + }, 81 + AUTHOR 82 + ); 83 + // `&amp;` (not `&`) — the URL came out the far side of the sanitiser. 84 + expect( html ).toContain( 85 + 'src="https://pds.example/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aalice&amp;cid=bafyreidcid"' 86 + ); 87 + expect( html ).not.toContain( 'stale-pds.example' ); 88 + } ); 89 + 90 + it( 'prefers the stored textContent for the plain-text view', () => { 91 + const { text } = renderArticle( 92 + { 93 + textContent: 'Stored summary.', 94 + content: { 95 + blocks: [ { name: 'core/paragraph', attributes: { content: 'Body.' } } ], 96 + }, 97 + }, 98 + AUTHOR 99 + ); 100 + expect( text ).toBe( 'Stored summary.' ); 101 + } ); 102 + 103 + it( 'falls back to the block text when textContent is absent or empty', () => { 104 + const { text } = renderArticle( 105 + { 106 + textContent: '', 107 + content: { 108 + blocks: [ 109 + { name: 'core/heading', attributes: { level: 2, content: 'Section' } }, 110 + { name: 'core/paragraph', attributes: { content: 'Body text.' } }, 111 + ], 112 + }, 113 + }, 114 + AUTHOR 115 + ); 116 + expect( text ).toBe( 'Section\n\nBody text.' ); 117 + } ); 118 + 119 + it( 'returns empty html and text for a document with no blocks', () => { 120 + const { html, text } = renderArticle( {}, AUTHOR ); 121 + expect( html ).toBe( '' ); 122 + expect( text ).toBe( '' ); 123 + } ); 124 + } );
+46
src/lib/reader/render-article.ts
··· 1 + /** 2 + * The render-article module: one deep interface for turning a stored document 3 + * into safe HTML + plain text (Decision 0017). 4 + * 5 + * Turning a block tree into injectable HTML requires three calls in one 6 + * load-bearing order — `resolveBlobImageUrls` → `renderBlocks` → 7 + * `sanitizeArticleHtml` — and the text fallback (`textContent || blocksToText`) 8 + * alongside it. That ordering is the AGENTS.md #6b invariant (sanitise is the 9 + * LAST step before any injection); it lives here, once, so every read surface 10 + * (article page, RSS, future AMP/email/embeds) gets it by construction instead 11 + * of by convention. Like `render.ts` underneath, this module is dependency-free 12 + * and must never import `@wordpress/*` (Decision 0003). 13 + */ 14 + import { renderBlocks, blocksToText } from '../blocks/render'; 15 + import { resolveBlobImageUrls } from '../media/blob'; 16 + import { sanitizeArticleHtml } from './sanitize'; 17 + import type { BlockNode } from '../blocks/render'; 18 + 19 + /** The slice of a `site.standard.document` value the renderer consumes. */ 20 + export interface RenderableDocument { 21 + textContent?: string; 22 + content?: { blocks?: BlockNode[] }; 23 + } 24 + 25 + export interface RenderedArticle { 26 + /** Sanitised article HTML, safe to inject (`set:html`, RSS CDATA, …). */ 27 + html: string; 28 + /** Plain text: the stored `textContent`, else derived from the blocks. */ 29 + text: string; 30 + } 31 + 32 + /** 33 + * Render a document's blocks to sanitised HTML and plain text. `author` is the 34 + * document's writer (current PDS + DID) — blob-backed image URLs are rebuilt 35 + * against it before rendering, so images survive a PDS migration. 36 + */ 37 + export function renderArticle( 38 + doc: RenderableDocument, 39 + author: { pdsUrl: string; did: string } 40 + ): RenderedArticle { 41 + const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], author ); 42 + return { 43 + html: sanitizeArticleHtml( renderBlocks( blocks ) ), 44 + text: doc.textContent || blocksToText( blocks ), 45 + }; 46 + }
+6 -7
src/pages/[author]/[slug]/[rkey].astro
··· 3 3 import PublicationFooter from '../../../components/PublicationFooter.astro'; 4 4 import { resolveArticleContext } from '../../../lib/reader/read-context'; 5 5 import { formatLongDate } from '../../../lib/reader/dates'; 6 - import { resolveBlobImageUrls } from '../../../lib/media/blob'; 7 - import { renderBlocks, blocksToText } from '../../../lib/blocks/render'; 8 - import { sanitizeArticleHtml } from '../../../lib/reader/sanitize'; 6 + import { renderArticle } from '../../../lib/reader/render-article'; 9 7 import { canonicalArticleUrl } from '../../../lib/publish/records'; 10 8 import { deriveExcerpt } from '../../../lib/publish/excerpt'; 11 9 import { themeStyleBlock } from '../../../lib/publish/themes'; ··· 26 24 const { author, slug, rkey } = Astro.params; 27 25 28 26 // Resolve author → publication → document (site-joined) behind one interface; 29 - // this page keeps the render pipeline and presentation. 27 + // rendering lives behind render-article — this page keeps only presentation. 30 28 const result = await resolveArticleContext( author, slug, rkey ); 31 29 const error = result.ok ? null : result.error; 32 30 const ctx = result.ok ? result.context : null; ··· 60 58 handle = readAuthor.handle; 61 59 62 60 const doc = document.value; 63 - const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 64 - html = sanitizeArticleHtml( renderBlocks( blocks ) ); 65 - const textContent = doc.textContent || blocksToText( blocks ); 61 + // Blob URLs, render, sanitise, and the text fallback all live in render-article. 62 + const rendered = renderArticle( doc, { pdsUrl, did } ); 63 + html = rendered.html; 64 + const textContent = rendered.text; 66 65 67 66 title = doc.title ?? 'Untitled'; 68 67 description = doc.description || deriveExcerpt( textContent );
+11
src/pages/[author]/[slug]/_[rkey].meta.test.ts
··· 62 62 expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 63 63 } ); 64 64 65 + it( 'delegates the render pipeline to the render-article module', () => { 66 + // The ordering invariant (resolve blobs → render → sanitise) is behaviourally 67 + // tested in src/lib/reader/render-article.test.ts; the page only injects. 68 + expect( page ).toMatch( 69 + /import\s*\{\s*renderArticle\s*\}\s*from\s*'[^']*lib\/reader\/render-article'/ 70 + ); 71 + expect( page ).toMatch( /renderArticle\(/ ); 72 + // No hand-assembled pipeline: these belong inside render-article now. 73 + expect( page ).not.toMatch( /sanitizeArticleHtml|renderBlocks|resolveBlobImageUrls/ ); 74 + } ); 75 + 65 76 it( 'derives the og:description fallback via deriveExcerpt (shared with publish)', () => { 66 77 expect( page ).toMatch( 67 78 /import\s*\{\s*deriveExcerpt\s*\}\s*from\s*'[^']*lib\/publish\/excerpt'/