Spaces:
Running
Running
| (() => { | |
| // ---- Adaptive ambient quality. | |
| // A lightweight requestAnimationFrame probe samples fps in the background. | |
| // If the scene drops below target for a couple of windows, it peels back the | |
| // cheapest-to-lose space effects first (shooting stars, then star count, then | |
| // twinkle) and retires once there is nothing else to cut. ---- | |
| function createAutoPerf(opts = {}) { | |
| const prefersReducedMotion = opts.prefersReducedMotion; | |
| const actions = []; // { order, label, run, done } | |
| let started = false; | |
| // order = cut priority (lowest first); cheaper-to-lose / most-expensive-to-run | |
| // effects get the lowest numbers so they go first. | |
| function register(order, label, run) { | |
| actions.push({ order, label, run, done: false }); | |
| } | |
| function degradeOnce() { | |
| actions.sort((a, b) => a.order - b.order); | |
| const next = actions.find((a) => !a.done); | |
| if (!next) return false; | |
| next.done = true; | |
| try { | |
| next.run(); | |
| } catch (e) { | |
| /* a single failed cut shouldn't stall the rest of the ladder */ | |
| } | |
| document.documentElement.dataset.perfCuts = String(actions.filter((a) => a.done).length); | |
| return actions.some((a) => !a.done); // any cuts left? | |
| } | |
| function start(cfg = {}) { | |
| if (started) return; | |
| started = true; | |
| // A page that respects reduced-motion isn't running the heavy ambient | |
| // animation in the first place, so there's nothing to claw back — skip the | |
| // probe entirely rather than burn battery sampling. | |
| if (prefersReducedMotion) return; | |
| const THRESHOLD = cfg.threshold ?? 30; // fps floor we try to hold | |
| const SAMPLE_MS = cfg.sampleMs ?? 1000; // measurement window length | |
| const PATIENCE = cfg.patience ?? 2; // bad windows in a row before cutting | |
| const GRACE_MS = cfg.graceMs ?? 1500; // ignore initial load/layout jank | |
| const COOLDOWN = cfg.cooldownMs ?? 1200; // settle time after each cut | |
| const MAX_LIFE = cfg.maxLifeMs ?? 60000; // stop probing once smooth this long | |
| let frames = 0; | |
| let windowStart = 0; | |
| let holdUntil = 0; | |
| let bornAt = 0; | |
| let lowStreak = 0; | |
| const tick = (now) => { | |
| frames++; | |
| const elapsed = now - windowStart; | |
| if (elapsed >= SAMPLE_MS) { | |
| const fps = (frames * 1000) / elapsed; | |
| frames = 0; | |
| windowStart = now; | |
| // A window that spans a tab switch / long stall (rAF pauses while the | |
| // tab is hidden) reports a meaningless fps — never cut on it. | |
| const stalled = elapsed > SAMPLE_MS * 2.5; | |
| if (!stalled && now >= holdUntil) { | |
| if (fps < THRESHOLD) { | |
| if (++lowStreak >= PATIENCE) { | |
| lowStreak = 0; | |
| const more = degradeOnce(); | |
| holdUntil = now + COOLDOWN; // let the cut land before re-judging | |
| if (!more) return; // exhausted the ladder — let the probe die | |
| } | |
| } else { | |
| lowStreak = 0; | |
| if (now - bornAt > MAX_LIFE) return; // smooth long enough — retire | |
| } | |
| } | |
| } | |
| requestAnimationFrame(tick); | |
| }; | |
| // Only begin sampling after the grace period so first-paint jank doesn't | |
| // trip an instant downgrade. | |
| setTimeout(() => { | |
| bornAt = windowStart = holdUntil = performance.now(); | |
| requestAnimationFrame(tick); | |
| }, GRACE_MS); | |
| } | |
| return { register, start, degradeOnce, actions }; | |
| } | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const bg = document.querySelector(".background"); | |
| // ---- Detect mobile / low-power devices ---- | |
| const isMobile = window.innerWidth < 768 || | |
| /Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini/i.test(navigator.userAgent); | |
| const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| // Governs the adaptive downgrade ladder below. The space background | |
| // registers individual cuts as it builds; autoPerf.start() begins watching | |
| // the frame rate once it's up. | |
| const autoPerf = createAutoPerf({ prefersReducedMotion }); | |
| window.__autoPerf = autoPerf; // handy for debugging / manual autoPerf.degradeOnce() | |
| // ---- Members-style space background: gradient + lightweight star field ---- | |
| if (bg && !bg.querySelector(".star")) { | |
| bg.setAttribute("aria-hidden", "true"); | |
| // Cheap: absolutely-positioned dots with opacity-only twinkle. | |
| // This mirrors the Members Space background while keeping the blog fast. | |
| const starCount = isMobile ? 60 : 140; | |
| const frag = document.createDocumentFragment(); | |
| for (let i = 0; i < starCount; i++) { | |
| const s = document.createElement("span"); | |
| s.className = "star" + (Math.random() < 0.15 ? " big" : ""); | |
| s.style.left = Math.random() * 100 + "%"; | |
| s.style.top = Math.random() * 100 + "%"; | |
| s.style.setProperty("--dur", (3 + Math.random() * 5).toFixed(2) + "s"); | |
| s.style.setProperty("--delay", (-Math.random() * 6).toFixed(2) + "s"); | |
| s.style.setProperty("--min-op", (0.08 + Math.random() * 0.15).toFixed(2)); | |
| s.style.setProperty("--max-op", (0.6 + Math.random() * 0.4).toFixed(2)); | |
| frag.appendChild(s); | |
| } | |
| bg.appendChild(frag); | |
| // A few slow shooting stars, skipped on mobile / reduced motion. | |
| if (!isMobile && !prefersReducedMotion) { | |
| for (let i = 0; i < 3; i++) { | |
| const sh = document.createElement("span"); | |
| sh.className = "shooting-star"; | |
| sh.style.setProperty("--top", (5 + Math.random() * 40) + "%"); | |
| sh.style.setProperty("--delay", (i * 7 + Math.random() * 5).toFixed(2) + "s"); | |
| bg.appendChild(sh); | |
| } | |
| } | |
| autoPerf.register(10, "space: remove shooting stars", () => { | |
| bg.querySelectorAll(".shooting-star").forEach((s) => s.remove()); | |
| }); | |
| autoPerf.register(30, "space: thin stars", () => { | |
| bg.querySelectorAll(".star").forEach((s, i) => { if (i % 2 === 0) s.remove(); }); | |
| }); | |
| autoPerf.register(60, "space: freeze twinkle", () => { | |
| bg.querySelectorAll(".star").forEach((s) => (s.style.animation = "none")); | |
| }); | |
| } | |
| // Everything ambient is built and registered — start watching the frame rate. | |
| autoPerf.start(); | |
| // ---- Copy buttons on <pre> ---- | |
| document.querySelectorAll("pre").forEach((pre) => { | |
| if (pre.querySelector(".copy-btn")) return; | |
| const code = pre.querySelector("code") || pre; | |
| const btn = document.createElement("button"); | |
| btn.className = "copy-btn"; | |
| btn.textContent = "Copy"; | |
| btn.addEventListener("click", async () => { | |
| try { | |
| await navigator.clipboard.writeText(code.innerText); | |
| btn.textContent = "Copied!"; | |
| setTimeout(() => (btn.textContent = "Copy"), 1200); | |
| } catch { | |
| btn.textContent = "Error"; | |
| setTimeout(() => (btn.textContent = "Copy"), 1200); | |
| } | |
| }); | |
| pre.appendChild(btn); | |
| }); | |
| // ---- Share button: copies the canonical ?post= URL for a post ---- | |
| const SITE_BASE = "https://huggingface.co/spaces/bench-labs/blog"; | |
| const SHARE_ICON = | |
| '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + | |
| '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/>' + | |
| '<line x1="8.2" y1="10.7" x2="15.8" y2="6.3"/><line x1="8.2" y1="13.3" x2="15.8" y2="17.7"/>' + | |
| "</svg>"; | |
| const CHECK_ICON = | |
| '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">' + | |
| '<polyline points="20 6 9 17 4 12"/></svg>'; | |
| // A single share menu is open at a time; clicking elsewhere or Escape closes it. | |
| let activeShareMenu = null; | |
| function closeShareMenu() { | |
| if (activeShareMenu) { activeShareMenu.remove(); activeShareMenu = null; } | |
| } | |
| document.addEventListener("click", closeShareMenu); | |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeShareMenu(); }); | |
| window.addEventListener("resize", closeShareMenu); | |
| window.addEventListener("scroll", closeShareMenu, true); | |
| function flashCopied(btn, ok) { | |
| btn.classList.remove("copied", "error"); | |
| btn.classList.add(ok ? "copied" : "error"); | |
| if (ok) btn.innerHTML = CHECK_ICON; | |
| btn.dataset.tooltip = ok ? "Copied!" : "Couldn't copy"; | |
| setTimeout(() => { | |
| btn.classList.remove("copied", "error"); | |
| btn.innerHTML = SHARE_ICON; | |
| btn.dataset.tooltip = "Share"; | |
| }, 1400); | |
| } | |
| // Build the three shareable representations of a post. | |
| function shareFormats(filename, meta) { | |
| const url = `${SITE_BASE}?post=${filename}`; | |
| const title = (meta.title || filename).replace(/\s+/g, " ").trim(); | |
| const desc = (meta.description || "").replace(/\s+/g, " ").trim(); | |
| return [ | |
| { label: "Link", sub: "URL only", text: url }, | |
| { label: "Markdown", sub: "[title](link)", text: `[${title}](${url})` }, | |
| { label: "Markdown + description", sub: "title, link & summary", | |
| text: desc ? `[${title}](${url})\n\n${desc}` : `[${title}](${url})` }, | |
| ]; | |
| } | |
| function openShareMenu(btn, filename, getMeta) { | |
| closeShareMenu(); | |
| const menu = document.createElement("div"); | |
| menu.className = "share-menu"; | |
| menu.setAttribute("role", "menu"); | |
| const heading = document.createElement("div"); | |
| heading.className = "share-menu-title"; | |
| heading.textContent = "Copy as"; | |
| menu.appendChild(heading); | |
| shareFormats(filename, getMeta()).forEach((fmt) => { | |
| const item = document.createElement("button"); | |
| item.type = "button"; | |
| item.className = "share-menu-item"; | |
| item.setAttribute("role", "menuitem"); | |
| item.innerHTML = `<span>${fmt.label}</span><span class="sub">${fmt.sub}</span>`; | |
| item.addEventListener("click", async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| closeShareMenu(); | |
| try { | |
| await navigator.clipboard.writeText(fmt.text); | |
| flashCopied(btn, true); | |
| } catch { | |
| flashCopied(btn, false); | |
| } | |
| }); | |
| menu.appendChild(item); | |
| }); | |
| // Position under the button, right-aligned, clamped to the viewport. | |
| document.body.appendChild(menu); | |
| const r = btn.getBoundingClientRect(); | |
| const mw = menu.offsetWidth, mh = menu.offsetHeight, gap = 8, pad = 8; | |
| let left = Math.min(r.right - mw, window.innerWidth - mw - pad); | |
| left = Math.max(pad, left); | |
| let top = r.bottom + gap; | |
| if (top + mh > window.innerHeight - pad) top = Math.max(pad, r.top - gap - mh); | |
| menu.style.left = `${Math.round(left)}px`; | |
| menu.style.top = `${Math.round(top)}px`; | |
| menu.addEventListener("click", (e) => e.stopPropagation()); | |
| activeShareMenu = menu; | |
| } | |
| function makeShareBtn(filename, getMeta) { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "share-btn tooltip"; | |
| btn.setAttribute("aria-label", "Share"); | |
| btn.setAttribute("aria-haspopup", "menu"); | |
| btn.dataset.tooltip = "Share"; | |
| btn.innerHTML = SHARE_ICON; | |
| btn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (activeShareMenu) { closeShareMenu(); return; } | |
| openShareMenu(btn, filename, getMeta || (() => ({ title: filename, description: "" }))); | |
| }); | |
| return btn; | |
| } | |
| // One share button per post card on the index page | |
| document.querySelectorAll(".post-card").forEach((card) => { | |
| if (card.querySelector(".share-btn")) return; | |
| const link = card.querySelector('a[href^="posts/"]'); | |
| if (!link) return; | |
| const filename = link.getAttribute("href").split("/").pop(); | |
| card.appendChild(makeShareBtn(filename, () => ({ | |
| title: card.querySelector("h2")?.textContent || filename, | |
| description: card.querySelector("p:not(.post-card-snippet)")?.textContent || "", | |
| }))); | |
| }); | |
| // ---- Search bar (index page only): basic title/description filter, | |
| // deep search across fetched post content, and a date-sort filter popup ---- | |
| const postsSection = document.querySelector(".posts"); | |
| if (postsSection) { | |
| const form = document.getElementById("search-form"); | |
| const input = document.getElementById("search-input"); | |
| const deepBtn = document.getElementById("deep-search-btn"); | |
| const filterBtn = document.getElementById("filter-btn"); | |
| const filterPopup = document.getElementById("filter-popup"); | |
| const statusEl = document.getElementById("deep-search-status"); | |
| const cardData = Array.from(postsSection.querySelectorAll(".post-card")).map((card) => { | |
| const link = card.querySelector('a[href^="posts/"]'); | |
| return { | |
| card, | |
| file: link ? link.getAttribute("href").split("/").pop() : null, | |
| title: (card.querySelector("h2")?.textContent || "").trim(), | |
| desc: (card.querySelector("p")?.textContent || "").trim(), | |
| dateText: (card.querySelector(".date")?.textContent || "").trim(), | |
| }; | |
| }); | |
| const deepTextCache = new Map(); // filename -> plain text content of that post | |
| function clearSnippets() { | |
| cardData.forEach(({ card }) => card.querySelector(".post-card-snippet")?.remove()); | |
| } | |
| function showAll() { | |
| clearSnippets(); | |
| cardData.forEach(({ card }) => (card.style.display = "")); | |
| } | |
| function runBasicSearch(query) { | |
| const q = query.trim().toLowerCase(); | |
| clearSnippets(); | |
| if (!q) { | |
| cardData.forEach(({ card }) => (card.style.display = "")); | |
| return; | |
| } | |
| cardData.forEach(({ card, title, desc }) => { | |
| const match = title.toLowerCase().includes(q) || desc.toLowerCase().includes(q); | |
| card.style.display = match ? "" : "none"; | |
| }); | |
| } | |
| async function fetchPostText(file) { | |
| if (deepTextCache.has(file)) return deepTextCache.get(file); | |
| const res = await fetch("posts/" + file); | |
| const html = await res.text(); | |
| const doc = new DOMParser().parseFromString(html, "text/html"); | |
| const text = (doc.querySelector(".article") || doc.body).textContent.replace(/\s+/g, " ").trim(); | |
| deepTextCache.set(file, text); | |
| return text; | |
| } | |
| function escapeHtml(s) { | |
| return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| } | |
| function makeSnippet(text, q) { | |
| const idx = text.toLowerCase().indexOf(q); | |
| if (idx === -1) return null; | |
| const start = Math.max(0, idx - 50); | |
| const end = Math.min(text.length, idx + q.length + 70); | |
| let snippet = text.slice(start, end); | |
| if (start > 0) snippet = "…" + snippet; | |
| if (end < text.length) snippet += "…"; | |
| return snippet; | |
| } | |
| function highlightSnippet(snippet, q) { | |
| const idx = snippet.toLowerCase().indexOf(q.toLowerCase()); | |
| if (idx === -1) return escapeHtml(snippet); | |
| return ( | |
| escapeHtml(snippet.slice(0, idx)) + | |
| "<mark>" + escapeHtml(snippet.slice(idx, idx + q.length)) + "</mark>" + | |
| escapeHtml(snippet.slice(idx + q.length)) | |
| ); | |
| } | |
| async function runDeepSearch(query) { | |
| const q = query.trim().toLowerCase(); | |
| clearSnippets(); | |
| if (!q) { | |
| showAll(); | |
| return; | |
| } | |
| deepBtn.classList.add("is-busy"); | |
| deepBtn.disabled = true; | |
| statusEl.hidden = false; | |
| statusEl.textContent = "Searching " + cardData.length + " posts…"; | |
| try { | |
| await Promise.all( | |
| cardData.map(async ({ card, title, desc, file }) => { | |
| const titleHit = title.toLowerCase().includes(q); | |
| const descHit = desc.toLowerCase().includes(q); | |
| let snippet = null; | |
| if (file) { | |
| try { | |
| const text = await fetchPostText(file); | |
| snippet = makeSnippet(text, q); | |
| } catch { | |
| // couldn't fetch this post's content — fall back to title/description match only | |
| } | |
| } | |
| const match = titleHit || descHit || snippet; | |
| card.style.display = match ? "" : "none"; | |
| if (match && snippet) { | |
| const snippetEl = document.createElement("p"); | |
| snippetEl.className = "post-card-snippet"; | |
| snippetEl.innerHTML = "Found in post: " + highlightSnippet(snippet, q); | |
| const readLink = card.querySelector('a[href^="posts/"]'); | |
| if (readLink) readLink.insertAdjacentElement("beforebegin", snippetEl); | |
| else card.appendChild(snippetEl); | |
| } | |
| }) | |
| ); | |
| } finally { | |
| deepBtn.classList.remove("is-busy"); | |
| deepBtn.disabled = false; | |
| statusEl.hidden = true; | |
| } | |
| } | |
| form?.addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| runBasicSearch(input.value); | |
| }); | |
| deepBtn?.addEventListener("click", () => { | |
| runDeepSearch(input.value); | |
| }); | |
| // ---- Filter popup: sort by date ---- | |
| function parseCardDate(dateText) { | |
| const m = dateText.match(/^(\w+)\s+(\d{4})$/); | |
| if (!m) return null; | |
| const t = new Date(`${m[1]} 1, ${m[2]}`).getTime(); | |
| return Number.isNaN(t) ? null : t; | |
| } | |
| function applySort(direction) { | |
| const withDate = cardData.filter((c) => parseCardDate(c.dateText) !== null); | |
| const withoutDate = cardData.filter((c) => parseCardDate(c.dateText) === null); | |
| withDate.sort((a, b) => { | |
| const ta = parseCardDate(a.dateText); | |
| const tb = parseCardDate(b.dateText); | |
| return direction === "new" ? tb - ta : ta - tb; | |
| }); | |
| [...withDate, ...withoutDate].forEach(({ card }) => postsSection.appendChild(card)); | |
| } | |
| filterBtn?.addEventListener("click", () => { | |
| const opening = filterPopup.hidden; | |
| filterPopup.hidden = !opening; | |
| filterBtn.setAttribute("aria-expanded", String(opening)); | |
| }); | |
| filterPopup?.querySelectorAll(".filter-choice").forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| filterPopup.querySelectorAll(".filter-choice").forEach((b) => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| applySort(btn.dataset.sort); | |
| }); | |
| }); | |
| document.addEventListener("click", (e) => { | |
| if (filterPopup && !filterPopup.hidden && !filterPopup.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) { | |
| filterPopup.hidden = true; | |
| filterBtn.setAttribute("aria-expanded", "false"); | |
| } | |
| }); | |
| } | |
| // ---- Article-only features: sidebar + collapsible code ---- | |
| const article = document.querySelector(".article"); | |
| if (!article) return; | |
| // ---- Wrap tables so very wide ones scroll horizontally instead of | |
| // overflowing the page. The table stays width:100% (a real table, so its | |
| // columns distribute across the full container); the wrapper owns the | |
| // overflow-x scrolling. ---- | |
| article.querySelectorAll("table").forEach((table) => { | |
| if (table.parentElement.classList.contains("table-scroll")) return; | |
| const wrap = document.createElement("div"); | |
| wrap.className = "table-scroll"; | |
| table.parentNode.insertBefore(wrap, table); | |
| wrap.appendChild(table); | |
| }); | |
| // ---- Share button for the current post ---- | |
| { | |
| const filename = location.pathname.split("/").pop(); | |
| if (filename && /\.html?$/i.test(filename) && !article.querySelector(".share-btn")) { | |
| const shareBtn = makeShareBtn(filename, () => ({ | |
| title: article.querySelector("h1")?.textContent || document.title, | |
| description: article.querySelector("h1 ~ p")?.textContent | |
| || article.querySelector("p")?.textContent || "", | |
| })); | |
| shareBtn.classList.add("share-btn-article"); | |
| const back = article.querySelector(".back"); | |
| if (back) back.insertAdjacentElement("afterend", shareBtn); | |
| else article.prepend(shareBtn); | |
| } | |
| } | |
| // ---- External links: open in a new tab + hover preview card with fetched metadata ---- | |
| { | |
| const linkMetaCache = new Map(); | |
| function faviconlessTitle(href) { | |
| try { | |
| const u = new URL(href); | |
| const parts = u.pathname.split("/").filter(Boolean); | |
| if (u.hostname === "huggingface.co" && parts[0] === "spaces" && parts.length >= 3) { | |
| return `${parts[1]}/${parts[2]} · Hugging Face Space`; | |
| } | |
| if (u.hostname === "huggingface.co" && parts.length >= 2) { | |
| return `${parts[0]}/${parts[1]} · Hugging Face Hub`; | |
| } | |
| return u.hostname; | |
| } catch { | |
| return href; | |
| } | |
| } | |
| async function loadLinkMeta(href) { | |
| if (linkMetaCache.has(href)) return linkMetaCache.get(href); | |
| const metaPromise = (async () => { | |
| try { | |
| const res = await fetch(href, { mode: "cors" }); | |
| if (!res.ok) throw new Error("bad status"); | |
| const html = await res.text(); | |
| const doc = new DOMParser().parseFromString(html, "text/html"); | |
| const title = doc.querySelector("title")?.textContent?.trim() || null; | |
| const desc = | |
| doc.querySelector('meta[name="description"]')?.getAttribute("content")?.trim() || | |
| doc.querySelector('meta[property="og:description"]')?.getAttribute("content")?.trim() || | |
| null; | |
| return { title, desc }; | |
| } catch { | |
| return { title: null, desc: null }; | |
| } | |
| })(); | |
| linkMetaCache.set(href, metaPromise); | |
| return metaPromise; | |
| } | |
| article.querySelectorAll('a[href^="http"]').forEach((link) => { | |
| link.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| window.open(link.href, "_blank", "noopener,noreferrer"); | |
| }); | |
| let card = null; | |
| let hideTimer = null; | |
| link.addEventListener("mouseenter", () => { | |
| clearTimeout(hideTimer); | |
| card = document.createElement("div"); | |
| card.className = "link-preview-card"; | |
| const urlEl = document.createElement("div"); | |
| urlEl.className = "link-preview-url"; | |
| urlEl.textContent = link.href; | |
| const titleEl = document.createElement("div"); | |
| titleEl.className = "link-preview-title"; | |
| titleEl.textContent = faviconlessTitle(link.href); | |
| card.appendChild(titleEl); | |
| card.appendChild(urlEl); | |
| document.body.appendChild(card); | |
| const rect = link.getBoundingClientRect(); | |
| card.style.top = rect.bottom + window.scrollY + 8 + "px"; | |
| const maxLeft = window.scrollX + document.documentElement.clientWidth - card.offsetWidth - 12; | |
| card.style.left = Math.min(rect.left + window.scrollX, Math.max(12, maxLeft)) + "px"; | |
| requestAnimationFrame(() => card?.classList.add("visible")); | |
| loadLinkMeta(link.href).then((meta) => { | |
| if (!card || !card.isConnected) return; | |
| if (meta.title) titleEl.textContent = meta.title; | |
| if (meta.desc) { | |
| const descEl = document.createElement("div"); | |
| descEl.className = "link-preview-desc"; | |
| descEl.textContent = meta.desc; | |
| card.appendChild(descEl); | |
| } | |
| }); | |
| }); | |
| link.addEventListener("mouseleave", () => { | |
| const toRemove = card; | |
| card = null; | |
| if (!toRemove) return; | |
| toRemove.classList.remove("visible"); | |
| hideTimer = setTimeout(() => toRemove.remove(), 180); | |
| }); | |
| }); | |
| } | |
| const headings = article.querySelectorAll("h1, h2, h3"); | |
| const codeBlocks = article.querySelectorAll("pre"); | |
| const codeFiles = []; | |
| codeBlocks.forEach((pre, idx) => { | |
| let name = null; | |
| let el = pre.previousElementSibling; | |
| while (el) { | |
| if (el.tagName === "H2" || el.tagName === "H3") { | |
| const text = el.textContent.trim(); | |
| if (text.match(/\.\w{1,5}$/) || text.match(/\.\w{1,5}\s/)) { | |
| name = text.replace(/\s*\(.*\)/, "").trim(); | |
| } | |
| break; | |
| } | |
| el = el.previousElementSibling; | |
| } | |
| if (!name) name = `code-block-${idx + 1}.txt`; | |
| codeFiles.push({ name, pre }); | |
| }); | |
| // Make code blocks collapsible | |
| codeBlocks.forEach((pre, idx) => { | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "code-collapsible"; | |
| const toggle = document.createElement("button"); | |
| toggle.className = "code-toggle"; | |
| const fileName = codeFiles[idx].name; | |
| toggle.innerHTML = `<span class="code-toggle-arrow">▶</span> <span class="code-toggle-name">${fileName}</span>`; | |
| toggle.setAttribute("aria-expanded", "false"); | |
| pre.parentNode.insertBefore(wrapper, pre); | |
| wrapper.appendChild(toggle); | |
| wrapper.appendChild(pre); | |
| pre.classList.add("collapsed"); | |
| toggle.addEventListener("click", () => { | |
| const expanded = pre.classList.toggle("collapsed"); | |
| toggle.setAttribute("aria-expanded", !expanded); | |
| toggle.querySelector(".code-toggle-arrow").textContent = expanded ? "▶" : "▼"; | |
| }); | |
| }); | |
| // ---- Build sidebar ---- | |
| // MOBILE: sidebar becomes a collapsible drawer at the top | |
| const sidebar = document.createElement("nav"); | |
| sidebar.className = "article-sidebar"; | |
| if (isMobile) { | |
| sidebar.classList.add("sidebar-mobile"); | |
| const drawerToggle = document.createElement("button"); | |
| drawerToggle.className = "sidebar-drawer-toggle"; | |
| drawerToggle.textContent = "☰ Contents & Files"; | |
| drawerToggle.setAttribute("aria-expanded", "false"); | |
| const drawerBody = document.createElement("div"); | |
| drawerBody.className = "sidebar-drawer-body"; | |
| drawerBody.style.display = "none"; | |
| drawerToggle.addEventListener("click", () => { | |
| const open = drawerBody.style.display === "none"; | |
| drawerBody.style.display = open ? "block" : "none"; | |
| drawerToggle.setAttribute("aria-expanded", open); | |
| }); | |
| sidebar.appendChild(drawerToggle); | |
| sidebar.appendChild(drawerBody); | |
| // We'll append TOC / files into drawerBody instead | |
| sidebar._container = drawerBody; | |
| } else { | |
| sidebar._container = sidebar; | |
| } | |
| const container = sidebar._container; | |
| // TOC section | |
| const tocTitle = document.createElement("div"); | |
| tocTitle.className = "sidebar-title"; | |
| tocTitle.textContent = "Contents"; | |
| container.appendChild(tocTitle); | |
| const tocList = document.createElement("ul"); | |
| tocList.className = "sidebar-toc"; | |
| headings.forEach((h, i) => { | |
| if (!h.id) h.id = "heading-" + i; | |
| const li = document.createElement("li"); | |
| li.className = "toc-" + h.tagName.toLowerCase(); | |
| const a = document.createElement("a"); | |
| a.href = "#" + h.id; | |
| a.textContent = h.textContent; | |
| a.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| h.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| // MOBILE: close drawer after tap | |
| if (isMobile) { | |
| const body = sidebar.querySelector(".sidebar-drawer-body"); | |
| if (body) body.style.display = "none"; | |
| const tog = sidebar.querySelector(".sidebar-drawer-toggle"); | |
| if (tog) tog.setAttribute("aria-expanded", "false"); | |
| } | |
| }); | |
| li.appendChild(a); | |
| tocList.appendChild(li); | |
| }); | |
| container.appendChild(tocList); | |
| // Files section | |
| if (codeFiles.length > 0) { | |
| const filesTitle = document.createElement("div"); | |
| filesTitle.className = "sidebar-title"; | |
| filesTitle.textContent = "Files"; | |
| container.appendChild(filesTitle); | |
| const fileList = document.createElement("ul"); | |
| fileList.className = "sidebar-files"; | |
| codeFiles.forEach(({ name, pre }) => { | |
| const li = document.createElement("li"); | |
| const btn = document.createElement("button"); | |
| btn.className = "sidebar-file-btn"; | |
| btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> ${name}`; | |
| btn.addEventListener("click", () => { | |
| const code = pre.querySelector("code") || pre; | |
| const text = code.innerText; | |
| const blob = new Blob([text], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = name; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| li.appendChild(btn); | |
| fileList.appendChild(li); | |
| }); | |
| container.appendChild(fileList); | |
| } | |
| document.body.appendChild(sidebar); | |
| // Highlight active TOC item on scroll | |
| // MOBILE: use a throttled observer with larger margins for perf | |
| const tocLinks = tocList.querySelectorAll("a"); | |
| const observer = new IntersectionObserver( | |
| (entries) => { | |
| entries.forEach((entry) => { | |
| if (entry.isIntersecting) { | |
| tocLinks.forEach((l) => l.classList.remove("active")); | |
| const link = tocList.querySelector(`a[href="#${entry.target.id}"]`); | |
| if (link) link.classList.add("active"); | |
| } | |
| }); | |
| }, | |
| { | |
| rootMargin: isMobile ? "-60px 0px -60% 0px" : "-80px 0px -70% 0px", | |
| // MOBILE: lower threshold = fewer callbacks | |
| threshold: isMobile ? 0 : 0, | |
| } | |
| ); | |
| headings.forEach((h) => observer.observe(h)); | |
| }); | |
| })(); |