Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to
### Added

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

### Fixed

Expand Down
309 changes: 309 additions & 0 deletions src/frontend/servers/y-provider/__tests__/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('../src/env', async (importOriginal) => {
};
});

import { docsBlockNoteSchema } from '@/blockSpecs';
import { initApp } from '@/servers';

import {
Expand Down Expand Up @@ -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")',
);
Comment thread
sylvinus marked this conversation as resolved.
});

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"');
});
Comment thread
sylvinus marked this conversation as resolved.

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');
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/servers/y-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"node": ">=22"
},
"dependencies": {
"@blocknote/core": "0.49.0",
"@blocknote/server-util": "0.49.0",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.49.0",
Expand All @@ -30,7 +31,6 @@
"yjs": "*"
},
"devDependencies": {
"@blocknote/core": "0.49.0",
"@hocuspocus/provider": "3.4.4",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
Expand Down
50 changes: 50 additions & 0 deletions src/frontend/servers/y-provider/src/blockSpecs/Callout.ts
Original file line number Diff line number Diff line change
@@ -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 };
},
});
Loading
Loading