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

View File

@@ -4417,6 +4417,69 @@ hhh
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('should handle footnote reference with url prefix', async () => {
const blockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'https://example.com',
attributes: {
link: 'https://example.com',
},
},
{
insert: ' ',
},
{
insert: ' ',
attributes: {
footnote: {
label: '1',
reference: {
type: 'url',
url,
favicon,
title,
description,
},
},
},
},
],
},
},
children: [],
},
],
};
const markdown = `https://example.com[^1]\n\n[^1]: {"type":"url","url":"${url}","favicon":"${favicon}","title":"${title}","description":"${description}"}\n`;
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
});
test('should not wrap url with angle brackets if it is not a url', async () => {

View File

@@ -33,6 +33,9 @@
"yjs": "^13.6.21",
"zod": "^3.23.8"
},
"devDependencies": {
"vitest": "3.1.3"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",

View File

@@ -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);
});
});

View File

@@ -1,2 +1,3 @@
export * from './markdown/inline-delta';
export * from './markdown/markdown-inline';
export * from './markdown/preprocessor';

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
browser: {
enabled: true,
headless: true,
name: 'chromium',
provider: 'playwright',
isolate: false,
providerOptions: {},
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/footnote',
},
restoreMocks: true,
},
});

View File

@@ -3480,6 +3480,7 @@ __metadata:
lit-html: "npm:^3.2.1"
lodash-es: "npm:^4.17.21"
rxjs: "npm:^7.8.1"
vitest: "npm:3.1.3"
yjs: "npm:^13.6.21"
zod: "npm:^3.23.8"
languageName: unknown