mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
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:
@@ -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,
|
||||
BlockMarkdownAdapterMatcherIdentifier,
|
||||
FOOTNOTE_DEFINITION_PREFIX,
|
||||
getCalloutEmoji,
|
||||
getFootnoteDefinitionText,
|
||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||
InlineDeltaToMarkdownAdapterExtension,
|
||||
type InlineDeltaToMarkdownAdapterMatcher,
|
||||
InlineDeltaToMarkdownAdapterMatcherIdentifier,
|
||||
isCalloutNode,
|
||||
isFootnoteDefinitionNode,
|
||||
isMarkdownAST,
|
||||
type Markdown,
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from './delta-converter';
|
||||
import { remarkGfm } from './gfm';
|
||||
import { MarkdownPreprocessorManager } from './preprocessor';
|
||||
import { remarkCallout } from './remark-plugins/remark-callout';
|
||||
import type { Markdown, MarkdownAST } from './type';
|
||||
|
||||
type MarkdownToSliceSnapshotPayload = {
|
||||
@@ -204,11 +205,13 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
|
||||
}
|
||||
|
||||
private _markdownToAst(markdown: Markdown) {
|
||||
return unified()
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkMath)
|
||||
.parse(markdown);
|
||||
.use(remarkCallout);
|
||||
const ast = processor.parse(markdown);
|
||||
return processor.runSync(ast);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -28,5 +33,13 @@ export const getFootnoteDefinitionText = (node: FootnoteDefinition) => {
|
||||
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 IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph';
|
||||
|
||||
Reference in New Issue
Block a user