Skip to content

Commit 55345bd

Browse files
committed
✨(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.
1 parent abd03d1 commit 55345bd

9 files changed

Lines changed: 588 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to
99
### Added
1010

1111
- ✨(backend) support creating subdoc from file #1987
12+
- ✨(y-provider) preserve callouts, PDFs, page breaks and interlinking
13+
links on HTML/markdown export
1214

1315
### Fixed
1416

src/frontend/servers/y-provider/__tests__/convert.test.ts

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock('../src/env', async (importOriginal) => {
1111
};
1212
});
1313

14+
import { docsBlockNoteSchema } from '@/blockSpecs';
1415
import { initApp } from '@/servers';
1516

1617
import {
@@ -300,6 +301,314 @@ describe('Conversion Testing', () => {
300301
expect(response.body).toStrictEqual(expectedBlocks);
301302
});
302303

304+
test('POST /api/convert Yjs to HTML with callout block', async () => {
305+
const app = initApp();
306+
const editor = ServerBlockNoteEditor.create({
307+
schema: docsBlockNoteSchema,
308+
});
309+
const blocks = [
310+
{
311+
type: 'callout' as const,
312+
props: { emoji: '⚠️', backgroundColor: 'yellow' },
313+
content: [{ type: 'text' as const, text: 'Be careful', styles: {} }],
314+
},
315+
];
316+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
317+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
318+
const response = await request(app)
319+
.post('/api/convert')
320+
.set('origin', origin)
321+
.set('authorization', `Bearer ${apiKey}`)
322+
.set('content-type', 'application/vnd.yjs.doc')
323+
.set('accept', 'text/html')
324+
.send(Buffer.from(yjsUpdate));
325+
326+
expect(response.status).toBe(200);
327+
expect(response.text).toContain('<aside');
328+
expect(response.text).toContain('role="note"');
329+
expect(response.text).toContain('data-emoji="⚠️"');
330+
expect(response.text).toContain('data-background-color="yellow"');
331+
expect(response.text).toContain('Be careful');
332+
// The inner emoji span is marked so downstream parsers can drop it
333+
// (the canonical emoji is on the <aside>).
334+
expect(response.text).toContain(
335+
'<span aria-hidden="true" data-emoji="⚠️">',
336+
);
337+
});
338+
339+
test('POST /api/convert Yjs to Markdown preserves callout content', async () => {
340+
const app = initApp();
341+
const editor = ServerBlockNoteEditor.create({
342+
schema: docsBlockNoteSchema,
343+
});
344+
const blocks = [
345+
{
346+
type: 'callout' as const,
347+
props: { emoji: '⚠️', backgroundColor: 'yellow' },
348+
content: [{ type: 'text' as const, text: 'Be careful', styles: {} }],
349+
},
350+
];
351+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
352+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
353+
const response = await request(app)
354+
.post('/api/convert')
355+
.set('origin', origin)
356+
.set('authorization', `Bearer ${apiKey}`)
357+
.set('content-type', 'application/vnd.yjs.doc')
358+
.set('accept', 'text/markdown')
359+
.send(Buffer.from(yjsUpdate));
360+
361+
expect(response.status).toBe(200);
362+
expect(response.text).toContain('⚠️');
363+
expect(response.text).toContain('Be careful');
364+
});
365+
366+
test('POST /api/convert Yjs to Markdown preserves interlinking link', async () => {
367+
const app = initApp();
368+
const editor = ServerBlockNoteEditor.create({
369+
schema: docsBlockNoteSchema,
370+
});
371+
const blocks = [
372+
{
373+
type: 'paragraph' as const,
374+
content: [
375+
{
376+
type: 'interlinkingLinkInline' as const,
377+
props: {
378+
docId: '00000000-0000-0000-0000-000000000123',
379+
title: 'Other doc',
380+
disabled: false,
381+
trigger: '/' as const,
382+
},
383+
},
384+
],
385+
},
386+
];
387+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
388+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
389+
const response = await request(app)
390+
.post('/api/convert')
391+
.set('origin', origin)
392+
.set('authorization', `Bearer ${apiKey}`)
393+
.set('content-type', 'application/vnd.yjs.doc')
394+
.set('accept', 'text/markdown')
395+
.send(Buffer.from(yjsUpdate));
396+
397+
expect(response.status).toBe(200);
398+
expect(response.text).toContain(
399+
'[Other doc](/docs/00000000-0000-0000-0000-000000000123/ "Other doc")',
400+
);
401+
});
402+
403+
test('POST /api/convert Yjs to HTML with PDF block', async () => {
404+
const app = initApp();
405+
const editor = ServerBlockNoteEditor.create({
406+
schema: docsBlockNoteSchema,
407+
});
408+
const blocks = [
409+
{
410+
type: 'pdf' as const,
411+
props: {
412+
url: 'https://example.com/file.pdf',
413+
name: 'Annual report',
414+
showPreview: true,
415+
},
416+
},
417+
];
418+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
419+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
420+
const response = await request(app)
421+
.post('/api/convert')
422+
.set('origin', origin)
423+
.set('authorization', `Bearer ${apiKey}`)
424+
.set('content-type', 'application/vnd.yjs.doc')
425+
.set('accept', 'text/html')
426+
.send(Buffer.from(yjsUpdate));
427+
428+
expect(response.status).toBe(200);
429+
expect(response.text).toContain('<iframe');
430+
expect(response.text).toContain('src="https://example.com/file.pdf"');
431+
expect(response.text).toContain('title="Annual report"');
432+
});
433+
434+
test('POST /api/convert Yjs to HTML strips unsafe PDF URL schemes', async () => {
435+
const app = initApp();
436+
const editor = ServerBlockNoteEditor.create({
437+
schema: docsBlockNoteSchema,
438+
});
439+
const blocks = [
440+
{
441+
type: 'pdf' as const,
442+
props: {
443+
url: 'javascript:alert(1)',
444+
name: 'Malicious',
445+
showPreview: true,
446+
},
447+
},
448+
];
449+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
450+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
451+
const response = await request(app)
452+
.post('/api/convert')
453+
.set('origin', origin)
454+
.set('authorization', `Bearer ${apiKey}`)
455+
.set('content-type', 'application/vnd.yjs.doc')
456+
.set('accept', 'text/html')
457+
.send(Buffer.from(yjsUpdate));
458+
459+
expect(response.status).toBe(200);
460+
expect(response.text).not.toContain('<iframe');
461+
expect(response.text).not.toMatch(/(?:src|href)="javascript:/);
462+
});
463+
464+
test('POST /api/convert Yjs to HTML with interlinking inline content', async () => {
465+
const app = initApp();
466+
const editor = ServerBlockNoteEditor.create({
467+
schema: docsBlockNoteSchema,
468+
});
469+
const blocks = [
470+
{
471+
type: 'paragraph' as const,
472+
content: [
473+
{
474+
type: 'interlinkingLinkInline' as const,
475+
props: {
476+
docId: '00000000-0000-0000-0000-000000000123',
477+
title: 'Other doc',
478+
disabled: false,
479+
trigger: '/' as const,
480+
},
481+
},
482+
],
483+
},
484+
];
485+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
486+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
487+
const response = await request(app)
488+
.post('/api/convert')
489+
.set('origin', origin)
490+
.set('authorization', `Bearer ${apiKey}`)
491+
.set('content-type', 'application/vnd.yjs.doc')
492+
.set('accept', 'text/html')
493+
.send(Buffer.from(yjsUpdate));
494+
495+
expect(response.status).toBe(200);
496+
expect(response.text).toContain(
497+
'href="/docs/00000000-0000-0000-0000-000000000123/"',
498+
);
499+
expect(response.text).toContain(
500+
'data-doc-id="00000000-0000-0000-0000-000000000123"',
501+
);
502+
expect(response.text).toContain('title="Other doc"');
503+
expect(response.text).toContain('Other doc');
504+
expect(response.text).not.toContain('data-inline-content-type');
505+
});
506+
507+
test('POST /api/convert Yjs to HTML with disabled interlinking renders no link', async () => {
508+
const app = initApp();
509+
const editor = ServerBlockNoteEditor.create({
510+
schema: docsBlockNoteSchema,
511+
});
512+
const blocks = [
513+
{
514+
type: 'paragraph' as const,
515+
content: [
516+
{
517+
type: 'interlinkingLinkInline' as const,
518+
props: {
519+
docId: '00000000-0000-0000-0000-000000000123',
520+
title: 'Hidden',
521+
disabled: true,
522+
trigger: '/' as const,
523+
},
524+
},
525+
],
526+
},
527+
];
528+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
529+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
530+
const response = await request(app)
531+
.post('/api/convert')
532+
.set('origin', origin)
533+
.set('authorization', `Bearer ${apiKey}`)
534+
.set('content-type', 'application/vnd.yjs.doc')
535+
.set('accept', 'text/html')
536+
.send(Buffer.from(yjsUpdate));
537+
538+
expect(response.status).toBe(200);
539+
expect(response.text).not.toContain('href=');
540+
expect(response.text).not.toContain('data-doc-id');
541+
expect(response.text).not.toContain('Hidden');
542+
});
543+
544+
test('POST /api/convert Yjs to BlockNote JSON preserves pageBreak block', async () => {
545+
const app = initApp();
546+
const editor = ServerBlockNoteEditor.create({
547+
schema: docsBlockNoteSchema,
548+
});
549+
const blocks = [
550+
{
551+
type: 'paragraph' as const,
552+
content: [{ type: 'text' as const, text: 'before', styles: {} }],
553+
},
554+
{ type: 'pageBreak' as const },
555+
{
556+
type: 'paragraph' as const,
557+
content: [{ type: 'text' as const, text: 'after', styles: {} }],
558+
},
559+
];
560+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
561+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
562+
const response = await request(app)
563+
.post('/api/convert')
564+
.set('origin', origin)
565+
.set('authorization', `Bearer ${apiKey}`)
566+
.set('content-type', 'application/vnd.yjs.doc')
567+
.set('accept', 'application/json')
568+
.send(Buffer.from(yjsUpdate));
569+
570+
expect(response.status).toBe(200);
571+
const types = (response.body as { type: string }[]).map((b) => b.type);
572+
expect(types).toContain('pageBreak');
573+
});
574+
575+
test('POST /api/convert Yjs to BlockNote JSON preserves uploadLoader block', async () => {
576+
const app = initApp();
577+
const editor = ServerBlockNoteEditor.create({
578+
schema: docsBlockNoteSchema,
579+
});
580+
const blocks = [
581+
{
582+
type: 'uploadLoader' as const,
583+
props: {
584+
information: 'uploading',
585+
type: 'loading' as const,
586+
blockUploadName: 'doc.pdf',
587+
},
588+
},
589+
];
590+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
591+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
592+
const response = await request(app)
593+
.post('/api/convert')
594+
.set('origin', origin)
595+
.set('authorization', `Bearer ${apiKey}`)
596+
.set('content-type', 'application/vnd.yjs.doc')
597+
.set('accept', 'application/json')
598+
.send(Buffer.from(yjsUpdate));
599+
600+
expect(response.status).toBe(200);
601+
const uploadLoader = (
602+
response.body as { type: string; props: Record<string, unknown> }[]
603+
).find((b) => b.type === 'uploadLoader');
604+
expect(uploadLoader).toBeDefined();
605+
expect(uploadLoader?.props).toMatchObject({
606+
information: 'uploading',
607+
type: 'loading',
608+
blockUploadName: 'doc.pdf',
609+
});
610+
});
611+
303612
test('POST /api/convert with invalid Yjs content returns 400', async () => {
304613
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
305614
const app = initApp();

src/frontend/servers/y-provider/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"node": ">=22"
1717
},
1818
"dependencies": {
19+
"@blocknote/core": "0.49.0",
1920
"@blocknote/server-util": "0.49.0",
2021
"@hocuspocus/server": "3.4.4",
2122
"@sentry/node": "10.49.0",
@@ -30,7 +31,6 @@
3031
"yjs": "*"
3132
},
3233
"devDependencies": {
33-
"@blocknote/core": "0.49.0",
3434
"@hocuspocus/provider": "3.4.4",
3535
"@types/cors": "2.8.19",
3636
"@types/express": "5.0.6",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createBlockSpec, defaultProps } from '@blocknote/core';
2+
3+
// Must stay in sync with the frontend CalloutBlock propSchema
4+
// (custom-blocks/CalloutBlock.tsx).
5+
const calloutPropSchema = {
6+
textAlignment: defaultProps.textAlignment,
7+
backgroundColor: { default: 'default' as const },
8+
emoji: { default: '💡' as const },
9+
} as const;
10+
11+
const calloutConfig = {
12+
type: 'callout' as const,
13+
propSchema: calloutPropSchema,
14+
content: 'inline' as const,
15+
};
16+
17+
export const CalloutBlock = createBlockSpec(calloutConfig, {
18+
render: (block) => {
19+
const dom = document.createElement('div');
20+
dom.setAttribute('data-content-type', 'callout');
21+
dom.setAttribute('data-emoji', block.props.emoji);
22+
if (block.props.backgroundColor !== 'default') {
23+
dom.setAttribute('data-background-color', block.props.backgroundColor);
24+
}
25+
const contentDOM = document.createElement('p');
26+
dom.appendChild(contentDOM);
27+
return { dom, contentDOM };
28+
},
29+
toExternalHTML: (block) => {
30+
const dom = document.createElement('aside');
31+
dom.setAttribute('role', 'note');
32+
dom.setAttribute('data-emoji', block.props.emoji);
33+
if (block.props.backgroundColor !== 'default') {
34+
dom.setAttribute('data-background-color', block.props.backgroundColor);
35+
}
36+
// The emoji lives *inside* contentDOM so rehype-remark (markdown export)
37+
// sees a single text-bearing child and doesn't drop the body text.
38+
// BlockNote appends inline content to contentDOM, so the emoji stays first.
39+
// The data-emoji marker lets downstream parsers strip the duplicated emoji
40+
// when reading the callout back (the canonical emoji is on the <aside>).
41+
const contentDOM = document.createElement('p');
42+
const emoji = document.createElement('span');
43+
emoji.setAttribute('aria-hidden', 'true');
44+
emoji.setAttribute('data-emoji', block.props.emoji);
45+
emoji.textContent = `${block.props.emoji} `;
46+
contentDOM.appendChild(emoji);
47+
dom.appendChild(contentDOM);
48+
return { dom, contentDOM };
49+
},
50+
});

0 commit comments

Comments
 (0)