Icons
The icons integration resolves icon metadata at build time from a file-keyed configuration. Each key is an output file name, and each value defines generation behavior, head-tag emission, and optional manifest registration.
When source is provided, the integration merges your entries over a built-in default icon set. You can replace any default by reusing its key, or disable it by setting that key to false.
Install required dependencies for raster and .ico generation:
npm install sharp sharp-icopnpm add sharp sharp-icoyarn add sharp sharp-icoOptions
Section titled “Options”IconsOptions
Section titled “IconsOptions”| option | type | required | description |
|---|---|---|---|
source | string | Yes | Global source image used for generated raster and .ico outputs unless a keyed entry sets its own source. |
[filename.ico] | { sizes: number[] | false; tag?: Partial<IconTag> & Pick<IconTag, "rel">; manifest?: boolean | ManifestIconOptions; source?: string; sharpOptions?: SharpOptions } | false | No | Generates a multi-size .ico file. sizes: false skips file generation only. Setting the key to false disables the full entry (generation, head tag, and manifest). Link-tag sizes are never inferred for .ico; set tag.sizes explicitly if needed. |
[filename.svg] | { tag?: Partial<IconTag> & Pick<IconTag, "rel">; manifest?: boolean | ManifestIconOptions } | false | No | Metadata-only SVG entry. Explicit SVG keys do not generate files. If global source is SVG and favicon.svg is not defined, the integration auto-copies the source and prepends a favicon tag. Set "favicon.svg": false to disable that auto-copy. |
[filename.<png|jpg|jpeg|gif|webp|avif>] | { size: number | false; tag?: Partial<IconTag> & Pick<IconTag, "rel">; manifest?: boolean | ManifestIconOptions; source?: string; sharpOptions?: SharpOptions } | false | No | Generates a raster file at the configured square size. size: false skips file generation only. Setting the key to false disables the full entry. Raster link-tag sizes are inferred as NxN. |
IconTag
Section titled “IconTag”| field | type | required | description |
|---|---|---|---|
rel | string | Yes | Required to emit a <link> tag. If tag.rel is omitted, the file can still generate and contribute to the manifest, but no head tag is emitted. |
href | string | No | Ignored in build config. The integration always derives href from the file key and normalizes it with a leading /. |
sizes | string | No | Optional explicit sizes. Used as-is for .ico or SVG metadata. Raster files infer this automatically from size. |
type | string | No | Optional MIME override. Defaults to inferImageMimeType(fileName). |
media | "light" | "dark" | string | No | Optional media hint consumed by the runtime Icons component. |
ManifestIconOptions
Section titled “ManifestIconOptions”| field | type | required | description |
|---|---|---|---|
src | string | No | Override the manifest icon path. Defaults to the normalized file key. |
sizes | string | No | Override inferred sizes. Raster icons infer NxN; .ico icons infer a space-separated list such as "16x16 32x32 48x48". |
type | string | No | Override inferred MIME type. |
purpose | string | No | Optional manifest purpose such as "maskable" or "monochrome". |
Examples
Section titled “Examples”Input:
eminence({ icons: { source: "src/assets/logo.png", },});Output:
<link rel="icon" href="/favicon.ico" type="image/x-icon" /><link rel="icon" href="/favicon.png" sizes="32x32" type="image/png" /><link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" type="image/png"/><link rel="icon" href="/icon-192.png" sizes="192x192" type="image/png" /><link rel="icon" href="/icon.png" sizes="512x512" type="image/png" />Automatic
Section titled “Automatic”When source is SVG and favicon.svg is not explicitly configured, the source is auto-copied to /favicon.svg and its tag is prepended.
Input:
eminence({ icons: { source: "src/assets/logo.svg", },});Output:
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" /><link rel="icon" href="/favicon.ico" type="image/x-icon" /><link rel="icon" href="/favicon.png" sizes="32x32" type="image/png" /><link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" type="image/png"/><link rel="icon" href="/icon-192.png" sizes="192x192" type="image/png" /><link rel="icon" href="/icon.png" sizes="512x512" type="image/png" />Complete
Section titled “Complete”Input:
eminence({ icons: { source: "src/assets/logo.svg", "favicon.ico": false, "apple-touch-icon.png": { size: 180, tag: { rel: "apple-touch-icon" }, manifest: true, }, "badge.png": { size: 96, tag: { rel: "icon" }, manifest: { src: "/brand/badge.png", purpose: "monochrome" }, }, "safari-pinned-tab.svg": { tag: { rel: "mask-icon", color: "#111111" }, }, },});Output:
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" /><link rel="icon" href="/favicon.png" sizes="32x32" type="image/png" /><link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" type="image/png"/><link rel="icon" href="/icon-192.png" sizes="192x192" type="image/png" /><link rel="icon" href="/icon.png" sizes="512x512" type="image/png" /><link rel="icon" href="/badge.png" sizes="96x96" type="image/png" /><link rel="mask-icon" color="#111111" href="/safari-pinned-tab.svg" type="image/svg+xml"/>Decisions Made
Section titled “Decisions Made”File keys define public URLs
Section titled “File keys define public URLs”Config keys are output file names, so generated URLs, head tags, and manifest icons come from one source of truth.
false disables the full entry
Section titled “false disables the full entry”Setting a file key to false disables generation and metadata output for that key. This differs from size: false or sizes: false, which skip generation only.
Merge precedence is explicit
Section titled “Merge precedence is explicit”Resolution layers in this order: generated entries from icons, then headTags.icons by href, then manifest.icons by src. Later layers override earlier collisions.
SVG auto-favicon is conditional
Section titled “SVG auto-favicon is conditional”favicon.svg is auto-added only when source is SVG and the user did not define or explicitly disable favicon.svg.
Source code
Section titled “Source code”import { existsSync } from "node:fs";import { mkdir, readFile, writeFile } from "node:fs/promises";import { createRequire } from "node:module";import { dirname, join, resolve } from "node:path";import { fileURLToPath } from "node:url";import type { ResizeOptions } from "sharp";import type { IntegrationRuntimeContext } from "..";import { inferImageMimeType, isSvg } from "../utils";
const require = createRequire(fileURLToPath(import.meta.url));
export const SUPPORTED_EXTENSIONS = [ "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico",] as const;
export type SupportedExtensions = (typeof SUPPORTED_EXTENSIONS)[number];export type RasterSupportedExtensions = Exclude< SupportedExtensions, "ico" | "svg">;export type IconFileName = `${string}.${SupportedExtensions}`;export type SharpOptions = ResizeOptions;
export interface IconTag { rel: string; href: string; sizes?: string; type?: string; media?: "light" | "dark" | (string & {}); [key: string]: string | undefined;}
type IconTagInput = Partial<IconTag> & Pick<IconTag, "rel">;
export interface ManifestIconItem { src: string; sizes?: string; type?: string; purpose?: string;}
export interface ManifestIconOptions { src?: string; sizes?: string; type?: string; purpose?: string;}
interface BaseIconDefinition { tag?: IconTagInput; manifest?: boolean | ManifestIconOptions;}
export interface IcoIconDefinition extends BaseIconDefinition { sizes: number[] | false; source?: string; sharpOptions?: SharpOptions;}
export interface SvgIconDefinition extends BaseIconDefinition { size?: never; source?: never; sharpOptions?: never;}
export interface RasterIconDefinition extends BaseIconDefinition { size: number | false; source?: string; sharpOptions?: SharpOptions;}
type IconDefinition = | IcoIconDefinition | SvgIconDefinition | RasterIconDefinition;
type IconDefinitionInput = IconDefinition | false;
export type IconsOptions = { source: string;} & { [filename: `${string}.ico`]: IcoIconDefinition | false;} & { [filename: `${string}.svg`]: SvgIconDefinition | false;} & { [filename: `${string}.${RasterSupportedExtensions}`]: | RasterIconDefinition | false;};
interface BaseGenerationTask { fileName: string; href: string; source: string;}
export interface CopyIconTask extends BaseGenerationTask { kind: "copy";}
export interface IcoGenerationTask extends BaseGenerationTask { kind: "ico"; sizes: number[]; sharpOptions?: SharpOptions;}
export interface RasterGenerationTask extends BaseGenerationTask { kind: "raster"; size: number; format: RasterSupportedExtensions; sharpOptions?: SharpOptions;}
export type IconGenerationTask = | CopyIconTask | IcoGenerationTask | RasterGenerationTask;
export interface ResolvedIconsOptions { tags: IconTag[]; manifestIcons: ManifestIconItem[]; generationTasks: IconGenerationTask[];}
const ICONS_UNDEFINED_WARNING = "No icons were generated because options.icons is undefined. Set it to false to explicitly disable icon generation or provide a valid configuration.";const ICON_SOURCE_UNDEFINED_WARNING = "No icons were generated because options.icons.source is not defined. Set it to false to explicitly disable icon generation or provide a valid source path to generate icons.";
const SUPPORTED_EXTENSION_SET = new Set<string>(SUPPORTED_EXTENSIONS);
const DEFAULT_ICON_DEFINITIONS: Readonly<Record<IconFileName, IconDefinition>> = { "favicon.ico": { sizes: [16, 32, 48], tag: { rel: "icon" }, }, "favicon.png": { size: 32, tag: { rel: "icon" }, }, "apple-touch-icon.png": { size: 180, tag: { rel: "apple-touch-icon" }, }, "icon-192.png": { size: 192, tag: { rel: "icon" }, manifest: true, }, "icon.png": { size: 512, tag: { rel: "icon" }, manifest: true, }, };
const hasSupportedExtension = (value: string): value is IconFileName => { const extension = value.split(".").pop()?.toLowerCase(); return extension !== undefined && SUPPORTED_EXTENSION_SET.has(extension);};
const getExtension = (fileName: string): SupportedExtensions | undefined => { const extension = fileName.split(".").pop()?.toLowerCase(); if (extension === undefined || !SUPPORTED_EXTENSION_SET.has(extension)) { return undefined; }
return extension as SupportedExtensions;};
const getIconFileExtension = (fileName: IconFileName): SupportedExtensions => getExtension(fileName) as SupportedExtensions;
const isIcoDefinition = ( fileName: IconFileName, _definition: IconDefinition,): _definition is IcoIconDefinition => { return getIconFileExtension(fileName) === "ico";};
const isSvgDefinition = ( fileName: IconFileName, _definition: IconDefinition,): _definition is SvgIconDefinition => { return getIconFileExtension(fileName) === "svg";};
const isRasterDefinition = ( fileName: IconFileName, _definition: IconDefinition,): _definition is RasterIconDefinition => { const extension = getIconFileExtension(fileName); return extension !== "ico" && extension !== "svg";};
const normalizeHref = (fileName: string): string => fileName.startsWith("/") ? fileName : `/${fileName}`;
const inferRasterSizes = (size: number): string => `${size}x${size}`;
const inferIcoManifestSizes = (sizes: number[]): string | undefined => { if (sizes.length === 0) { return undefined; }
return sizes.map((size) => `${size}x${size}`).join(" ");};
const isFalseSizedEntry = ( fileName: IconFileName, definition: IconDefinition,): boolean => { if (isIcoDefinition(fileName, definition)) { return definition.sizes === false; }
if (isSvgDefinition(fileName, definition)) { return false; }
if (!isRasterDefinition(fileName, definition)) { return false; }
return definition.size === false;};
const getDefinitionSource = ( definition: IconDefinition, fallbackSource: string,): string => { if ("source" in definition && typeof definition.source === "string") { return definition.source; }
return fallbackSource;};
const getDefinitionSharpOptions = ( definition: IconDefinition,): SharpOptions | undefined => { if ("sharpOptions" in definition) { return definition.sharpOptions; }
return undefined;};
const resolveTagSizes = ( fileName: IconFileName, definition: IconDefinition,): string | undefined => { if (isIcoDefinition(fileName, definition)) { return definition.tag?.sizes; }
if (isSvgDefinition(fileName, definition)) { return definition.tag?.sizes; }
if (!isRasterDefinition(fileName, definition)) { return undefined; }
if (definition.size === false) { return undefined; }
return inferRasterSizes(definition.size);};
const resolveManifestSizes = ( fileName: IconFileName, definition: IconDefinition,): string | undefined => { if (isIcoDefinition(fileName, definition)) { return definition.sizes === false ? undefined : inferIcoManifestSizes(definition.sizes); }
if (isSvgDefinition(fileName, definition)) { return undefined; }
if (!isRasterDefinition(fileName, definition)) { return undefined; }
if (definition.size === false) { return undefined; }
return inferRasterSizes(definition.size);};
const resolveTagType = ( fileName: IconFileName, definition: IconDefinition,): string | undefined => { return definition.tag?.type ?? inferImageMimeType(fileName);};
const resolveIconTag = ( fileName: IconFileName, definition: IconDefinition,): IconTag | undefined => { if ( isFalseSizedEntry(fileName, definition) || definition.tag?.rel === undefined ) { return undefined; }
const href = normalizeHref(fileName); const sizes = resolveTagSizes(fileName, definition); const type = resolveTagType(fileName, definition);
return { ...definition.tag, rel: definition.tag.rel, href, sizes, type, };};
const resolveManifestIcon = ( fileName: IconFileName, definition: IconDefinition,): ManifestIconItem | undefined => { if (isFalseSizedEntry(fileName, definition) || !definition.manifest) { return undefined; }
const manifestOptions = definition.manifest === true ? {} : definition.manifest;
return { src: manifestOptions.src ?? normalizeHref(fileName), sizes: manifestOptions.sizes ?? resolveManifestSizes(fileName, definition), type: manifestOptions.type ?? inferImageMimeType(fileName), purpose: manifestOptions.purpose, };};
const normalizeSharpFormat = ( extension: RasterSupportedExtensions,): keyof import("sharp").FormatEnum => { if (extension === "jpg") { return "jpeg"; }
return extension;};
const resolveGenerationTask = ( fileName: IconFileName, definition: IconDefinition, fallbackSource: string,): IconGenerationTask | undefined => { if (isFalseSizedEntry(fileName, definition)) { return undefined; }
const href = normalizeHref(fileName); const source = getDefinitionSource(definition, fallbackSource); const sharpOptions = getDefinitionSharpOptions(definition);
if (isSvgDefinition(fileName, definition)) { return undefined; }
if (isIcoDefinition(fileName, definition)) { if (definition.sizes === false || definition.sizes.length === 0) { return undefined; }
return { kind: "ico", fileName, href, source, sizes: definition.sizes, sharpOptions, }; }
if (!isRasterDefinition(fileName, definition)) { return undefined; }
if (definition.size === false) { return undefined; }
return { kind: "raster", fileName, href, source, size: definition.size, format: getIconFileExtension(fileName) as RasterSupportedExtensions, sharpOptions, };};
const getIconDefinitions = ( icons: IconsOptions,): Array<[IconFileName, IconDefinition]> => { const definitions = new Map<IconFileName, IconDefinitionInput>( Object.entries(DEFAULT_ICON_DEFINITIONS) as Array< [IconFileName, IconDefinitionInput] >, );
for (const [fileName, definition] of Object.entries(icons)) { if (fileName === "source" || !hasSupportedExtension(fileName)) { continue; }
definitions.set(fileName, definition as IconDefinitionInput); }
return Array.from(definitions.entries()).filter( (entry): entry is [IconFileName, IconDefinition] => entry[1] !== false && typeof entry[1] === "object" && entry[1] !== null, );};
export const resolveIconsOptions = ( icons: IconsOptions | false | undefined,): ResolvedIconsOptions => { if (icons === false || icons === undefined) { return { tags: [], manifestIcons: [], generationTasks: [] }; }
const explicitDefinitions = getIconDefinitions(icons); const explicitFileNames = new Set( explicitDefinitions.map(([fileName]) => fileName), ); const tags: IconTag[] = []; const manifestIcons: ManifestIconItem[] = []; const generationTasks: IconGenerationTask[] = [];
for (const [fileName, definition] of explicitDefinitions) { const tag = resolveIconTag(fileName, definition); if (tag) { tags.push(tag); }
const manifestIcon = resolveManifestIcon(fileName, definition); if (manifestIcon) { manifestIcons.push(manifestIcon); }
const generationTask = resolveGenerationTask( fileName, definition, icons.source, ); if (generationTask) { generationTasks.push(generationTask); } }
if (isSvg(icons.source) && !explicitFileNames.has("favicon.svg")) { tags.unshift({ rel: "icon", href: "/favicon.svg", sizes: "any", type: inferImageMimeType("favicon.svg"), });
generationTasks.unshift({ kind: "copy", fileName: "favicon.svg", href: "/favicon.svg", source: icons.source, }); }
return { tags, manifestIcons, generationTasks, };};
export function resolveManifestIconsFromIconsOptions( icons: IconsOptions | false | undefined,): ManifestIconItem[] { return resolveIconsOptions(icons).manifestIcons;}
function validateSource(sourceFile: string): { isValid: boolean; isSvg: boolean; error?: string;} { const resolvedPath = resolve(sourceFile); if (!existsSync(resolvedPath)) { return { isValid: false, isSvg: false, error: `Icon source file not found: ${sourceFile}`, }; }
return { isValid: true, isSvg: isSvg(sourceFile), };}
function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; }
return String(error);}
async function loadSharp(logger: IntegrationRuntimeContext["logger"]) { try { type SharpModule = typeof import("sharp"); const module = require("sharp") as SharpModule | { default: SharpModule }; return "default" in module ? module.default : module; } catch (error) { logger.error( `Icon generation skipped because optional dependency \"sharp\" could not be loaded: ${getErrorMessage(error)}. Install it with \"pnpm add sharp\" (or \"npm install sharp\").`, ); return null; }}
async function loadSharpsToIco(logger: IntegrationRuntimeContext["logger"]) { try { type SharpIcoModule = typeof import("sharp-ico"); const module = require("sharp-ico") as | SharpIcoModule | { default: Pick<SharpIcoModule, "sharpsToIco"> }; return "default" in module ? module.default.sharpsToIco : module.sharpsToIco; } catch (error) { logger.error( `favicon.ico was not generated because optional dependency \"sharp-ico\" could not be loaded: ${getErrorMessage(error)}. Install it with \"pnpm add sharp-ico\" (or \"npm install sharp-ico\").`, ); return null; }}
async function writeRasterIcon( outputDir: string, task: RasterGenerationTask, sharp: NonNullable<Awaited<ReturnType<typeof loadSharp>>>,): Promise<void> { const outputPath = join(outputDir, task.fileName); await mkdir(dirname(outputPath), { recursive: true });
const buffer = await sharp(task.source) .resize(task.size, task.size, task.sharpOptions) .toFormat(normalizeSharpFormat(task.format)) .toBuffer();
await writeFile(outputPath, buffer);}
async function writeIcoIcon( outputDir: string, task: IcoGenerationTask, sharp: NonNullable<Awaited<ReturnType<typeof loadSharp>>>, sharpsToIco: NonNullable<Awaited<ReturnType<typeof loadSharpsToIco>>>,): Promise<void> { const outputPath = join(outputDir, task.fileName); await mkdir(dirname(outputPath), { recursive: true });
await sharpsToIco([sharp(task.source)], outputPath, { sizes: task.sizes, resizeOptions: task.sharpOptions ?? {}, });}
async function writeCopiedIcon( outputDir: string, task: CopyIconTask,): Promise<void> { const outputPath = join(outputDir, task.fileName); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, await readFile(resolve(task.source)));}
export async function generateIcons({ dir, options, logger,}: IntegrationRuntimeContext): Promise<void> { const outputDir = fileURLToPath(dir); const icons = options.icons; if (icons === false) return;
if (icons === undefined) { logger.warn(ICONS_UNDEFINED_WARNING); return; }
if (icons.source === undefined) { logger.warn(ICON_SOURCE_UNDEFINED_WARNING); return; }
const resolvedIcons = resolveIconsOptions(icons); if (resolvedIcons.generationTasks.length === 0) { return; }
const sourceValidationCache = new Map< string, ReturnType<typeof validateSource> >(); const getSourceValidation = (sourceFile: string) => { const cached = sourceValidationCache.get(sourceFile); if (cached) { return cached; }
const validation = validateSource(sourceFile); sourceValidationCache.set(sourceFile, validation); return validation; };
for (const task of resolvedIcons.generationTasks) { if (task.kind !== "copy") { continue; }
const { isValid, error } = getSourceValidation(task.source); if (!isValid) { logger.error(`Icon source validation failed: ${error}`); continue; }
await writeCopiedIcon(outputDir, task); }
const sharpTasks = resolvedIcons.generationTasks.filter( (task): task is IcoGenerationTask | RasterGenerationTask => task.kind !== "copy", );
if (sharpTasks.length === 0) { return; }
const validSharpTasks = sharpTasks.filter((task) => { const { isValid, error } = getSourceValidation(task.source); if (!isValid) { logger.error(`Icon source validation failed: ${error}`); return false; }
return true; });
if (validSharpTasks.length === 0) { return; }
const sharp = await loadSharp(logger); if (!sharp) { return; }
let sharpsToIco: Awaited<ReturnType<typeof loadSharpsToIco>> | null = null;
for (const task of validSharpTasks) { if (task.kind === "ico") { sharpsToIco ??= await loadSharpsToIco(logger); if (!sharpsToIco) { continue; }
await writeIcoIcon(outputDir, task, sharp, sharpsToIco); continue; }
await writeRasterIcon(outputDir, task, sharp); }}