diff --git a/.dprint.jsonc b/.dprint.jsonc
index 11d37e7..33b5ea9 100644
--- a/.dprint.jsonc
+++ b/.dprint.jsonc
@@ -1,7 +1,7 @@
{ // git ls-remote "https://github.com/kjanat/kjanat.git" HEAD | awk '{print substr($1, 1, 8)}' | xargs -r -I{} sed -i -E 's#(github\.com/kjanat/kjanat/raw/)[0-9a-zA-Z]{4,40}(/configs/dprint\.remote\.json)#\1{}\2#g' .dprint.jsonc
"lineWidth": 120,
"extends": "https://github.com/kjanat/kjanat/raw/e5f1f678/configs/dprint.remote.json",
- "excludes": [".github/workflows/capture.yml", ".github/actions/capture/action.yml", "**/tests/fixtures"],
+ "excludes": ["tests/fixtures", "tests/*/fixture"],
"malva": { "useTabs": false },
"markup": {
"indentWidth": 2,
diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
index d789939..3a2a073 100644
--- a/.github/workflows/autofix.yml
+++ b/.github/workflows/autofix.yml
@@ -17,6 +17,11 @@ jobs:
- uses: kjanat/runner@master
- uses: oven-sh/setup-bun@v2
- run: runner install fmt:autofix
- - if: github.ref_name == 'master'
+ - if: github.ref_name == 'master' && github.event_name == 'push'
run: run fmt:cfg:up
+ - if: github.ref_name == 'master' && github.event_name == 'push'
+ run: |
+ git ls-remote "https://github.com/kjanat/kjanat.git" HEAD | awk '{print substr($1, 1, 8)}' \
+ | xargs -r -I{} sed -i -E 's#(github\.com/kjanat/kjanat/raw/)[0-9a-zA-Z]{4,40}(/configs/dprint\.remote\.json)#\1{}\2#g' \
+ .dprint.jsonc
- uses: autofix-ci/action@v1
diff --git a/.gitignore b/.gitignore
index 4f51f55..ea1546b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,6 +73,11 @@ web_modules/
# Output of 'npm pack'
*.tgz
+# Smoke-fixture lockfile: pins the integrity of the packed tarball, which is
+# rebuilt from changing source on every `pretest` run. Tracking it would drift
+# and break CI's frozen install — the fixture regenerates it each run instead.
+tests/smoke/fixture/bun.lock
+
# Yarn Integrity file
.yarn-integrity
@@ -161,3 +166,4 @@ dist
AGENTS.md
/_*.md
/.idea/
+superpowers/
diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc
new file mode 100644
index 0000000..6744d97
--- /dev/null
+++ b/.markdownlint.jsonc
@@ -0,0 +1,5 @@
+{
+ "MD013": false,
+ "MD033": false,
+ "MD041": false
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58f5798..bee16a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [4.0.0] - 2026-06-30
+
+### Removed (BREAKING)
+
+- Dropped the v2 `emit: { source, sizes, inject }` object shape — `emit` now
+ accepts only an `EmitSpec[]` array. Removed the exported types
+ `LegacyEmitOptions`, `EmitOptions`, `isLegacyEmit`, `NormalizedEmit`,
+ `IncludeSourceOptions`, `EmitSizesFormat`, and `EMIT_SIZES_FORMATS`.
+- Removed the inert `--mode`/`-m` flag from the `svg-to-ico inject` CLI and the
+ now-unused `InjectMode` / `INJECT_MODES` exports — the flag never affected
+ output (the CLI emits ICO + optional SVG links regardless).
+
+ | v2 (removed) | v3/v4 |
+ | ------------------------------------------- | -------------------------------------------------------------------------- |
+ | `emit: { source: true }` | `emit: [{ format: 'ico' }, { format: 'svg' }]` |
+ | `emit: { sizes: 'png' }` | `emit: [{ format: 'ico' }, { format: 'png', sizes: [16, 32, 48] }]` |
+ | `emit: { sizes: 'ico' }` | `emit: [{ format: 'ico' }, { format: 'ico', sizes: [n], filename: … }, …]` |
+ | `emit: { source: true, inject: 'minimal' }` | `emit: [{ format: 'ico', inject: true }, { format: 'svg', inject: true }]` |
+ | `emit: { inject: 'full', sizes: 'png' }` | add `{ format: 'png', sizes: […], inject: true }` to the array |
+
+### Internal
+
+- Restructured the library internals (`src/*.ts`) by pipeline stage: a single
+ `parseConfig` boundary (`config.ts`) replaces three scattered parse/validate
+ sites; one shared favicon-tag builder (`favicon-tags.ts`) serves both the
+ plugin and the CLI (removing the duplicated `withBase`/`` logic); byte
+ production moves into a testable `AssetProducer` (`assets.ts`); `index.ts`
+ shrinks from ~600 to ~300 lines. `ico.ts` split into `raster.ts` (sharp) +
+ `ico.ts` (packing); `html.ts` split into `favicon-tags.ts` + `inject-html.ts`.
+ `IconSize` is now a branded type produced by `parseSize` at the boundary
+ (public option fields remain plain `number`). No runtime behavior change
+ beyond the v2 removal above.
+
+### Added
+
+- Embed favicons inline as `data:` URIs instead of (or alongside) emitting
+ files. Each `ico`/`png`/`svg` emit spec gains two orthogonal knobs:
+ - `inject: 'embed'` — the injected ``'s `href` carries the image
+ bytes as a `data:` URI (base64 for binary, configurable for SVG) rather
+ than pointing at a file.
+ - `emit: false` — skip writing the file to disk; only meaningful with
+ `inject: 'embed'` (embed without a file). Defaults to `true`.
+ - `SvgSpec.encoding: 'base64' | 'utf8'` — `utf8` produces a smaller,
+ human-readable `data:image/svg+xml,…` URI. Defaults to `'base64'`.
+ - PNG specs also accept `{ sizes, embed: true }` to inline a subset.
+
+ Data-URI hrefs are never cache-busted (a query param would corrupt the
+ bytes), and the dev HMR client skips them. A spec that writes nothing and
+ injects nothing now emits a one-time config warning.
+
+- `svg-to-ico inject` gained matching `--embed` / `--encoding` / `--asset-dir`
+ flags: inline the referenced ICO (and SVG `--source`) straight into the
+ rewritten HTML as `data:` URIs instead of URL ``s. Assets are
+ read from `--asset-dir` (default: each HTML file's own directory).
+
### Changed
- CLI help/deprecation output now renders OSC 8 terminal hyperlinks
diff --git a/README.md b/README.md
index 6e8f6ff..10770c7 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,31 @@ When any spec has `inject: true`, the plugin strips existing
from the HTML before injecting the new set,
to prevent duplicates. `apple-touch-icon` tags are preserved.
+### Embedding as `data:` URIs
+
+`inject: 'embed'` inlines the favicon bytes directly into the `` href as a
+`data:` URI — the HTML carries the image itself, no file reference. Pair it with
+`emit: false` to embed without writing a file at all.
+
+```ts
+svgToIco({
+ input: 'src/icon.svg',
+ emit: [
+ // ICO inlined as base64 AND written to disk (default emit: true).
+ { format: 'ico', sizes: [16, 32], inject: 'embed' },
+ // SVG inlined as a utf8 data: URI, no file on disk.
+ { format: 'svg', emit: false, inject: 'embed', encoding: 'utf8' },
+ ],
+});
+```
+
+Encoding (`SvgSpec` only): `base64` (default) is opaque and uniform; `utf8`
+(`data:image/svg+xml,…`) keeps the markup readable and is usually smaller. The
+SVG bytes are preserved verbatim — quotes and significant whitespace (including
+CDATA and `xml:space="preserve"`) survive the round-trip unchanged. Binary ICO
+and PNG are always base64. Embedded hrefs are never cache-busted, since the href
+_is_ the content.
+
### Non-SVG input
PNG, JPEG, WebP, AVIF, GIF, and TIFF sources are supported — the plugin detects
@@ -133,13 +158,12 @@ svg-to-ico generate https://example.com/icon.svg --out-dir build
npx -y --package=vite-svg-to-ico svg-to-ico generate https://example.com/icon.svg --out-dir build
```
-### Legacy v2 `emit` shape
+### Migrating from the v2 `emit` shape
-The `{ source, sizes, inject }` object shape from v2 still works
-via a compatibility shim and logs a one-time deprecation warning.
-It will be **removed in v4**. Migrate examples:
+The `{ source, sizes, inject }` object shape was **removed in v4** — `emit`
+now accepts only an `EmitSpec[]` array. Convert as follows:
-| v2 | v3 |
+| v2 (removed) | v3/v4 |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `emit: { source: true }` | `emit: [{ format: 'ico' }, { format: 'svg' }]` |
| `emit: { sizes: 'png' }` | `emit: [{ format: 'ico' }, { format: 'png', sizes: [16, 32, 48] }]` |
@@ -219,14 +243,14 @@ svgToIco({ input: 'src/icon.svg', dev: { hmr: false } });
## Options
-| Option | Type | Default | Description |
-| -------- | --------------------------------- | --------------------- | --------------------------------------------------------------------------------- |
-| `input` | `string \| URL` | **(required)** | Source image: path, `URL` instance, or `file://` / `http(s)://` URL string. |
-| `sizes` | `number \| number[]` | `[16, 32, 48]` | Default sizes used when an `IcoSpec` omits its own `sizes`. |
-| `emit` | `EmitSpec[] \| LegacyEmitOptions` | `[{ format: 'ico' }]` | What to emit and inject. Array of specs (v3) or legacy object shape (deprecated). |
-| `output` | `string` | `'favicon.ico'` | _Deprecated_. Fallback ICO filename when an `IcoSpec` omits `filename`. |
-| `sharp` | `SharpOptions` | `{}` | Sharp image processing options. |
-| `dev` | `boolean \| DevOptions` | `true` | Control dev-server behavior. |
+| Option | Type | Default | Description |
+| -------- | ----------------------- | --------------------- | --------------------------------------------------------------------------- |
+| `input` | `string \| URL` | **(required)** | Source image: path, `URL` instance, or `file://` / `http(s)://` URL string. |
+| `sizes` | `number \| number[]` | `[16, 32, 48]` | Default sizes used when an `IcoSpec` omits its own `sizes`. |
+| `emit` | `EmitSpec[]` | `[{ format: 'ico' }]` | What to emit and inject — an array of per-format specs. |
+| `output` | `string` | `'favicon.ico'` | Fallback ICO filename when an `IcoSpec` omits `filename`. |
+| `sharp` | `SharpOptions` | `{}` | Sharp image processing options. |
+| `dev` | `boolean \| DevOptions` | `true` | Control dev-server behavior. |
### `emit` (v3 — recommended)
@@ -234,29 +258,33 @@ Array of per-format specs. Each entry is one of:
#### `IcoSpec`
-| Field | Type | Default | Description |
-| ---------- | ----------- | ----------------- | ----------------------------------------------- |
-| `format` | `'ico'` | — | Discriminator. |
-| `sizes` | `number[]?` | Top-level `sizes` | Sizes to pack into this ICO (1–256). |
-| `filename` | `string?` | `'favicon.ico'` | Output filename (relative to build output). |
-| `inject` | `boolean?` | `false` | Inject ``. |
+| Field | Type | Default | Description |
+| ---------- | -------------------- | ----------------- | ------------------------------------------------------------------------ |
+| `format` | `'ico'` | — | Discriminator. |
+| `sizes` | `number[]?` | Top-level `sizes` | Sizes to pack into this ICO (1–256). |
+| `filename` | `string?` | `'favicon.ico'` | Output filename (relative to build output). |
+| `emit` | `boolean?` | `true` | Write the ICO file. Set `false` to embed without writing (see `inject`). |
+| `inject` | `boolean \| 'embed'` | `false` | `true` links the file; `'embed'` inlines the bytes as a `data:` URI. |
#### `PngSpec`
-| Field | Type | Default | Description |
-| ------------------ | ---------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
-| `format` | `'png'` | — | Discriminator. |
-| `sizes` | `number[]` | **(required)** | Sizes to emit as separate PNG files (1–256). |
-| `filenameTemplate` | `string?` | `'favicon-{size}x{size}.png'` | Template using `{size}` placeholder. |
-| `inject` | `boolean \| { sizes?: number[] }?` | `false` | `true` injects all sizes; `{ sizes }` injects only the listed subset. |
+| Field | Type | Default | Description |
+| ------------------ | ------------------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------- |
+| `format` | `'png'` | — | Discriminator. |
+| `sizes` | `number[]` | **(required)** | Sizes to emit as separate PNG files (1–4096 — not bound by ICO's 256 cap). |
+| `filenameTemplate` | `string?` | `'favicon-{size}x{size}.png'` | Template using `{size}` placeholder. |
+| `emit` | `boolean?` | `true` | Write the PNG files. Set `false` to embed without writing. |
+| `inject` | `boolean \| 'embed' \| { sizes?, embed? }` | `false` | `true` links all sizes; `'embed'` inlines all; `{ sizes }` / `{ embed }` scope it. |
#### `SvgSpec`
-| Field | Type | Default | Description |
-| ---------- | ---------- | ----------------- | ------------------------------------------------------------ |
-| `format` | `'svg'` | — | Discriminator. |
-| `filename` | `string?` | `basename(input)` | Output filename (only meaningful when input is an SVG). |
-| `inject` | `boolean?` | `false` | Inject ``. |
+| Field | Type | Default | Description |
+| ---------- | -------------------- | ----------------- | ----------------------------------------------------------------------------------- |
+| `format` | `'svg'` | — | Discriminator. |
+| `filename` | `string?` | `basename(input)` | Output filename (only meaningful when input is an SVG). |
+| `emit` | `boolean?` | `true` | Write the SVG copy. Set `false` to embed without writing. |
+| `inject` | `boolean \| 'embed'` | `false` | `true` links the file; `'embed'` inlines the SVG as a `data:` URI. |
+| `encoding` | `'base64' \| 'utf8'` | `'base64'` | Embed encoding (only with `inject: 'embed'`). `utf8` is readable + usually smaller. |
### `emit` (v2 — deprecated, removed in v4)
diff --git a/bun.lock b/bun.lock
index 9ec9092..92bbe02 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,12 +22,6 @@
"vite": "^8",
},
},
- "tests/smoke/fixture": {
- "name": "vsi-smoke-fixture",
- "devDependencies": {
- "vite": "^8",
- },
- },
},
"overrides": {
"fflate": "0.8.2",
@@ -391,8 +385,6 @@
"vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="],
- "vsi-smoke-fixture": ["vsi-smoke-fixture@workspace:tests/smoke/fixture"],
-
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="],
diff --git a/bunfig.toml b/bunfig.toml
index 337ec20..7ce3ed6 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,2 +1,4 @@
[test]
-preload = ["./preload.ts"]
+coverage = true
+coverageSkipTestFiles = true
+coveragePathIgnorePatterns = ["tests/**"]
diff --git a/package.json b/package.json
index 5ac99d9..7b3f56f 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,7 @@
{
"name": "vite-svg-to-ico",
- "version": "3.1.6",
+ "version": "4.0.0",
"description": "Vite plugin that converts SVG to ICO during site build.",
- "workspaces": [
- "tests/smoke/fixture"
- ],
"keywords": [
"vite-plugin",
"vite",
@@ -32,13 +29,19 @@
"#vite-svg-to-ico": "./src/index.ts",
"#internals/*": "./src/*",
"#types": "./src/types.ts",
- "#html": "./src/html.ts",
+ "#faviconTags": "./src/favicon-tags.ts",
+ "#injectHtml": "./src/inject-html.ts",
"#ico": "./src/ico.ts",
+ "#raster": "./src/raster.ts",
+ "#size": "./src/size.ts",
"#cli/*": "./src/cli/*.ts",
"#cli": "./src/cli.ts",
+ "#assets": "./src/assets.ts",
+ "#config": "./src/config.ts",
+ "#devClient": "./src/dev-client.ts",
"#instrumentation": "./src/instrumentation.ts",
- "#normalizeEmit": "./src/normalize-emit.ts",
"#loadInput": "./src/load-input.ts",
+ "#dataUri": "./src/data-uri.ts",
"#resolveSpecs": "./src/resolve-specs.ts",
"#pkg": "./package.json"
},
@@ -50,11 +53,12 @@
"build": "tsdown",
"dev": "tsdown --watch",
"fmt": "dprint fmt",
- "fmt:cfg:up": "dprint config update -y",
+ "fmt:cfg:up": "dprint config update -yr",
"fmt:autofix": "dprint fmt --allow-no-files --diff --excludes .github",
"prepack": "bun --bun bd -l error",
"prepublishOnly": "run -s test typecheck",
"tar": "bun pm pack --quiet | awk 'NF{line=$0} END{print line}'",
+ "pretest": "bun pm pack --gzip-level 0 --filename tests/smoke/fixture/vite-svg-to-ico.tgz && bun --cwd=tests/smoke/fixture install --no-frozen-lockfile",
"test": "bun test",
"typecheck": "tsgo --noEmit",
"lint": "biome lint"
@@ -80,7 +84,8 @@
"fflate": "0.8.2"
},
"engines": {
- "node": ">=22.18.0"
+ "node": ">=22.22.2",
+ "bun": ">=1.3"
},
"packageManager": "bun@1.3.14",
"publishConfig": {
diff --git a/preload.ts b/preload.ts
deleted file mode 100644
index 37e80e5..0000000
--- a/preload.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * Preload for `bun test`: bootstraps the smoke-test fixture once before
- * any test file runs. Wired in via `bunfig.toml [test] preload`, so both
- * local invocations and CI converge on plain `bun test` — no `&&` chain,
- * no orchestrator script, no duplicated yaml steps.
- *
- * Idempotent: builds dist if missing, registers `bun link`, installs the
- * fixture. Each step is cheap on a warm tree (sub-second).
- */
-import { $ } from 'bun';
-
-import { afterAll, beforeAll } from 'bun:test';
-import { error, log } from 'node:console';
-import { existsSync } from 'node:fs';
-import { resolve } from 'node:path';
-
-import { exports } from '#pkg' with { type: 'json' };
-import { outDir } from './tsdown.config.ts';
-
-const getExport = (input: unknown): string | undefined => {
- return typeof input === 'string' ? input : undefined;
-};
-
-const [EXPORTS, FIXTURE] = [
- resolve(
- import.meta.dir,
- getExport(exports['.']) ?? // @ts-expect-error
- getExport(exports['.']['import']) ??
- getExport(exports['.']['default']) ?? // @ts-expect-error
- getExport(exports['.']['require']) ??
- 'dist/index.js',
- ),
- resolve(import.meta.dir, 'tests/smoke/fixture'),
-];
-
-beforeAll(async () => {
- // Build the dist entry if missing (e.g. first run, or after a clean).
- if (existsSync(EXPORTS)) await $`rm -rf ${outDir}`.cwd(import.meta.dir);
-
- try {
- await $`bun --bun bd`.cwd(import.meta.dir).quiet();
- log('built dist entry\n');
-
- await $`bun link`.cwd(import.meta.dir);
- log('linked package\n');
-
- await $`bun link vite-svg-to-ico`.cwd(FIXTURE);
- log('linked package into fixture\n');
- } catch (err) {
- error('Smoke-test setup failed:', err);
- throw err;
- }
-
- // Install the fixture's dependencies, which will pick up the linked package.
- await $`bun install`.cwd(FIXTURE).quiet();
-});
-
-afterAll(async () => {
- await $`bun unlink vite-svg-to-ico`.cwd(FIXTURE).nothrow();
- log('unlinked package from fixture\n');
-
- await $`bun unlink`.cwd(import.meta.dir);
- log('unlinked package from local tree');
-});
diff --git a/src/assets.ts b/src/assets.ts
new file mode 100644
index 0000000..8cce40d
--- /dev/null
+++ b/src/assets.ts
@@ -0,0 +1,107 @@
+/**
+ * Stateful favicon-byte production with caching, extracted from the plugin so
+ * it can be tested without driving Vite. Generates PNGs once for the union of
+ * required sizes, then assembles each {@link ResolvedFile} (ICO container, PNG,
+ * source copy) and memoizes embedded `data:` URIs. `reset()` drops the caches
+ * for an HMR cycle.
+ */
+
+import type { ResolvedConfig } from '#config';
+import { toDataUri } from '#dataUri';
+import { packIco } from '#ico';
+import { loadInputBytes } from '#loadInput';
+import { generateSizedPngs, type SizedPng } from '#raster';
+import type { ResolvedFile, ResolvedInjection } from '#resolveSpecs';
+import type { IconSize } from '#types';
+
+export class AssetProducer {
+ #pngs: SizedPng[] | null = null;
+ #inputBuffer: Buffer | null = null;
+ #embedUris = new Map();
+ /** Source path/URL to read; the abs filesystem path is set in `configResolved`. */
+ #input: string;
+
+ constructor(
+ private readonly cfg: ResolvedConfig,
+ private readonly requiredSizes: IconSize[],
+ ) {
+ this.#input = cfg.input;
+ }
+
+ /** Point byte production at the Vite-resolved absolute input path. */
+ setResolvedInput(input: string): void {
+ this.#input = input;
+ }
+
+ /** Read + cache the source input buffer (filesystem or http(s) URL). */
+ async inputBytes(): Promise {
+ if (!this.#inputBuffer) this.#inputBuffer = await loadInputBytes(this.#input);
+ return this.#inputBuffer;
+ }
+
+ /** Generate (once) and return PNGs for every required size. */
+ async pngs(): Promise {
+ if (!this.#pngs) {
+ // URLs are fetched once and cached; sharp accepts the Buffer directly.
+ // Filesystem paths pass through so sharp opens the file itself.
+ const src = this.cfg.inputIsUrl ? await this.inputBytes() : this.#input;
+ this.#pngs = await generateSizedPngs(src, {
+ sizes: this.requiredSizes,
+ optimize: this.cfg.optimize,
+ resize: this.cfg.resize,
+ png: this.cfg.png,
+ });
+ }
+ return this.#pngs;
+ }
+
+ /** Find a generated PNG of `size`, or throw if the size wasn't requested. */
+ async #pngOfSize(size: IconSize): Promise {
+ const png = (await this.pngs()).find((p) => p.size === size);
+ if (!png) throw new Error(`[svg-to-ico] internal: missing PNG size ${size}`);
+ return png;
+ }
+
+ /** Produce the bytes for a resolved file. */
+ async produce(file: ResolvedFile): Promise {
+ const source = file.source;
+ switch (source.kind) {
+ case 'source-copy':
+ return this.inputBytes();
+ case 'png':
+ return (await this.#pngOfSize(source.size)).buffer;
+ case 'single-ico':
+ return packIco([await this.#pngOfSize(source.size)]);
+ case 'combined-ico': {
+ const all = await this.pngs();
+ const subset = source.sizes
+ .map((s) => all.find((p) => p.size === s))
+ .filter((p): p is SizedPng => p !== undefined);
+ return packIco(subset);
+ }
+ }
+ }
+
+ /** Produce (and memoize) the `data:` URI for an embed injection from the same bytes the emitter uses. */
+ async embedUri(inj: ResolvedInjection): Promise {
+ const cached = this.#embedUris.get(inj);
+ if (cached !== undefined) return cached;
+ if (inj.href.kind !== 'embed') throw new Error('[svg-to-ico] internal: embedUri called on a non-embed injection');
+ const bytes = await this.produce({ filename: '', mime: '', source: inj.href.source });
+ const uri = toDataUri(bytes, inj.type, inj.href.encoding);
+ this.#embedUris.set(inj, uri);
+ return uri;
+ }
+
+ /** Content-Type header for a file based on its mime subtype. */
+ contentType(mime: string): string {
+ return mime === 'svg+xml' ? this.cfg.sourceMimeType : `image/${mime}`;
+ }
+
+ /** Drop all caches so the next access regenerates (HMR). */
+ reset(): void {
+ this.#pngs = null;
+ this.#inputBuffer = null;
+ this.#embedUris.clear();
+ }
+}
diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts
index 54e69d9..eebdb60 100644
--- a/src/cli/commands/generate.ts
+++ b/src/cli/commands/generate.ts
@@ -2,7 +2,8 @@ import { source } from '#cli/args/source';
import { blue, green, red } from '#cli/colors';
import { pathFlag } from '#cli/flags/path';
import { sizesFlag } from '#cli/flags/sizes';
-import { generateSizedPngs, packIco } from '#ico';
+import { packIco } from '#ico';
+import { generateSizedPngs } from '#raster';
import { inputBasename, loadInputBytes } from '#loadInput';
import { command, flag } from '@kjanat/dreamcli';
import { mkdir, writeFile } from 'node:fs/promises';
@@ -18,7 +19,10 @@ import { dirname, resolve } from 'node:path';
*/
export 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 ${blue('vite build')}, but runs standalone.`,
+ `\
+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 ${blue('vite build')}, but runs standalone.`,
)
.arg(
'input',
diff --git a/src/cli/commands/inject.ts b/src/cli/commands/inject.ts
index d8f5a35..06e0caa 100644
--- a/src/cli/commands/inject.ts
+++ b/src/cli/commands/inject.ts
@@ -1,7 +1,12 @@
import { blue, green, red } from '#cli/colors';
+import { pathFlag } from '#cli/flags/path';
import { sizesFlag } from '#cli/flags/sizes';
-import { buildFaviconTags, injectTagsIntoHtml } from '#html';
-import { INJECT_MODES } from '#types';
+import { toDataUri } from '#dataUri';
+import { buildFaviconTags, type TagContext } from '#faviconTags';
+import { injectTagsIntoHtml } from '#injectHtml';
+import { resolveSpecs } from '#resolveSpecs';
+import type { EmitSpec } from '#types';
+import { DATA_URI_ENCODINGS } from '#types';
import { arg, CLIError, command, flag } from '@kjanat/dreamcli';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
@@ -15,11 +20,12 @@ import { dirname, resolve } from 'node:path';
*/
export const inject = command('inject')
.description(
- `Rewrite existing HTML files on disk: strip ${blue('')} and ${
+ `Rewrite existing HTML files on disk:
+strip ${blue('')} and ${
blue('')
- } tags (preserves ${red('apple-touch-icon')}), splice in the configured favicon tag set before ${blue(
- '',
- )}, and write back. The ICO/SVG files themselves are expected to already exist at the configured paths.`,
+ } tags (preserves ${red('apple-touch-icon')}),
+splice in the configured favicon tag set before ${blue('')}, and write back.
+The ICO/SVG files themselves are expected to already exist at the configured paths.`,
)
.arg(
'files',
@@ -41,16 +47,6 @@ export const inject = command('inject')
),
)
.flag('sizes', sizesFlag())
- .flag(
- 'mode',
- flag
- .enum(INJECT_MODES)
- .alias('m')
- .default('minimal')
- .describe(
- `Tag set to inject. ${red('minimal')}: ICO + optional SVG source link. ${red('full')}: also per-size PNG/ICO links.`,
- ),
- )
.flag(
'base',
flag
@@ -79,14 +75,42 @@ export const inject = command('inject')
`Format of ${blue('--source')} for the MIME type attribute. Only ${red('svg')} triggers the SVG ${blue('')}; other values are accepted but currently inert in tag generation.`,
),
)
+ .flag(
+ 'embed',
+ flag
+ .boolean()
+ .default(false)
+ .describe(
+ `Inline the favicon bytes as ${blue('data:')} URIs instead of URL hrefs — the ${blue('')} carries the image itself, no file reference. Reads the referenced files from ${blue('--asset-dir')}.`,
+ ),
+ )
+ .flag(
+ 'encoding',
+ flag
+ .enum(DATA_URI_ENCODINGS)
+ .default('base64')
+ .describe(
+ `Encoding for an embedded SVG ${blue('--source')}: ${red('base64')} or ${red('utf8')} (smaller, human-readable). Binary ICO is always ${red('base64')}. Only applies with ${blue('--embed')}.`,
+ ),
+ )
+ .flag(
+ 'asset-dir',
+ pathFlag().describe(
+ `Directory to read favicon files from when ${blue('--embed')} is set. Defaults to each HTML file's own directory.`,
+ ),
+ )
.example(green('inject build/index.html'), 'Inject default favicon.ico tag (16/32/48) into a single file.')
.example(
green('inject build/index.html build/404.html -s16 -s32 -s48 --source favicon.svg'),
`Multi-file rewrite, also injects SVG source ${blue('')}.`,
)
.example(
- green('inject dist/index.html --base /repo/ -m full'),
- 'Full tag set under a subpath base (e.g. GitHub Pages project site).',
+ green('inject dist/index.html --base /repo/'),
+ 'Inject under a subpath base (e.g. GitHub Pages project site).',
+ )
+ .example(
+ green('inject dist/index.html --source favicon.svg --embed --encoding utf8'),
+ 'Inline the ICO + SVG straight into the HTML as data: URIs (no file references).',
)
.action(async ({ args, flags, out }) => {
const { error, log } = out;
@@ -94,18 +118,41 @@ export const inject = command('inject')
if (files.length === 0) {
throw new CLIError('At least one HTML file path is required', { code: 'MISSING_FILES' });
}
- const sizes = flags.sizes;
- const mode = flags.mode;
const sourceName = flags.source;
- const tags = buildFaviconTags({
- output: flags.output,
- sizes,
- sourceEmitted: !!sourceName,
- sourceName: sourceName ?? '',
- inputFormat: flags['input-format'],
- mode,
- base: flags.base,
- });
+ // Build the same spec model the plugin uses, then resolve to injections.
+ const specs: EmitSpec[] = [{ format: 'ico', sizes: flags.sizes, filename: flags.output, inject: true }];
+ if (sourceName) specs.push({ format: 'svg', filename: sourceName, inject: true });
+ const { injections } = resolveSpecs(specs, { inputFormat: flags['input-format'] });
+
+ /**
+ * Read the favicon files an embed run needs (ICO, plus the SVG source if
+ * set) from `assetDir`, returning a {@link TagContext} embed resolver that
+ * inlines them by filename. Throws a clear error if a referenced file is missing.
+ */
+ async function embedResolverFor(assetDir: string): Promise> {
+ // Read exactly the files the resolved injections reference — not whatever
+ // `--source` implies. resolveSpecs() may drop an inert tag (e.g. an SVG
+ // source under `--input-format png`), and reading its file would fail for
+ // no user-visible reason.
+ const names = [...new Set(injections.flatMap((inj) => (inj.href.kind === 'file' ? [inj.href.filename] : [])))];
+ const bytesByName = new Map();
+ for (const name of names) {
+ const path = resolve(assetDir, name);
+ try {
+ bytesByName.set(name, await readFile(path));
+ } catch (e) {
+ throw new CLIError(`inject --embed: cannot read "${name}" at ${path}: ${(e as Error).message}`, {
+ code: 'EMBED_READ',
+ });
+ }
+ }
+ return (inj) => {
+ if (inj.href.kind !== 'file') return undefined;
+ const bytes = bytesByName.get(inj.href.filename);
+ if (!bytes) return undefined; // not pre-read → leave the URL href untouched
+ return toDataUri(bytes, inj.type, inj.type === 'image/svg+xml' ? flags.encoding : 'base64');
+ };
+ }
let rewritten = 0;
for (const rel of files) {
@@ -120,6 +167,9 @@ export const inject = command('inject')
}
throw e;
}
+ // Embedded hrefs read assets per file (default: the HTML's own directory).
+ const embed = flags.embed ? await embedResolverFor(flags['asset-dir'] ?? dirname(abs)) : undefined;
+ const tags = await buildFaviconTags(injections, { base: flags.base, embed });
const next = injectTagsIntoHtml(original, tags);
if (next !== original) {
const dir = dirname(abs);
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..13cd78a
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,232 @@
+/**
+ * The single parse boundary: `PluginOptions` (loose, user-authored) → a
+ * validated, fully-defaulted `ResolvedConfig` (strict, internal). Everything
+ * downstream — spec resolution, byte production, the Vite hooks — consumes
+ * `ResolvedConfig` and trusts it. All option validation lives here and throws
+ * eagerly; there is no second validation pass in the plugin hooks.
+ *
+ * Pure: no Vite `root`/`base` (those are applied at `configResolved` time),
+ * no filesystem, no sharp.
+ */
+
+import { inspect } from 'node:util';
+
+import { inputBasename, inputExtname, isHttpUrl, normalizeInput } from '#loadInput';
+import type { GenerateOptions } from '#raster';
+import type { DevOptions, EmitSpec, PluginOptions } from '#types';
+import { DATA_URI_ENCODINGS, DEV_INJECTIONS, EMIT_FORMATS, SUPPORTED_EXTENSIONS, SVG_EXTENSIONS } from '#types';
+
+/** Normalize extensions to correct MIME subtypes. */
+const MIME_OVERRIDES: Record = { jpg: 'jpeg', tif: 'tiff' };
+
+/** Fully-validated, defaulted plugin configuration. The internal source of truth. */
+export interface ResolvedConfig {
+ /** Canonical input: filesystem path or `http(s)://` URL (URL/`file://` collapsed). */
+ input: string;
+ inputIsUrl: boolean;
+ /** Detected input format token: `'svg'`, `'png'`, `'jpeg'`, … */
+ inputFormat: string;
+ /** Full MIME type for the source format, e.g. `'image/svg+xml'`. */
+ sourceMimeType: string;
+ /** Validated top-level sizes (fallback for specs omitting `sizes`). */
+ sizes: number[];
+ optimize: boolean;
+ resize?: GenerateOptions['resize'];
+ png?: GenerateOptions['png'];
+ dev: Required;
+ /** Spec array with every default filled and range-validated. */
+ specs: EmitSpec[];
+}
+
+/** Throw a namespaced configuration error. */
+function fail(message: string): never {
+ throw new Error(`[svg-to-ico] ${message}`);
+}
+
+/** Validate that every size is an integer in `[1, max]`; throw listing offenders. */
+function assertSizeRange(sizes: readonly number[], max: number, label: string): void {
+ const bad = sizes.filter((s) => !Number.isInteger(s) || s < 1 || s > max);
+ if (bad.length > 0) fail(`${label} invalid: ${bad.join(', ')}. Must be integers 1–${max}.`);
+}
+
+/** Require a value to be a boolean (JS consumers can pass anything). Params typed `unknown` to dodge no-overlap narrowing. */
+function assertBoolean(value: unknown, label: string): void {
+ if (typeof value !== 'boolean') fail(`${label} must be a boolean.`);
+}
+
+/** Require an `inject` value to be `false`, `true`, or `'embed'` (the shape ICO/SVG specs accept). */
+function assertSimpleInject(value: unknown, i: number): void {
+ if (value !== false && value !== true && value !== 'embed') {
+ fail(`emit[${i}].inject must be false, true, or 'embed'.`);
+ }
+}
+
+interface Defaults {
+ sizes: number[];
+ icoFilename: string;
+ svgFilename: string;
+}
+
+/** Fill an {@link EmitSpec}'s optional fields with defaults; unknown formats pass through for validation to reject. */
+function fillSpecDefaults(spec: EmitSpec, d: Defaults): EmitSpec {
+ switch (spec.format) {
+ case 'ico':
+ return {
+ format: 'ico',
+ sizes: spec.sizes ?? d.sizes,
+ filename: spec.filename ?? d.icoFilename,
+ emit: spec.emit ?? true,
+ inject: spec.inject ?? false,
+ };
+ case 'png':
+ return {
+ format: 'png',
+ sizes: spec.sizes,
+ filenameTemplate: spec.filenameTemplate ?? 'favicon-{size}x{size}.png',
+ emit: spec.emit ?? true,
+ inject: spec.inject ?? false,
+ };
+ case 'svg':
+ return {
+ format: 'svg',
+ filename: spec.filename ?? d.svgFilename,
+ emit: spec.emit ?? true,
+ inject: spec.inject ?? false,
+ encoding: spec.encoding ?? 'base64',
+ };
+ default:
+ return spec;
+ }
+}
+
+/** Validate one defaulted spec. Rejects bogus JS shapes so downstream layers only ever see normalized, valid values. */
+function validateSpec(spec: EmitSpec, i: number): void {
+ if (!(EMIT_FORMATS as readonly string[]).includes(spec.format)) {
+ fail(
+ `emit[${i}].format invalid: "${spec.format}". Must be one of ${EMIT_FORMATS.map((f) => `'${f}'`).join(', ')}.`,
+ );
+ }
+ assertBoolean(spec.emit, `emit[${i}].emit`);
+ if (spec.format === 'ico') {
+ assertSimpleInject(spec.inject, i);
+ if (!spec.sizes || spec.sizes.length === 0) fail(`emit[${i}] (ico) requires \`sizes\` with at least one value.`);
+ else assertSizeRange(spec.sizes, 256, `emit[${i}].sizes`);
+ }
+ if (spec.format === 'png') {
+ if (!spec.sizes || spec.sizes.length === 0) fail(`emit[${i}] (png) requires \`sizes\` with at least one value.`);
+ // PNG specs are standalone files — they don't share ICO's 8-bit width/height
+ // field, so the 256 cap doesn't apply. Cap at 4096 to catch obvious typos.
+ assertSizeRange(spec.sizes, 4096, `emit[${i}].sizes`);
+ const inj: unknown = spec.inject;
+ const isPlainObject = typeof inj === 'object' && inj !== null && !Array.isArray(inj);
+ if (inj !== false && inj !== true && inj !== 'embed' && !isPlainObject) {
+ fail(`emit[${i}].inject must be false, true, 'embed', or a { sizes?, embed? } object.`);
+ }
+ if (isPlainObject) {
+ const obj = inj as { sizes?: unknown; embed?: unknown };
+ if (obj.embed !== undefined) assertBoolean(obj.embed, `emit[${i}].inject.embed`);
+ if (obj.sizes !== undefined) {
+ // An empty subset injects nothing yet reports as "configured", masking a
+ // spec that silently produces no output — reject it at the boundary.
+ if (!Array.isArray(obj.sizes) || obj.sizes.length === 0) {
+ fail(`emit[${i}].inject.sizes must be a non-empty subset of spec.sizes.`);
+ }
+ const allowed = new Set(spec.sizes);
+ const bad = obj.sizes.filter((s) => !allowed.has(s));
+ if (bad.length > 0) {
+ fail(
+ `emit[${i}].inject.sizes contains values not in spec.sizes: ${bad.join(', ')}. ` +
+ `Must be a subset of [${spec.sizes.join(', ')}].`,
+ );
+ }
+ }
+ }
+ }
+ if (spec.format === 'svg') {
+ assertSimpleInject(spec.inject, i);
+ if (!(DATA_URI_ENCODINGS as readonly unknown[]).includes(spec.encoding)) {
+ fail(
+ `emit[${i}].encoding invalid: "${String(spec.encoding)}". Must be ${DATA_URI_ENCODINGS.map(
+ (e) => `'${e}'`,
+ ).join(', ')}.`,
+ );
+ }
+ }
+}
+
+/**
+ * Parse and validate {@link PluginOptions} into a {@link ResolvedConfig}.
+ * @throws Error (prefixed `[svg-to-ico]`) on any invalid option.
+ */
+export function parseConfig(opts: PluginOptions): ResolvedConfig {
+ const input = opts.input == null ? '' : normalizeInput(opts.input);
+ if (!input) fail('`input` must be a non-empty string');
+
+ const inputExt = inputExtname(input);
+ if (!SUPPORTED_EXTENSIONS.has(inputExt)) {
+ fail(`Unsupported input format: "${inputExt}". Supported: ${[...SUPPORTED_EXTENSIONS].join(', ')}`);
+ }
+ const inputIsUrl = isHttpUrl(input);
+ const inputFormat = SVG_EXTENSIONS.has(inputExt) ? 'svg' : inputExt.replace('.', '');
+ const mimeFormat = MIME_OVERRIDES[inputFormat] ?? inputFormat;
+ const sourceMimeType = inputFormat === 'svg' ? 'image/svg+xml' : `image/${mimeFormat}`;
+
+ const rawSizes = opts.sizes ?? [16, 32, 48];
+ const sizes = Array.isArray(rawSizes) ? rawSizes : [rawSizes];
+ if (sizes.length === 0) fail('`sizes` must contain at least one value');
+ assertSizeRange(sizes, 256, 'Invalid sizes:');
+
+ const sharp = opts.sharp;
+
+ const devDefaults: Required = { enabled: true, injection: 'transform', hmr: true };
+ const rawDev = opts.dev ?? true;
+ // JS consumers can pass anything. Reject non-boolean/non-object `dev`, and any
+ // non-boolean `enabled`/`hmr`, before they get spread in as the wrong type.
+ if (typeof rawDev !== 'boolean' && (rawDev === null || typeof rawDev !== 'object' || Array.isArray(rawDev))) {
+ fail('`dev` must be a boolean or an object.');
+ }
+ if (typeof rawDev === 'object') {
+ if (rawDev.enabled !== undefined) assertBoolean(rawDev.enabled, '`dev.enabled`');
+ if (rawDev.hmr !== undefined) assertBoolean(rawDev.hmr, '`dev.hmr`');
+ }
+ const dev: Required =
+ typeof rawDev === 'boolean' ? { ...devDefaults, enabled: rawDev } : { ...devDefaults, ...rawDev };
+ if (
+ typeof rawDev === 'object' &&
+ rawDev.injection !== undefined &&
+ !(DEV_INJECTIONS as readonly string[]).includes(rawDev.injection)
+ ) {
+ fail(
+ `Invalid dev.injection value: "${rawDev.injection}". Must be ${DEV_INJECTIONS.map((m) => `'${m}'`).join(', ')}.`,
+ );
+ }
+
+ const defaults: Defaults = {
+ sizes,
+ icoFilename: opts.output ?? 'favicon.ico',
+ svgFilename: inputBasename(input),
+ };
+ // `emit` is typed `EmitSpec[]`, but JS consumers can pass anything. Only
+ // `undefined` falls back to the default; non-array values (incl. `null`,
+ // numbers, BigInt) are rejected with a readable repr via `inspect`.
+ if (opts.emit !== undefined && !Array.isArray(opts.emit)) {
+ fail(
+ `Invalid \`emit\` value: expected an EmitSpec[], received ${inspect(opts.emit, { depth: 2, breakLength: Infinity })}.`,
+ );
+ }
+ const specs = (opts.emit ?? [{ format: 'ico' }]).map((spec) => fillSpecDefaults(spec, defaults));
+ specs.forEach(validateSpec);
+
+ return {
+ input,
+ inputIsUrl,
+ inputFormat,
+ sourceMimeType,
+ sizes,
+ optimize: sharp?.optimize ?? true,
+ resize: sharp?.resize,
+ png: sharp?.png,
+ dev,
+ specs,
+ };
+}
diff --git a/src/data-uri.ts b/src/data-uri.ts
new file mode 100644
index 0000000..64e41b2
--- /dev/null
+++ b/src/data-uri.ts
@@ -0,0 +1,69 @@
+/**
+ * Build `data:` URIs for embedding favicon bytes directly into HTML, so a
+ * `` can carry the image inline instead of pointing at a file.
+ *
+ * Two encodings ({@link DataUriEncoding}):
+ *
+ * - `base64` — `data:;base64,<…>`. Works for any bytes and is the only
+ * valid choice for binary formats (ICO, PNG). Costs ~33% over the raw size.
+ * - `utf8` — `data:,`. Text formats only (SVG).
+ * Keeps the markup human-readable and is typically *smaller* than base64.
+ *
+ * Pure module: no Vite, sharp, or filesystem. The plugin feeds it bytes it has
+ * already produced and splices the result into a `` href.
+ */
+
+import type { DataUriEncoding } from '#types';
+
+/**
+ * Percent-escape a UTF-8 SVG string for use inside a `data:` URI that will
+ * itself sit in an HTML double-quoted attribute.
+ *
+ * Every escape is a percent-encoding, so the original bytes are recovered
+ * verbatim when the browser decodes the URI: the embedded favicon is identical
+ * to the source down to whitespace and quotes. CDATA, `xml:space="preserve"`
+ * text, and `a b';
+ const uri = toDataUri(Buffer.from(svg), 'image/svg+xml', 'utf8');
+ const decoded = decodeURIComponent(uri.slice('data:image/svg+xml,'.length));
+ expect(decoded).toBe(svg);
+ });
+
+ it('survives the browser pipeline (URL parse) with CRLF and tabs intact', () => {
+ // A browser resolves the href through the WHATWG URL parser, which strips
+ // raw tab/LF/CR. Decode through `new URL()` — not just decodeURIComponent —
+ // to prove the line endings and indentation actually round-trip.
+ const svg = '';
+ const uri = toDataUri(Buffer.from(svg), 'image/svg+xml', 'utf8');
+ const parsed = new URL(uri);
+ const decoded = decodeURIComponent(parsed.href.slice('data:image/svg+xml,'.length));
+ expect(decoded).toBe(svg);
+ // Belt and braces: a naïve unencoded body would lose these to URL parsing.
+ expect(uri).toContain('%0D%0A'); // CRLF
+ expect(uri).toContain('%09'); // tab
+ });
+
+ it('utf8 is smaller than base64 for typical SVG', () => {
+ const utf8 = toDataUri(Buffer.from(SVG), 'image/svg+xml', 'utf8');
+ const b64 = toDataUri(Buffer.from(SVG), 'image/svg+xml', 'base64');
+ expect(utf8.length).toBeLessThan(b64.length);
+ });
+});
diff --git a/tests/favicon-tags.test.ts b/tests/favicon-tags.test.ts
new file mode 100644
index 0000000..1efbcc4
--- /dev/null
+++ b/tests/favicon-tags.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'bun:test';
+
+import { buildFaviconTags, cacheBust } from '#faviconTags';
+import { resolveSpecs } from '#resolveSpecs';
+import { unwrap } from './_helpers.ts';
+
+const svgCtx = { inputFormat: 'svg' };
+
+describe('cacheBust', () => {
+ it('appends the version query to a plain href', () => {
+ expect(cacheBust('/favicon.svg', 'abc')).toBe('/favicon.svg?v=abc');
+ });
+
+ it('uses & when a query already exists', () => {
+ expect(cacheBust('/favicon.svg?x=1', 'abc')).toBe('/favicon.svg?x=1&v=abc');
+ });
+
+ it('inserts the version before a #fragment so the bust still matches', () => {
+ expect(cacheBust('/favicon.svg#icon', 'abc')).toBe('/favicon.svg?v=abc#icon');
+ expect(cacheBust('/favicon.svg?x=1#icon', 'abc')).toBe('/favicon.svg?x=1&v=abc#icon');
+ });
+
+ it('leaves data: URIs untouched', () => {
+ expect(cacheBust('data:image/svg+xml,STUB', 'abc')).toBe('data:image/svg+xml,STUB');
+ });
+});
+
+describe('buildFaviconTags', () => {
+ it('builds a base-prefixed file href', async () => {
+ const { injections } = resolveSpecs([{ format: 'ico', sizes: [16, 32], inject: true }], svgCtx);
+ const [tag] = await buildFaviconTags(injections, { base: '/app/' });
+ const attrs = unwrap(unwrap(tag).attrs);
+ expect(attrs['href']).toBe('/app/favicon.ico');
+ expect(attrs['type']).toBe('image/x-icon');
+ expect(attrs['sizes']).toBe('16x16 32x32');
+ });
+
+ it('cache-busts file hrefs when cacheId is set', async () => {
+ const { injections } = resolveSpecs([{ format: 'ico', sizes: [16], inject: true }], svgCtx);
+ const [tag] = await buildFaviconTags(injections, { cacheId: 'abc' });
+ expect(unwrap(unwrap(tag).attrs)['href']).toBe('/favicon.ico?v=abc');
+ });
+
+ it('resolves embed-kind injections through ctx.embed and never cache-busts them', async () => {
+ const { injections } = resolveSpecs([{ format: 'svg', inject: 'embed' }], svgCtx);
+ const [tag] = await buildFaviconTags(injections, {
+ cacheId: 'abc',
+ embed: () => 'data:image/svg+xml,STUB',
+ });
+ expect(unwrap(unwrap(tag).attrs)['href']).toBe('data:image/svg+xml,STUB');
+ });
+
+ it('lets ctx.embed inline a file-kind injection (CLI path)', async () => {
+ const { injections } = resolveSpecs([{ format: 'ico', sizes: [16], inject: true }], svgCtx);
+ const [tag] = await buildFaviconTags(injections, {
+ embed: (inj) => (inj.href.kind === 'file' ? 'data:image/x-icon;base64,AAA' : undefined),
+ });
+ expect(unwrap(unwrap(tag).attrs)['href']).toBe('data:image/x-icon;base64,AAA');
+ });
+
+ it('throws if an embed-kind injection has no resolver', async () => {
+ const { injections } = resolveSpecs([{ format: 'svg', inject: 'embed' }], svgCtx);
+ await expect(buildFaviconTags(injections, {})).rejects.toThrow(/without a resolver/);
+ });
+});
diff --git a/tests/html.test.ts b/tests/html.test.ts
deleted file mode 100644
index 49afe48..0000000
--- a/tests/html.test.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { describe, expect, it } from 'bun:test';
-
-import { buildFaviconTags, INJECT_ICON_LINK_RE } from '#internals/html.ts';
-import { unwrap } from './_helpers.ts';
-
-describe('buildFaviconTags', () => {
- it('minimal mode: returns ICO tag only for non-SVG input', () => {
- const tags = buildFaviconTags({
- output: 'favicon.ico',
- sizes: [16, 32],
- sourceEmitted: false,
- sourceName: 'icon.png',
- inputFormat: 'png',
- mode: 'minimal',
- });
- expect(tags).toHaveLength(1);
- const attrs = unwrap(unwrap(tags[0]).attrs);
- expect(attrs['type']).toBe('image/x-icon');
- expect(attrs['href']).toBe('/favicon.ico');
- expect(attrs['sizes']).toBe('16x16 32x32');
- });
-
- it('minimal mode: includes SVG tag when SVG input + source emitted', () => {
- const tags = buildFaviconTags({
- output: 'favicon.ico',
- sizes: [16, 32],
- sourceEmitted: true,
- sourceName: 'icon.svg',
- inputFormat: 'svg',
- mode: 'minimal',
- });
- expect(tags).toHaveLength(2);
- const svgAttrs = unwrap(unwrap(tags[1]).attrs);
- expect(svgAttrs['type']).toBe('image/svg+xml');
- expect(svgAttrs['href']).toBe('/icon.svg');
- });
-
- it('minimal mode: no SVG tag when source not emitted', () => {
- const tags = buildFaviconTags({
- output: 'favicon.ico',
- sizes: [16],
- sourceEmitted: false,
- sourceName: 'icon.svg',
- inputFormat: 'svg',
- mode: 'minimal',
- });
- expect(tags).toHaveLength(1);
- });
-
- it('full mode: includes per-size file tags', () => {
- const tags = buildFaviconTags({
- output: 'favicon.ico',
- sizes: [16, 32],
- sourceEmitted: false,
- sourceName: 'icon.png',
- inputFormat: 'png',
- mode: 'full',
- sizedFiles: [
- { name: 'favicon-16x16.png', size: 16, format: 'png' },
- { name: 'favicon-32x32.png', size: 32, format: 'png' },
- ],
- });
- // 1 ICO + 2 per-size
- expect(tags).toHaveLength(3);
- expect(unwrap(unwrap(tags[1]).attrs)['sizes']).toBe('16x16');
- expect(unwrap(unwrap(tags[2]).attrs)['sizes']).toBe('32x32');
- });
-
- it('full mode without sizedFiles: no per-size tags', () => {
- const tags = buildFaviconTags({
- output: 'favicon.ico',
- sizes: [16],
- sourceEmitted: false,
- sourceName: 'icon.png',
- inputFormat: 'png',
- mode: 'full',
- });
- expect(tags).toHaveLength(1);
- });
-
- it('all tags inject to head', () => {
- const tags = buildFaviconTags({
- output: 'favicon.ico',
- sizes: [16],
- sourceEmitted: true,
- sourceName: 'icon.svg',
- inputFormat: 'svg',
- mode: 'full',
- sizedFiles: [{ name: 'favicon-16x16.png', size: 16, format: 'png' }],
- });
- for (const tag of tags) {
- expect(tag.injectTo).toBe('head');
- }
- });
-});
-
-describe('INJECT_ICON_LINK_RE', () => {
- it('matches ', () => {
- expect('').toMatch(INJECT_ICON_LINK_RE);
- });
-
- it('matches ', () => {
- // Reset lastIndex since it's a global regex
- INJECT_ICON_LINK_RE.lastIndex = 0;
- expect('').toMatch(INJECT_ICON_LINK_RE);
- });
-
- it('matches with single quotes', () => {
- INJECT_ICON_LINK_RE.lastIndex = 0;
- expect("").toMatch(INJECT_ICON_LINK_RE);
- });
-
- it('does NOT match apple-touch-icon', () => {
- INJECT_ICON_LINK_RE.lastIndex = 0;
- expect('').not.toMatch(INJECT_ICON_LINK_RE);
- });
-
- it('does NOT match stylesheet', () => {
- INJECT_ICON_LINK_RE.lastIndex = 0;
- expect('').not.toMatch(INJECT_ICON_LINK_RE);
- });
-});
diff --git a/tests/ico.test.ts b/tests/ico.test.ts
index d21eea8..4487e04 100644
--- a/tests/ico.test.ts
+++ b/tests/ico.test.ts
@@ -1,50 +1,11 @@
import { describe, expect, it } from 'bun:test';
-import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
-import { generateIco, generateSizedPngs, packIco } from '#internals/ico.ts';
-import type { SizedPng } from '#internals/ico.ts';
-import { unwrap } from './_helpers.ts';
+import { packIco } from '#ico';
+import { generateSizedPngs, type SizedPng } from '#raster';
const FIXTURE = resolve(import.meta.dirname, 'fixtures/test.svg');
-describe('generateSizedPngs', () => {
- it('returns correct number of PNGs for given sizes', async () => {
- const pngs = await generateSizedPngs(FIXTURE, { sizes: [16, 32], optimize: false });
- expect(pngs).toHaveLength(2);
- expect(unwrap(pngs[0]).size).toBe(16);
- expect(unwrap(pngs[1]).size).toBe(32);
- });
-
- it('accepts a Buffer input', async () => {
- const buf = await readFile(FIXTURE);
- const pngs = await generateSizedPngs(buf, { sizes: [16], optimize: false });
- expect(pngs).toHaveLength(1);
- expect(unwrap(pngs[0]).buffer).toBeInstanceOf(Buffer);
- });
-
- it('produces valid PNG buffers (PNG magic bytes)', async () => {
- const pngs = await generateSizedPngs(FIXTURE, { sizes: [32], optimize: false });
- const magic = unwrap(pngs[0]).buffer.subarray(0, 4);
- expect(magic[0]).toBe(0x89);
- expect(magic[1]).toBe(0x50); // P
- expect(magic[2]).toBe(0x4e); // N
- expect(magic[3]).toBe(0x47); // G
- });
-
- it('respects optimize flag (produces different output)', async () => {
- const [unopt] = await generateSizedPngs(FIXTURE, { sizes: [48], optimize: false });
- const [opt] = await generateSizedPngs(FIXTURE, { sizes: [48], optimize: true });
- // Optimize changes compression settings, so buffers should differ
- expect(unwrap(opt).buffer.equals(unwrap(unopt).buffer)).toBe(false);
- });
-
- it('handles size 256', async () => {
- const pngs = await generateSizedPngs(FIXTURE, { sizes: [256], optimize: false });
- expect(unwrap(pngs[0]).size).toBe(256);
- });
-});
-
describe('packIco', () => {
it('produces valid ICO header (magic bytes)', async () => {
const pngs = await generateSizedPngs(FIXTURE, { sizes: [16], optimize: false });
@@ -93,12 +54,3 @@ describe('packIco', () => {
expect(ico.readUInt32LE(entryStart + 12)).toBe(6 + 16); // data offset
});
});
-
-describe('generateIco', () => {
- it('returns a valid ICO buffer from path', async () => {
- const ico = await generateIco(FIXTURE, { sizes: [16, 32], optimize: false });
- expect(ico.readUInt16LE(0)).toBe(0);
- expect(ico.readUInt16LE(2)).toBe(1);
- expect(ico.readUInt16LE(4)).toBe(2);
- });
-});
diff --git a/tests/inject-html.test.ts b/tests/inject-html.test.ts
new file mode 100644
index 0000000..ab4f4e0
--- /dev/null
+++ b/tests/inject-html.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'bun:test';
+
+import { INJECT_ICON_LINK_RE, injectTagsIntoHtml, renderTag } from '#injectHtml';
+
+describe('INJECT_ICON_LINK_RE', () => {
+ it('matches ', () => {
+ expect('').toMatch(INJECT_ICON_LINK_RE);
+ });
+
+ it('matches ', () => {
+ INJECT_ICON_LINK_RE.lastIndex = 0;
+ expect('').toMatch(INJECT_ICON_LINK_RE);
+ });
+
+ it('matches with single quotes', () => {
+ INJECT_ICON_LINK_RE.lastIndex = 0;
+ expect("").toMatch(INJECT_ICON_LINK_RE);
+ });
+
+ it('does NOT match apple-touch-icon', () => {
+ INJECT_ICON_LINK_RE.lastIndex = 0;
+ expect('').not.toMatch(INJECT_ICON_LINK_RE);
+ });
+
+ it('does NOT match stylesheet', () => {
+ INJECT_ICON_LINK_RE.lastIndex = 0;
+ expect('').not.toMatch(INJECT_ICON_LINK_RE);
+ });
+});
+
+describe('renderTag', () => {
+ it('renders attrs and escapes double quotes', () => {
+ const html = renderTag({ tag: 'link', attrs: { rel: 'icon', href: 'a"b' }, injectTo: 'head' });
+ expect(html).toBe('');
+ });
+
+ it('omits false/undefined/null attrs and renders boolean-true as bare', () => {
+ const html = renderTag({ tag: 'script', attrs: { defer: true, nomodule: false }, injectTo: 'head' });
+ expect(html).toBe('