From b1594fcf6fc7dab3808a90ba3a3227f57125d88b Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Thu, 15 May 2025 01:03:37 +0000 Subject: [PATCH] feat(editor): provide callout markdown export options middleware (#12283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: [BS-3491](https://linear.app/affine-design/issue/BS-3491/支持-callout-导出成-vitepress-文档站支持的-markdown-格式) ## 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. --- .../__tests__/adapters/markdown.unit.spec.ts | 362 +++++++++++++----- .../blocks/callout/src/adapters/markdown.ts | 159 +++++++- .../middlewares/callout-export-options.ts | 45 +++ .../shared/src/adapters/middlewares/index.ts | 1 + 4 files changed, 452 insertions(+), 115 deletions(-) create mode 100644 blocksuite/affine/shared/src/adapters/middlewares/callout-export-options.ts diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts index bfb1377f78..26df93f952 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -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); }); }); diff --git a/blocksuite/affine/blocks/callout/src/adapters/markdown.ts b/blocksuite/affine/blocks/callout/src/adapters/markdown.ts index 2606506b7a..b5439153f9 100644 --- a/blocksuite/affine/blocks/callout/src/adapters/markdown.ts +++ b/blocksuite/affine/blocks/callout/src/adapters/markdown.ts @@ -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 +): 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(); + } }, }, }; diff --git a/blocksuite/affine/shared/src/adapters/middlewares/callout-export-options.ts b/blocksuite/affine/shared/src/adapters/middlewares/callout-export-options.ts new file mode 100644 index 0000000000..18b8632576 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/middlewares/callout-export-options.ts @@ -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 = 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); + }; +}; diff --git a/blocksuite/affine/shared/src/adapters/middlewares/index.ts b/blocksuite/affine/shared/src/adapters/middlewares/index.ts index 52c3c450b0..d830b359db 100644 --- a/blocksuite/affine/shared/src/adapters/middlewares/index.ts +++ b/blocksuite/affine/shared/src/adapters/middlewares/index.ts @@ -1,3 +1,4 @@ +export * from './callout-export-options'; export * from './code'; export * from './copy'; export * from './doc-link';