{
"path": "/projects/bismuth",
"site": "at://did:plc:ofrbh253gwicbkc5nktqepol/site.standard.publication/3mfyq5mpohw25",
"tags": [
"atproto",
"pkgs",
"tooling",
"typescript"
],
"$type": "site.standard.document",
"title": "@ewanc26/bismuth",
"description": "Convert richtext-block documents from the Standard.site ecosystem (Leaflet, Pckt, Offprint) to Markdown — available as both a CLI tool and a TypeScript library.",
"publishedAt": "2026-03-24T00:00:00.000Z",
"textContent": "@ewanc26/bismuth converts richtext-block documents from the Standard.site platform ecosystem to Markdown. It supports all three publishing platforms — Leaflet (pub.leaflet.), Pckt (blog.pckt.), and Offprint (app.offprint.) — and ships as both a CLI tool and a TypeScript library.\n\nPart of the @ewanc26/pkgs monorepo.\n\nWhy Bismuth?\n\nStandard.site stores longform content as site.standard.document ATProto records, with the actual body represented as a block tree from one of the platform's three editors. Each editor uses its own lexicon namespace (pub.leaflet., blog.pckt., app.offprint.), but they share the same core shape: typed blocks (paragraphs, headings, code, lists, embeds) with byte-slice facet annotations for inline formatting. This is a great format for a federated document store, but it's opaque to anything outside the Standard.site ecosystem.\n\nThe gap bismuth fills: if you want to take a document from any Standard.site platform and use it somewhere else — feed it into a static site generator, archive it, diff it, or just read it in a terminal — you need something that understands the block tree and can produce plain text. Markdown is the obvious target because it preserves the document's semantic structure (headings, lists, code blocks, emphasis) without requiring a custom renderer.\n\nThe alternative is hand-rolling the same conversion every time it's needed, which is what prompted bismuth's existence. The conversion logic for facet byte-slice annotations in particular — which must be applied in reverse order to avoid index drift — is fiddly enough to be worth extracting once into a tested library.\n\nWhy Bismuth as a name?\n\nBismuth the element is known for its iridescent oxide surface — a single underlying structure that refracts into many colours depending on how you look at it. A pub.leaflet document is the same thing: one block tree that can be rendered as a rich web UI, a terminal reader, a static site, or plain Markdown depending on what's doing the rendering. The name also fits the monorepo's loose theme of naming packages after elements and minerals (see @ewanc26/malachite).\n\nInstall\n\nShips as both ESM and CJS with full TypeScript type definitions.\n\nCLI\n\nThe input JSON can be any of:\n\n- site.standard.document\n- pub.leaflet.content\n- blog.pckt.content\n- app.offprint.content\n\nLibrary\n\ndocumentToMarkdown(doc, opts?)\n\nConverts a site.standard.document to Markdown. When opts.frontmatter is true, a YAML front matter block is prepended containing title, publishedAt, description, tags, and path.\n\ncontentToMarkdown(content, opts?)\n\nConverts a pub.leaflet.content to Markdown. Multi-page documents are joined with opts.pageBreak (default \\n\\n---\\n\\n). Canvas pages emit an HTML comment since their spatial layout cannot be represented linearly.\n\npcktContentToMarkdown(content, sourceDid?, opts?)\n\nAsync. Converts a blog.pckt.content to Markdown. Pckt content can be either inline (items array) or extended (a blob reference); extended mode requires sourceDid for blob resolution and will throw if it is absent. An optional opts.blobResolver can override the default PDS resolver.\n\noffprintContentToMarkdown(content, opts?)\n\nConverts an app.offprint.content to Markdown.\n\nblockToMarkdown(block)\n\nConverts a single AnyBlock — from any of the three platforms — to a { markdown, footnotes } result.\n\napplyFacets(plaintext, facets?)\n\nApplies richtext facet byte-slice annotations to a plaintext string, returning annotated Markdown. Supports facets from all three platform namespaces (pub.leaflet.richtext.facet, blog.pckt.richtext.facet, app.offprint.richtext.facet).\n\nresolvePcktContent(content, sourceDid, resolver?)\n\nAsync. Resolves a blog.pckt.content to a flat block array, handling both inline and blob modes. Exported for cases where you want the blocks without immediately converting them.\n\ncreatePdsBlobResolver(pdsEndpoint?)\n\nReturns a BlobResolver that fetches blobs from a PDS via com.atproto.sync.getBlob. Defaults to https://bsky.network.\n\nOptions\n\n| Option | Type | Default | Description |\n| -------------- | -------------- | --------------- | ------------------------------------------------- |\n| frontmatter | boolean | false | Prepend YAML front matter (Leaflet/document only) |\n| pageBreak | string | \"\\n\\n---\\n\\n\" | Separator inserted between Leaflet pages |\n| blobResolver | BlobResolver | PDS resolver | Custom blob resolver for Pckt extended mode |\n\nBlock support\n\nAll block types from all three platforms are supported via a unified dispatcher.\n\nLeaflet (pub.leaflet.)\n\n| Block | Markdown output |\n| ------------------------ | ----------------------------------------- |\n| text | Paragraph with facet annotations |\n| header | #–###### heading |\n| blockquote | > ... |\n| code | Fenced code block |\n| horizontalRule | --- |\n| image | (blob refs have no public URL) |\n| math | $$ block |\n| button | text or plain text |\n| bskyPost | Linked blockquote |\n| iframe | Raw <iframe> HTML |\n| website | title with optional description |\n| orderedList | Numbered list (with nesting) |\n| unorderedList | Bullet list (with nesting) |\n| canvas, poll, page | HTML comment |\n\nPckt (blog.pckt.)\n\n| Block | Markdown output |\n| ---------------- | -------------------------------- |\n| text | Paragraph with facet annotations |\n| heading | #–###### heading |\n| blockquote | > ... (content array) |\n| horizontalRule | --- |\n| image | !alt via attrs.src |\n| bulletList | Bullet list (with nesting) |\n| orderedList | Numbered list (with nesting) |\n\nOffprint (app.offprint.)\n\n| Block | Markdown output |\n| ---------------- | ----------------------------------------- |\n| text | Paragraph with facet annotations |\n| heading | #–###### heading |\n| blockquote | > ... (content array) |\n| codeBlock | Fenced code block |\n| horizontalRule | --- |\n| image | (blob ref, no public URL) |\n| bulletList | Bullet list (with nesting) |\n| orderedList | Numbered list (with nesting) |\n| taskList | - [x]/- [ ] task list (with nesting) |\n| webEmbed | title with optional description |\n| blueskyPost | Linked blockquote via at:// URI |\n\nFacet support\n\nFacets from all three platform namespaces are normalised to a shared internal representation before being applied. The byteStart/byteEnd byte offsets are handled correctly for multi-byte UTF-8 characters.\n\n| Facet | Markdown |\n| ------------------------ | ------------------------------------------------------------- |\n| bold | text |\n| italic | text* |\n| code | ` text |\n| link | text |\n| strikethrough | ~~text~~ |\n| underline | <u>text</u> |\n| highlight | ==text==; Offprint colour variant uses <mark style=\"...\"> |\n| footnote | text[^n] + definition block (Leaflet only) |\n| mention / didMention | Appends (@handle) link if handle is available |\n| webMention | text (Offprint only) |\n| atMention, id` | Pass-through (no Markdown equivalent) |\n\nLicence\n\nAGPL-3.0-only — see the pkgs monorepo.",
"canonicalUrl": "https://docs.ewancroft.uk/projects/bismuth"
}