vite-svg-to-ico 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # vite-svg-to-ico
2
2
 
3
- [![NPM Version](https://img.shields.io/npm/v/vite-svg-to-ico?logo=npm&labelColor=CB3837&color=black)](https://www.npmjs.com/package/vite-svg-to-ico)
3
+ [![NPM Version](https://img.shields.io/npm/v/vite-svg-to-ico?logo=npm&labelColor=CB3837&color=black)](https://npm.im/package/vite-svg-to-ico)
4
4
 
5
5
  Vite plugin that converts an image file into a multi-size `.ico` favicon at
6
6
  build time.\
@@ -18,13 +18,11 @@ Requires [`sharp`](https://sharp.pixelplumbing.com/) as a runtime dependency (in
18
18
 
19
19
  ```ts
20
20
  // vite.config.ts
21
- import { defineConfig } from 'vite';
22
- import svgToIco from 'vite-svg-to-ico';
21
+ import { defineConfig } from "vite";
22
+ import svgToIco from "vite-svg-to-ico";
23
23
 
24
24
  export default defineConfig({
25
- plugins: [
26
- svgToIco({ input: 'src/icon.svg' }),
27
- ],
25
+ plugins: [svgToIco({ input: "src/icon.svg" })],
28
26
  });
29
27
  ```
30
28
 
@@ -32,9 +30,9 @@ export default defineConfig({
32
30
 
33
31
  ```ts
34
32
  svgToIco({
35
- input: 'src/logo.svg',
36
- output: 'icon.ico',
37
- sizes: [16, 24, 32, 48, 64, 128, 256],
33
+ input: "src/logo.svg",
34
+ output: "icon.ico",
35
+ sizes: [16, 24, 32, 48, 64, 128, 256],
38
36
  });
39
37
  ```
40
38
 
@@ -42,8 +40,8 @@ svgToIco({
42
40
 
43
41
  ```ts
44
42
  svgToIco({
45
- input: 'src/icon.svg',
46
- sharp: { optimize: false },
43
+ input: "src/icon.svg",
44
+ sharp: { optimize: false },
47
45
  });
48
46
  ```
49
47
 
@@ -51,8 +49,8 @@ svgToIco({
51
49
 
52
50
  ```ts
53
51
  svgToIco({
54
- input: 'src/icon.svg',
55
- emit: { source: true },
52
+ input: "src/icon.svg",
53
+ emit: { source: true },
56
54
  });
57
55
  ```
58
56
 
@@ -60,8 +58,8 @@ svgToIco({
60
58
 
61
59
  ```ts
62
60
  svgToIco({
63
- input: 'src/icon.svg',
64
- emit: { source: { name: 'logo.svg' } },
61
+ input: "src/icon.svg",
62
+ emit: { source: { name: "logo.svg" } },
65
63
  });
66
64
  ```
67
65
 
@@ -72,7 +70,7 @@ format from the file extension:
72
70
 
73
71
  ```ts
74
72
  svgToIco({
75
- input: 'src/logo.png',
73
+ input: "src/logo.png",
76
74
  });
77
75
  ```
78
76
 
@@ -80,8 +78,8 @@ svgToIco({
80
78
 
81
79
  ```ts
82
80
  svgToIco({
83
- input: 'src/icon.svg',
84
- emit: { sizes: true }, // emits favicon-16x16.png, favicon-32x32.png, etc.
81
+ input: "src/icon.svg",
82
+ emit: { sizes: true }, // emits favicon-16x16.png, favicon-32x32.png, etc.
85
83
  });
86
84
  ```
87
85
 
@@ -90,8 +88,8 @@ file format:
90
88
 
91
89
  ```ts
92
90
  svgToIco({
93
- input: 'src/icon.svg',
94
- emit: { sizes: 'both' }, // emits both .png and .ico per size
91
+ input: "src/icon.svg",
92
+ emit: { sizes: "both" }, // emits both .png and .ico per size
95
93
  });
96
94
  ```
97
95
 
@@ -99,8 +97,8 @@ svgToIco({
99
97
 
100
98
  ```ts
101
99
  svgToIco({
102
- input: 'src/icon.svg',
103
- emit: { source: true, inject: true }, // injects ICO + SVG <link> tags into HTML
100
+ input: "src/icon.svg",
101
+ emit: { source: true, inject: true }, // injects ICO + SVG <link> tags into HTML
104
102
  });
105
103
  ```
106
104
 
@@ -108,8 +106,8 @@ Use `'full'` to also inject per-size `<link>` tags (requires `emit.sizes`):
108
106
 
109
107
  ```ts
110
108
  svgToIco({
111
- input: 'src/icon.svg',
112
- emit: { source: true, sizes: true, inject: 'full' },
109
+ input: "src/icon.svg",
110
+ emit: { source: true, sizes: true, inject: "full" },
113
111
  });
114
112
  ```
115
113
 
@@ -117,15 +115,55 @@ When `emit.inject` is enabled, existing `<link rel="icon">` and
117
115
  `<link rel="shortcut icon">` tags are stripped from the HTML to prevent
118
116
  duplicates. `apple-touch-icon` tags are preserved.
119
117
 
118
+ ### Framework integration (SvelteKit, VitePress, Astro adapters)
119
+
120
+ SvelteKit, VitePress, and some Astro adapters render their HTML
121
+ **outside** Vite's pipeline, so `transformIndexHtml` never fires and
122
+ `emit.inject` produces no tags. The build plugin detects this
123
+ and emits a warning, but the fix lives outside the plugin.
124
+
125
+ Two options:
126
+
127
+ **1. Configure tags at the framework level.** Use SvelteKit's
128
+ `app.html`, VitePress's `head` config, or Astro's `<Head>` slot. The
129
+ plugin still emits the ICO/SVG files — only the tag injection moves to
130
+ the framework.
131
+
132
+ **2. Use the bundled `svg-to-ico` CLI as a `postbuild` step.** The CLI
133
+ rewrites HTML files on disk after the framework's adapter finishes
134
+ writing them. Useful when you want a single source of truth for the
135
+ icon sizes and don't want to duplicate them in framework config.
136
+
137
+ ```json
138
+ {
139
+ "scripts": {
140
+ "build": "vite build && svg-to-ico inject build/index.html build/404.html --sizes 16 --sizes 32 --sizes 48 --source favicon.svg"
141
+ }
142
+ }
143
+ ```
144
+
145
+ The CLI is **not Vite-specific** — it ships with this package as a
146
+ convenience but works against any HTML and any image source. Install
147
+ the package globally (`bun i -g vite-svg-to-ico`, `npm i -g vite-svg-to-ico`) to
148
+ get the `svg-to-ico` command on your PATH for use in non-Vite
149
+ pipelines, one-off CI scripts, or other framework toolchains:
150
+
151
+ ```sh
152
+ svg-to-ico generate src/icon.svg --out-dir build --sizes 16 --sizes 32 --sizes 48 --emit-source --emit-sizes png
153
+ svg-to-ico inject build/index.html --sizes 16 --sizes 32 --sizes 48 --source icon.svg
154
+ ```
155
+
156
+ Run `svg-to-ico --help` for the full surface.
157
+
120
158
  ### Override sharp options
121
159
 
122
160
  ```ts
123
161
  svgToIco({
124
- input: 'src/pixel-icon.svg',
125
- sharp: {
126
- resize: { kernel: 'nearest' }, // crisp pixel art scaling
127
- png: { palette: true, colours: 64 }, // indexed color output
128
- },
162
+ input: "src/pixel-icon.svg",
163
+ sharp: {
164
+ resize: { kernel: "nearest" }, // crisp pixel art scaling
165
+ png: { palette: true, colours: 64 }, // indexed color output
166
+ },
129
167
  });
130
168
  ```
131
169
 
@@ -139,13 +177,13 @@ available options.
139
177
 
140
178
  ```ts
141
179
  // Disable dev server entirely (build-only)
142
- svgToIco({ input: 'src/icon.svg', dev: false });
180
+ svgToIco({ input: "src/icon.svg", dev: false });
143
181
 
144
182
  // Use runtime shim instead of HTML transform for favicon injection
145
- svgToIco({ input: 'src/icon.svg', dev: { injection: 'shim' } });
183
+ svgToIco({ input: "src/icon.svg", dev: { injection: "shim" } });
146
184
 
147
185
  // Disable HMR favicon refresh
148
- svgToIco({ input: 'src/icon.svg', dev: { hmr: false } });
186
+ svgToIco({ input: "src/icon.svg", dev: { hmr: false } });
149
187
  ```
150
188
 
151
189
  ## Options
@@ -216,5 +254,3 @@ Set `DEBUG=vite-svg-to-ico` to enable timing instrumentation.
216
254
  ## License
217
255
 
218
256
  [MIT](https://github.com/kjanat/vite-svg-to-ico/blob/master/LICENSE)
219
-
220
- <!--markdownlint-disable-file no-hard-tabs-->
package/dist/cli.mjs ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ import { buildFaviconTags, injectTagsIntoHtml } from "./html.mjs";
3
+ import { generateSizedPngs, packIco } from "./ico.mjs";
4
+ import { INJECT_MODES } from "./types.mjs";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, resolve } from "node:path";
7
+ import { cwd } from "node:process";
8
+ import { CLIError, arg, cli, command, flag } from "@kjanat/dreamcli";
9
+ //#region src/cli.ts
10
+ /**
11
+ * # svg-to-ico
12
+ *
13
+ * CLI companion to the `vite-svg-to-ico` Vite plugin.
14
+ * Generates multi-size ICO favicons from any sharp-supported source image,
15
+ * and/or injects favicon `<link>` tags into existing HTML files.
16
+ *
17
+ * ## Why
18
+ *
19
+ * Vite's plugin pipeline ends at `closeBundle`. Frameworks that render HTML
20
+ * outside that pipeline (SvelteKit, VitePress, Astro adapters) write the user-
21
+ * visible HTML *after* the plugin's last hook fires, so the plugin's built-in
22
+ * `emit.inject` silently no-ops. This CLI runs as a `"postbuild"` step against
23
+ * the framework's final on-disk HTML, sidestepping the hook-ordering trap.
24
+ *
25
+ * The CLI is also useful for non-Vite pipelines: a one-off ICO generator that
26
+ * shares the plugin's emit logic (sharp + ICO packer) without dragging Vite
27
+ * into the equation. Global install with `bun i -g vite-svg-to-ico` (or
28
+ * `npm i -g vite-svg-to-ico`) puts `svg-to-ico` on PATH.
29
+ *
30
+ * ## Subcommands
31
+ *
32
+ * - `generate <input>` — rasterize an image to a multi-size ICO (and
33
+ * optionally per-size PNGs/ICOs + a copy of the source) on disk.
34
+ * - `inject <files...>` — rewrite existing HTML files: strip
35
+ * `<link rel="icon">`/`<link rel="shortcut icon">` tags, splice the
36
+ * configured favicon tag set before `</head>`, preserve `apple-touch-icon`.
37
+ *
38
+ * @example
39
+ * ```sh
40
+ * # SvelteKit adapter-static, wired into package.json scripts:
41
+ * # "build": "vite build && svg-to-ico inject build/index.html build/404.html -s 16 -s 32 -s 48 --source favicon.svg"
42
+ *
43
+ * # Generate a 16/32/48 ICO + per-size PNGs alongside it:
44
+ * svg-to-ico generate src/icon.svg --out-dir build -s 16 -s 32 -s 48 --emit-sizes png --emit-source
45
+ *
46
+ * # Inject favicon links into multiple HTML files at once:
47
+ * svg-to-ico inject dist/index.html dist/404.html -s 16 -s 32 -s 48 --source favicon.svg --base /app/
48
+ * ```
49
+ */
50
+ /**
51
+ * Build the `--sizes` flag: an array of per-element-validated integers, each
52
+ * restricted to `[1, 256]` per the ICO spec. Parsing and range-check live
53
+ * inside the flag definition so the action handler can trust the value.
54
+ */
55
+ const sizesFlag = () => flag.array(flag.custom((raw) => {
56
+ const n = typeof raw === "number" ? raw : Number(raw);
57
+ if (!Number.isInteger(n) || n < 1 || n > 256) throw new CLIError(`Invalid size: ${String(raw)}. Must be an integer 1–256.`, { code: "INVALID_SIZE" });
58
+ return n;
59
+ })).alias("s").default([
60
+ 16,
61
+ 32,
62
+ 48
63
+ ]).describe("Pixel sizes (integers 1–256). Pass repeated: `-s16 -s32 -s48`.");
64
+ /** Resolve a raw string to an absolute filesystem path (relative to CWD). */
65
+ const toAbsolutePath = (raw) => resolve(String(raw));
66
+ /** Reusable absolute-path arg: parsing happens at the schema layer. */
67
+ const pathArg = () => arg.custom(toAbsolutePath);
68
+ /** Reusable absolute-path flag: parsing happens at the schema layer. */
69
+ const pathFlag = () => flag.custom(toAbsolutePath);
70
+ /**
71
+ * `generate` subcommand: rasterize a source image into a multi-size ICO favicon.
72
+ * Optionally also emits per-size PNG/ICO files and a copy of the source.
73
+ *
74
+ * Exported so consumers can compose it into their own `@kjanat/dreamcli` CLI
75
+ * or unit-test it directly via `runCommand(generate, [...])` from
76
+ * `@kjanat/dreamcli/testkit`.
77
+ */
78
+ const generate = command("generate").description("Rasterize a source image into a multi-size ICO favicon. Optionally also emit per-size PNG/ICO files and a copy of the original source. Equivalent to what the Vite plugin emits during `vite build`, but runs standalone.").arg("input", pathArg().describe("Path to source image (resolved to absolute). Sharp-supported formats: .svg, .svgz, .png, .jpg/.jpeg, .webp, .avif, .gif, .tif/.tiff.")).flag("output", flag.string().alias("o").default("favicon.ico").describe("Filename for the combined ICO (relative to --out-dir). May include subdirectories; they are created as needed.")).flag("sizes", sizesFlag()).flag("out-dir", pathFlag().alias("d").default(cwd()).describe("Directory to write outputs into (resolved to absolute). Created if missing. Defaults to the current working directory.")).flag("emit-sizes", flag.enum([
79
+ "none",
80
+ "png",
81
+ "ico",
82
+ "both"
83
+ ]).default("none").describe("Emit per-size files alongside the combined ICO: `png` (favicon-NxN.png), `ico` (favicon-NxN.ico), `both`, or `none`.")).flag("emit-source", flag.boolean().default(false).describe("Copy the original source image into --out-dir (preserves its original basename).")).flag("optimize", flag.boolean().default(true).describe("Apply max PNG compression (level 9 + adaptive filtering). Disable with `--optimize=false` for faster builds at the cost of larger files.")).example("generate src/icon.svg", "Write favicon.ico (16/32/48) to the current directory.").example("generate src/icon.svg -d build -s16 -s32 -s48 --emit-sizes png --emit-source", "Generate ICO + per-size PNGs + copy of source into build/.").example("generate src/icon.png -s64 -s128 -s256 -o icons/favicon.ico", "PNG input, custom sizes, nested output path.").action(async ({ args, flags, out }) => {
84
+ const sizes = flags.sizes;
85
+ const inputPath = args.input;
86
+ const outDir = flags["out-dir"];
87
+ const outputStem = flags.output.replace(/\.ico$/i, "");
88
+ const inputBuffer = await readFile(inputPath);
89
+ const pngs = await generateSizedPngs(inputBuffer, {
90
+ sizes,
91
+ optimize: flags.optimize
92
+ });
93
+ const icoBuffer = packIco(pngs);
94
+ await mkdir(outDir, { recursive: true });
95
+ async function writeAt(targetPath, data, label) {
96
+ await mkdir(dirname(targetPath), { recursive: true });
97
+ await writeFile(targetPath, data);
98
+ out.log(label);
99
+ }
100
+ const icoPath = resolve(outDir, flags.output);
101
+ await writeAt(icoPath, icoBuffer, `Wrote ${icoPath} (${icoBuffer.length} B, ${sizes.length} size${sizes.length === 1 ? "" : "s"})`);
102
+ if (flags["emit-source"]) {
103
+ const sourcePath = resolve(outDir, basename(inputPath));
104
+ await writeAt(sourcePath, inputBuffer, `Wrote ${sourcePath} (source)`);
105
+ }
106
+ const emitSizes = flags["emit-sizes"];
107
+ if (emitSizes !== "none") for (const png of pngs) {
108
+ if (emitSizes === "png" || emitSizes === "both") {
109
+ const p = resolve(outDir, `${outputStem}-${png.size}x${png.size}.png`);
110
+ await writeAt(p, png.buffer, `Wrote ${p}`);
111
+ }
112
+ if (emitSizes === "ico" || emitSizes === "both") {
113
+ const p = resolve(outDir, `${outputStem}-${png.size}x${png.size}.ico`);
114
+ await writeAt(p, packIco([png]), `Wrote ${p}`);
115
+ }
116
+ }
117
+ });
118
+ /**
119
+ * `inject` subcommand: rewrite existing HTML files on disk to include the
120
+ * configured favicon `<link>` tag set. Strips existing icon links,
121
+ * preserves `apple-touch-icon`, splices new tags before `</head>`.
122
+ *
123
+ * Exported for composition and `runCommand(inject, [...])` testing.
124
+ */
125
+ const inject = command("inject").description("Rewrite existing HTML files on disk: strip `<link rel=\"icon\">` and `<link rel=\"shortcut icon\">` tags (preserves `apple-touch-icon`), splice in the configured favicon tag set before `</head>`, and write back. The ICO/SVG files themselves are expected to already exist at the configured paths.").arg("files", arg.string().variadic().describe("HTML file paths to rewrite (one or more). Missing files emit a warning and are skipped, but do not fail the run.")).flag("output", flag.string().alias("o").default("favicon.ico").describe("ICO filename referenced in the injected `<link>` (matches `generate`'s --output).")).flag("sizes", sizesFlag()).flag("mode", flag.enum(INJECT_MODES).alias("m").default("minimal").describe("Tag set to inject. `minimal`: ICO + optional SVG source link. `full`: also per-size PNG/ICO links.")).flag("base", flag.string().default("/").describe("URL base prefix for hrefs (matches Vite's `base` config). Trailing slash is optional; `--base /app` and `--base /app/` both yield `/app/favicon.ico`.")).flag("source", flag.string().describe("Filename of the source file (e.g. `favicon.svg`). When set, an additional `<link rel=\"icon\" type=\"image/svg+xml\">` tag is injected.")).flag("input-format", flag.enum([
126
+ "svg",
127
+ "png",
128
+ "jpg",
129
+ "webp",
130
+ "avif",
131
+ "gif",
132
+ "tiff"
133
+ ]).default("svg").describe("Format of `--source` for the MIME type attribute. Only `svg` triggers the SVG `<link>`; other values are accepted but currently inert in tag generation.")).example("inject build/index.html", "Inject default favicon.ico tag (16/32/48) into a single file.").example("inject build/index.html build/404.html -s16 -s32 -s48 --source favicon.svg", "Multi-file rewrite, also injects SVG source `<link>`.").example("inject dist/index.html --base /repo/ -m full", "Full tag set under a subpath base (e.g. GitHub Pages project site).").action(async ({ args, flags, out }) => {
134
+ const files = args.files;
135
+ if (files.length === 0) throw new CLIError("At least one HTML file path is required", { code: "MISSING_FILES" });
136
+ const sizes = flags.sizes;
137
+ const mode = flags.mode;
138
+ const sourceName = flags.source;
139
+ const tags = buildFaviconTags({
140
+ output: flags.output,
141
+ sizes,
142
+ sourceEmitted: !!sourceName,
143
+ sourceName: sourceName ?? "",
144
+ inputFormat: flags["input-format"],
145
+ mode,
146
+ base: flags.base
147
+ });
148
+ let rewritten = 0;
149
+ for (const rel of files) {
150
+ const abs = resolve(rel);
151
+ let original;
152
+ try {
153
+ original = await readFile(abs, "utf8");
154
+ } catch (error) {
155
+ if (error.code === "ENOENT") {
156
+ out.error(`inject: "${rel}" — file not found at ${abs}, skipping`);
157
+ continue;
158
+ }
159
+ throw error;
160
+ }
161
+ const next = injectTagsIntoHtml(original, tags);
162
+ if (next !== original) {
163
+ await mkdir(dirname(abs), { recursive: true });
164
+ await writeFile(abs, next, "utf8");
165
+ rewritten++;
166
+ out.log(`Rewrote ${rel}`);
167
+ } else out.log(`Unchanged ${rel}`);
168
+ }
169
+ if (rewritten === 0 && files.length > 0) out.log("No files were modified.");
170
+ });
171
+ /**
172
+ * Top-level `svg-to-ico` CLI: bundles {@link generate} and {@link inject}
173
+ * subcommands plus shell-completion generation.
174
+ */
175
+ const app = cli("svg-to-ico").description("Generate ICO favicons and inject <link> tags into HTML files").command(generate).command(inject).completions();
176
+ if (import.meta.main) app.run();
177
+ //#endregion
178
+ export { app, generate, inject };
package/dist/html.mjs CHANGED
@@ -1,12 +1,24 @@
1
+ //#region src/html.ts
2
+ /**
3
+ * Build an array of Vite {@link HtmlTagDescriptor}s for favicon `<link>` tags.
4
+ *
5
+ * - **minimal**: ICO (always) + SVG source (if SVG input & source emitted).
6
+ * - **full**: minimal + per-size PNG `<link>` tags.
7
+ *
8
+ * @param opts - Configuration describing which tags to generate.
9
+ * @returns Array of Vite HTML tag descriptors to inject into `<head>`.
10
+ */
1
11
  function buildFaviconTags(opts) {
2
12
  const tags = [];
3
- const base = opts.base ?? "/";
13
+ const baseRaw = opts.base ?? "/";
14
+ const base = baseRaw.endsWith("/") ? baseRaw : `${baseRaw}/`;
15
+ const withBase = (name) => `${base}${name.replace(/^\/+/, "")}`;
4
16
  tags.push({
5
17
  tag: "link",
6
18
  attrs: {
7
19
  rel: "icon",
8
20
  type: "image/x-icon",
9
- href: `${base}${opts.output}`,
21
+ href: withBase(opts.output),
10
22
  sizes: opts.sizes.map((s) => `${s}x${s}`).join(" ")
11
23
  },
12
24
  injectTo: "head"
@@ -16,7 +28,7 @@ function buildFaviconTags(opts) {
16
28
  attrs: {
17
29
  rel: "icon",
18
30
  type: "image/svg+xml",
19
- href: `${base}${opts.sourceName}`,
31
+ href: withBase(opts.sourceName),
20
32
  sizes: "any"
21
33
  },
22
34
  injectTo: "head"
@@ -27,11 +39,43 @@ function buildFaviconTags(opts) {
27
39
  rel: "icon",
28
40
  type: `image/${file.format}`,
29
41
  sizes: `${file.size}x${file.size}`,
30
- href: `${base}${file.name}`
42
+ href: withBase(file.name)
31
43
  },
32
44
  injectTo: "head"
33
45
  });
34
46
  return tags;
35
47
  }
48
+ /**
49
+ * Regex matching `<link>` tags whose `rel` is exactly `icon` or `shortcut icon`.
50
+ *
51
+ * Intentionally does **not** match `apple-touch-icon` so those are preserved.
52
+ */
36
53
  const INJECT_ICON_LINK_RE = /\s*<link\b[^>]*\brel\s*=\s*["'](?:shortcut\s+)?icon["'][^>]*>\s*/gi;
37
- export { INJECT_ICON_LINK_RE, buildFaviconTags };
54
+ /** Escape double quotes in an attribute value so it can be safely emitted inside `"..."`. */
55
+ function escapeAttr(v) {
56
+ return v.replace(/"/g, "&quot;");
57
+ }
58
+ /** Render a Vite {@link HtmlTagDescriptor} as an HTML string. */
59
+ function renderTag(tag) {
60
+ const attrs = tag.attrs ? Object.entries(tag.attrs).filter(([, v]) => v !== false && v !== void 0 && v !== null).map(([k, v]) => v === true ? k : `${k}="${escapeAttr(String(v))}"`).join(" ") : "";
61
+ const open = attrs ? `<${tag.tag} ${attrs}>` : `<${tag.tag}>`;
62
+ if (tag.children == null) return open;
63
+ return `${open}${typeof tag.children === "string" ? tag.children : tag.children.map(renderTag).join("")}</${tag.tag}>`;
64
+ }
65
+ /**
66
+ * Inject favicon `<link>` tags into an HTML document string.
67
+ *
68
+ * Strips any existing `icon` / `shortcut icon` links (preserving `apple-touch-icon`)
69
+ * and inserts the new tags before `</head>`. If no `</head>` is present, tags are
70
+ * appended at the end of the document.
71
+ */
72
+ function injectTagsIntoHtml(html, tags) {
73
+ const cleaned = html.replace(INJECT_ICON_LINK_RE, "");
74
+ const rendered = tags.map(renderTag).join("\n ");
75
+ const headCloseRe = /<\/head>/i;
76
+ const match = cleaned.match(headCloseRe);
77
+ if (match) return cleaned.replace(headCloseRe, ` ${rendered}\n ${match[0]}`);
78
+ return `${cleaned}\n${rendered}`;
79
+ }
80
+ //#endregion
81
+ export { INJECT_ICON_LINK_RE, buildFaviconTags, injectTagsIntoHtml };
package/dist/ico.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import sharp from "sharp";
3
+ //#region src/ico.ts
4
+ /** Default resize options applied when no overrides are provided. */
3
5
  const DEFAULT_RESIZE = {
4
6
  fit: "contain",
5
7
  background: {
@@ -9,6 +11,13 @@ const DEFAULT_RESIZE = {
9
11
  alpha: 0
10
12
  }
11
13
  };
14
+ /**
15
+ * Rasterize an input image to individual per-size PNG buffers.
16
+ *
17
+ * @param input - Image contents as a Buffer, or a filesystem path to read.
18
+ * @param opts - Generation options including sizes, optimization, and sharp overrides.
19
+ * @returns Array of sized PNG buffers.
20
+ */
12
21
  async function generateSizedPngs(input, opts) {
13
22
  const inputBuffer = Buffer.isBuffer(input) ? input : await readFile(input);
14
23
  const resizeOpts = {
@@ -25,9 +34,22 @@ async function generateSizedPngs(input, opts) {
25
34
  buffer: await sharp(inputBuffer).resize(size, size, resizeOpts).png(pngOpts).toBuffer()
26
35
  })));
27
36
  }
37
+ /** ICO type identifier (1 = icon, 2 = cursor). */
28
38
  const ICO_TYPE = 1;
39
+ /** Byte length of the ICONDIR header. */
29
40
  const HEADER_SIZE = 6;
41
+ /** Byte length of a single ICONDIRENTRY. */
30
42
  const ENTRY_SIZE = 16;
43
+ /**
44
+ * Pack pre-rendered PNG buffers into an ICO container.
45
+ *
46
+ * Uses the modern PNG-in-ICO format (supported since Windows Vista).
47
+ * Each PNG is stored verbatim — no BMP conversion or pixel manipulation.
48
+ *
49
+ * @param pngs - Array of pre-rendered PNG buffers with their target sizes.
50
+ * @returns ICO file contents as a {@link Buffer}.
51
+ * @see {@link https://en.wikipedia.org/wiki/ICO_(file_format) "ICO (file format)"}
52
+ */
31
53
  function packIco(pngs) {
32
54
  const count = pngs.length;
33
55
  const dataOffset = HEADER_SIZE + count * ENTRY_SIZE;
@@ -55,4 +77,5 @@ function packIco(pngs) {
55
77
  ...pngs.map((p) => p.buffer)
56
78
  ]);
57
79
  }
80
+ //#endregion
58
81
  export { generateSizedPngs, packIco };
package/dist/index.mjs CHANGED
@@ -4,6 +4,79 @@ import { DEBUG, Instrumentation } from "./instrumentation.mjs";
4
4
  import { DEV_INJECTIONS, EMIT_SIZES_FORMATS, INJECT_MODES, SUPPORTED_EXTENSIONS, SVG_EXTENSIONS } from "./types.mjs";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { basename, extname, resolve } from "node:path";
7
+ //#region src/index.ts
8
+ /**
9
+ * Vite plugin that converts an image source into a multi-size `.ico` favicon.
10
+ *
11
+ * Returns three composable sub-plugins:
12
+ * 1. **config** — validates options after config is resolved.
13
+ * 2. **serve** — lazily generates the ICO and serves it via dev-server middleware;
14
+ * regenerates on HMR when the source file changes.
15
+ * 3. **build** — generates the ICO at build time and emits it as a Rollup asset.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * // Basic usage
20
+ * // vite.config.ts
21
+ * import { defineConfig } from 'vite';
22
+ * import svgToIco from 'vite-svg-to-ico';
23
+ *
24
+ * export default defineConfig({
25
+ * plugins: [
26
+ * svgToIco({ input: 'src/icon.svg' }),
27
+ * ],
28
+ * });
29
+ * ```
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * // Custom sizes and output filename
34
+ * svgToIco({
35
+ * input: 'src/logo.svg',
36
+ * output: 'icon.ico',
37
+ * sizes: [16, 24, 32, 48, 64, 128, 256],
38
+ * })
39
+ * ```
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * // Emit individual per-size PNGs alongside the ICO
44
+ * svgToIco({
45
+ * input: 'src/icon.svg',
46
+ * emit: { sizes: true },
47
+ * })
48
+ * ```
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * // Auto-inject favicon link tags into HTML
53
+ * svgToIco({
54
+ * input: 'src/icon.svg',
55
+ * emit: { source: true, inject: true },
56
+ * })
57
+ * ```
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * // Use a PNG source instead of SVG
62
+ * svgToIco({
63
+ * input: 'src/icon.png',
64
+ * emit: { inject: 'full', sizes: true },
65
+ * })
66
+ * ```
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * // Override sharp resize/PNG options
71
+ * svgToIco({
72
+ * input: 'src/pixel-icon.svg',
73
+ * sharp: {
74
+ * resize: { kernel: 'nearest' }, // crisp pixel art
75
+ * png: { palette: true, colours: 64 },
76
+ * },
77
+ * })
78
+ * ```
79
+ */
7
80
  function svgToIco(opts) {
8
81
  let generatedIco = null;
9
82
  let generatedPngs = null;
@@ -40,6 +113,7 @@ function svgToIco(opts) {
40
113
  enabled: !!rawIncludeSource,
41
114
  name: basename(input)
42
115
  };
116
+ /** Shared generation options threaded to every `generateSizedPngs` call. */
43
117
  const genOpts = {
44
118
  sizes,
45
119
  optimize,
@@ -53,13 +127,16 @@ function svgToIco(opts) {
53
127
  tif: "tiff"
54
128
  }[inputFormat] ?? inputFormat;
55
129
  const sourceMimeType = inputFormat === "svg" ? "image/svg+xml" : `image/${mimeFormat}`;
130
+ /** Resolved absolute path to the input file, set in `configResolved`. */
56
131
  let resolvedInput = input;
132
+ /** Resolved Vite `base` path, set in `configResolved`. */
57
133
  let resolvedBase = "/";
58
134
  const emitSizesFormat = rawEmitSizes === true ? "png" : rawEmitSizes === false ? false : rawEmitSizes;
59
135
  const emitPng = emitSizesFormat === "png" || emitSizesFormat === "both";
60
136
  const emitIco = emitSizesFormat === "ico" || emitSizesFormat === "both";
61
137
  const injectMode = rawInject === true ? "minimal" : rawInject === false ? false : rawInject;
62
138
  const outputStem = output.replace(/\.ico$/i, "");
139
+ /** Build the list of per-size files that will be emitted. */
63
140
  function buildSizedFileInfos() {
64
141
  if (!emitSizesFormat) return [];
65
142
  const files = [];
@@ -77,8 +154,17 @@ function svgToIco(opts) {
77
154
  }
78
155
  return files;
79
156
  }
157
+ /** Cache-bust key appended to icon hrefs; updated on each HMR cycle. */
80
158
  let cacheId = Date.now().toString(36);
159
+ /** Matches `<link>` tags whose `rel` contains `icon` (covers `icon`, `shortcut icon`, `apple-touch-icon`). */
81
160
  const ICON_LINK_RE = /(<link\b[^>]*\brel\s*=\s*["'][^"']*icon[^"']*["'][^>]*\bhref\s*=\s*["'])([^"']+)(["'][^>]*>)/gi;
161
+ /**
162
+ * Small client-side HMR snippet injected during dev.
163
+ *
164
+ * Listens for the `svg-to-ico:update` custom event and swaps every
165
+ * `<link rel="…icon…">` href with a fresh cache-bust param so the
166
+ * browser re-fetches the favicon without a full page reload.
167
+ */
82
168
  const hmrClientCode = [
83
169
  "if (import.meta.hot) {",
84
170
  " import.meta.hot.on('svg-to-ico:update', (data) => {",
@@ -90,6 +176,7 @@ function svgToIco(opts) {
90
176
  " });",
91
177
  "}"
92
178
  ].join("\n");
179
+ /** Build favicon tags for HTML injection. */
93
180
  function faviconTags(options) {
94
181
  if (!injectMode) return [];
95
182
  return buildFaviconTags({
@@ -103,9 +190,11 @@ function svgToIco(opts) {
103
190
  base: options?.base
104
191
  });
105
192
  }
193
+ /** Apply cache-bust param to an href string. */
106
194
  function cacheBust(href) {
107
195
  return `${href}${href.includes("?") ? "&" : "?"}v=${cacheId}`;
108
196
  }
197
+ /** Build the shim script that dynamically manages link tags. */
109
198
  function buildShimScript() {
110
199
  const tags = faviconTags();
111
200
  const lines = [
@@ -296,6 +385,11 @@ function svgToIco(opts) {
296
385
  this.error(`[svg-to-ico] Failed to generate ICO: ${error}`);
297
386
  }
298
387
  },
388
+ /**
389
+ * Strip existing icon `<link>` tags from the HTML and append the
390
+ * configured favicon tag set. Records that the hook fired so
391
+ * {@link closeBundle} can detect frameworks that bypass this pipeline.
392
+ */
299
393
  transformIndexHtml(html) {
300
394
  buildTransformIndexHtmlCalled = true;
301
395
  if (!injectMode) return;
@@ -304,6 +398,18 @@ function svgToIco(opts) {
304
398
  tags: faviconTags({ base: resolvedBase })
305
399
  };
306
400
  },
401
+ /**
402
+ * Surface a warning when `emit.inject` was configured but
403
+ * `transformIndexHtml` was never called during this build cycle. This
404
+ * happens with frameworks (SvelteKit, VitePress build, some Astro
405
+ * adapters) that render HTML outside Vite's pipeline, causing the
406
+ * `<link>` injection to silently no-op while files are still emitted.
407
+ *
408
+ * Multi-environment Vite builds (SvelteKit drives client + ssr) call
409
+ * `closeBundle` per environment; only the client environment ever
410
+ * triggers `transformIndexHtml`, so the warning is scoped there to
411
+ * avoid duplicate output.
412
+ */
307
413
  closeBundle() {
308
414
  const envName = this.environment?.name;
309
415
  if (envName && envName !== "client") return;
@@ -312,4 +418,5 @@ function svgToIco(opts) {
312
418
  }
313
419
  ];
314
420
  }
421
+ //#endregion
315
422
  export { svgToIco as default };
@@ -1,11 +1,19 @@
1
+ //#region src/instrumentation.ts
1
2
  const DEBUG = process.env.DEBUG === "vite-svg-to-ico";
3
+ /**
4
+ * Debug-only timing instrumentation.
5
+ *
6
+ * Enable with `DEBUG=vite-svg-to-ico`.
7
+ */
2
8
  var Instrumentation = class {
3
9
  times = /* @__PURE__ */ new Map();
10
+ /** Begin a labeled timer; no-op when {@link DEBUG} is `false`. */
4
11
  start(label) {
5
12
  if (!DEBUG) return;
6
13
  this.times.set(label, performance.now());
7
14
  console.log(`[svg-to-ico] ${label}...`);
8
15
  }
16
+ /** Log elapsed time for a previously started label; no-op when {@link DEBUG} is `false`. */
9
17
  end(label) {
10
18
  if (!DEBUG) return;
11
19
  const start = this.times.get(label);
@@ -15,4 +23,5 @@ var Instrumentation = class {
15
23
  }
16
24
  }
17
25
  };
26
+ //#endregion
18
27
  export { DEBUG, Instrumentation };
package/dist/types.mjs CHANGED
@@ -1,10 +1,15 @@
1
+ //#region src/types.ts
2
+ /** Valid string values for {@link PluginOptions.emitSizes}. */
1
3
  const EMIT_SIZES_FORMATS = [
2
4
  "png",
3
5
  "ico",
4
6
  "both"
5
7
  ];
8
+ /** Valid string values for {@link PluginOptions.dev} injection mode. */
6
9
  const DEV_INJECTIONS = ["transform", "shim"];
10
+ /** Valid string values for {@link PluginOptions.inject}. */
7
11
  const INJECT_MODES = ["minimal", "full"];
12
+ /** Supported input image formats (sharp-compatible file extensions including the leading dot). */
8
13
  const SUPPORTED_EXTENSIONS = new Set([
9
14
  ".svg",
10
15
  ".svgz",
@@ -17,5 +22,7 @@ const SUPPORTED_EXTENSIONS = new Set([
17
22
  ".tiff",
18
23
  ".tif"
19
24
  ]);
25
+ /** Extensions that identify SVG input (`.svg` and `.svgz`). */
20
26
  const SVG_EXTENSIONS = new Set([".svg", ".svgz"]);
27
+ //#endregion
21
28
  export { DEV_INJECTIONS, EMIT_SIZES_FORMATS, INJECT_MODES, SUPPORTED_EXTENSIONS, SVG_EXTENSIONS };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vite-svg-to-ico",
3
- "version": "2.2.0",
4
- "description": "A Vite plugin that converts SVG files to ICO format during the build process.",
3
+ "version": "2.3.0",
4
+ "description": "Vite plugin that converts SVG to ICO during site build.",
5
5
  "keywords": [
6
6
  "vite-plugin",
7
7
  "vite",
@@ -24,6 +24,10 @@
24
24
  },
25
25
  "./package.json": "./package.json"
26
26
  },
27
+ "imports": {
28
+ "#vite-svg-to-ico": "./src/index.ts",
29
+ "#internals/*": "./src/*"
30
+ },
27
31
  "files": [
28
32
  "dist",
29
33
  "src"
@@ -33,7 +37,7 @@
33
37
  "build": "tsdown",
34
38
  "dev": "tsdown --watch",
35
39
  "fmt": "dprint fmt",
36
- "prepack": "bun --bun bd -l error && dprint fmt README.md >/dev/null",
40
+ "prepack": "bun --bun bd -l error && bunx prettier -w README.md >/dev/null",
37
41
  "postpack": "git restore README.md >/dev/null",
38
42
  "prepublishOnly": "bun test && bun --bun typecheck",
39
43
  "tar": "bun pm pack --quiet | awk 'NF{line=$0} END{print line}'",
@@ -41,26 +45,33 @@
41
45
  "typecheck": "tsgo --noEmit"
42
46
  },
43
47
  "dependencies": {
48
+ "@kjanat/dreamcli": "^2.1.0",
44
49
  "sharp": "^0.34.5"
45
50
  },
46
51
  "devDependencies": {
47
52
  "@arethetypeswrong/core": "^0.18.2",
48
- "@types/bun": "latest",
49
- "@typescript/native-preview": "^7.0.0-dev.20260225.1",
50
- "dprint": "^0.52.0",
51
- "publint": "^0.3.17",
52
- "tsdown": "^0.20.3",
53
- "typescript": "^5.9.3",
53
+ "@types/bun": "^1.3.13",
54
+ "@typescript/native-preview": "^7.0.0-dev.20260512.1",
55
+ "dprint": "^0.54.0",
56
+ "publint": "^0.3.20",
57
+ "tsdown": "^0.22.0",
58
+ "typescript": "^6.0.3",
54
59
  "unplugin-unused": "^0.5.7"
55
60
  },
56
61
  "peerDependencies": {
57
62
  "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
58
63
  },
64
+ "engines": {
65
+ "node": ">=22.18.0"
66
+ },
59
67
  "packageManager": "bun@1.3.13",
60
68
  "publishConfig": {
61
69
  "access": "public",
62
70
  "provenance": true,
63
71
  "registry": "https://registry.npmjs.org/",
64
72
  "tag": "latest"
73
+ },
74
+ "bin": {
75
+ "svg-to-ico": "./dist/cli.mjs"
65
76
  }
66
77
  }
package/src/cli.ts ADDED
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * # svg-to-ico
4
+ *
5
+ * CLI companion to the `vite-svg-to-ico` Vite plugin.
6
+ * Generates multi-size ICO favicons from any sharp-supported source image,
7
+ * and/or injects favicon `<link>` tags into existing HTML files.
8
+ *
9
+ * ## Why
10
+ *
11
+ * Vite's plugin pipeline ends at `closeBundle`. Frameworks that render HTML
12
+ * outside that pipeline (SvelteKit, VitePress, Astro adapters) write the user-
13
+ * visible HTML *after* the plugin's last hook fires, so the plugin's built-in
14
+ * `emit.inject` silently no-ops. This CLI runs as a `"postbuild"` step against
15
+ * the framework's final on-disk HTML, sidestepping the hook-ordering trap.
16
+ *
17
+ * The CLI is also useful for non-Vite pipelines: a one-off ICO generator that
18
+ * shares the plugin's emit logic (sharp + ICO packer) without dragging Vite
19
+ * into the equation. Global install with `bun i -g vite-svg-to-ico` (or
20
+ * `npm i -g vite-svg-to-ico`) puts `svg-to-ico` on PATH.
21
+ *
22
+ * ## Subcommands
23
+ *
24
+ * - `generate <input>` — rasterize an image to a multi-size ICO (and
25
+ * optionally per-size PNGs/ICOs + a copy of the source) on disk.
26
+ * - `inject <files...>` — rewrite existing HTML files: strip
27
+ * `<link rel="icon">`/`<link rel="shortcut icon">` tags, splice the
28
+ * configured favicon tag set before `</head>`, preserve `apple-touch-icon`.
29
+ *
30
+ * @example
31
+ * ```sh
32
+ * # SvelteKit adapter-static, wired into package.json scripts:
33
+ * # "build": "vite build && svg-to-ico inject build/index.html build/404.html -s 16 -s 32 -s 48 --source favicon.svg"
34
+ *
35
+ * # Generate a 16/32/48 ICO + per-size PNGs alongside it:
36
+ * svg-to-ico generate src/icon.svg --out-dir build -s 16 -s 32 -s 48 --emit-sizes png --emit-source
37
+ *
38
+ * # Inject favicon links into multiple HTML files at once:
39
+ * svg-to-ico inject dist/index.html dist/404.html -s 16 -s 32 -s 48 --source favicon.svg --base /app/
40
+ * ```
41
+ */
42
+
43
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
44
+ import { basename, dirname, resolve } from 'node:path';
45
+ import { cwd } from 'node:process';
46
+
47
+ import { arg, cli, CLIError, command, flag } from '@kjanat/dreamcli';
48
+
49
+ import { buildFaviconTags, injectTagsIntoHtml } from './html.ts';
50
+ import { generateSizedPngs, packIco } from './ico.ts';
51
+ import { INJECT_MODES, type InjectMode } from './types.ts';
52
+
53
+ /**
54
+ * Build the `--sizes` flag: an array of per-element-validated integers, each
55
+ * restricted to `[1, 256]` per the ICO spec. Parsing and range-check live
56
+ * inside the flag definition so the action handler can trust the value.
57
+ */
58
+ const sizesFlag = () =>
59
+ flag.array(flag.custom<number>((raw) => {
60
+ const n = typeof raw === 'number' ? raw : Number(raw);
61
+ if (!Number.isInteger(n) || n < 1 || n > 256) {
62
+ throw new CLIError(`Invalid size: ${String(raw)}. Must be an integer 1–256.`, { code: 'INVALID_SIZE' });
63
+ }
64
+ return n;
65
+ }))
66
+ .alias('s')
67
+ .default([16, 32, 48])
68
+ .describe('Pixel sizes (integers 1–256). Pass repeated: `-s16 -s32 -s48`.');
69
+
70
+ /** Resolve a raw string to an absolute filesystem path (relative to CWD). */
71
+ const toAbsolutePath = (raw: unknown): string => resolve(String(raw));
72
+
73
+ /** Reusable absolute-path arg: parsing happens at the schema layer. */
74
+ const pathArg = () => arg.custom<string>(toAbsolutePath);
75
+
76
+ /** Reusable absolute-path flag: parsing happens at the schema layer. */
77
+ const pathFlag = () => flag.custom<string>(toAbsolutePath);
78
+
79
+ /**
80
+ * `generate` subcommand: rasterize a source image into a multi-size ICO favicon.
81
+ * Optionally also emits per-size PNG/ICO files and a copy of the source.
82
+ *
83
+ * Exported so consumers can compose it into their own `@kjanat/dreamcli` CLI
84
+ * or unit-test it directly via `runCommand(generate, [...])` from
85
+ * `@kjanat/dreamcli/testkit`.
86
+ */
87
+ export const generate = command('generate')
88
+ .description(
89
+ 'Rasterize a source image into a multi-size ICO favicon. Optionally also emit per-size '
90
+ + 'PNG/ICO files and a copy of the original source. Equivalent to what the Vite plugin '
91
+ + 'emits during `vite build`, but runs standalone.',
92
+ )
93
+ .arg(
94
+ 'input',
95
+ pathArg().describe(
96
+ 'Path to source image (resolved to absolute). Sharp-supported formats: .svg, .svgz, .png, '
97
+ + '.jpg/.jpeg, .webp, .avif, .gif, .tif/.tiff.',
98
+ ),
99
+ )
100
+ .flag(
101
+ 'output',
102
+ flag.string().alias('o').default('favicon.ico').describe(
103
+ 'Filename for the combined ICO (relative to --out-dir). May include subdirectories; they are created as needed.',
104
+ ),
105
+ )
106
+ .flag('sizes', sizesFlag())
107
+ .flag(
108
+ 'out-dir',
109
+ pathFlag().alias('d').default(cwd()).describe(
110
+ 'Directory to write outputs into (resolved to absolute). Created if missing. '
111
+ + 'Defaults to the current working directory.',
112
+ ),
113
+ )
114
+ .flag(
115
+ 'emit-sizes',
116
+ flag.enum(['none', 'png', 'ico', 'both']).default('none').describe(
117
+ 'Emit per-size files alongside the combined ICO: `png` (favicon-NxN.png), `ico` (favicon-NxN.ico), `both`, or `none`.',
118
+ ),
119
+ )
120
+ .flag(
121
+ 'emit-source',
122
+ flag.boolean().default(false).describe(
123
+ 'Copy the original source image into --out-dir (preserves its original basename).',
124
+ ),
125
+ )
126
+ .flag(
127
+ 'optimize',
128
+ flag.boolean().default(true).describe(
129
+ 'Apply max PNG compression (level 9 + adaptive filtering). Disable with `--optimize=false` for '
130
+ + 'faster builds at the cost of larger files.',
131
+ ),
132
+ )
133
+ .example('generate src/icon.svg', 'Write favicon.ico (16/32/48) to the current directory.')
134
+ .example(
135
+ 'generate src/icon.svg -d build -s16 -s32 -s48 --emit-sizes png --emit-source',
136
+ 'Generate ICO + per-size PNGs + copy of source into build/.',
137
+ )
138
+ .example(
139
+ 'generate src/icon.png -s64 -s128 -s256 -o icons/favicon.ico',
140
+ 'PNG input, custom sizes, nested output path.',
141
+ )
142
+ .action(async ({ args, flags, out }) => {
143
+ const sizes = flags.sizes;
144
+ const inputPath = args.input;
145
+ const outDir = flags['out-dir'];
146
+ const outputStem = flags.output.replace(/\.ico$/i, '');
147
+
148
+ const inputBuffer = await readFile(inputPath);
149
+ const pngs = await generateSizedPngs(inputBuffer, {
150
+ sizes,
151
+ optimize: flags.optimize,
152
+ });
153
+ const icoBuffer = packIco(pngs);
154
+
155
+ await mkdir(outDir, { recursive: true });
156
+
157
+ async function writeAt(targetPath: string, data: Buffer | string, label: string) {
158
+ await mkdir(dirname(targetPath), { recursive: true });
159
+ await writeFile(targetPath, data);
160
+ out.log(label);
161
+ }
162
+
163
+ const icoPath = resolve(outDir, flags.output);
164
+ await writeAt(
165
+ icoPath,
166
+ icoBuffer,
167
+ `Wrote ${icoPath} (${icoBuffer.length} B, ${sizes.length} size${sizes.length === 1 ? '' : 's'})`,
168
+ );
169
+
170
+ if (flags['emit-source']) {
171
+ const sourcePath = resolve(outDir, basename(inputPath));
172
+ await writeAt(sourcePath, inputBuffer, `Wrote ${sourcePath} (source)`);
173
+ }
174
+
175
+ const emitSizes = flags['emit-sizes'];
176
+ if (emitSizes !== 'none') {
177
+ for (const png of pngs) {
178
+ if (emitSizes === 'png' || emitSizes === 'both') {
179
+ const p = resolve(outDir, `${outputStem}-${png.size}x${png.size}.png`);
180
+ await writeAt(p, png.buffer, `Wrote ${p}`);
181
+ }
182
+ if (emitSizes === 'ico' || emitSizes === 'both') {
183
+ const p = resolve(outDir, `${outputStem}-${png.size}x${png.size}.ico`);
184
+ await writeAt(p, packIco([png]), `Wrote ${p}`);
185
+ }
186
+ }
187
+ }
188
+ });
189
+
190
+ /**
191
+ * `inject` subcommand: rewrite existing HTML files on disk to include the
192
+ * configured favicon `<link>` tag set. Strips existing icon links,
193
+ * preserves `apple-touch-icon`, splices new tags before `</head>`.
194
+ *
195
+ * Exported for composition and `runCommand(inject, [...])` testing.
196
+ */
197
+ export const inject = command('inject')
198
+ .description(
199
+ 'Rewrite existing HTML files on disk: strip `<link rel="icon">` and `<link rel="shortcut icon">` '
200
+ + 'tags (preserves `apple-touch-icon`), splice in the configured favicon tag set before `</head>`, '
201
+ + 'and write back. The ICO/SVG files themselves are expected to already exist at the configured paths.',
202
+ )
203
+ .arg(
204
+ 'files',
205
+ arg.string().variadic().describe(
206
+ 'HTML file paths to rewrite (one or more). Missing files emit a warning and are skipped, '
207
+ + 'but do not fail the run.',
208
+ ),
209
+ )
210
+ .flag(
211
+ 'output',
212
+ flag.string().alias('o').default('favicon.ico').describe(
213
+ "ICO filename referenced in the injected `<link>` (matches `generate`'s --output).",
214
+ ),
215
+ )
216
+ .flag('sizes', sizesFlag())
217
+ .flag(
218
+ 'mode',
219
+ flag.enum(INJECT_MODES).alias('m').default('minimal').describe(
220
+ 'Tag set to inject. `minimal`: ICO + optional SVG source link. `full`: also per-size PNG/ICO links.',
221
+ ),
222
+ )
223
+ .flag(
224
+ 'base',
225
+ flag.string().default('/').describe(
226
+ "URL base prefix for hrefs (matches Vite's `base` config). Trailing slash is optional; "
227
+ + '`--base /app` and `--base /app/` both yield `/app/favicon.ico`.',
228
+ ),
229
+ )
230
+ .flag(
231
+ 'source',
232
+ flag.string().describe(
233
+ 'Filename of the source file (e.g. `favicon.svg`). When set, an additional '
234
+ + '`<link rel="icon" type="image/svg+xml">` tag is injected.',
235
+ ),
236
+ )
237
+ .flag(
238
+ 'input-format',
239
+ flag.enum(['svg', 'png', 'jpg', 'webp', 'avif', 'gif', 'tiff']).default('svg').describe(
240
+ 'Format of `--source` for the MIME type attribute. Only `svg` triggers the SVG `<link>`; '
241
+ + 'other values are accepted but currently inert in tag generation.',
242
+ ),
243
+ )
244
+ .example(
245
+ 'inject build/index.html',
246
+ 'Inject default favicon.ico tag (16/32/48) into a single file.',
247
+ )
248
+ .example(
249
+ 'inject build/index.html build/404.html -s16 -s32 -s48 --source favicon.svg',
250
+ 'Multi-file rewrite, also injects SVG source `<link>`.',
251
+ )
252
+ .example(
253
+ 'inject dist/index.html --base /repo/ -m full',
254
+ 'Full tag set under a subpath base (e.g. GitHub Pages project site).',
255
+ )
256
+ .action(async ({ args, flags, out }) => {
257
+ const files = args.files;
258
+ if (files.length === 0) {
259
+ throw new CLIError('At least one HTML file path is required', { code: 'MISSING_FILES' });
260
+ }
261
+ const sizes = flags.sizes;
262
+ const mode = flags.mode as InjectMode;
263
+ const sourceName = flags.source;
264
+ const tags = buildFaviconTags({
265
+ output: flags.output,
266
+ sizes,
267
+ sourceEmitted: !!sourceName,
268
+ sourceName: sourceName ?? '',
269
+ inputFormat: flags['input-format'],
270
+ mode,
271
+ base: flags.base,
272
+ });
273
+
274
+ let rewritten = 0;
275
+ for (const rel of files) {
276
+ const abs = resolve(rel);
277
+ let original: string;
278
+ try {
279
+ original = await readFile(abs, 'utf8');
280
+ } catch (error) {
281
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
282
+ out.error(`inject: "${rel}" — file not found at ${abs}, skipping`);
283
+ continue;
284
+ }
285
+ throw error;
286
+ }
287
+ const next = injectTagsIntoHtml(original, tags);
288
+ if (next !== original) {
289
+ const dir = dirname(abs);
290
+ await mkdir(dir, { recursive: true });
291
+ await writeFile(abs, next, 'utf8');
292
+ rewritten++;
293
+ out.log(`Rewrote ${rel}`);
294
+ } else {
295
+ out.log(`Unchanged ${rel}`);
296
+ }
297
+ }
298
+
299
+ if (rewritten === 0 && files.length > 0) {
300
+ out.log('No files were modified.');
301
+ }
302
+ });
303
+
304
+ /**
305
+ * Top-level `svg-to-ico` CLI: bundles {@link generate} and {@link inject}
306
+ * subcommands plus shell-completion generation.
307
+ */
308
+ export const app = cli('svg-to-ico')
309
+ .description('Generate ICO favicons and inject <link> tags into HTML files')
310
+ .command(generate)
311
+ .command(inject)
312
+ .completions();
313
+
314
+ if (import.meta.main) {
315
+ void app.run();
316
+ }
package/src/html.ts CHANGED
@@ -35,7 +35,9 @@ export interface FaviconTagOptions {
35
35
  */
36
36
  export function buildFaviconTags(opts: FaviconTagOptions): HtmlTagDescriptor[] {
37
37
  const tags: HtmlTagDescriptor[] = [];
38
- const base = opts.base ?? '/';
38
+ const baseRaw = opts.base ?? '/';
39
+ const base = baseRaw.endsWith('/') ? baseRaw : `${baseRaw}/`;
40
+ const withBase = (name: string) => `${base}${name.replace(/^\/+/, '')}`;
39
41
 
40
42
  // 1. ICO — always
41
43
  tags.push({
@@ -43,7 +45,7 @@ export function buildFaviconTags(opts: FaviconTagOptions): HtmlTagDescriptor[] {
43
45
  attrs: {
44
46
  rel: 'icon',
45
47
  type: 'image/x-icon',
46
- href: `${base}${opts.output}`,
48
+ href: withBase(opts.output),
47
49
  sizes: opts.sizes.map((s) => `${s}x${s}`).join(' '),
48
50
  },
49
51
  injectTo: 'head',
@@ -56,7 +58,7 @@ export function buildFaviconTags(opts: FaviconTagOptions): HtmlTagDescriptor[] {
56
58
  attrs: {
57
59
  rel: 'icon',
58
60
  type: 'image/svg+xml',
59
- href: `${base}${opts.sourceName}`,
61
+ href: withBase(opts.sourceName),
60
62
  sizes: 'any',
61
63
  },
62
64
  injectTo: 'head',
@@ -72,7 +74,7 @@ export function buildFaviconTags(opts: FaviconTagOptions): HtmlTagDescriptor[] {
72
74
  rel: 'icon',
73
75
  type: `image/${file.format}`,
74
76
  sizes: `${file.size}x${file.size}`,
75
- href: `${base}${file.name}`,
77
+ href: withBase(file.name),
76
78
  },
77
79
  injectTo: 'head',
78
80
  });
@@ -88,3 +90,42 @@ export function buildFaviconTags(opts: FaviconTagOptions): HtmlTagDescriptor[] {
88
90
  * Intentionally does **not** match `apple-touch-icon` so those are preserved.
89
91
  */
90
92
  export const INJECT_ICON_LINK_RE = /\s*<link\b[^>]*\brel\s*=\s*["'](?:shortcut\s+)?icon["'][^>]*>\s*/gi;
93
+
94
+ /** Escape double quotes in an attribute value so it can be safely emitted inside `"..."`. */
95
+ function escapeAttr(v: string): string {
96
+ return v.replace(/"/g, '&quot;');
97
+ }
98
+
99
+ /** Render a Vite {@link HtmlTagDescriptor} as an HTML string. */
100
+ export function renderTag(tag: HtmlTagDescriptor): string {
101
+ const attrs = tag.attrs
102
+ ? Object.entries(tag.attrs)
103
+ .filter(([, v]) => v !== false && v !== undefined && v !== null)
104
+ .map(([k, v]) => v === true ? k : `${k}="${escapeAttr(String(v))}"`)
105
+ .join(' ')
106
+ : '';
107
+ const open = attrs ? `<${tag.tag} ${attrs}>` : `<${tag.tag}>`;
108
+ if (tag.children == null) return open;
109
+ const children = typeof tag.children === 'string'
110
+ ? tag.children
111
+ : tag.children.map(renderTag).join('');
112
+ return `${open}${children}</${tag.tag}>`;
113
+ }
114
+
115
+ /**
116
+ * Inject favicon `<link>` tags into an HTML document string.
117
+ *
118
+ * Strips any existing `icon` / `shortcut icon` links (preserving `apple-touch-icon`)
119
+ * and inserts the new tags before `</head>`. If no `</head>` is present, tags are
120
+ * appended at the end of the document.
121
+ */
122
+ export function injectTagsIntoHtml(html: string, tags: HtmlTagDescriptor[]): string {
123
+ const cleaned = html.replace(INJECT_ICON_LINK_RE, '');
124
+ const rendered = tags.map(renderTag).join('\n ');
125
+ const headCloseRe = /<\/head>/i;
126
+ const match = cleaned.match(headCloseRe);
127
+ if (match) {
128
+ return cleaned.replace(headCloseRe, ` ${rendered}\n ${match[0]}`);
129
+ }
130
+ return `${cleaned}\n${rendered}`;
131
+ }