mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -4417,6 +4417,69 @@ hhh
|
|||||||
});
|
});
|
||||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
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 () => {
|
test('should not wrap url with angle brackets if it is not a url', async () => {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
"yjs": "^13.6.21",
|
"yjs": "^13.6.21",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "3.1.3"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./view": "./src/view.ts",
|
"./view": "./src/view.ts",
|
||||||
|
|||||||
@@ -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/inline-delta';
|
||||||
export * from './markdown/markdown-inline';
|
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 {
|
import {
|
||||||
footnoteReferenceDeltaToMarkdownAdapterMatcher,
|
footnoteReferenceDeltaToMarkdownAdapterMatcher,
|
||||||
|
FootnoteReferenceMarkdownPreprocessorExtension,
|
||||||
markdownFootnoteReferenceToDeltaMatcher,
|
markdownFootnoteReferenceToDeltaMatcher,
|
||||||
} from './adapters';
|
} from './adapters';
|
||||||
|
|
||||||
@@ -15,5 +16,6 @@ export class FootnoteStoreExtension extends StoreExtensionProvider {
|
|||||||
super.setup(context);
|
super.setup(context);
|
||||||
context.register(markdownFootnoteReferenceToDeltaMatcher);
|
context.register(markdownFootnoteReferenceToDeltaMatcher);
|
||||||
context.register(footnoteReferenceDeltaToMarkdownAdapterMatcher);
|
context.register(footnoteReferenceDeltaToMarkdownAdapterMatcher);
|
||||||
|
context.register(FootnoteReferenceMarkdownPreprocessorExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
blocksuite/affine/inlines/footnote/vitest.config.ts
Normal file
25
blocksuite/affine/inlines/footnote/vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3480,6 +3480,7 @@ __metadata:
|
|||||||
lit-html: "npm:^3.2.1"
|
lit-html: "npm:^3.2.1"
|
||||||
lodash-es: "npm:^4.17.21"
|
lodash-es: "npm:^4.17.21"
|
||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
|
vitest: "npm:3.1.3"
|
||||||
yjs: "npm:^13.6.21"
|
yjs: "npm:^13.6.21"
|
||||||
zod: "npm:^3.23.8"
|
zod: "npm:^3.23.8"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
|
|||||||
Reference in New Issue
Block a user