Files
suaveplan/packages/devtools/tooling/electrobun/src/icons/icons.ts
2026-05-27 23:29:53 +01:00

144 lines
4.5 KiB
TypeScript

import { existsSync } from "node:fs";
import { join } from "node:path";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import {
GenerateIconsOptionsSchema,
parseOrThrow,
} from "../schemas/schemas.js";
import { recordIcons } from "../telemetry/telemetry.js";
import type {
IconRendererAdapter,
IconRendererInput,
IconReport,
} from "../types/electrobun.types.js";
const ICNS_SIZES = [16, 32, 64, 128, 256, 512, 1024] as const;
const ICO_SIZES = [16, 32, 48, 64, 128, 256] as const;
const PNG_SIZES = [16, 32, 48, 64, 128, 256, 512, 1024] as const;
interface IconzModule {
createIconz(input: {
input: string;
output: string;
icons: Record<
string,
{
type: string;
name: string;
sizes: readonly number[];
folder?: string;
}
>;
}): { generate(): Promise<unknown> };
}
/**
* Default icon renderer backed by the `iconz` package. Loads the upstream
* library lazily so test environments that swap in a recording adapter
* never pay the import cost.
*/
export const iconzRendererAdapter: IconRendererAdapter = {
async render(input: IconRendererInput) {
const mod = (await import("iconz")) as unknown as IconzModule;
const iconz = mod.createIconz({
input: input.input,
output: input.output,
icons: {
appIcns: {
type: "icns",
name: input.icons.icns.name,
sizes: input.icons.icns.sizes,
},
appIco: {
type: "ico",
name: input.icons.ico.name,
sizes: input.icons.ico.sizes,
},
pngIcons: {
type: "png",
name: input.icons.png.name,
sizes: input.icons.png.sizes,
folder: ".",
},
},
});
await iconz.generate();
const files = [
`${input.icons.icns.name}.icns`,
`${input.icons.ico.name}.ico`,
...input.icons.png.sizes.map(
(size) =>
`${input.icons.png.name.replace("{{dims}}", String(size))}.png`
),
];
return { files };
},
};
export interface GenerateIconsOptions {
readonly inputPath: string;
readonly outputDir: string;
}
export interface GenerateIconsDeps {
readonly renderer?: IconRendererAdapter;
readonly fileExists?: (path: string) => boolean;
}
/**
* Run the icon pipeline against the user's input image. The default
* renderer is `iconz`; tests may inject a recording fake to assert the
* `IconRendererInput` shape. Returns an `IconReport`; raises
* `ELECTROBUN.ICON_SOURCE_MISSING` when the input path does not exist,
* and wraps renderer throws as `ELECTROBUN.ICON_RENDER_FAILED` (the
* report's `error` field carries the underlying message).
*/
export async function generateIcons(
options: GenerateIconsOptions,
deps: GenerateIconsDeps = {}
): Promise<IconReport> {
const parsed = parseOrThrow(
GenerateIconsOptionsSchema,
options,
"GenerateIconsOptions"
);
const fileExists = deps.fileExists ?? ((p: string) => existsSync(p));
if (!fileExists(parsed.inputPath)) {
recordIcons("failed");
throw new ElectrobunError(
ELECTROBUN_CODES.ICON_SOURCE_MISSING,
`icon source not found: ${parsed.inputPath}`,
{ details: { inputPath: parsed.inputPath } }
);
}
const renderer = deps.renderer ?? iconzRendererAdapter;
const iconsDir = join(parsed.outputDir, "icons");
try {
const { files } = await renderer.render({
input: parsed.inputPath,
output: iconsDir,
icons: {
icns: { name: "app", sizes: ICNS_SIZES },
ico: { name: "app", sizes: ICO_SIZES },
png: { name: "icon-{{dims}}", sizes: PNG_SIZES },
},
});
recordIcons("generated");
return {
generated: true,
files: files.map((f) => `icons/${f}`),
};
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
recordIcons("failed");
return {
generated: false,
files: [],
error: `Failed to generate icons: ${message}`,
};
}
}