Skip to content

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:

Terminal window
npm install sharp sharp-ico
optiontyperequireddescription
sourcestringYesGlobal 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 } | falseNoGenerates 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 } | falseNoMetadata-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 } | falseNoGenerates 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.
fieldtyperequireddescription
relstringYesRequired 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.
hrefstringNoIgnored in build config. The integration always derives href from the file key and normalizes it with a leading /.
sizesstringNoOptional explicit sizes. Used as-is for .ico or SVG metadata. Raster files infer this automatically from size.
typestringNoOptional MIME override. Defaults to inferImageMimeType(fileName).
media"light" | "dark" | stringNoOptional media hint consumed by the runtime Icons component.
fieldtyperequireddescription
srcstringNoOverride the manifest icon path. Defaults to the normalized file key.
sizesstringNoOverride inferred sizes. Raster icons infer NxN; .ico icons infer a space-separated list such as "16x16 32x32 48x48".
typestringNoOverride inferred MIME type.
purposestringNoOptional manifest purpose such as "maskable" or "monochrome".

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" />

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" />

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"
/>

Config keys are output file names, so generated URLs, head tags, and manifest icons come from one source of truth.

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.

Resolution layers in this order: generated entries from icons, then headTags.icons by href, then manifest.icons by src. Later layers override earlier collisions.

favicon.svg is auto-added only when source is SVG and the user did not define or explicitly disable favicon.svg.

generate-icons.ts
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);
}
}