Offprint export fixes & operational notes #
Written after the Offprint app developer reviewed an exported batch and reported four issues. All were reproduced, fixed, and re-exported. This doc captures the fixes and the operational gotchas discovered while re-running so a future agent doesn't repeat the debugging.
Block conversion fixes (src/html-to-offprint.ts) #
The authoritative reference for correct Offprint block shapes is the "All
available blocks" record used as a fixture:
tests/fixtures/offprint-all-blocks-feb-2026.json.
-
app.offprint.block.imageGridentries must useimage, notblob. Every grid entry carries the uploaded blob underimage:{ "image": { "$type": "blob", "ref": {"$link": "..."}, "mimeType": "image/jpeg", "size": 123 }, "alt": "...", "aspectRatio": {"width":1066,"height":1222} }GhostOff was emitting
blob, which Offprint's schema ignores, so entries degraded to{ "_mediaId": null, "aspectRatio": {...} }and rendered blank. Cover images and singleapp.offprint.block.imageblocks were already correct because they usedimage. -
app.offprint.block.blueskyPostusespost: { uri, cid }, notpostRefwith a nested$type. Prefer thedata-bluesky-urianddata-bluesky-cidattributes Ghost puts on the embed blockquote (no extra network call). Only fall back toresolveBlueskyPostRefwhen the CID is absent; fall back towebEmbedotherwise. Never emitpost: { uri: "", cid: "" }. -
Blockquote: Ghost emits quoted text as a direct text node (no
<p>wrapper), optionally with inline<a>/<strong>/<br>children. Iterating only elementchildrenproducedcontent: []and dropped the quote. If the blockquote has a direct text node, render the whole blockquote as one inline text block. -
Lists: unwrap container-wrapped
<li>s, drop genuinely empty lists, and stop merging nested-list text into the parent item. A<ul><div><li>..(or empty<ul></ul>) previously emittedchildren: [].
Tests covering all four: tests/converter.test.ts (run via npm test).
Re-run operational lessons #
These tripped up the live re-export and are not in the block converter:
-
Account/PDS must match the records. The GhostOff records live under
atprotocol.dev→did:plc:bbj62ki3gmlsjfzsr3dxbknaon PDShttps://selfhosted.social, not the default bmann.ca account. SetATP_IDENTIFIER=atprotocol.dev. The.envis updated for our testing. -
state.jsonis per-account/PDS. Cached blob refs and record rkeys are pinned to the account that created them. Importing into a different account reuses old blob refs, so new records reference blobs the new PDS doesn't have →putRecord … failed: Could not find blob. The CLI now detects this: on login it compares the account DID stored instate.postswith the logged-in account and, on a mismatch, warns and treats the run as a fresh import (re-uploads assets, creates new records). For a normal idempotent re-run on the same account nothing changes. -
putRecordswapRecordstaleness. If a record was updated elsewhere (a dashboard/sync), the stored CID is stale andputRecordrejects withRecord was at <cid>.src/atproto.tsnow retries once withoutswapRecord(unconditional update) on that error. -
OAuth is interactive. With no
ATP_APP_PASSWORD, the CLI opens a browser for OAuth. Useprintf 'Y\n' | node dist/index.jsto answer the confirm prompt, then complete auth in the browser. -
PDS blob indexing lag.
com.atproto.sync.getBlob/listBlobson selfhosted.social can returnBlob not foundfor freshly uploaded blobs for a short window.putRecordvalidates blob refs at write time, so a successful write is authoritative — don't treat agetBlob400 as a missing blob. -
Offprint ingests on a ~5-minute poll (not a live firehose) for now. After re-export, records may need that poll (or a manual dashboard sync) to refresh.
Verify #
Check a record directly instead of relying on the renderer:
curl -s "https://selfhosted.social/xrpc/com.atproto.repo.getRecord?repo=did:plc:bbj62ki3gmlsjfzsr3dxbkna&collection=site.standard.document&rkey=<rkey>"
W for ATProto (rkey 3mpnnekkw2n2l) is a good test case: it has an imageGrid
and a blueskyPost. Expect imageGrid.images[*] to have an image key and
blueskyPost.post to have a non-empty uri/cid.