import path from 'node:path'; import fs from 'node:fs/promises'; import matter from 'gray-matter'; import { html } from 'satori-html'; import satori from 'satori'; import sharp from 'sharp'; import imageSize from 'image-size'; import { Resvg } from '@resvg/resvg-js'; const ALL_ROADMAP_DIR = path.join(process.cwd(), '/src/data/roadmaps'); const ALL_BEST_PRACTICE_DIR = path.join( process.cwd(), '/src/data/best-practices', ); const ALL_GUIDE_DIR = path.join(process.cwd(), '/src/data/guides'); const ALl_AUTHOR_DIR = path.join(process.cwd(), '/src/data/authors'); const ALL_ROADMAP_IMAGE_DIR = path.join(process.cwd(), '/public/roadmaps'); const ALL_BEST_PRACTICE_IMAGE_DIR = path.join( process.cwd(), '/public/best-practices', ); const ALL_AUTHOR_IMAGE_DIR = path.join(process.cwd(), '/public'); const alreadyGeneratedImages = await fs.readdir( path.join(process.cwd(), '/public/og-images'), { recursive: true, }, ); async function getAllRoadmaps() { const allRoadmapDirNames = await fs.readdir(ALL_ROADMAP_DIR); const allRoadmapFrontmatter = await Promise.all( allRoadmapDirNames.map(async (roadmapDirName) => { const roadmapDirPath = path.join( ALL_ROADMAP_DIR, roadmapDirName, `${roadmapDirName}.md`, ); const markdown = await fs.readFile(roadmapDirPath, 'utf8'); const { data } = matter(markdown); return { id: roadmapDirName, title: data?.briefTitle, description: data?.briefDescription, }; }), ); return allRoadmapFrontmatter; } async function getAllBestPractices() { const allBestPracticeDirNames = await fs.readdir(ALL_BEST_PRACTICE_DIR); const allBestPracticeFrontmatter = await Promise.all( allBestPracticeDirNames.map(async (bestPracticeDirName) => { const bestPracticeDirPath = path.join( ALL_BEST_PRACTICE_DIR, bestPracticeDirName, `${bestPracticeDirName}.md`, ); const markdown = await fs.readFile(bestPracticeDirPath, 'utf8'); const { data } = matter(markdown); return { id: bestPracticeDirName, title: data?.briefTitle, description: data?.briefDescription, }; }), ); return allBestPracticeFrontmatter; } async function getAllGuides() { const allGuideDirNames = await fs.readdir(ALL_GUIDE_DIR); const allGuideFrontmatter = await Promise.all( allGuideDirNames.map(async (guideDirName) => { const guideDirPath = path.join(ALL_GUIDE_DIR, guideDirName); const markdown = await fs.readFile(guideDirPath, 'utf8'); const { data } = matter(markdown); return { id: guideDirName?.replace('.md', ''), title: data?.title, description: data?.description, authorId: data?.authorId, }; }), ); return allGuideFrontmatter; } async function getAllAuthors() { const allAuthorDirNames = await fs.readdir(ALl_AUTHOR_DIR); const allAuthorFrontmatter = await Promise.all( allAuthorDirNames.map(async (authorDirName) => { const authorDirPath = path.join(ALl_AUTHOR_DIR, authorDirName); const markdown = await fs.readFile(authorDirPath, 'utf8'); const { data } = matter(markdown); return { id: authorDirName?.replace('.md', ''), name: data?.name, imageUrl: data?.imageUrl, }; }), ); return allAuthorFrontmatter; } async function getAllRoadmapImageIds() { const allRoadmapImageDirNames = await fs.readdir(ALL_ROADMAP_IMAGE_DIR); return allRoadmapImageDirNames?.reduce((acc, image) => { acc[image.replace(/(\.[^.]*)$/, '')] = image; return acc; }, {}); } async function getAllBestPracticeImageIds() { const allBestPracticeImageDirNames = await fs.readdir( ALL_BEST_PRACTICE_IMAGE_DIR, ); return allBestPracticeImageDirNames?.reduce((acc, image) => { acc[image.replace(/(\.[^.]*)$/, '')] = image; return acc; }, {}); } async function generateResourceOpenGraph() { const allRoadmaps = (await getAllRoadmaps()).filter( (roadmap) => !alreadyGeneratedImages.includes(`roadmaps/${roadmap.id}.png`), ); const allBestPractices = (await getAllBestPractices()).filter( (bestPractice) => !alreadyGeneratedImages.includes(`best-practices/${bestPractice.id}.png`), ); const allRoadmapImageIds = await getAllRoadmapImageIds(); const allBestPracticeImageIds = await getAllBestPracticeImageIds(); const resources = []; allRoadmaps.forEach((roadmap) => { const hasImage = allRoadmapImageIds?.[roadmap.id]; resources.push({ type: 'roadmaps', id: roadmap.id, title: roadmap.title, description: roadmap.description, image: hasImage ? path.join(ALL_ROADMAP_IMAGE_DIR, allRoadmapImageIds[roadmap.id]) : null, }); }); allBestPractices.forEach((bestPractice) => { const hasImage = allBestPracticeImageIds?.[bestPractice.id]; resources.push({ type: 'best-practices', id: bestPractice.id, title: bestPractice.title, description: bestPractice.description, image: hasImage ? path.join( ALL_BEST_PRACTICE_IMAGE_DIR, allBestPracticeImageIds[bestPractice.id], ) : null, }); }); for (const resource of resources) { if (!resource.image) { let template = getRoadmapDefaultTemplate(resource); if ( hasSpecialCharacters(resource.title) || hasSpecialCharacters(resource.description) ) { // For some reason special characters are not being rendered properly // https://github.com/natemoo-re/satori-html/issues/20 // So we need to unescape the html template = JSON.parse(unescapeHtml(JSON.stringify(template))); } await generateOpenGraph( template, resource.type, resource.id + '.png', 'resvg', ); } else { const image = await fs.readFile(resource.image); const dimensions = imageSize(image); const widthRatio = 1200 / dimensions.width; let width = dimensions.width * widthRatio * 0.85; let height = dimensions.height * widthRatio * 0.85; let template = getRoadmapImageTemplate({ ...resource, image: `data:image/${dimensions.type};base64,${image.toString('base64')}`, width, height, }); if ( hasSpecialCharacters(resource.title) || hasSpecialCharacters(resource.description) ) { // For some reason special characters are not being rendered properly // https://github.com/natemoo-re/satori-html/issues/20 // So we need to unescape the html template = JSON.parse(unescapeHtml(JSON.stringify(template))); } await generateOpenGraph(template, resource.type, resource.id + '.png'); } } } async function generateGuideOpenGraph() { const allGuides = (await getAllGuides()).filter( (guide) => !alreadyGeneratedImages.includes(`guides/${guide.id}.png`), ); const allAuthors = await getAllAuthors(); for (const guide of allGuides) { const author = allAuthors.find((author) => author.id === guide.authorId); const image = author?.imageUrl || 'https://roadmap.sh/images/default-avatar.png'; const isExternalImage = image?.startsWith('http'); let authorImageExtention = ''; let authorAvatar; if (!isExternalImage) { authorAvatar = await fs.readFile(path.join(ALL_AUTHOR_IMAGE_DIR, image)); authorImageExtention = image?.split('.')[1]; } const template = getGuideTemplate({ ...guide, authorName: author.name, authorAvatar: isExternalImage ? image : `data:image/${authorImageExtention};base64,${authorAvatar.toString('base64')}`, }); if ( hasSpecialCharacters(guide.title) || hasSpecialCharacters(guide.description) ) { // For some reason special characters are not being rendered properly // https://github.com/natemoo-re/satori-html/issues/20 // So we need to unescape the html template = JSON.parse(unescapeHtml(JSON.stringify(template))); } await generateOpenGraph(template, 'guides', guide.id + '.png'); } } async function generateOpenGraph( htmlString, type, fileName, renderer = 'sharp', ) { console.log('Started 🚀', `${type}/${fileName}`); const svg = await satori(htmlString, { width: 1200, height: 630, fonts: [ { name: 'balsamiq', data: await fs.readFile( path.join(process.cwd(), '/public/fonts/BalsamiqSans-Regular.ttf'), ), weight: 400, style: 'normal', }, ], }); await fs.mkdir(path.join(process.cwd(), '/public/og-images/' + type), { recursive: true, }); // It will be used to generate the default image // for some reasone sharp is not working with this // FIXME: Investigate why sharp is not working with this if (renderer === 'resvg') { const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 2500, }, }); const pngData = resvg.render(); const pngBuffer = pngData.asPng(); await fs.writeFile( path.join(process.cwd(), '/public/og-images/' + `${type}/${fileName}`), pngBuffer, ); } else { await sharp(Buffer.from(svg), { density: 150 }) .png() .toFile( path.join(process.cwd(), '/public/og-images/' + `${type}/${fileName}`), ); } console.log('Completed ✅', `${type}/${fileName}`); } await generateResourceOpenGraph(); await generateGuideOpenGraph(); function getRoadmapDefaultTemplate({ title, description }) { return html`
${title}
${description}
7th most starred GitHub project
Created and maintained by community
Up-to-date roadmap
`; } function getRoadmapImageTemplate({ title, description, image, height, width }) { return html`
${title?.replace('&', `{"&"}`)}
${description}
`; } function getGuideTemplate({ title, description, authorName, authorAvatar }) { return html`
${authorName}
${title}
${description}
`; } function unescapeHtml(html) { return html .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); } function hasSpecialCharacters(str) { return /[&<>"]/.test(str); }