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 +70 -34
- package/dist/cli.mjs +178 -0
- package/dist/html.mjs +49 -5
- package/dist/ico.mjs +23 -0
- package/dist/index.mjs +107 -0
- package/dist/instrumentation.mjs +9 -0
- package/dist/types.mjs +7 -0
- package/package.json +20 -9
- package/src/cli.ts +316 -0
- package/src/html.ts +45 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vite-svg-to-ico
|
|
2
2
|
|
|
3
|
-
[](https://
|
|
3
|
+
[](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
|
|
22
|
-
import svgToIco from
|
|
21
|
+
import { defineConfig } from "vite";
|
|
22
|
+
import svgToIco from "vite-svg-to-ico";
|
|
23
23
|
|
|
24
24
|
export default defineConfig({
|
|
25
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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:
|
|
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:
|
|
183
|
+
svgToIco({ input: "src/icon.svg", dev: { injection: "shim" } });
|
|
146
184
|
|
|
147
185
|
// Disable HMR favicon refresh
|
|
148
|
-
svgToIco({ input:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
54
|
+
/** Escape double quotes in an attribute value so it can be safely emitted inside `"..."`. */
|
|
55
|
+
function escapeAttr(v) {
|
|
56
|
+
return v.replace(/"/g, """);
|
|
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 };
|
package/dist/instrumentation.mjs
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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 &&
|
|
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": "
|
|
49
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
50
|
-
"dprint": "^0.
|
|
51
|
-
"publint": "^0.3.
|
|
52
|
-
"tsdown": "^0.
|
|
53
|
-
"typescript": "^
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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, '"');
|
|
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
|
+
}
|