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

@@ -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 { BookmarkBlockHtmlAdapterExtension } from './html.js';
import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js';
import { BookmarkBlockMarkdownAdapterExtensions } from './markdown/index.js';
import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js';
export const BookmarkBlockAdapterExtensions: ExtensionType[] = [
BookmarkBlockHtmlAdapterExtension,
BookmarkBlockMarkdownAdapterExtension,
BookmarkBlockMarkdownAdapterExtensions,
BookmarkBlockNotionHtmlAdapterExtension,
BookmarkBlockPlainTextAdapterExtension,
];
].flat();

View File

@@ -1,4 +1,4 @@
export * from './html.js';
export * from './markdown.js';
export * from './markdown/index.js';
export * from './notion-html.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);