Migrating content using the Ghost API to Offprint / Standard Site on atproto
ghostoff docs offprint-feedback-fixes.md
4.5 kB · 85 lines
Markdown
at main

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:

    { "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 <p> wrapper), optionally with inline <a>/<strong>/<br> 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 <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 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 <cid>. 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=<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.