diff --git a/lib/unfurl-shortcode.js b/lib/unfurl-shortcode.js index 0f34739..9173fec 100644 --- a/lib/unfurl-shortcode.js +++ b/lib/unfurl-shortcode.js @@ -5,6 +5,29 @@ import { createHash } from "crypto"; const CACHE_DIR = resolve(import.meta.dirname, "..", ".cache", "unfurl"); const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 1 week +const FAILURE_CACHE_MS = 24 * 60 * 60 * 1000; // 1 day for failed fetches + +// Concurrency limiter — prevents overwhelming outbound network +let activeRequests = 0; +const MAX_CONCURRENT = 5; +const queue = []; + +function runNext() { + if (queue.length === 0 || activeRequests >= MAX_CONCURRENT) return; + activeRequests++; + const { resolve: res, fn } = queue.shift(); + fn().then(res).finally(() => { + activeRequests--; + runNext(); + }); +} + +function throttled(fn) { + return new Promise((res) => { + queue.push({ resolve: res, fn }); + runNext(); + }); +} function getCachePath(url) { const hash = createHash("md5").update(url).digest("hex"); @@ -13,22 +36,24 @@ function getCachePath(url) { function readCache(url) { const path = getCachePath(url); - if (!existsSync(path)) return null; + if (!existsSync(path)) return undefined; // undefined = not cached try { const data = JSON.parse(readFileSync(path, "utf-8")); - if (Date.now() - data.cachedAt < CACHE_DURATION_MS) { - return data.metadata; + const age = Date.now() - data.cachedAt; + const ttl = data.failed ? FAILURE_CACHE_MS : CACHE_DURATION_MS; + if (age < ttl) { + return data; // return full cache entry (includes .failed flag) } } catch { // Corrupt cache file, ignore } - return null; + return undefined; } -function writeCache(url, metadata) { +function writeCache(url, metadata, failed = false) { mkdirSync(CACHE_DIR, { recursive: true }); const path = getCachePath(url); - writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata })); + writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata, failed })); } function extractDomain(url) { @@ -48,59 +73,38 @@ function escapeHtml(str) { .replace(/"/g, """); } -/** - * Register the {% unfurl "URL" %} shortcode on an Eleventy config. - */ -export default function registerUnfurlShortcode(eleventyConfig) { - eleventyConfig.addAsyncShortcode("unfurl", async function (url) { - if (!url) return ""; +function renderFallbackLink(url) { + const domain = escapeHtml(extractDomain(url)); + return `${domain}`; +} - // Check cache first - let metadata = readCache(url); +function renderCard(url, metadata) { + const og = metadata.open_graph || {}; + const tc = metadata.twitter_card || {}; - if (!metadata) { - try { - metadata = await unfurl(url, { timeout: 10000 }); - writeCache(url, metadata); - } catch (err) { - console.warn(`[unfurl] Failed to fetch ${url}: ${err.message}`); - // Fallback: plain link - const domain = escapeHtml(extractDomain(url)); - return `${domain}`; - } - } + const title = og.title || tc.title || metadata.title || extractDomain(url); + const description = og.description || tc.description || metadata.description || ""; + const image = og.images?.[0]?.url || tc.images?.[0]?.url || null; + const favicon = metadata.favicon || null; + const domain = extractDomain(url); - const og = metadata.open_graph || {}; - const tc = metadata.twitter_card || {}; + const maxDesc = 160; + const desc = description.length > maxDesc + ? description.slice(0, maxDesc).trim() + "\u2026" + : description; - const title = og.title || tc.title || metadata.title || extractDomain(url); - const description = og.description || tc.description || metadata.description || ""; - const image = - og.images?.[0]?.url || - tc.images?.[0]?.url || - null; - const favicon = metadata.favicon || null; - const domain = extractDomain(url); + const imgHtml = image + ? `