Files
freno-dev/src/components/blog/PostBodyClient.tsx
2025-12-30 11:38:08 -05:00

355 lines
11 KiB
TypeScript

import { createEffect, createSignal, onMount } from "solid-js";
import type { HLJSApi } from "highlight.js";
import MermaidRenderer from "./MermaidRenderer";
export interface PostBodyClientProps {
body: string;
hasCodeBlock: boolean;
}
async function loadHighlightJS(): Promise<HLJSApi> {
const [
hljsModule,
javascript,
typescript,
python,
rust,
c,
cpp,
csharp,
ocaml,
lua,
swift,
bash,
css,
xml, // handles HTML
go,
glsl,
json,
markdown,
yaml,
sql,
diff,
toml
] = await Promise.all([
import("highlight.js/lib/core"),
import("highlight.js/lib/languages/javascript"),
import("highlight.js/lib/languages/typescript"),
import("highlight.js/lib/languages/python"),
import("highlight.js/lib/languages/rust"),
import("highlight.js/lib/languages/c"),
import("highlight.js/lib/languages/cpp"),
import("highlight.js/lib/languages/csharp"),
import("highlight.js/lib/languages/ocaml"),
import("highlight.js/lib/languages/lua"),
import("highlight.js/lib/languages/swift"),
import("highlight.js/lib/languages/bash"),
import("highlight.js/lib/languages/css"),
import("highlight.js/lib/languages/xml"),
import("highlight.js/lib/languages/go"),
import("highlight.js/lib/languages/glsl"),
import("highlight.js/lib/languages/json"),
import("highlight.js/lib/languages/markdown"),
import("highlight.js/lib/languages/yaml"),
import("highlight.js/lib/languages/sql"),
import("highlight.js/lib/languages/diff"),
import("highlight.js/lib/languages/ini"), // handles TOML
import("highlight.js/styles/github-dark.css")
]);
const hljs = hljsModule.default;
hljs.registerLanguage("javascript", javascript.default);
hljs.registerLanguage("typescript", typescript.default);
hljs.registerLanguage("python", python.default);
hljs.registerLanguage("rust", rust.default);
hljs.registerLanguage("c", c.default);
hljs.registerLanguage("cpp", cpp.default);
hljs.registerLanguage("csharp", csharp.default);
hljs.registerLanguage("ocaml", ocaml.default);
hljs.registerLanguage("lua", lua.default);
hljs.registerLanguage("swift", swift.default);
hljs.registerLanguage("bash", bash.default);
hljs.registerLanguage("sh", bash.default); // alias
hljs.registerLanguage("css", css.default);
hljs.registerLanguage("html", xml.default);
hljs.registerLanguage("xml", xml.default);
hljs.registerLanguage("go", go.default);
hljs.registerLanguage("glsl", glsl.default);
hljs.registerLanguage("json", json.default);
hljs.registerLanguage("markdown", markdown.default);
hljs.registerLanguage("yaml", yaml.default);
hljs.registerLanguage("yml", yaml.default); // alias
hljs.registerLanguage("sql", sql.default);
hljs.registerLanguage("diff", diff.default);
hljs.registerLanguage("toml", toml.default);
hljs.registerLanguage("js", javascript.default);
hljs.registerLanguage("ts", typescript.default);
hljs.registerLanguage("jsx", javascript.default);
hljs.registerLanguage("tsx", typescript.default);
return hljs;
}
export default function PostBodyClient(props: PostBodyClientProps) {
let contentRef: HTMLDivElement | undefined;
const [hljs, setHljs] = createSignal<HLJSApi | null>(null);
const addCopyButtons = () => {
if (!contentRef) return;
const codeBlocks = contentRef.querySelectorAll("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
if (!pre || pre.querySelector(".copy-button")) return;
// Create wrapper for positioning
pre.style.position = "relative";
// Create copy button
const copyButton = document.createElement("button");
copyButton.className =
"copy-button absolute top-2 right-2 px-3 py-1.5 text-xs font-medium rounded transition-all duration-200 z-10";
copyButton.style.cssText =
"background-color: var(--color-surface0); color: var(--color-text); border: 1px solid var(--color-overlay0);";
copyButton.textContent = "Copy";
copyButton.addEventListener("mouseenter", () => {
copyButton.style.backgroundColor = "var(--color-surface1)";
});
copyButton.addEventListener("mouseleave", () => {
if (copyButton.textContent === "Copy") {
copyButton.style.backgroundColor = "var(--color-surface0)";
}
});
copyButton.addEventListener("click", async () => {
const code = codeBlock.textContent || "";
try {
await navigator.clipboard.writeText(code);
copyButton.textContent = "Copied!";
copyButton.style.backgroundColor = "var(--color-green)";
copyButton.style.color = "var(--color-base)";
setTimeout(() => {
copyButton.textContent = "Copy";
copyButton.style.backgroundColor = "var(--color-surface0)";
copyButton.style.color = "var(--color-text)";
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
copyButton.textContent = "Failed";
copyButton.style.backgroundColor = "var(--color-red)";
setTimeout(() => {
copyButton.textContent = "Copy";
copyButton.style.backgroundColor = "var(--color-surface0)";
}, 2000);
}
});
pre.appendChild(copyButton);
});
};
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.style.cssText =
"text-decoration: none; font-size: 0.75em; vertical-align: super;";
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 headings = contentRef.querySelectorAll("h2");
let referencesSection: HTMLElement | null = null;
headings.forEach((heading) => {
if (heading.textContent?.trim() === "References") {
referencesSection = heading;
}
});
if (referencesSection) {
referencesSection.className = "text-2xl font-bold mb-4 text-text";
// Find the parent container and add styling
const parentDiv = referencesSection.parentElement;
if (parentDiv) {
// Add top border and padding
parentDiv.style.cssText =
"border-top: 1px solid var(--surface2); margin-top: 4rem; padding-top: 2rem;";
}
// Find all paragraphs after the References heading that start with [n]
let currentElement = 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}`;
// Set the ID for linking
currentElement.id = refId;
// Add styling
currentElement.className =
"reference-item transition-colors duration-500 text-sm mb-3";
currentElement.style.cssText = "scroll-margin-top: 100px;";
// Parse and style the content - get everything after [n]
let refText = text.substring(match[0].length);
// Remove any existing "↑ Back" text (including various Unicode arrow variants)
refText = refText.replace(/[↑⬆️]\s*Back\s*$/i, "").trim();
// Create styled content
currentElement.innerHTML = "";
// Add bold reference number
const refNumSpan = document.createElement("span");
refNumSpan.className = "text-blue font-semibold";
refNumSpan.textContent = `[${refNumber}]`;
currentElement.appendChild(refNumSpan);
// Add reference text
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);
}
// Add back button
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" });
// Highlight the reference link briefly
target.style.backgroundColor = "rgba(203, 166, 247, 0.2)";
setTimeout(() => {
target.style.backgroundColor = "";
}, 2000);
}
};
currentElement.appendChild(backLink);
}
}
// Check if we've reached another heading (end of references)
if (
currentElement.tagName.match(/^H[1-6]$/) &&
currentElement !== referencesSection
) {
break;
}
currentElement = currentElement.nextElementSibling;
}
}
};
// Load highlight.js only when needed
createEffect(() => {
if (props.hasCodeBlock && !hljs()) {
loadHighlightJS().then(setHljs);
}
});
// Apply syntax highlighting when hljs loads and when body changes
createEffect(() => {
const hljsInstance = hljs();
if (hljsInstance && props.hasCodeBlock && contentRef) {
setTimeout(() => {
hljsInstance.highlightAll();
addCopyButtons();
}, 100);
}
});
// Process references after content is mounted and when body changes
onMount(() => {
setTimeout(() => {
processReferences();
if (props.hasCodeBlock) {
addCopyButtons();
}
}, 150);
});
createEffect(() => {
// Re-process when body changes
if (props.body && contentRef) {
setTimeout(() => {
processReferences();
if (props.hasCodeBlock) {
addCopyButtons();
}
}, 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={props.body}
/>
<MermaidRenderer />
</div>
);
}