+
{title}
+ {subtitle && (
+
{subtitle}
+ )} {description && ( -{description}
+{description}
)} - +diff --git a/package-lock.json b/package-lock.json index 4a7df24..2fd0a36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,15 @@ "dependencies": { "@astrojs/rss": "^4.0.14", "@astrojs/sitemap": "^3.6.0", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "astro": "^5.16.6", "markdown-it": "^14.1.0", "mermaid": "^11.12.2", + "react": "^19.2.3", "sanitize-html": "^2.17.0", + "satori": "^0.19.1", "sharp": "^0.34.5", "tailwindcss": "^4.1.18" }, @@ -1212,6 +1215,221 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "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/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1593,6 +1811,22 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "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/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -2581,6 +2815,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2725,6 +2968,12 @@ "node": ">=6" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2793,6 +3042,36 @@ "uncrypto": "^0.1.3" } }, + "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-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -2809,6 +3088,17 @@ "url": "https://github.com/sponsors/fb55" } }, + "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/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -3616,6 +3906,15 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "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/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -3688,6 +3987,12 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -3762,6 +4067,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/flattie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", @@ -4036,6 +4347,18 @@ "url": "https://opencollective.com/unified" } }, + "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-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4538,6 +4861,25 @@ "url": "https://opencollective.com/parcel" } }, + "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/linebreak/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/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -5657,6 +5999,16 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, + "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-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -5797,6 +6149,12 @@ "node": ">=4" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -5844,6 +6202,15 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -6188,6 +6555,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/satori": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.19.1.tgz", + "integrity": "sha512-/XaT/JiWLfNlgjlQdde4wXB1/6F+FEze9c3OW2QIH0ywsfOrY57YOetgESWyOFHW3JfEQ6dJAo2U9Xwb7+DDAw==", + "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.3", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", @@ -6351,6 +6740,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7163,6 +7558,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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 346ccee..8815011 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,21 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", - "new": "node scripts/new-post.js" + "new": "node scripts/new-post.js", + "cover": "node scripts/generate-cover.js" }, "dependencies": { "@astrojs/rss": "^4.0.14", "@astrojs/sitemap": "^3.6.0", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "astro": "^5.16.6", "markdown-it": "^14.1.0", "mermaid": "^11.12.2", + "react": "^19.2.3", "sanitize-html": "^2.17.0", + "satori": "^0.19.1", "sharp": "^0.34.5", "tailwindcss": "^4.1.18" }, diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..8b5bfea Binary files /dev/null and b/public/images/logo.png differ diff --git a/scripts/assets/calvin.png b/scripts/assets/calvin.png new file mode 100644 index 0000000..e5d14af Binary files /dev/null and b/scripts/assets/calvin.png differ diff --git a/scripts/assets/inter-bold.woff b/scripts/assets/inter-bold.woff new file mode 100644 index 0000000..32ab5ea Binary files /dev/null and b/scripts/assets/inter-bold.woff differ diff --git a/scripts/assets/inter-italic.woff b/scripts/assets/inter-italic.woff new file mode 100644 index 0000000..35e0a7f --- /dev/null +++ b/scripts/assets/inter-italic.woff @@ -0,0 +1,11 @@ + + + + +
404. That’s an error. +
The requested URL {subtitle} {description} {description}/s/inter/v13/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7SUc.woff was not found on this server. That’s all we know.
diff --git a/scripts/assets/inter-regular.woff b/scripts/assets/inter-regular.woff
new file mode 100644
index 0000000..53c8e9a
Binary files /dev/null and b/scripts/assets/inter-regular.woff differ
diff --git a/scripts/assets/logo.png b/scripts/assets/logo.png
new file mode 100644
index 0000000..8b5bfea
Binary files /dev/null and b/scripts/assets/logo.png differ
diff --git a/scripts/assets/lora-italic.woff b/scripts/assets/lora-italic.woff
new file mode 100644
index 0000000..11c76b2
Binary files /dev/null and b/scripts/assets/lora-italic.woff differ
diff --git a/scripts/assets/lora-italic.woff2 b/scripts/assets/lora-italic.woff2
new file mode 100644
index 0000000..81e6cf8
Binary files /dev/null and b/scripts/assets/lora-italic.woff2 differ
diff --git a/scripts/generate-cover.js b/scripts/generate-cover.js
new file mode 100644
index 0000000..406bf97
--- /dev/null
+++ b/scripts/generate-cover.js
@@ -0,0 +1,484 @@
+import satori from "satori";
+import { Resvg } from "@resvg/resvg-js";
+import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
+import { readFile } from "fs/promises";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+import { createInterface } from "readline";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Dimensions
+const WIDTH = 1920;
+const HEIGHT = 1080;
+
+// Brand colors
+const COLORS = {
+ blue: "#3B8DBD",
+ darkBlue: "#1a5a7a",
+ darkerBlue: "#0d3d54",
+ darkestBlue: "#0a2a3a",
+ orange: "#FFB833",
+ white: "#FFFFFF",
+ gray: "#6D6E71",
+};
+
+// Load image as base64
+function loadImageBase64(filename) {
+ const imagePath = join(__dirname, "assets", filename);
+ const buffer = readFileSync(imagePath);
+ return `data:image/png;base64,${buffer.toString("base64")}`;
+}
+
+// Load fonts from local files
+function loadFont() {
+ const fontPath = join(__dirname, "assets", "inter-regular.woff");
+ return readFileSync(fontPath);
+}
+
+function loadFontBold() {
+ const fontPath = join(__dirname, "assets", "inter-bold.woff");
+ return readFileSync(fontPath);
+}
+
+function loadFontLoraItalic() {
+ const fontPath = join(__dirname, "assets", "lora-italic.woff");
+ return readFileSync(fontPath);
+}
+
+// Create the cover template
+function createTemplate(title, subtitle, logoBase64, calvinBase64) {
+ return {
+ type: "div",
+ props: {
+ style: {
+ width: WIDTH,
+ height: HEIGHT,
+ display: "flex",
+ flexDirection: "column",
+ position: "relative",
+ overflow: "hidden",
+ backgroundColor: COLORS.darkestBlue,
+ },
+ children: [
+ // Background layers (angled shapes)
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ display: "flex",
+ },
+ children: [
+ // Darkest layer (base)
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ width: "100%",
+ height: "100%",
+ backgroundColor: COLORS.darkestBlue,
+ },
+ },
+ },
+ // Dark blue angled layer
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "absolute",
+ top: 0,
+ left: "15%",
+ width: "100%",
+ height: "100%",
+ backgroundColor: COLORS.darkerBlue,
+ transform: "skewX(-15deg)",
+ },
+ },
+ },
+ // Medium blue angled layer
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "absolute",
+ top: 0,
+ left: "30%",
+ width: "100%",
+ height: "100%",
+ backgroundColor: COLORS.darkBlue,
+ transform: "skewX(-15deg)",
+ },
+ },
+ },
+ // Light blue angled layer
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "absolute",
+ top: 0,
+ left: "45%",
+ width: "100%",
+ height: "100%",
+ backgroundColor: COLORS.blue,
+ transform: "skewX(-15deg)",
+ },
+ },
+ },
+ ],
+ },
+ },
+ // Decorative code brackets
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ fontSize: 840,
+ fontWeight: 700,
+ color: COLORS.white,
+ opacity: 0.06,
+ letterSpacing: -10,
+ },
+ children: ">",
+ },
+ },
+ // Content container
+ {
+ type: "div",
+ props: {
+ style: {
+ position: "relative",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ width: "100%",
+ height: "100%",
+ padding: 60,
+ },
+ children: [
+ // Title section (top)
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: 16,
+ paddingLeft: 20,
+ },
+ children: [
+ // Main title (max 2 lines to avoid overlapping Calvin)
+ {
+ type: "div",
+ props: {
+ style: {
+ fontSize: 92,
+ fontWeight: 700,
+ color: COLORS.white,
+ lineHeight: 1.1,
+ maxWidth: "90%",
+ textShadow: "0 4px 12px rgba(0,0,0,0.4)",
+ },
+ children: title,
+ },
+ },
+ // Subtitle (can wrap to multiple lines, width constrained to avoid Calvin)
+ subtitle
+ ? {
+ type: "div",
+ props: {
+ style: {
+ fontSize: 78,
+ fontWeight: 400,
+ color: COLORS.orange,
+ lineHeight: 1.2,
+ maxWidth: "85%",
+ textShadow: "0 3px 6px rgba(0,0,0,0.7), 0 1px 2px rgba(0,0,0,0.5)",
+ },
+ children: subtitle,
+ },
+ }
+ : null,
+ ].filter(Boolean),
+ },
+ },
+ // Logo (bottom left)
+ {
+ type: "img",
+ props: {
+ src: logoBase64,
+ width: 320,
+ height: 166,
+ style: {
+ objectFit: "contain",
+ opacity: 0.4,
+ },
+ },
+ },
+ ],
+ },
+ },
+ // Calvin mascot (absolute positioned, bottom right)
+ {
+ type: "img",
+ props: {
+ src: calvinBase64,
+ width: 700,
+ height: 875,
+ style: {
+ position: "absolute",
+ bottom: -40,
+ right: 0,
+ objectFit: "contain",
+ transform: "scaleX(-1)",
+ },
+ },
+ },
+ ],
+ },
+ };
+}
+
+// Generate the cover image
+async function generateCover(title, subtitle) {
+ const font = loadFont();
+ const fontBold = loadFontBold();
+ const fontLoraItalic = loadFontLoraItalic();
+ const logoBase64 = loadImageBase64("logo.png");
+ const calvinBase64 = loadImageBase64("calvin.png");
+
+ const template = createTemplate(title, subtitle, logoBase64, calvinBase64);
+
+ const svg = await satori(template, {
+ width: WIDTH,
+ height: HEIGHT,
+ fonts: [
+ { name: "Inter", data: font, weight: 400, style: "normal" },
+ { name: "Inter", data: fontBold, weight: 700, style: "normal" },
+ { name: "Lora", data: fontLoraItalic, weight: 400, style: "italic" },
+ ],
+ });
+
+ const resvg = new Resvg(svg, {
+ fitTo: { mode: "width", value: WIDTH },
+ });
+
+ return Buffer.from(resvg.render().asPng());
+}
+
+// Parse frontmatter from markdown file
+function parseFrontmatter(content) {
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
+ if (!match) return {};
+
+ const frontmatter = {};
+ const lines = match[1].split("\n");
+
+ for (const line of lines) {
+ const colonIndex = line.indexOf(":");
+ if (colonIndex > 0) {
+ const key = line.slice(0, colonIndex).trim();
+ let value = line.slice(colonIndex + 1).trim();
+ // Remove quotes
+ if (value.startsWith('"') && value.endsWith('"')) {
+ value = value.slice(1, -1);
+ }
+ frontmatter[key] = value;
+ }
+ }
+
+ return frontmatter;
+}
+
+// Find all blog posts
+function findBlogPosts() {
+ const blogDir = join(process.cwd(), "src", "content", "blog");
+ const posts = [];
+
+ const years = readdirSync(blogDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name);
+
+ for (const year of years) {
+ const yearDir = join(blogDir, year);
+ const slugs = readdirSync(yearDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name);
+
+ for (const slug of slugs) {
+ const postDir = join(yearDir, slug);
+ const indexPath = join(postDir, "index.md");
+
+ if (existsSync(indexPath)) {
+ posts.push({
+ year,
+ slug,
+ path: postDir,
+ indexPath,
+ });
+ }
+ }
+ }
+
+ return posts;
+}
+
+// Interactive post selection
+async function selectPost(posts) {
+ const rl = createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ const ask = (q) =>
+ new Promise((resolve) => rl.question(q, (a) => resolve(a.trim())));
+
+ console.log("\nAvailable posts without cover images:\n");
+
+ const postsWithoutCover = posts.filter(
+ (p) => !existsSync(join(p.path, "cover.png"))
+ );
+
+ if (postsWithoutCover.length === 0) {
+ console.log("All posts have cover images!");
+ rl.close();
+ return null;
+ }
+
+ postsWithoutCover.forEach((p, i) => {
+ console.log(` ${i + 1}. ${p.year}/${p.slug}`);
+ });
+
+ console.log(`\n 0. Generate for a specific path`);
+ console.log(` a. Generate all missing covers\n`);
+
+ const choice = await ask("Select an option: ");
+ rl.close();
+
+ if (choice.toLowerCase() === "a") {
+ return postsWithoutCover;
+ }
+
+ const index = parseInt(choice, 10);
+ if (index === 0) {
+ return "manual";
+ }
+
+ if (index > 0 && index <= postsWithoutCover.length) {
+ return [postsWithoutCover[index - 1]];
+ }
+
+ return null;
+}
+
+// Generate cover for a single post
+async function generateForPost(post) {
+ const content = readFileSync(post.indexPath, "utf-8");
+ const frontmatter = parseFrontmatter(content);
+
+ if (!frontmatter.title) {
+ console.log(` Skipping ${post.slug}: No title found`);
+ return false;
+ }
+
+ // Use title as-is, optional subtitle from frontmatter
+ const title = frontmatter.title;
+ const subtitle = frontmatter.subtitle || null;
+
+ console.log(` Generating: ${post.year}/${post.slug}`);
+ console.log(` Title: ${title}`);
+ if (subtitle) console.log(` Subtitle: ${subtitle}`);
+
+ const png = await generateCover(title, subtitle);
+ const outputPath = join(post.path, "cover.png");
+ writeFileSync(outputPath, png);
+
+ console.log(` Saved: ${outputPath}\n`);
+ return true;
+}
+
+// Main CLI
+async function main() {
+ const args = process.argv.slice(2);
+
+ // Direct path provided
+ if (args[0]) {
+ const postPath = args[0];
+ const indexPath = join(postPath, "index.md");
+
+ if (!existsSync(indexPath)) {
+ console.error(`Error: No index.md found at ${postPath}`);
+ process.exit(1);
+ }
+
+ const post = {
+ path: postPath,
+ indexPath,
+ slug: postPath.split(/[/\\]/).pop(),
+ year: postPath.split(/[/\\]/).slice(-2)[0],
+ };
+
+ await generateForPost(post);
+ return;
+ }
+
+ // Interactive mode
+ const posts = findBlogPosts();
+ const selection = await selectPost(posts);
+
+ if (!selection) {
+ console.log("No selection made.");
+ return;
+ }
+
+ if (selection === "manual") {
+ const rl = createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+ const path = await new Promise((resolve) =>
+ rl.question("Enter post path: ", resolve)
+ );
+ rl.close();
+
+ const indexPath = join(path, "index.md");
+ if (!existsSync(indexPath)) {
+ console.error(`Error: No index.md found at ${path}`);
+ process.exit(1);
+ }
+
+ await generateForPost({
+ path,
+ indexPath,
+ slug: path.split(/[/\\]/).pop(),
+ year: path.split(/[/\\]/).slice(-2)[0],
+ });
+ return;
+ }
+
+ // Generate for selected posts
+ console.log(`\nGenerating ${selection.length} cover(s)...\n`);
+
+ for (const post of selection) {
+ await generateForPost(post);
+ }
+
+ console.log("Done!");
+}
+
+// Export for use in new-post.js
+export { generateCover, parseFrontmatter };
+
+main().catch(console.error);
diff --git a/scripts/new-post.js b/scripts/new-post.js
index 4bd2eda..6863fcd 100644
--- a/scripts/new-post.js
+++ b/scripts/new-post.js
@@ -1,6 +1,11 @@
import { createInterface } from "readline";
import { mkdir, writeFile } from "fs/promises";
import { existsSync } from "fs";
+import { spawn } from "child_process";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
const rl = createInterface({
input: process.stdin,
@@ -29,6 +34,19 @@ function getISODate() {
return now.toISOString().replace("Z", "").split(".")[0] + offsetStr;
}
+async function generateCover(postDir) {
+ return new Promise((resolve, reject) => {
+ const scriptPath = join(__dirname, "generate-cover.js");
+ const child = spawn("node", [scriptPath, postDir], {
+ stdio: "inherit",
+ });
+ child.on("close", (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`Cover generation failed with code ${code}`));
+ });
+ });
+}
+
async function main() {
console.log("\n📝 New Blog Post\n");
@@ -49,7 +67,14 @@ async function main() {
: [];
const description = await ask("Description (optional): ");
- const includeCover = await ask("Include cover image? (y/N): ");
+
+ const includeCover = await ask("Generate cover image? (y/N): ");
+ const wantsCover = includeCover.toLowerCase() === "y";
+
+ let subtitle = "";
+ if (wantsCover) {
+ subtitle = await ask("Cover subtitle (optional): ");
+ }
rl.close();
@@ -72,9 +97,8 @@ async function main() {
frontmatter.push(`description: "${description}"`);
}
- const wantsCover = includeCover.toLowerCase() === "y";
- if (wantsCover) {
- frontmatter.push("image: ./cover.png");
+ if (subtitle) {
+ frontmatter.push(`subtitle: "${subtitle}"`);
}
frontmatter.push("---", "", "Your content here...", "");
@@ -83,8 +107,15 @@ async function main() {
await writeFile(`${dir}/index.md`, frontmatter.join("\n"));
console.log(`\n✅ Created: ${dir}/index.md`);
+
if (wantsCover) {
- console.log(`📷 Don't forget to add cover.png to ${dir}/`);
+ console.log(`\n🎨 Generating cover image...`);
+ try {
+ await generateCover(dir);
+ } catch (err) {
+ console.log(`⚠️ Cover generation failed: ${err.message}`);
+ console.log(` You can run 'npm run cover' later to generate it.`);
+ }
}
}
diff --git a/src/components/PostCard.astro b/src/components/PostCard.astro
index 32fa0e1..9b13018 100644
--- a/src/components/PostCard.astro
+++ b/src/components/PostCard.astro
@@ -4,6 +4,7 @@ import type { ImageMetadata } from 'astro';
interface Props {
title: string;
+ subtitle?: string;
slug: string;
date: Date;
description?: string;
@@ -11,7 +12,7 @@ interface Props {
image?: ImageMetadata;
}
-const { title, slug, date, description, categories, image } = Astro.props;
+const { title, subtitle, slug, date, description, categories, image } = Astro.props;
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
@@ -21,20 +22,22 @@ const formattedDate = date.toLocaleDateString('en-US', {
});
---
-
+
{title}
+ {subtitle && (
+