diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts index 26df93f952..b5531145eb 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -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 () => { diff --git a/blocksuite/affine/inlines/footnote/package.json b/blocksuite/affine/inlines/footnote/package.json index 51e7cfb073..5a44a046cb 100644 --- a/blocksuite/affine/inlines/footnote/package.json +++ b/blocksuite/affine/inlines/footnote/package.json @@ -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", diff --git a/blocksuite/affine/inlines/footnote/src/__tests__/adapters/preprocessor.unit.spec.ts b/blocksuite/affine/inlines/footnote/src/__tests__/adapters/preprocessor.unit.spec.ts new file mode 100644 index 0000000000..5ef0f62cea --- /dev/null +++ b/blocksuite/affine/inlines/footnote/src/__tests__/adapters/preprocessor.unit.spec.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); + }); +}); diff --git a/blocksuite/affine/inlines/footnote/src/adapters/index.ts b/blocksuite/affine/inlines/footnote/src/adapters/index.ts index 7264039840..ffa82fff1a 100644 --- a/blocksuite/affine/inlines/footnote/src/adapters/index.ts +++ b/blocksuite/affine/inlines/footnote/src/adapters/index.ts @@ -1,2 +1,3 @@ export * from './markdown/inline-delta'; export * from './markdown/markdown-inline'; +export * from './markdown/preprocessor'; diff --git a/blocksuite/affine/inlines/footnote/src/adapters/markdown/preprocessor.ts b/blocksuite/affine/inlines/footnote/src/adapters/markdown/preprocessor.ts new file mode 100644 index 0000000000..4db7e06a35 --- /dev/null +++ b/blocksuite/affine/inlines/footnote/src/adapters/markdown/preprocessor.ts @@ -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); diff --git a/blocksuite/affine/inlines/footnote/src/store.ts b/blocksuite/affine/inlines/footnote/src/store.ts index 2826f2f7be..85a7bf0d4f 100644 --- a/blocksuite/affine/inlines/footnote/src/store.ts +++ b/blocksuite/affine/inlines/footnote/src/store.ts @@ -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); } } diff --git a/blocksuite/affine/inlines/footnote/vitest.config.ts b/blocksuite/affine/inlines/footnote/vitest.config.ts new file mode 100644 index 0000000000..05591362f9 --- /dev/null +++ b/blocksuite/affine/inlines/footnote/vitest.config.ts @@ -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, + }, +}); diff --git a/yarn.lock b/yarn.lock index dfa162efff..e9880e395a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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