mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 18:02:47 +08:00
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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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);
|
||||||
25
blocksuite/affine/blocks/bookmark/vitest.config.ts
Normal file
25
blocksuite/affine/blocks/bookmark/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/bookmark',
|
||||||
|
},
|
||||||
|
restoreMocks: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user