From 06c523c66716a73ca6f9f76a08e1edf7c4956cbe Mon Sep 17 00:00:00 2001 From: grassblock Date: Sat, 31 May 2025 18:35:11 +0800 Subject: [PATCH 1/4] feat: caching for author's avatars and og:images --- src/pages/images/avatars/[author].png.js | 227 ++++++++++++++++++----- src/pages/post/[slug]/featured.png.js | 163 ++++++++++++++-- 2 files changed, 322 insertions(+), 68 deletions(-) diff --git a/src/pages/images/avatars/[author].png.js b/src/pages/images/avatars/[author].png.js index 7ffe78f..df69c22 100644 --- a/src/pages/images/avatars/[author].png.js +++ b/src/pages/images/avatars/[author].png.js @@ -1,5 +1,82 @@ import { getCollection } from 'astro:content'; import { getImage } from "astro:assets"; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; + +// Simple in-memory cache for frequently accessed avatars +const AVATAR_CACHE = new Map(); +const CACHE_MAX_SIZE = 50; // Maximum number of avatars to keep in memory +const CACHE_DIR = 'node_modules/.astro/avatar-cache'; +const AVATAR_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds +const MC_CACHE_TIME = 7 * 24 * 60 * 60; // 7 days in seconds for HTTP caching + +// Cache helpers +async function ensureCacheDir() { + try { + await mkdir(CACHE_DIR, { recursive: true }); + } catch (err) { + console.warn('Failed to create cache directory:', err); + } +} + +async function getCachedAvatar(cacheKey) { + // Check memory cache first + if (AVATAR_CACHE.has(cacheKey)) { + const { data, timestamp } = AVATAR_CACHE.get(cacheKey); + // Validate that cache entry isn't too old + if (Date.now() - timestamp < AVATAR_CACHE_TTL) { + return data; + } + // Remove stale cache entry + AVATAR_CACHE.delete(cacheKey); + } + + // Check file cache + const filePath = join(CACHE_DIR, `${cacheKey}.png`); + try { + if (existsSync(filePath)) { + const stat = await import('node:fs/promises').then(f => f.stat(filePath)); + // Check if file cache is still valid + if (Date.now() - stat.mtimeMs < AVATAR_CACHE_TTL) { + const data = await readFile(filePath); + // Update memory cache + addToMemoryCache(cacheKey, data); + return data; + } + } + } catch (err) { + console.warn(`Failed to read cached avatar ${cacheKey}:`, err); + } + + return null; +} + +async function cacheAvatar(cacheKey, data) { + // Add to memory cache + addToMemoryCache(cacheKey, data); + + // Add to file cache + try { + await ensureCacheDir(); + await writeFile(join(CACHE_DIR, `${cacheKey}.png`), data); + } catch (err) { + console.warn(`Failed to cache avatar ${cacheKey}:`, err); + } +} + +function addToMemoryCache(key, data) { + // If cache is full, remove oldest entry + if (AVATAR_CACHE.size >= CACHE_MAX_SIZE) { + const oldestKey = AVATAR_CACHE.keys().next().value; + AVATAR_CACHE.delete(oldestKey); + } + + AVATAR_CACHE.set(key, { + data, + timestamp: Date.now() + }); +} export async function getStaticPaths() { const authorsData = await getCollection('authors'); @@ -11,8 +88,22 @@ export async function getStaticPaths() { export async function GET({ props }) { const { author } = props; + const authorId = author.id; + // Try to retrieve from cache first if (author.data.avatar) { + const cacheKey = `avatar-${authorId}`; + const cachedAvatar = await getCachedAvatar(cacheKey); + + if (cachedAvatar) { + return new Response(cachedAvatar, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': `public, max-age=${MC_CACHE_TIME}` + } + }); + } + try { const optimizedImage = await getImage({ src: author.data.avatar, @@ -28,11 +119,15 @@ export async function GET({ props }) { } const imageBuffer = await imageResponse.arrayBuffer(); + const bufferData = new Uint8Array(imageBuffer); - return new Response(imageBuffer, { + // Cache the avatar for future requests + await cacheAvatar(cacheKey, bufferData); + + return new Response(bufferData, { headers: { 'Content-Type': 'image/png', - 'Cache-Control': 'public, max-age=86400' // Cache for 24 hours + 'Cache-Control': `public, max-age=${MC_CACHE_TIME}` } }); } catch (error) { @@ -46,11 +141,22 @@ export async function GET({ props }) { } const username = author.data.mcplayerid; + const cacheKey = `mc-${username}`; + + // Check cache for Minecraft avatar + const cachedMcAvatar = await getCachedAvatar(cacheKey); + if (cachedMcAvatar) { + return new Response(cachedMcAvatar, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': `public, max-age=${MC_CACHE_TIME}` + } + }); + } try { - // get Minecraft profile by username + // Get Minecraft profile by username const profileResponse = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`); - if (!profileResponse.ok) { return new Response('Player not found', { status: 404 }); } @@ -58,31 +164,43 @@ export async function GET({ props }) { const profile = await profileResponse.json(); const uuid = profile.id; - // get skin data from session server + // Get skin data from session server const sessionResponse = await fetch(`https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`); - const sessionData = await sessionResponse.json(); + if (!sessionResponse.ok) { + return new Response('Session data not found', { status: 404 }); + } + + const sessionData = await sessionResponse.json(); const texturesProperty = sessionData.properties.find((prop) => prop.name === 'textures'); + + if (!texturesProperty) { + return new Response('Textures not found', { status: 404 }); + } + const texturesData = JSON.parse(atob(texturesProperty.value)); const skinUrl = texturesData.textures.SKIN?.url; + if (!skinUrl) { return new Response('Skin not found', { status: 404 }); } - // get skin image from the URL + // Get skin image from the URL const skinResponse = await fetch(skinUrl); const skinBuffer = await skinResponse.arrayBuffer(); - // render the Minecraft head image + // Render the Minecraft head image const headImage = await renderMinecraftHead(new Uint8Array(skinBuffer)); + // Cache the rendered head + await cacheAvatar(cacheKey, headImage); + return new Response(headImage, { headers: { 'Content-Type': 'image/png', - 'Cache-Control': 'public, max-age=3600', // 缓存1小时 + 'Cache-Control': `public, max-age=${MC_CACHE_TIME}` }, }); - } catch (error) { console.error('Error fetching Minecraft head:', error); return new Response('Internal server error', { status: 500 }); @@ -93,7 +211,7 @@ async function renderMinecraftHead(skinData) { // Use sharp library to process images const sharp = (await import('sharp')).default; - // Load the skin image + // Load the skin image once and get metadata const skinImage = sharp(skinData); const metadata = await skinImage.metadata(); const { width, height } = metadata; @@ -102,54 +220,67 @@ async function renderMinecraftHead(skinData) { const isNewFormat = height === 64; const headSize = 8; // Head is 8x8 pixels const scale = 8; // Scale factor, final output is 64x64 + const offset = -1; // 3D-like effect offset - // 3D-like effect: slightly offset hat layer - // TODO: real 3D effect, which would require more complex rendering - const offset = -1; // Negative value moves up/left (creates 3D effect) + // Extract head base layer and prepare for compositing if needed + let extractionPromises = [ + // Head base layer extraction promise + await skinImage + .clone() + .extract({left: 8, top: 8, width: headSize, height: headSize}) + .png() + .toBuffer() + ]; - // Extract head base layer (8x8 pixels) - const headBase = await skinImage - .clone() // Clone to avoid modifying original - .extract({ left: 8, top: 8, width: headSize, height: headSize }) - .png() - .toBuffer(); + // If new format, also extract hat layer in parallel + if (isNewFormat && width >= 48 && height >= 16) { + extractionPromises.push( + skinImage + .clone() + .extract({ left: 40, top: 8, width: headSize, height: headSize }) + .png() + .toBuffer() + ); + } + // Wait for all extractions to complete in parallel + const [headBase, hatLayer] = await Promise.all(extractionPromises); + + // Start with base layer let finalHead = sharp(headBase).resize(headSize * scale, headSize * scale, { - kernel: 'nearest' // Keep pixel art style + kernel: 'nearest', // Keep pixel art style + fastShrinkOnLoad: true // Performance optimization }); - // If new format and has hat layer, composite hat layer - if (isNewFormat) { + // If we have a hat layer, composite it + if (hatLayer) { try { - // Check if we're in bounds before extracting hat layer - if (width >= 48 && height >= 16) { - // Extract hat layer (8x8 pixels) - const hatLayer = await skinImage - .clone() // Clone to avoid modifying original - .extract({ left: 40, top: 8, width: headSize, height: headSize }) - .png() - .toBuffer(); + const hatResized = await sharp(hatLayer) + .resize(headSize * scale, headSize * scale, { + kernel: 'nearest', + fastShrinkOnLoad: true + }) + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toBuffer(); - // Resize hat layer - const hatResized = await sharp(hatLayer) - .resize(headSize * scale, headSize * scale, { kernel: 'nearest' }) - .png() - .toBuffer(); - - // Composite base layer and hat layer with offset for 3D effect - finalHead = finalHead.composite([{ - input: hatResized, - left: offset * scale, // Apply scaled offset horizontally - top: offset * scale, // Apply scaled offset vertically - blend: 'over' - }]); - } + finalHead = finalHead.composite([{ + input: hatResized, + left: offset * scale, + top: offset * scale, + blend: 'over' + }]); } catch (error) { - // If hat layer processing fails, just use base layer console.warn('Failed to process hat layer:', error); } } - const result = await finalHead.png().toBuffer(); + // Optimize PNG compression + const result = await finalHead.png({ + compressionLevel: 9, + adaptiveFiltering: true, + force: true + }).toBuffer(); + return new Uint8Array(result); -} \ No newline at end of file +} + diff --git a/src/pages/post/[slug]/featured.png.js b/src/pages/post/[slug]/featured.png.js index bcd4371..4755d27 100644 --- a/src/pages/post/[slug]/featured.png.js +++ b/src/pages/post/[slug]/featured.png.js @@ -1,6 +1,65 @@ import { getCollection } from 'astro:content'; import sharp from 'sharp'; import {getImage} from "astro:assets"; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +// Ensure cache directory exists +const CACHE_DIR = 'node_modules/.astro/og-cache'; + +// Cache for external images to avoid repeated processing +const externalImageCache = new Map(); +// Cache for generated OG images (memory cache) +const ogImageCache = new Map(); + +// Initialize cache directory +async function ensureCacheDir() { + try { + await fs.mkdir(CACHE_DIR, { recursive: true }); + } catch (error) { + console.error('Error creating cache directory:', error); + } +} + +// Create hash for consistent filenames +function createHash(str) { + return crypto.createHash('md5').update(str).digest('hex'); +} + +// File-based cache operations +const fileCache = { + async get(key) { + try { + const filePath = path.join(CACHE_DIR, `${createHash(key)}.webp`); + const stats = await fs.stat(filePath); + + // Check if file exists and is not too old (30 days) + const now = new Date(); + const fileAge = (now - stats.mtime) / (1000 * 60 * 60 * 24); + + if (fileAge > 30) { + return null; // File is too old + } + + return await fs.readFile(filePath); + } catch (error) { + return null; // File doesn't exist or can't be read + } + }, + + async set(key, data) { + try { + await ensureCacheDir(); + const filePath = path.join(CACHE_DIR, `${createHash(key)}.webp`); + await fs.writeFile(filePath, data); + return true; + } catch (error) { + console.error('Error writing to file cache:', error); + return false; + } + } +}; export async function getStaticPaths() { const blogEntries = await getCollection('posts'); @@ -11,13 +70,22 @@ export async function getStaticPaths() { // get the post has a external featured.* image files async function getExternalImage(post) { + // Check cache first + if (externalImageCache.has(post.slug)) { + return externalImageCache.get(post.slug); + } + const featuredImages = import.meta.glob(`/src/content/posts/*/featured.*`, {import: 'default', eager: true}); const matchedImage = Object.keys(featuredImages).find(path => path.includes(post.slug)); let matchedImage_; if (matchedImage) { matchedImage_ = await getImage({src: featuredImages[matchedImage], format: 'webp'}) || null; } - return matchedImage_?.src; + + // Store in cache + const result = matchedImage_?.src; + externalImageCache.set(post.slug, result); + return result; } // Function to check for images in markdown without rendering @@ -30,12 +98,52 @@ function checkForImages(markdownContent) { // This function dynamically generates og:images for posts that don't have a featured image export async function GET({ props }) { const {post} = props; - const ExternalImageURL = await getExternalImage(post); - const hasImage = checkForImages(post.body); + + // Generate consistent cache key + const cacheKey = `${post.slug}-${post.id}`; + + // Check in-memory cache first (fastest) + if (ogImageCache.has(cacheKey)) { + return new Response(ogImageCache.get(cacheKey), { + headers: { + 'Content-Type': 'image/webp', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': `"${cacheKey}"` + } + }); + } + + // Then check file cache (persists between server restarts) + const cachedFile = await fileCache.get(cacheKey); + if (cachedFile) { + // Store in memory cache for faster subsequent access + ogImageCache.set(cacheKey, cachedFile); + + return new Response(cachedFile, { + headers: { + 'Content-Type': 'image/webp', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': `"${cacheKey}"`, + 'X-Cache': 'HIT-FILE' + } + }); + } + try { - // Check if a custom cover image already exists - if (post.data.cover || ExternalImageURL || hasImage) { - // set it to empty to prevent the image generation + // Short-circuit early if we know we won't generate an image + if (post.data.cover) { + return new Response(null); + } + + // Only fetch external image if needed + const ExternalImageURL = await getExternalImage(post); + if (ExternalImageURL) { + return new Response(null); + } + + // Only check for images if needed + const hasImage = checkForImages(post.body); + if (hasImage) { return new Response(null); } @@ -43,29 +151,44 @@ export async function GET({ props }) { const width = 1280; const height = 720; - // Create a simple image with text - const svg = ` - - - ${post.data.title} - ${post.data.description} - - `; + // Sanitize text for SVG + const sanitizeText = (text) => { + return text + ? text.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + : ''; + }; - // Convert SVG to WebP + const title = sanitizeText(post.data.title); + const description = sanitizeText(post.data.description); + + // Create a simple image with text - optimized SVG + const svg = `${title}${description}`; + + // Convert SVG to WebP with optimized settings const buffer = await sharp(Buffer.from(svg)) - .toFormat('webp') + .webp({ quality: 80, lossless: false }) .toBuffer(); - // Return the image + // Store in both memory and file caches + ogImageCache.set(cacheKey, buffer); + await fileCache.set(cacheKey, buffer); + + // Return the image with proper caching headers return new Response(buffer, { headers: { - 'Content-Type': 'image/png', - 'Cache-Control': 'public, max-age=31536000' + 'Content-Type': 'image/webp', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': `"${cacheKey}"`, + 'X-Cache': 'MISS' } }); } catch (error) { console.error('Error generating image:', error); return new Response('Error generating image', { status: 500 }); } -} \ No newline at end of file +} + From e0f8e0257735206be0ce7e93362afa1b2761c5cf Mon Sep 17 00:00:00 2001 From: grassblock Date: Sat, 31 May 2025 19:03:14 +0800 Subject: [PATCH 2/4] feat: better glob matching featured images --- src/pages/post/[...slug].astro | 2 +- src/pages/post/[slug]/featured.png.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/post/[...slug].astro b/src/pages/post/[...slug].astro index e430eed..886f4b2 100644 --- a/src/pages/post/[...slug].astro +++ b/src/pages/post/[...slug].astro @@ -27,7 +27,7 @@ const authorInfo = authorData ? authorData.data : siteConfig.defaultAuthor; // get featured image and use it as og:image // use the custom cover image if it exists, otherwise use the featured image file in the same directory -const featuredImages = import.meta.glob(`/src/content/posts/*/featured.*`,{import:'default',eager:true}); +const featuredImages = import.meta.glob(`/src/content/posts/*/featured.{avif,png,jpg,jpeg,webp}`,{import:'default',eager:true}); const customFeaturedImage = entry.data.cover?.src const matchedImage = Object.keys(featuredImages).find(path => path.includes(slug)); let matchedImage_src; diff --git a/src/pages/post/[slug]/featured.png.js b/src/pages/post/[slug]/featured.png.js index 4755d27..1d43c47 100644 --- a/src/pages/post/[slug]/featured.png.js +++ b/src/pages/post/[slug]/featured.png.js @@ -75,7 +75,7 @@ async function getExternalImage(post) { return externalImageCache.get(post.slug); } - const featuredImages = import.meta.glob(`/src/content/posts/*/featured.*`, {import: 'default', eager: true}); + const featuredImages = import.meta.glob(`/src/content/posts/*/featured.{avif,png,jpg,jpeg,webp}`, {import: 'default', eager: true}); const matchedImage = Object.keys(featuredImages).find(path => path.includes(post.slug)); let matchedImage_; if (matchedImage) { From e5d6dbda6ff5844b948b91f320e442e6b4265cd6 Mon Sep 17 00:00:00 2001 From: grassblock Date: Sat, 31 May 2025 19:17:14 +0800 Subject: [PATCH 3/4] feat: add umami analytics --- src/config.ts | 7 +++++++ src/layouts/Layout.astro | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/config.ts b/src/config.ts index c5618a8..c45b1c5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,6 +78,13 @@ export const siteConfig = { instanceDomain: '', } }, + // umami analytics + // by enabling this, you can track the visitors of your site + umami: { + enabled: false, // enable umami analytics + instanceDomain: 'cloud.umami.is', // the url of the umami script, usually your-umami-instance.com (default: official cloud.umami.is) + websiteId: 'your-website-id', // the id of your website in umami, get it from your umami dashboard + }, // neko // by enabling this, you can add a neko that follows cursor to your site // this will load script from webneko.net diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index f402f64..7847a12 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -18,6 +18,7 @@ interface Props { } const noscript = siteConfig.noClientJavaScript +const umami = siteConfig.umami const defaultTitle = siteConfig.title const formattedRootPath = defaultTitle.toLowerCase().replace(/\s+/g, '-'); @@ -75,6 +76,7 @@ const { title = pageTitle, author = siteConfig.defaultAuthor.name,description =

Powered by mercury

+ {umami.enabled && } { (siteConfig.neko.enabled && !noscript) && <>