UNPKG

favium

Version:

Favium generates favicon assets from canvas in the browser and from image files in the terminal.

688 lines (681 loc) 24.3 kB
#!/usr/bin/env node "use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); // src/cli/index.ts var import_prompts = require("@clack/prompts"); var import_node_fs = require("fs"); var import_promises2 = require("fs/promises"); var import_node_path2 = require("path"); // src/cli/core.ts var import_promises = require("fs/promises"); var import_node_path = require("path"); var import_png_to_ico = __toESM(require("png-to-ico")); var import_sharp = __toESM(require("sharp")); var DEFAULT_ICO_SIZES = [16, 32, 48]; var DEFAULT_PNG_SIZES = [16, 32, 150, 180, 192, 512]; var SUPPORTED_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([ ".avif", ".gif", ".heic", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp" ]); function isSupportedImagePath(filePath) { return SUPPORTED_IMAGE_EXTENSIONS.has((0, import_node_path.extname)(filePath).toLowerCase()); } function parseSizeList(input) { const values = input.split(",").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isInteger(value) && value > 0 && value <= 1024); return [...new Set(values)].sort((left, right) => left - right); } async function collectImagesFromDirectory(directory, recursive = false) { const entries = await (0, import_promises.readdir)(directory, { withFileTypes: true }); const files = []; for (const entry of entries) { const filePath = (0, import_node_path.join)(directory, entry.name); if (entry.isDirectory()) { if (recursive) { files.push(...await collectImagesFromDirectory(filePath, true)); } continue; } if (entry.isFile() && isSupportedImagePath(filePath)) { files.push(filePath); } } return files.sort((left, right) => left.localeCompare(right)); } function isExternalImageUrl(value) { try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } async function loadImageFromPath(filePath) { const absolutePath = (0, import_node_path.resolve)(filePath); const buffer = await (0, import_promises.readFile)(absolutePath); const metadata = await (0, import_sharp.default)(buffer, { animated: true }).metadata(); if (!metadata.width || !metadata.height || !metadata.format) { throw new Error(`Unsupported image file: ${absolutePath}`); } return { kind: "custom-path", label: (0, import_node_path.relative)(process.cwd(), absolutePath) || (0, import_node_path.basename)(absolutePath), origin: absolutePath, buffer, width: metadata.width, height: metadata.height, format: metadata.format, sizeBytes: buffer.byteLength, suggestedBaseName: sanitizeBaseName((0, import_node_path.basename)(absolutePath, (0, import_node_path.extname)(absolutePath))), directory: (0, import_node_path.dirname)(absolutePath) }; } async function loadImageFromUrl(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); } const contentType = response.headers.get("content-type"); if (contentType && !contentType.startsWith("image/")) { throw new Error(`URL did not return an image. Received content-type: ${contentType}`); } const buffer = Buffer.from(await response.arrayBuffer()); const metadata = await (0, import_sharp.default)(buffer, { animated: true }).metadata(); if (!metadata.width || !metadata.height || !metadata.format) { throw new Error(`Unsupported image payload from ${url}`); } const urlObject = new URL(url); const pathname = urlObject.pathname; const rawName = (0, import_node_path.basename)(pathname, (0, import_node_path.extname)(pathname)) || "favicon"; return { kind: "external-url", label: url, origin: url, buffer, width: metadata.width, height: metadata.height, format: metadata.format, sizeBytes: buffer.byteLength, suggestedBaseName: sanitizeBaseName(rawName) }; } function sanitizeBaseName(value) { const sanitized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); return sanitized || "favicon"; } function getPresetBlueprint(preset, baseName, pngSizes = DEFAULT_PNG_SIZES, icoSizes = DEFAULT_ICO_SIZES) { if (preset === "custom") { return { icoSizes, pngOutputs: pngSizes.map((size) => ({ size, filename: `${baseName}-${size}x${size}.png`, rel: size === 180 ? "apple-touch-icon" : size <= 64 ? "icon" : void 0, manifest: size === 192 || size === 512 })), htmlSnippet: true, manifest: pngSizes.includes(192) || pngSizes.includes(512), manifestFilename: "manifest.webmanifest" }; } if (preset === "apple-android") { return { icoSizes: [16, 32, 48], pngOutputs: [ { size: 180, filename: "apple-touch-icon.png", rel: "apple-touch-icon" }, { size: 192, filename: "android-chrome-192x192.png", manifest: true }, { size: 512, filename: "android-chrome-512x512.png", manifest: true } ], htmlSnippet: true, manifest: true, manifestFilename: "manifest.webmanifest" }; } if (preset === "web-app") { return { icoSizes: [16, 32, 48, 64, 256], pngOutputs: [ { size: 16, filename: "favicon-16x16.png", rel: "icon" }, { size: 32, filename: "favicon-32x32.png", rel: "icon" }, { size: 64, filename: `${baseName}-64x64.png` }, { size: 128, filename: `${baseName}-128x128.png` }, { size: 180, filename: "apple-touch-icon.png", rel: "apple-touch-icon" }, { size: 192, filename: "android-chrome-192x192.png", manifest: true }, { size: 256, filename: "android-chrome-256x256.png" }, { size: 512, filename: "android-chrome-512x512.png", manifest: true } ], htmlSnippet: true, manifest: true, manifestFilename: "manifest.webmanifest" }; } return { icoSizes: DEFAULT_ICO_SIZES, pngOutputs: [ { size: 16, filename: "favicon-16x16.png", rel: "icon" }, { size: 32, filename: "favicon-32x32.png", rel: "icon" }, { size: 150, filename: "mstile-150x150.png" }, { size: 180, filename: "apple-touch-icon.png", rel: "apple-touch-icon" }, { size: 192, filename: "android-chrome-192x192.png", manifest: true }, { size: 512, filename: "android-chrome-512x512.png", manifest: true } ], htmlSnippet: true, manifest: true, manifestFilename: "manifest.webmanifest" }; } function renderHtmlSnippet(plan) { const lines = []; if (plan.icoSizes.length > 0) { lines.push(`<link rel="icon" href="./${plan.baseName}.ico" sizes="any">`); } for (const output of plan.pngOutputs) { if (output.rel === "icon") { lines.push( `<link rel="icon" type="image/png" sizes="${output.size}x${output.size}" href="./${output.filename}">` ); } if (output.rel === "apple-touch-icon") { lines.push( `<link rel="apple-touch-icon" sizes="${output.size}x${output.size}" href="./${output.filename}">` ); } } if (plan.manifest) { lines.push(`<link rel="manifest" href="./${plan.manifestFilename}">`); } return lines.join("\n"); } function renderManifest(plan) { if (!plan.manifestOptions) { throw new Error("Manifest options are required when manifest generation is enabled"); } const icons = plan.pngOutputs.filter((output) => output.manifest).map((output) => ({ src: `./${output.filename}`, sizes: `${output.size}x${output.size}`, type: "image/png", purpose: output.purpose ?? "any" })); return JSON.stringify( { name: plan.manifestOptions.name, short_name: plan.manifestOptions.shortName, start_url: plan.manifestOptions.startUrl, display: plan.manifestOptions.display, background_color: plan.manifestOptions.backgroundColor, theme_color: plan.manifestOptions.themeColor, icons }, null, 2 ); } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function getSuggestedOutputDirectory(source) { return (0, import_node_path.resolve)(source.directory ?? process.cwd(), source.suggestedBaseName); } function summarizePlan(source, plan) { return [ `Source: ${source.label}`, `Image: ${source.width}x${source.height} ${source.format.toUpperCase()} (${formatBytes(source.sizeBytes)})`, `Output: ${plan.outputDir}`, `ICO sizes: ${plan.icoSizes.length > 0 ? plan.icoSizes.join(", ") : "none"}`, `PNG files: ${plan.pngOutputs.map((output) => `${output.filename} (${output.size})`).join(", ")}`, `Fit: ${plan.fit}${plan.fit === "contain" ? ` on ${plan.background}` : ""}`, `HTML snippet: ${plan.htmlSnippet ? "yes" : "no"}`, `Manifest: ${plan.manifest ? "yes" : "no"}` ].join("\n"); } async function generateArtifacts(source, plan) { await (0, import_promises.mkdir)(plan.outputDir, { recursive: true }); const artifacts = []; const pngCache = /* @__PURE__ */ new Map(); for (const output of plan.pngOutputs) { const pngBuffer = pngCache.get(output.size) ?? await renderPng(source.buffer, output.size, plan.fit, plan.background); pngCache.set(output.size, pngBuffer); const outputPath = (0, import_node_path.join)(plan.outputDir, output.filename); await writeFileSafely(outputPath, pngBuffer, plan.overwrite); artifacts.push({ type: "png", filePath: outputPath }); } if (plan.icoSizes.length > 0) { const icoImages = await Promise.all( plan.icoSizes.map( (size) => renderPng(source.buffer, size, plan.fit, plan.background) ) ); const icoBuffer = await (0, import_png_to_ico.default)(icoImages); const icoPath = (0, import_node_path.join)(plan.outputDir, `${plan.baseName}.ico`); await writeFileSafely(icoPath, icoBuffer, plan.overwrite); artifacts.push({ type: "ico", filePath: icoPath }); } if (plan.htmlSnippet) { const htmlPath = (0, import_node_path.join)(plan.outputDir, `${plan.baseName}.html`); await writeFileSafely(htmlPath, renderHtmlSnippet(plan), plan.overwrite, "utf8"); artifacts.push({ type: "html", filePath: htmlPath }); } if (plan.manifest) { const manifestPath = (0, import_node_path.join)(plan.outputDir, plan.manifestFilename); await writeFileSafely(manifestPath, renderManifest(plan), plan.overwrite, "utf8"); artifacts.push({ type: "manifest", filePath: manifestPath }); } return artifacts; } async function renderPng(input, size, fit, background) { return (0, import_sharp.default)(input, { animated: true }).rotate().resize(size, size, { fit, background }).png().toBuffer(); } async function writeFileSafely(filePath, contents, overwrite, encoding) { if (!overwrite) { try { await (0, import_promises.stat)(filePath); throw new Error(`Refusing to overwrite existing file: ${filePath}`); } catch (error) { if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { throw error; } } } if (typeof contents === "string") { await (0, import_promises.writeFile)(filePath, contents, encoding ?? "utf8"); return; } await (0, import_promises.writeFile)(filePath, contents); } // src/cli/index.ts var hasExitedGracefully = false; var packageVersion = getPackageVersion(); async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); return; } if (args.version) { console.log(`favium ${packageVersion}`); return; } (0, import_prompts.intro)("Favium CLI"); try { const source = await resolveSource(args); (0, import_prompts.note)( [ `Source: ${source.label}`, `Image: ${source.width}x${source.height}`, `Format: ${source.format}` ].join("\n"), "Selected image" ); const outputDir = await resolveOutputDirectory(source, args); const baseName = await resolveBaseName(source, args.yes); const preset = await resolvePreset(args); const fit = await resolveFitMode(args.yes); const background = fit === "contain" ? await promptText("Background color for padding", "#ffffff") : "#000000"; const overwrite = args.yes ? true : await promptConfirm("Overwrite existing files if needed?", false); const blueprint = await resolveBlueprint(baseName, preset); const htmlSnippet = args.yes ? blueprint.htmlSnippet : await promptConfirm("Generate an HTML snippet file?", blueprint.htmlSnippet); const manifest = args.yes ? blueprint.manifest : await promptConfirm( "Generate a web manifest file when relevant sizes exist?", blueprint.manifest ); const plan = { baseName, outputDir, fit, background, overwrite, icoSizes: blueprint.icoSizes, pngOutputs: blueprint.pngOutputs, htmlSnippet, manifest, manifestFilename: blueprint.manifestFilename, manifestOptions: manifest ? await resolveManifestOptions(baseName, args.yes) : void 0 }; (0, import_prompts.note)(summarizePlan(source, plan), "Plan"); if (!args.yes) { const approved = await promptConfirm("Generate these assets now?", true); if (!approved) { exitGracefully(); } } const progress = (0, import_prompts.spinner)(); progress.start("Generating favicon assets"); const artifacts = await generateArtifacts(source, plan); progress.stop("Assets generated"); (0, import_prompts.note)( artifacts.map((artifact) => `- ${artifact.type}: ${artifact.filePath}`).join("\n"), "Written files" ); (0, import_prompts.outro)(`Done. ${artifacts.length} file(s) created.`); } catch (error) { (0, import_prompts.outro)(error instanceof Error ? error.message : "Unknown error"); process.exitCode = 1; } } function parseArgs(argv) { const args = { help: false, recursive: false, version: false, yes: false }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === "--help" || arg === "-h") args.help = true; if (arg === "--version" || arg === "-v") args.version = true; if (arg === "--recursive") args.recursive = true; if (arg === "--yes" || arg === "-y") args.yes = true; if (arg === "--source") args.source = argv[++index]; if (arg === "--output") args.output = argv[++index]; if (arg === "--preset") args.preset = argv[++index]; } return args; } function getPackageVersion() { try { const packageJsonPath = (0, import_node_path2.resolve)(__dirname, "..", "package.json"); const packageJson = JSON.parse((0, import_node_fs.readFileSync)(packageJsonPath, "utf8")); return packageJson.version ?? "unknown"; } catch { return "unknown"; } } async function resolveSource(args) { if (args.source) { return resolveExplicitSource(args.source, args.recursive); } while (true) { const sourceMode = await promptSelect( "Where should the source image come from?", [ { label: "Pick from current directory", value: "current-dir" }, { label: "Use a custom local path", value: "custom-path" }, { label: "Use an external image URL", value: "external-url" } ] ); try { if (sourceMode === "current-dir") { const recursive = await promptConfirm("Scan subdirectories too?", false); const files = await collectImagesFromDirectory(process.cwd(), recursive); if (files.length === 0) { (0, import_prompts.note)( "No valid image files were found in the current directory. Try another source.", "No images found" ); continue; } const selected = await promptSelect( "Select an image file", files.map((filePath) => ({ label: filePath.replace(`${process.cwd()}/`, ""), value: filePath })) ); const source = await loadImageFromPath(selected); source.kind = "current-dir"; return source; } if (sourceMode === "custom-path") { return promptLocalImageSource(); } return promptExternalImageSource(); } catch (error) { (0, import_prompts.note)( error instanceof Error ? error.message : "Failed to resolve the selected source.", "Source rejected" ); } } } async function resolveExplicitSource(sourceValue, recursive) { if (isExternalImageUrl(sourceValue)) { return loadImageFromUrl(sourceValue); } const absolutePath = (0, import_node_path2.resolve)(sourceValue); const details = await (0, import_promises2.stat)(absolutePath); if (details.isDirectory()) { const files = await collectImagesFromDirectory(absolutePath, recursive); if (files.length === 0) { throw new Error(`No valid image files found in directory: ${absolutePath}`); } const selected = await promptSelect( "Select an image file", files.map((filePath) => ({ label: filePath.replace(`${absolutePath}/`, ""), value: filePath })) ); return loadImageFromPath(selected); } return loadImageFromPath(absolutePath); } async function resolveOutputDirectory(source, args) { if (args.output) { return (0, import_node_path2.resolve)(args.output); } const suggestedDirectory = getSuggestedOutputDirectory(source); if (args.yes) { return suggestedDirectory; } const options = [ { label: `Create "${source.suggestedBaseName}" directory (Recommended)`, value: suggestedDirectory }, { label: "Current working directory", value: process.cwd() } ]; if (source.directory) { options.push({ label: `Same directory as source (${source.directory})`, value: source.directory }); } options.push({ label: "Custom directory", value: "__custom__" }); const selection = await promptSelect( "Where should the generated files be written?", options ); if (selection === "__custom__") { return (0, import_node_path2.resolve)(await promptText("Enter output directory", process.cwd())); } return (0, import_node_path2.resolve)(selection); } async function resolveBaseName(source, yes) { if (yes) { return sanitizeBaseName(source.suggestedBaseName); } return sanitizeBaseName( await promptText("Base filename for generated assets", source.suggestedBaseName) ); } async function resolvePreset(args) { if (args.preset) { return args.preset; } return promptSelect("Choose an output preset", [ { label: "Default favicon set", value: "default" }, { label: "Rich web app set", value: "web-app" }, { label: "Apple + Android essentials", value: "apple-android" }, { label: "Custom size set", value: "custom" } ]); } async function resolveFitMode(yes) { if (yes) return "cover"; return promptSelect("How should images be fit into square outputs?", [ { label: "Cover and crop to fill the square", value: "cover" }, { label: "Contain and pad the square", value: "contain" } ]); } async function resolveBlueprint(baseName, preset) { if (preset !== "custom") { return getPresetBlueprint(preset, baseName); } while (true) { const defaultPngSizes = await promptText( "PNG sizes (comma-separated)", "16,32,64,128,180,192,256,512" ); const defaultIcoSizes = await promptText( "ICO sizes (comma-separated, max 256)", "16,32,48,64,256" ); const pngSizes = parseSizeList(defaultPngSizes); const icoSizes = parseSizeList(defaultIcoSizes).filter((size) => size <= 256); if (pngSizes.length === 0) { (0, import_prompts.note)("At least one valid PNG size must be provided.", "Invalid PNG sizes"); continue; } return getPresetBlueprint("custom", baseName, pngSizes, icoSizes); } } async function resolveManifestOptions(baseName, yes) { if (yes) { return { name: baseName, shortName: baseName, backgroundColor: "#ffffff", themeColor: "#111827", display: "standalone", startUrl: "/" }; } const name = await promptText("Manifest app name", baseName); const shortName = await promptText("Manifest short name", name); const themeColor = await promptText("Theme color", "#111827"); const backgroundColor = await promptText("Background color", "#ffffff"); const startUrl = await promptText("Start URL", "/"); const display = await promptSelect("Display mode", [ { label: "Standalone", value: "standalone" }, { label: "Minimal UI", value: "minimal-ui" }, { label: "Fullscreen", value: "fullscreen" }, { label: "Browser", value: "browser" } ]); return { name, shortName, backgroundColor, themeColor, display, startUrl }; } async function promptExternalImageSource() { while (true) { const url = await promptText("Enter an image URL", "https://"); if (!isExternalImageUrl(url)) { (0, import_prompts.note)("Please enter a valid http or https image URL.", "Invalid URL"); continue; } try { return await loadImageFromUrl(url); } catch (error) { (0, import_prompts.note)( error instanceof Error ? error.message : "Failed to load the image URL.", "URL rejected" ); } } } async function promptLocalImageSource() { while (true) { const inputPath = await promptText("Enter a file or directory path", process.cwd()); try { return await resolveExplicitSource(inputPath, true); } catch (error) { (0, import_prompts.note)( error instanceof Error ? error.message : "Failed to resolve the local image path.", "Path rejected" ); } } } async function promptText(message, initialValue) { return unwrapPrompt( await (0, import_prompts.text)({ message, initialValue }) ); } async function promptConfirm(message, initialValue) { return unwrapPrompt( await (0, import_prompts.confirm)({ message, initialValue }) ); } async function promptSelect(message, options) { return unwrapPrompt( await (0, import_prompts.select)({ message, options }) ); } function unwrapPrompt(value) { if ((0, import_prompts.isCancel)(value)) { exitGracefully(); } return value; } function exitGracefully() { if (!hasExitedGracefully) { hasExitedGracefully = true; process.stdout.write("Thanks for using favium\n"); } process.exit(0); } function printHelp() { console.log(`favium Interactive favicon generator for terminal workflows. Usage: favium favium --source ./logo.png --output ./public --preset web-app --yes Options: -h, --help Show this help message -v, --version Show the current version --source Local file, local directory, or external image URL --output Output directory --preset default | web-app | apple-android | custom --recursive Recursively scan directories for valid images -y, --yes Accept defaults for optional prompts `); } void main(); process.on("SIGINT", () => { exitGracefully(); });