149 lines
6.1 KiB
TypeScript
149 lines
6.1 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
|
||
/**
|
||
* Inline loader for a static app (SVGnest) placed under /public/svgnest.
|
||
* It fetches index.html, injects the HTML into a container, then
|
||
* re-executes <script> tags (both external and inline) in order.
|
||
* CSS <link> files are fetched and inlined into <style> tags to avoid path issues.
|
||
*
|
||
* NOTE: This runs in the light DOM (not shadow). That maximizes compatibility
|
||
* with older scripts that rely on document-level selectors. If we see style bleed,
|
||
* we can optionally switch to a Shadow DOM variant later.
|
||
*/
|
||
export default function SVGNestPanel() {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
async function load() {
|
||
try {
|
||
setLoading(true);
|
||
// 1) Fetch index.html
|
||
const htmlRes = await fetch("/svgnest/index.html", { cache: "no-cache" });
|
||
if (!htmlRes.ok) throw new Error(`Failed to fetch index.html (HTTP ${htmlRes.status})`);
|
||
const htmlText = await htmlRes.text();
|
||
|
||
if (cancelled) return;
|
||
|
||
// 2) Parse
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(htmlText, "text/html");
|
||
|
||
// 3) Find and inline CSS (convert <link rel="stylesheet"> to <style>)
|
||
const linkEls = Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]'));
|
||
for (const link of linkEls) {
|
||
const href = link.getAttribute("href") || "";
|
||
const absHref = absolutizeAsset(href);
|
||
try {
|
||
const cssRes = await fetch(absHref, { cache: "no-cache" });
|
||
if (cssRes.ok) {
|
||
const cssText = await cssRes.text();
|
||
const style = doc.createElement("style");
|
||
style.setAttribute("data-inlined-from", href);
|
||
style.textContent = cssText;
|
||
link.replaceWith(style);
|
||
} else {
|
||
// leave the original link as a fallback
|
||
}
|
||
} catch {
|
||
// leave the original link as a fallback
|
||
}
|
||
}
|
||
|
||
// 4) Extract scripts (to re-execute later) & remove them from doc body
|
||
const scriptInfos: { src?: string; inline?: string; type?: string; nomodule?: boolean; async?: boolean; defer?: boolean }[] = [];
|
||
doc.querySelectorAll("script").forEach((s) => {
|
||
scriptInfos.push({
|
||
src: s.getAttribute("src") || undefined,
|
||
inline: s.textContent || undefined,
|
||
type: s.getAttribute("type") || undefined,
|
||
nomodule: s.hasAttribute("nomodule"),
|
||
async: s.hasAttribute("async"),
|
||
defer: s.hasAttribute("defer"),
|
||
});
|
||
s.remove(); // prevent duplicate execution when we inject innerHTML
|
||
});
|
||
|
||
// 5) Inject BODY HTML into our container
|
||
const host = containerRef.current!;
|
||
host.innerHTML = doc.body.innerHTML;
|
||
|
||
// 6) Sequentially (re)execute scripts in original order
|
||
// We do this after body is in place so DOM targets exist.
|
||
for (const info of scriptInfos) {
|
||
await runScript(host, info);
|
||
}
|
||
} catch (e: any) {
|
||
if (!cancelled) setError(e?.message || String(e));
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
}
|
||
|
||
load();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
return (
|
||
<div className="w-full">
|
||
{error && (
|
||
<div className="mb-3 rounded border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
|
||
Couldn’t load SVGnest: {error}
|
||
</div>
|
||
)}
|
||
{loading && (
|
||
<div className="mb-3 text-sm text-zinc-400">Loading SVGnest…</div>
|
||
)}
|
||
<div ref={containerRef} className="w-full min-h-[70vh]" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Convert a (possibly relative) asset path from /svgnest/index.html into an absolute path */
|
||
function absolutizeAsset(path: string): string {
|
||
// Ignore full URLs
|
||
try {
|
||
const u = new URL(path);
|
||
return u.toString();
|
||
} catch {
|
||
// not a full URL
|
||
}
|
||
|
||
// Handle protocol-relative //host/path -> treat as absolute
|
||
if (path.startsWith("//")) return window.location.protocol + path;
|
||
|
||
// If already absolute (/x/y), leave as-is
|
||
if (path.startsWith("/")) return path;
|
||
|
||
// Otherwise treat as relative to /svgnest/
|
||
return `/svgnest/${path.replace(/^\.?\//, "")}`;
|
||
}
|
||
|
||
/** Append a new <script> to the container, respecting order. Supports src and inline. */
|
||
function runScript(container: HTMLElement, info: { src?: string; inline?: string; type?: string; nomodule?: boolean; async?: boolean; defer?: boolean; }): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const s = document.createElement("script");
|
||
if (info.type) s.type = info.type;
|
||
if (info.nomodule) s.noModule = true;
|
||
// Preserve async/defer semantics if they existed
|
||
if (info.async) s.async = true;
|
||
if (info.defer) s.defer = true;
|
||
|
||
if (info.src) {
|
||
s.src = absolutizeAsset(info.src);
|
||
s.onload = () => resolve();
|
||
s.onerror = () => reject(new Error(`Failed to load script: ${s.src}`));
|
||
container.appendChild(s);
|
||
} else {
|
||
// Inline script: execute synchronously
|
||
s.textContent = info.inline || "";
|
||
container.appendChild(s);
|
||
resolve();
|
||
}
|
||
});
|
||
}
|