Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(exporters): let block mappings place their children
A block mapping is a plain function (the exporter places the block's
children) or `{ withChildren }`, which receives the rendered children and
places them itself. A container with a plain mapping throws.
  • Loading branch information
YousefED committed Sep 30, 2026
commit cd6bd892f6783c4f9458d8607871d789075035db
22 changes: 13 additions & 9 deletions docs/content/docs/features/export/typst.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,22 @@ For a block with inline content, render it the way the default mappings do:
`exporter.transformInlineContent(block.content).join("")` (inline results are
markup strings, so plain concatenation composes them).

### Container blocks
### Blocks that place their children

A [container block](/docs/features/custom-schemas/container-blocks) holds
child blocks, and its mapping decides where they go: the exporter renders the
children first and passes them in as the mapping's last argument, rather than
appending them after the container's own output. A container without a
mapping is an error rather than a silent omission, since dropping it would
drop its children too.
By default, a mapping renders only its block, and the exporter places the
block's children after it, indented. A block whose children are part of it -
a [container block](/docs/features/custom-schemas/container-blocks), or a
callout with a body - uses a `{ withChildren }` mapping instead: the exporter renders
the children first and passes them in as its last argument, and the mapping
decides where they go. Container blocks must use a `{ withChildren }` mapping, and a
container without one is an error rather than a silent omission, since
dropping it would drop its children too.

```typescript
myContainer: (block, exporter, nestingLevel, numberedListIndex, children) =>
`#rect(width: 100%)[${children.join("\n\n")}]`,
myContainer: {
withChildren: (block, exporter, nestingLevel, numberedListIndex, children) =>
`#rect(width: 100%)[${children.join("\n\n")}]`,
},
```

Separate the children with a blank line, as above, if each should stay its own
Expand Down
78 changes: 57 additions & 21 deletions packages/core/src/exporter/Exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,30 +190,66 @@ describe("Exporter missing mappings", () => {
});
});

describe("Exporter block types outside its schema", () => {
it("treats a childless block of an unknown type as a regular block", () => {
// A container block (`container: true`), and an exporter
// whose mappings return strings.
const box = createBlockSpec(
{
type: "box",
propSchema: {},
content: "none",
container: true,
},
{
render: () => {
const dom = document.createElement("div");
return { dom, contentDOM: dom };
},
},
)();

class StringExporter extends Exporter<any, any, any, string, void, void, void> {
constructor(blockMapping: Record<string, unknown>) {
super(
BlockNoteSchema.create().extend({ blockSpecs: { box } }),
{ blockMapping, inlineContentMapping: {}, styleMapping: {} } as any,
{ colors: COLORS_DEFAULT },
);
}

public transformStyledText(_styledText: StyledText<any>) {
return undefined;
}
}

describe("Exporter child placement", () => {
it("passes a `{ withChildren }` mapping the block's rendered children", async () => {
const exporter = new StringExporter({
box: {
withChildren: (_b: any, _e: any, _n: any, _i: any, c: string[]) =>
`[${c.join(",")}]`,
},
});

expect(exporter.placesChildren({ type: "box" })).toBe(true);
await expect(
exporter.mapBlock({ type: "box" } as any, 0, 0, ["a", "b"]),
).resolves.toBe("[a,b]");
});

it("throws when a container block has a plain mapping", async () => {
const exporter = new StringExporter({ box: () => "box" });

expect(exporter.placesChildren({ type: "box" })).toBe(false);
await expect(
exporter.mapBlock({ type: "box" } as any, 0, 0, []),
).rejects.toThrow("must be a `{ withChildren }` mapping");
});

it("leaves the children of an unmapped or plainly mapped block to the exporter", () => {
// Block packages (math, diagram, ...) commonly supply only a mapping,
// which reads the block's JSON - their specs need not be in the schema.
expect(
new EmptyMappingsExporter().isContainerBlock({
type: "mathBlock",
children: [],
}),
new EmptyMappingsExporter().placesChildren({ type: "mathBlock" }),
).toBe(false);
});

it("throws when a block of an unknown type has children", () => {
// Ambiguous: without the spec there is no way to tell whether the
// mapping places these children itself (container) or the exporter
// appends them (regular block), and guessing puts them in the wrong
// place silently.
expect(() =>
new EmptyMappingsExporter().isContainerBlock({
type: "columnList",
children: [{ type: "column" }],
}),
).toThrow(
'Exporter has no block spec for block type "columnList", and blocks of that type in this document have children',
);
});
});
45 changes: 29 additions & 16 deletions packages/core/src/exporter/Exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,13 @@ export abstract class Exporter<
public readonly options: ExporterOptions,
) {}

/** Container mappings place their own children; regular mappings do not. */
public isContainerBlock(block: {
type: string;
children?: unknown[];
}): boolean {
const spec = this.schema.blockSpecs[block.type];
if (!spec && block.children?.length) {
throw new Error(
`Exporter has no block spec for block type "${block.type}", and blocks of that type in this document have children. Add its spec to the exporter schema so it can determine who renders the children.`,
);
}
return spec?.config.children !== undefined;
/**
* Whether the block's mapping places its children itself (a `{ withChildren }`
* mapping). Otherwise the exporter places them after the block.
*/
public placesChildren(block: { type: string }): boolean {
const mapping = this.mappings.blockMapping[block.type];
return typeof mapping === "object" && mapping !== null;
}

/**
Expand Down Expand Up @@ -161,11 +156,29 @@ export abstract class Exporter<
const mapping = this.mappings.blockMapping[block.type];
if (!mapping) {
throw new Error(
this.isContainerBlock(block)
? `No mapping found for container block type "${block.type}". Container blocks require an explicit block mapping that places their children.`
: `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`,
`Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`,
);
}
return mapping(block, this, nestingLevel, numberedListIndex, children);
if (typeof mapping === "function") {
// A container's children belong inside it, which only a
// `{ withChildren }` mapping can do. Fail early rather than export them
// after it.
// TODO: remove once the `BlockMapping` type requires `{ withChildren }`
// for containers (`createBlockSpec` doesn't keep `container: true` in the
// config type yet).
if (this.schema.blockSpecs[block.type]?.config.container === true) {
throw new Error(
`The mapping for container block type "${block.type}" must be a \`{ withChildren }\` mapping, which places the block's children.`,
);
}
return mapping(block, this, nestingLevel, numberedListIndex);
}
return mapping.withChildren(
block,
this,
nestingLevel,
numberedListIndex,
children ?? [],
);
}
}
51 changes: 41 additions & 10 deletions packages/core/src/exporter/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import type { Exporter } from "./Exporter.js";

