From ca56f893fa862c2c6f1c8de2934b4c3308878137 Mon Sep 17 00:00:00 2001 From: dodolalorc Date: Thu, 11 Dec 2025 01:16:15 +0800 Subject: [PATCH] chore(cache): add cached link icon part in the icon link plugin --- quartz/plugins/emitters/faviconCache.ts | 166 ++++++++++++++++++++++++ quartz/plugins/emitters/index.ts | 1 + quartz/plugins/transformers/links.ts | 16 ++- 3 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 quartz/plugins/emitters/faviconCache.ts diff --git a/quartz/plugins/emitters/faviconCache.ts b/quartz/plugins/emitters/faviconCache.ts new file mode 100644 index 000000000..9753be9ac --- /dev/null +++ b/quartz/plugins/emitters/faviconCache.ts @@ -0,0 +1,166 @@ +import { QuartzEmitterPlugin } from "../types" +import { FilePath, joinSegments } from "../../util/path" +import fs from "fs" +import path from "path" + +interface Options { + /** + * Enable favicon caching during build time. + * When enabled, all external link favicons are pre-fetched and cached to /static/externalFavicons/ + */ + enabled: boolean + /** + * Number of concurrent favicon fetch requests. + * Keep this low (1-3) to avoid hitting API rate limits. + */ + concurrency: number + /** + * Timeout for individual favicon fetch requests in milliseconds. + */ + timeout: number + /** + * Whether to continue build if favicon fetch fails. + * If false, a single failed favicon will fail the entire build. + */ + continueOnError: boolean +} + +const defaultOptions: Options = { + enabled: false, + concurrency: 2, + timeout: 5000, + continueOnError: true, +} + +/** + * Pre-fetches and caches external link favicons during the build process. + * When enabled, this plugin collects all unique external domains found in links, + * fetches their favicons, and stores them locally in /static/externalFavicons/. + * + * This reduces runtime requests and improves page load performance. + * Used in conjunction with CrawlLinks plugin configured with cacheLinkFavicons: true. + */ +export const FaviconCache: QuartzEmitterPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + + return { + name: "FaviconCache", + async *emit(ctx, content) { + if (!opts.enabled) { + return + } + + // Collect all unique domains from external links + const domains = new Set() + + for (const [_tree, vfile] of content) { + // CrawlLinks plugin stores external link information in file.data.externalDomains + const externalDomains = (vfile.data.externalDomains ?? []) as string[] + for (const domain of externalDomains) { + if (domain && domain.trim()) { + domains.add(domain.toLowerCase()) + } + } + } + + if (domains.size === 0) { + return + } + + // Create favicon cache directory + const faviconCachePath = joinSegments( + ctx.argv.output, + "static/externalFavicons", + ) as FilePath + + await fs.promises.mkdir(faviconCachePath, { recursive: true }) + + // Fetch favicons with concurrency control + const domainArray = Array.from(domains) + let successCount = 0 + let failureCount = 0 + + console.log(`Caching favicons for ${domainArray.length} unique domains...`) + + for (let i = 0; i < domainArray.length; i += opts.concurrency) { + const batch = domainArray.slice(i, i + opts.concurrency) + + const results = await Promise.allSettled( + batch.map((domain) => fetchAndCacheFavicon(domain, faviconCachePath, opts)), + ) + + for (let j = 0; j < results.length; j++) { + const result = results[j] + const domain = batch[j] + + if (result.status === "fulfilled") { + successCount++ + yield joinSegments(faviconCachePath, `${domain}.ico`) as FilePath + } else { + failureCount++ + if (!opts.continueOnError) { + throw result.reason + } + } + } + } + + console.log( + `Favicon cache complete: ${successCount} succeeded, ${failureCount} failed`, + ) + }, + + async *partialEmit(_ctx, _content, _resources, _changeEvents) { + // For incremental builds, we could optimize by only fetching favicons + // for newly added/modified files. For now, we skip partial emission + // since favicons are relatively small and the cost is minimal. + }, + } +} + +async function fetchAndCacheFavicon( + domain: string, + cachePath: FilePath, + opts: Options, +): Promise { + const faviconUrl = `https://s2.googleusercontent.com/s2/favicons?domain_url=${domain}` + const fileName = `${domain}.ico` + const destPath = path.join(cachePath, fileName) as FilePath + + try { + // Create abort controller for timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), opts.timeout) + + const response = await fetch(faviconUrl, { + signal: controller.signal, + // Add User-Agent to avoid potential blocks + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; Quartz/4.0 +https://quartz.jzhao.xyz/)", + }, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + throw new Error(`HTTP ${response.status} when fetching favicon for ${domain}`) + } + + const buffer = await response.arrayBuffer() + await fs.promises.writeFile(destPath, Buffer.from(buffer)) + } catch (err) { + if (!opts.continueOnError) { + throw err + } + // Log warning but continue + const errorMsg = err instanceof Error ? err.message : String(err) + console.warn(`⚠️ Failed to cache favicon for domain '${domain}': ${errorMsg}`) + } +} + +declare module "vfile" { + interface DataMap { + externalDomains: string[] + } +} diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index d2de2ed1e..4f8bcc951 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -6,6 +6,7 @@ export { AliasRedirects } from "./aliases" export { Assets } from "./assets" export { Static } from "./static" export { Favicon } from "./favicon" +export { FaviconCache } from "./faviconCache" export { ComponentResources } from "./componentResources" export { NotFoundPage } from "./404" export { CNAME } from "./cname" diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 40086e97f..59cde2b40 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -40,10 +40,6 @@ const defaultOptions: Options = { const _faviconCache: Map = new Map() -/** - * Get cached favicon URL for a domain, or return the Google Favicon API URL. - * Results are cached to ensure each unique domain is only fetched once per build. - */ function getFaviconUrl(domain: string): string | null { if (_faviconCache.has(domain)) { const cached = _faviconCache.get(domain) @@ -65,6 +61,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) return (tree: Root, file: any) => { const curSlug = simplifySlug(file.data.slug!) const outgoing: Set = new Set() + const externalDomains: Set = new Set() const transformOptions: TransformOptions = { strategy: opts.markdownLinkResolution, @@ -109,7 +106,14 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) if (isExternal && opts.showLinkFavicon) { const domain = new URL(node.properties.href).hostname if (domain) { - const faviconUrl = getFaviconUrl(domain) + // Track this domain for favicon caching if enabled + externalDomains.add(domain) + + // Determine favicon URL based on caching option + const faviconUrl = opts.cacheLinkFavicons + ? `/static/externalFavicons/${domain}.ico` // Use locally cached favicon + : getFaviconUrl(domain) // Use Google Favicon API + if (faviconUrl) { node.children.unshift({ type: "element", @@ -204,6 +208,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) }) file.data.links = [...outgoing] + file.data.externalDomains = [...externalDomains] } }, ] @@ -214,5 +219,6 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) declare module "vfile" { interface DataMap { links: SimpleSlug[] + externalDomains: string[] } }