diff --git a/_includes/layouts/base.njk b/_includes/layouts/base.njk
index dfdd752..f4b79ad 100644
--- a/_includes/layouts/base.njk
+++ b/_includes/layouts/base.njk
@@ -23,12 +23,15 @@
+ {% set hasGeneratedOg = page.fileSlug | hasOgImage %}
{% if ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
{% elif image and image != "" and (image | length) > 10 %}
{% elif contentImage and contentImage != "" and (contentImage | length) > 10 %}
+ {% elif hasGeneratedOg %}
+
{% else %}
{% endif %}
@@ -37,7 +40,7 @@
{# Twitter Card meta tags #}
- {% set hasImage = (ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10) or (image and image != "" and (image | length) > 10) or (contentImage and contentImage != "" and (contentImage | length) > 10) %}
+ {% set hasImage = hasGeneratedOg or (ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10) or (image and image != "" and (image | length) > 10) or (contentImage and contentImage != "" and (contentImage | length) > 10) %}
@@ -47,6 +50,8 @@
{% elif contentImage and contentImage != "" and (contentImage | length) > 10 %}
+ {% elif hasGeneratedOg %}
+
{% endif %}
{# Favicon #}
diff --git a/eleventy.config.js b/eleventy.config.js
index d3919b1..547fdad 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -9,7 +9,8 @@ import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import { minify } from "html-minifier-terser";
import { createHash } from "crypto";
import { execFileSync } from "child_process";
-import { readFileSync } from "fs";
+import { readFileSync, existsSync } from "fs";
+import { generateOgImages } from "./lib/og.js";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
@@ -165,6 +166,7 @@ export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("images");
eleventyConfig.addPassthroughCopy("js");
eleventyConfig.addPassthroughCopy("favicon.ico");
+ eleventyConfig.addPassthroughCopy({ ".cache/og": "og" });
// Watch for content changes
eleventyConfig.addWatchTarget("./content/");
@@ -266,6 +268,12 @@ export default function (eleventyConfig) {
}
});
+ // Check if a generated OG image exists for this page slug
+ eleventyConfig.addFilter("hasOgImage", (fileSlug) => {
+ if (!fileSlug) return false;
+ return existsSync(resolve(__dirname, ".cache", "og", `${fileSlug}.png`));
+ });
+
// Current timestamp filter (for client-side JS buildtime)
eleventyConfig.addFilter("timestamp", () => Date.now());
@@ -445,6 +453,18 @@ export default function (eleventyConfig) {
.slice(0, 5);
});
+ // Generate OpenGraph images for posts without photos
+ eleventyConfig.on("eleventy.before", async () => {
+ const contentDir = resolve(__dirname, "content");
+ const cacheDir = resolve(__dirname, ".cache");
+ const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
+ try {
+ await generateOgImages(contentDir, cacheDir, siteName);
+ } catch (err) {
+ console.error("[og] Image generation failed:", err.message);
+ }
+ });
+
// Pagefind indexing + WebSub hub notification after each build
eleventyConfig.on("eleventy.after", async ({ dir, runMode }) => {
// Pagefind indexing
diff --git a/lib/og.js b/lib/og.js
new file mode 100644
index 0000000..fdffe94
--- /dev/null
+++ b/lib/og.js
@@ -0,0 +1,329 @@
+/**
+ * OpenGraph image generation for posts without photos.
+ * Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG).
+ * Generated images are cached in .cache/og/ and passthrough-copied to output.
+ */
+
+import satori from "satori";
+import { Resvg } from "@resvg/resvg-js";
+import {
+ readFileSync,
+ writeFileSync,
+ mkdirSync,
+ existsSync,
+ readdirSync,
+} from "node:fs";
+import { resolve, join, basename, dirname } from "node:path";
+import { createHash } from "node:crypto";
+import { fileURLToPath } from "node:url";
+import matter from "gray-matter";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const WIDTH = 1200;
+const HEIGHT = 630;
+
+const COLORS = {
+ bg: "#09090b",
+ title: "#f4f4f5",
+ date: "#a1a1aa",
+ siteName: "#71717a",
+ accent: "#3b82f6",
+ badge: "#2563eb",
+ badgeText: "#ffffff",
+};
+
+const POST_TYPE_MAP = {
+ articles: "Article",
+ notes: "Note",
+ bookmarks: "Bookmark",
+ photos: "Photo",
+ likes: "Like",
+ replies: "Reply",
+ reposts: "Repost",
+ pages: "Page",
+ videos: "Video",
+ audio: "Audio",
+ jams: "Jam",
+ rsvps: "RSVP",
+ events: "Event",
+};
+
+function loadFonts() {
+ const fontsDir = resolve(
+ __dirname,
+ "..",
+ "node_modules",
+ "@fontsource",
+ "inter",
+ "files",
+ );
+ return [
+ {
+ name: "Inter",
+ data: readFileSync(join(fontsDir, "inter-latin-400-normal.woff")),
+ weight: 400,
+ style: "normal",
+ },
+ {
+ name: "Inter",
+ data: readFileSync(join(fontsDir, "inter-latin-700-normal.woff")),
+ weight: 700,
+ style: "normal",
+ },
+ ];
+}
+
+function computeHash(title, date, postType, siteName) {
+ return createHash("md5")
+ .update(`${title}|${date}|${postType}|${siteName}`)
+ .digest("hex")
+ .slice(0, 12);
+}
+
+function detectPostType(filePath) {
+ const parts = filePath.split("/");
+ const contentIdx = parts.indexOf("content");
+ if (contentIdx >= 0 && contentIdx + 1 < parts.length) {
+ const typeDir = parts[contentIdx + 1];
+ if (POST_TYPE_MAP[typeDir]) return POST_TYPE_MAP[typeDir];
+ }
+ return "Post";
+}
+
+function formatDate(dateStr) {
+ if (!dateStr) return "";
+ try {
+ const d = new Date(dateStr);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ } catch {
+ return "";
+ }
+}
+
+function truncateTitle(title, max = 120) {
+ if (!title || title.length <= max) return title || "Untitled";
+ return title.slice(0, max).trim() + "\u2026";
+}
+
+function extractBodyText(raw) {
+ const body = raw
+ .replace(/^---[\s\S]*?---\s*/, "")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/[#*_~`>]/g, "")
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, "")
+ .replace(/\n+/g, " ")
+ .trim();
+ if (!body) return "Untitled";
+ return body.length > 120 ? body.slice(0, 120).trim() + "\u2026" : body;
+}
+
+function buildCard(title, dateStr, postType, siteName) {
+ return {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ width: `${WIDTH}px`,
+ height: `${HEIGHT}px`,
+ backgroundColor: COLORS.bg,
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ width: "6px",
+ height: "100%",
+ backgroundColor: COLORS.accent,
+ flexShrink: 0,
+ },
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ padding: "60px",
+ flex: 1,
+ overflow: "hidden",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "24px",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: { display: "flex" },
+ children: [
+ {
+ type: "span",
+ props: {
+ style: {
+ backgroundColor: COLORS.badge,
+ color: COLORS.badgeText,
+ fontSize: "16px",
+ fontWeight: 700,
+ fontFamily: "Inter",
+ padding: "6px 16px",
+ borderRadius: "999px",
+ textTransform: "uppercase",
+ letterSpacing: "0.05em",
+ },
+ children: postType,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ color: COLORS.title,
+ fontSize: "48px",
+ fontWeight: 700,
+ fontFamily: "Inter",
+ lineHeight: 1.2,
+ overflow: "hidden",
+ },
+ children: truncateTitle(title),
+ },
+ },
+ dateStr
+ ? {
+ type: "div",
+ props: {
+ style: {
+ color: COLORS.date,
+ fontSize: "24px",
+ fontWeight: 400,
+ fontFamily: "Inter",
+ },
+ children: formatDate(dateStr),
+ },
+ }
+ : null,
+ ].filter(Boolean),
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ color: COLORS.siteName,
+ fontSize: "20px",
+ fontWeight: 400,
+ fontFamily: "Inter",
+ },
+ children: siteName,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ };
+}
+
+function scanContentFiles(contentDir) {
+ const files = [];
+ function walk(dir) {
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (entry.name === ".indiekit") continue;
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(fullPath);
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
+ files.push(fullPath);
+ }
+ }
+ }
+ walk(contentDir);
+ return files;
+}
+
+/**
+ * Generate OG images for all content posts without photos.
+ * @param {string} contentDir - Path to content/ directory
+ * @param {string} cacheDir - Path to .cache/ directory
+ * @param {string} siteName - Site name for the card
+ */
+export async function generateOgImages(contentDir, cacheDir, siteName) {
+ const ogDir = join(cacheDir, "og");
+ mkdirSync(ogDir, { recursive: true });
+
+ const manifestPath = join(ogDir, "manifest.json");
+ let manifest = {};
+ try {
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+ } catch {
+ // First run
+ }
+
+ const fonts = loadFonts();
+ const mdFiles = scanContentFiles(contentDir);
+
+ let generated = 0;
+ let skipped = 0;
+ const newManifest = {};
+
+ for (const filePath of mdFiles) {
+ const raw = readFileSync(filePath, "utf8");
+ const { data: fm } = matter(raw);
+
+ if (fm.photo || fm.image) {
+ skipped++;
+ continue;
+ }
+
+ const slug = basename(filePath, ".md");
+ const title = fm.title || fm.name || extractBodyText(raw);
+ const date = fm.published || fm.date || "";
+ const postType = detectPostType(filePath);
+ const hash = computeHash(title, date, postType, siteName);
+
+ if (manifest[slug]?.hash === hash && existsSync(join(ogDir, `${slug}.png`))) {
+ newManifest[slug] = manifest[slug];
+ skipped++;
+ continue;
+ }
+
+ const card = buildCard(title, date, postType, siteName);
+ const svg = await satori(card, { width: WIDTH, height: HEIGHT, fonts });
+ const resvg = new Resvg(svg, {
+ fitTo: { mode: "width", value: WIDTH },
+ });
+ const pngBuffer = resvg.render().asPng();
+
+ writeFileSync(join(ogDir, `${slug}.png`), pngBuffer);
+ newManifest[slug] = { title: slug, hash };
+ generated++;
+ }
+
+ writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
+ console.log(
+ `[og] Generated ${generated} images, skipped ${skipped} (cached or have photos)`,
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index f41c894..3594c53 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,13 +15,17 @@
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@atproto/api": "^0.12.0",
"@chrisburnell/eleventy-cache-webmentions": "^2.2.7",
+ "@fontsource/inter": "^5.2.8",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
+ "@resvg/resvg-js": "^2.6.2",
"eleventy-plugin-embed-everything": "^1.21.0",
+ "gray-matter": "^4.0.3",
"html-minifier-terser": "^7.0.0",
"markdown-it": "^14.0.0",
"markdown-it-anchor": "^9.2.0",
"pagefind": "^1.3.0",
- "rss-parser": "^3.13.0"
+ "rss-parser": "^3.13.0",
+ "satori": "^0.19.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.0",
@@ -466,6 +470,15 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@fontsource/inter": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
+ "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -1001,6 +1014,221 @@
"node": ">=10.0.0"
}
},
+ "node_modules/@resvg/resvg-js": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
+ "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@resvg/resvg-js-android-arm-eabi": "2.6.2",
+ "@resvg/resvg-js-android-arm64": "2.6.2",
+ "@resvg/resvg-js-darwin-arm64": "2.6.2",
+ "@resvg/resvg-js-darwin-x64": "2.6.2",
+ "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
+ "@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
+ "@resvg/resvg-js-linux-arm64-musl": "2.6.2",
+ "@resvg/resvg-js-linux-x64-gnu": "2.6.2",
+ "@resvg/resvg-js-linux-x64-musl": "2.6.2",
+ "@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
+ "@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
+ "@resvg/resvg-js-win32-x64-msvc": "2.6.2"
+ }
+ },
+ "node_modules/@resvg/resvg-js-android-arm-eabi": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
+ "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-android-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
+ "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-darwin-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
+ "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-darwin-x64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
+ "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
+ "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-arm64-gnu": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
+ "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-arm64-musl": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
+ "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-x64-gnu": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
+ "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-linux-x64-musl": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
+ "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-win32-arm64-msvc": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
+ "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-win32-ia32-msvc": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
+ "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@resvg/resvg-js-win32-x64-msvc": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
+ "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@rgrove/parse-xml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.2.0.tgz",
@@ -1010,6 +1238,22 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@shuding/opentype.js": {
+ "version": "1.4.0-beta.0",
+ "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
+ "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
+ "license": "MIT",
+ "dependencies": {
+ "fflate": "^0.7.3",
+ "string.prototype.codepointat": "^0.2.1"
+ },
+ "bin": {
+ "ot": "bin/ot"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
"node_modules/@sindresorhus/slugify": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
@@ -1297,6 +1541,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.9.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
@@ -1452,6 +1705,15 @@
"node": ">= 6"
}
},
+ "node_modules/camelize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -1580,6 +1842,47 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
+ "node_modules/css-background-parser": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
+ "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
+ "license": "MIT"
+ },
+ "node_modules/css-box-shadow": {
+ "version": "1.0.0-3",
+ "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
+ "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
+ "license": "MIT"
+ },
+ "node_modules/css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/css-gradient-parser": {
+ "version": "0.0.17",
+ "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz",
+ "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/css-to-react-native": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1982,6 +2285,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/emoji-regex-xs": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
+ "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -2145,6 +2457,12 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz",
+ "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
+ "license": "MIT"
+ },
"node_modules/filesize": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
@@ -2393,6 +2711,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/hex-rgb": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
+ "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/html-minifier-terser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
@@ -2755,6 +3085,16 @@
"url": "https://github.com/sponsors/antonk52"
}
},
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -3225,6 +3565,12 @@
"@pagefind/windows-x64": "1.4.0"
}
},
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -3235,6 +3581,16 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/parse-css-color": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
+ "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.1.4",
+ "hex-rgb": "^4.1.0"
+ }
+ },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -3562,7 +3918,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/posthtml": {
@@ -3946,6 +4301,28 @@
"entities": "^4.4.0"
}
},
+ "node_modules/satori": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/satori/-/satori-0.19.2.tgz",
+ "integrity": "sha512-71plFHWcq6WJBM5sf/n0eHOmTBiKLUB/G8du7SmLTTLHKEKrV3TPHGKcEVIoyjnbhnjvu9HhLyF9MATB/zzL7g==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@shuding/opentype.js": "1.4.0-beta.0",
+ "css-background-parser": "^0.1.0",
+ "css-box-shadow": "1.0.0-3",
+ "css-gradient-parser": "^0.0.17",
+ "css-to-react-native": "^3.0.0",
+ "emoji-regex-xs": "^2.0.1",
+ "escape-html": "^1.0.3",
+ "linebreak": "^1.1.0",
+ "parse-css-color": "^0.2.1",
+ "postcss-value-parser": "^4.2.0",
+ "yoga-layout": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/sax": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
@@ -4182,6 +4559,12 @@
"node": ">=8"
}
},
+ "node_modules/string.prototype.codepointat": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
+ "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
+ "license": "MIT"
+ },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -4369,6 +4752,12 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4455,6 +4844,16 @@
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==",
"license": "MIT"
},
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -4656,6 +5055,12 @@
"node": ">=12"
}
},
+ "node_modules/yoga-layout": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
+ "license": "MIT"
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
diff --git a/package.json b/package.json
index 1380505..1bfe437 100644
--- a/package.json
+++ b/package.json
@@ -16,13 +16,17 @@
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@atproto/api": "^0.12.0",
"@chrisburnell/eleventy-cache-webmentions": "^2.2.7",
+ "@fontsource/inter": "^5.2.8",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
+ "@resvg/resvg-js": "^2.6.2",
"eleventy-plugin-embed-everything": "^1.21.0",
+ "gray-matter": "^4.0.3",
"html-minifier-terser": "^7.0.0",
"markdown-it": "^14.0.0",
"markdown-it-anchor": "^9.2.0",
"pagefind": "^1.3.0",
- "rss-parser": "^3.13.0"
+ "rss-parser": "^3.13.0",
+ "satori": "^0.19.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.0",