/**
* Defines a mapping from all block types with a schema to a result type `R`.
* Each block type maps to either:
* - a function that renders the block itself. The exporter places the
* block's children after it, nested as the format does it; or
* - `{ withChildren }`, a function that renders the block *and* its
* children, which it receives already rendered as its last argument. For
* blocks whose children are part of them, like a column or a callout's
* body. Container blocks must use it.
*/
export type BlockMapping<
B extends BlockSchema,
Expand All @@ -19,18 +26,42 @@ export type BlockMapping<
RB,
RI,
> = {
[K in keyof B]: (
block: BlockFromConfigNoChildren<B[K], I, S>,
// we don't know the exact types that are supported by the exporter at this point,
// because the mapping only knows about converting certain types (which might be a subset of the supported types)
// this is why there are many `any` types here (same for types below)
exporter: Exporter<any, any, any, RB, RI, any, any>,
nestingLevel: number,
numberedListIndex?: number,
children?: Array<Awaited<RB>>,
) => RB | Promise<RB>;
[K in keyof B]:
| BlockMappingFunction<B[K], I, S, RB, RI>
| { withChildren: BlockMappingWithChildrenFunction<B[K], I, S, RB, RI> };
};

export type BlockMappingFunction<
C extends BlockSchema[string],
I extends InlineContentSchema,
S extends StyleSchema,
RB,
RI,
> = (
block: BlockFromConfigNoChildren<C, I, S>,
// we don't know the exact types that are supported by the exporter at this point,
// because the mapping only knows about converting certain types (which might be a subset of the supported types)
// this is why there are many `any` types here (same for types below)
exporter: Exporter<any, any, any, RB, RI, any, any>,
nestingLevel: number,
numberedListIndex?: number,
) => RB | Promise<RB>;

/** A `{ withChildren }` mapping: it also receives the block's rendered children. */
export type BlockMappingWithChildrenFunction<
C extends BlockSchema[string],
I extends InlineContentSchema,
S extends StyleSchema,
RB,
RI,
> = (
block: BlockFromConfigNoChildren<C, I, S>,
exporter: Exporter<any, any, any, RB, RI, any, any>,
nestingLevel: number,
numberedListIndex: number | undefined,
children: Array<Awaited<RB>>,
) => RB | Promise<RB>;

/**
* Defines a mapping from all inline content types with a schema to a result type R.
*/
Expand Down
102 changes: 56 additions & 46 deletions packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,54 +227,64 @@ export const docxBlockMappingForDefaultSchema: BlockMapping<
},
});
},
column: (block, _exporter, _nestingLevel, _numberedListIndex, children) => {
return new TableCell({
width: {
size: `${block.props.width * 100}%`,
type: "pct",
},
children: (children || []).flatMap((child) => {
if (Array.isArray(child)) {
return child;
}
column: {
withChildren: (
block,
_exporter,
_nestingLevel,
_numberedListIndex,
children,
) => {
return new TableCell({
width: {
size: `${block.props.width * 100}%`,
type: "pct",
},
children: (children || []).flatMap((child) => {
if (Array.isArray(child)) {
return child;
}

return [child];
}),
}) as any;
},
columnList: (
_block,
_exporter,
_nestingLevel,
_numberedListIndex,
children,
) => {
return new DocxTable({
layout: "autofit",
borders: {
bottom: { style: "nil" },
top: { style: "nil" },
left: { style: "nil" },
right: { style: "nil" },
insideHorizontal: { style: "nil" },
insideVertical: { style: "nil" },
},
rows: [
new TableRow({
children: (children as unknown as TableCell[]).map(
(cell, _index, children) => {
return new TableCell({
width: {
size: `${(parseFloat(`${cell.options.width?.size || "100%"}`) / (children.length * 100)) * 100}%`,
type: "pct",
},
children: cell.options.children,
});
},
),
return [child];
}),
],
});
}) as any;
},
},
columnList: {
withChildren: (
_block,
_exporter,
_nestingLevel,
_numberedListIndex,
children,
) => {
return new DocxTable({
layout: "autofit",
borders: {
bottom: { style: "nil" },
top: { style: "nil" },
left: { style: "nil" },
right: { style: "nil" },
insideHorizontal: { style: "nil" },
insideVertical: { style: "nil" },
},
rows: [
new TableRow({
children: (children as unknown as TableCell[]).map(
(cell, _index, children) => {
return new TableCell({
width: {
size: `${(parseFloat(`${cell.options.width?.size || "100%"}`) / (children.length * 100)) * 100}%`,
type: "pct",
},
children: cell.options.children,
});
},
),
}),
],
});
},
},
image: async (block, exporter) => {
if (!block.props.url) {
Expand Down
Loading