@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,53 @@
1
+ /**
2
+ * Remove empty attrs objects and marks with null values from Tiptap JSON to reduce storage size.
3
+ * When no attributes are set, the attrs object is omitted entirely.
4
+ * Marks with null or undefined values in their attributes are removed.
5
+ */
6
+ export function cleanTiptapJson(json) {
7
+ function cleanNode(node) {
8
+ const cleaned = {
9
+ type: node.type,
10
+ };
11
+ // Only include attrs if it has properties with non-null values
12
+ if (node.attrs && Object.keys(node.attrs).length > 0) {
13
+ const cleanedAttrs = {};
14
+ for (const [key, value] of Object.entries(node.attrs)) {
15
+ if (value !== null && value !== undefined) {
16
+ cleanedAttrs[key] = value;
17
+ }
18
+ }
19
+ if (Object.keys(cleanedAttrs).length > 0) {
20
+ cleaned.attrs = cleanedAttrs;
21
+ }
22
+ }
23
+ // Recursively clean child nodes
24
+ if (node.content) {
25
+ cleaned.content = node.content.map(cleanNode);
26
+ }
27
+ // Clean marks: remove marks with null/undefined values or empty attrs
28
+ if (node.marks) {
29
+ const cleanedMarks = node.marks.filter((mark) => {
30
+ // Keep marks without attrs
31
+ if (!mark.attrs)
32
+ return true;
33
+ // Remove marks with empty attrs object
34
+ if (Object.keys(mark.attrs).length === 0)
35
+ return false;
36
+ // Remove marks where all attribute values are null or undefined
37
+ const hasValidValue = Object.values(mark.attrs).some((value) => value !== null && value !== undefined);
38
+ return hasValidValue;
39
+ });
40
+ if (cleanedMarks.length > 0) {
41
+ cleaned.marks = cleanedMarks;
42
+ }
43
+ }
44
+ if (node.text !== undefined) {
45
+ cleaned.text = node.text;
46
+ }
47
+ return cleaned;
48
+ }
49
+ return {
50
+ type: "doc",
51
+ content: json.content?.map(cleanNode),
52
+ };
53
+ }
@@ -0,0 +1,28 @@
1
+ import { Node } from "@tiptap/core";
2
+ import { type } from "arktype";
3
+ export const HtmlSnippetInputAT = type({
4
+ "html?": "string|undefined|null",
5
+ "+": "reject",
6
+ }).pipe((data) => ({
7
+ html: data.html ?? undefined,
8
+ }));
9
+ export function createHtmlSnippetNodeBackend() {
10
+ return Node.create({
11
+ name: "htmlSnippet",
12
+ group: "block",
13
+ atom: true,
14
+ addAttributes() {
15
+ return {
16
+ html: { default: "" },
17
+ };
18
+ },
19
+ parseHTML() {
20
+ return [{ tag: 'button[data-pa-node="htmlSnippet"]' }];
21
+ },
22
+ renderHTML() {
23
+ // Return a simple placeholder structure.
24
+ // The actual HTML injection is handled via nodeMapping in renderToHTMLString options.
25
+ return ["div", { "data-html-snippet": "true" }, 0];
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,57 @@
1
+ import { Node } from "@tiptap/core";
2
+ export async function preloadInternalLinkDocuments(service, json, options) {
3
+ const documentMap = new Map();
4
+ async function walkNodes(nodes) {
5
+ for (const node of nodes) {
6
+ if (node.type === "internalLink" && node.attrs?.documentId) {
7
+ const documentId = node.attrs.documentId;
8
+ if (!documentMap.has(documentId)) {
9
+ try {
10
+ const doc = await service.getDocument(documentId);
11
+ if (doc) {
12
+ const url = await doc.getUrl({
13
+ absoluteUrl: options?.absoluteUrls,
14
+ });
15
+ documentMap.set(documentId, {
16
+ title: doc.title ?? "",
17
+ url: url,
18
+ });
19
+ }
20
+ }
21
+ catch (_error) {
22
+ service.pluginService.logger.warn(`Failed to load document '${documentId}'`);
23
+ }
24
+ }
25
+ }
26
+ if (node.content) {
27
+ await walkNodes(node.content);
28
+ }
29
+ }
30
+ }
31
+ if (json.content) {
32
+ await walkNodes(json.content);
33
+ }
34
+ return documentMap;
35
+ }
36
+ export function createInternalLinkNodeBackend(_service, documentMap, _options) {
37
+ return Node.create({
38
+ name: "internalLink",
39
+ group: "inline",
40
+ inline: true,
41
+ atom: true,
42
+ addAttributes() {
43
+ return { documentId: { default: "" } };
44
+ },
45
+ parseHTML() {
46
+ return [{ tag: "span[data-internal-link]" }];
47
+ },
48
+ renderHTML({ node }) {
49
+ const { documentId } = node.attrs;
50
+ const data = documentMap.get(documentId);
51
+ if (!data) {
52
+ return ["span", { class: "InternalLink" }, ""];
53
+ }
54
+ return ["a", { class: "InternalLink", href: data.url }, data.title];
55
+ },
56
+ });
57
+ }
@@ -0,0 +1,110 @@
1
+ import { isResizeRule } from "@paroicms/public-anywhere-lib";
2
+ import { Node } from "@tiptap/core";
3
+ import { type } from "arktype";
4
+ export const MediaNodeInputAT = type({
5
+ mediaId: "string",
6
+ resizeRule: "string",
7
+ "align?": '"center"|"left"|"right"|undefined|null',
8
+ "href?": "string|undefined|null",
9
+ "zoomable?": "boolean|undefined|null",
10
+ "+": "reject",
11
+ }).pipe((data) => {
12
+ if (!isResizeRule(data.resizeRule))
13
+ throw new Error(`Invalid resizeRule: ${data.resizeRule}`);
14
+ return {
15
+ mediaId: data.mediaId,
16
+ resizeRule: data.resizeRule,
17
+ align: data.align ?? undefined,
18
+ href: data.href ?? undefined,
19
+ zoomable: data.zoomable ?? undefined,
20
+ };
21
+ });
22
+ export async function preloadMediaImages(service, json, options) {
23
+ const mediaMap = new Map();
24
+ async function walkNodes(nodes) {
25
+ for (const node of nodes) {
26
+ if (node.type === "media" && node.attrs) {
27
+ const { mediaId, resizeRule } = node.attrs;
28
+ const key = `${mediaId}:${resizeRule}`;
29
+ if (mediaMap.has(key))
30
+ continue;
31
+ const media = await service.pluginService.getMedia({ mediaId }, { withAttachedData: { language: service.language } });
32
+ if (media?.kind === "image") {
33
+ const image = await service.useImage(media, resizeRule, {
34
+ absoluteUrl: options?.absoluteUrls,
35
+ });
36
+ if (image) {
37
+ mediaMap.set(key, { image, media });
38
+ }
39
+ else {
40
+ service.pluginService.logger.warn(`failed to load image '${mediaId}' with resize rule '${resizeRule}'`);
41
+ }
42
+ }
43
+ else {
44
+ service.pluginService.logger.warn(`media '${mediaId}' is not an image`);
45
+ }
46
+ }
47
+ if (node.content) {
48
+ await walkNodes(node.content);
49
+ }
50
+ }
51
+ }
52
+ if (json.content) {
53
+ await walkNodes(json.content);
54
+ }
55
+ return mediaMap;
56
+ }
57
+ export function createMediaNodeBackend(service, mediaMap) {
58
+ return Node.create({
59
+ name: "media",
60
+ group: "block",
61
+ atom: true,
62
+ addAttributes() {
63
+ return {
64
+ mediaId: { default: null },
65
+ resizeRule: { default: null },
66
+ align: { default: "center" },
67
+ zoomable: { default: false },
68
+ href: { default: null },
69
+ };
70
+ },
71
+ parseHTML() {
72
+ return [{ tag: 'figure[data-pa-node="media"]' }];
73
+ },
74
+ renderHTML({ node }) {
75
+ const { mediaId, resizeRule, align, href, zoomable } = node.attrs;
76
+ const key = `${mediaId}:${resizeRule}`;
77
+ const data = mediaMap.get(key);
78
+ if (!data) {
79
+ return ["div", { class: "media-error" }, ""];
80
+ }
81
+ const { image, media } = data;
82
+ const caption = media.attachedData?.[service.language]?.caption;
83
+ const figClass = `Fig${align ? ` ${align}` : ""}`;
84
+ const figAttrs = { class: figClass };
85
+ if (zoomable) {
86
+ figAttrs["data-zoomable"] = mediaId;
87
+ service.setRenderState("paZoom:enabled", true);
88
+ }
89
+ const imgSpec = [
90
+ "img",
91
+ {
92
+ class: "Fig-media",
93
+ src: image.url,
94
+ width: String(image.width),
95
+ height: String(image.height),
96
+ loading: "lazy",
97
+ alt: "",
98
+ },
99
+ ];
100
+ const captionSpec = caption ? ["figcaption", { class: "Fig-caption" }, caption] : null;
101
+ if (href) {
102
+ const linkSpec = ["a", { href }, imgSpec];
103
+ const children = [linkSpec, captionSpec].filter(Boolean);
104
+ return ["figure", figAttrs, ...children];
105
+ }
106
+ const children = [imgSpec, captionSpec].filter(Boolean);
107
+ return ["figure", figAttrs, ...children];
108
+ },
109
+ });
110
+ }
@@ -0,0 +1,59 @@
1
+ import { generateObfuscatedHtml, obfuscateAsHtmlLink } from "@paroicms/public-server-lib";
2
+ import { Mark } from "@tiptap/core";
3
+ /**
4
+ * Creates the obfuscate mark for backend rendering.
5
+ * The actual obfuscation logic is handled via markMapping in renderToHTMLString options,
6
+ * similar to how htmlSnippet uses nodeMapping.
7
+ */
8
+ export function createObfuscateMarkBackend() {
9
+ return Mark.create({
10
+ name: "obfuscate",
11
+ addAttributes() {
12
+ return {
13
+ asALink: {
14
+ default: "raw",
15
+ parseHTML: (element) => element.getAttribute("data-as-a-link") || "raw",
16
+ renderHTML: (attributes) => {
17
+ return {
18
+ "data-as-a-link": attributes.asALink,
19
+ };
20
+ },
21
+ },
22
+ };
23
+ },
24
+ parseHTML() {
25
+ return [
26
+ {
27
+ tag: "span.Obfuscate",
28
+ },
29
+ ];
30
+ },
31
+ renderHTML({ HTMLAttributes }) {
32
+ // Return a simple placeholder - the actual rendering is handled by markMapping
33
+ // Note: HTMLAttributes will contain { "data-as-a-link": "..." } from addAttributes
34
+ return [
35
+ "span",
36
+ {
37
+ ...HTMLAttributes,
38
+ class: "Obfuscate",
39
+ },
40
+ 0,
41
+ ];
42
+ },
43
+ });
44
+ }
45
+ /**
46
+ * Creates the mark renderer function for use with markMapping in renderToHTMLString.
47
+ * This function receives the mark and its children (text content) and returns the obfuscated HTML.
48
+ */
49
+ export function createObfuscateMarkRenderer() {
50
+ return ({ mark, children }) => {
51
+ // Get the text content from children
52
+ const textContent = Array.isArray(children) ? children.join("") : (children ?? "");
53
+ const asALink = mark.attrs?.asALink;
54
+ if (asALink === "mailto" || asALink === "tel") {
55
+ return obfuscateAsHtmlLink(textContent);
56
+ }
57
+ return generateObfuscatedHtml(textContent);
58
+ };
59
+ }
@@ -0,0 +1,45 @@
1
+ import { Node } from "@tiptap/core";
2
+ import { type } from "arktype";
3
+ const PlatformVideoInputAT = type({
4
+ videoId: "string",
5
+ platform: "string",
6
+ "+": "reject",
7
+ });
8
+ export function createPlatformVideoNodeBackend() {
9
+ return Node.create({
10
+ name: "platformVideo",
11
+ group: "block",
12
+ atom: true,
13
+ addAttributes() {
14
+ return {
15
+ videoId: { default: "" },
16
+ platform: { default: "youtube" },
17
+ };
18
+ },
19
+ parseHTML() {
20
+ return [
21
+ {
22
+ tag: 'div[data-pa-node="platformVideo"]',
23
+ },
24
+ ];
25
+ },
26
+ renderHTML({ node }) {
27
+ const { videoId, platform } = PlatformVideoInputAT.assert(node.attrs);
28
+ if (platform !== "youtube") {
29
+ console.error(`Unsupported video platform "${platform}"`);
30
+ return ["span", { "data-unsupported-video-platform": platform }, 0];
31
+ }
32
+ return [
33
+ "iframe",
34
+ {
35
+ width: "420",
36
+ height: "315",
37
+ src: `https://www.youtube-nocookie.com/embed/${videoId}`,
38
+ frameborder: "0",
39
+ allowfullscreen: "true",
40
+ },
41
+ 0,
42
+ ];
43
+ },
44
+ });
45
+ }
@@ -0,0 +1,86 @@
1
+ import { isJsonFieldValue, isObj } from "@paroicms/public-anywhere-lib";
2
+ import { makeStylesheetLinkAsyncTag } from "@paroicms/public-server-lib";
3
+ import { esmDirName, extractPackageNameAndVersionSync } from "@paroicms/script-lib";
4
+ import { dirname, join } from "node:path";
5
+ import { cleanTiptapJson } from "./clean-tiptap-json.js";
6
+ import { normalizeTypographyInTiptapJson } from "./normalize-typography-in-tiptap.js";
7
+ import { convertTiptapJsonToHtml, convertTiptapJsonToPlainText } from "./tiptap-json.js";
8
+ const projectDir = dirname(esmDirName(import.meta.url));
9
+ const packageDir = dirname(projectDir);
10
+ const { name: pluginName, version } = extractPackageNameAndVersionSync(packageDir);
11
+ const plugin = {
12
+ version,
13
+ siteInit(service) {
14
+ service.setPublicAssetsDirectory(join(packageDir, "public-assets", "dist"));
15
+ service.setAdminUiAssetsDirectory(join(packageDir, "admin-ui-plugin", "dist"));
16
+ service.registerSiteSchemaLibrary(join(packageDir, "site-schema-lib"));
17
+ service.registerRenderingHook("fieldPreprocessor", ({ service, value, options }) => {
18
+ const { fieldType } = options;
19
+ if (fieldType.pluginName !== pluginName)
20
+ return value;
21
+ if (!isJsonFieldValue(value))
22
+ return value;
23
+ if (!isObj(value.j) || value.j.type !== "doc") {
24
+ throw new Error("Invalid TiptapJsonValue");
25
+ }
26
+ const json = value.j;
27
+ if (options.outputType === "plainText") {
28
+ return convertTiptapJsonToPlainText(service, json);
29
+ }
30
+ service.setRenderState("paTiptapEditor:html", true);
31
+ return convertTiptapJsonToHtml(service, json);
32
+ });
33
+ service.registerHook("beforeSaveValue", async ({ service, value, options }) => {
34
+ const { fieldType, language } = options;
35
+ if (fieldType.pluginName !== pluginName)
36
+ return value;
37
+ if (fieldType.dataType !== "json")
38
+ return value;
39
+ if (!isJsonFieldValue(value) || !isObj(value.j) || value.j.type !== "doc") {
40
+ return value;
41
+ }
42
+ let newJson = value.j;
43
+ if (fieldType.normalizeTypography) {
44
+ newJson = await normalizeTypographyInTiptapJson(service, newJson, language);
45
+ }
46
+ newJson = cleanTiptapJson(newJson);
47
+ return { j: newJson };
48
+ });
49
+ service.registerHeadTags(({ state }) => {
50
+ if (!state.get("paTiptapEditor:html"))
51
+ return;
52
+ return [makeStylesheetLinkAsyncTag(`${service.pluginAssetsUrl}/text.css`)];
53
+ });
54
+ if (service.configuration.adminUi?.code) {
55
+ service.registerHeadTags(({ state }) => {
56
+ if (!state.get("paTiptapEditor:html"))
57
+ return;
58
+ const usedLanguages = state.get("paTiptapEditor:languages");
59
+ if (!usedLanguages || usedLanguages.length === 0)
60
+ return;
61
+ const scripts = [
62
+ makeStylesheetLinkAsyncTag("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/styles/github.min.css"),
63
+ '<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/highlight.min.js" defer></script>',
64
+ ];
65
+ for (const lang of usedLanguages) {
66
+ scripts.push(`<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/languages/${lang}.min.js" defer></script>`);
67
+ }
68
+ scripts.push(`<script>
69
+ document.addEventListener("DOMContentLoaded", () => {
70
+ hljs.highlightAll();
71
+ const list = document.querySelectorAll("pre[data-language]");
72
+ for (const el of list) {
73
+ const code = el.querySelector("code");
74
+ if (code) {
75
+ code.classList.add(\`language-\${el.dataset.language}\`);
76
+ hljs.highlightElement(code);
77
+ }
78
+ }
79
+ });
80
+ </script>`);
81
+ return scripts;
82
+ });
83
+ }
84
+ },
85
+ };
86
+ export default plugin;
@@ -0,0 +1,39 @@
1
+ import { isPromise } from "node:util/types";
2
+ // Whitelist of node types where typography normalization should be applied.
3
+ // These are standard textual nodes from StarterKit and related extensions.
4
+ // Custom nodes (media, htmlSnippet, platformVideo, internalLink) and code blocks
5
+ // are intentionally excluded to preserve their content as-is.
6
+ const TEXTUAL_NODE_TYPES = new Set([
7
+ "doc",
8
+ "paragraph",
9
+ "heading",
10
+ "blockquote",
11
+ "listItem",
12
+ "bulletList",
13
+ "orderedList",
14
+ ]);
15
+ export async function normalizeTypographyInTiptapJson(service, json, language) {
16
+ async function processNode(node) {
17
+ if (node.text !== undefined) {
18
+ const result = service.executeHook("normalizeTypography", {
19
+ value: node.text,
20
+ options: { language },
21
+ });
22
+ const normalizedValue = isPromise(result) ? await result : result;
23
+ const normalizedText = typeof normalizedValue === "string" ? normalizedValue : node.text;
24
+ return { ...node, text: normalizedText };
25
+ }
26
+ // Only recursively process children if this is a textual node type.
27
+ // This prevents normalization in codeBlock, htmlSnippet, media, platformVideo, etc.
28
+ if (node.content && TEXTUAL_NODE_TYPES.has(node.type)) {
29
+ const processedContent = await Promise.all(node.content.map(processNode));
30
+ return { ...node, content: processedContent };
31
+ }
32
+ return node;
33
+ }
34
+ if (!json.content) {
35
+ return json;
36
+ }
37
+ const processedContent = await Promise.all(json.content.map(processNode));
38
+ return { ...json, content: processedContent };
39
+ }