Skip to content

Commit aa290aa

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 aa290aa

9 files changed

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