[WIKI-788] fix: editor markdown copy rules (#8140)
This commit is contained in:
parent
f510020daa
commit
d462546055
32 changed files with 1467 additions and 189 deletions
|
|
@ -28,16 +28,26 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.2.7",
|
||||
"hast": "^1.0.0",
|
||||
"hast-util-to-mdast": "^10.1.2",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mdast": "^3.0.0",
|
||||
"react": "catalog:",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-remark": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// local imports
|
||||
import { getFileURL } from "./file";
|
||||
import { getFileURL } from "../file";
|
||||
|
||||
type TEditorSrcArgs = {
|
||||
assetId: string;
|
||||
2
packages/utils/src/editor/index.ts
Normal file
2
packages/utils/src/editor/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./common";
|
||||
export * from "./markdown-parser";
|
||||
6
packages/utils/src/editor/markdown-parser/common.ts
Normal file
6
packages/utils/src/editor/markdown-parser/common.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { Text as MDASTText } from "mdast";
|
||||
|
||||
export const createTextNode = (value: string): MDASTText => ({
|
||||
type: "text",
|
||||
value,
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { Handle } from "hast-util-to-mdast";
|
||||
// local imports
|
||||
import { createTextNode } from "./common";
|
||||
import type { TCustomComponentsMetaData } from "./types";
|
||||
|
||||
type TArgs = {
|
||||
metaData: TCustomComponentsMetaData;
|
||||
};
|
||||
|
||||
export const parseCustomComponents = (args: TArgs): Record<string, Handle> => {
|
||||
const { metaData } = args;
|
||||
|
||||
const getFileAssetDetails = (id: string) => metaData.file_assets.find((asset) => asset.id === id);
|
||||
|
||||
return {
|
||||
"image-component": (_state, node) => {
|
||||
const properties = node.properties || {};
|
||||
const src = String(properties.src);
|
||||
const fileAssetDetails = getFileAssetDetails(src);
|
||||
if (!src || !fileAssetDetails) return createTextNode("");
|
||||
return createTextNode(``);
|
||||
},
|
||||
img: (_state, node) => {
|
||||
const properties = node.properties || {};
|
||||
const src = String(properties.src);
|
||||
const alt = String(properties.alt);
|
||||
if (!src || !alt) return createTextNode("");
|
||||
return createTextNode(``);
|
||||
},
|
||||
"mention-component": (_state, node) => {
|
||||
const properties = node.properties || {};
|
||||
const userId = String(properties.entity_identifier);
|
||||
const userDetails = metaData.user_mentions.find((user) => user.id === userId);
|
||||
if (!userDetails) return createTextNode("");
|
||||
return createTextNode(`[@${userDetails.display_name || "Unknown user"}](${userDetails.url || ""}) `);
|
||||
},
|
||||
...parseExtendedCustomComponents({ metaData }),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseExtendedCustomComponents = (_args: TArgs): Record<string, Handle> => ({});
|
||||
2
packages/utils/src/editor/markdown-parser/index.ts
Normal file
2
packages/utils/src/editor/markdown-parser/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./types";
|
||||
export * from "./root";
|
||||
42
packages/utils/src/editor/markdown-parser/marks-handler.ts
Normal file
42
packages/utils/src/editor/markdown-parser/marks-handler.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Handle } from "hast-util-to-mdast";
|
||||
import type { PhrasingContent, Text as MDASTText } from "mdast";
|
||||
// local imports
|
||||
import { createTextNode } from "./common";
|
||||
|
||||
const processMarkElement = (state: Parameters<Handle>[0], node: Parameters<Handle>[1], wrapper: string): MDASTText => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
// Process all children and collect their text content
|
||||
const processedChildren: PhrasingContent[] = [];
|
||||
|
||||
for (const child of node.children) {
|
||||
if (child.type === "text") {
|
||||
// Direct text child - keep as is
|
||||
processedChildren.push(child as MDASTText);
|
||||
} else if (child.type === "element") {
|
||||
// Element child - recursively process it
|
||||
const processed = state.one(child, node);
|
||||
if (processed) {
|
||||
if (Array.isArray(processed)) {
|
||||
processedChildren.push(...(processed as PhrasingContent[]));
|
||||
} else {
|
||||
processedChildren.push(processed as PhrasingContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate all text content and wrap with the specified wrapper
|
||||
const combinedText = processedChildren.map((child) => (child.type === "text" ? child.value : "")).join("");
|
||||
|
||||
return createTextNode(`${wrapper}${combinedText}${wrapper}`);
|
||||
}
|
||||
|
||||
// Empty element - return empty text
|
||||
return createTextNode("");
|
||||
};
|
||||
|
||||
export const parseMarks: Record<string, Handle> = {
|
||||
u: (state, node) => processMarkElement(state, node, ""),
|
||||
i: (state, node) => processMarkElement(state, node, "_"),
|
||||
em: (state, node) => processMarkElement(state, node, "_"),
|
||||
};
|
||||
143
packages/utils/src/editor/markdown-parser/root.ts
Normal file
143
packages/utils/src/editor/markdown-parser/root.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// - Parses TipTap/ProseMirror HTML fragments
|
||||
// - Removes <u> tags (Markdown has no underline)
|
||||
// - Adds a space after checkbox inputs for correct GFM task list rendering
|
||||
// - Converts to Markdown using rehype→remark, GFM, and remark-stringify
|
||||
|
||||
import type { Element as HASTElement, ElementContent, Parent as HASTParent } from "hast";
|
||||
import rehypeParse from "rehype-parse";
|
||||
import rehypeRemark from "rehype-remark";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkStringify from "remark-stringify";
|
||||
import { unified } from "unified";
|
||||
// local imports
|
||||
import { parseCustomComponents } from "./custom-components-handler";
|
||||
import { parseMarks } from "./marks-handler";
|
||||
import type { TCustomComponentsMetaData } from "./types";
|
||||
|
||||
// Rehype plugin to handle TipTap task lists and convert them to GFM-compatible format
|
||||
// TipTap structure: <li data-type="taskItem"><label><input><span></span></label><div><p>text</p></div></li>
|
||||
// We need: <li><input> text (with space after checkbox for proper GFM rendering)
|
||||
function addSpacesToCheckboxes() {
|
||||
return (tree: HASTParent) => {
|
||||
const helper = (node: HASTParent): void => {
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
||||
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children[i];
|
||||
|
||||
// Check if this is a task list item
|
||||
if (
|
||||
child &&
|
||||
child.type === "element" &&
|
||||
child.tagName === "li" &&
|
||||
child.properties &&
|
||||
child.properties["data-type"] === "taskItem"
|
||||
) {
|
||||
const liElement = child as HASTElement;
|
||||
|
||||
// Find the label and div elements
|
||||
const label = liElement.children?.find(
|
||||
(c) => c.type === "element" && (c as HASTElement).tagName === "label"
|
||||
) as HASTElement | undefined;
|
||||
|
||||
const contentDiv = liElement.children?.find(
|
||||
(c) => c.type === "element" && (c as HASTElement).tagName === "div"
|
||||
) as HASTElement | undefined;
|
||||
|
||||
if (label && contentDiv) {
|
||||
// Find the checkbox input
|
||||
const checkbox = label.children?.find(
|
||||
(c) =>
|
||||
c.type === "element" &&
|
||||
(c as HASTElement).tagName === "input" &&
|
||||
(c as HASTElement).properties?.type === "checkbox"
|
||||
) as HASTElement | undefined;
|
||||
|
||||
if (checkbox) {
|
||||
// Extract text content from the div, unwrapping any paragraph tags
|
||||
const textContent: ElementContent[] = [];
|
||||
if (contentDiv.children) {
|
||||
for (const child of contentDiv.children) {
|
||||
if (child.type === "element" && (child as HASTElement).tagName === "p") {
|
||||
// Unwrap paragraph - add its children directly
|
||||
const pElement = child as HASTElement;
|
||||
if (pElement.children) {
|
||||
textContent.push(...pElement.children);
|
||||
}
|
||||
} else {
|
||||
// Keep other elements as-is
|
||||
textContent.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the structure: move checkbox and content to be direct children of li
|
||||
liElement.children = [
|
||||
checkbox,
|
||||
{ type: "text", value: " " }, // Add space after checkbox
|
||||
...textContent,
|
||||
];
|
||||
}
|
||||
}
|
||||
} else if (child && child.type === "element") {
|
||||
helper(child as HASTElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
helper(tree);
|
||||
};
|
||||
}
|
||||
|
||||
type TArgs = {
|
||||
description_html: string;
|
||||
metaData: TCustomComponentsMetaData;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a string by escaping HTML entities to prevent XSS attacks
|
||||
* @param str - The string to sanitize
|
||||
* @returns The sanitized string with escaped HTML entities
|
||||
*/
|
||||
function sanitizeHTML(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function convertHTMLToMarkdown(args: TArgs): string {
|
||||
const { description_html, metaData, name } = args;
|
||||
|
||||
let updatedDescriptionHtml = description_html;
|
||||
if (name) {
|
||||
const sanitizedName = sanitizeHTML(name);
|
||||
updatedDescriptionHtml = `<h1>${sanitizedName}</h1>\n\n${description_html}`;
|
||||
}
|
||||
|
||||
const result = unified()
|
||||
.use(rehypeParse, { fragment: true })
|
||||
.use(addSpacesToCheckboxes)
|
||||
.use(rehypeRemark, {
|
||||
handlers: {
|
||||
...parseCustomComponents({
|
||||
metaData,
|
||||
}),
|
||||
...parseMarks,
|
||||
},
|
||||
})
|
||||
.use(remarkGfm)
|
||||
.use(remarkStringify, {
|
||||
handlers: {
|
||||
text: (node: { value: string }): string => node.value,
|
||||
},
|
||||
})
|
||||
.processSync(updatedDescriptionHtml);
|
||||
|
||||
const markdown = String(result.value ?? result);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
16
packages/utils/src/editor/markdown-parser/types.ts
Normal file
16
packages/utils/src/editor/markdown-parser/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type TCoreCustomComponentsMetaData = {
|
||||
file_assets: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}[];
|
||||
user_mentions: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
url: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TExtendedCustomComponentsMetaData = unknown;
|
||||
|
||||
export type TCustomComponentsMetaData = TCoreCustomComponentsMetaData & TExtendedCustomComponentsMetaData;
|
||||
Loading…
Add table
Add a link
Reference in a new issue