Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/locale/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export default {
replace: 'Replace',
},
link: {
convertToCard: 'Convert to Card',
convertToIframe: 'Convert to Iframe',
convertToLink: 'Convert to Link',
convertToSchema: 'Convert to Schema',
edit: 'Edit Link',
editLinkTitle: 'Link',
editTextTitle: 'Text',
Expand Down
71 changes: 70 additions & 1 deletion src/plugins/link/__test__/litexml.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';

import Editor, { resetRandomKey } from '@/editor-kernel';
import { CommonPlugin } from '@/plugins/common';
Expand Down Expand Up @@ -34,4 +34,73 @@ describe('link litexml', () => {
`<?xml version="1.0" encoding="UTF-8"?><root><p id="ll63"><a id="lqqe" href="https://logo.com/logo.png"><span id="lwap">logo</span></a></p></root>`,
);
});

it('new link display nodes should write markdown as normal links', () => {
editor.setDocument('json', {
root: {
children: [
{
description: 'Home page',
icon: '',
openTarget: '_self',
title: 'Card title',
type: 'link-card',
url: 'https://lobehub.com',
version: 1,
},
{
src: 'https://lobehub.com',
title: 'Iframe title',
type: 'link-iframe',
url: 'https://lobehub.com/embed',
version: 1,
},
{
payload: { id: '123' },
schemaType: 'card',
title: 'Schema title',
type: 'schema-link',
url: 'schema://card/123',
version: 1,
},
],
direction: 'ltr',
type: 'root',
version: 1,
},
});

const markdown = editor.getDocument('markdown') as unknown as string;
expect(markdown).toBe(
'[Card title](https://lobehub.com)\n' +
'[Iframe title](https://lobehub.com/embed)\n' +
'[Schema title](schema://card/123)\n',
);
});

it('new link display nodes should read and write litexml', () => {
editor.setDocument(
'litexml',
'<?xml version="1.0" encoding="UTF-8"?><root>' +
'<link-card id="1" href="https://lobehub.com" title="Card title" description="Home page" openTarget="_self"/>' +
'<link-iframe id="2" href="https://lobehub.com/embed" ' +
'src="https://lobehub.com" title="Iframe title"/>' +
'<schema-link id="3" href="schema://card/123" schemaType="card" ' +
'title="Schema title" payload="{&quot;id&quot;:&quot;123&quot;}"/>' +
'</root>',
);

const markdown = editor.getDocument('markdown') as unknown as string;
expect(markdown).toBe(
'[Card title](https://lobehub.com)\n' +
'[Iframe title](https://lobehub.com/embed)\n' +
'[Schema title](schema://card/123)\n',
);

const xml = editor.getDocument('litexml') as unknown as string;
expect(xml).toContain('<link-card');
expect(xml).toContain('openTarget="_self"');
expect(xml).toContain('<link-iframe');
expect(xml).toContain('<schema-link');
});
});
48 changes: 48 additions & 0 deletions src/plugins/link/__test__/renderer-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';

import { LinkReactRendererRegistry, splitReactSchemaRules } from '../react/renderer-registry';

describe('link react renderer registry', () => {
it('splits react schema rules into aligned core rules and renderers by id', () => {
const render = vi.fn(() => 'schema-render');
const { coreRules, schemaRenderers } = splitReactSchemaRules([
{
id: 'schema-card',
match: (url) => url.startsWith('schema://'),
render,
},
]);

expect(coreRules?.[0]).toEqual({
id: 'schema-card',
match: expect.any(Function),
});
expect(schemaRenderers.get(coreRules?.[0].id || '')).toBe(render);
});

it('renders schema nodes by the schemaType id from split rules', () => {
const render = vi.fn(() => 'schema-render');
const { schemaRenderers } = splitReactSchemaRules([
{
id: 'schema-card',
match: (url) => url.startsWith('schema://'),
render,
},
]);
const registry = new LinkReactRendererRegistry();
registry.update({ schemaRenderers });

expect(
registry.renderSchemaNode({
editor: {} as any,
node: {} as any,
payload: null,
schema: null,
schemaType: 'schema-card',
title: 'Schema card',
url: 'schema://card/123',
}),
).toBe('schema-render');
expect(render).toHaveBeenCalledOnce();
});
});
223 changes: 223 additions & 0 deletions src/plugins/link/__test__/schema-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$isElementNode,
type LexicalEditor,
createEditor,
} from 'lexical';
import { describe, expect, it, vi } from 'vitest';

