# 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`.