fix(editor): add footnote url markdown preprocessor to avoid link node parsing (#11888)

Closes: [BS-3282](https://linear.app/affine-design/issue/BS-3282/预处理-footnote-definition-中的-url-避免-markdown-link-node-parsing)
This commit is contained in:
donteatfriedrice
2025-04-22 14:19:09 +00:00
parent c17c335f9b
commit bbdea71686
11 changed files with 200 additions and 16 deletions

View File

@@ -4028,11 +4028,8 @@ hhh
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
test('without footnote middleware', async () => { describe('footnote', () => {
const markdown = const createFootnoteBlockSnapshot = (url: string): BlockSnapshot => ({
'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n';
const blockSnapshot: BlockSnapshot = {
type: 'block', type: 'block',
id: 'matchesReplaceMap[0]', id: 'matchesReplaceMap[0]',
flavour: 'affine:note', flavour: 'affine:note',
@@ -4063,7 +4060,7 @@ hhh
label: '1', label: '1',
reference: { reference: {
type: 'url', type: 'url',
url: 'https://www.example.com', url,
}, },
}, },
}, },
@@ -4100,13 +4097,37 @@ hhh
children: [], children: [],
}, },
], ],
};
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
}); });
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
test('with encoded url', async () => {
const markdown =
'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n';
const blockSnapshot = createFootnoteBlockSnapshot(
'https://www.example.com'
);
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('with unencoded url', async () => {
const markdown =
'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https://www.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n';
const blockSnapshot = createFootnoteBlockSnapshot(
'https://www.example.com'
);
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 () => {

View File

@@ -1,7 +1,9 @@
import { BookmarkBlockMarkdownPreprocessorExtension } from '@blocksuite/affine-block-bookmark';
import { CodeMarkdownPreprocessorExtension } from '@blocksuite/affine-block-code'; import { CodeMarkdownPreprocessorExtension } from '@blocksuite/affine-block-code';
import { LatexMarkdownPreprocessorExtension } from '@blocksuite/affine-block-latex'; import { LatexMarkdownPreprocessorExtension } from '@blocksuite/affine-block-latex';
export const defaultMarkdownPreprocessors = [ export const defaultMarkdownPreprocessors = [
LatexMarkdownPreprocessorExtension, LatexMarkdownPreprocessorExtension,
CodeMarkdownPreprocessorExtension, CodeMarkdownPreprocessorExtension,
BookmarkBlockMarkdownPreprocessorExtension,
]; ];

View File

@@ -30,6 +30,9 @@
"yjs": "^13.6.23", "yjs": "^13.6.23",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": {
"vitest": "3.1.1"
},
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./effects": "./src/effects.ts", "./effects": "./src/effects.ts",

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { footnoteUrlPreprocessor } from '../../adapters/markdown/preprocessor';
describe('footnoteUrlPreprocessor', () => {
it('should encode unencoded URLs in footnote definitions', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com?param=value"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should not encode already encoded URLs', () => {
const input = '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle invalid JSON content', () => {
const input = '[^ref]: {"invalid json"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle non-object footnote data', () => {
const input = '[^ref]: "not an object"';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle footnote data without url property', () => {
const input = '[^ref]: {"type":"url"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle multiple footnote definitions', () => {
const input = `
[^ref1]: {"type":"url","url":"https://example1.com"}
[^ref2]: {"type":"url","url":"https://example2.com"}
`.trim();
const expected = `
[^ref1]: {"type":"url","url":"https%3A%2F%2Fexample1.com"}
[^ref2]: {"type":"url","url":"https%3A%2F%2Fexample2.com"}
`.trim();
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle special characters in URLs', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com/path with spaces?param=value&another=param"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fparam%3Dvalue%26another%3Dparam"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
});

View File

@@ -1,13 +1,13 @@
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { BookmarkBlockHtmlAdapterExtension } from './html.js'; import { BookmarkBlockHtmlAdapterExtension } from './html.js';
import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js'; import { BookmarkBlockMarkdownAdapterExtensions } from './markdown/index.js';
import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js'; import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js'; import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js';
export const BookmarkBlockAdapterExtensions: ExtensionType[] = [ export const BookmarkBlockAdapterExtensions: ExtensionType[] = [
BookmarkBlockHtmlAdapterExtension, BookmarkBlockHtmlAdapterExtension,
BookmarkBlockMarkdownAdapterExtension, BookmarkBlockMarkdownAdapterExtensions,
BookmarkBlockNotionHtmlAdapterExtension, BookmarkBlockNotionHtmlAdapterExtension,
BookmarkBlockPlainTextAdapterExtension, BookmarkBlockPlainTextAdapterExtension,
]; ].flat();

View File

@@ -1,4 +1,4 @@
export * from './html.js'; export * from './html.js';
export * from './markdown.js'; export * from './markdown/index.js';
export * from './notion-html.js'; export * from './notion-html.js';
export * from './plain-text.js'; export * from './plain-text.js';

View File

@@ -0,0 +1,12 @@
import type { ExtensionType } from '@blocksuite/store';
import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js';
import { BookmarkBlockMarkdownPreprocessorExtension } from './preprocessor.js';
export * from './markdown.js';
export * from './preprocessor.js';
export const BookmarkBlockMarkdownAdapterExtensions: ExtensionType[] = [
BookmarkBlockMarkdownPreprocessorExtension,
BookmarkBlockMarkdownAdapterExtension,
];

View File

@@ -0,0 +1,67 @@
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
// Check if a URL is already encoded with encodeURIComponent
function isEncoded(uri: string): boolean {
try {
// If decoding produces a different result than the original,
// then the URI contains encoded characters
return uri !== decodeURIComponent(uri);
} catch {
// If decoding fails, the URI contains invalid percent-encoding
return true;
}
}
// Format footnote definition with consistent spacing
function formatFootnoteDefinition(reference: string, data: object): string {
return `[^${reference}]: ${JSON.stringify(data)}`;
}
/**
* Preprocessor for footnote url
* We should encode url in footnote definition to avoid markdown link parsing
*
* Example of footnote definition:
* [^ref]: {"type":"url","url":"https://example.com"}
*/
export function footnoteUrlPreprocessor(content: string): string {
// Match footnote definitions with any reference (not just numbers)
// Format: [^reference]: {json_content}
return content.replace(
/\[\^([^\]]+)\]:\s*({[\s\S]*?})/g,
(match, reference, jsonContent) => {
try {
const footnoteData = JSON.parse(jsonContent.trim());
// If footnoteData is not an object or doesn't have url, return original content
// If the url is already encoded, return original content
if (
typeof footnoteData !== 'object' ||
!footnoteData.url ||
isEncoded(footnoteData.url)
) {
return match;
}
return formatFootnoteDefinition(reference, {
...footnoteData,
url: encodeURIComponent(footnoteData.url),
});
} catch {
// Keep original content if JSON parsing fails
return match;
}
}
);
}
const bookmarkBlockPreprocessor: MarkdownAdapterPreprocessor = {
name: 'bookmark-block',
levels: ['block', 'slice', 'doc'],
preprocess: footnoteUrlPreprocessor,
};
export const BookmarkBlockMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(bookmarkBlockPreprocessor);

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/bookmark',
},
restoreMocks: true,
},
});

View File

@@ -2417,6 +2417,7 @@ __metadata:
lit: "npm:^3.2.0" lit: "npm:^3.2.0"
minimatch: "npm:^10.0.1" minimatch: "npm:^10.0.1"
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
vitest: "npm:3.1.1"
yjs: "npm:^13.6.23" yjs: "npm:^13.6.23"
zod: "npm:^3.23.8" zod: "npm:^3.23.8"
languageName: unknown languageName: unknown