import { LinkNode } from '../node/LinkNode';
import { SchemaNode } from '../node/SchemaNode';
import { normalizeSchemaLinkNode } from '../plugin';
import { LinkService } from '../service/i-link-service';

const editor = {} as LexicalEditor;

describe('link display rules', () => {
it('matches generic and amap embed rules by configuration', () => {
const linkService = new LinkService();
linkService.setEmbedRules([
{
allowCard: true,
allowIframe: true,
id: 'amap',
match: (url) => url.includes('uri.amap.com'),
},
{
allowCard: true,
id: 'generic',
match: (url) => /^https?:\/\//.test(url),
},
]);

const context = { editor, text: 'map', title: 'map' };

expect(linkService.getEmbedRule('https://uri.amap.com/marker', context)?.id).toBe('amap');
expect(linkService.getEmbedRule('https://lobehub.com', context)?.id).toBe('generic');
expect(linkService.getEmbedRule('schema://card/123', context)).toBeNull();
});

it('matches schema rules for schema and custom protocols', () => {
const linkService = new LinkService();
linkService.setSchemaRules([
{
id: 'schema',
match: (url) => url.startsWith('schema://'),
},
{
id: 'alipay',
match: (url) => url.startsWith('alipay://'),
},
]);

const context = { editor, text: 'pay', title: 'pay' };

expect(linkService.getSchemaRule('schema://card/123', context)?.id).toBe('schema');
expect(linkService.getSchemaRule('alipay://pay/2088', context)?.id).toBe('alipay');
expect(linkService.getSchemaRule('https://lobehub.com', context)).toBeNull();
});

it('parses schema url details for renderers', () => {
const linkService = new LinkService();
const schema = linkService.parseSchemaUrl('schema://card/123?source=demo#top');

expect(schema).toEqual({
hash: '#top',
host: 'card',
params: { source: 'demo' },
pathname: '/123',
protocol: 'schema:',
raw: 'schema://card/123?source=demo#top',
search: '?source=demo',
});
});

it('allows custom protocols to be configured for sanitization', () => {
const linkService = new LinkService();

linkService.setAllowedProtocols(['schema://', 'alipay:']);

expect(linkService.getAllowedProtocols().has('schema:')).toBe(true);
expect(linkService.getAllowedProtocols().has('alipay:')).toBe(true);
});

it('keeps toolbar base enabled separate from suppression tokens', () => {
const linkService = new LinkService();
const handleChange = vi.fn();
linkService.on('linkToolbarChange', handleChange);

const token = linkService.suppressLinkToolbar('test-toolbar');
expect(linkService.enableLinkToolbar).toBe(false);
expect(handleChange).toHaveBeenLastCalledWith(false);

linkService.setLinkToolbar(true);
expect(linkService.enableLinkToolbar).toBe(false);

linkService.restoreLinkToolbar(token);
expect(linkService.enableLinkToolbar).toBe(true);
expect(handleChange).toHaveBeenLastCalledWith(true);

linkService.setLinkToolbar(false);
expect(linkService.enableLinkToolbar).toBe(false);
const secondToken = linkService.suppressLinkToolbar('second-toolbar');
linkService.restoreLinkToolbar(secondToken);
expect(linkService.enableLinkToolbar).toBe(false);

linkService.setLinkToolbar(true);
expect(linkService.enableLinkToolbar).toBe(true);
});

it('keeps legacy schema renderer protocol matching available', () => {
const linkService = new LinkService();

linkService.setSchemaLinkRenderers([{ protocol: 'schema://' }]);

expect(linkService.hasSchemaLinkRenderer('schema://card/123')).toBe(true);
expect(linkService.hasSchemaLinkRenderer('https://lobehub.com')).toBe(false);
});

it('updates protocol, schema rules, and legacy renderers through service config', () => {
const linkService = new LinkService();

linkService.updateConfig({
allowedProtocols: ['alipay://'],
schemaLinkRenderers: [{ protocol: 'schema://' }],
schemaRules: [
{
id: 'alipay',
match: (url) => url.startsWith('alipay://'),
},
],
});

const context = { editor, text: 'pay', title: 'pay' };
expect(linkService.getAllowedProtocols().has('alipay:')).toBe(true);
expect(linkService.getAllowedProtocols().has('schema:')).toBe(true);
expect(linkService.hasSchemaLinkRenderer('schema://card/123')).toBe(true);
expect(linkService.getSchemaRule('schema://card/123', context)?.id).toBe('schema');
expect(linkService.getSchemaRule('alipay://pay/2088', context)?.id).toBe('alipay');
});

it('converts legacy schema renderer links to schema nodes automatically', async () => {
const linkService = new LinkService();
const lexicalEditor = createEditor({
nodes: [LinkNode, SchemaNode],
});
linkService.setSchemaLinkRenderers([{ protocol: 'schema://' }]);
linkService.setSchemaRules([
{
id: 'schema',
match: (url) => url.startsWith('schema://'),
},
]);

await lexicalEditor.update(() => {
const paragraph = $createParagraphNode();
const linkNode = new LinkNode('schema://card/123', {
rel: 'noreferrer',
target: '_blank',
title: 'Schema title',
});
linkNode.append($createTextNode('Schema card'));
paragraph.append(linkNode);
$getRoot().append(paragraph);

expect(normalizeSchemaLinkNode(linkNode, lexicalEditor, linkService)).toBe(true);
});

let childType = '';
let schemaTitle = '';
await lexicalEditor.getEditorState().read(() => {
const paragraph = $getRoot().getFirstChildOrThrow();
expect($isElementNode(paragraph)).toBe(true);
if (!$isElementNode(paragraph)) return;
const schemaNode = paragraph.getChildren()[0];
childType = schemaNode.getType();
schemaTitle = schemaNode.getTextContent();
});

expect(childType).toBe('schema-link');
expect(schemaTitle).toBe('Schema title');
});

it('normalizes schema rule links without legacy renderer registration', async () => {
const linkService = new LinkService();
const lexicalEditor = createEditor({
nodes: [LinkNode, SchemaNode],
});
linkService.setSchemaRules([
{
id: 'alipay',
match: (url) => url.startsWith('alipay://'),
},
]);

await lexicalEditor.update(() => {
const paragraph = $createParagraphNode();
const linkNode = new LinkNode('alipay://pay/2088?amount=10', {
title: 'Pay now',
});
linkNode.append($createTextNode('Pay'));
paragraph.append(linkNode);
$getRoot().append(paragraph);

expect(normalizeSchemaLinkNode(linkNode, lexicalEditor, linkService)).toBe(true);
});

let childType = '';
let schemaTitle = '';
await lexicalEditor.getEditorState().read(() => {
const paragraph = $getRoot().getFirstChildOrThrow();
expect($isElementNode(paragraph)).toBe(true);
if (!$isElementNode(paragraph)) return;
const schemaNode = paragraph.getChildren()[0];
childType = schemaNode.getType();
schemaTitle = schemaNode.getTextContent();
});

expect(childType).toBe('schema-link');
expect(schemaTitle).toBe('Pay now');
});
});
Loading
Loading