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
33 changes: 33 additions & 0 deletions .changeset/gate-default-renderers-on-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@portabletext/markdown': minor
---

feat: gate the default markdown renderers on the schema in `portableTextToMarkdown`

`portableTextToMarkdown` now accepts an optional `schema`. When set, each built-in type renderer (`callout`, `code`, `horizontal-rule`, `html`, `image`, `table`) runs only when the schema declares that type at the matching position (`blockObjects` for a block, `inlineObjects` for an inline object); an undeclared type falls back to `unknownType`, whose default output is a `json:object` fence (or tagged code span, inline) that reparses back to the same value under the same schema. Renderers you pass in `types` are never gated. The gate checks the type name only, so declare each type with its fields: `markdownToPortableText` cannot rebuild a value from a fieldless declaration. Omit `schema` and every default renderer stays active, as before.


```ts
import {compileSchema, defineSchema} from '@portabletext/schema'
import {portableTextToMarkdown} from '@portabletext/markdown'

const schema = compileSchema(
defineSchema({
blockObjects: [
{
name: 'code',
fields: [
{name: 'code', type: 'string'},
{name: 'language', type: 'string'},
],
},
],
}),
)

portableTextToMarkdown(blocks, {schema})
// a `code` block renders as a fenced code block; a `table` block
// (undeclared) renders as a `json:object` fence instead
```

Pass the same schema to `markdownToPortableText` to keep the round trip consistent.
11 changes: 11 additions & 0 deletions .changeset/inline-carrier-in-table-cells.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@portabletext/markdown': patch
---

fix: use the inline `json:object` carrier for objects inside table cells

In `portableTextToMarkdown`, an object inside a table cell that renders through the `json:object` carrier now uses the carrier's single-line inline form instead of a block fence squashed with `<br>`.

In `markdownToPortableText`, a carrier object standing alone in a table cell is placed by the schema: it comes back at block position in the cell (`cell.value`), unless the schema declares the type inline-only, in which case it stays an inline child of the cell's text block. The same placement rule now governs standalone images, so under a schema without a block-level `image`, an image alone in a cell stays inline (reported as `image-block-to-inline`) instead of being lifted to block position.

