Files
freno-dev/src/components/blog/PostBodyClient.tsx
2026-05-28 20:22:30 -04:00

453 lines
14 KiB
TypeScript

import { createEffect, createSignal, onMount, lazy, Show } from "solid-js";
import type { HLJSApi } from "highlight.js";
const MermaidRenderer = lazy(() => import("./MermaidRenderer"));
/**
* Sanitize HTML content to prevent XSS when rendering user-generated blog content.
* Removes dangerous elements (script, iframe, object, etc.) and event handlers.
*/
function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Remove dangerous elements
doc
.querySelectorAll(
"script, iframe, object, embed, form, link, meta, base, svg script"
)
.forEach((el) => el.remove());
// Remove event handler attributes and dangerous URLs from all elements
doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => {
const attrs = Array.from(el.attributes);
attrs.forEach((attr) => {
const name = attr.name;
const value = attr.value;
if (
name.startsWith("on") ||
(name === "href" &&
(value.startsWith("javascript:") ||
value.startsWith("data:text/html"))) ||
(name === "style" &&
(value.includes("expression(") ||
value.includes("url(") ||
value.includes("javascript:"))) ||
(name === "action" && value.startsWith("javascript:"))
) {
el.removeAttribute(name);
}
});
});
return doc.body.innerHTML;
}
export interface PostBodyClientProps {
body: string;
hasCodeBlock: boolean;
hasMermaid: boolean;
}
async function loadHighlightJS(): Promise<HLJSApi> {
const hljs = (await import("~/lib/highlight-bundle")).default;
return hljs;
}
export default function PostBodyClient(props: PostBodyClientProps) {
let contentRef: HTMLDivElement | undefined;
const [hljs, setHljs] = createSignal<HLJSApi | null>(null);
const processCodeBlocks = () => {
if (!contentRef) return;
const codeBlocks = contentRef.querySelectorAll<HTMLElement>("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.type === "mermaid") return;
const existingHeader = pre.previousElementSibling;
if (
existingHeader?.classList.contains("language-header") &&
existingHeader.querySelector(".copy-button")
) {
return;
}
pre.style.backgroundColor = "#1a1a1a";
const classes = Array.from(codeBlock.classList);
const languageClass = classes.find((cls) => cls.startsWith("language-"));
const language = languageClass?.replace("language-", "") || "";
if (language) {
const languageHeader = document.createElement("div");
languageHeader.className = "language-header";
languageHeader.style.backgroundColor = "#1a1a1a";
const languageLabel = document.createElement("span");
languageLabel.textContent = language;
languageHeader.appendChild(languageLabel);
const copyButton = document.createElement("button");
copyButton.className = "copy-button";
copyButton.textContent = "Copy";
copyButton.dataset.codeBlock = "true";
copyButton.dataset.codeBlockId = `code-${Math.random().toString(36).substr(2, 9)}`;
codeBlock.dataset.codeBlockId = copyButton.dataset.codeBlockId;
languageHeader.appendChild(copyButton);
pre.parentElement?.insertBefore(languageHeader, pre);
}
const codeText = codeBlock.textContent || "";
const lines = codeText.split("\n");
const lineCount =
lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
if (lineCount > 0 && !pre.querySelector(".line-numbers")) {
const lineNumbers = document.createElement("div");
lineNumbers.className = "line-numbers";
for (let i = 1; i <= lineCount; i++) {
const lineNum = document.createElement("div");
lineNum.textContent = i.toString();
lineNumbers.appendChild(lineNum);
}
pre.appendChild(lineNumbers);
}
});
};
const processVideos = () => {
if (!contentRef) return;
// Handle direct video elements
const videoElements = contentRef.querySelectorAll("video");
videoElements.forEach((video) => {
// Ensure videos play inline and don't trigger downloads
video.setAttribute("playsinline", "");
video.setAttribute("controls", "");
// Remove download attribute if present
video.removeAttribute("download");
// Ensure proper MIME types on source elements
const sources = video.querySelectorAll("source");
sources.forEach((source) => {
const src = source.getAttribute("src");
if (src) {
// Remove download attribute from sources
source.removeAttribute("download");
// Set correct type attribute if missing
if (!source.hasAttribute("type")) {
if (src.endsWith(".mp4")) {
source.setAttribute("type", "video/mp4");
} else if (src.endsWith(".webm")) {
source.setAttribute("type", "video/webm");
} else if (src.endsWith(".ogg")) {
source.setAttribute("type", "video/ogg");
}
}
}
});
// If video has direct src attribute, ensure type is set
const videoSrc = video.getAttribute("src");
if (videoSrc && !video.hasAttribute("type")) {
if (videoSrc.endsWith(".mp4")) {
video.setAttribute("type", "video/mp4");
} else if (videoSrc.endsWith(".webm")) {
video.setAttribute("type", "video/webm");
} else if (videoSrc.endsWith(".ogg")) {
video.setAttribute("type", "video/ogg");
}
}
});
// Handle iframes with video sources - replace with proper video tags
const iframes = contentRef.querySelectorAll("iframe");
iframes.forEach((iframe) => {
const src = iframe.getAttribute("src");
if (
src &&
(src.endsWith(".mp4") ||
src.endsWith(".mov") ||
src.endsWith(".webm") ||
src.endsWith(".ogg"))
) {
// Create a proper video element
const video = document.createElement("video");
video.setAttribute("controls", "");
video.setAttribute("playsinline", "");
video.setAttribute("preload", "metadata");
video.style.maxWidth = "100%";
video.style.height = "auto";
// Set appropriate type based on file extension
let videoType = "video/mp4";
if (src.endsWith(".mov")) {
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4
} else if (src.endsWith(".webm")) {
videoType = "video/webm";
} else if (src.endsWith(".ogg")) {
videoType = "video/ogg";
}
video.setAttribute("type", videoType);
video.src = src;
// Replace the iframe with the video element
const parent = iframe.parentElement;
if (parent) {
parent.replaceChild(video, iframe);
}
}
});
// Also check for any anchor tags wrapping videos that might have download attribute
const videoLinks = contentRef.querySelectorAll("a");
videoLinks.forEach((link) => {
const hasVideo = link.querySelector("video");
if (hasVideo) {
link.removeAttribute("download");
}
});
};
const processReferences = () => {
if (!contentRef) return;
const supElements = contentRef.querySelectorAll("sup");
supElements.forEach((sup) => {
const text = sup.textContent?.trim() || "";
const match = text.match(/^\[(.+?)\]$/);
if (match) {
const refNumber = match[1];
const refId = `ref-${refNumber}`;
const refBackId = `ref-${refNumber}-back`;
sup.id = refBackId;
sup.innerHTML = "";
const link = document.createElement("a");
link.href = `#${refId}`;
link.textContent = `[${refNumber}]`;
link.className =
"reference-link text-blue hover:text-sky no-underline cursor-pointer";
link.onclick = (e) => {
e.preventDefault();
const target = document.getElementById(refId);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.style.backgroundColor = "rgba(137, 180, 250, 0.2)";
setTimeout(() => {
target.style.backgroundColor = "";
}, 2000);
}
};
sup.appendChild(link);
}
});
const marker = contentRef.querySelector(
"span[id='references-section-start']"
) as HTMLElement | null;
const referencesHeadingText =
marker?.getAttribute("data-heading") || "References";
const headings = contentRef.querySelectorAll<HTMLElement>("h2");
let referencesSection: HTMLElement | null = null;
for (const heading of headings) {
if (heading.textContent?.trim() === referencesHeadingText) {
referencesSection = heading;
break;
}
}
if (referencesSection) {
referencesSection.className = "text-2xl font-bold mb-4 text-text";
const parentDiv = referencesSection.parentElement;
if (parentDiv) {
parentDiv.classList.add("references-heading");
}
let currentElement: Element | null = referencesSection.nextElementSibling;
while (currentElement) {
if (currentElement.tagName === "P") {
const text = currentElement.textContent?.trim() || "";
const match = text.match(/^\[(.+?)\]\s*/);
if (match) {
const refNumber = match[1];
const refId = `ref-${refNumber}`;
currentElement.id = refId;
currentElement.className =
"reference-item transition-colors duration-500 text-sm mb-3";
let refText = text.substring(match[0].length);
refText = refText.replace(/[↑⬆️]\s*Back\s*$/i, "").trim();
currentElement.innerHTML = "";
const refNumSpan = document.createElement("span");
refNumSpan.className = "text-blue font-semibold";
refNumSpan.textContent = `[${refNumber}]`;
currentElement.appendChild(refNumSpan);
if (refText) {
const refTextSpan = document.createElement("span");
refTextSpan.className = "ml-2";
refTextSpan.textContent = refText;
currentElement.appendChild(refTextSpan);
} else {
const refTextSpan = document.createElement("span");
refTextSpan.className = "ml-2 text-subtext0 italic";
refTextSpan.textContent = "Add your reference text here";
currentElement.appendChild(refTextSpan);
}
const backLink = document.createElement("a");
backLink.href = `#ref-${refNumber}-back`;
backLink.className =
"text-mauve hover:text-pink ml-2 text-xs cursor-pointer";
backLink.textContent = "↑ Back";
backLink.onclick = (e) => {
e.preventDefault();
const target = document.getElementById(`ref-${refNumber}-back`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.style.backgroundColor = "rgba(203, 166, 247, 0.2)";
setTimeout(() => {
target.style.backgroundColor = "";
}, 2000);
}
};
currentElement.appendChild(backLink);
}
}
if (
currentElement.tagName.match(/^H[1-6]$/) &&
currentElement !== referencesSection
) {
break;
}
currentElement = currentElement.nextElementSibling;
}
}
};
createEffect(() => {
if (props.hasCodeBlock && !hljs()) {
loadHighlightJS().then(setHljs);
}
});
createEffect(() => {
const hljsInstance = hljs();
if (hljsInstance && props.hasCodeBlock && contentRef) {
setTimeout(() => {
hljsInstance.highlightAll();
processCodeBlocks();
}, 100);
}
});
onMount(() => {
setTimeout(() => {
processVideos();
processReferences();
if (props.hasCodeBlock) {
processCodeBlocks();
}
}, 150);
if (contentRef) {
const handleCopyButtonInteraction = async (e: Event) => {
const target = e.target as HTMLElement;
if (e.type === "click" && target.classList.contains("copy-button")) {
const codeBlockId = target.dataset.codeBlockId;
const codeBlock = codeBlockId
? contentRef?.querySelector(
`code[data-code-block-id="${codeBlockId}"]`
)
: null;
if (!codeBlock) return;
const code = codeBlock.textContent || "";
try {
await navigator.clipboard.writeText(code);
target.textContent = "Copied!";
target.classList.add("copied");
setTimeout(() => {
target.textContent = "Copy";
target.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
target.textContent = "Failed";
target.classList.add("failed");
setTimeout(() => {
target.textContent = "Copy";
target.classList.remove("failed");
}, 2000);
}
}
};
contentRef.addEventListener("click", handleCopyButtonInteraction);
}
});
createEffect(() => {
if (props.body && contentRef) {
setTimeout(() => {
processVideos();
processReferences();
if (props.hasCodeBlock) {
processCodeBlocks();
}
}, 150);
}
});
return (
<div class="mx-auto max-w-4xl px-4">
<div
id="post-content-body"
ref={contentRef}
class="text-text prose dark:prose-invert max-w-none"
innerHTML={sanitizeHtml(props.body)}
/>
<Show when={props.hasMermaid}>
<MermaidRenderer />
</Show>
</div>
);
}