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 { Assets } from "./assets"
|
||||||
export { Static } from "./static"
|
export { Static } from "./static"
|
||||||
export { Favicon } from "./favicon"
|
export { Favicon } from "./favicon"
|
||||||
|
export { FaviconCache } from "./faviconCache"
|
||||||
export { ComponentResources } from "./componentResources"
|
export { ComponentResources } from "./componentResources"
|
||||||
export { NotFoundPage } from "./404"
|
export { NotFoundPage } from "./404"
|
||||||
export { CNAME } from "./cname"
|
export { CNAME } from "./cname"
|
||||||
|
|||||||
@ -40,10 +40,6 @@ const defaultOptions: Options = {
|
|||||||
|
|
||||||
const _faviconCache: Map<string, string | "ERROR"> = new Map()
|
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 {
|
function getFaviconUrl(domain: string): string | null {
|
||||||
if (_faviconCache.has(domain)) {
|
if (_faviconCache.has(domain)) {
|
||||||
const cached = _faviconCache.get(domain)
|
const cached = _faviconCache.get(domain)
|
||||||
@ -65,6 +61,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
return (tree: Root, file: any) => {
|
return (tree: Root, file: any) => {
|
||||||
const curSlug = simplifySlug(file.data.slug!)
|
const curSlug = simplifySlug(file.data.slug!)
|
||||||
const outgoing: Set<SimpleSlug> = new Set()
|
const outgoing: Set<SimpleSlug> = new Set()
|
||||||
|
const externalDomains: Set<string> = new Set()
|
||||||
|
|
||||||
const transformOptions: TransformOptions = {
|
const transformOptions: TransformOptions = {
|
||||||
strategy: opts.markdownLinkResolution,
|
strategy: opts.markdownLinkResolution,
|
||||||
@ -109,7 +106,14 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
if (isExternal && opts.showLinkFavicon) {
|
if (isExternal && opts.showLinkFavicon) {
|
||||||
const domain = new URL(node.properties.href).hostname
|
const domain = new URL(node.properties.href).hostname
|
||||||
if (domain) {
|
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) {
|
if (faviconUrl) {
|
||||||
node.children.unshift({
|
node.children.unshift({
|
||||||
type: "element",
|
type: "element",
|
||||||
@ -204,6 +208,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
})
|
})
|
||||||
|
|
||||||
file.data.links = [...outgoing]
|
file.data.links = [...outgoing]
|
||||||
|
file.data.externalDomains = [...externalDomains]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -214,5 +219,6 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
declare module "vfile" {
|
declare module "vfile" {
|
||||||
interface DataMap {
|
interface DataMap {
|
||||||
links: SimpleSlug[]
|
links: SimpleSlug[]
|
||||||
|
externalDomains: string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user