@use-kona/editor 0.1.27 → 0.1.28

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.
@@ -64,6 +64,7 @@ const createEditable = (editor, plugins)=>({ readOnly })=>{
64
64
  currentBlock[0]
65
65
  ]);
66
66
  else {
67
+ event.preventDefault();
67
68
  selectedNodes.set([]);
68
69
  ReactEditor.blur(editor);
69
70
  }
@@ -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 && is_url(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);
@@ -1,2 +1,3 @@
1
1
  import { LinksPlugin } from "./LinksPlugin.js";
2
- export { LinksPlugin };
2
+ import { isSafeLinkUrl } from "./url.js";
3
+ export { LinksPlugin, isSafeLinkUrl };
@@ -0,0 +1,13 @@
1
+ const allowedProtocols = new Set([
2
+ 'http:',
3
+ 'https:',
4
+ 'mailto:'
5
+ ]);
6
+ const isSafeLinkUrl = (value)=>{
7
+ try {
8
+ return allowedProtocols.has(new URL(value).protocol);
9
+ } catch {
10
+ return false;
11
+ }
12
+ };
13
+ export { isSafeLinkUrl };
@@ -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) => import("../../../types").CustomElement | undefined;
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;
@@ -1,2 +1,3 @@
1
1
  export { LinksPlugin } from './LinksPlugin';
2
2
  export type { OptionsMethods } from './types';
3
+ export { isSafeLinkUrl } from './url';
@@ -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.27",
3
+ "version": "0.1.28",
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
  }
@@ -118,6 +118,7 @@ export const createEditable =
118
118
  if ($selectedNodes.length === 0) {
119
119
  selectedNodes.set([currentBlock[0]]);
120
120
  } else {
121
+ event.preventDefault();
121
122
  selectedNodes.set([]);
122
123
  ReactEditor.blur(editor);
123
124
  }
@@ -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 && isUrl(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) {
@@ -1,2 +1,3 @@
1
1
  export { LinksPlugin } from './LinksPlugin';
2
2
  export type { OptionsMethods } from './types';
3
+ export { isSafeLinkUrl } from './url';
@@ -0,0 +1,9 @@
1
+ const allowedProtocols = new Set(['http:', 'https:', 'mailto:']);
2
+
3
+ export const isSafeLinkUrl = (value: string): boolean => {
4
+ try {
5
+ return allowedProtocols.has(new URL(value).protocol);
6
+ } catch {
7
+ return false;
8
+ }
9
+ };
@@ -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 { LinksPlugin, type OptionsMethods } from './LinksPlugin';
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';