@cyberyozh/text-editor 0.4.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyberyozh/text-editor",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Rich text editor library based on Tiptap for CyberYozh",
5
5
  "author": "CyberYozh",
6
6
  "license": "MIT",
@@ -60,6 +60,12 @@ export interface TiptapProps {
60
60
  */
61
61
  lang?: SupportedLanguage;
62
62
  error?: string;
63
+ /**
64
+ * Domains (with all their subdomains) whose links are treated as internal:
65
+ * rel="noopener noreferrer nofollow" is not added on paste / HTML parsing.
66
+ * Read once when the editor instance is created.
67
+ */
68
+ trustedDomains?: string[];
63
69
  }
64
70
 
65
71
  export interface KatexDialogContextProps {
@@ -75,6 +81,7 @@ function TiptapContent({
75
81
  isDisabled = false,
76
82
  lang,
77
83
  error,
84
+ trustedDomains,
78
85
  }: TiptapProps) {
79
86
  const [linkDialogContext, setLinkDialogContext] = useState<LinkDialogContextProps | null>(null);
80
87
  const [katexDialogContext, setKatexDialogContext] = useState<KatexDialogContextProps | null>(
@@ -117,6 +124,7 @@ function TiptapContent({
117
124
  suggestion: createSuggestions(translations, !!imageHandlers),
118
125
  isEditable: isEditable && !disabled,
119
126
  getImageById: imageHandlers?.getImage,
127
+ trustedDomains,
120
128
  onKatexClick: (latex, pos) => {
121
129
  setKatexDialogContext({ latex, pos });
122
130
  openDialogCommand("katex");
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { shouldHandleInsertedFile } from "./markup-insertion";
3
+
4
+ function fakeEvent() {
5
+ return {
6
+ preventDefault: vi.fn(),
7
+ stopPropagation: vi.fn(),
8
+ } as unknown as Event & {
9
+ preventDefault: ReturnType<typeof vi.fn>;
10
+ stopPropagation: ReturnType<typeof vi.fn>;
11
+ };
12
+ }
13
+
14
+ describe("shouldHandleInsertedFile", () => {
15
+ it("leaves the event untouched when clipboard has no files (plain text / html paste)", () => {
16
+ const event = fakeEvent();
17
+ expect(shouldHandleInsertedFile(undefined, event)).toBe(false);
18
+ expect(event.preventDefault).not.toHaveBeenCalled();
19
+ expect(event.stopPropagation).not.toHaveBeenCalled();
20
+ });
21
+
22
+ it("blocks the event for a non-image file and does not handle it", () => {
23
+ const event = fakeEvent();
24
+ const file = new File(["data"], "doc.pdf", { type: "application/pdf" });
25
+ expect(shouldHandleInsertedFile(file, event)).toBe(false);
26
+ expect(event.preventDefault).toHaveBeenCalledOnce();
27
+ expect(event.stopPropagation).toHaveBeenCalledOnce();
28
+ });
29
+
30
+ it("blocks the event for an image file and handles it", () => {
31
+ const event = fakeEvent();
32
+ const file = new File(["data"], "pic.png", { type: "image/png" });
33
+ expect(shouldHandleInsertedFile(file, event)).toBe(true);
34
+ expect(event.preventDefault).toHaveBeenCalledOnce();
35
+ expect(event.stopPropagation).toHaveBeenCalledOnce();
36
+ });
37
+ });
@@ -3,7 +3,12 @@ import { useDialogCommand } from "../context";
3
3
  import { EditorView } from "@tiptap/pm/view";
4
4
  import { Slice } from "@tiptap/pm/model";
5
5
  import { ImageHandlers } from "@/types";
6
- import { applyUploadResultsToNodes, collectRehostableImages, removeImagesFromSlice } from "./markup-insertion";
6
+ import {
7
+ applyUploadResultsToNodes,
8
+ collectRehostableImages,
9
+ removeImagesFromSlice,
10
+ shouldHandleInsertedFile,
11
+ } from "./markup-insertion";
7
12
 
8
13
  export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
9
14
  const [insertedImage, setInsertedImage] = useState<File | null>(null);
@@ -17,10 +22,7 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
17
22
  }, [insertedImage, activeDialog]);
18
23
 
19
24
  function genericInsertionHandler(file: File | undefined, event: Event) {
20
- event.preventDefault();
21
- event.stopPropagation();
22
-
23
- if (!file || !file.type.startsWith("image/")) return;
25
+ if (!file || !shouldHandleInsertedFile(file, event)) return;
24
26
  if (!imageHandlers?.upload) return;
25
27
 
26
28
  setInsertedImage(file);
@@ -48,7 +50,7 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
48
50
  if (
49
51
  "clipboardData" in event &&
50
52
  Array.from(event.clipboardData?.items ?? []).some(
51
- (item) => item.type === "text/html"
53
+ (item) => item.type === "text/html",
52
54
  )
53
55
  ) {
54
56
  hasMarkupInsertion = true;
@@ -57,7 +59,7 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
57
59
  if (
58
60
  "dataTransfer" in event &&
59
61
  Array.from(event.dataTransfer?.items ?? []).some(
60
- (item) => item.type === "text/html"
62
+ (item) => item.type === "text/html",
61
63
  )
62
64
  ) {
63
65
  hasMarkupInsertion = true;
@@ -69,7 +71,8 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
69
71
 
70
72
  const imagesToNodes = collectRehostableImages(slice);
71
73
 
72
- imageHandlers.uploadMultipleByURL(imagesToNodes.map(([url]) => url))
74
+ imageHandlers
75
+ .uploadMultipleByURL(imagesToNodes.map(([url]) => url))
73
76
  .then((results) => {
74
77
  applyUploadResultsToNodes(imagesToNodes, results);
75
78
  view.dispatch(view.state.tr.replaceSelection(slice));
@@ -78,7 +81,9 @@ export function useInsertionHandlers(imageHandlers?: ImageHandlers) {
78
81
  setDisabled(false);
79
82
  });
80
83
  } else {
81
- view.dispatch(view.state.tr.replaceSelection(removeImagesFromSlice(slice)));
84
+ view.dispatch(
85
+ view.state.tr.replaceSelection(removeImagesFromSlice(slice)),
86
+ );
82
87
  }
83
88
 
84
89
  return true;
@@ -70,3 +70,12 @@ export function applyUploadResultsToNodes(
70
70
  node.attrs.storageKey = result.storageKey;
71
71
  });
72
72
  }
73
+
74
+ export function shouldHandleInsertedFile(file: File | undefined, event: Event): boolean {
75
+ if (!file) return false;
76
+
77
+ event.preventDefault();
78
+ event.stopPropagation();
79
+
80
+ return file.type.startsWith("image/");
81
+ }
@@ -0,0 +1,60 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { isTrustedDomain } from "./link";
3
+
4
+ describe("isTrustedDomain", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals();
7
+ });
8
+
9
+ it("returns false for external urls when no trusted domains configured", () => {
10
+ expect(isTrustedDomain("https://example.com/page", [])).toBe(false);
11
+ });
12
+
13
+ it("matches an exact trusted domain", () => {
14
+ expect(
15
+ isTrustedDomain("https://cyberyozh.com/blog", ["cyberyozh.com"]),
16
+ ).toBe(true);
17
+ });
18
+
19
+ it("matches subdomains of a trusted domain", () => {
20
+ expect(
21
+ isTrustedDomain("https://app.cyberyozh.com/account", ["cyberyozh.com"]),
22
+ ).toBe(true);
23
+ expect(
24
+ isTrustedDomain("https://www.deep.cyberyozh.pro/", ["cyberyozh.pro"]),
25
+ ).toBe(true);
26
+ });
27
+
28
+ it("does not match lookalike domains sharing a suffix without a dot boundary", () => {
29
+ expect(
30
+ isTrustedDomain("https://evilcyberyozh.com/", ["cyberyozh.com"]),
31
+ ).toBe(false);
32
+ expect(
33
+ isTrustedDomain("https://cyberyozh.com.attacker.io/", ["cyberyozh.com"]),
34
+ ).toBe(false);
35
+ });
36
+
37
+ it("matches any domain from the list", () => {
38
+ const trusted = ["cyberyozh.com", "cyberyozh.pro", "scriptrun.ai"];
39
+ expect(isTrustedDomain("https://scriptrun.ai/docs", trusted)).toBe(true);
40
+ expect(isTrustedDomain("https://cyberyozh.pro/", trusted)).toBe(true);
41
+ expect(isTrustedDomain("https://github.com/", trusted)).toBe(false);
42
+ });
43
+
44
+ it("treats the current window hostname as trusted regardless of the list", () => {
45
+ vi.stubGlobal("window", { location: { hostname: "app.detect.expert" } });
46
+ expect(isTrustedDomain("https://app.detect.expert/page", [])).toBe(true);
47
+ expect(isTrustedDomain("https://other.detect.expert/page", [])).toBe(false);
48
+ });
49
+
50
+ it("returns false for unparseable urls", () => {
51
+ expect(isTrustedDomain("not a url", ["cyberyozh.com"])).toBe(false);
52
+ expect(isTrustedDomain("/relative/path", ["cyberyozh.com"])).toBe(false);
53
+ });
54
+
55
+ it("is case-insensitive for the hostname", () => {
56
+ expect(
57
+ isTrustedDomain("https://App.CyberYozh.com/", ["cyberyozh.com"]),
58
+ ).toBe(true);
59
+ });
60
+ });
@@ -1,8 +1,17 @@
1
- import Link, { isAllowedUri } from "@tiptap/extension-link";
1
+ import Link, { isAllowedUri, type LinkOptions } from "@tiptap/extension-link";
2
2
  import { getMarkRange, markPasteRule } from "@tiptap/core";
3
3
  import { Plugin, TextSelection } from "@tiptap/pm/state";
4
4
  import { find } from "linkifyjs";
5
5
 
6
+ export interface ExtendedLinkOptions extends LinkOptions {
7
+ /**
8
+ * Domains (with all their subdomains) whose links are treated as internal:
9
+ * rel="noopener noreferrer nofollow" is not added on paste / HTML parsing.
10
+ * The current window hostname is always trusted.
11
+ */
12
+ trustedDomains: string[];
13
+ }
14
+
6
15
  const isSameDomain = (url: string): boolean => {
7
16
  if (typeof window === "undefined") return false;
8
17
  try {
@@ -12,8 +21,38 @@ const isSameDomain = (url: string): boolean => {
12
21
  }
13
22
  };
14
23
 
15
- export const LinkExtension = Link.extend({
24
+ export const isTrustedDomain = (
25
+ url: string,
26
+ trustedDomains: string[],
27
+ ): boolean => {
28
+ try {
29
+ const { hostname } = new URL(url);
30
+ if (
31
+ typeof window !== "undefined" &&
32
+ hostname === window.location.hostname
33
+ ) {
34
+ return true;
35
+ }
36
+ return trustedDomains.some((trusted) => {
37
+ const domain = trusted.toLowerCase();
38
+ return hostname === domain || hostname.endsWith(`.${domain}`);
39
+ });
40
+ } catch {
41
+ return false;
42
+ }
43
+ };
44
+
45
+ export const LinkExtension = Link.extend<ExtendedLinkOptions>({
46
+ addOptions() {
47
+ return {
48
+ ...(this.parent?.() ?? ({} as ExtendedLinkOptions)),
49
+ trustedDomains: [] as string[],
50
+ };
51
+ },
52
+
16
53
  addAttributes() {
54
+ const { trustedDomains } = this.options;
55
+
17
56
  return {
18
57
  ...this.parent?.(),
19
58
  target: {
@@ -27,7 +66,9 @@ export const LinkExtension = Link.extend({
27
66
  default: null,
28
67
  parseHTML: (element) => {
29
68
  const href = element.getAttribute("href") ?? "";
30
- return isSameDomain(href) ? null : "noopener noreferrer nofollow";
69
+ return isTrustedDomain(href, trustedDomains)
70
+ ? null
71
+ : "noopener noreferrer nofollow";
31
72
  },
32
73
  },
33
74
  };
@@ -53,16 +94,23 @@ export const LinkExtension = Link.extend({
53
94
  defaultProtocol,
54
95
  }),
55
96
  )
56
- .filter((link) => this.options.shouldAutoLink?.(link.value) !== false)
57
- .map((link) => ({ text: link.value, data: { href: link.href }, index: link.start })),
97
+ .filter(
98
+ (link) => this.options.shouldAutoLink?.(link.value) !== false,
99
+ )
100
+ .map((link) => ({
101
+ text: link.value,
102
+ data: { href: link.href },
103
+ index: link.start,
104
+ })),
58
105
  type: this.type,
59
106
  getAttributes: (match) => {
60
107
  const href = match.data?.href ?? "";
61
- const sameDomain = isSameDomain(href);
62
108
  return {
63
109
  href,
64
- target: sameDomain ? "_self" : "_blank",
65
- rel: sameDomain ? null : "noopener noreferrer nofollow",
110
+ target: isSameDomain(href) ? "_self" : "_blank",
111
+ rel: isTrustedDomain(href, this.options.trustedDomains)
112
+ ? null
113
+ : "noopener noreferrer nofollow",
66
114
  };
67
115
  },
68
116
  }),
@@ -80,7 +128,11 @@ export const LinkExtension = Link.extend({
80
128
  const range = getMarkRange(view.state.doc.resolve(pos), this.type);
81
129
  if (!range) return false;
82
130
 
83
- const selection = TextSelection.create(view.state.doc, range.from, range.to);
131
+ const selection = TextSelection.create(
132
+ view.state.doc,
133
+ range.from,
134
+ range.to,
135
+ );
84
136
  view.dispatch(view.state.tr.setSelection(selection));
85
137
  return true;
86
138
  },
@@ -29,6 +29,7 @@ interface GetExtensionsOptions {
29
29
  getImageById?: (fileId: string) => string | null;
30
30
  onKatexClick?: (latex: string, pos?: number) => void;
31
31
  onFigureClick?: (attrs: any, pos: number) => void;
32
+ trustedDomains?: string[];
32
33
  }
33
34
 
34
35
  /**
@@ -44,8 +45,9 @@ export function getEditorExtensions(options: GetExtensionsOptions) {
44
45
  getImageById,
45
46
  onKatexClick,
46
47
  onFigureClick,
48
+ trustedDomains,
47
49
  } = options;
48
- const sharedExtensions = getSharedExtensions();
50
+ const sharedExtensions = getSharedExtensions({ trustedDomains });
49
51
 
50
52
  return [
51
53
  ...sharedExtensions,
@@ -1,25 +1,41 @@
1
1
  import StarterKit from "@tiptap/starter-kit";
2
- import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
2
+ import {
3
+ Details,
4
+ DetailsContent,
5
+ DetailsSummary,
6
+ } from "@tiptap/extension-details";
3
7
  import Highlight from "@tiptap/extension-highlight";
4
- import { LinkExtension, BaseMythReality, BaseMythRealityBlock } from "@/extensions/base";
8
+ import {
9
+ LinkExtension,
10
+ BaseMythReality,
11
+ BaseMythRealityBlock,
12
+ } from "@/extensions/base";
5
13
  import Subscript from "@tiptap/extension-subscript";
6
14
  import Superscript from "@tiptap/extension-superscript";
7
15
  import TextAlign from "@tiptap/extension-text-align";
8
16
  import { TextStyleKit } from "@tiptap/extension-text-style";
9
17
  import { LegacyInlineLatexSE } from "@/extensions/base/compatibility";
10
18
 
19
+ export interface SharedExtensionsOptions {
20
+ /**
21
+ * Domains (with all their subdomains) whose links are treated as internal —
22
+ * see the trustedDomains option of LinkExtension.
23
+ */
24
+ trustedDomains?: string[];
25
+ }
26
+
11
27
  /**
12
28
  * Get all Tiptap extensions for editing and rendering without ReactNodeViewRenderer for Editor
13
29
  * Can be used for both static render and editor
14
30
  */
15
- export function getSharedExtensions() {
31
+ export function getSharedExtensions(options: SharedExtensionsOptions = {}) {
16
32
  return [
17
33
  StarterKit.configure({
18
34
  codeBlock: false,
19
35
  link: false,
20
36
  }),
21
37
  Highlight.configure({ multicolor: true }),
22
- LinkExtension,
38
+ LinkExtension.configure({ trustedDomains: options.trustedDomains ?? [] }),
23
39
  Superscript,
24
40
  Subscript,
25
41
  TextAlign.configure({
@@ -1,16 +1,25 @@
1
1
  import { generateJSON, type JSONContent } from "@tiptap/core";
2
- import { getSharedExtensions } from "./get-shared-extensions";
2
+ import {
3
+ getSharedExtensions,
4
+ type SharedExtensionsOptions,
5
+ } from "./get-shared-extensions";
3
6
  import { BaseFigure } from "@/extensions/base/figure";
4
7
  import { TableKit } from "@tiptap/extension-table";
5
8
  import { BaseCodeBlock } from "@/extensions/base";
6
9
 
7
10
  function isJSONContent(value: unknown): value is JSONContent {
8
11
  return (
9
- typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"
12
+ typeof value === "object" &&
13
+ value !== null &&
14
+ "type" in value &&
15
+ typeof value.type === "string"
10
16
  );
11
17
  }
12
18
 
13
- export function htmlToTiptapContent(content: string | JSONContent): JSONContent {
19
+ export function htmlToTiptapContent(
20
+ content: string | JSONContent,
21
+ options: SharedExtensionsOptions = {},
22
+ ): JSONContent {
14
23
  if (isJSONContent(content)) {
15
24
  return content;
16
25
  }
@@ -30,7 +39,7 @@ export function htmlToTiptapContent(content: string | JSONContent): JSONContent
30
39
  return { type: "doc", content: [{ type: "paragraph" }] };
31
40
  }
32
41
 
33
- const sharedExtensions = getSharedExtensions();
42
+ const sharedExtensions = getSharedExtensions(options);
34
43
 
35
44
  const extensions = [...sharedExtensions, BaseFigure, TableKit, BaseCodeBlock];
36
45