@use-kona/editor 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/createEditable.js +1 -0
- package/dist/plugins/CommandsPlugin/Menu.js +2 -2
- package/dist/plugins/LinksPlugin/Link.js +6 -0
- package/dist/plugins/LinksPlugin/LinksPlugin.js +4 -2
- package/dist/plugins/LinksPlugin/index.js +2 -1
- package/dist/plugins/LinksPlugin/url.js +13 -0
- package/dist/plugins/index.js +2 -2
- package/dist/src/plugins/LinksPlugin/LinksPlugin.d.ts +1 -1
- package/dist/src/plugins/LinksPlugin/index.d.ts +1 -0
- package/dist/src/plugins/LinksPlugin/url.d.ts +1 -0
- package/dist/src/plugins/index.d.ts +1 -1
- package/package.json +1 -3
- package/src/core/createEditable.tsx +1 -0
- package/src/plugins/CommandsPlugin/Menu.spec.tsx +87 -0
- package/src/plugins/CommandsPlugin/Menu.tsx +2 -2
- package/src/plugins/LinksPlugin/Link.tsx +9 -0
- package/src/plugins/LinksPlugin/LinksPlugin.spec.tsx +91 -0
- package/src/plugins/LinksPlugin/LinksPlugin.tsx +10 -2
- package/src/plugins/LinksPlugin/index.ts +1 -0
- package/src/plugins/LinksPlugin/url.ts +9 -0
- package/src/plugins/index.ts +5 -1
|
@@ -196,9 +196,9 @@ const Menu = (props)=>{
|
|
|
196
196
|
break;
|
|
197
197
|
}
|
|
198
198
|
};
|
|
199
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
199
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
200
200
|
return ()=>{
|
|
201
|
-
document.removeEventListener('keydown', handleKeyDown);
|
|
201
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
202
202
|
};
|
|
203
203
|
}, [
|
|
204
204
|
actions,
|
|
@@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import { useReadOnly, useSlateStatic } from "slate-react";
|
|
5
5
|
import styles_module from "./styles.module.js";
|
|
6
|
+
import { isSafeLinkUrl } from "./url.js";
|
|
6
7
|
const Link = (props)=>{
|
|
7
8
|
const editor = useSlateStatic();
|
|
8
9
|
const { attributes, as: Component = 'span', children, element, renderHint } = props;
|
|
@@ -37,6 +38,11 @@ const Link = (props)=>{
|
|
|
37
38
|
}, [
|
|
38
39
|
isOpen
|
|
39
40
|
]);
|
|
41
|
+
if (!isSafeLinkUrl(element.url)) return /*#__PURE__*/ jsx(Component, {
|
|
42
|
+
ref: ref,
|
|
43
|
+
className: styles_module.link,
|
|
44
|
+
children: children
|
|
45
|
+
});
|
|
40
46
|
const getLinkElement = ()=>element;
|
|
41
47
|
const getUrl = ()=>element.url;
|
|
42
48
|
const getEditor = ()=>editor;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
import is_url from "is-url";
|
|
3
2
|
import { Editor, Element, Range, Transforms } from "slate";
|
|
4
3
|
import { jsx as external_slate_hyperscript_jsx } from "slate-hyperscript";
|
|
5
4
|
import { LINK_ELEMENT } from "./constants.js";
|
|
6
5
|
import { Link } from "./Link.js";
|
|
6
|
+
import { isSafeLinkUrl } from "./url.js";
|
|
7
7
|
class LinksPlugin {
|
|
8
8
|
options;
|
|
9
9
|
static LINK_TYPE = LINK_ELEMENT;
|
|
@@ -13,7 +13,7 @@ class LinksPlugin {
|
|
|
13
13
|
init(editor) {
|
|
14
14
|
const { insertText } = editor;
|
|
15
15
|
editor.insertText = (text)=>{
|
|
16
|
-
if (text &&
|
|
16
|
+
if (text && isSafeLinkUrl(text)) {
|
|
17
17
|
const selectedText = window.getSelection()?.toString();
|
|
18
18
|
if (selectedText) Transforms.wrapNodes(editor, {
|
|
19
19
|
type: LinksPlugin.LINK_TYPE,
|
|
@@ -55,6 +55,7 @@ class LinksPlugin {
|
|
|
55
55
|
deserialize: (element, children)=>{
|
|
56
56
|
if ('A' === element.tagName) {
|
|
57
57
|
const url = element.getAttribute('href') || '';
|
|
58
|
+
if (!isSafeLinkUrl(url)) return children;
|
|
58
59
|
return external_slate_hyperscript_jsx('element', {
|
|
59
60
|
type: LinksPlugin.LINK_TYPE,
|
|
60
61
|
url
|
|
@@ -64,6 +65,7 @@ class LinksPlugin {
|
|
|
64
65
|
}
|
|
65
66
|
];
|
|
66
67
|
static addLink = (editor, url)=>{
|
|
68
|
+
if (!isSafeLinkUrl(url)) return;
|
|
67
69
|
const { selection } = editor;
|
|
68
70
|
if (!selection) return;
|
|
69
71
|
const isCollapsed = selection && Range.isCollapsed(selection);
|
package/dist/plugins/index.js
CHANGED
|
@@ -8,11 +8,11 @@ import { EmojiPlugin } from "./EmojiPlugin/index.js";
|
|
|
8
8
|
import { FloatingMenuPlugin } from "./FloatingMenuPlugin/index.js";
|
|
9
9
|
import { HeadingsPlugin } from "./HeadingsPlugin/index.js";
|
|
10
10
|
import { HighlightsPlugin } from "./HighlightsPlugin/index.js";
|
|
11
|
-
import { LinksPlugin } from "./LinksPlugin/index.js";
|
|
11
|
+
import { LinksPlugin, isSafeLinkUrl } from "./LinksPlugin/index.js";
|
|
12
12
|
import { ListsPlugin } from "./ListsPlugin/index.js";
|
|
13
13
|
import { MenuPlugin } from "./MenuPlugin/index.js";
|
|
14
14
|
import { NodeIdPlugin } from "./NodeIdPlugin/index.js";
|
|
15
15
|
import { PlaceholderPlugin } from "./PlaceholderPlugin/index.js";
|
|
16
16
|
import { ShortcutsPlugin } from "./ShortcutsPlugin/index.js";
|
|
17
17
|
import { TableOfContentsPlugin } from "./TableOfContentsPlugin/index.js";
|
|
18
|
-
export { AttachmentsPlugin, BasicFormattingPlugin, BreaksPlugin, CodeBlockPlugin, CommandsPlugin, DnDPlugin, EmojiPlugin, FloatingMenuPlugin, HeadingsPlugin, HighlightsPlugin, LinksPlugin, ListsPlugin, MenuPlugin, NodeIdPlugin, PlaceholderPlugin, ShortcutsPlugin, TableOfContentsPlugin };
|
|
18
|
+
export { AttachmentsPlugin, BasicFormattingPlugin, BreaksPlugin, CodeBlockPlugin, CommandsPlugin, DnDPlugin, EmojiPlugin, FloatingMenuPlugin, HeadingsPlugin, HighlightsPlugin, LinksPlugin, ListsPlugin, MenuPlugin, NodeIdPlugin, PlaceholderPlugin, ShortcutsPlugin, TableOfContentsPlugin, isSafeLinkUrl };
|
|
@@ -14,7 +14,7 @@ export declare class LinksPlugin implements IPlugin {
|
|
|
14
14
|
type: string;
|
|
15
15
|
render: (props: RenderElementProps) => import("react/jsx-runtime").JSX.Element;
|
|
16
16
|
serialize: (element: any, children: any) => string | undefined;
|
|
17
|
-
deserialize: (element: HTMLElement, children: any) =>
|
|
17
|
+
deserialize: (element: HTMLElement, children: any) => any;
|
|
18
18
|
}[];
|
|
19
19
|
static addLink: (editor: Editor, url: string) => void;
|
|
20
20
|
static removeLink: (editor: Editor) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const isSafeLinkUrl: (value: string) => boolean;
|
|
@@ -8,7 +8,7 @@ export { EmojiPlugin } from './EmojiPlugin';
|
|
|
8
8
|
export { type Commands, FloatingMenuPlugin } from './FloatingMenuPlugin';
|
|
9
9
|
export { HeadingsPlugin } from './HeadingsPlugin';
|
|
10
10
|
export { HighlightsPlugin } from './HighlightsPlugin';
|
|
11
|
-
export { LinksPlugin, type OptionsMethods } from './LinksPlugin';
|
|
11
|
+
export { isSafeLinkUrl, LinksPlugin, type OptionsMethods, } from './LinksPlugin';
|
|
12
12
|
export { ListsPlugin } from './ListsPlugin';
|
|
13
13
|
export { MenuPlugin } from './MenuPlugin';
|
|
14
14
|
export { NodeIdPlugin } from './NodeIdPlugin';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@use-kona/editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts"
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"@testing-library/jest-dom": "^6.6.3",
|
|
36
36
|
"@testing-library/react": "^16.3.0",
|
|
37
37
|
"@types/escape-html": "^1.0.4",
|
|
38
|
-
"@types/is-url": "^1.2.32",
|
|
39
38
|
"@types/react": "^19.1.8",
|
|
40
39
|
"@types/react-dom": "18",
|
|
41
40
|
"jsdom": "^26.1.0",
|
|
@@ -70,7 +69,6 @@
|
|
|
70
69
|
"clsx": "^2.1.1",
|
|
71
70
|
"escape-html": "^1.0.3",
|
|
72
71
|
"is-hotkey": "^0.2.0",
|
|
73
|
-
"is-url": "^1.2.4",
|
|
74
72
|
"nanostores": "^1.0.1",
|
|
75
73
|
"prismjs": "^1.30.0"
|
|
76
74
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from '@testing-library/react';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import type { Editor } from 'slate';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { KonaEditor } from '../../editor';
|
|
14
|
+
import type { IPlugin } from '../../types';
|
|
15
|
+
import { CommandsPlugin } from './CommandsPlugin';
|
|
16
|
+
|
|
17
|
+
Object.assign(globalThis, { React });
|
|
18
|
+
|
|
19
|
+
const commands = [
|
|
20
|
+
{
|
|
21
|
+
name: 'one',
|
|
22
|
+
commandName: 'one',
|
|
23
|
+
title: 'One',
|
|
24
|
+
icon: null,
|
|
25
|
+
action: () => {},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'two',
|
|
29
|
+
commandName: 'two',
|
|
30
|
+
title: 'Two',
|
|
31
|
+
icon: null,
|
|
32
|
+
action: () => {},
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
describe('CommandsPlugin keyboard navigation', () => {
|
|
37
|
+
it('keeps the slash menu open when ArrowDown would select the void block below', async () => {
|
|
38
|
+
HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
39
|
+
let editor: Editor;
|
|
40
|
+
const onKeyDown = vi.fn();
|
|
41
|
+
const plugin = new CommandsPlugin({
|
|
42
|
+
commands,
|
|
43
|
+
renderMenu: (children) => <div>{children}</div>,
|
|
44
|
+
});
|
|
45
|
+
const voidPlugin: IPlugin = {
|
|
46
|
+
init(value) {
|
|
47
|
+
editor = value;
|
|
48
|
+
return value;
|
|
49
|
+
},
|
|
50
|
+
blocks: [
|
|
51
|
+
{
|
|
52
|
+
type: 'void',
|
|
53
|
+
isVoid: true,
|
|
54
|
+
render: (props) => <div {...props.attributes}>{props.children}</div>,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
handlers: { onKeyDown },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
render(
|
|
61
|
+
<KonaEditor
|
|
62
|
+
initialValue={[
|
|
63
|
+
{ type: 'paragraph', children: [{ text: '' }] },
|
|
64
|
+
{ type: 'void', children: [{ text: '' }] },
|
|
65
|
+
]}
|
|
66
|
+
plugins={[plugin, voidPlugin]}
|
|
67
|
+
onChange={() => {}}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
act(() => editor.insertText('/'));
|
|
72
|
+
await screen.findByRole('button', { name: 'One' });
|
|
73
|
+
const before = screen
|
|
74
|
+
.getAllByRole('button')
|
|
75
|
+
.map((button) => button.className);
|
|
76
|
+
|
|
77
|
+
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'ArrowDown' });
|
|
78
|
+
|
|
79
|
+
await waitFor(() =>
|
|
80
|
+
expect(screen.getByRole('button', { name: 'Two' })).toBeInTheDocument(),
|
|
81
|
+
);
|
|
82
|
+
expect(
|
|
83
|
+
screen.getAllByRole('button').map((button) => button.className),
|
|
84
|
+
).toEqual([before[1], before[0]]);
|
|
85
|
+
expect(onKeyDown).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -260,10 +260,10 @@ export const Menu = (props: Props) => {
|
|
|
260
260
|
}
|
|
261
261
|
};
|
|
262
262
|
|
|
263
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
263
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
264
264
|
|
|
265
265
|
return () => {
|
|
266
|
-
document.removeEventListener('keydown', handleKeyDown);
|
|
266
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
267
267
|
};
|
|
268
268
|
}, [
|
|
269
269
|
actions,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from 'slate-react';
|
|
9
9
|
import styles from './styles.module.css';
|
|
10
10
|
import type { LinkElement, Options } from './types';
|
|
11
|
+
import { isSafeLinkUrl } from './url';
|
|
11
12
|
|
|
12
13
|
type Props = {
|
|
13
14
|
as?: React.ElementType;
|
|
@@ -53,6 +54,14 @@ export const Link = (props: Props) => {
|
|
|
53
54
|
}
|
|
54
55
|
}, [isOpen]);
|
|
55
56
|
|
|
57
|
+
if (!isSafeLinkUrl(element.url)) {
|
|
58
|
+
return (
|
|
59
|
+
<Component ref={ref} className={styles.link}>
|
|
60
|
+
{children}
|
|
61
|
+
</Component>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
const getLinkElement = () => element;
|
|
57
66
|
|
|
58
67
|
const getUrl = () => element.url;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import { createEditor as createSlateEditor, Transforms } from 'slate';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { createEditor } from '../../core/createEditor';
|
|
7
|
+
import { deserialize } from '../../core/deserialize';
|
|
8
|
+
import { isSafeLinkUrl } from '../../index';
|
|
9
|
+
import { Link } from './Link';
|
|
10
|
+
import { LinksPlugin } from './LinksPlugin';
|
|
11
|
+
|
|
12
|
+
vi.mock('slate-react', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import('slate-react')>();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
useReadOnly: () => true,
|
|
18
|
+
useSlateStatic: () => createSlateEditor(),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const createEditorWithLinks = () => {
|
|
23
|
+
const plugin = new LinksPlugin({});
|
|
24
|
+
const editor = createEditor([plugin])();
|
|
25
|
+
editor.children = [{ type: 'paragraph', children: [{ text: '' }] }];
|
|
26
|
+
Transforms.select(editor, { path: [0, 0], offset: 0 });
|
|
27
|
+
|
|
28
|
+
return { editor, plugin };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('LinksPlugin', () => {
|
|
32
|
+
it.each([
|
|
33
|
+
'https://use-kona.com',
|
|
34
|
+
'http://use-kona.com',
|
|
35
|
+
'mailto:hello@use-kona.com',
|
|
36
|
+
])('accepts %s', (url) => {
|
|
37
|
+
expect(isSafeLinkUrl(url)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects javascript URLs in addLink', () => {
|
|
41
|
+
const { editor } = createEditorWithLinks();
|
|
42
|
+
|
|
43
|
+
LinksPlugin.addLink(editor, 'javascript:alert(1)');
|
|
44
|
+
|
|
45
|
+
expect(editor.children).toEqual([
|
|
46
|
+
{ type: 'paragraph', children: [{ text: '' }] },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects javascript URLs typed into the editor', () => {
|
|
51
|
+
const { editor } = createEditorWithLinks();
|
|
52
|
+
|
|
53
|
+
editor.insertText('javascript:alert(1)');
|
|
54
|
+
|
|
55
|
+
expect(editor.children).toEqual([
|
|
56
|
+
{ type: 'paragraph', children: [{ text: 'javascript:alert(1)' }] },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('deserializes unsafe anchors as their non-link children', () => {
|
|
61
|
+
const { plugin } = createEditorWithLinks();
|
|
62
|
+
const document = new DOMParser().parseFromString(
|
|
63
|
+
'<a href="javascript:alert(1)">legacy link</a>',
|
|
64
|
+
'text/html',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(deserialize([plugin])(document.body)).toEqual({
|
|
68
|
+
type: 'paragraph',
|
|
69
|
+
children: [{ text: 'legacy link' }],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders legacy unsafe links without an anchor', () => {
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<Link
|
|
76
|
+
attributes={{ 'data-slate-node': 'element' }}
|
|
77
|
+
element={{
|
|
78
|
+
type: LinksPlugin.LINK_TYPE,
|
|
79
|
+
url: 'javascript:alert(1)',
|
|
80
|
+
children: [{ text: 'legacy link' }],
|
|
81
|
+
}}
|
|
82
|
+
renderHint={() => null}
|
|
83
|
+
>
|
|
84
|
+
legacy link
|
|
85
|
+
</Link>,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(container.querySelector('a')).toBeNull();
|
|
89
|
+
expect(container.textContent).toBe('legacy link');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import isUrl from 'is-url';
|
|
2
1
|
import { Editor, Element, Range, Transforms } from 'slate';
|
|
3
2
|
import { jsx } from 'slate-hyperscript';
|
|
4
3
|
import type { RenderElementProps } from 'slate-react';
|
|
@@ -6,6 +5,7 @@ import type { IPlugin } from '../../types';
|
|
|
6
5
|
import { LINK_ELEMENT } from './constants';
|
|
7
6
|
import { Link } from './Link';
|
|
8
7
|
import type { LinkElement, Options } from './types';
|
|
8
|
+
import { isSafeLinkUrl } from './url';
|
|
9
9
|
|
|
10
10
|
export class LinksPlugin implements IPlugin {
|
|
11
11
|
static LINK_TYPE = LINK_ELEMENT;
|
|
@@ -16,7 +16,7 @@ export class LinksPlugin implements IPlugin {
|
|
|
16
16
|
const { insertText } = editor;
|
|
17
17
|
|
|
18
18
|
editor.insertText = (text: string) => {
|
|
19
|
-
if (text &&
|
|
19
|
+
if (text && isSafeLinkUrl(text)) {
|
|
20
20
|
const selectedText = window.getSelection()?.toString();
|
|
21
21
|
if (selectedText) {
|
|
22
22
|
Transforms.wrapNodes(editor, {
|
|
@@ -66,6 +66,10 @@ export class LinksPlugin implements IPlugin {
|
|
|
66
66
|
deserialize: (element: HTMLElement, children) => {
|
|
67
67
|
if (element.tagName === 'A') {
|
|
68
68
|
const url = element.getAttribute('href') || '';
|
|
69
|
+
if (!isSafeLinkUrl(url)) {
|
|
70
|
+
return children;
|
|
71
|
+
}
|
|
72
|
+
|
|
69
73
|
return jsx(
|
|
70
74
|
'element',
|
|
71
75
|
{
|
|
@@ -80,6 +84,10 @@ export class LinksPlugin implements IPlugin {
|
|
|
80
84
|
];
|
|
81
85
|
|
|
82
86
|
static addLink = (editor: Editor, url: string) => {
|
|
87
|
+
if (!isSafeLinkUrl(url)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
const { selection } = editor;
|
|
84
92
|
|
|
85
93
|
if (!selection) {
|
package/src/plugins/index.ts
CHANGED
|
@@ -13,7 +13,11 @@ export { EmojiPlugin } from './EmojiPlugin';
|
|
|
13
13
|
export { type Commands, FloatingMenuPlugin } from './FloatingMenuPlugin';
|
|
14
14
|
export { HeadingsPlugin } from './HeadingsPlugin';
|
|
15
15
|
export { HighlightsPlugin } from './HighlightsPlugin';
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
isSafeLinkUrl,
|
|
18
|
+
LinksPlugin,
|
|
19
|
+
type OptionsMethods,
|
|
20
|
+
} from './LinksPlugin';
|
|
17
21
|
export { ListsPlugin } from './ListsPlugin';
|
|
18
22
|
export { MenuPlugin } from './MenuPlugin';
|
|
19
23
|
export { NodeIdPlugin } from './NodeIdPlugin';
|