Skip to content

Commit 122832a

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 122832a

9 files changed

Lines changed: 553 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: 272 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,277 @@ 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/',
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 with interlinking inline content', async () => {
435+
const app = initApp();
436+
const editor = ServerBlockNoteEditor.create({
437+
schema: docsBlockNoteSchema,
438+
});
439+
const blocks = [
440+
{
441+
type: 'paragraph' as const,
442+
content: [
443+
{
444+
type: 'interlinkingLinkInline' as const,
445+
props: {
446+
docId: '00000000-0000-0000-0000-000000000123',
447+
title: 'Other doc',
448+
disabled: false,
449+
trigger: '/' as const,
450+
},
451+
},
452+
],
453+
},
454+
];
455+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
456+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
457+
const response = await request(app)
458+
.post('/api/convert')
459+
.set('origin', origin)
460+
.set('authorization', `Bearer ${apiKey}`)
461+
.set('content-type', 'application/vnd.yjs.doc')
462+
.set('accept', 'text/html')
463+
.send(Buffer.from(yjsUpdate));
464+
465+
expect(response.status).toBe(200);
466+
expect(response.text).toContain(
467+
'href="/docs/00000000-0000-0000-0000-000000000123/"',
468+
);
469+
expect(response.text).toContain(
470+
'data-doc-id="00000000-0000-0000-0000-000000000123"',
471+
);
472+
expect(response.text).toContain('title="Other doc"');
473+
expect(response.text).toContain('Other doc');
474+
expect(response.text).not.toContain('data-inline-content-type');
475+
});
476+
477+
test('POST /api/convert Yjs to HTML with disabled interlinking renders no link', async () => {
478+
const app = initApp();
479+
const editor = ServerBlockNoteEditor.create({
480+
schema: docsBlockNoteSchema,
481+
});
482+
const blocks = [
483+
{
484+
type: 'paragraph' as const,
485+
content: [
486+
{
487+
type: 'interlinkingLinkInline' as const,
488+
props: {
489+
docId: '00000000-0000-0000-0000-000000000123',
490+
title: 'Hidden',
491+
disabled: true,
492+
trigger: '/' as const,
493+
},
494+
},
495+
],
496+
},
497+
];
498+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
499+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
500+
const response = await request(app)
501+
.post('/api/convert')
502+
.set('origin', origin)
503+
.set('authorization', `Bearer ${apiKey}`)
504+
.set('content-type', 'application/vnd.yjs.doc')
505+
.set('accept', 'text/html')
506+
.send(Buffer.from(yjsUpdate));
507+
508+
expect(response.status).toBe(200);
509+
expect(response.text).not.toContain('href=');
510+
expect(response.text).not.toContain('data-doc-id');
511+
expect(response.text).not.toContain('Hidden');
512+
});
513+
514+
test('POST /api/convert Yjs to BlockNote JSON preserves pageBreak block', async () => {
515+
const app = initApp();
516+
const editor = ServerBlockNoteEditor.create({
517+
schema: docsBlockNoteSchema,
518+
});
519+
const blocks = [
520+
{
521+
type: 'paragraph' as const,
522+
content: [{ type: 'text' as const, text: 'before', styles: {} }],
523+
},
524+
{ type: 'pageBreak' as const },
525+
{
526+
type: 'paragraph' as const,
527+
content: [{ type: 'text' as const, text: 'after', styles: {} }],
528+
},
529+
];
530+
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
531+
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
532+
const response = await request(app)
533+
.post('/api/convert')
534+
.set('origin', origin)
535+
.set('authorization', `Bearer ${apiKey}`)
536+
.set('content-type', 'application/vnd.yjs.doc')
537+
.set('accept', 'application/json')
538+
.send(Buffer.from(yjsUpdate));
539+
540+
expect(response.status).toBe(200);
541+
const types = (response.body as { type: string }[]).map((b) => b.type);
542+
expect(types).toContain('pageBreak');
543+
});
544+
545+
test('POST /api/convert Yjs to BlockNote JSON preserves uploadLoader block', async () => {
546+
const app = initApp();
547+
const editor = ServerBlockNoteEditor.create({
548+
schema: docsBlockNoteSchema,
549+
});
550+
const blocks = [
551+
{
552+
type: 'uploadLoader' as const,
553+
props: {
554+
information: 'uploading',
555+
type: 'loading' as const,
556+
blockUploadName: 'doc.pdf',
557+
},
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('uploadLoader');
573+
});
574+
303575
test('POST /api/convert with invalid Yjs content returns 400', async () => {
304576
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
305577
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)