Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/guard-unparsable-image-src.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@portabletext/markdown': patch
---

fix: serialize images the parser would refuse as `json:object` fences

An `image` whose `src` a Markdown parser would refuse (a `data:` URI outside `png`/`gif`/`jpeg`/`webp`, or a `javascript:`/`vbscript:`/`file:` URI) previously serialized as `![alt](src)` anyway; reparsing that markdown turned the image into literal text instead of an image object, destroying it. Such an image now serializes as a `json:object` fence (or, inline, a tagged code span), which reparses back to the identical image value. Images with an accepted `src` are unaffected.
2 changes: 1 addition & 1 deletion packages/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ The conversion is driven by **Renderers**: functions that render Portable Text e

Unknown types render as JSON code blocks by default; unknown styles, list items, and marks pass through their children.

The default type renderers are collision-safe: because the serializer dispatches on the `_type` name alone, `code`, `html`, `image`, `callout`, and `table` fall back to the `unknownType` renderer (a JSON code block) when a value doesn't match the shape their renderer expects (say, your own differently-shaped `code` type); `horizontal-rule` has no shape to check and always renders `---`. Register your own `types.<name>` renderer to override how any of them serialize, or to handle a same-named type of a different shape.
The default type renderers are collision-safe: because the serializer dispatches on the `_type` name alone, `code`, `html`, `image`, `callout`, and `table` fall back to the `unknownType` renderer (a JSON code block) when a value doesn't match the shape their renderer expects (say, your own differently-shaped `code` type); `horizontal-rule` has no shape to check and always renders `---`. `image` also falls back when `src` is a string a Markdown parser would refuse (a `javascript:`/`vbscript:`/`file:` URI, or a `data:` URI outside `png`/`gif`/`jpeg`/`webp`), so the value survives as a `json:object` fence instead of reparsing as literal text. Register your own `types.<name>` renderer to override how any of them serialize, or to handle a same-named type of a different shape.

> **Note:** The `underline` renderer is included for Portable Text that uses it, but there's no standard Markdown syntax for underline, so it renders as HTML.

Expand Down
12 changes: 11 additions & 1 deletion packages/markdown/src/from-portable-text/renderers/type.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {isTypedObject} from '@portabletext/schema'
import {isPortableTextBlock} from '@portabletext/toolkit'
import type {PortableTextBlock, TypedObject} from '@portabletext/types'
import markdownit from 'markdown-it'
import {
escapeImageAndLinkText,
escapeImageAndLinkTitle,
Expand Down Expand Up @@ -82,7 +83,7 @@ export const DefaultImageRenderer: PortableTextTypeRenderer<{
alt: string | undefined
title: string | undefined
}> = (options) => {
if (!isImageShaped(options.value)) {
if (!isImageShaped(options.value) || !linkValidator(options.value.src)) {
return DefaultUnknownTypeRenderer(options)
}
const alt = escapeImageAndLinkText(options.value.alt ?? '')
Expand All @@ -92,6 +93,15 @@ export const DefaultImageRenderer: PortableTextTypeRenderer<{
return `![${alt}](${options.value.src}${title})`
}

// The markdown-it instance the parse side builds
// (`to-portable-text/markdown-to-portable-text.ts`) never overrides
// `validateLink`, so this default instance's validator is the one that
// will run on reparse. It rejects `javascript:`/`vbscript:`/`file:` and
// all `data:` URIs except png/gif/jpeg/webp; a `src` it rejects would
// reparse as literal text instead of an image, so such a `src` is
// guarded here the same way a malformed image shape is.
const linkValidator = new markdownit().validateLink

function isImageShaped(value: unknown): value is {
src: string
alt: string | null | undefined
Expand Down
83 changes: 83 additions & 0 deletions packages/markdown/src/portable-text-to-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,12 +1432,95 @@ describe(portableTextToMarkdown.name, () => {
'![](https://example.com/image.png)',
)
})

test('a `src` the parser refuses (an SVG data URI) falls back to fenced JSON', () => {
const keyGenerator = createTestKeyGenerator()
const value = {
_type: 'image',
_key: keyGenerator(),
src: 'data:image/svg+xml;base64,PHN2Zy8+',
alt: 'logo',
}

expect(portableTextToMarkdown([value])).toBe(
['```json:object', JSON.stringify(value, null, 2), '```'].join('\n'),
)
})

test('a `src` the parser accepts (a PNG data URI) keeps its markdown form', () => {
const keyGenerator = createTestKeyGenerator()
const value = {
_type: 'image',
_key: keyGenerator(),
src: 'data:image/png;base64,iVBORw0KGgo=',
alt: 'logo',
}

expect(portableTextToMarkdown([value])).toBe(
'![logo](data:image/png;base64,iVBORw0KGgo=)',
)
})

test('a `javascript:` protocol `src` falls back to fenced JSON', () => {
const keyGenerator = createTestKeyGenerator()
const value = {
_type: 'image',
_key: keyGenerator(),
src: 'javascript:alert(1)',
alt: 'logo',
}

expect(portableTextToMarkdown([value])).toBe(
['```json:object', JSON.stringify(value, null, 2), '```'].join('\n'),
)
})

test('the fenced form round-trips back to the identical image value', () => {
const keyGenerator = createTestKeyGenerator()
const value = {
_type: 'image',
_key: keyGenerator(),
src: 'data:image/svg+xml;base64,PHN2Zy8+',
alt: 'logo',
}
const markdown = portableTextToMarkdown([value])

expect(
markdownToPortableText(markdown, {
keyGenerator: createTestKeyGenerator(),
}),
).toEqual([value])
})
})

describe('inline image', () => {
const keyGenerator = createTestKeyGenerator()
const markdown = 'foo ![alt text](https://example.com/image.png) bar'

test('a `src` the parser refuses (an SVG data URI) falls back to the tagged code span', () => {
const inlineImageValue = {
_type: 'image',
_key: 'img1',
src: 'data:image/svg+xml;base64,PHN2Zy8+',
alt: 'logo',
}
expect(
portableTextToMarkdown([
{
_type: 'block',
_key: 'b1',
style: 'normal',
markDefs: [],
children: [
{_type: 'span', _key: 's1', text: 'before ', marks: []},
inlineImageValue,
{_type: 'span', _key: 's2', text: ' after', marks: []},
],
},
]),
).toBe(`before json:object\`${JSON.stringify(inlineImageValue)}\` after`)
})

describe('supported by deserializer', () => {
const portableText = markdownToPortableText(markdown, {keyGenerator})

Expand Down
Loading