# 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`.
1. **`app.offprint.block.imageGrid` entries must use `image`, not `blob`.**
Every grid entry carries the uploaded blob under `image`:
```json
{ "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 single `app.offprint.block.image` blocks were already
correct because they used `image`.
2. **`app.offprint.block.blueskyPost` uses `post: { uri, cid }`, not
`postRef` with a nested `$type`.** Prefer the `data-bluesky-uri` **and**
`data-bluesky-cid` attributes Ghost puts on the embed blockquote (no extra
network call). Only fall back to `resolveBlueskyPostRef` when the CID is
absent; fall back to `webEmbed` otherwise. Never emit `post: { uri: "", cid: "" }`.
3. **Blockquote: Ghost emits quoted text as a direct text node** (no `
`
wrapper), optionally with inline `//
` children. Iterating only
element `children` produced `content: []` and dropped the quote. If the
blockquote has a direct text node, render the whole blockquote as one inline
text block.
4. **Lists: unwrap container-wrapped ``s, drop genuinely empty lists, and
stop merging nested-list text into the parent item.** A `- ..` (or
empty ``) previously emitted `children: []`.
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:bbj62ki3gmlsjfzsr3dxbkna` on PDS
`https://selfhosted.social`, not the default bmann.ca account. Set
`ATP_IDENTIFIER=atprotocol.dev`. The `.env` is updated for our testing.
- **`state.json` is 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 in `state.posts` with 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.
- **`putRecord` `swapRecord` staleness.** If a record was updated elsewhere (a
dashboard/sync), the stored CID is stale and `putRecord` rejects with
`Record was at `. `src/atproto.ts` now retries once without `swapRecord`
(unconditional update) on that error.
- **OAuth is interactive.** With no `ATP_APP_PASSWORD`, the CLI opens a browser
for OAuth. Use `printf 'Y\n' | node dist/index.js` to answer the confirm
prompt, then complete auth in the browser.
- **PDS blob indexing lag.** `com.atproto.sync.getBlob` / `listBlobs` on
selfhosted.social can return `Blob not found` for freshly uploaded blobs for a
short window. `putRecord` validates blob refs at write time, so a successful
write is authoritative — don't treat a `getBlob` 400 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="
```
`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`.