(y-provider) preserve custom blocks on HTML/markdown conversion

Wire the docs BlockNote schema (callout, pdf, uploadLoader, interlinking
link, page break) into the conversion editor so /api/convert no longer
drops or mangles these blocks.
This commit is contained in:
Sylvain Zimmer
2026-06-23 09:42:43 +02:00
committed by Anthony LC
parent 3382e67a46
commit 46fc08a538
9 changed files with 591 additions and 21 deletions
+5
View File
@@ -6,6 +6,11 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(y-provider) preserve callouts, PDFs, page breaks and
interlinking links on HTML/markdown export #2296
### Fixed
- 🐛(frontend) fix removed item in the tree #2420
@@ -11,6 +11,7 @@ vi.mock('../src/env', async (importOriginal) => {
};
});
import { docsBlockNoteSchema } from '@/blockSpecs';
import { initApp } from '@/servers';
import {
@@ -300,6 +301,314 @@ describe('Conversion Testing', () => {
expect(response.body).toStrictEqual(expectedBlocks);
});
test('POST /api/convert Yjs to HTML with callout block', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'callout' as const,
props: { emoji: '⚠️', backgroundColor: 'yellow' },
content: [{ type: 'text' as const, text: 'Be careful', styles: {} }],
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/html')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).toContain('<aside');
expect(response.text).toContain('role="note"');
expect(response.text).toContain('data-emoji="⚠️"');
expect(response.text).toContain('data-background-color="yellow"');
expect(response.text).toContain('Be careful');
// The inner emoji span is marked so downstream parsers can drop it
// (the canonical emoji is on the <aside>).
expect(response.text).toContain(
'<span aria-hidden="true" data-emoji="⚠️">',
);
});
test('POST /api/convert Yjs to Markdown preserves callout content', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'callout' as const,
props: { emoji: '⚠️', backgroundColor: 'yellow' },
content: [{ type: 'text' as const, text: 'Be careful', styles: {} }],
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/markdown')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).toContain('⚠️');
expect(response.text).toContain('Be careful');
});
test('POST /api/convert Yjs to Markdown preserves interlinking link', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'paragraph' as const,
content: [
{
type: 'interlinkingLinkInline' as const,
props: {
docId: '00000000-0000-0000-0000-000000000123',
title: 'Other doc',
disabled: false,
trigger: '/' as const,
},
},
],
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/markdown')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).toContain(
'[Other doc](/docs/00000000-0000-0000-0000-000000000123/ "Other doc")',
);
});
test('POST /api/convert Yjs to HTML with PDF block', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'pdf' as const,
props: {
url: 'https://example.com/file.pdf',
name: 'Annual report',
showPreview: true,
},
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/html')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).toContain('<iframe');
expect(response.text).toContain('src="https://example.com/file.pdf"');
expect(response.text).toContain('title="Annual report"');
});
test('POST /api/convert Yjs to HTML strips unsafe PDF URL schemes', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'pdf' as const,
props: {
url: 'javascript:alert(1)',
name: 'Malicious',
showPreview: true,
},
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/html')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).not.toContain('<iframe');
expect(response.text).not.toMatch(/(?:src|href)="javascript:/);
});
test('POST /api/convert Yjs to HTML with interlinking inline content', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'paragraph' as const,
content: [
{
type: 'interlinkingLinkInline' as const,
props: {
docId: '00000000-0000-0000-0000-000000000123',
title: 'Other doc',
disabled: false,
trigger: '/' as const,
},
},
],
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/html')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).toContain(
'href="/docs/00000000-0000-0000-0000-000000000123/"',
);
expect(response.text).toContain(
'data-doc-id="00000000-0000-0000-0000-000000000123"',
);
expect(response.text).toContain('title="Other doc"');
expect(response.text).toContain('Other doc');
expect(response.text).not.toContain('data-inline-content-type');
});
test('POST /api/convert Yjs to HTML with disabled interlinking renders no link', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'paragraph' as const,
content: [
{
type: 'interlinkingLinkInline' as const,
props: {
docId: '00000000-0000-0000-0000-000000000123',
title: 'Hidden',
disabled: true,
trigger: '/' as const,
},
},
],
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/html')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.text).not.toContain('href=');
expect(response.text).not.toContain('data-doc-id');
expect(response.text).not.toContain('Hidden');
});
test('POST /api/convert Yjs to BlockNote JSON preserves pageBreak block', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'paragraph' as const,
content: [{ type: 'text' as const, text: 'before', styles: {} }],
},
{ type: 'pageBreak' as const },
{
type: 'paragraph' as const,
content: [{ type: 'text' as const, text: 'after', styles: {} }],
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'application/json')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
const types = (response.body as { type: string }[]).map((b) => b.type);
expect(types).toContain('pageBreak');
});
test('POST /api/convert Yjs to BlockNote JSON preserves uploadLoader block', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create({
schema: docsBlockNoteSchema,
});
const blocks = [
{
type: 'uploadLoader' as const,
props: {
information: 'uploading',
type: 'loading' as const,
blockUploadName: 'doc.pdf',
},
},
];
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'application/json')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
const uploadLoader = (
response.body as { type: string; props: Record<string, unknown> }[]
).find((b) => b.type === 'uploadLoader');
expect(uploadLoader).toBeDefined();
expect(uploadLoader?.props).toMatchObject({
information: 'uploading',
type: 'loading',
blockUploadName: 'doc.pdf',
});
});
test('POST /api/convert with invalid Yjs content returns 400', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
+1 -1
View File
@@ -16,6 +16,7 @@
"node": ">=22"
},
"dependencies": {
"@blocknote/core": "0.51.4",
"@blocknote/server-util": "0.51.4",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.53.1",
@@ -30,7 +31,6 @@
"yjs": "*"
},
"devDependencies": {
"@blocknote/core": "0.51.4",
"@hocuspocus/provider": "3.4.4",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
@@ -0,0 +1,50 @@
import { createBlockSpec, defaultProps } from '@blocknote/core';
// Must stay in sync with the frontend CalloutBlock propSchema
// (custom-blocks/CalloutBlock.tsx).
const calloutPropSchema = {
textAlignment: defaultProps.textAlignment,
backgroundColor: { default: 'default' as const },
emoji: { default: '💡' as const },
} as const;
const calloutConfig = {
type: 'callout' as const,
propSchema: calloutPropSchema,
content: 'inline' as const,
};
export const CalloutBlock = createBlockSpec(calloutConfig, {
render: (block) => {
const dom = document.createElement('div');
dom.setAttribute('data-content-type', 'callout');
dom.setAttribute('data-emoji', block.props.emoji);
if (block.props.backgroundColor !== 'default') {
dom.setAttribute('data-background-color', block.props.backgroundColor);
}
const contentDOM = document.createElement('p');
dom.appendChild(contentDOM);
return { dom, contentDOM };
},
toExternalHTML: (block) => {
const dom = document.createElement('aside');
dom.setAttribute('role', 'note');
dom.setAttribute('data-emoji', block.props.emoji);
if (block.props.backgroundColor !== 'default') {
dom.setAttribute('data-background-color', block.props.backgroundColor);
}
// The emoji lives *inside* contentDOM so rehype-remark (markdown export)
// sees a single text-bearing child and doesn't drop the body text.
// BlockNote appends inline content to contentDOM, so the emoji stays first.
// The data-emoji marker lets downstream parsers strip the duplicated emoji
// when reading the callout back (the canonical emoji is on the <aside>).
const contentDOM = document.createElement('p');
const emoji = document.createElement('span');
emoji.setAttribute('aria-hidden', 'true');
emoji.setAttribute('data-emoji', block.props.emoji);
emoji.textContent = `${block.props.emoji} `;
contentDOM.appendChild(emoji);
dom.appendChild(contentDOM);
return { dom, contentDOM };
},
});
@@ -0,0 +1,61 @@
import { createInlineContentSpec } from '@blocknote/core';
const interlinkingPropSchema = {
docId: { default: '' as string },
disabled: {
default: false as boolean,
values: [true, false] as const,
},
trigger: {
default: '/' as const,
values: ['/', '@'] as const,
},
title: { default: '' as string },
} as const;
const interlinkingConfig = {
type: 'interlinkingLinkInline' as const,
propSchema: interlinkingPropSchema,
content: 'none' as const,
};
// Matches the frontend route (LinkSelected.tsx) so exported HTML/markdown
// links resolve identically client-side.
const interlinkingHref = (docId: string) =>
`/docs/${encodeURIComponent(docId)}/`;
export const InterlinkingLinkInline = createInlineContentSpec(
interlinkingConfig,
{
render: (inlineContent) => {
const { disabled, docId, title } = inlineContent.props;
if (disabled || !docId) {
return { dom: document.createElement('span') };
}
const dom = document.createElement('a');
dom.setAttribute('data-inline-content-type', 'interlinkingLinkInline');
dom.setAttribute('href', interlinkingHref(docId));
dom.setAttribute('data-doc-id', docId);
dom.textContent = title || docId;
return { dom };
},
toExternalHTML: (inlineContent) => {
const { disabled, docId, title } = inlineContent.props;
// Matches the frontend (InterlinkingLinkInlineContent.tsx): a disabled
// or unresolved link must not render any visible content.
if (disabled || !docId) {
return { dom: document.createElement('span') };
}
const dom = document.createElement('a');
dom.setAttribute('href', interlinkingHref(docId));
dom.setAttribute('data-doc-id', docId);
if (title) {
dom.setAttribute('title', title);
}
dom.textContent = title || docId;
return { dom };
},
},
);
@@ -0,0 +1,76 @@
import { createBlockSpec } from '@blocknote/core';
const pdfPropSchema = {
backgroundColor: { default: 'default' as const },
caption: { default: '' as string },
name: { default: '' as string },
previewWidth: { default: undefined, type: 'number' as const },
showPreview: { default: true as boolean },
textAlignment: { default: 'left' as const },
url: { default: '' as string },
} as const;
const pdfConfig = {
type: 'pdf' as const,
propSchema: pdfPropSchema,
content: 'none' as const,
};
// Reject schemes like `javascript:` that would execute on click/load. Allow
// http(s) (the upload backend) and protocol-relative/relative paths.
const isSafePdfUrl = (url: string) => {
try {
const parsed = new URL(url, 'http://_');
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
const buildPdfDom = (block: {
props: {
url: string;
name: string;
caption: string;
previewWidth: number | undefined;
showPreview: boolean;
};
}) => {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-content-type', 'pdf');
const safeUrl = block.props.url && isSafePdfUrl(block.props.url);
if (safeUrl && block.props.showPreview !== false) {
const iframe = document.createElement('iframe');
iframe.setAttribute('src', block.props.url);
iframe.setAttribute('title', block.props.name || 'PDF preview');
if (block.props.previewWidth) {
iframe.setAttribute('width', String(block.props.previewWidth));
}
wrapper.appendChild(iframe);
} else if (safeUrl) {
const link = document.createElement('a');
link.setAttribute('href', block.props.url);
link.textContent = block.props.name || block.props.url;
wrapper.appendChild(link);
} else if (block.props.url) {
const fallback = document.createElement('span');
fallback.textContent = block.props.name || block.props.url;
wrapper.appendChild(fallback);
}
if (block.props.caption) {
const caption = document.createElement('p');
caption.className = 'bn-file-caption';
caption.textContent = block.props.caption;
wrapper.appendChild(caption);
}
return wrapper;
};
export const PdfBlock = createBlockSpec(pdfConfig, {
render: (block) => ({ dom: buildPdfDom(block) }),
toExternalHTML: (block) => ({ dom: buildPdfDom(block) }),
});
@@ -0,0 +1,35 @@
import { createBlockSpec } from '@blocknote/core';
const uploadLoaderPropSchema = {
information: { default: '' as string },
type: {
default: 'loading' as const,
values: ['loading', 'warning'] as const,
},
blockUploadName: { default: '' as string },
blockUploadShowPreview: { default: true as boolean },
blockUploadType: { default: '' as string },
blockUploadUrl: { default: '' as string },
} as const;
const uploadLoaderConfig = {
type: 'uploadLoader' as const,
propSchema: uploadLoaderPropSchema,
content: 'none' as const,
};
// Transient block representing an in-progress upload. We render it as an empty
// element in HTML export so it disappears from finished documents but the
// prosemirror node round-trips correctly.
export const UploadLoaderBlock = createBlockSpec(uploadLoaderConfig, {
render: () => {
const dom = document.createElement('div');
dom.setAttribute('data-content-type', 'uploadLoader');
return { dom };
},
toExternalHTML: () => {
const dom = document.createElement('div');
dom.setAttribute('data-content-type', 'uploadLoader');
return { dom };
},
});
@@ -0,0 +1,33 @@
import {
BlockNoteSchema,
defaultBlockSpecs,
defaultInlineContentSpecs,
withPageBreak,
} from '@blocknote/core';
import { CalloutBlock } from './Callout';
import { InterlinkingLinkInline } from './InterlinkingLinkInline';
import { PdfBlock } from './Pdf';
import { UploadLoaderBlock } from './UploadLoader';
// Must stay in sync with the frontend schema (BlockNoteEditor.tsx) so Yjs
// documents authored client-side round-trip without dropping nodes.
export const docsBlockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock(),
pdf: PdfBlock(),
uploadLoader: UploadLoaderBlock(),
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
interlinkingLinkInline: InterlinkingLinkInline,
},
}),
);
export type DocsBlockSchema = typeof docsBlockNoteSchema.blockSchema;
export type DocsInlineContentSchema =
typeof docsBlockNoteSchema.inlineContentSchema;
export type DocsStyleSchema = typeof docsBlockNoteSchema.styleSchema;
@@ -1,13 +1,14 @@
import {
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema,
PartialBlock,
} from '@blocknote/core';
import { PartialBlock } from '@blocknote/core';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { Request, Response } from 'express';
import * as Y from 'yjs';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
docsBlockNoteSchema,
} from '@/blockSpecs';
import { logger } from '@/utils';
interface ErrorResponse {
@@ -16,21 +17,27 @@ interface ErrorResponse {
type ConversionResponseBody = Uint8Array | string | object | ErrorResponse;
type DocsPartialBlock = PartialBlock<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>;
interface InputReader {
supportedContentTypes: string[];
read(data: Buffer): Promise<PartialBlock[]>;
read(data: Buffer): Promise<DocsPartialBlock[]>;
}
interface OutputWriter {
supportedContentTypes: string[];
write(blocks: PartialBlock[]): Promise<ConversionResponseBody>;
write(blocks: DocsPartialBlock[]): Promise<ConversionResponseBody>;
}
const editor = ServerBlockNoteEditor.create<
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema
>();
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>({ schema: docsBlockNoteSchema });
const ContentTypes = {
XMarkdown: 'text/x-markdown',
@@ -43,7 +50,7 @@ const ContentTypes = {
JSON: 'application/json',
} as const;
const createYDocument = (blocks: PartialBlock[]) =>
const createYDocument = (blocks: DocsPartialBlock[]) =>
editor.blocksToYDoc(blocks, 'document-store');
const readers: InputReader[] = [
@@ -135,13 +142,7 @@ export const convertHandler = async (
return;
}
let blocks:
| PartialBlock<
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema
>[]
| null;
let blocks: DocsPartialBlock[] | null;
try {
try {
blocks = await reader.read(req.body);