@paroicms/tiptap-editor-plugin 1.0.0

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.
@@ -0,0 +1,211 @@
1
+ import { stripHtmlTags } from "@paroicms/public-server-lib";
2
+ import { Mark } from "@tiptap/core";
3
+ import Code from "@tiptap/extension-code";
4
+ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
5
+ import Subscript from "@tiptap/extension-subscript";
6
+ import Superscript from "@tiptap/extension-superscript";
7
+ import TextAlign from "@tiptap/extension-text-align";
8
+ import { TextStyle } from "@tiptap/extension-text-style";
9
+ import StarterKit from "@tiptap/starter-kit";
10
+ import { renderToHTMLString } from "@tiptap/static-renderer";
11
+ import { createHtmlSnippetNodeBackend } from "./extensions/html-snippet-node.js";
12
+ import { createInternalLinkNodeBackend, preloadInternalLinkDocuments, } from "./extensions/internal-link-node.js";
13
+ import { createMediaNodeBackend, preloadMediaImages, } from "./extensions/media-node.js";
14
+ import { createObfuscateMarkBackend, createObfuscateMarkRenderer, } from "./extensions/obfuscate-mark.js";
15
+ import { createPlatformVideoNodeBackend } from "./extensions/platform-video-node.js";
16
+ import { shouldOpenInBlankTab } from "./url-blank-tab.js";
17
+ const TextSize = Mark.create({
18
+ name: "textSize",
19
+ addAttributes() {
20
+ return {
21
+ value: {
22
+ default: null,
23
+ parseHTML: (element) => {
24
+ if (element.classList.contains("text-size-small"))
25
+ return "small";
26
+ if (element.classList.contains("text-size-large"))
27
+ return "large";
28
+ if (element.classList.contains("text-size-huge"))
29
+ return "huge";
30
+ return null;
31
+ },
32
+ renderHTML: (attributes) => {
33
+ if (!attributes.value)
34
+ return {};
35
+ return { class: `text-size-${attributes.value}` };
36
+ },
37
+ },
38
+ };
39
+ },
40
+ keepOnSplit: false,
41
+ parseHTML() {
42
+ return [
43
+ { tag: "span.text-size-small" },
44
+ { tag: "span.text-size-large" },
45
+ { tag: "span.text-size-huge" },
46
+ ];
47
+ },
48
+ renderHTML({ HTMLAttributes }) {
49
+ return ["span", HTMLAttributes, 0];
50
+ },
51
+ });
52
+ export async function convertTiptapJsonToHtml(service, json, options) {
53
+ const processedJson = addTargetBlankToLinks(service, json);
54
+ const mediaMap = await preloadMediaImages(service, processedJson, options);
55
+ const documentMap = await preloadInternalLinkDocuments(service, processedJson, options);
56
+ const hasCodeInConf = service.pluginService.configuration.adminUi?.code ?? false;
57
+ const usedLanguages = collectCodeBlockLanguages(processedJson);
58
+ if (usedLanguages.size > 0) {
59
+ service.setRenderState("paTiptapEditor:languages", Array.from(usedLanguages));
60
+ }
61
+ const extensions = [
62
+ StarterKit.configure({
63
+ code: hasCodeInConf ? false : undefined,
64
+ codeBlock: hasCodeInConf ? false : undefined,
65
+ }),
66
+ ...(hasCodeInConf ? [Code, CodeBlockLowlight] : []),
67
+ TextAlign.configure({
68
+ types: ["heading", "paragraph"],
69
+ defaultAlignment: undefined,
70
+ }),
71
+ TextStyle,
72
+ TextSize,
73
+ Subscript,
74
+ Superscript,
75
+ createMediaNodeBackend(service, mediaMap),
76
+ createHtmlSnippetNodeBackend(),
77
+ createObfuscateMarkBackend(),
78
+ createPlatformVideoNodeBackend(),
79
+ createInternalLinkNodeBackend(service, documentMap, options),
80
+ ];
81
+ const html = renderToHTMLString({
82
+ content: processedJson,
83
+ extensions,
84
+ options: {
85
+ nodeMapping: {
86
+ htmlSnippet: ({ node }) => {
87
+ return node.attrs.html || "";
88
+ },
89
+ },
90
+ markMapping: {
91
+ obfuscate: createObfuscateMarkRenderer(),
92
+ },
93
+ },
94
+ });
95
+ return html;
96
+ }
97
+ function addTargetBlankToLinks(service, json) {
98
+ if (!json.content)
99
+ return json;
100
+ function processNodes(nodes) {
101
+ return nodes.map((node) => {
102
+ let processedNode = node;
103
+ if (node.marks) {
104
+ processedNode = {
105
+ ...processedNode,
106
+ marks: node.marks.map((mark) => {
107
+ if (mark.type === "link" && mark.attrs?.href) {
108
+ if (shouldOpenInBlankTab(service.pluginService, mark.attrs.href)) {
109
+ return {
110
+ ...mark,
111
+ attrs: {
112
+ ...mark.attrs,
113
+ target: "_blank",
114
+ },
115
+ };
116
+ }
117
+ }
118
+ return mark;
119
+ }),
120
+ };
121
+ }
122
+ if (node.content) {
123
+ return {
124
+ ...processedNode,
125
+ content: processNodes(node.content),
126
+ };
127
+ }
128
+ return processedNode;
129
+ });
130
+ }
131
+ return {
132
+ ...json,
133
+ content: processNodes(json.content),
134
+ };
135
+ }
136
+ export function convertTiptapJsonToPlainText(service, json) {
137
+ const cleanedJson = removeObfuscatedText(json);
138
+ const hasCodeInConf = service.pluginService.configuration.adminUi?.code ?? false;
139
+ const emptyMediaMap = new Map();
140
+ const emptyDocumentMap = new Map();
141
+ const extensions = [
142
+ StarterKit.configure({
143
+ code: hasCodeInConf ? false : undefined,
144
+ codeBlock: hasCodeInConf ? false : undefined,
145
+ }),
146
+ ...(hasCodeInConf ? [Code, CodeBlockLowlight] : []),
147
+ TextAlign.configure({
148
+ types: ["heading", "paragraph"],
149
+ defaultAlignment: undefined,
150
+ }),
151
+ TextStyle,
152
+ TextSize,
153
+ Subscript,
154
+ Superscript,
155
+ createMediaNodeBackend(service, emptyMediaMap),
156
+ createHtmlSnippetNodeBackend(),
157
+ createPlatformVideoNodeBackend(),
158
+ createInternalLinkNodeBackend(service, emptyDocumentMap),
159
+ ];
160
+ const html = renderToHTMLString({
161
+ content: cleanedJson,
162
+ extensions,
163
+ });
164
+ return stripHtmlTags(html, { blockSeparator: " – " });
165
+ }
166
+ function removeObfuscatedText(json) {
167
+ function processNodes(nodes) {
168
+ return nodes
169
+ .filter((node) => {
170
+ // Filter out internal link nodes
171
+ if (node.type === "internalLink") {
172
+ return false;
173
+ }
174
+ // Remove text nodes with obfuscate mark
175
+ if (node.marks?.some((m) => m.type === "obfuscate")) {
176
+ return false;
177
+ }
178
+ return true;
179
+ })
180
+ .map((node) => {
181
+ if (node.content) {
182
+ return {
183
+ ...node,
184
+ content: processNodes(node.content),
185
+ };
186
+ }
187
+ return node;
188
+ });
189
+ }
190
+ return {
191
+ ...json,
192
+ content: json.content ? processNodes(json.content) : undefined,
193
+ };
194
+ }
195
+ function collectCodeBlockLanguages(json) {
196
+ const languages = new Set();
197
+ function traverse(nodes) {
198
+ if (!nodes)
199
+ return;
200
+ for (const node of nodes) {
201
+ if (node.type === "codeBlock" && node.attrs?.language) {
202
+ languages.add(node.attrs.language);
203
+ }
204
+ if (node.content) {
205
+ traverse(node.content);
206
+ }
207
+ }
208
+ }
209
+ traverse(json.content);
210
+ return languages;
211
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ const forceBlankTabExtensions = ["pdf", "txt"];
2
+ export function shouldOpenInBlankTab(service, url) {
3
+ const extensionMatch = url.match(/\.([^./?#]+)(?:[?#]|$)/);
4
+ if (extensionMatch) {
5
+ const extension = extensionMatch[1].toLowerCase();
6
+ if (forceBlankTabExtensions.includes(extension)) {
7
+ return true;
8
+ }
9
+ }
10
+ if (url.startsWith("/"))
11
+ return false; // path-only url
12
+ const regex = /^https?:\/\/([^/]+)/;
13
+ const match = url.match(regex);
14
+ if (!match)
15
+ return false;
16
+ const [, domainAndPort] = match;
17
+ const domain = domainAndPort.split(":")[0];
18
+ if (domain !== service.fqdn)
19
+ return true;
20
+ return false;
21
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@paroicms/tiptap-editor-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Tiptap editor plugin for ParoiCMS",
5
+ "keywords": [
6
+ "paroicms",
7
+ "tiptap",
8
+ "plugin",
9
+ "editor"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://gitlab.com/paroi/opensource/paroicms.git",
14
+ "directory": "plugins/tiptap-editor-plugin"
15
+ },
16
+ "author": "Paroi Team",
17
+ "license": "MIT",
18
+ "scripts": {
19
+ "build": "npm run build:backend && npm run build:admin-ui && npm run build:public-assets",
20
+ "build:backend": "(cd backend && tsc)",
21
+ "build:admin-ui": "(cd admin-ui-plugin && npm run build)",
22
+ "build:public-assets": "npm run _scss -- --no-source-map --style=compressed",
23
+ "build:backend:watch": "(cd backend && tsc --watch --preserveWatchOutput)",
24
+ "build:admin-ui:watch": "(cd admin-ui-plugin && npm run build:watch)",
25
+ "build:public-assets:watch": "npm run _scss && npm run _scss -- --watch",
26
+ "_scss": "sass public-assets/src/text.scss public-assets/dist/text.css",
27
+ "clear": "rimraf backend/dist/* admin-ui-plugin/dist/*",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest"
30
+ },
31
+ "dependencies": {
32
+ "@paroicms/script-lib": "0.3.3",
33
+ "@tiptap/core": "~3.7.2",
34
+ "@tiptap/extension-code": "~3.7.2",
35
+ "@tiptap/extension-code-block-lowlight": "~3.7.2",
36
+ "@tiptap/extension-subscript": "~3.7.2",
37
+ "@tiptap/extension-superscript": "~3.7.2",
38
+ "@tiptap/extension-text-align": "~3.7.2",
39
+ "@tiptap/extension-text-style": "~3.7.2",
40
+ "@tiptap/starter-kit": "~3.7.2",
41
+ "@tiptap/static-renderer": "~3.7.2",
42
+ "arktype": "~2.1.23"
43
+ },
44
+ "peerDependencies": {
45
+ "@paroicms/public-anywhere-lib": "0",
46
+ "@paroicms/public-server-lib": "0"
47
+ },
48
+ "devDependencies": {
49
+ "@paroicms/public-anywhere-lib": "0.37.1",
50
+ "@paroicms/public-server-lib": "0.47.1",
51
+ "@types/node": "~24.8.1",
52
+ "rimraf": "~6.0.1",
53
+ "sass": "~1.93.2",
54
+ "typescript": "~5.9.3",
55
+ "vitest": "~3.2.4"
56
+ },
57
+ "type": "module",
58
+ "main": "backend/dist/index.js",
59
+ "types": "types/html-editor-public-types.d.ts",
60
+ "files": [
61
+ "backend/dist",
62
+ "admin-ui-plugin/dist",
63
+ "public-assets/dist",
64
+ "site-schema-lib",
65
+ "types/html-editor-public-types.d.ts"
66
+ ]
67
+ }
@@ -0,0 +1 @@
1
+ .Text .Fig{display:table;margin:0}.Text .Fig.left{float:left;margin:5px 20px 10px 0}.Text .Fig.right{float:right;margin:5px 0 10px 20px}.Text .Fig.center{clear:both;margin:20px auto}.Text .Fig-media{display:block;height:auto;max-width:100%}.Text .Fig-caption{caption-side:bottom;display:table-caption;font-size:.875em;margin-top:8px;opacity:.6;text-align:center}.Text .Fig a{display:block}.Text .Fig a:hover{opacity:.9}.Text .text-align-center{text-align:center}.Text .text-align-right{text-align:right}.Text .text-align-justify{text-align:justify}.Text .text-size-small{font-size:.5625em}.Text .text-size-large{font-size:2em}.Text .text-size-huge{font-size:4em}.Text iframe{display:block;margin:20px auto}
@@ -0,0 +1,56 @@
1
+ {
2
+ "ParoiCMSSiteSchemaFormatVersion": "10",
3
+ "languages": ["en", "fr", "es", "de", "it", "pt"],
4
+ "fieldTypes": [
5
+ {
6
+ "name": "leadParagraph",
7
+ "localized": true,
8
+ "storedAs": "text",
9
+ "dataType": "json",
10
+ "renderAs": "html",
11
+ "normalizeTypography": true,
12
+ "useAsExcerpt": 1,
13
+ "adminUi": {
14
+ "editorRows": 4
15
+ },
16
+ "plugin": "@paroicms/tiptap-editor-plugin"
17
+ },
18
+ {
19
+ "name": "htmlContent",
20
+ "localized": true,
21
+ "storedAs": "text",
22
+ "dataType": "json",
23
+ "renderAs": "html",
24
+ "normalizeTypography": true,
25
+ "withGallery": true,
26
+ "useAsExcerpt": 2,
27
+ "useAsDefaultImage": 2,
28
+ "plugin": "@paroicms/tiptap-editor-plugin"
29
+ },
30
+ {
31
+ "name": "introduction",
32
+ "localized": true,
33
+ "storedAs": "text",
34
+ "dataType": "json",
35
+ "renderAs": "html",
36
+ "normalizeTypography": true,
37
+ "useAsExcerpt": 1,
38
+ "adminUi": {
39
+ "editorRows": 8
40
+ },
41
+ "plugin": "@paroicms/tiptap-editor-plugin"
42
+ },
43
+ {
44
+ "name": "footerMention",
45
+ "localized": true,
46
+ "storedAs": "text",
47
+ "dataType": "json",
48
+ "renderAs": "html",
49
+ "normalizeTypography": true,
50
+ "adminUi": {
51
+ "editorRows": 4
52
+ },
53
+ "plugin": "@paroicms/tiptap-editor-plugin"
54
+ }
55
+ ]
56
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "fieldTypes": {
3
+ "leadParagraph": {
4
+ "label": "Lead paragraph"
5
+ },
6
+ "htmlContent": {
7
+ "label": "Content"
8
+ },
9
+ "introduction": {
10
+ "label": "Introduction"
11
+ },
12
+ "footerMention": {
13
+ "label": "Footer mention"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "fieldTypes": {
3
+ "leadParagraph": {
4
+ "label": "Chapeau"
5
+ },
6
+ "htmlContent": {
7
+ "label": "Contenu"
8
+ },
9
+ "introduction": {
10
+ "label": "Introduction"
11
+ },
12
+ "footerMention": {
13
+ "label": "Mention de bas de page"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,34 @@
1
+ export interface PlatformVideoValue {
2
+ videoId: string;
3
+ platform: string;
4
+ }
5
+
6
+ export interface HtmlEditorInitService {
7
+ registerVideoPlugin(plugin: HtmlEditorVideoPlugin): void;
8
+ registerInternalLinkPlugin(plugin: HtmlEditorInternalLinkPlugin): void;
9
+ }
10
+
11
+ export interface HtmlEditorVideoPlugin {
12
+ recognizeUrl(url: string): PlatformVideoValue | undefined;
13
+ getThumbnailUrl(value: PlatformVideoValue): string;
14
+ createComponent(
15
+ value: PlatformVideoValue,
16
+ options: {
17
+ getLanguage: () => string;
18
+ onChange: (newVal: PlatformVideoValue | null) => void;
19
+ },
20
+ ): {
21
+ element: HTMLElement;
22
+ dispose: () => void;
23
+ };
24
+ }
25
+
26
+ export interface HtmlEditorInternalLinkPlugin {
27
+ getDocumentTitle(lNodeId: string): Promise<string | undefined>;
28
+ openDialog(
29
+ documentId: string | undefined,
30
+ options: {
31
+ getLanguage: () => string;
32
+ },
33
+ ): Promise<{ documentId: string | undefined } | undefined>;
34
+ }