From 76d646410b6477caab90dfd9ff2bd941a86a3375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Melv=C3=A6r?= Date: Thu, 12 Feb 2026 01:07:08 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Portable=20Text=20=E2=86=94=20Markdown=20?= =?UTF-8?q?rule,=20fix=20legacy=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New rule: pte-to-markdown.md - Bidirectional conversion with @portabletext/markdown v1.1.2 - portableTextToMarkdown with custom renderers (not serializers) - markdownToPortableText with schema-aware matchers - Built-in DefaultCodeBlockRenderer, DefaultImageRenderer, etc. - @portabletext/sanity-bridge for Sanity schema integration - Next.js route handler for AI-friendly markdown endpoints Fix: sanity-migration.mdc - Replace deprecated @sanity/block-content-to-markdown reference - Recommend @portabletext/markdown for direct Markdown → PT conversion - Keep HTML → PT path as alternative for complex cases Based on: - Package README: https://github.com/portabletext/editor/tree/main/packages/markdown - Sanity Learn: https://www.sanity.io/learn/course/markdown-routes-with-nextjs --- rules/sanity-migration.mdc | 22 +- .../rules/pte-to-markdown.md | 201 ++++++++++++++++++ 2 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 skills/sanity-best-practices/rules/pte-to-markdown.md diff --git a/rules/sanity-migration.mdc b/rules/sanity-migration.mdc index cf8bd6f..48632a7 100644 --- a/rules/sanity-migration.mdc +++ b/rules/sanity-migration.mdc @@ -40,18 +40,24 @@ const blocks = htmlToBlocks(htmlString, blockContentType, { ``` ## 2. Markdown Import (Static Sites) -Use `@sanity/block-content-to-markdown` (legacy name, often used in reverse) OR use a dedicated parser like `remark` to convert Markdown to HTML, then use `block-tools`. +Use `@portabletext/markdown` for direct, schema-aware Markdown ↔ Portable Text conversion. -**Recommended Path: Markdown -> HTML -> Portable Text** -This is often more robust than direct Markdown-to-PT parsers because `block-tools` handles schema validation better. +**Recommended: Direct Conversion with `@portabletext/markdown`** +```typescript +import {markdownToPortableText} from '@portabletext/markdown' + +const blocks = markdownToPortableText(markdownString) +``` + +This handles headings, lists, bold, italic, code, links, images, and tables. It's schema-aware — use `@portabletext/sanity-bridge` to pass your Sanity schema so only valid types are produced. + +**Alternative: Markdown → HTML → Portable Text** +For complex Markdown with non-standard extensions, convert to HTML first, then use `htmlToBlocks` (see above). 1. **Parse:** `marked` or `remark` to convert MD to HTML. -2. **Convert:** Use `htmlToBlocks` (see above). +2. **Convert:** Use `htmlToBlocks` from `@portabletext/block-tools`. -**Alternative: Direct Parsing** -If using a library like `markdown-to-sanity` or writing a custom `remark` serializer: -- Ensure you handle "inline" vs "block" nodes correctly. -- Map images to Sanity asset uploads. +> **Note:** `@sanity/block-content-to-markdown` is deprecated. Use `@portabletext/markdown` instead. ## 3. Image Handling (Universal) Don't just link to external images. Download them and upload to Sanity Asset Pipeline. diff --git a/skills/sanity-best-practices/rules/pte-to-markdown.md b/skills/sanity-best-practices/rules/pte-to-markdown.md new file mode 100644 index 0000000..4a4941c --- /dev/null +++ b/skills/sanity-best-practices/rules/pte-to-markdown.md @@ -0,0 +1,201 @@ +--- +title: Convert Between Portable Text and Markdown +description: Use @portabletext/markdown for bidirectional conversion between Portable Text and Markdown +tags: portable-text, markdown, ai, content-negotiation, rendering, migration +--- + +## Convert Between Portable Text and Markdown + +Use `@portabletext/markdown` for bidirectional conversion between Portable Text and Markdown. This enables AI-friendly content routes, Markdown-based content ingestion, documentation exports, and multi-format delivery. + +### Setup + +```bash +npm install @portabletext/markdown +``` + +### Portable Text → Markdown + +```typescript +import {portableTextToMarkdown} from '@portabletext/markdown' + +const markdown = portableTextToMarkdown([ + { + _type: 'block', + _key: 'f4s8k2', + style: 'h1', + children: [{_type: 'span', _key: 'a9c3x1', text: 'Hello ', marks: []}, + {_type: 'span', _key: 'b7d2m5', text: 'world', marks: ['strong']}], + markDefs: [], + }, +]) +// # Hello **world** +``` + +Standard blocks, marks, and lists are handled automatically: + +| Portable Text | Markdown | +|---------------|----------| +| `style: 'h1'`–`'h6'` | `#`–`######` | +| `marks: ['strong']` | `**bold**` | +| `marks: ['em']` | `_italic_` | +| `marks: ['code']` | `` `code` `` | +| `marks: ['strike-through']` | `~~text~~` | +| Link annotation | `[text](url)` | +| Bullet list | `- item` | +| Number list | `1. item` | +| Blockquote | `> text` | + +#### Custom Renderers + +Handle custom block types with renderer functions. The library exports default renderers for common types: + +```typescript +import { + portableTextToMarkdown, + DefaultCodeBlockRenderer, + DefaultImageRenderer, + DefaultHorizontalRuleRenderer, + DefaultTableRenderer, +} from '@portabletext/markdown' + +const markdown = portableTextToMarkdown(blocks, { + // Built-in renderers for standard block objects + types: { + 'code': DefaultCodeBlockRenderer, // {code, language?} → fenced code block + 'image': DefaultImageRenderer, // {src, alt?, title?} → ![alt](src) + 'horizontal-rule': DefaultHorizontalRuleRenderer, // → --- + 'table': DefaultTableRenderer, // {rows, headerRows?} → markdown table + }, +}) +``` + +For custom types, write your own renderer: + +```typescript +const markdown = portableTextToMarkdown(blocks, { + types: { + // Custom callout block + callout: ({value}) => `> **${value.title}**\n> ${value.text}`, + + // Sanity image with CDN URL + image: ({value, isInline}) => { + if (isInline) return '' + const url = imageUrlBuilder(client).image(value.asset).url() + return `![${value.alt || ''}](${url})` + }, + }, + + // Override block style rendering + block: { + h1: ({children}) => `# ${children} #`, + }, + + // Override mark rendering + marks: { + internalLink: ({value, children}) => `[${children}](${value.href})`, + }, +}) +``` + +Renderers receive contextual props: +- **Block renderers** (`block.*`): `{value, children, index}` +- **Mark renderers** (`marks.*`): `{value, children, text, markType, markKey}` +- **Type renderers** (`types.*`): `{value, index, isInline}` + +### Markdown → Portable Text + +```typescript +import {markdownToPortableText} from '@portabletext/markdown' + +const blocks = markdownToPortableText('# Hello **world**') +// Returns valid Portable Text blocks with _type, _key, children, markDefs +``` + +The conversion respects a schema and uses matchers to map Markdown elements to Portable Text types. + +#### With a Sanity Schema + +Use `@portabletext/sanity-bridge` to convert your Sanity schema: + +```typescript +import {markdownToPortableText} from '@portabletext/markdown' +import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge' + +const schema = sanitySchemaToPortableTextSchema(sanityBlockArraySchema) +const blocks = markdownToPortableText(markdown, {schema}) +``` + +#### Custom Matchers + +Control how Markdown elements map to your schema types: + +```typescript +import {markdownToPortableText} from '@portabletext/markdown' +import {compileSchema, defineSchema} from '@portabletext/schema' + +const blocks = markdownToPortableText(markdown, { + schema: compileSchema( + defineSchema({ + styles: [{name: 'normal'}, {name: 'heading 1'}], + }), + ), + block: { + h1: ({context}) => { + const style = context.schema.styles.find((s) => s.name === 'heading 1') + return style?.name + }, + }, +}) +``` + +### Next.js Markdown Route Handler + +Serve content as Markdown via a route handler: + +```typescript +// app/docs/[slug]/markdown/route.ts +import {NextRequest, NextResponse} from 'next/server' +import {portableTextToMarkdown, DefaultCodeBlockRenderer} from '@portabletext/markdown' +import {client} from '@/sanity/lib/client' + +export async function GET( + request: NextRequest, + {params}: {params: Promise<{slug: string}>}, +) { + const {slug} = await params + const doc = await client.fetch( + `*[_type == "article" && slug.current == $slug][0]{ + title, "slug": slug.current, description, body + }`, + {slug}, + ) + + if (!doc) return new NextResponse('Not found', {status: 404}) + + const body = portableTextToMarkdown(doc.body, { + types: {code: DefaultCodeBlockRenderer}, + }) + + const parts = [`# ${doc.title}`, ''] + if (doc.description) parts.push(doc.description, '') + parts.push('---', '', body) + + return new NextResponse(parts.join('\n'), { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, s-maxage=3600', + }, + }) +} +``` + +### Why This Matters + +- **AI agents** parse Markdown far more reliably than HTML +- **Content ingestion** — import Markdown docs directly into Portable Text +- **Bidirectional** — one library handles both directions +- **Schema-aware** — respects your content model in both directions +- **No content duplication** — one Portable Text source, multiple outputs + +Reference: [Markdown Routes with Next.js](https://www.sanity.io/learn/course/markdown-routes-with-nextjs) · [Package README](https://github.com/portabletext/editor/tree/main/packages/markdown)