feat(editor): support markdown adapter preprocessed with latex delimiters (#11431)

To close [BS-2870](https://linear.app/affine-design/issue/BS-2870/支持识别-和-[-包裹内容为公式)

## Add Markdown Preprocessor Extension and Enhanced LaTeX Support

### Markdown Preprocessor Extension
This PR introduces a new preprocessor extension for Markdown adapters that allows preprocessing of content before conversion:

Adds a new PreprocessorManager for handling text transformations
Introduces extensible preprocessor interface that supports different processing levels (block/slice/doc)

Integrates preprocessor extension into the existing Markdown adapter workflow

### LaTeX Support Enhancement
Extends LaTeX support to handle both traditional and alternative syntax:
Adds support for backslash LaTeX syntax:

Block math: ```\[...\] ``` alongside existing ```$$...$$```
Inline math: ```\(...\) ``` alongside existing ```$...$```

Implements LaTeX preprocessor to standardize syntax before conversion

Updates tests to cover both syntax variants
This commit is contained in:
donteatfriedrice
2025-04-07 02:18:04 +00:00
parent e376992ccf
commit 568a390b75
31 changed files with 450 additions and 260 deletions

View File

@@ -3680,88 +3680,95 @@ bbb
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
test('inline latex', async () => { describe('inline latex', () => {
const markdown = 'inline $E=mc^2$ latex\n'; test.each([
const blockSnapshot: BlockSnapshot = { ['dollar sign syntax', 'inline $E=mc^2$ latex\n'],
type: 'block', ['backslash syntax', 'inline \\(E=mc^2\\) latex\n'],
id: 'matchesReplaceMap[0]', ])('should convert %s correctly', async (_, markdown) => {
flavour: 'affine:note', const blockSnapshot: BlockSnapshot = {
props: { type: 'block',
xywh: '[0,0,800,95]', id: 'matchesReplaceMap[0]',
background: DefaultTheme.noteBackgrounColor, flavour: 'affine:note',
index: 'a0', props: {
hidden: false, xywh: '[0,0,800,95]',
displayMode: NoteDisplayMode.DocAndEdgeless, background: DefaultTheme.noteBackgrounColor,
}, index: 'a0',
children: [ hidden: false,
{ displayMode: NoteDisplayMode.DocAndEdgeless,
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'inline ',
},
{
insert: ' ',
attributes: {
latex: 'E=mc^2',
},
},
{
insert: ' latex',
},
],
},
},
children: [],
}, },
], children: [
}; {
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'inline ',
},
{
insert: ' ',
attributes: {
latex: 'E=mc^2',
},
},
{
insert: ' latex',
},
],
},
},
children: [],
},
],
};
const mdAdapter = new MarkdownAdapter(createJob(), provider); const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown, file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
test('latex block', async () => { describe('latex block', () => {
const markdown = '$$\nE=mc^2\n$$\n'; test.each([
['dollar sign syntax', '$$\nE=mc^2\n$$\n'],
const blockSnapshot: BlockSnapshot = { ['backslash syntax', '\\[\nE=mc^2\n\\]\n'],
type: 'block', ])('should convert %s correctly', async (_, markdown) => {
id: 'matchesReplaceMap[0]', const blockSnapshot: BlockSnapshot = {
flavour: 'affine:note', type: 'block',
props: { id: 'matchesReplaceMap[0]',
xywh: '[0,0,800,95]', flavour: 'affine:note',
background: DefaultTheme.noteBackgrounColor, props: {
index: 'a0', xywh: '[0,0,800,95]',
hidden: false, background: DefaultTheme.noteBackgrounColor,
displayMode: NoteDisplayMode.DocAndEdgeless, index: 'a0',
}, hidden: false,
children: [ displayMode: NoteDisplayMode.DocAndEdgeless,
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:latex',
props: {
latex: 'E=mc^2',
},
children: [],
}, },
], children: [
}; {
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:latex',
props: {
latex: 'E=mc^2',
},
children: [],
},
],
};
const mdAdapter = new MarkdownAdapter(createJob(), provider); const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown, file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
test('reference', async () => { test('reference', async () => {

View File

@@ -20,6 +20,7 @@ import type { ExtensionType } from '@blocksuite/store';
import { defaultBlockHtmlAdapterMatchers } from './html/block-matcher'; import { defaultBlockHtmlAdapterMatchers } from './html/block-matcher';
import { defaultBlockMarkdownAdapterMatchers } from './markdown/block-matcher'; import { defaultBlockMarkdownAdapterMatchers } from './markdown/block-matcher';
import { defaultMarkdownPreprocessors } from './markdown/preprocessor';
import { defaultBlockNotionHtmlAdapterMatchers } from './notion-html/block-matcher'; import { defaultBlockNotionHtmlAdapterMatchers } from './notion-html/block-matcher';
import { defaultBlockPlainTextAdapterMatchers } from './plain-text/block-matcher'; import { defaultBlockPlainTextAdapterMatchers } from './plain-text/block-matcher';
@@ -44,6 +45,7 @@ export const MarkdownAdapterExtension: ExtensionType[] = [
...MarkdownInlineToDeltaAdapterExtensions, ...MarkdownInlineToDeltaAdapterExtensions,
...defaultBlockMarkdownAdapterMatchers, ...defaultBlockMarkdownAdapterMatchers,
...InlineDeltaToMarkdownAdapterExtensions, ...InlineDeltaToMarkdownAdapterExtensions,
...defaultMarkdownPreprocessors,
]; ];
export const NotionHtmlAdapterExtension: ExtensionType[] = [ export const NotionHtmlAdapterExtension: ExtensionType[] = [

View File

@@ -1,5 +1,6 @@
export * from './extension.js'; export * from './extension.js';
export * from './html/block-matcher.js'; export * from './html/block-matcher.js';
export * from './markdown/block-matcher.js'; export * from './markdown/block-matcher.js';
export * from './markdown/preprocessor.js';
export * from './notion-html/block-matcher.js'; export * from './notion-html/block-matcher.js';
export * from './plain-text/block-matcher.js'; export * from './plain-text/block-matcher.js';

View File

@@ -0,0 +1,7 @@
import { CodeMarkdownPreprocessorExtension } from '@blocksuite/affine-block-code';
import { LatexMarkdownPreprocessorExtension } from '@blocksuite/affine-block-latex';
export const defaultMarkdownPreprocessors = [
LatexMarkdownPreprocessorExtension,
CodeMarkdownPreprocessorExtension,
];

View File

@@ -1,13 +1,13 @@
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { CodeBlockHtmlAdapterExtension } from './html.js'; import { CodeBlockHtmlAdapterExtension } from './html.js';
import { CodeBlockMarkdownAdapterExtension } from './markdown.js'; import { CodeBlockMarkdownAdapterExtensions } from './markdown/index.js';
import { CodeBlockNotionHtmlAdapterExtension } from './notion-html.js'; import { CodeBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { CodeBlockPlainTextAdapterExtension } from './plain-text.js'; import { CodeBlockPlainTextAdapterExtension } from './plain-text.js';
export const CodeBlockAdapterExtensions: ExtensionType[] = [ export const CodeBlockAdapterExtensions: ExtensionType[] = [
CodeBlockHtmlAdapterExtension, CodeBlockHtmlAdapterExtension,
CodeBlockMarkdownAdapterExtension, CodeBlockMarkdownAdapterExtensions,
CodeBlockPlainTextAdapterExtension, CodeBlockPlainTextAdapterExtension,
CodeBlockNotionHtmlAdapterExtension, CodeBlockNotionHtmlAdapterExtension,
]; ].flat();

View File

@@ -1,4 +1,4 @@
export * from './html.js'; export * from './html.js';
export * from './markdown.js'; export * from './markdown/index.js';
export * from './notion-html.js'; export * from './notion-html.js';
export * from './plain-text.js'; export * from './plain-text.js';

View File

@@ -0,0 +1,12 @@
import type { ExtensionType } from '@blocksuite/store';
import { CodeBlockMarkdownAdapterExtension } from './markdown.js';
import { CodeMarkdownPreprocessorExtension } from './preprocessor.js';
export * from './markdown.js';
export * from './preprocessor.js';
export const CodeBlockMarkdownAdapterExtensions: ExtensionType[] = [
CodeMarkdownPreprocessorExtension,
CodeBlockMarkdownAdapterExtension,
];

View File

@@ -0,0 +1,76 @@
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
const codePreprocessor: MarkdownAdapterPreprocessor = {
name: 'code',
levels: ['slice'],
preprocess: content => {
let codeFence = '';
const lines = content
.split('\n')
.map(line => {
if (line.trimStart().startsWith('-')) {
return line;
}
let trimmedLine = line.trimStart();
if (!codeFence && trimmedLine.startsWith('```')) {
codeFence = trimmedLine.substring(
0,
trimmedLine.lastIndexOf('```') + 3
);
if (codeFence.split('').every(c => c === '`')) {
return line;
}
codeFence = '';
}
if (!codeFence && trimmedLine.startsWith('~~~')) {
codeFence = trimmedLine.substring(
0,
trimmedLine.lastIndexOf('~~~') + 3
);
if (codeFence.split('').every(c => c === '~')) {
return line;
}
codeFence = '';
}
if (
!!codeFence &&
trimmedLine.startsWith(codeFence) &&
trimmedLine.lastIndexOf(codeFence) === 0
) {
codeFence = '';
}
if (codeFence) {
return line;
}
trimmedLine = trimmedLine.trimEnd();
if (!trimmedLine.startsWith('<') && !trimmedLine.endsWith('>')) {
// check if it is a url link and wrap it with the angle brackets
// sometimes the url includes emphasis `_` that will break URL parsing
//
// eg. /MuawcBMT1Mzvoar09-_66?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_
// https://www.markdownguide.org/basic-syntax/#urls-and-email-addresses
try {
const valid =
URL.canParse?.(trimmedLine) ?? Boolean(new URL(trimmedLine));
if (valid) {
return `<${trimmedLine}>`;
}
} catch (err) {
console.log(err);
}
}
return line.replace(/^ /, '&#x20;');
})
.join('\n');
return lines;
},
};
export const CodeMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(codePreprocessor);

View File

@@ -1,11 +1,11 @@
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { LatexBlockMarkdownAdapterExtension } from './markdown.js'; import { LatexMarkdownAdapterExtensions } from './markdown/index.js';
import { LatexBlockNotionHtmlAdapterExtension } from './notion-html.js'; import { LatexBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { LatexBlockPlainTextAdapterExtension } from './plain-text.js'; import { LatexBlockPlainTextAdapterExtension } from './plain-text.js';
export const LatexBlockAdapterExtensions: ExtensionType[] = [ export const LatexBlockAdapterExtensions: ExtensionType[] = [
LatexBlockMarkdownAdapterExtension, LatexMarkdownAdapterExtensions,
LatexBlockNotionHtmlAdapterExtension, LatexBlockNotionHtmlAdapterExtension,
LatexBlockPlainTextAdapterExtension, LatexBlockPlainTextAdapterExtension,
]; ].flat();

View File

@@ -1,3 +1,3 @@
export * from './markdown.js'; export * from './markdown/index.js';
export * from './notion-html.js'; export * from './notion-html.js';
export * from './plain-text.js'; export * from './plain-text.js';

View File

@@ -0,0 +1,12 @@
import type { ExtensionType } from '@blocksuite/store';
import { LatexBlockMarkdownAdapterExtension } from './markdown.js';
import { LatexMarkdownPreprocessorExtension } from './preprocessor.js';
export * from './markdown.js';
export * from './preprocessor.js';
export const LatexMarkdownAdapterExtensions: ExtensionType[] = [
LatexMarkdownPreprocessorExtension,
LatexBlockMarkdownAdapterExtension,
];

View File

@@ -0,0 +1,25 @@
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
const latexPreprocessor: MarkdownAdapterPreprocessor = {
name: 'latex',
levels: ['block', 'slice', 'doc'],
preprocess: content => {
// Replace block-level LaTeX delimiters \[ \] with $$ $$
const blockProcessedContent = content.replace(
/\\\[(.*?)\\\]/gs,
(_, equation) => `$$${equation}$$`
);
// Replace inline LaTeX delimiters \( \) with $ $
const inlineProcessedContent = blockProcessedContent.replace(
/\\\((.*?)\\\)/gs,
(_, equation) => `$${equation}$`
);
return inlineProcessedContent;
},
};
export const LatexMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(latexPreprocessor);

View File

@@ -11,7 +11,7 @@ import {
DeltaASTConverter, DeltaASTConverter,
type DeltaASTConverterOptions, type DeltaASTConverterOptions,
type InlineDeltaMatcher, type InlineDeltaMatcher,
} from '../types/adapter.js'; } from '../types/delta-converter.js';
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js'; import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
import { AdapterTextUtils } from '../utils/text.js'; import { AdapterTextUtils } from '../utils/text.js';

View File

@@ -30,11 +30,14 @@ export {
MarkdownAdapter, MarkdownAdapter,
MarkdownAdapterFactoryExtension, MarkdownAdapterFactoryExtension,
MarkdownAdapterFactoryIdentifier, MarkdownAdapterFactoryIdentifier,
type MarkdownAdapterPreprocessor,
type MarkdownAST, type MarkdownAST,
MarkdownASTToDeltaExtension, MarkdownASTToDeltaExtension,
type MarkdownASTToDeltaMatcher, type MarkdownASTToDeltaMatcher,
MarkdownASTToDeltaMatcherIdentifier, MarkdownASTToDeltaMatcherIdentifier,
MarkdownDeltaConverter, MarkdownDeltaConverter,
MarkdownPreprocessorExtension,
MarkdownPreprocessorManager,
} from './markdown'; } from './markdown';
export * from './middlewares'; export * from './middlewares';
export * from './mix-text'; export * from './mix-text';

View File

@@ -10,7 +10,7 @@ import {
type ASTToDeltaMatcher, type ASTToDeltaMatcher,
DeltaASTConverter, DeltaASTConverter,
type InlineDeltaMatcher, type InlineDeltaMatcher,
} from '../types/adapter.js'; } from '../types/delta-converter.js';
import type { MarkdownAST } from './type.js'; import type { MarkdownAST } from './type.js';
export type InlineDeltaToMarkdownAdapterMatcher = export type InlineDeltaToMarkdownAdapterMatcher =

View File

@@ -1,4 +1,5 @@
export * from './block-adapter.js'; export * from './block-adapter.js';
export * from './delta-converter.js'; export * from './delta-converter.js';
export * from './markdown.js'; export * from './markdown.js';
export * from './preprocessor.js';
export * from './type.js'; export * from './type.js';

View File

@@ -37,6 +37,7 @@ import {
MarkdownDeltaConverter, MarkdownDeltaConverter,
} from './delta-converter'; } from './delta-converter';
import { remarkGfm } from './gfm'; import { remarkGfm } from './gfm';
import { MarkdownPreprocessorManager } from './preprocessor';
import type { Markdown, MarkdownAST } from './type'; import type { Markdown, MarkdownAST } from './type';
type MarkdownToSliceSnapshotPayload = { type MarkdownToSliceSnapshotPayload = {
@@ -167,6 +168,7 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
}; };
deltaConverter: MarkdownDeltaConverter; deltaConverter: MarkdownDeltaConverter;
preprocessorManager: MarkdownPreprocessorManager;
readonly blockMatchers: BlockMarkdownAdapterMatcher[]; readonly blockMatchers: BlockMarkdownAdapterMatcher[];
@@ -187,6 +189,7 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
inlineDeltaToMarkdownAdapterMatchers, inlineDeltaToMarkdownAdapterMatchers,
markdownInlineToDeltaMatchers markdownInlineToDeltaMatchers
); );
this.preprocessorManager = new MarkdownPreprocessorManager(provider);
} }
private _astToMarkdown(ast: Root) { private _astToMarkdown(ast: Root) {
@@ -273,7 +276,11 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
async toBlockSnapshot( async toBlockSnapshot(
payload: ToBlockSnapshotPayload<Markdown> payload: ToBlockSnapshotPayload<Markdown>
): Promise<BlockSnapshot> { ): Promise<BlockSnapshot> {
const markdownAst = this._markdownToAst(payload.file); const markdownFile = this.preprocessorManager.process(
'block',
payload.file
);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = { const blockSnapshotRoot = {
type: 'block', type: 'block',
id: nanoid(), id: nanoid(),
@@ -297,7 +304,8 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
async toDocSnapshot( async toDocSnapshot(
payload: ToDocSnapshotPayload<Markdown> payload: ToDocSnapshotPayload<Markdown>
): Promise<DocSnapshot> { ): Promise<DocSnapshot> {
const markdownAst = this._markdownToAst(payload.file); const markdownFile = this.preprocessorManager.process('doc', payload.file);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = { const blockSnapshotRoot = {
type: 'block', type: 'block',
id: nanoid(), id: nanoid(),
@@ -356,67 +364,11 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
async toSliceSnapshot( async toSliceSnapshot(
payload: MarkdownToSliceSnapshotPayload payload: MarkdownToSliceSnapshotPayload
): Promise<SliceSnapshot | null> { ): Promise<SliceSnapshot | null> {
let codeFence = ''; const markdownFile = this.preprocessorManager.process(
payload.file = payload.file 'slice',
.split('\n') payload.file
.map(line => { );
if (line.trimStart().startsWith('-')) { const markdownAst = this._markdownToAst(markdownFile);
return line;
}
let trimmedLine = line.trimStart();
if (!codeFence && trimmedLine.startsWith('```')) {
codeFence = trimmedLine.substring(
0,
trimmedLine.lastIndexOf('```') + 3
);
if (codeFence.split('').every(c => c === '`')) {
return line;
}
codeFence = '';
}
if (!codeFence && trimmedLine.startsWith('~~~')) {
codeFence = trimmedLine.substring(
0,
trimmedLine.lastIndexOf('~~~') + 3
);
if (codeFence.split('').every(c => c === '~')) {
return line;
}
codeFence = '';
}
if (
!!codeFence &&
trimmedLine.startsWith(codeFence) &&
trimmedLine.lastIndexOf(codeFence) === 0
) {
codeFence = '';
}
if (codeFence) {
return line;
}
trimmedLine = trimmedLine.trimEnd();
if (!trimmedLine.startsWith('<') && !trimmedLine.endsWith('>')) {
// check if it is a url link and wrap it with the angle brackets
// sometimes the url includes emphasis `_` that will break URL parsing
//
// eg. /MuawcBMT1Mzvoar09-_66?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_
// https://www.markdownguide.org/basic-syntax/#urls-and-email-addresses
try {
const valid =
URL.canParse?.(trimmedLine) ?? Boolean(new URL(trimmedLine));
if (valid) {
return `<${trimmedLine}>`;
}
} catch (err) {
console.log(err);
}
}
return line.replace(/^ /, '&#x20;');
})
.join('\n');
const markdownAst = this._markdownToAst(payload.file);
const blockSnapshotRoot = { const blockSnapshotRoot = {
type: 'block', type: 'block',
id: nanoid(), id: nanoid(),

View File

@@ -0,0 +1,40 @@
import {
createIdentifier,
type ServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import {
type AdapterPreprocessor,
PreprocessorManager,
} from '../types/preprocessor';
import type { Markdown } from './type';
export type MarkdownAdapterPreprocessor = AdapterPreprocessor<Markdown>;
const MarkdownPreprocessorIdentifier =
createIdentifier<MarkdownAdapterPreprocessor>('MarkdownPreprocessor');
export const MarkdownPreprocessorExtension = (
preprocessor: MarkdownAdapterPreprocessor
): ExtensionType & {
identifier: ServiceIdentifier<MarkdownAdapterPreprocessor>;
} => {
const identifier = MarkdownPreprocessorIdentifier(preprocessor.name);
return {
setup: di => {
di.addImpl(identifier, () => preprocessor);
},
identifier,
};
};
export class MarkdownPreprocessorManager extends PreprocessorManager<
Markdown,
MarkdownAdapterPreprocessor
> {
constructor(provider: ServiceProvider) {
super(provider, MarkdownPreprocessorIdentifier);
}
}

View File

@@ -11,7 +11,7 @@ import {
DeltaASTConverter, DeltaASTConverter,
type DeltaASTConverterOptions, type DeltaASTConverterOptions,
type InlineDeltaMatcher, type InlineDeltaMatcher,
} from '../types/adapter.js'; } from '../types/delta-converter.js';
import type { HtmlAST, InlineHtmlAST } from '../types/hast.js'; import type { HtmlAST, InlineHtmlAST } from '../types/hast.js';
export type InlineDeltaToNotionHtmlAdapterMatcher = export type InlineDeltaToNotionHtmlAdapterMatcher =

View File

@@ -5,12 +5,12 @@ import {
import type { DeltaInsert, ExtensionType } from '@blocksuite/store'; import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types/index.js'; import type { AffineTextAttributes } from '../../types/index.js';
import type { TextBuffer } from '../types/adapter.js';
import { import {
type ASTToDeltaMatcher, type ASTToDeltaMatcher,
DeltaASTConverter, DeltaASTConverter,
type InlineDeltaMatcher, type InlineDeltaMatcher,
type TextBuffer, } from '../types/delta-converter.js';
} from '../types/adapter.js';
export type InlineDeltaToPlainTextAdapterMatcher = export type InlineDeltaToPlainTextAdapterMatcher =
InlineDeltaMatcher<TextBuffer>; InlineDeltaMatcher<TextBuffer>;

View File

@@ -4,15 +4,13 @@ import {
type ASTWalker, type ASTWalker,
type ASTWalkerContext, type ASTWalkerContext,
type BaseAdapter, type BaseAdapter,
type BaseTextAttributes,
type BlockSnapshot, type BlockSnapshot,
BlockSnapshotSchema, BlockSnapshotSchema,
type DeltaInsert,
type NodeProps, type NodeProps,
type Transformer, type Transformer,
} from '@blocksuite/store'; } from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types/index.js'; import type { DeltaASTConverter } from './delta-converter.js';
export const isBlockSnapshotNode = (node: unknown): node is BlockSnapshot => export const isBlockSnapshotNode = (node: unknown): node is BlockSnapshot =>
BlockSnapshotSchema.safeParse(node).success; BlockSnapshotSchema.safeParse(node).success;
@@ -21,13 +19,6 @@ export type TextBuffer = {
content: string; content: string;
}; };
export type DeltaASTConverterOptions = {
trim?: boolean;
pre?: boolean;
pageMap?: Map<string, string>;
removeLastBr?: boolean;
};
export type AdapterContext< export type AdapterContext<
ONode extends object, ONode extends object,
TNode extends object = never, TNode extends object = never,
@@ -124,56 +115,6 @@ export type BlockAdapterMatcher<
}; };
}; };
export abstract class DeltaASTConverter<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
AST = unknown,
> {
/**
* Convert AST format to delta format
*/
abstract astToDelta(
ast: AST,
options?: unknown
): DeltaInsert<TextAttributes>[];
/**
* Convert delta format to AST format
*/
abstract deltaToAST(
deltas: DeltaInsert<TextAttributes>[],
options?: unknown
): AST[];
}
export type InlineDeltaMatcher<TNode extends object = never> = {
name: keyof AffineTextAttributes | string;
match: (delta: DeltaInsert<AffineTextAttributes>) => boolean;
toAST: (
delta: DeltaInsert<AffineTextAttributes>,
context: {
configs: Map<string, string>;
current: TNode;
},
provider?: ServiceProvider
) => TNode;
};
export type ASTToDeltaMatcher<AST> = {
name: string;
match: (ast: AST) => boolean;
toDelta: (
ast: AST,
context: {
configs: Map<string, string>;
options: DeltaASTConverterOptions;
toDelta: (
ast: AST,
options?: DeltaASTConverterOptions
) => DeltaInsert<AffineTextAttributes>[];
}
) => DeltaInsert<AffineTextAttributes>[];
};
export type AdapterFactory = { export type AdapterFactory = {
// TODO(@chen): Make it return the specific adapter type // TODO(@chen): Make it return the specific adapter type
get: (job: Transformer) => BaseAdapter; get: (job: Transformer) => BaseAdapter;

View File

@@ -0,0 +1,61 @@
import type { ServiceProvider } from '@blocksuite/global/di';
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
import type { AffineTextAttributes } from '../../types';
export type DeltaASTConverterOptions = {
trim?: boolean;
pre?: boolean;
pageMap?: Map<string, string>;
removeLastBr?: boolean;
};
export abstract class DeltaASTConverter<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
AST = unknown,
> {
/**
* Convert AST format to delta format
*/
abstract astToDelta(
ast: AST,
options?: unknown
): DeltaInsert<TextAttributes>[];
/**
* Convert delta format to AST format
*/
abstract deltaToAST(
deltas: DeltaInsert<TextAttributes>[],
options?: unknown
): AST[];
}
export type InlineDeltaMatcher<TNode extends object = never> = {
name: keyof AffineTextAttributes | string;
match: (delta: DeltaInsert<AffineTextAttributes>) => boolean;
toAST: (
delta: DeltaInsert<AffineTextAttributes>,
context: {
configs: Map<string, string>;
current: TNode;
},
provider?: ServiceProvider
) => TNode;
};
export type ASTToDeltaMatcher<AST> = {
name: string;
match: (ast: AST) => boolean;
toDelta: (
ast: AST,
context: {
configs: Map<string, string>;
options: DeltaASTConverterOptions;
toDelta: (
ast: AST,
options?: DeltaASTConverterOptions
) => DeltaInsert<AffineTextAttributes>[];
}
) => DeltaInsert<AffineTextAttributes>[];
};

View File

@@ -1,2 +1,4 @@
export * from './adapter.js'; export * from './adapter.js';
export * from './delta-converter.js';
export * from './hast.js'; export * from './hast.js';
export * from './preprocessor.js';

View File

@@ -0,0 +1,88 @@
import type { ServiceIdentifier, ServiceProvider } from '@blocksuite/global/di';
/**
* Level of preprocessing
* - doc: Process at to doc snapshot level
* - slice: Process at to slice snapshot level
* - block: Process at to block snapshot level
*/
export type PreprocessLevel = 'doc' | 'slice' | 'block';
/**
* Interface for adapter preprocessor
* @template T Type of content to process, defaults to string
*/
export type AdapterPreprocessor<T = string> = {
/**
* Unique name of the preprocessor
*/
name: string;
/**
* Levels this preprocessor supports
*/
levels: PreprocessLevel[];
/**
* Process the content
* @param content Content to process
* @returns Processed content
*/
preprocess: (content: T) => T;
};
/**
* Manager class for handling preprocessors
* @template T Type of content to process
* @template P Type of preprocessor
*/
export abstract class PreprocessorManager<T, P extends AdapterPreprocessor<T>> {
protected readonly preprocessors: Map<PreprocessLevel, Set<P>>;
constructor(
protected readonly provider: ServiceProvider,
protected readonly identifier: ServiceIdentifier<P>
) {
this.preprocessors = new Map();
// Initialize Sets for each level
this.preprocessors.set('doc', new Set());
this.preprocessors.set('slice', new Set());
this.preprocessors.set('block', new Set());
// Register all preprocessors from provider
this.initializePreprocessors();
}
/**
* Initialize preprocessors from provider
*/
private initializePreprocessors(): void {
const preprocessors = Array.from(
this.provider.getAll(this.identifier).values()
);
for (const preprocessor of preprocessors) {
for (const level of preprocessor.levels) {
const levelSet = this.preprocessors.get(level);
if (levelSet) {
levelSet.add(preprocessor);
}
}
}
}
/**
* Pre process content at specified level
* @param level Level to process at
* @param content Content to process
* @returns Processed content
*/
process(level: PreprocessLevel, content: T): T {
const processors = this.preprocessors.get(level) ?? new Set();
return Array.from(processors).reduce(
(result, preprocessor) => preprocessor.preprocess(result),
content
);
}
}

View File

@@ -1,9 +1,5 @@
import { defaultBlockMarkdownAdapterMatchers } from '@blocksuite/affine/adapters'; import { MarkdownAdapterExtension } from '@blocksuite/affine/adapters';
import { Container } from '@blocksuite/affine/global/di'; import { Container } from '@blocksuite/affine/global/di';
import {
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
} from '@blocksuite/affine/inlines/preset';
import type { import type {
AttachmentBlockModel, AttachmentBlockModel,
BookmarkBlockModel, BookmarkBlockModel,
@@ -123,11 +119,7 @@ function generateMarkdownPreviewBuilder(
}; };
const container = new Container(); const container = new Container();
[ [...MarkdownAdapterExtension].forEach(ext => {
...MarkdownInlineToDeltaAdapterExtensions,
...defaultBlockMarkdownAdapterMatchers,
...InlineDeltaToMarkdownAdapterExtensions,
].forEach(ext => {
ext.setup(container); ext.setup(container);
}); });

View File

@@ -26,12 +26,8 @@ import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspac
import { getWorkerUrl } from '@affine/env/worker'; import { getWorkerUrl } from '@affine/env/worker';
import { I18n } from '@affine/i18n'; import { I18n } from '@affine/i18n';
import { StoreManagerClient } from '@affine/nbstore/worker/client'; import { StoreManagerClient } from '@affine/nbstore/worker/client';
import { defaultBlockMarkdownAdapterMatchers } from '@blocksuite/affine/adapters'; import { MarkdownAdapterExtension } from '@blocksuite/affine/adapters';
import { Container } from '@blocksuite/affine/global/di'; import { Container } from '@blocksuite/affine/global/di';
import {
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
} from '@blocksuite/affine/inlines/preset';
import { import {
docLinkBaseURLMiddleware, docLinkBaseURLMiddleware,
MarkdownAdapter, MarkdownAdapter,
@@ -203,11 +199,7 @@ framework.impl(AIButtonProvider, {
const snapshot = transformer.docToSnapshot(blockSuiteDoc); const snapshot = transformer.docToSnapshot(blockSuiteDoc);
const container = new Container(); const container = new Container();
[ [...MarkdownAdapterExtension].forEach(ext => {
...MarkdownInlineToDeltaAdapterExtensions,
...defaultBlockMarkdownAdapterMatchers,
...InlineDeltaToMarkdownAdapterExtensions,
].forEach(ext => {
ext.setup(container); ext.setup(container);
}); });
const provider = container.provider(); const provider = container.provider();

View File

@@ -38,13 +38,9 @@ import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspac
import { getWorkerUrl } from '@affine/env/worker'; import { getWorkerUrl } from '@affine/env/worker';
import { I18n } from '@affine/i18n'; import { I18n } from '@affine/i18n';
import { StoreManagerClient } from '@affine/nbstore/worker/client'; import { StoreManagerClient } from '@affine/nbstore/worker/client';
import { defaultBlockMarkdownAdapterMatchers } from '@blocksuite/affine/adapters'; import { MarkdownAdapterExtension } from '@blocksuite/affine/adapters';
import { MarkdownTransformer } from '@blocksuite/affine/blocks/root'; import { MarkdownTransformer } from '@blocksuite/affine/blocks/root';
import { Container } from '@blocksuite/affine/global/di'; import { Container } from '@blocksuite/affine/global/di';
import {
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
} from '@blocksuite/affine/inlines/preset';
import { import {
docLinkBaseURLMiddleware, docLinkBaseURLMiddleware,
MarkdownAdapter, MarkdownAdapter,
@@ -266,11 +262,7 @@ const frameworkProvider = framework.provider();
const snapshot = transformer.docToSnapshot(blockSuiteDoc); const snapshot = transformer.docToSnapshot(blockSuiteDoc);
const container = new Container(); const container = new Container();
[ [...MarkdownAdapterExtension].forEach(ext => {
...MarkdownInlineToDeltaAdapterExtensions,
...defaultBlockMarkdownAdapterMatchers,
...InlineDeltaToMarkdownAdapterExtensions,
].forEach(ext => {
ext.setup(container); ext.setup(container);
}); });
const provider = container.provider(); const provider = container.provider();

View File

@@ -1,5 +1,5 @@
import { createReactComponentFromLit } from '@affine/component'; import { createReactComponentFromLit } from '@affine/component';
import { defaultBlockMarkdownAdapterMatchers } from '@blocksuite/affine/adapters'; import { MarkdownAdapterExtension } from '@blocksuite/affine/adapters';
import { import {
defaultImageProxyMiddleware, defaultImageProxyMiddleware,
ImageProxyService, ImageProxyService,
@@ -7,10 +7,6 @@ import {
import { PageEditorBlockSpecs } from '@blocksuite/affine/extensions'; import { PageEditorBlockSpecs } from '@blocksuite/affine/extensions';
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di'; import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import {
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
} from '@blocksuite/affine/inlines/preset';
import { codeBlockWrapMiddleware } from '@blocksuite/affine/shared/adapters'; import { codeBlockWrapMiddleware } from '@blocksuite/affine/shared/adapters';
import { LinkPreviewerService } from '@blocksuite/affine/shared/services'; import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
import { import {
@@ -215,11 +211,7 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
provider = this.host.std.provider; provider = this.host.std.provider;
} else { } else {
const container = new Container(); const container = new Container();
[ [...MarkdownAdapterExtension].forEach(ext => {
...MarkdownInlineToDeltaAdapterExtensions,
...defaultBlockMarkdownAdapterMatchers,
...InlineDeltaToMarkdownAdapterExtensions,
].forEach(ext => {
ext.setup(container); ext.setup(container);
}); });

View File

@@ -1,20 +1,12 @@
import { defaultBlockMarkdownAdapterMatchers } from '@blocksuite/affine/adapters'; import { MarkdownAdapterExtension } from '@blocksuite/affine/adapters';
import { Container } from '@blocksuite/affine/global/di'; import { Container } from '@blocksuite/affine/global/di';
import {
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
} from '@blocksuite/affine/inlines/preset';
import { TestWorkspace } from '@blocksuite/affine/store/test'; import { TestWorkspace } from '@blocksuite/affine/store/test';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { markdownToMindmap } from '../mindmap-preview.js'; import { markdownToMindmap } from '../mindmap-preview.js';
const container = new Container(); const container = new Container();
[ [...MarkdownAdapterExtension].forEach(ext => {
...MarkdownInlineToDeltaAdapterExtensions,
...defaultBlockMarkdownAdapterMatchers,
...InlineDeltaToMarkdownAdapterExtensions,
].forEach(ext => {
ext.setup(container); ext.setup(container);
}); });
const provider = container.provider(); const provider = container.provider();