mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 17:46:18 +08:00
feat(editor): provide callout markdown export options middleware (#12283)
Closes: [BS-3491](https://linear.app/affine-design/issue/BS-3491/支持-callout-导出成-vitepress-文档站支持的-markdown-格式) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for exporting callout blocks in both GFM and Admonition markdown styles, including configurable admonition types and custom titles. - Introduced middleware to customize callout export options for markdown serialization. - **Tests** - Enhanced test coverage for callout markdown export, including scenarios with and without export middleware for different styles and content structures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -4,6 +4,9 @@ import {
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
CalloutAdmonitionType,
|
||||
CalloutExportStyle,
|
||||
calloutMarkdownExportMiddleware,
|
||||
embedSyncedDocMiddleware,
|
||||
MarkdownAdapter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
@@ -2449,119 +2452,288 @@ World!
|
||||
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: {},
|
||||
describe('callout', () => {
|
||||
test('without export middleware', async () => {
|
||||
const blockSnapshot: BlockSnapshot = {
|
||||
type: 'block',
|
||||
id: 'block:vu6SK6WJpW',
|
||||
flavour: 'affine:page',
|
||||
props: {
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [],
|
||||
},
|
||||
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:Tk4gSPocAt',
|
||||
flavour: 'affine:surface',
|
||||
props: {
|
||||
elements: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
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' }],
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
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' }],
|
||||
{
|
||||
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: 'Warning second callout without emoji' },
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'block:8hOLxbfdb',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'quote',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: 'This is a regular blockquote' }],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'block:8hOLxad5Fv',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: 'Text in second callout' }],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const markdown = `> \\[!💡]
|
||||
const markdown = `> \\[!💡]
|
||||
>
|
||||
> First callout
|
||||
|
||||
> \\[!]
|
||||
>
|
||||
> Second callout without emoji
|
||||
|
||||
> This is a regular blockquote
|
||||
> Warning second callout without emoji
|
||||
>
|
||||
> Text in second callout
|
||||
`;
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||
const target = await mdAdapter.fromBlockSnapshot({
|
||||
snapshot: blockSnapshot,
|
||||
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||
const target = await mdAdapter.fromBlockSnapshot({
|
||||
snapshot: blockSnapshot,
|
||||
});
|
||||
expect(target.file).toBe(markdown);
|
||||
});
|
||||
|
||||
test('with export middleware', 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: 'Callout that does not have a title' },
|
||||
],
|
||||
},
|
||||
},
|
||||
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:
|
||||
'Warning callout with custom title and multiple paragraphs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'block:8hOLxad5Fv',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: 'Text in second callout' }],
|
||||
},
|
||||
},
|
||||
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: 'details' },
|
||||
{ insert: ' ' },
|
||||
{ insert: '\nText in details callout with new line' },
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const markdown = `::: info
|
||||
|
||||
Callout that does not have a title
|
||||
|
||||
:::
|
||||
|
||||
::: warning callout with custom title and multiple paragraphs
|
||||
|
||||
Text in second callout
|
||||
|
||||
:::
|
||||
|
||||
::: details
|
||||
|
||||
Text in details callout with new line
|
||||
|
||||
:::
|
||||
`;
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(
|
||||
createJob([
|
||||
calloutMarkdownExportMiddleware({
|
||||
style: CalloutExportStyle.Admonitions,
|
||||
admonitionType: CalloutAdmonitionType.Info,
|
||||
}),
|
||||
]),
|
||||
provider
|
||||
);
|
||||
const target = await mdAdapter.fromBlockSnapshot({
|
||||
snapshot: blockSnapshot,
|
||||
});
|
||||
expect(target.file).toBe(markdown);
|
||||
});
|
||||
expect(target.file).toBe(markdown);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@ import { CalloutBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY,
|
||||
type CalloutAdmonitionType,
|
||||
CalloutAdmonitionTypeSet,
|
||||
CalloutExportStyle,
|
||||
type CalloutMarkdownExportOptions,
|
||||
calloutMarkdownExportOptionsSchema,
|
||||
DEFAULT_ADMONITION_TYPE,
|
||||
getCalloutEmoji,
|
||||
isCalloutNode,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import { type DeltaInsert, 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`
|
||||
@@ -16,6 +23,29 @@ const CALLOUT_BLOCK_CHILDREN_TYPES = new Set([
|
||||
'blockquote',
|
||||
]);
|
||||
|
||||
const ADMONITION_SYMBOL = ':::';
|
||||
const DEFAULT_OPTIONS: CalloutMarkdownExportOptions = {
|
||||
style: CalloutExportStyle.GFM,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the callout export options from the configs
|
||||
* @param configs - The configs of the callout block
|
||||
* @returns The callout export options
|
||||
*/
|
||||
function getCalloutExportOptions(
|
||||
configs: Map<string, unknown>
|
||||
): CalloutMarkdownExportOptions {
|
||||
let exportOptions: CalloutMarkdownExportOptions = DEFAULT_OPTIONS;
|
||||
try {
|
||||
const options = configs.get(CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY);
|
||||
if (options) {
|
||||
exportOptions = calloutMarkdownExportOptionsSchema.parse(options);
|
||||
}
|
||||
} catch {}
|
||||
return exportOptions;
|
||||
}
|
||||
|
||||
export const calloutBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: CalloutBlockSchema.model.flavour,
|
||||
toMatch: o => isCalloutNode(o.node),
|
||||
@@ -57,29 +87,118 @@ export const calloutBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
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: [
|
||||
const { walkerContext, configs } = context;
|
||||
|
||||
const exportOptions = getCalloutExportOptions(configs);
|
||||
const { style, admonitionType } = exportOptions;
|
||||
// If the style is admonitions, we should handle the first child
|
||||
if (style === CalloutExportStyle.Admonitions) {
|
||||
let type = admonitionType ?? DEFAULT_ADMONITION_TYPE;
|
||||
let customTitle = '';
|
||||
let restOfText = '';
|
||||
|
||||
const firstChild = o.node.children[0];
|
||||
const isTextNode = !!firstChild.props.text;
|
||||
// If the first child is a text block, we should get the type and custom title from the first line of the text
|
||||
// And remove the first child from the children
|
||||
// Otherwise, we should use the default admonition type as the type
|
||||
if (isTextNode) {
|
||||
const textDelta = (firstChild.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
// Get the text of the first child
|
||||
const text = textDelta.delta.reduce((acc, delta) => {
|
||||
if (delta.insert) {
|
||||
acc += delta.insert;
|
||||
}
|
||||
return acc;
|
||||
}, '');
|
||||
|
||||
// If the text is not empty, we should try to get type and custom title from the text
|
||||
if (text) {
|
||||
// Get the first line of the text
|
||||
const firstLine = text.includes('\n') ? text.split('\n')[0] : text;
|
||||
// Get the rest of the text besides the first line
|
||||
restOfText = text.split('\n').slice(1).join('\n');
|
||||
// Get the possible type from the first line
|
||||
const possibleType = firstLine.split(' ')[0].toLowerCase();
|
||||
// If the type is a valid admonition type, we should use it as the type
|
||||
if (CalloutAdmonitionTypeSet.has(possibleType)) {
|
||||
type = possibleType as CalloutAdmonitionType;
|
||||
// Get the custom title from the first line
|
||||
customTitle = firstLine.split(' ').slice(1).join(' ').trim();
|
||||
// Remove the first child from the children
|
||||
o.node.children = o.node.children.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add an admonition symbol paragraph to the start of the children
|
||||
const admonitionSymbol =
|
||||
`${ADMONITION_SYMBOL} ${type} ${customTitle}`.trim();
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: admonitionSymbol,
|
||||
},
|
||||
],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
// Add the rest of the text to the children content
|
||||
if (restOfText) {
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', value: `${restOfText}` }],
|
||||
})
|
||||
.closeNode();
|
||||
}
|
||||
} else {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'text',
|
||||
value: `[!${emoji}]`,
|
||||
type: 'blockquote',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
.closeNode();
|
||||
'children'
|
||||
)
|
||||
.openNode({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: `[!${emoji}]`,
|
||||
},
|
||||
],
|
||||
})
|
||||
.closeNode();
|
||||
}
|
||||
},
|
||||
leave: (_, context) => {
|
||||
const { walkerContext } = context;
|
||||
walkerContext.closeNode();
|
||||
const { walkerContext, configs } = context;
|
||||
const exportOptions = getCalloutExportOptions(configs);
|
||||
const { style } = exportOptions;
|
||||
// If the style is admonitions, we should add an admonition symbol paragraph to the end of the children
|
||||
if (style === CalloutExportStyle.Admonitions) {
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: ADMONITION_SYMBOL,
|
||||
},
|
||||
],
|
||||
})
|
||||
.closeNode();
|
||||
} else {
|
||||
// If the style is gfm, we should close the outer blockquote node
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { TransformerMiddleware } from '@blocksuite/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY =
|
||||
'calloutMarkdownExportOptions';
|
||||
|
||||
export enum CalloutExportStyle {
|
||||
GFM = 'GFM',
|
||||
Admonitions = 'Admonitions',
|
||||
}
|
||||
|
||||
export enum CalloutAdmonitionType {
|
||||
Info = 'info',
|
||||
Tip = 'tip',
|
||||
Warning = 'warning',
|
||||
Danger = 'danger',
|
||||
Details = 'details',
|
||||
}
|
||||
|
||||
export const DEFAULT_ADMONITION_TYPE = CalloutAdmonitionType.Info;
|
||||
|
||||
export const CalloutAdmonitionTypeSet: Set<string> = new Set(
|
||||
Object.values(CalloutAdmonitionType)
|
||||
);
|
||||
|
||||
export const calloutMarkdownExportOptionsSchema = z.object({
|
||||
style: z.nativeEnum(CalloutExportStyle),
|
||||
admonitionType: z.nativeEnum(CalloutAdmonitionType).optional(),
|
||||
});
|
||||
export type CalloutMarkdownExportOptions = z.infer<
|
||||
typeof calloutMarkdownExportOptionsSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Middleware to set the export style of the callout block
|
||||
* @param style - The markdown export style of the callout block
|
||||
* @returns A TransformerMiddleware that sets the markdown export style of the callout block
|
||||
*/
|
||||
export const calloutMarkdownExportMiddleware = (
|
||||
options: CalloutMarkdownExportOptions
|
||||
): TransformerMiddleware => {
|
||||
return ({ adapterConfigs }) => {
|
||||
adapterConfigs.set(CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY, options);
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './callout-export-options';
|
||||
export * from './code';
|
||||
export * from './copy';
|
||||
export * from './doc-link';
|
||||
|
||||
Reference in New Issue
Block a user