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 = '\r\n\t\r\n'; + 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('