feat(editor): add callout block markdown adapter (#12070)
Closes: [BS-3358](https://linear.app/affine-design/issue/BS-3358/remark-callout-plugin) Closes: [BS-3247](https://linear.app/affine-design/issue/BS-3247/callout-markdown-adapter-适配) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for callout blocks in Markdown, enabling recognition and conversion of callout syntax (e.g., `[!emoji]`) to and from block structures. - **Bug Fixes** - Improved handling to distinguish callout blocks from regular blockquotes and paragraphs during Markdown processing. - **Tests** - Introduced comprehensive tests for callout block serialization, deserialization, and plugin behavior to ensure correct Markdown handling. - **Chores** - Added a new dependency for Markdown AST traversal. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -2448,6 +2448,121 @@ World!
|
|||||||
});
|
});
|
||||||
expect(target.file).toBe(markdown);
|
expect(target.file).toBe(markdown);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('callout', async () => {
|
||||||
|
const blockSnapshot: BlockSnapshot = {
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:vu6SK6WJpW',
|
||||||
|
flavour: 'affine:page',
|
||||||
|
props: {
|
||||||
|
title: {
|
||||||
|
'$blocksuite:internal:text$': true,
|
||||||
|
delta: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:Tk4gSPocAt',
|
||||||
|
flavour: 'affine:surface',
|
||||||
|
props: {
|
||||||
|
elements: {},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:WfnS5ZDCJT',
|
||||||
|
flavour: 'affine:note',
|
||||||
|
props: {
|
||||||
|
xywh: '[0,0,800,95]',
|
||||||
|
background: DefaultTheme.noteBackgrounColor,
|
||||||
|
index: 'a0',
|
||||||
|
hidden: false,
|
||||||
|
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:8hOLxad5Fv',
|
||||||
|
flavour: 'affine:callout',
|
||||||
|
props: {
|
||||||
|
emoji: '💡',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:8hOLxad5Fv',
|
||||||
|
flavour: 'affine:paragraph',
|
||||||
|
props: {
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
'$blocksuite:internal:text$': true,
|
||||||
|
delta: [{ insert: 'First callout' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:8hOLxadvdv',
|
||||||
|
flavour: 'affine:callout',
|
||||||
|
props: {
|
||||||
|
emoji: '',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:8hOLxad5Fv',
|
||||||
|
flavour: 'affine:paragraph',
|
||||||
|
props: {
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
'$blocksuite:internal:text$': true,
|
||||||
|
delta: [{ insert: 'Second callout without emoji' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'block:8hOLxbfdb',
|
||||||
|
flavour: 'affine:paragraph',
|
||||||
|
props: {
|
||||||
|
type: 'quote',
|
||||||
|
text: {
|
||||||
|
'$blocksuite:internal:text$': true,
|
||||||
|
delta: [{ insert: 'This is a regular blockquote' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = `> \\[!💡]
|
||||||
|
>
|
||||||
|
> First callout
|
||||||
|
|
||||||
|
> \\[!]
|
||||||
|
>
|
||||||
|
> Second callout without emoji
|
||||||
|
|
||||||
|
> This is a regular blockquote
|
||||||
|
`;
|
||||||
|
|
||||||
|
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||||
|
const target = await mdAdapter.fromBlockSnapshot({
|
||||||
|
snapshot: blockSnapshot,
|
||||||
|
});
|
||||||
|
expect(target.file).toBe(markdown);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('markdown to snapshot', () => {
|
describe('markdown to snapshot', () => {
|
||||||
@@ -4182,4 +4297,62 @@ hhh
|
|||||||
});
|
});
|
||||||
expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot);
|
expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('callout', () => {
|
||||||
|
const calloutBlockSnapshot: BlockSnapshot = {
|
||||||
|
type: 'block',
|
||||||
|
id: 'matchesReplaceMap[0]',
|
||||||
|
flavour: 'affine:note',
|
||||||
|
props: {
|
||||||
|
xywh: '[0,0,800,95]',
|
||||||
|
background: DefaultTheme.noteBackgrounColor,
|
||||||
|
index: 'a0',
|
||||||
|
hidden: false,
|
||||||
|
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'matchesReplaceMap[1]',
|
||||||
|
flavour: 'affine:callout',
|
||||||
|
props: {
|
||||||
|
emoji: '💬',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: 'matchesReplaceMap[2]',
|
||||||
|
flavour: 'affine:paragraph',
|
||||||
|
props: {
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
'$blocksuite:internal:text$': true,
|
||||||
|
delta: [{ insert: 'This is a callout' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('callout start with escape character', async () => {
|
||||||
|
const markdown = '> \\[!💬]\n> This is a callout';
|
||||||
|
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
|
||||||
|
file: markdown,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(calloutBlockSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callout start without escape character', async () => {
|
||||||
|
const markdown = '> [!💬]\n> This is a callout';
|
||||||
|
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
|
||||||
|
file: markdown,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(calloutBlockSnapshot);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
88
blocksuite/affine/blocks/callout/src/adapters/markdown.ts
Normal file
88
blocksuite/affine/blocks/callout/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { CalloutBlockSchema } from '@blocksuite/affine-model';
|
||||||
|
import {
|
||||||
|
BlockMarkdownAdapterExtension,
|
||||||
|
type BlockMarkdownAdapterMatcher,
|
||||||
|
getCalloutEmoji,
|
||||||
|
isCalloutNode,
|
||||||
|
} from '@blocksuite/affine-shared/adapters';
|
||||||
|
import { nanoid } from '@blocksuite/store';
|
||||||
|
|
||||||
|
// Currently, the callout block children can only be paragraph block or list block
|
||||||
|
// In mdast, the node types are `paragraph`, `list`, `heading`, `blockquote`
|
||||||
|
const CALLOUT_BLOCK_CHILDREN_TYPES = new Set([
|
||||||
|
'paragraph',
|
||||||
|
'list',
|
||||||
|
'heading',
|
||||||
|
'blockquote',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const calloutBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||||
|
flavour: CalloutBlockSchema.model.flavour,
|
||||||
|
toMatch: o => isCalloutNode(o.node),
|
||||||
|
fromMatch: o => o.node.flavour === CalloutBlockSchema.model.flavour,
|
||||||
|
toBlockSnapshot: {
|
||||||
|
enter: (o, context) => {
|
||||||
|
if (!o.node.data || !isCalloutNode(o.node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently, the callout block children can only be a paragraph or a list
|
||||||
|
// So we should filter out the other children
|
||||||
|
o.node.children = o.node.children.filter(child =>
|
||||||
|
CALLOUT_BLOCK_CHILDREN_TYPES.has(child.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { walkerContext } = context;
|
||||||
|
const calloutEmoji = getCalloutEmoji(o.node);
|
||||||
|
walkerContext.openNode(
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
id: nanoid(),
|
||||||
|
flavour: CalloutBlockSchema.model.flavour,
|
||||||
|
props: {
|
||||||
|
emoji: calloutEmoji,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
'children'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
leave: (o, context) => {
|
||||||
|
const { walkerContext } = context;
|
||||||
|
if (isCalloutNode(o.node)) {
|
||||||
|
walkerContext.closeNode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fromBlockSnapshot: {
|
||||||
|
enter: (o, context) => {
|
||||||
|
const emoji = o.node.props.emoji as string;
|
||||||
|
const { walkerContext } = context;
|
||||||
|
walkerContext
|
||||||
|
.openNode(
|
||||||
|
{
|
||||||
|
type: 'blockquote',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
'children'
|
||||||
|
)
|
||||||
|
.openNode({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
value: `[!${emoji}]`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.closeNode();
|
||||||
|
},
|
||||||
|
leave: (_, context) => {
|
||||||
|
const { walkerContext } = context;
|
||||||
|
walkerContext.closeNode();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CalloutBlockMarkdownAdapterExtension =
|
||||||
|
BlockMarkdownAdapterExtension(calloutBlockMarkdownAdapterMatcher);
|
||||||
@@ -3,6 +3,7 @@ import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
|||||||
import type { ExtensionType } from '@blocksuite/store';
|
import type { ExtensionType } from '@blocksuite/store';
|
||||||
import { literal } from 'lit/static-html.js';
|
import { literal } from 'lit/static-html.js';
|
||||||
|
|
||||||
|
import { CalloutBlockMarkdownAdapterExtension } from './adapters/markdown';
|
||||||
import { CalloutKeymapExtension } from './callout-keymap';
|
import { CalloutKeymapExtension } from './callout-keymap';
|
||||||
import { calloutSlashMenuConfig } from './configs/slash-menu';
|
import { calloutSlashMenuConfig } from './configs/slash-menu';
|
||||||
|
|
||||||
@@ -11,4 +12,5 @@ export const CalloutBlockSpec: ExtensionType[] = [
|
|||||||
BlockViewExtension('affine:callout', literal`affine-callout`),
|
BlockViewExtension('affine:callout', literal`affine-callout`),
|
||||||
CalloutKeymapExtension,
|
CalloutKeymapExtension,
|
||||||
SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig),
|
SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig),
|
||||||
|
CalloutBlockMarkdownAdapterExtension,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
} from '@blocksuite/affine-ext-loader';
|
} from '@blocksuite/affine-ext-loader';
|
||||||
import { CalloutBlockSchemaExtension } from '@blocksuite/affine-model';
|
import { CalloutBlockSchemaExtension } from '@blocksuite/affine-model';
|
||||||
|
|
||||||
|
import { CalloutBlockMarkdownAdapterExtension } from './adapters/markdown';
|
||||||
|
|
||||||
export class CalloutStoreExtension extends StoreExtensionProvider {
|
export class CalloutStoreExtension extends StoreExtensionProvider {
|
||||||
override name = 'affine-callout-block';
|
override name = 'affine-callout-block';
|
||||||
|
|
||||||
override setup(context: StoreExtensionContext) {
|
override setup(context: StoreExtensionContext) {
|
||||||
super.setup(context);
|
super.setup(context);
|
||||||
context.register(CalloutBlockSchemaExtension);
|
context.register(CalloutBlockSchemaExtension);
|
||||||
|
context.register(CalloutBlockMarkdownAdapterExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BlockMarkdownAdapterExtension,
|
BlockMarkdownAdapterExtension,
|
||||||
type BlockMarkdownAdapterMatcher,
|
type BlockMarkdownAdapterMatcher,
|
||||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||||
|
isCalloutNode,
|
||||||
type MarkdownAST,
|
type MarkdownAST,
|
||||||
} from '@blocksuite/affine-shared/adapters';
|
} from '@blocksuite/affine-shared/adapters';
|
||||||
import type { DeltaInsert } from '@blocksuite/store';
|
import type { DeltaInsert } from '@blocksuite/store';
|
||||||
@@ -26,7 +27,7 @@ const isParagraphMDASTType = (node: MarkdownAST) =>
|
|||||||
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||||
{
|
{
|
||||||
flavour: ParagraphBlockSchema.model.flavour,
|
flavour: ParagraphBlockSchema.model.flavour,
|
||||||
toMatch: o => isParagraphMDASTType(o.node),
|
toMatch: o => isParagraphMDASTType(o.node) && !isCalloutNode(o.node),
|
||||||
fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour,
|
fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour,
|
||||||
toBlockSnapshot: {
|
toBlockSnapshot: {
|
||||||
enter: (o, context) => {
|
enter: (o, context) => {
|
||||||
@@ -78,6 +79,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'blockquote': {
|
case 'blockquote': {
|
||||||
|
if (isCalloutNode(o.node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
walkerContext
|
walkerContext
|
||||||
.openNode(
|
.openNode(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"remark-stringify": "^11.0.0",
|
"remark-stringify": "^11.0.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"yjs": "^13.6.21",
|
"yjs": "^13.6.21",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Blockquote, Paragraph } from 'mdast';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { remarkGfm } from '../../../adapters/markdown/gfm';
|
||||||
|
import { remarkCallout } from '../../../adapters/markdown/remark-plugins';
|
||||||
|
import type { MarkdownAST } from '../../../adapters/markdown/type';
|
||||||
|
|
||||||
|
describe('remarkCallout plugin', () => {
|
||||||
|
function isBlockQuote(node: MarkdownAST): node is Blockquote {
|
||||||
|
return node.type === 'blockquote';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParagraph(node: MarkdownAST): node is Paragraph {
|
||||||
|
return node.type === 'paragraph';
|
||||||
|
}
|
||||||
|
|
||||||
|
const process = (content: string) => {
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkCallout);
|
||||||
|
const ast = processor.parse(content);
|
||||||
|
return processor.runSync(ast);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertCallout = (
|
||||||
|
root: any,
|
||||||
|
expectedEmoji: string,
|
||||||
|
expectedText?: string
|
||||||
|
) => {
|
||||||
|
const firstChild = root.children[0];
|
||||||
|
expect(isBlockQuote(firstChild)).toBe(true);
|
||||||
|
expect(firstChild.data).toEqual({
|
||||||
|
isCallout: true,
|
||||||
|
calloutEmoji: expectedEmoji,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expectedText !== undefined) {
|
||||||
|
if (expectedText === '') {
|
||||||
|
// if expectedText is empty, the callout should not have any children
|
||||||
|
expect(firstChild.children).toHaveLength(0);
|
||||||
|
} else {
|
||||||
|
const firstParagraph = firstChild.children[0];
|
||||||
|
expect(isParagraph(firstParagraph)).toBe(true);
|
||||||
|
expect(firstParagraph.children[0].value).toBe(expectedText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertRegularBlockquote = (root: any, expectedText: string) => {
|
||||||
|
const firstChild = root.children[0];
|
||||||
|
expect(isBlockQuote(firstChild)).toBe(true);
|
||||||
|
expect(firstChild.data).toBeUndefined();
|
||||||
|
|
||||||
|
const firstParagraph = firstChild.children[0];
|
||||||
|
expect(isParagraph(firstParagraph)).toBe(true);
|
||||||
|
expect(firstParagraph.children[0].value).toBe(expectedText);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should transform callout with emoji and text in the same line', async () => {
|
||||||
|
const root = process('> [!💡] This is a callout with emoji');
|
||||||
|
assertCallout(root, '💡', 'This is a callout with emoji');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform callout without emoji and text in the same line', async () => {
|
||||||
|
const root = process('> [!] This is a callout without emoji');
|
||||||
|
assertCallout(root, '', 'This is a callout without emoji');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle callout with multiple lines and text in the different line', async () => {
|
||||||
|
const root = process('> [!💡]\n> with multiple lines');
|
||||||
|
assertCallout(root, '💡', 'with multiple lines');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty callout', async () => {
|
||||||
|
const root = process('> [!💡]');
|
||||||
|
assertCallout(root, '💡', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle callout with leading whitespace', async () => {
|
||||||
|
const root = process(
|
||||||
|
'> [!💡]\n> This is a callout with leading whitespace\n '
|
||||||
|
);
|
||||||
|
assertCallout(root, '💡', 'This is a callout with leading whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle callout with trailing whitespace', async () => {
|
||||||
|
const root = process(
|
||||||
|
'> [!💡]\n> This is a callout with trailing whitespace\n '
|
||||||
|
);
|
||||||
|
assertCallout(root, '💡', 'This is a callout with trailing whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not transform regular blockquote', async () => {
|
||||||
|
const root = process('> This is a regular blockquote');
|
||||||
|
assertRegularBlockquote(root, 'This is a regular blockquote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not transform regular blockquote when the emoji is not in the start of the line', async () => {
|
||||||
|
const root = process('> This is a regular blockquote [!💡]');
|
||||||
|
assertRegularBlockquote(root, 'This is a regular blockquote [!💡]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not transform when callout marker is in the middle of text', async () => {
|
||||||
|
const root = process(
|
||||||
|
'> This is a regular blockquote with [!💡] in the middle'
|
||||||
|
);
|
||||||
|
assertRegularBlockquote(
|
||||||
|
root,
|
||||||
|
'This is a regular blockquote with [!💡] in the middle'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple callouts in the same document', async () => {
|
||||||
|
const root = process(
|
||||||
|
`> [!💡] First callout\n\n> [!] Second callout without emoji`
|
||||||
|
);
|
||||||
|
expect(root.children).toHaveLength(2);
|
||||||
|
|
||||||
|
assertCallout({ children: [root.children[0]] }, '💡', 'First callout');
|
||||||
|
assertCallout(
|
||||||
|
{ children: [root.children[1]] },
|
||||||
|
'',
|
||||||
|
'Second callout without emoji'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple callouts and regular blockquote in the same document', async () => {
|
||||||
|
const root = process(
|
||||||
|
`> [!💡] First callout\n\n> [!] Second callout without emoji\n\n> This is a regular blockquote`
|
||||||
|
);
|
||||||
|
expect(root.children).toHaveLength(3);
|
||||||
|
|
||||||
|
assertCallout({ children: [root.children[0]] }, '💡', 'First callout');
|
||||||
|
assertCallout(
|
||||||
|
{ children: [root.children[1]] },
|
||||||
|
'',
|
||||||
|
'Second callout without emoji'
|
||||||
|
);
|
||||||
|
assertRegularBlockquote(
|
||||||
|
{ children: [root.children[2]] },
|
||||||
|
'This is a regular blockquote'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,11 +22,13 @@ export {
|
|||||||
type BlockMarkdownAdapterMatcher,
|
type BlockMarkdownAdapterMatcher,
|
||||||
BlockMarkdownAdapterMatcherIdentifier,
|
BlockMarkdownAdapterMatcherIdentifier,
|
||||||
FOOTNOTE_DEFINITION_PREFIX,
|
FOOTNOTE_DEFINITION_PREFIX,
|
||||||
|
getCalloutEmoji,
|
||||||
getFootnoteDefinitionText,
|
getFootnoteDefinitionText,
|
||||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||||
InlineDeltaToMarkdownAdapterExtension,
|
InlineDeltaToMarkdownAdapterExtension,
|
||||||
type InlineDeltaToMarkdownAdapterMatcher,
|
type InlineDeltaToMarkdownAdapterMatcher,
|
||||||
InlineDeltaToMarkdownAdapterMatcherIdentifier,
|
InlineDeltaToMarkdownAdapterMatcherIdentifier,
|
||||||
|
isCalloutNode,
|
||||||
isFootnoteDefinitionNode,
|
isFootnoteDefinitionNode,
|
||||||
isMarkdownAST,
|
isMarkdownAST,
|
||||||
type Markdown,
|
type Markdown,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from './delta-converter';
|
} from './delta-converter';
|
||||||
import { remarkGfm } from './gfm';
|
import { remarkGfm } from './gfm';
|
||||||
import { MarkdownPreprocessorManager } from './preprocessor';
|
import { MarkdownPreprocessorManager } from './preprocessor';
|
||||||
|
import { remarkCallout } from './remark-plugins/remark-callout';
|
||||||
import type { Markdown, MarkdownAST } from './type';
|
import type { Markdown, MarkdownAST } from './type';
|
||||||
|
|
||||||
type MarkdownToSliceSnapshotPayload = {
|
type MarkdownToSliceSnapshotPayload = {
|
||||||
@@ -204,11 +205,13 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _markdownToAst(markdown: Markdown) {
|
private _markdownToAst(markdown: Markdown) {
|
||||||
return unified()
|
const processor = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
.use(remarkMath)
|
.use(remarkMath)
|
||||||
.parse(markdown);
|
.use(remarkCallout);
|
||||||
|
const ast = processor.parse(markdown);
|
||||||
|
return processor.runSync(ast);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fromBlockSnapshot({
|
async fromBlockSnapshot({
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './remark-callout';
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Root } from 'mdast';
|
||||||
|
import type { Plugin } from 'unified';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex for the callout
|
||||||
|
* The emoji is optional, so we use `[\p{Extended_Pictographic}]?` to match it
|
||||||
|
* The `u` flag is for Unicode support
|
||||||
|
* And only match the line start
|
||||||
|
* @example
|
||||||
|
* ```md
|
||||||
|
* [!💡]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const calloutRegex = /^\[!([\p{Extended_Pictographic}]?)\]/u;
|
||||||
|
|
||||||
|
export const remarkCallout: Plugin<[], Root> = () => {
|
||||||
|
return tree => {
|
||||||
|
visit(tree, 'blockquote', node => {
|
||||||
|
// Only process the first child of the blockquote
|
||||||
|
const firstChild = node.children[0];
|
||||||
|
let children = node.children;
|
||||||
|
if (firstChild?.type === 'paragraph') {
|
||||||
|
const firstNode = firstChild.children[0];
|
||||||
|
if (firstNode?.type === 'text') {
|
||||||
|
const text = firstNode.value;
|
||||||
|
const match = text.match(calloutRegex);
|
||||||
|
if (match) {
|
||||||
|
const calloutEmoji = match[1];
|
||||||
|
// Set the callout data
|
||||||
|
node.data = {
|
||||||
|
isCallout: true,
|
||||||
|
calloutEmoji,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the matched callout pattern
|
||||||
|
const currentText = text
|
||||||
|
.replace(calloutRegex, '')
|
||||||
|
.replace(/^\n/, '');
|
||||||
|
|
||||||
|
// If only one child node and it's empty text, remove all children
|
||||||
|
if (firstChild.children.length === 1 && currentText.length === 0) {
|
||||||
|
firstChild.children = [];
|
||||||
|
// If the first child only has one text node, and the text is only whitespace, remove the first child of blockquote
|
||||||
|
children = children.slice(1);
|
||||||
|
} else {
|
||||||
|
// Otherwise keep remaining children with the callout pattern removed
|
||||||
|
firstChild.children[0] = {
|
||||||
|
type: 'text',
|
||||||
|
value: currentText.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children = [...children];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the BlockquoteData interface to include isCallout and calloutEmoji properties
|
||||||
|
*/
|
||||||
|
declare module 'mdast' {
|
||||||
|
interface BlockquoteData {
|
||||||
|
isCallout?: boolean;
|
||||||
|
calloutEmoji?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { FootnoteDefinition, Root, RootContentMap } from 'mdast';
|
import type {
|
||||||
|
Blockquote,
|
||||||
|
FootnoteDefinition,
|
||||||
|
Root,
|
||||||
|
RootContentMap,
|
||||||
|
} from 'mdast';
|
||||||
|
|
||||||
export type Markdown = string;
|
export type Markdown = string;
|
||||||
|
|
||||||
@@ -28,5 +33,13 @@ export const getFootnoteDefinitionText = (node: FootnoteDefinition) => {
|
|||||||
return paragraph.value;
|
return paragraph.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isCalloutNode = (node: MarkdownAST): node is Blockquote => {
|
||||||
|
return node.type === 'blockquote' && !!node.data?.isCallout;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCalloutEmoji = (node: Blockquote) => {
|
||||||
|
return node.data?.calloutEmoji ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:';
|
export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:';
|
||||||
export const IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph';
|
export const IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph';
|
||||||
|
|||||||
@@ -3702,6 +3702,7 @@ __metadata:
|
|||||||
remark-stringify: "npm:^11.0.0"
|
remark-stringify: "npm:^11.0.0"
|
||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
unified: "npm:^11.0.5"
|
unified: "npm:^11.0.5"
|
||||||
|
unist-util-visit: "npm:^5.0.0"
|
||||||
vitest: "npm:3.1.2"
|
vitest: "npm:3.1.2"
|
||||||
yjs: "npm:^13.6.21"
|
yjs: "npm:^13.6.21"
|
||||||
zod: "npm:^3.23.8"
|
zod: "npm:^3.23.8"
|
||||||
|
|||||||
Reference in New Issue
Block a user