Skip to content
brainNotFound

AI Automations/Content pipeline

Convert markdown to Portable Text without losing structure

The token-to-block mapping that turns an agent's markdown into real Sanity Portable Text, including tabs and callouts.

intermediate25 min
On this page
  1. The mapping
  2. Inline marks are the fiddly part
  3. Directives for blocks markdown has no syntax for
  4. Idempotency

Markdown is what an agent writes. Portable Text is what Sanity stores. The conversion between them is where most publishing pipelines quietly lose the structure that made the content worth publishing.

The failure mode is always the same: someone converts markdown to HTML, then HTML to blocks, and every heading arrives as a paragraph with a font size. Go straight from the markdown token tree to blocks instead.

The mapping

MarkdownBlockNotes
`## Heading`block, style h2Real heading, slugified id at render
`- item`block, listItem bulletlevel tracks nesting
```` ```ts ````codeBlockLanguage becomes the highlighter hint
`> [!TIP]`callouttone derived from the marker
`| a | b |`tableBlockFirst row becomes the header
`![alt](path)`contentImageAsset uploaded, alt carried across
Markdown token → Portable Text

Inline marks are the fiddly part

Block-level mapping is mechanical. Inline content is not: Portable Text represents a paragraph as an array of spans, each carrying a list of mark keys, with link targets stored separately in markDefs. A naive implementation emits one span per character.

lib/markdown/inline.tstypescript
type Span = { _type: "span"; _key: string; text: string; marks: string[] };

/** Walks marked's inline tokens, accumulating active marks as it descends. */
function walk(tokens: Token[], active: string[], out: Span[], defs: MarkDef[]) {
  for (const token of tokens) {
    switch (token.type) {
      case "text":
        push(out, token.text, active);
        break;
      case "strong":
        walk(token.tokens, [...active, "strong"], out, defs);
        break;
      case "em":
        walk(token.tokens, [...active, "em"], out, defs);
        break;
      case "codespan":
        push(out, token.text, [...active, "code"]);
        break;
      case "link": {
        const key = nextKey();
        defs.push({ _type: "link", _key: key, href: token.href, follow: true });
        walk(token.tokens, [...active, key], out, defs);
        break;
      }
    }
  }
}

Directives for blocks markdown has no syntax for

Tabbed code, step sequences and env tables have no markdown equivalent. Rather than inventing a parser, use HTML comments as directives: they are invisible in any markdown renderer and trivial to detect in the token stream.

Directive syntaxtext
<!-- tabs -->
```bash
npm install
```
```docker
RUN npm ci
```
<!-- /tabs -->

<!-- steps: Deploy the service -->
### Build the image
Run the build with the production target.
### Push and restart
Ship it, then reload the unit.
<!-- /steps -->

Consecutive fences between <!-- tabs --> and <!-- /tabs --> merge into a single tabbed block, with each fence's language becoming its tab label. The same trick handles steps and env tables; the full syntax is documented in AGENTS.md at the repository root.

Idempotency

A publishing script that creates a new document every time it runs is a script nobody trusts. Derive a deterministic document id from type and slug, then createOrReplace — re-running becomes a patch instead of a duplicate.

typescript
const id = `${type}-${slug}`;

// Refuse to clobber a different type occupying the same id.
const existing = await client.fetch(`*[_id == $id][0]{_type}`, { id });
if (existing && existing._type !== type) {
  throw new Error(`${id} already exists as ${existing._type}`);
}

await client.createOrReplace({ _id: draft ? `drafts.${id}` : id, _type: type, ...fields });

// related

From the rest of the site.