Together the two sides make objects in table cells survive the serialize-edit-reparse round trip.
36 changes: 35 additions & 1 deletion packages/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,41 @@ portableTextToMarkdown(blocks, {

By default, unknown types render as `json:object` fences or tagged code spans that round-trip (see [Round-trip behavior](#round-trip-behavior)), and unknown marks/styles pass through their children unchanged.

You can also customize hard break rendering:
#### Gating default renderers on a schema

Going to convert the markdown back with `markdownToPortableText`? Pass the same `schema` to both, and nothing the schema can't rebuild becomes markdown that gets destroyed on the way back: undeclared types travel as `json:object` fences that reparse to the same value.

```ts
import {compileSchema, defineSchema} from '@portabletext/schema'

const schema = compileSchema(
defineSchema({
blockObjects: [
{
name: 'code',
fields: [
{name: 'code', type: 'string'},
{name: 'language', type: 'string'},
],
},
],
}),
)

portableTextToMarkdown(blocks, {schema})
```

A default renderer (`callout`, `code`, `horizontal-rule`, `html`, `image`, `table`) runs only when the schema declares that type at the position the node appears in: `blockObjects` for a block, `inlineObjects` for an inline object. An `image` declared in only one of the two still falls back to `unknownType` at the other position.

The gate reads type names, never field values: declaring a type doesn't validate anything, and a value's fields play no part in which renderer runs. Fields matter on the parse side instead: `markdownToPortableText` filters a construct down to its declared fields, so declare each type with the fields its values carry, or the markdown forms this gate lets through come back rebuilt without them.

An undeclared type falls back to `unknownType`, whose default output is the same `json:object` fence or tagged code span described above, so it round-trips at block and inline positions. Inside a table cell the carrier uses its inline form (a GFM cell is one line), so an undeclared object in a cell survives too; declared types whose markdown form spans multiple lines (a code block in a cell) still flatten on reparse. Renderers you register in `types` bypass the gate entirely, whether or not the schema declares them.

Without a `schema`, every default renderer stays active.

#### Hard breaks

Customize how a hard break (a `\n` inside a span's text) renders:

```ts
portableTextToMarkdown(blocks, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {Schema} from '@portabletext/schema'
import type {
ArbitraryTypedObject,
PortableTextBlock,
Expand Down Expand Up @@ -84,6 +85,20 @@ const defaultRenderers: PortableTextRenderers = {

type Options = Partial<PortableTextRenderers> & {
blockSpacing?: BlockSpacingRenderer

/**
* Compiled schema that gates the built-in type renderers; it never
* validates the value. A default renderer runs only when the schema
* declares its type (`blockObjects` for block position, `inlineObjects`
* for inline); undeclared types render through `unknownType`, whose
* default output reparses back to the same value. The gate checks the
* type name only, so declare the type's fields too:
* `markdownToPortableText` cannot rebuild a value from a fieldless
* declaration. Renderers passed in `types` are never gated. Pass the
* same schema to `markdownToPortableText` to keep the round trip
* consistent. Omitted, all default renderers stay active.
*/
schema?: Schema
}

/**
Expand All @@ -103,7 +118,11 @@ export function portableTextToMarkdown<
...options.marks,
},
types: {
...defaultRenderers.types,
...gateDefaultTypeRenderers(
defaultRenderers.types,
options.schema,
options.unknownType ?? defaultRenderers.unknownType,
),
...options.types,
},
hardBreak: options.hardBreak ?? defaultRenderers.hardBreak,
Expand Down Expand Up @@ -148,3 +167,28 @@ export function portableTextToMarkdown<
})
.join('')
}

function gateDefaultTypeRenderers(
defaultTypeRenderers: PortableTextRenderers['types'],
schema: Schema | undefined,
resolvedUnknownType: PortableTextRenderers['unknownType'],
): PortableTextRenderers['types'] {
if (!schema) {
return defaultTypeRenderers
}

return Object.fromEntries(
Object.entries(defaultTypeRenderers).map(([typeName, renderer]) => [
typeName,
(rendererOptions: Parameters<typeof resolvedUnknownType>[0]) => {
const declared = (
rendererOptions.isInline ? schema.inlineObjects : schema.blockObjects
).some((item) => item.name === typeName)

return declared && renderer
? renderer(rendererOptions)
: resolvedUnknownType(rendererOptions)
},
]),
)
}
27 changes: 23 additions & 4 deletions packages/markdown/src/from-portable-text/renderers/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,33 @@ function renderTable(
// Helper to extract text from cell blocks
const getCellText = (cellBlocks: Array<TypedObject>): string => {
return cellBlocks
.map((block, index) =>
renderNode({
.map((block, index) => {
const rendered = renderNode({
node: block,
index,
isInline: false,
renderNode,
}),
)
})
const rendererOptions = {
value: block,
isInline: false,
index,
renderNode,
}
if (rendered === DefaultUnknownTypeRenderer(rendererOptions)) {
// A GFM cell is one line, so the multi-line fence carrier
// would squash into `<br>` soup that reparses as plain text;
// the inline carrier is single-line and reparses to the same
// value. Exact-matched against the default carrier's output,
// so declared markdown forms and custom `unknownType` output
// pass through untouched.
return DefaultUnknownTypeRenderer({
...rendererOptions,
isInline: true,
})
}
return rendered
})
.join(' ')
.trim()
}
Expand Down
34 changes: 28 additions & 6 deletions packages/markdown/src/markdown-to-portable-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7551,7 +7551,7 @@ describe(markdownToPortableText.name, () => {
expect(onDegradation).not.toHaveBeenCalled()
})

test('image lifted back to block: standalone image inside a table cell, schema has no block-level `image`', () => {
test('image stays inline: standalone image inside a table cell, schema has no block-level `image`', () => {
const keyGenerator = createTestKeyGenerator()
const onDegradation = vi.fn<(report: DegradationReport) => void>()
// `image` only exists as an inline object, so the standalone-image
Expand Down Expand Up @@ -7607,7 +7607,20 @@ describe(markdownToPortableText.name, () => {
_type: 'cell',
_key: 'k6',
value: [
{_key: 'k5', _type: 'image', src: 'src.png', alt: 'alt'},
{
_type: 'block',
style: 'normal',
children: [
{
_key: 'k5',
_type: 'image',
src: 'src.png',
alt: 'alt',
},
],
_key: 'k4',
markDefs: [],
},
],
},
],
Expand All @@ -7616,10 +7629,19 @@ describe(markdownToPortableText.name, () => {
},
])

// The cell holds nothing but the image, so `td_close`'s sole-image
// lift recovers the canonical block-level shape: reporting the
// intermediate inline demotion would be a false positive.
expect(onDegradation).not.toHaveBeenCalled()
// `image` is declared inline-only, which is a legal inline home, so
// `td_close`'s sole-object lift declines and the image stays put as
// the cell's inline child.
expect(onDegradation).toHaveBeenCalledTimes(1)
expect(onDegradation.mock.calls[0]![0]!.degradations).toEqual([
{
type: 'image-block-to-inline',
message:
'The image became inline: the schema has no block-level `image`',
line: 1,
snippet: 'alt',
},
])
})

test('image demoted, not lifted: mixed cell content, schema has no block-level `image`', () => {
Expand Down
Loading
Loading