@docyrus/logo-asset-generator
Version:
CLI tool to generate favicon and app icons from a source logo
124 lines (122 loc) • 5.46 kB
JavaScript
// src/index.ts
import sharp from "sharp";
import fs from "fs/promises";
import path from "path";
import axios from "axios";
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 axios.get(url, { responseType: "arraybuffer" });
return Buffer.from(response.data);
}
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", "\n" + metaTags.join("\n"));
} else {
head.innerHTML = metaTags.join("\n") + "\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
};