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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user