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.
On this page
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
| Markdown | Block | Notes |
|---|---|---|
| `## Heading` | block, style h2 | Real heading, slugified id at render |
| `- item` | block, listItem bullet | level tracks nesting |
| ```` ```ts ```` | codeBlock | Language becomes the highlighter hint |
| `> [!TIP]` | callout | tone derived from the marker |
| `| a | b |` | tableBlock | First row becomes the header |
| `` | contentImage | Asset uploaded, alt carried across |
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.
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.
<!-- 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.
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.
Portable Text That Ranks: Structuring Sanity Content for Search
How to model rich article bodies in Sanity so the HTML that comes out is semantic, linkable and genuinely crawlable.
Building an AI Content Pipeline That Doesn't Publish Slop
Where language models genuinely help a publishing workflow, where they quietly destroy it, and how to wire the difference.

