UNPKG

@docyrus/logo-asset-generator

Version:

CLI tool to generate favicon and app icons from a source logo

138 lines (136 loc) 5.6 kB
// src/index.ts import fs from "fs/promises"; import path from "path"; import sharp from "sharp"; import toIco from "to-ico"; import { parse as parseHtml } from "node-html-parser"; var DEFAULT_SIZES = [ { name: "favicon-16x16.png", size: 16 }, { name: "favicon-32x32.png", size: 32 }, { name: "favicon-96x96.png", size: 96 }, { name: "android-chrome-192x192.png", size: 192 }, { name: "android-chrome-512x512.png", size: 512 }, { name: "apple-touch-icon-60x60.png", size: 60 }, { name: "apple-touch-icon-76x76.png", size: 76 }, { name: "apple-touch-icon-120x120.png", size: 120 }, { name: "apple-touch-icon-152x152.png", size: 152 }, { name: "apple-touch-icon-180x180.png", size: 180 }, { name: "apple-touch-icon.png", size: 180 }, { name: "mstile-150x150.png", size: 150 }, { name: "og-image.png", size: 1200, purpose: "og" } ]; async function downloadImage(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to download image: ${response.status} ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } async function loadImage(imagePath) { if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return downloadImage(imagePath); } const resolvedPath = path.resolve(imagePath); return fs.readFile(resolvedPath); } async function generateAssets(options) { const { logoPath, htmlPath, outputDir, sizes = DEFAULT_SIZES } = options; const resolvedOutputDir = outputDir || path.dirname(htmlPath); await fs.mkdir(resolvedOutputDir, { recursive: true }); const imageBuffer = await loadImage(logoPath); const isSvg = logoPath.toLowerCase().endsWith(".svg") || logoPath.startsWith("http") && imageBuffer.toString("utf8", 0, 5).includes("<svg"); const processedImages = []; for (const sizeConfig of sizes) { const outputPath = path.join(resolvedOutputDir, sizeConfig.name); let processedBuffer; if (isSvg) { processedBuffer = await sharp(imageBuffer).resize(sizeConfig.size, sizeConfig.size).png().toBuffer(); } else { processedBuffer = await sharp(imageBuffer).resize(sizeConfig.size, sizeConfig.size).png().toBuffer(); } await fs.writeFile(outputPath, processedBuffer); if ([ 16, 32, 48, 64, 128, 256 ].includes(sizeConfig.size)) { processedImages.push({ buffer: processedBuffer, size: sizeConfig.size }); } } const icoBuffers = await Promise.all( processedImages.map((img) => sharp(img.buffer).toBuffer()) ); const icoBuffer = await toIco(icoBuffers); await fs.writeFile(path.join(resolvedOutputDir, "favicon.ico"), icoBuffer); await updateHtmlMetaTags(htmlPath, sizes); } async function updateHtmlMetaTags(htmlPath, sizes) { const htmlContent = await fs.readFile(htmlPath, "utf-8"); const root = parseHtml(htmlContent); const head = root.querySelector("head"); if (!head) { throw new Error("No <head> tag found in HTML file"); } const existingLinks = head.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"], meta[property^="og:image"]'); existingLinks.forEach((link) => link.remove()); const metaTags = []; metaTags.push('<link rel="icon" type="image/x-icon" href="/favicon.ico">'); metaTags.push('<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">'); metaTags.push('<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">'); metaTags.push('<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">'); const appleTouchIcon = sizes.find((s) => s.name === "apple-touch-icon.png"); if (appleTouchIcon) { metaTags.push('<link rel="apple-touch-icon" href="/apple-touch-icon.png">'); } sizes.filter((s) => s.name.startsWith("apple-touch-icon-") && s.name !== "apple-touch-icon.png").forEach((size) => { const sizeMatch = size.name.match(/(\d+)x\d+/); if (sizeMatch) { metaTags.push(`<link rel="apple-touch-icon" sizes="${sizeMatch[0]}" href="/${size.name}">`); } }); metaTags.push('<link rel="manifest" href="/manifest.json">'); metaTags.push('<meta name="msapplication-TileColor" content="#ffffff">'); metaTags.push('<meta name="msapplication-TileImage" content="/mstile-150x150.png">'); metaTags.push('<meta name="theme-color" content="#ffffff">'); const ogImage = sizes.find((s) => s.purpose === "og"); if (ogImage) { metaTags.push(`<meta property="og:image" content="/${ogImage.name}">`); metaTags.push('<meta property="og:image:width" content="1200">'); metaTags.push('<meta property="og:image:height" content="1200">'); } const titleTag = head.querySelector("title"); if (titleTag) { titleTag.insertAdjacentHTML("afterend", ` ${metaTags.join("\n")}`); } else { head.innerHTML = `${metaTags.join("\n")} ${head.innerHTML}`; } await fs.writeFile(htmlPath, root.toString()); const manifestPath = path.join(path.dirname(htmlPath), "manifest.json"); const manifest = { icons: [{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" }, { src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" }] }; try { const existingManifest = JSON.parse(await fs.readFile(manifestPath, "utf-8")); existingManifest.icons = manifest.icons; await fs.writeFile(manifestPath, JSON.stringify(existingManifest, null, 2)); } catch { await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); } } export { downloadImage, loadImage, generateAssets, updateHtmlMetaTags };