@cyberyozh/text-editor 0.5.2 → 0.5.4
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/editor.d.mts +11 -0
- package/dist/editor.d.ts +11 -0
- package/dist/editor.js +93 -38
- package/dist/editor.js.map +1 -1
- package/dist/editor.mjs +93 -38
- package/dist/editor.mjs.map +1 -1
- package/dist/html.d.mts +28 -0
- package/dist/html.d.ts +28 -0
- package/dist/html.js +2803 -0
- package/dist/html.js.map +1 -0
- package/dist/html.mjs +2771 -0
- package/dist/html.mjs.map +1 -0
- package/dist/render.d.mts +5 -0
- package/dist/render.d.ts +5 -0
- package/dist/render.js +47 -40
- package/dist/render.js.map +1 -1
- package/dist/render.mjs +47 -40
- package/dist/render.mjs.map +1 -1
- package/package.json +9 -3
- package/src/editor/editor.tsx +8 -0
- package/src/extensions/base/code-block.test.ts +39 -0
- package/src/extensions/base/code-block.ts +15 -1
- package/src/extensions/base/link.test.ts +54 -1
- package/src/extensions/base/link.ts +47 -41
- package/src/extensions/editor/get-editor-extensions.ts +3 -1
- package/src/extensions/render/get-render-extensions.ts +6 -3
- package/src/html/html.test.ts +154 -0
- package/src/html/index.ts +29 -0
- package/src/utils/get-shared-extensions.ts +9 -1
- package/src/utils/html-to-tiptap-content.ts +5 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyberyozh/text-editor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Rich text editor library based on Tiptap for CyberYozh",
|
|
5
5
|
"author": "CyberYozh",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"import": "./dist/render/client.mjs",
|
|
18
18
|
"require": "./dist/render/client.js"
|
|
19
19
|
},
|
|
20
|
+
"./html": {
|
|
21
|
+
"types": "./dist/html.d.ts",
|
|
22
|
+
"import": "./dist/html.mjs",
|
|
23
|
+
"require": "./dist/html.js"
|
|
24
|
+
},
|
|
20
25
|
"./editor": {
|
|
21
26
|
"types": "./dist/editor.d.ts",
|
|
22
27
|
"import": "./dist/editor.mjs",
|
|
@@ -74,6 +79,7 @@
|
|
|
74
79
|
"@tiptap/extension-text-align": "3.31.3",
|
|
75
80
|
"@tiptap/extensions": "3.31.3",
|
|
76
81
|
"@tiptap/html": "3.31.3",
|
|
82
|
+
"happy-dom": "^20.14.0",
|
|
77
83
|
"@tiptap/pm": "3.31.3",
|
|
78
84
|
"@tiptap/react": "3.31.3",
|
|
79
85
|
"@tiptap/starter-kit": "3.31.3",
|
|
@@ -82,13 +88,13 @@
|
|
|
82
88
|
"@tiptap/y-tiptap": "3.0.9",
|
|
83
89
|
"emoji-picker-react": "4.19.1",
|
|
84
90
|
"highlight.js": "11.12.0",
|
|
85
|
-
"katex": "0.
|
|
91
|
+
"katex": "0.18.10",
|
|
86
92
|
"lowlight": "3.3.0",
|
|
87
93
|
"y-protocols": "1.0.7",
|
|
88
94
|
"yjs": "13.6.30"
|
|
89
95
|
},
|
|
90
96
|
"devDependencies": {
|
|
91
|
-
"@changesets/cli": "^
|
|
97
|
+
"@changesets/cli": "^3.0.3",
|
|
92
98
|
"@types/node": "^25.3.3",
|
|
93
99
|
"cross-env": "^10.1.0",
|
|
94
100
|
"eslint": "^10.0.2",
|
package/src/editor/editor.tsx
CHANGED
|
@@ -60,6 +60,12 @@ export interface TiptapProps {
|
|
|
60
60
|
*/
|
|
61
61
|
lang?: SupportedLanguage;
|
|
62
62
|
error?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Domains (with all their subdomains) of the site the content belongs to:
|
|
65
|
+
* such links open in the same tab and get no rel on paste / HTML parsing.
|
|
66
|
+
* Read once when the editor instance is created.
|
|
67
|
+
*/
|
|
68
|
+
ownDomains?: string[];
|
|
63
69
|
/**
|
|
64
70
|
* Domains (with all their subdomains) whose links are treated as internal:
|
|
65
71
|
* rel="noopener noreferrer nofollow" is not added on paste / HTML parsing.
|
|
@@ -81,6 +87,7 @@ function TiptapContent({
|
|
|
81
87
|
isDisabled = false,
|
|
82
88
|
lang,
|
|
83
89
|
error,
|
|
90
|
+
ownDomains,
|
|
84
91
|
trustedDomains,
|
|
85
92
|
}: TiptapProps) {
|
|
86
93
|
const [linkDialogContext, setLinkDialogContext] = useState<LinkDialogContextProps | null>(null);
|
|
@@ -124,6 +131,7 @@ function TiptapContent({
|
|
|
124
131
|
suggestion: createSuggestions(translations, !!imageHandlers),
|
|
125
132
|
isEditable: isEditable && !disabled,
|
|
126
133
|
getImageById: imageHandlers?.getImage,
|
|
134
|
+
ownDomains,
|
|
127
135
|
trustedDomains,
|
|
128
136
|
onKatexClick: (latex, pos) => {
|
|
129
137
|
setKatexDialogContext({ latex, pos });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateHTML, generateJSON } from "@tiptap/html";
|
|
3
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
+
import { BaseCodeBlock } from "./code-block";
|
|
5
|
+
|
|
6
|
+
const extensions = [StarterKit.configure({ codeBlock: false }), BaseCodeBlock];
|
|
7
|
+
|
|
8
|
+
const codeBlock = (language: string | null) => ({
|
|
9
|
+
type: "doc",
|
|
10
|
+
content: [
|
|
11
|
+
{
|
|
12
|
+
type: "codeBlock",
|
|
13
|
+
attrs: { language },
|
|
14
|
+
content: [{ type: "text", text: "{}" }],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("BaseCodeBlock", () => {
|
|
20
|
+
it("renders the language as a class only, not as a raw attribute", () => {
|
|
21
|
+
const html = generateHTML(codeBlock("json"), extensions);
|
|
22
|
+
expect(html).toBe('<pre><code class="language-json">{}</code></pre>');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps the language through an HTML round-trip", () => {
|
|
26
|
+
const html = generateHTML(codeBlock("json"), extensions);
|
|
27
|
+
expect(generateJSON(html, extensions)).toEqual(codeBlock("json"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads the legacy data-language attribute", () => {
|
|
31
|
+
const json = generateJSON('<pre><code data-language="python">x</code></pre>', extensions);
|
|
32
|
+
expect(json.content?.[0]?.attrs?.language).toBe("python");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to bash when the language is unknown", () => {
|
|
36
|
+
const json = generateJSON("<pre><code>x</code></pre>", extensions);
|
|
37
|
+
expect(json.content?.[0]?.attrs?.language).toBe("bash");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -5,6 +5,13 @@ import { common, createLowlight } from "lowlight";
|
|
|
5
5
|
const lowlight = createLowlight(common);
|
|
6
6
|
lowlight.register("html", html);
|
|
7
7
|
|
|
8
|
+
function languageFromClass(code: Element | null, prefix: string): string | null {
|
|
9
|
+
const languageClass = Array.from(code?.classList ?? []).find((className) =>
|
|
10
|
+
className.startsWith(prefix),
|
|
11
|
+
);
|
|
12
|
+
return languageClass ? languageClass.slice(prefix.length) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export const BaseCodeBlock = CodeBlockLowlight.configure({
|
|
9
16
|
lowlight,
|
|
10
17
|
}).extend({
|
|
@@ -12,11 +19,18 @@ export const BaseCodeBlock = CodeBlockLowlight.configure({
|
|
|
12
19
|
draggable: true,
|
|
13
20
|
|
|
14
21
|
addAttributes() {
|
|
22
|
+
const { languageClassPrefix } = this.options;
|
|
23
|
+
|
|
15
24
|
return {
|
|
16
25
|
language: {
|
|
17
26
|
default: null,
|
|
27
|
+
rendered: false,
|
|
18
28
|
parseHTML: (element: HTMLElement) => {
|
|
19
|
-
|
|
29
|
+
const code = element.querySelector("code");
|
|
30
|
+
const fromClass = languageClassPrefix
|
|
31
|
+
? languageFromClass(code, languageClassPrefix)
|
|
32
|
+
: null;
|
|
33
|
+
return fromClass || code?.getAttribute("data-language") || "bash";
|
|
20
34
|
},
|
|
21
35
|
},
|
|
22
36
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { isTrustedDomain } from "./link";
|
|
2
|
+
import { isOwnDomain, isTrustedDomain, linkAttributes } from "./link";
|
|
3
3
|
|
|
4
4
|
describe("isTrustedDomain", () => {
|
|
5
5
|
afterEach(() => {
|
|
@@ -58,3 +58,56 @@ describe("isTrustedDomain", () => {
|
|
|
58
58
|
).toBe(true);
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("isOwnDomain", () => {
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.unstubAllGlobals();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("matches the own domain and its subdomains", () => {
|
|
68
|
+
expect(isOwnDomain("https://cyberyozh.com/", ["cyberyozh.com"])).toBe(true);
|
|
69
|
+
expect(isOwnDomain("https://app.cyberyozh.com/ru/proxy/", ["cyberyozh.com"])).toBe(true);
|
|
70
|
+
expect(isOwnDomain("https://cyberyozh.pro/", ["cyberyozh.com"])).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("treats the current window hostname as own regardless of the list", () => {
|
|
74
|
+
vi.stubGlobal("window", { location: { hostname: "app.detect.expert" } });
|
|
75
|
+
expect(isOwnDomain("https://app.detect.expert/page", [])).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns false for unparseable urls", () => {
|
|
79
|
+
expect(isOwnDomain("/relative/path", ["cyberyozh.com"])).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("linkAttributes", () => {
|
|
84
|
+
const options = { ownDomains: ["cyberyozh.com"], trustedDomains: ["cyberyozh.pro"] };
|
|
85
|
+
|
|
86
|
+
it("opens own links in the same tab without rel", () => {
|
|
87
|
+
expect(linkAttributes("https://app.cyberyozh.com/ru/catalog/", options)).toEqual({
|
|
88
|
+
target: "_self",
|
|
89
|
+
rel: null,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("opens trusted links in a new tab without rel", () => {
|
|
94
|
+
expect(linkAttributes("https://cyberyozh.pro/blog/", options)).toEqual({
|
|
95
|
+
target: "_blank",
|
|
96
|
+
rel: null,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("opens external links in a new tab with nofollow", () => {
|
|
101
|
+
expect(linkAttributes("https://github.com/", options)).toEqual({
|
|
102
|
+
target: "_blank",
|
|
103
|
+
rel: "noopener noreferrer nofollow",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps the previous behaviour when no domains are configured", () => {
|
|
108
|
+
expect(linkAttributes("https://app.cyberyozh.com/", { ownDomains: [], trustedDomains: [] })).toEqual({
|
|
109
|
+
target: "_blank",
|
|
110
|
+
rel: "noopener noreferrer nofollow",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -4,6 +4,12 @@ import { Plugin, TextSelection } from "@tiptap/pm/state";
|
|
|
4
4
|
import { find } from "linkifyjs";
|
|
5
5
|
|
|
6
6
|
export interface ExtendedLinkOptions extends LinkOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Domains (with all their subdomains) of the site the content belongs to:
|
|
9
|
+
* such links open in the same tab and get no rel on paste / HTML parsing.
|
|
10
|
+
* The current window hostname is always own.
|
|
11
|
+
*/
|
|
12
|
+
ownDomains: string[];
|
|
7
13
|
/**
|
|
8
14
|
* Domains (with all their subdomains) whose links are treated as internal:
|
|
9
15
|
* rel="noopener noreferrer nofollow" is not added on paste / HTML parsing.
|
|
@@ -12,64 +18,70 @@ export interface ExtendedLinkOptions extends LinkOptions {
|
|
|
12
18
|
trustedDomains: string[];
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
export type LinkDomains = Pick<ExtendedLinkOptions, "ownDomains" | "trustedDomains">;
|
|
22
|
+
|
|
23
|
+
const hostnameOf = (url: string): string | null => {
|
|
17
24
|
try {
|
|
18
|
-
return new URL(url).hostname
|
|
25
|
+
return new URL(url).hostname.toLowerCase();
|
|
19
26
|
} catch {
|
|
20
|
-
return
|
|
27
|
+
return null;
|
|
21
28
|
}
|
|
22
29
|
};
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
31
|
+
const isSameDomain = (url: string): boolean => {
|
|
32
|
+
if (typeof window === "undefined") return false;
|
|
33
|
+
return hostnameOf(url) === window.location.hostname;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const matchesDomains = (url: string, domains: string[]): boolean => {
|
|
37
|
+
const hostname = hostnameOf(url);
|
|
38
|
+
if (hostname === null) return false;
|
|
39
|
+
return domains.some((candidate) => {
|
|
40
|
+
const domain = candidate.toLowerCase();
|
|
41
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const isOwnDomain = (url: string, ownDomains: string[]): boolean =>
|
|
46
|
+
isSameDomain(url) || matchesDomains(url, ownDomains);
|
|
47
|
+
|
|
48
|
+
export const isTrustedDomain = (url: string, trustedDomains: string[]): boolean =>
|
|
49
|
+
isSameDomain(url) || matchesDomains(url, trustedDomains);
|
|
50
|
+
|
|
51
|
+
export const linkAttributes = (
|
|
52
|
+
href: string,
|
|
53
|
+
{ ownDomains, trustedDomains }: LinkDomains,
|
|
54
|
+
): { target: string; rel: string | null } => {
|
|
55
|
+
const own = isOwnDomain(href, ownDomains);
|
|
56
|
+
return {
|
|
57
|
+
target: own ? "_self" : "_blank",
|
|
58
|
+
rel: own || isTrustedDomain(href, trustedDomains) ? null : "noopener noreferrer nofollow",
|
|
59
|
+
};
|
|
43
60
|
};
|
|
44
61
|
|
|
45
62
|
export const LinkExtension = Link.extend<ExtendedLinkOptions>({
|
|
46
63
|
addOptions() {
|
|
47
64
|
return {
|
|
48
65
|
...(this.parent?.() ?? ({} as ExtendedLinkOptions)),
|
|
66
|
+
ownDomains: [] as string[],
|
|
49
67
|
trustedDomains: [] as string[],
|
|
50
68
|
};
|
|
51
69
|
},
|
|
52
70
|
|
|
53
71
|
addAttributes() {
|
|
54
|
-
const
|
|
72
|
+
const domains = this.options;
|
|
55
73
|
|
|
56
74
|
return {
|
|
57
75
|
...this.parent?.(),
|
|
58
76
|
target: {
|
|
59
77
|
default: "_blank",
|
|
60
|
-
parseHTML: (element) =>
|
|
61
|
-
|
|
62
|
-
return isSameDomain(href) ? "_self" : "_blank";
|
|
63
|
-
},
|
|
78
|
+
parseHTML: (element) =>
|
|
79
|
+
linkAttributes(element.getAttribute("href") ?? "", domains).target,
|
|
64
80
|
},
|
|
65
81
|
rel: {
|
|
66
82
|
default: null,
|
|
67
|
-
parseHTML: (element) =>
|
|
68
|
-
|
|
69
|
-
return isTrustedDomain(href, trustedDomains)
|
|
70
|
-
? null
|
|
71
|
-
: "noopener noreferrer nofollow";
|
|
72
|
-
},
|
|
83
|
+
parseHTML: (element) =>
|
|
84
|
+
linkAttributes(element.getAttribute("href") ?? "", domains).rel,
|
|
73
85
|
},
|
|
74
86
|
};
|
|
75
87
|
},
|
|
@@ -105,13 +117,7 @@ export const LinkExtension = Link.extend<ExtendedLinkOptions>({
|
|
|
105
117
|
type: this.type,
|
|
106
118
|
getAttributes: (match) => {
|
|
107
119
|
const href = match.data?.href ?? "";
|
|
108
|
-
return {
|
|
109
|
-
href,
|
|
110
|
-
target: isSameDomain(href) ? "_self" : "_blank",
|
|
111
|
-
rel: isTrustedDomain(href, this.options.trustedDomains)
|
|
112
|
-
? null
|
|
113
|
-
: "noopener noreferrer nofollow",
|
|
114
|
-
};
|
|
120
|
+
return { href, ...linkAttributes(href, this.options) };
|
|
115
121
|
},
|
|
116
122
|
}),
|
|
117
123
|
];
|
|
@@ -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
|
+
ownDomains?: string[];
|
|
32
33
|
trustedDomains?: string[];
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -45,9 +46,10 @@ export function getEditorExtensions(options: GetExtensionsOptions) {
|
|
|
45
46
|
getImageById,
|
|
46
47
|
onKatexClick,
|
|
47
48
|
onFigureClick,
|
|
49
|
+
ownDomains,
|
|
48
50
|
trustedDomains,
|
|
49
51
|
} = options;
|
|
50
|
-
const sharedExtensions = getSharedExtensions({ trustedDomains });
|
|
52
|
+
const sharedExtensions = getSharedExtensions({ ownDomains, trustedDomains });
|
|
51
53
|
|
|
52
54
|
return [
|
|
53
55
|
...sharedExtensions,
|
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
BaseVideo,
|
|
16
16
|
BaseFigure,
|
|
17
17
|
} from "../base";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getSharedExtensions,
|
|
20
|
+
type SharedExtensionsOptions,
|
|
21
|
+
} from "@/utils/get-shared-extensions";
|
|
19
22
|
import {
|
|
20
23
|
BaseLegacyCustomBlockquote,
|
|
21
24
|
BaseLegacyPromptSE,
|
|
@@ -28,8 +31,8 @@ import {
|
|
|
28
31
|
BaseLegacyFigure,
|
|
29
32
|
} from "../base/compatibility";
|
|
30
33
|
|
|
31
|
-
export function getRenderExtensions() {
|
|
32
|
-
const sharedExtensions = getSharedExtensions();
|
|
34
|
+
export function getRenderExtensions(options: SharedExtensionsOptions = {}) {
|
|
35
|
+
const sharedExtensions = getSharedExtensions(options);
|
|
33
36
|
|
|
34
37
|
return [
|
|
35
38
|
...sharedExtensions,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { htmlToTiptapContent, tiptapContentToHtml } from "./index";
|
|
3
|
+
|
|
4
|
+
const paragraph = (content: object[]) => ({
|
|
5
|
+
type: "paragraph",
|
|
6
|
+
attrs: { textAlign: null },
|
|
7
|
+
content,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const link = (href: string, target: string, rel: string | null) => ({
|
|
11
|
+
type: "link",
|
|
12
|
+
attrs: { href, target, rel, class: null, title: null },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const document = {
|
|
16
|
+
type: "doc",
|
|
17
|
+
content: [
|
|
18
|
+
paragraph([
|
|
19
|
+
{ type: "text", text: "Read about " },
|
|
20
|
+
{
|
|
21
|
+
type: "text",
|
|
22
|
+
marks: [link("https://app.cyberyozh.com/ru/proxy/", "_self", null), { type: "bold" }],
|
|
23
|
+
text: "proxies",
|
|
24
|
+
},
|
|
25
|
+
{ type: "text", text: " and " },
|
|
26
|
+
{
|
|
27
|
+
type: "text",
|
|
28
|
+
marks: [link("https://cyberyozh.pro/", "_blank", null)],
|
|
29
|
+
text: "the blog",
|
|
30
|
+
},
|
|
31
|
+
{ type: "text", text: " or " },
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
marks: [link("https://github.com/", "_blank", "noopener noreferrer nofollow")],
|
|
35
|
+
text: "GitHub",
|
|
36
|
+
},
|
|
37
|
+
{ type: "text", text: "." },
|
|
38
|
+
]),
|
|
39
|
+
{
|
|
40
|
+
type: "callout",
|
|
41
|
+
attrs: { type: "info", bgColor: "#F6F0FF", emoji: "⭐", calloutType: null, callout: null },
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "bulletList",
|
|
45
|
+
content: [
|
|
46
|
+
{ type: "listItem", content: [paragraph([{ type: "text", text: "First" }])] },
|
|
47
|
+
{ type: "listItem", content: [paragraph([{ type: "text", text: "Second" }])] },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: "table",
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: "tableRow",
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: "tableHeader",
|
|
60
|
+
attrs: { colspan: 1, rowspan: 1, colwidth: [200], align: null },
|
|
61
|
+
content: [paragraph([{ type: "text", text: "Access" }])],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "tableCell",
|
|
65
|
+
attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
|
|
66
|
+
content: [paragraph([{ type: "text", text: "Manual search" }])],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: "codeBlock",
|
|
74
|
+
attrs: { language: "json" },
|
|
75
|
+
content: [{ type: "text", text: '{ "ok": true }' }],
|
|
76
|
+
},
|
|
77
|
+
paragraph([
|
|
78
|
+
{ type: "text", marks: [{ type: "strike" }], text: "struck" },
|
|
79
|
+
{ type: "text", text: " " },
|
|
80
|
+
{ type: "text", marks: [{ type: "highlight", attrs: { color: "#adb79e" } }], text: "marked" },
|
|
81
|
+
{ type: "text", text: " " },
|
|
82
|
+
{ type: "text", marks: [{ type: "copyable-text" }], text: "copyable" },
|
|
83
|
+
{ type: "text", text: " " },
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
marks: [
|
|
87
|
+
{
|
|
88
|
+
type: "textStyle",
|
|
89
|
+
attrs: { backgroundColor: "", color: "#582f95", fontFamily: "", fontSize: "", lineHeight: "" },
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
text: "colored",
|
|
93
|
+
},
|
|
94
|
+
{ type: "text", text: " " },
|
|
95
|
+
{ type: "inlineMath", attrs: { latex: "\\utilde{AB}" } },
|
|
96
|
+
]),
|
|
97
|
+
{
|
|
98
|
+
type: "orderedList",
|
|
99
|
+
attrs: { start: 1, type: null },
|
|
100
|
+
content: [{ type: "listItem", content: [paragraph([{ type: "text", text: "One" }])] }],
|
|
101
|
+
},
|
|
102
|
+
{ type: "blockquote", content: [paragraph([{ type: "text", text: "Quote" }])] },
|
|
103
|
+
{
|
|
104
|
+
type: "details",
|
|
105
|
+
attrs: { open: false },
|
|
106
|
+
content: [
|
|
107
|
+
{ type: "detailsSummary", content: [{ type: "text", text: "Summary" }] },
|
|
108
|
+
{ type: "detailsContent", content: [paragraph([{ type: "text", text: "Hidden" }])] },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: "customQuote",
|
|
113
|
+
content: [
|
|
114
|
+
{ type: "customQuoteText", attrs: { open: true }, content: [{ type: "text", text: "Quote" }] },
|
|
115
|
+
{ type: "customQuoteAuthor", content: [{ type: "text", text: "Author" }] },
|
|
116
|
+
{ type: "customQuoteSign", content: [{ type: "text", text: "Sign" }] },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "mythReality",
|
|
121
|
+
content: [
|
|
122
|
+
{ type: "mythRealityBlock", content: [paragraph([{ type: "text", text: "Myth" }])] },
|
|
123
|
+
{ type: "mythRealityBlock", content: [paragraph([{ type: "text", text: "Reality" }])] },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: "prompt",
|
|
128
|
+
content: [
|
|
129
|
+
{ type: "promptHeader", content: [{ type: "text", text: "Prompt" }] },
|
|
130
|
+
{ type: "promptContent", content: [{ type: "text", text: "Text of the prompt" }] },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const domains = { ownDomains: ["cyberyozh.com"], trustedDomains: ["cyberyozh.pro"] };
|
|
137
|
+
|
|
138
|
+
describe("html entry point", () => {
|
|
139
|
+
it("round-trips every node and mark of the content model", () => {
|
|
140
|
+
const html = tiptapContentToHtml(document, domains);
|
|
141
|
+
expect(htmlToTiptapContent(html, domains)).toEqual(document);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("writes custom nodes as HTML the parser reads back", () => {
|
|
145
|
+
const html = tiptapContentToHtml(document, domains);
|
|
146
|
+
expect(html).toContain('<div data-callout="info"');
|
|
147
|
+
expect(html).toContain("<table");
|
|
148
|
+
expect(html).toContain('<code class="language-json">');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("parses empty HTML into an empty document", () => {
|
|
152
|
+
expect(htmlToTiptapContent("")).toEqual({ type: "doc", content: [{ type: "paragraph", attrs: { textAlign: null } }] });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML entry point for @cyberyozh/text-editor
|
|
3
|
+
*
|
|
4
|
+
* Converts Tiptap JSON content to HTML and back with the full set of content extensions.
|
|
5
|
+
* Server-side only: parses with happy-dom, no React and no stylesheets, so it is safe for
|
|
6
|
+
* server code, scripts and tests. Broken HTML throws instead of returning an empty document.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JSONContent } from "@tiptap/core";
|
|
10
|
+
import { generateHTML, generateJSON } from "@tiptap/html/server";
|
|
11
|
+
import { getRenderExtensions } from "../extensions/render";
|
|
12
|
+
import type { SharedExtensionsOptions } from "../utils/get-shared-extensions";
|
|
13
|
+
|
|
14
|
+
export type { SharedExtensionsOptions } from "../utils/get-shared-extensions";
|
|
15
|
+
export type { JSONContent };
|
|
16
|
+
|
|
17
|
+
export function tiptapContentToHtml(
|
|
18
|
+
content: JSONContent,
|
|
19
|
+
options: SharedExtensionsOptions = {},
|
|
20
|
+
): string {
|
|
21
|
+
return generateHTML(content, getRenderExtensions(options));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function htmlToTiptapContent(
|
|
25
|
+
html: string,
|
|
26
|
+
options: SharedExtensionsOptions = {},
|
|
27
|
+
): JSONContent {
|
|
28
|
+
return generateJSON(html, getRenderExtensions(options));
|
|
29
|
+
}
|
|
@@ -17,6 +17,11 @@ import { TextStyleKit } from "@tiptap/extension-text-style";
|
|
|
17
17
|
import { LegacyInlineLatexSE } from "@/extensions/base/compatibility";
|
|
18
18
|
|
|
19
19
|
export interface SharedExtensionsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Domains (with all their subdomains) of the site the content belongs to —
|
|
22
|
+
* see the ownDomains option of LinkExtension.
|
|
23
|
+
*/
|
|
24
|
+
ownDomains?: string[];
|
|
20
25
|
/**
|
|
21
26
|
* Domains (with all their subdomains) whose links are treated as internal —
|
|
22
27
|
* see the trustedDomains option of LinkExtension.
|
|
@@ -35,7 +40,10 @@ export function getSharedExtensions(options: SharedExtensionsOptions = {}) {
|
|
|
35
40
|
link: false,
|
|
36
41
|
}),
|
|
37
42
|
Highlight.configure({ multicolor: true }),
|
|
38
|
-
LinkExtension.configure({
|
|
43
|
+
LinkExtension.configure({
|
|
44
|
+
ownDomains: options.ownDomains ?? [],
|
|
45
|
+
trustedDomains: options.trustedDomains ?? [],
|
|
46
|
+
}),
|
|
39
47
|
Superscript,
|
|
40
48
|
Subscript,
|
|
41
49
|
TextAlign.configure({
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "./get-shared-extensions";
|
|
6
|
-
import { BaseFigure } from "@/extensions/base/figure";
|
|
7
|
-
import { TableKit } from "@tiptap/extension-table";
|
|
8
|
-
import { BaseCodeBlock } from "@/extensions/base";
|
|
1
|
+
import { type JSONContent } from "@tiptap/core";
|
|
2
|
+
import { generateJSON } from "@tiptap/html";
|
|
3
|
+
import { getRenderExtensions } from "@/extensions/render";
|
|
4
|
+
import type { SharedExtensionsOptions } from "./get-shared-extensions";
|
|
9
5
|
|
|
10
6
|
function isJSONContent(value: unknown): value is JSONContent {
|
|
11
7
|
return (
|
|
@@ -39,12 +35,8 @@ export function htmlToTiptapContent(
|
|
|
39
35
|
return { type: "doc", content: [{ type: "paragraph" }] };
|
|
40
36
|
}
|
|
41
37
|
|
|
42
|
-
const sharedExtensions = getSharedExtensions(options);
|
|
43
|
-
|
|
44
|
-
const extensions = [...sharedExtensions, BaseFigure, TableKit, BaseCodeBlock];
|
|
45
|
-
|
|
46
38
|
try {
|
|
47
|
-
return generateJSON(content,
|
|
39
|
+
return generateJSON(content, getRenderExtensions(options));
|
|
48
40
|
} catch (error) {
|
|
49
41
|
console.error("Failed to convert HTML to Tiptap content:", error);
|
|
50
42
|
return { type: "doc", content: [{ type: "paragraph" }] };
|