Compare commits
4 commits
11bc60c103
...
eaabe1b737
Author | SHA1 | Date | |
---|---|---|---|
|
eaabe1b737 | ||
|
e5d6dbda6f | ||
|
e0f8e02577 | ||
|
06c523c667 |
7 changed files with 337 additions and 70 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -4,6 +4,9 @@ dist/
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
|
# wrangler output
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|
1
.idea/mercury.iml
generated
1
.idea/mercury.iml
generated
|
@ -6,6 +6,7 @@
|
||||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.astro" />
|
<excludeFolder url="file://$MODULE_DIR$/.astro" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.wrangler" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|
|
@ -78,6 +78,13 @@ export const siteConfig = {
|
||||||
instanceDomain: '',
|
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
|
// neko
|
||||||
// by enabling this, you can add a neko that follows cursor to your site
|
// by enabling this, you can add a neko that follows cursor to your site
|
||||||
// this will load script from webneko.net
|
// this will load script from webneko.net
|
||||||
|
|
|
@ -18,6 +18,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const noscript = siteConfig.noClientJavaScript
|
const noscript = siteConfig.noClientJavaScript
|
||||||
|
const umami = siteConfig.umami
|
||||||
|
|
||||||
const defaultTitle = siteConfig.title
|
const defaultTitle = siteConfig.title
|
||||||
const formattedRootPath = defaultTitle.toLowerCase().replace(/\s+/g, '-');
|
const formattedRootPath = defaultTitle.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
@ -75,6 +76,7 @@ const { title = pageTitle, author = siteConfig.defaultAuthor.name,description =
|
||||||
<p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p>
|
<p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{umami.enabled && <script defer src=`https://${umami.instanceDomain}/script.js` data-website-id={umami.websiteId}></script>}
|
||||||
{ (siteConfig.neko.enabled && !noscript) &&
|
{ (siteConfig.neko.enabled && !noscript) &&
|
||||||
<>
|
<>
|
||||||
<script is:inline define:vars={{ nekoType }}>
|
<script is:inline define:vars={{ nekoType }}>
|
||||||
|
|
|
@ -1,5 +1,82 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { getImage } from "astro:assets";
|
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() {
|
export async function getStaticPaths() {
|
||||||
const authorsData = await getCollection('authors');
|
const authorsData = await getCollection('authors');
|
||||||
|
@ -11,8 +88,22 @@ export async function getStaticPaths() {
|
||||||
|
|
||||||
export async function GET({ props }) {
|
export async function GET({ props }) {
|
||||||
const { author } = props;
|
const { author } = props;
|
||||||
|
const authorId = author.id;
|
||||||
|
|
||||||
|
// Try to retrieve from cache first
|
||||||
if (author.data.avatar) {
|
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 {
|
try {
|
||||||
const optimizedImage = await getImage({
|
const optimizedImage = await getImage({
|
||||||
src: author.data.avatar,
|
src: author.data.avatar,
|
||||||
|
@ -28,11 +119,15 @@ export async function GET({ props }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageBuffer = await imageResponse.arrayBuffer();
|
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: {
|
headers: {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
'Cache-Control': 'public, max-age=86400' // Cache for 24 hours
|
'Cache-Control': `public, max-age=${MC_CACHE_TIME}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -46,11 +141,22 @@ export async function GET({ props }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = author.data.mcplayerid;
|
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 {
|
try {
|
||||||
// get Minecraft profile by username
|
// Get Minecraft profile by username
|
||||||
const profileResponse = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
|
const profileResponse = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
|
||||||
|
|
||||||
if (!profileResponse.ok) {
|
if (!profileResponse.ok) {
|
||||||
return new Response('Player not found', { status: 404 });
|
return new Response('Player not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
@ -58,31 +164,43 @@ export async function GET({ props }) {
|
||||||
const profile = await profileResponse.json();
|
const profile = await profileResponse.json();
|
||||||
const uuid = profile.id;
|
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 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');
|
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 texturesData = JSON.parse(atob(texturesProperty.value));
|
||||||
const skinUrl = texturesData.textures.SKIN?.url;
|
const skinUrl = texturesData.textures.SKIN?.url;
|
||||||
|
|
||||||
if (!skinUrl) {
|
if (!skinUrl) {
|
||||||
return new Response('Skin not found', { status: 404 });
|
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 skinResponse = await fetch(skinUrl);
|
||||||
const skinBuffer = await skinResponse.arrayBuffer();
|
const skinBuffer = await skinResponse.arrayBuffer();
|
||||||
|
|
||||||
// render the Minecraft head image
|
// Render the Minecraft head image
|
||||||
const headImage = await renderMinecraftHead(new Uint8Array(skinBuffer));
|
const headImage = await renderMinecraftHead(new Uint8Array(skinBuffer));
|
||||||
|
|
||||||
|
// Cache the rendered head
|
||||||
|
await cacheAvatar(cacheKey, headImage);
|
||||||
|
|
||||||
return new Response(headImage, {
|
return new Response(headImage, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
'Cache-Control': 'public, max-age=3600', // 缓存1小时
|
'Cache-Control': `public, max-age=${MC_CACHE_TIME}`
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Minecraft head:', error);
|
console.error('Error fetching Minecraft head:', error);
|
||||||
return new Response('Internal server error', { status: 500 });
|
return new Response('Internal server error', { status: 500 });
|
||||||
|
@ -93,7 +211,7 @@ async function renderMinecraftHead(skinData) {
|
||||||
// Use sharp library to process images
|
// Use sharp library to process images
|
||||||
const sharp = (await import('sharp')).default;
|
const sharp = (await import('sharp')).default;
|
||||||
|
|
||||||
// Load the skin image
|
// Load the skin image once and get metadata
|
||||||
const skinImage = sharp(skinData);
|
const skinImage = sharp(skinData);
|
||||||
const metadata = await skinImage.metadata();
|
const metadata = await skinImage.metadata();
|
||||||
const { width, height } = metadata;
|
const { width, height } = metadata;
|
||||||
|
@ -102,54 +220,67 @@ async function renderMinecraftHead(skinData) {
|
||||||
const isNewFormat = height === 64;
|
const isNewFormat = height === 64;
|
||||||
const headSize = 8; // Head is 8x8 pixels
|
const headSize = 8; // Head is 8x8 pixels
|
||||||
const scale = 8; // Scale factor, final output is 64x64
|
const scale = 8; // Scale factor, final output is 64x64
|
||||||
|
const offset = -1; // 3D-like effect offset
|
||||||
|
|
||||||
// 3D-like effect: slightly offset hat layer
|
// Extract head base layer and prepare for compositing if needed
|
||||||
// TODO: real 3D effect, which would require more complex rendering
|
let extractionPromises = [
|
||||||
const offset = -1; // Negative value moves up/left (creates 3D effect)
|
// Head base layer extraction promise
|
||||||
|
await skinImage
|
||||||
// Extract head base layer (8x8 pixels)
|
.clone()
|
||||||
const headBase = await skinImage
|
.extract({left: 8, top: 8, width: headSize, height: headSize})
|
||||||
.clone() // Clone to avoid modifying original
|
|
||||||
.extract({ left: 8, top: 8, width: headSize, height: headSize })
|
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer()
|
||||||
|
];
|
||||||
|
|
||||||
let finalHead = sharp(headBase).resize(headSize * scale, headSize * scale, {
|
// If new format, also extract hat layer in parallel
|
||||||
kernel: 'nearest' // Keep pixel art style
|
if (isNewFormat && width >= 48 && height >= 16) {
|
||||||
});
|
extractionPromises.push(
|
||||||
|
skinImage
|
||||||
// If new format and has hat layer, composite hat layer
|
.clone()
|
||||||
if (isNewFormat) {
|
|
||||||
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 })
|
.extract({ left: 40, top: 8, width: headSize, height: headSize })
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Resize hat layer
|
// 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
|
||||||
|
fastShrinkOnLoad: true // Performance optimization
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have a hat layer, composite it
|
||||||
|
if (hatLayer) {
|
||||||
|
try {
|
||||||
const hatResized = await sharp(hatLayer)
|
const hatResized = await sharp(hatLayer)
|
||||||
.resize(headSize * scale, headSize * scale, { kernel: 'nearest' })
|
.resize(headSize * scale, headSize * scale, {
|
||||||
.png()
|
kernel: 'nearest',
|
||||||
|
fastShrinkOnLoad: true
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
// Composite base layer and hat layer with offset for 3D effect
|
|
||||||
finalHead = finalHead.composite([{
|
finalHead = finalHead.composite([{
|
||||||
input: hatResized,
|
input: hatResized,
|
||||||
left: offset * scale, // Apply scaled offset horizontally
|
left: offset * scale,
|
||||||
top: offset * scale, // Apply scaled offset vertically
|
top: offset * scale,
|
||||||
blend: 'over'
|
blend: 'over'
|
||||||
}]);
|
}]);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If hat layer processing fails, just use base layer
|
|
||||||
console.warn('Failed to process hat layer:', error);
|
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);
|
return new Uint8Array(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ const authorInfo = authorData ? authorData.data : siteConfig.defaultAuthor;
|
||||||
|
|
||||||
// get featured image and use it as og:image
|
// 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
|
// 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 customFeaturedImage = entry.data.cover?.src
|
||||||
const matchedImage = Object.keys(featuredImages).find(path => path.includes(slug));
|
const matchedImage = Object.keys(featuredImages).find(path => path.includes(slug));
|
||||||
let matchedImage_src;
|
let matchedImage_src;
|
||||||
|
|
|
@ -1,6 +1,65 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import {getImage} from "astro:assets";
|
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() {
|
export async function getStaticPaths() {
|
||||||
const blogEntries = await getCollection('posts');
|
const blogEntries = await getCollection('posts');
|
||||||
|
@ -11,13 +70,22 @@ export async function getStaticPaths() {
|
||||||
|
|
||||||
// get the post has a external featured.* image files
|
// get the post has a external featured.* image files
|
||||||
async function getExternalImage(post) {
|
async function getExternalImage(post) {
|
||||||
const featuredImages = import.meta.glob(`/src/content/posts/*/featured.*`, {import: 'default', eager: true});
|
// Check cache first
|
||||||
|
if (externalImageCache.has(post.slug)) {
|
||||||
|
return externalImageCache.get(post.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
const matchedImage = Object.keys(featuredImages).find(path => path.includes(post.slug));
|
||||||
let matchedImage_;
|
let matchedImage_;
|
||||||
if (matchedImage) {
|
if (matchedImage) {
|
||||||
matchedImage_ = await getImage({src: featuredImages[matchedImage], format: 'webp'}) || null;
|
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
|
// 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
|
// This function dynamically generates og:images for posts that don't have a featured image
|
||||||
export async function GET({ props }) {
|
export async function GET({ props }) {
|
||||||
const {post} = 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 {
|
try {
|
||||||
// Check if a custom cover image already exists
|
// Short-circuit early if we know we won't generate an image
|
||||||
if (post.data.cover || ExternalImageURL || hasImage) {
|
if (post.data.cover) {
|
||||||
// set it to empty to prevent the image generation
|
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);
|
return new Response(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,25 +151,39 @@ export async function GET({ props }) {
|
||||||
const width = 1280;
|
const width = 1280;
|
||||||
const height = 720;
|
const height = 720;
|
||||||
|
|
||||||
// Create a simple image with text
|
// Sanitize text for SVG
|
||||||
const svg = `
|
const sanitizeText = (text) => {
|
||||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
return text
|
||||||
<rect width="${width}" height="${height}" fill="#2e3440"/>
|
? text.replace(/&/g, '&')
|
||||||
<text x="50%" y="40%" font-family="JetBrains Mono, monospace" font-size="50" text-anchor="middle" fill="#eceff4">${post.data.title}</text>
|
.replace(/</g, '<')
|
||||||
<text x="50%" y="60%" font-family="JetBrains Mono, monospace" font-size="30" text-anchor="middle" fill="#81a1c1">${post.data.description}</text>
|
.replace(/>/g, '>')
|
||||||
</svg>
|
.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 = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><rect width="${width}" height="${height}" fill="#2e3440"/><text x="50%" y="40%" font-family="JetBrains Mono, monospace" font-size="50" text-anchor="middle" fill="#eceff4">${title}</text><text x="50%" y="60%" font-family="JetBrains Mono, monospace" font-size="30" text-anchor="middle" fill="#81a1c1">${description}</text></svg>`;
|
||||||
|
|
||||||
|
// Convert SVG to WebP with optimized settings
|
||||||
const buffer = await sharp(Buffer.from(svg))
|
const buffer = await sharp(Buffer.from(svg))
|
||||||
.toFormat('webp')
|
.webp({ quality: 80, lossless: false })
|
||||||
.toBuffer();
|
.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, {
|
return new Response(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/webp',
|
||||||
'Cache-Control': 'public, max-age=31536000'
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
'ETag': `"${cacheKey}"`,
|
||||||
|
'X-Cache': 'MISS'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -69,3 +191,4 @@ export async function GET({ props }) {
|
||||||
return new Response('Error generating image', { status: 500 });
|
return new Response('Error generating image', { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue