diff --git a/js/toc-scanner.js b/js/toc-scanner.js new file mode 100644 index 0000000..5bc11f0 --- /dev/null +++ b/js/toc-scanner.js @@ -0,0 +1,51 @@ +/** + * Alpine.js TOC scanner component. + * Scans .e-content for h2/h3/h4 headings with IDs, + * builds a table of contents, and highlights the + * current section via IntersectionObserver scroll spy. + */ +document.addEventListener("alpine:init", () => { + Alpine.data("tocScanner", () => ({ + items: [], + _observer: null, + + init() { + const content = document.querySelector(".e-content"); + if (!content) { this._hideWrapper(); return; } + + const headings = content.querySelectorAll("h2[id], h3[id], h4[id]"); + if (headings.length < 3) { this._hideWrapper(); return; } + + this.items = Array.from(headings).map((h) => ({ + id: h.id, + text: h.textContent.replace(/^#\s*/, "").trim(), + level: parseInt(h.tagName[1]), + active: false, + })); + + this._observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + this.items.forEach((item) => { + item.active = item.id === entry.target.id; + }); + } + } + }, + { rootMargin: "0px 0px -70% 0px" }, + ); + + headings.forEach((h) => this._observer.observe(h)); + }, + + _hideWrapper() { + const wrapper = this.$root.closest(".widget-collapsible"); + if (wrapper) wrapper.style.display = "none"; + }, + + destroy() { + if (this._observer) this._observer.disconnect(); + }, + })); +}); diff --git a/lib/data-fetch.js b/lib/data-fetch.js new file mode 100644 index 0000000..22bd6cb --- /dev/null +++ b/lib/data-fetch.js @@ -0,0 +1,54 @@ +/** + * Shared data-fetching helper for _data files. + * + * Wraps @11ty/eleventy-fetch with two protections: + * 1. Hard timeout — 10-second AbortController ceiling on every request + * 2. Watch-mode cache extension — uses "4h" TTL during watch/serve, + * keeping the original (shorter) TTL only for production builds + * + * Usage: + * import { cachedFetch } from "../lib/data-fetch.js"; + * const data = await cachedFetch(url, { duration: "15m", type: "json" }); + */ + +import EleventyFetch from "@11ty/eleventy-fetch"; + +const FETCH_TIMEOUT_MS = 10_000; // 10 seconds + +// In watch/serve mode, extend cache to avoid re-fetching on every rebuild. +// Production builds use the caller's original TTL for fresh data. +const isWatchMode = process.env.ELEVENTY_RUN_MODE !== "build"; +const WATCH_MODE_DURATION = "4h"; + +/** + * Fetch with timeout and watch-mode cache extension. + * + * @param {string} url - URL to fetch + * @param {object} options - EleventyFetch options (duration, type, fetchOptions, etc.) + * @returns {Promise} Parsed response + */ +export async function cachedFetch(url, options = {}) { + // Extend cache in watch mode + const duration = isWatchMode ? WATCH_MODE_DURATION : (options.duration || "15m"); + + // Create abort controller for hard timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const fetchOptions = { + ...options.fetchOptions, + signal: controller.signal, + }; + + const result = await EleventyFetch(url, { + ...options, + duration, + fetchOptions, + }); + + return result; + } finally { + clearTimeout(timeoutId); + } +}