453 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|