From ce624815d421e7a707c959c71e7576309985e6cb Mon Sep 17 00:00:00 2001 From: conradolandia Date: Wed, 2 Oct 2024 21:27:16 -0500 Subject: [PATCH] Testing the new OG image generator system --- .github/workflows/build.yaml | 5 + package-lock.json | 109 +++++++++++ package.json | 6 +- scripts/generate-og-images.js | 176 ++++++++++++++++++ scripts/templates/og-template.svg | 33 ++++ scripts/utils.js | 100 ++++++++++ .../vite-plugin-copy-images.js | 0 src/hooks.server.js | 173 +++++------------ src/lib/utils/index.js | 4 +- vite.config.js | 2 +- 10 files changed, 479 insertions(+), 129 deletions(-) create mode 100644 scripts/generate-og-images.js create mode 100644 scripts/templates/og-template.svg create mode 100644 scripts/utils.js rename vite-plugin-copy-images.js => scripts/vite-plugin-copy-images.js (100%) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 62a18e80..8dc092b1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -36,6 +36,11 @@ jobs: - name: Install dependencies shell: bash run: ./ci/install.sh + - name: Install Fonts + run: | + mkdir -p ~/.fonts/silka + cp -r static/assets/fonts/silka/* ~/.fonts/silka/ + fc-cache -f -v - name: Build site shell: bash run: ./ci/build.sh diff --git a/package-lock.json b/package-lock.json index 9170b82e..f510e66c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "license": "MIT", "dependencies": { + "gray-matter": "^4.0.3", "rehype-class-names": "^2.0.0", "rehype-rewrite": "^4.0.2", "rehype-title-figure": "^0.1.2", @@ -1409,6 +1410,15 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2019,6 +2029,19 @@ "dev": true, "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2034,6 +2057,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -2328,6 +2363,21 @@ "dev": true, "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2562,6 +2612,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2659,6 +2718,19 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -2672,6 +2744,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -3707,6 +3788,19 @@ "node": ">=6" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3854,6 +3948,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3958,6 +4058,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-outer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", diff --git a/package.json b/package.json index 339cc740..c45bcda2 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "license": "MIT", "type": "module", "scripts": { - "dev": "vite dev", - "build": "vite build", + "generate-og-images": "node scripts/generate-og-images.js", + "dev": "npm run generate-og-images && vite dev", + "build": "npm run generate-og-images && vite build", "preview": "vite preview", "deploy": "gh-pages -d build -t true" }, @@ -24,6 +25,7 @@ "vite": "^5.1.6" }, "dependencies": { + "gray-matter": "^4.0.3", "rehype-class-names": "^2.0.0", "rehype-rewrite": "^4.0.2", "rehype-title-figure": "^0.1.2", diff --git a/scripts/generate-og-images.js b/scripts/generate-og-images.js new file mode 100644 index 00000000..406c72e9 --- /dev/null +++ b/scripts/generate-og-images.js @@ -0,0 +1,176 @@ +import sharp from 'sharp'; +import path from 'path'; +import fs from 'fs'; +import { promisify } from 'util'; +import { + fetchMarkdownPostsMetadata, + formattedPubDate, + fetchAuthorMetadata, +} from './utils.js'; + +const writeFile = promisify(fs.writeFile); +const mkdir = promisify(fs.mkdir); +const access = promisify(fs.access); +const readFile = promisify(fs.readFile); + +/** + * Checks if a given path exists. + * @param {string} pathToCheck - Path to verify. + * @returns {Promise} - True if exists, false otherwise. + */ +async function exists(pathToCheck) { + try { + await access(pathToCheck, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + +/** + * Splits a title into multiple lines if it exceeds a maximum length. + * @param {string} title - Title to split. + * @param {number} maxLineLength - Maximum length of each line. + * @returns {Array} - Array of title lines. + */ +function splitTitle(title, maxLineLength = 30) { + const words = title.split(' '); + const lines = []; + let currentLine = ''; + + for (const word of words) { + if ((currentLine + word).length > maxLineLength) { + lines.push(currentLine.trim()); + currentLine = word + ' '; + } else { + currentLine += word + ' '; + } + } + + if (currentLine.trim()) { + lines.push(currentLine.trim()); + } + + return lines; +} + +/** + * Generates an Open Graph image for a specific post. + * @param {object} data - Data for the SVG template. + * @param {string} slug - Slug of the post. + */ +async function generateOgImage(data, slug) { + const width = 1200; + const height = 630; + const titleLines = splitTitle(data.title); + const bylineY = height - 120; + + // Generate multiple elements for each title line + const titleTexts = titleLines + .map((line, index) => `${line}`) + .join('\n '); + + // Read the SVG template + const templatePath = path.join(process.cwd(), 'scripts', 'templates', 'og-template.svg'); + const templateContent = await readFile(templatePath, 'utf-8'); + + // Replace placeholders with actual data + const svg = templateContent + .replace('${width}', width) + .replace('${height}', height) + .replace('${titleTexts}', titleTexts) + .replace('${bylineY}', bylineY) + .replace('${author}', data.author) + .replace('${pubDate}', data.pubDate); + + try { + // Create SVG buffer + const svgBuffer = Buffer.from(svg); + + // Generate image using sharp + const image = await sharp({ + create: { + width: width, + height: height, + channels: 4, + background: { r: 247, g: 247, b: 242, alpha: 1 }, + }, + }) + .composite([ + { + input: svgBuffer, + top: 0, + left: 0, + }, + ]) + .png() + .toBuffer(); + + // Output directory and path + const outputDir = path.join(process.cwd(), 'static', 'assets', 'og'); + const outputPath = path.join(outputDir, `${slug}.png`); + + // Ensure output directory exists + if (!(await exists(outputDir))) { + await mkdir(outputDir, { recursive: true }); + } + + // Save the image to the filesystem + await writeFile(outputPath, image); + console.log(`OG image generated for post: ${slug}`); + } catch (error) { + console.error('Error generating OG image:', error); + } +} + +/** + * Generates Open Graph images for all Markdown posts. + */ +async function generateAllOgImages() { + try { + const posts = await fetchMarkdownPostsMetadata(); + + for (const post of posts) { + const { meta, path: postPath } = post; + const slug = path.basename(postPath); + const imagePath = path.join('static', 'assets', 'og', `${slug}.png`); + + // Check if the image already exists + if (await exists(imagePath)) { + console.log(`OG image for post "${slug}" already exists. Skipping.`); + continue; + } + + // Fetch author metadata + const authorMetadata = await fetchAuthorMetadata(meta.author); + + if (!authorMetadata) { + console.warn( + `Could not fetch metadata for author "${meta.author}". Skipping image generation for post "${slug}".` + ); + continue; + } + + // Format publication date + const pubDateFormatted = formattedPubDate(meta.pub_date); + + // Prepare data for the SVG + const svgData = { + title: meta.title, + author: authorMetadata.name, + pubDate: pubDateFormatted, + }; + + // Generate the OG image + await generateOgImage(svgData, slug); + } + + console.log('All Open Graph images have been successfully generated.'); + } catch (error) { + console.error('Error during Open Graph image generation:', error); + process.exit(1); // Exit with failure + } +} + +// Execute the script +generateAllOgImages(); diff --git a/scripts/templates/og-template.svg b/scripts/templates/og-template.svg new file mode 100644 index 00000000..ad1e58f2 --- /dev/null +++ b/scripts/templates/og-template.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + ${titleTexts} + + diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 00000000..f6aa2cf6 --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,100 @@ +import path from 'path'; +import matter from 'gray-matter'; +import { promises as fs } from 'fs'; + +/** + * Async function to recursively get all files with a specific extension. + * @param {string} dir - Directory to start searching from. + * @param {string} ext - File extension to search for (e.g., '.md'). + * @returns {Promise} - Array of file paths. + */ +export async function getFilesRecursively(dir, ext) { + let files = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nestedFiles = await getFilesRecursively(fullPath, ext); + files = files.concat(nestedFiles); + } else if (path.extname(entry.name).toLowerCase() === ext) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Sort posts by publication date in descending order. + * @param {Array} posts - Array of post objects. + * @returns {Array} - Sorted array of posts. + */ +export function sortPostsByDate(posts) { + return posts.sort((a, b) => new Date(b.meta.pub_date) - new Date(a.meta.pub_date)); +} + +/** + * Format publication date. + * @param {string} date - Date in ISO or recognizable format. + * @returns {string} - Formatted date in "Month Day, Year". + */ +export function formattedPubDate(date) { + const options = { year: 'numeric', month: 'long', day: 'numeric' }; + return new Date(date).toLocaleDateString('en-US', options); +} + +/** + * Fetch author metadata. + * @param {string} author - Author's name. + * @returns {Promise} - Object containing author information. + */ +export async function fetchAuthorMetadata(author) { + try { + const metadataPath = path.join(process.cwd(), 'static', 'assets', 'authors', author, 'metadata.json'); + const metadataContent = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(metadataContent); + + return { + src: `/assets/authors/${author}/${metadata.image}`, + name: metadata.name, + }; + } catch (error) { + console.error(`Failed to load metadata for author "${author}":`, error); + return null; + } +} + +/** + * Fetch all Markdown posts with their metadata. + * @returns {Promise} - Array of post objects. + */ +export async function fetchMarkdownPostsMetadata() { + const postsDir = path.join(process.cwd(), 'src', 'routes', 'blog'); + const markdownFiles = await getFilesRecursively(postsDir, '.md'); + + const allPosts = await Promise.all( + markdownFiles.map(async (filePath) => { + const fileContent = await fs.readFile(filePath, 'utf-8'); + const { data: metadata, content } = matter(fileContent); + + if (!metadata.title || !metadata.author || !metadata.pub_date) { + throw new Error(`File ${filePath} is missing required metadata.`); + } + + // Extract the name of the post directory (slug) + const slug = path.basename(path.dirname(filePath)); + + return { + meta: metadata, + path: `/blog/${slug}`, + //content, // we don't need the content of the posts for this + }; + }) + ); + + // Sort posts by publication date descending + const sortedPosts = sortPostsByDate(allPosts); + + return sortedPosts; +} diff --git a/vite-plugin-copy-images.js b/scripts/vite-plugin-copy-images.js similarity index 100% rename from vite-plugin-copy-images.js rename to scripts/vite-plugin-copy-images.js diff --git a/src/hooks.server.js b/src/hooks.server.js index 27417436..b6127c83 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.js @@ -1,139 +1,66 @@ -import sharp from 'sharp'; -import { join } from "path"; -import { existsSync, createReadStream, writeFileSync, mkdirSync } from "fs"; -import { fetchMarkdownPosts, formattedPubDate, fetchAuthorMetadata } from "$lib/utils"; -import { siteUrl, ogSlug, blogSlug } from "$lib/config" - -// Export the join function from the path module for use elsewhere -export default join; - -// Set the output directory for OpenGraph images -const outputDir = join(process.cwd(), 'static', 'assets', ogSlug); - -// Ensure the output directory exists -if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }); -} - -// Split long lines for svg -function splitTitle(title, maxLineLength = 35) { - const words = title.split(' '); - const lines = []; - let currentLine = ''; - - words.forEach(word => { - if ((currentLine + word).length > maxLineLength) { - lines.push(currentLine.trim()); - currentLine = word + ' '; - } else { - currentLine += word + ' '; - } - }); - - if (currentLine) { - lines.push(currentLine.trim()); - } - - return lines; -} - -// Function to generate OpenGraph image for each blog post -const generateOgImage = async (title, author, pubDate, slug) => { - const width = 1200; - const height = 630; - const titleLines = splitTitle(title); - const titleSvg = titleLines.map((line, index) => - `${line}` - ).join(''); - try { - const svg = ` - - - - - - - - - - - - - - - - - - - - - ${titleSvg} - - ${siteUrl}${blogSlug}/${slug} - - `; - - // Create image from SVG - const svgBuffer = Buffer.from(svg); - - // Composite background, SVG, and logo - const image = await sharp({ - create: { - width: width, - height: height, - channels: 4, - background: { r: 247, g: 247, b: 242, alpha: 1 } - } - }) - .composite([ - { input: svgBuffer }, - ]) - .png() - .toBuffer(); - - // Save the image - writeFileSync(join(outputDir, `${slug}.png`), image); - } catch (error) { - console.error('Error generating OG image:', error); - } -}; - -async function generateAllOgImages(fetch) { - const posts = await fetchMarkdownPosts(); - for (const post of posts) { - const slug = post.path.split('/').pop(); - const imagePath = join(outputDir, `${slug}.png`); - const authorMetadata = await fetchAuthorMetadata(post.meta.author, fetch); - - if (!existsSync(imagePath)) { - await generateOgImage(post.meta.title, authorMetadata.name, post.meta.pub_date, slug); - } +import { join, dirname } from "path"; +import { existsSync, createReadStream } from "fs"; +import { blogSlug } from "$lib/config"; + +/** + * Returns the MIME type based on the file extension. + * @param {string} filePath - Path to the file. + * @returns {string} - MIME type. + */ +function getMimeType(filePath) { + const ext = dirname(filePath).split('.').pop().toLowerCase(); + switch (ext) { + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + case 'webp': + return 'image/webp'; + case 'webm': + return 'video/webm'; + case 'mp4': + return 'video/mp4'; + case 'mpogv': + return 'video/ogg'; + case 'mp3': + return 'audio/mpeg'; + case 'ogg': + return 'audio/ogg'; + default: + return 'application/octet-stream'; } } - /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { - // Check if the request is for a blog media element + const imageExtensions = /\.(png|jpe?g|gif|svg|webp|webm|mpogv|mp3|mp4|ogg)$/i; + + // Check if the request is for a media element in a blog post (excluding the OG image) if ( event.url.pathname.startsWith(`/${blogSlug}/`) && - event.url.pathname.match( - /\.(png|jpe?g|gif|svg|webp|webm|mpogv|mp3|ogg)$/i - ) + imageExtensions.test(event.url.pathname) ) { + // Construct the full path to the image file within src/routes const imagePath = join(process.cwd(), "src", "routes", event.url.pathname); + + // Check if the image file exists if (existsSync(imagePath)) { const stream = createReadStream(imagePath); - return new Response(stream); + const mimeType = getMimeType(imagePath); + + return new Response(stream, { + headers: { + "Content-Type": mimeType || "application/octet-stream", + }, + }); } } - // Generate OpenGraph images - await generateAllOgImages(event.fetch); - + // For all other requests, proceed as usual return resolve(event); } diff --git a/src/lib/utils/index.js b/src/lib/utils/index.js index a6a4732b..1cbbb050 100644 --- a/src/lib/utils/index.js +++ b/src/lib/utils/index.js @@ -1,5 +1,3 @@ -import { browser } from "$app/environment"; - // Determine if a variable has a value (even `false` or `0`) export const hasValue = (a) => a !== undefined && a !== null; @@ -83,7 +81,7 @@ export const randomId = (length) => // Determine the operating system and return it export const getOS = () => { - if (browser) { + if (typeof window !== "undefined") { const userAgent = navigator.userAgent.toLowerCase(); const os = { mac: ["mac"], diff --git a/vite.config.js b/vite.config.js index ec78af68..faf8ccbd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,6 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; -import copyImages from "./vite-plugin-copy-images"; +import copyImages from "./scripts/vite-plugin-copy-images"; /** @type {import('vite').UserConfig} */ export default defineConfig({