From ac4b62d43cbfe52b60a301e39a619bacc881de01 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:15:33 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20sync=20upstream=20=E2=80=94=20add=20to?= =?UTF-8?q?c-scanner.js=20and=20data-fetch.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New files from rmdes/indiekit-eleventy-theme: - js/toc-scanner.js: Alpine.js TOC scanner with IntersectionObserver scroll spy - lib/data-fetch.js: shared fetch helper with 10s timeout and watch-mode cache extension Co-Authored-By: Claude Sonnet 4.6 --- js/toc-scanner.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ lib/data-fetch.js | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 js/toc-scanner.js create mode 100644 lib/data-fetch.js 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); + } +}