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
@@ -10,7 +10,7 @@ import {
type ASTToDeltaMatcher,
DeltaASTConverter,
type InlineDeltaMatcher,
} from '../types/adapter.js';
} from '../types/delta-converter.js';
import type { MarkdownAST } from './type.js';
export type InlineDeltaToMarkdownAdapterMatcher =
@@ -1,4 +1,5 @@
export * from './block-adapter.js';
export * from './delta-converter.js';
export * from './markdown.js';
export * from './preprocessor.js';
export * from './type.js';
@@ -37,6 +37,7 @@ import {
MarkdownDeltaConverter,
} from './delta-converter';
import { remarkGfm } from './gfm';
import { MarkdownPreprocessorManager } from './preprocessor';
import type { Markdown, MarkdownAST } from './type';
type MarkdownToSliceSnapshotPayload = {
@@ -167,6 +168,7 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
};
deltaConverter: MarkdownDeltaConverter;
preprocessorManager: MarkdownPreprocessorManager;
readonly blockMatchers: BlockMarkdownAdapterMatcher[];
@@ -187,6 +189,7 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
inlineDeltaToMarkdownAdapterMatchers,
markdownInlineToDeltaMatchers
);
this.preprocessorManager = new MarkdownPreprocessorManager(provider);
}
private _astToMarkdown(ast: Root) {
@@ -273,7 +276,11 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
async toBlockSnapshot(
payload: ToBlockSnapshotPayload<Markdown>
): Promise<BlockSnapshot> {
const markdownAst = this._markdownToAst(payload.file);
const markdownFile = this.preprocessorManager.process(
'block',
payload.file
);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
@@ -297,7 +304,8 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
async toDocSnapshot(
payload: ToDocSnapshotPayload<Markdown>
): Promise<DocSnapshot> {
const markdownAst = this._markdownToAst(payload.file);
const markdownFile = this.preprocessorManager.process('doc', payload.file);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
@@ -356,67 +364,11 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
async toSliceSnapshot(
payload: MarkdownToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
let codeFence = '';
payload.file = payload.file
.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');
const markdownAst = this._markdownToAst(payload.file);
const markdownFile = this.preprocessorManager.process(
'slice',
payload.file
);
const markdownAst = this._markdownToAst(markdownFile);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
@@ -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);
}
}