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
JavaScript
;
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();
});