fix(editor): handle footnote reference immediately follow URLs when importing markdown (#12449)

Closes: [BS-3525](https://linear.app/affine-design/issue/BS-3525/markdown-adapter-紧跟着链接的-footnote-reference-会被识别成链接的一部分)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Improved handling of footnote references that immediately follow URLs in markdown, ensuring correct spacing and parsing.
- **Bug Fixes**
  - Footnote references after URLs are now parsed correctly, preventing formatting issues.
- **Tests**
  - Added comprehensive test suites to verify footnote reference preprocessing and markdown conversion.
- **Chores**
  - Introduced Vitest as a development dependency and added a dedicated test configuration for the footnote module.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-05-23 03:24:23 +00:00
parent 57d31de854
commit f99b143bf9
8 changed files with 215 additions and 0 deletions
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { preprocessFootnoteReference } from '../../adapters/markdown/preprocessor';
describe('FootnoteReferenceMarkdownPreprocessorExtension', () => {
it('should add space before footnote reference when it follows a URL', () => {
const content = 'https://example.com[^label]';
const expected = 'https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should add space before footnote reference when URL has text prefix with space', () => {
const content = 'hello world https://example.com[^label]';
const expected = 'hello world https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should add space before footnote reference when URL has text prefix with dash', () => {
const content = 'text-https://example.com[^label]';
const expected = 'text-https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not add space when footnote reference follows non-URL text', () => {
const content = 'normal text[^label]';
const expected = 'normal text[^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not add space when there is already a space before footnote reference', () => {
const content = 'https://example.com [^label]';
const expected = 'https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle multiple footnote references with mixed URL and non-URL text', () => {
const content = 'https://example.com[^1]normal text[^2]http://test.com[^3]';
const expected =
'https://example.com [^1]normal text[^2]http://test.com [^3]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not modify footnote definitions', () => {
const content = '[^label]: This is a footnote definition';
const expected = '[^label]: This is a footnote definition';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle content without footnote references', () => {
const content = 'This is a normal text without any footnotes';
const expected = 'This is a normal text without any footnotes';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle complex URLs with paths and parameters', () => {
const content = 'https://example.com/path?param=value[^label]';
const expected = 'https://example.com/path?param=value [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle invalid URLs', () => {
const content = 'not-a-url[^label]';
const expected = 'not-a-url[^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
});
@@ -1,2 +1,3 @@
export * from './markdown/inline-delta';
export * from './markdown/markdown-inline';
export * from './markdown/preprocessor';
@@ -0,0 +1,54 @@
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
/**
* Check if a string is a URL
* @param str
* @returns
*/
function isUrl(str: string): boolean {
try {
new URL(str);
return true;
} catch {
return false;
}
}
/**
* Preprocess footnote references to avoid markdown link parsing
* Only add space when footnote reference follows a URL
* @param content
* @returns
* @example
* ```md
* https://example.com[^label] -> https://example.com [^label]
* normal text[^label] -> normal text[^label]
* ```
*/
export function preprocessFootnoteReference(content: string) {
return content.replace(
/([^\s]+?)(\[\^[^\]]+\])(?!:)/g,
(match, prevText, footnoteRef) => {
// Only add space if the previous text is a URL
if (isUrl(prevText)) {
return prevText + ' ' + footnoteRef;
}
// Otherwise return the original match
return match;
}
);
}
const footnoteReferencePreprocessor: MarkdownAdapterPreprocessor = {
name: 'footnote-reference',
levels: ['block', 'slice', 'doc'],
preprocess: content => {
return preprocessFootnoteReference(content);
},
};
export const FootnoteReferenceMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(footnoteReferencePreprocessor);
@@ -5,6 +5,7 @@ import {
import {
footnoteReferenceDeltaToMarkdownAdapterMatcher,
FootnoteReferenceMarkdownPreprocessorExtension,
markdownFootnoteReferenceToDeltaMatcher,
} from './adapters';
@@ -15,5 +16,6 @@ export class FootnoteStoreExtension extends StoreExtensionProvider {
super.setup(context);
context.register(markdownFootnoteReferenceToDeltaMatcher);
context.register(footnoteReferenceDeltaToMarkdownAdapterMatcher);
context.register(FootnoteReferenceMarkdownPreprocessorExtension);
}
}