mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
chore(cache): add cached link icon part in the icon link plugin
This commit is contained in:
parent
a1d8fc2751
commit
ca56f893fa
166
quartz/plugins/emitters/faviconCache.ts
Normal file
166
quartz/plugins/emitters/faviconCache.ts
Normal file
@ -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<Partial<Options>> = (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<string>()
|
||||
|
||||
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<void> {
|
||||
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[]
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -40,10 +40,6 @@ const defaultOptions: Options = {
|
||||
|
||||
const _faviconCache: Map<string, string | "ERROR"> = 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<Partial<Options>> = (userOpts)
|
||||
return (tree: Root, file: any) => {
|
||||
const curSlug = simplifySlug(file.data.slug!)
|
||||
const outgoing: Set<SimpleSlug> = new Set()
|
||||
const externalDomains: Set<string> = new Set()
|
||||
|
||||
const transformOptions: TransformOptions = {
|
||||
strategy: opts.markdownLinkResolution,
|
||||
@ -109,7 +106,14 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (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<Partial<Options>> = (userOpts)
|
||||
})
|
||||
|
||||
file.data.links = [...outgoing]
|
||||
file.data.externalDomains = [...externalDomains]
|
||||
}
|
||||
},
|
||||
]
|
||||
@ -214,5 +219,6 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
links: SimpleSlug[]
|
||||
externalDomains: string[]
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user