mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 09:30:01 +08:00
feat(editor): support frontmatter & colored text parsing (#14205)
fix #13847
This commit is contained in:
@@ -3,9 +3,17 @@ import {
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
|
||||
import type { Root } from 'hast';
|
||||
import type { PhrasingContent } from 'mdast';
|
||||
import rehypeParse from 'rehype-parse';
|
||||
import { unified } from 'unified';
|
||||
|
||||
import type { AffineTextAttributes } from '../../types/index.js';
|
||||
import { HtmlDeltaConverter } from '../html/delta-converter.js';
|
||||
import {
|
||||
rehypeInlineToBlock,
|
||||
rehypeWrapInlineElements,
|
||||
} from '../html/rehype-plugins/index.js';
|
||||
import {
|
||||
type ASTToDeltaMatcher,
|
||||
DeltaASTConverter,
|
||||
@@ -13,6 +21,88 @@ import {
|
||||
} from '../types/delta-converter.js';
|
||||
import type { MarkdownAST } from './type.js';
|
||||
|
||||
const INLINE_HTML_TAGS = new Set([
|
||||
'span',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'del',
|
||||
'u',
|
||||
'mark',
|
||||
'code',
|
||||
'ins',
|
||||
'bdi',
|
||||
'bdo',
|
||||
]);
|
||||
|
||||
const VOID_HTML_TAGS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
const ALLOWED_INLINE_HTML_TAGS = new Set([
|
||||
...INLINE_HTML_TAGS,
|
||||
...VOID_HTML_TAGS,
|
||||
]);
|
||||
|
||||
const isHtmlNode = (
|
||||
node: MarkdownAST
|
||||
): node is MarkdownAST & { type: 'html'; value: string } =>
|
||||
node.type === 'html' && 'value' in node && typeof node.value === 'string';
|
||||
|
||||
const isTextNode = (
|
||||
node: MarkdownAST
|
||||
): node is MarkdownAST & { type: 'text'; value: string } =>
|
||||
node.type === 'text' && 'value' in node && typeof node.value === 'string';
|
||||
|
||||
type HtmlTagInfo =
|
||||
| { name: string; kind: 'open' | 'self' }
|
||||
| { name: string; kind: 'close' };
|
||||
|
||||
const getHtmlTagInfo = (value: string): HtmlTagInfo | null => {
|
||||
const closingMatch = value.match(/^<\/([A-Za-z][A-Za-z0-9-]*)\s*>$/);
|
||||
if (closingMatch) {
|
||||
return {
|
||||
name: closingMatch[1].toLowerCase(),
|
||||
kind: 'close',
|
||||
};
|
||||
}
|
||||
|
||||
const selfClosingMatch = value.match(
|
||||
/^<([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/>$/i
|
||||
);
|
||||
if (selfClosingMatch) {
|
||||
return {
|
||||
name: selfClosingMatch[1].toLowerCase(),
|
||||
kind: 'self',
|
||||
};
|
||||
}
|
||||
|
||||
const openingMatch = value.match(/^<([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?>$/);
|
||||
if (openingMatch) {
|
||||
const name = openingMatch[1].toLowerCase();
|
||||
return {
|
||||
name,
|
||||
kind: VOID_HTML_TAGS.has(name) ? 'self' : 'open',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export type InlineDeltaToMarkdownAdapterMatcher =
|
||||
InlineDeltaMatcher<PhrasingContent>;
|
||||
|
||||
@@ -63,11 +153,30 @@ export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
constructor(
|
||||
readonly configs: Map<string, string>,
|
||||
readonly inlineDeltaMatchers: InlineDeltaToMarkdownAdapterMatcher[],
|
||||
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[]
|
||||
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[],
|
||||
readonly htmlDeltaConverter?: HtmlDeltaConverter
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private _convertHtmlToDelta(
|
||||
html: string
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
if (!this.htmlDeltaConverter) {
|
||||
return [{ insert: html }];
|
||||
}
|
||||
try {
|
||||
const processor = unified()
|
||||
.use(rehypeParse, { fragment: true })
|
||||
.use(rehypeInlineToBlock)
|
||||
.use(rehypeWrapInlineElements);
|
||||
const ast = processor.runSync(processor.parse(html)) as Root;
|
||||
return this.htmlDeltaConverter.astToDelta(ast, { trim: false });
|
||||
} catch {
|
||||
return [{ insert: html }];
|
||||
}
|
||||
}
|
||||
|
||||
applyTextFormatting(
|
||||
delta: DeltaInsert<AffineTextAttributes>
|
||||
): PhrasingContent {
|
||||
@@ -95,11 +204,110 @@ export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
return mdast;
|
||||
}
|
||||
|
||||
private _mergeInlineHtml(
|
||||
children: MarkdownAST[],
|
||||
startIndex: number
|
||||
): {
|
||||
endIndex: number;
|
||||
deltas: DeltaInsert<AffineTextAttributes>[];
|
||||
} | null {
|
||||
const startNode = children[startIndex];
|
||||
if (!isHtmlNode(startNode)) {
|
||||
return null;
|
||||
}
|
||||
const startTag = getHtmlTagInfo(startNode.value);
|
||||
if (
|
||||
!startTag ||
|
||||
startTag.kind !== 'open' ||
|
||||
!INLINE_HTML_TAGS.has(startTag.name)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stack = [startTag.name];
|
||||
let html = startNode.value;
|
||||
let endIndex = startIndex;
|
||||
|
||||
for (let i = startIndex + 1; i < children.length; i++) {
|
||||
const node = children[i];
|
||||
if (isHtmlNode(node)) {
|
||||
const info = getHtmlTagInfo(node.value);
|
||||
if (!info) {
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.kind === 'open') {
|
||||
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
|
||||
return null;
|
||||
}
|
||||
stack.push(info.name);
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.kind === 'self') {
|
||||
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
|
||||
return null;
|
||||
}
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
|
||||
return null;
|
||||
}
|
||||
const last = stack[stack.length - 1];
|
||||
if (last !== info.name) {
|
||||
return null;
|
||||
}
|
||||
stack.pop();
|
||||
|
||||
html += node.value;
|
||||
endIndex = i;
|
||||
if (stack.length === 0) {
|
||||
return {
|
||||
endIndex,
|
||||
deltas: this._convertHtmlToDelta(html),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTextNode(node)) {
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _astChildrenToDelta(
|
||||
children: MarkdownAST[]
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const merged = this._mergeInlineHtml(children, i);
|
||||
if (merged) {
|
||||
deltas.push(...merged.deltas);
|
||||
i = merged.endIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
deltas.push(...this.astToDelta(children[i]));
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
astToDelta(ast: MarkdownAST): DeltaInsert<AffineTextAttributes>[] {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
options: Object.create(null),
|
||||
toDelta: (ast: MarkdownAST) => this.astToDelta(ast),
|
||||
htmlToDelta: (html: string) => this._convertHtmlToDelta(html),
|
||||
};
|
||||
for (const matcher of this.markdownASTToDeltaMatchers) {
|
||||
if (matcher.match(ast)) {
|
||||
@@ -107,7 +315,7 @@ export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
}
|
||||
}
|
||||
return 'children' in ast
|
||||
? ast.children.flatMap(child => this.astToDelta(child))
|
||||
? this._astChildrenToDelta(ast.children as MarkdownAST[])
|
||||
: [];
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
import { unified } from 'unified';
|
||||
|
||||
import {
|
||||
HtmlASTToDeltaMatcherIdentifier,
|
||||
HtmlDeltaConverter,
|
||||
InlineDeltaToHtmlAdapterMatcherIdentifier,
|
||||
} from '../html/delta-converter.js';
|
||||
import { type AdapterContext, AdapterFactoryIdentifier } from '../types';
|
||||
import {
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
@@ -184,11 +189,24 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
|
||||
const markdownInlineToDeltaMatchers = Array.from(
|
||||
provider.getAll(MarkdownASTToDeltaMatcherIdentifier).values()
|
||||
);
|
||||
const inlineDeltaToHtmlAdapterMatchers = Array.from(
|
||||
provider.getAll(InlineDeltaToHtmlAdapterMatcherIdentifier).values()
|
||||
);
|
||||
const htmlInlineToDeltaMatchers = Array.from(
|
||||
provider.getAll(HtmlASTToDeltaMatcherIdentifier).values()
|
||||
);
|
||||
const htmlDeltaConverter = new HtmlDeltaConverter(
|
||||
job.adapterConfigs,
|
||||
inlineDeltaToHtmlAdapterMatchers,
|
||||
htmlInlineToDeltaMatchers,
|
||||
provider
|
||||
);
|
||||
this.blockMatchers = blockMatchers;
|
||||
this.deltaConverter = new MarkdownDeltaConverter(
|
||||
job.adapterConfigs,
|
||||
inlineDeltaToMarkdownAdapterMatchers,
|
||||
markdownInlineToDeltaMatchers
|
||||
markdownInlineToDeltaMatchers,
|
||||
htmlDeltaConverter
|
||||
);
|
||||
this.preprocessorManager = new MarkdownPreprocessorManager(provider);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export type ASTToDeltaMatcher<AST> = {
|
||||
ast: AST,
|
||||
options?: DeltaASTConverterOptions
|
||||
) => DeltaInsert<AffineTextAttributes>[];
|
||||
htmlToDelta?: (html: string) => DeltaInsert<AffineTextAttributes>[];
|
||||
}
|
||||
) => DeltaInsert<AffineTextAttributes>[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user