Files
freno-dev/src/components/blog/extensions/Mermaid.ts
Michael Freno 898c891bd5 chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars,
cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and
12 unused dependencies removed
Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose
blocks tightened; dead commented-out config removed
Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10);
login formHandler split into 3 submitters (CCN 63->~5); account page render
split into 8 section components (CCN 40); updatePost SQL builder rebuilt;
assert*Owned consolidated behind generic assertOwnedBy
Defensive guards: 4 redundant rethrow/nullish guards removed
2026-08-11 13:36:18 -04:00

307 lines
8.8 KiB
TypeScript

import { Node, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const Mermaid = Node.create({
name: "mermaid",
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
content: {
default: "",
parseHTML: (element) => {
const code = element.querySelector("code");
if (code) {
// Get text content, which strips out all HTML tags (including spans from syntax highlighting)
return code.textContent || "";
}
return element.textContent || "";
},
renderHTML: (attributes) => {
return {};
}
},
language: {
default: "mermaid",
parseHTML: (element) => element.getAttribute("data-language"),
renderHTML: (attributes) => {
return {
"data-language": attributes.language
};
}
}
};
},
parseHTML() {
return [
// Priority 1: Pre with explicit mermaid marker (from our own rendering)
{
tag: 'pre[data-mermaid-diagram="true"]',
priority: 100
},
// Priority 2: Pre with data-type="mermaid"
{
tag: 'pre[data-type="mermaid"]',
priority: 90
},
// Priority 3: Wrapper div (from NodeView)
{
tag: "div.mermaid-node-wrapper",
priority: 80,
getAttrs: (element) => {
if (typeof element === "string") return false;
const pre = element.querySelector('pre[data-type="mermaid"]');
if (!pre) return false;
const code = pre.querySelector("code");
return {
content: code?.textContent || ""
};
}
},
// Priority 4: Generic pre blocks that look like mermaid (fallback for legacy content)
{
tag: "pre",
priority: 51, // Higher than code block extension
getAttrs: (element) => {
if (typeof element === "string") return false;
if (
element.hasAttribute("data-type") ||
element.hasAttribute("data-mermaid-diagram")
) {
return false;
}
const code = element.querySelector("code");
if (!code) return false;
const content = code.textContent || "";
const trimmedContent = content.trim();
const mermaidKeywords = [
"graph ",
"sequenceDiagram",
"classDiagram",
"stateDiagram",
"erDiagram",
"gantt",
"pie ",
"journey",
"gitGraph",
"flowchart ",
"mindmap",
"timeline",
"quadrantChart",
"requirementDiagram",
"C4Context"
];
const isMermaid = mermaidKeywords.some((keyword) =>
trimmedContent.startsWith(keyword)
);
if (isMermaid) {
return {
content: trimmedContent
};
}
return false;
}
}
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"pre",
mergeAttributes(HTMLAttributes, {
"data-type": "mermaid",
"data-mermaid-diagram": "true", // Clear marker for parsing from DB
class: "mermaid-diagram"
}),
["code", {}, node.attrs.content || ""]
];
},
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: { content }
});
},
updateMermaid:
(content: string) =>
({ tr, state, dispatch }) => {
const { selection } = state;
const node = selection.$anchor.parent;
if (node.type.name === this.name) {
if (dispatch) {
tr.setNodeMarkup(selection.$anchor.before(), undefined, {
...node.attrs,
content
});
}
return true;
}
return false;
}
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const dom = document.createElement("div");
dom.className = "mermaid-node-wrapper relative group";
const pre = document.createElement("pre");
pre.setAttribute("data-type", "mermaid");
pre.setAttribute("data-mermaid-diagram", "true"); // Clear marker
pre.className = "mermaid-diagram";
const code = document.createElement("code");
code.textContent = node.attrs.content || "";
pre.appendChild(code);
const statusIndicator = document.createElement("div");
statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
const validateSyntax = async () => {
const content = node.attrs.content || "";
if (!content.trim()) {
statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
return;
}
try {
const mermaid = (await import("mermaid")).default;
await mermaid.parse(content);
statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full bg-green opacity-0 group-hover:opacity-100 transition-opacity duration-200";
statusIndicator.title = "Valid mermaid syntax";
} catch (err) {
statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full bg-red opacity-0 group-hover:opacity-100 transition-opacity duration-200";
statusIndicator.title = `Invalid syntax: ${
(err as any).message || "Parse error"
}`;
}
};
validateSyntax();
// Edit button overlay - visible on mobile tap/selection, hover on desktop
const editBtn = document.createElement("button");
editBtn.className =
"absolute top-2 right-2 bg-blue text-white px-3 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 touch-manipulation";
editBtn.textContent = "Edit Diagram";
editBtn.contentEditable = "false";
const openEditor = () => {
const pos = typeof getPos === "function" ? getPos() : 0;
const event = new CustomEvent("edit-mermaid", {
detail: { content: node.attrs.content, pos }
});
editor.view.dom.dispatchEvent(event);
};
editBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openEditor();
});
// Mobile support: Show button when node is selected (tapped)
let isSelected = false;
const updateButtonVisibility = () => {
if (typeof getPos !== "function") return;
const pos = getPos();
const { from, to } = editor.state.selection;
const nodeIsSelected = from === pos && to === pos + node.nodeSize;
if (nodeIsSelected !== isSelected) {
isSelected = nodeIsSelected;
if (isSelected) {
editBtn.style.opacity = "1";
} else {
editBtn.style.opacity = "";
}
}
};
const plugin = editor.view.state.plugins.find(
(p: any) => p.spec?.key === "mermaidSelection"
);
let updateInterval: ReturnType<typeof setInterval> | null = null;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
updateInterval = setInterval(updateButtonVisibility, 100);
} else {
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
}
});
},
{ threshold: 0.01 }
);
observer.observe(dom);
dom.addEventListener("touchstart", () => {
setTimeout(updateButtonVisibility, 50);
});
dom.appendChild(pre);
dom.appendChild(statusIndicator);
dom.appendChild(editBtn);
return {
dom,
contentDOM: undefined,
update: (updatedNode) => {
if (updatedNode.type.name !== this.name) {
return false;
}
code.textContent = updatedNode.attrs.content || "";
validateSyntax();
updateButtonVisibility();
return true;
},
destroy: () => {
if (updateInterval) {
clearInterval(updateInterval);
}
observer.disconnect();
}
};
};
}
});
declare module "@tiptap/core" {
interface Commands<ReturnType> {
mermaid: {
setMermaid: (content: string) => ReturnType;
updateMermaid: (content: string) => ReturnType;
};
}
}