# @macrulez/vue-image-kit — AI Reference Responsive, lazy-loaded Vue images: a `` component and `useImage()` composable with automatic `srcset`/`sizes`, AVIF/WebP `` sources, art-direction breakpoints, BlurHash/ThumbHash/LQIP placeholders, retry with backoff, save-data awareness, 11 CDN provider adapters (auto-detected from the URL), a self-hosted on-demand resize server, a build-time CLI + Vite plugin (batch generation, `?vik`/`?thumbhash` imports), and a Nuxt module. Version 1.1.5. This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs), and prose is kept to the minimum needed to use the API correctly. For human-readable narrative docs (why you'd reach for each piece, worked examples), see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/vue-image-kit/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/vue-image-kit/guide/overview - GitHub: https://github.com/macrulezru/vue-image-kit - npm: https://www.npmjs.com/package/@macrulez/vue-image-kit Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `@macrulez/vue-image-kit` | ``, `v-lazy-img`, all composables, srcset/blurhash/thumbhash utilities, `VImageKitPlugin` (sections 3–9). | | `@macrulez/vue-image-kit/cdn` | 11 CDN adapter factories + `autoLoader`/`autoSrcset` (section 7). | | `@macrulez/vue-image-kit/server` | `createImageHandler`, `buildImageUrl` — self-hosted on-demand resize (section 8). Needs `sharp` as a peer dependency to actually transform (falls back to passthrough for untransformable requests without it). | | `@macrulez/vue-image-kit/nuxt` | Nuxt module — registers the component/directive, auto-imports, optionally the on-demand server (section 12). | | `@macrulez/vue-image-kit/vite` | Vite plugin — batch generation, `?vik`/`?thumbhash` build-time imports, optional dev on-demand middleware (section 13). | | `@macrulez/vue-image-kit/vite/client` | Ambient types only — add a triple-slash reference for typed `?vik`/`?thumbhash` imports. | | `@macrulez/vue-image-kit/cli` (bin `vue-image-kit`) | `npx vue-image-kit generate [options]` — the same batch pipeline the Vite plugin runs, standalone (section 14). | **No app-level plugin install is required** to use ``/`useImage()` directly (import and use the component). `VImageKitPlugin` (`app.use(...)`) is only needed for global `breakpoints`/`serverRoute` defaults and to register `VImage`/`v-lazy-img` as GLOBAL (no-import) component/directive. --- ## 2. Core types ```ts type ImageStatus = 'idle' | 'loading' | 'loaded' | 'error' type ObjectFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' type Layout = 'fixed' | 'responsive' | 'fill' type Densities = number[] | Record // list = reuse one URL at each density; map = a distinct URL per density interface SrcSet { avif?: string; webp?: string; fallback: string } type ResponsiveSrc = Record // breakpoint name → URL or format set (art direction + format switching) type BreakpointMap = Record // breakpoint name → CSS media query interface FocalPoint { x: number; y: number } // fractions 0..1, maps to object-position with fit="cover" interface ImageMeta { // shape produced by the CLI manifest / a `?vik` import — pass as the `image` prop src: string; srcset?: string; webp?: string; avif?: string width?: number; height?: number; placeholder?: string; blurhash?: string; thumbhash?: string; sizes?: string } interface VImageKitOptions { breakpoints?: BreakpointMap; serverRoute?: string /* default '/_vik/image' */ } interface LazyImgOptions { src: string; placeholder?: string; rootMargin?: string; threshold?: number; transition?: string; onLoad?(): void; onError?(e: Event): void } ``` --- ## 3. `` — the main component ```ts interface Props { src?: string | SrcSet image?: ImageMeta // build-time metadata; explicit props above still override matching fields alt: string // REQUIRED — pass "" explicitly for a decorative image (see 10.1) width?: number; height?: number blurhash?: string; thumbhash?: string; placeholder?: string placeholderMode?: 'blur' | 'color' | 'shimmer' // default: blur (blurhash/LQIP/thumbhash-derived blur) placeholderColor?: string // explicit CSS color — wins over placeholderMode, no decode needed widths?: number[] // width (w) descriptor srcset — same URL, browser negotiates via `w` densities?: Densities // density (x) descriptor srcset — mutually exclusive with widths (densities wins if both given, see useImage) sizes?: string breakpoints?: BreakpointMap // merged over VImageKitPlugin's global breakpoints, local wins on key collision sources?: ResponsiveSrc // art-direction entries, resolved against breakpoints lazy?: boolean // default true rootMargin?: string // default '200px' threshold?: number // default 0 fit?: ObjectFit // default 'cover' focal?: FocalPoint maxRetries?: number; retryDelay?: number // default retryDelay 1000ms, exponential backoff — see 4 fetchpriority?: 'high' | 'low' | 'auto' decoding?: 'async' | 'sync' | 'auto' // default 'async' priority?: boolean // default false — shorthand: forces lazy=false, fetchpriority='high', decoding='sync'. NOT automatic LCP detection. respectSaveData?: boolean // default false — see 3.3 layout?: Layout // unset behaves like 'responsive' for sizing but WITHOUT the auto-sizes heuristic (see 3.2) cdn?: boolean | AutoLoaderConfig // opt-in: route a string src through autoLoader() — see section 7 loader?: 'server' // opt-in: route a string src through buildImageUrl() — see section 8. `cdn` wins if both are set. loaderRoute?: string // overrides serverRoute for loader="server" on this instance only } // Emits: load[e: Event], error[e: Event] // Slots: #error (replaces the default broken-image icon) ``` ### 3.1 `src` resolution priority (highest first) 1. Save-data downgrade (`respectSaveData` + an actual save-data connection) — see 3.3. 2. Explicit `src` prop. 3. `image` prop (`image.avif`/`image.webp`/`image.src` become a `SrcSet`, or plain `image.src` if neither format field is set). 4. Empty string (renders nothing meaningful) if none of the above. `width`/`height`/`blurhash`/`thumbhash`/`placeholder`/`sizes` each independently fall back from their own explicit prop to the matching `image.*` field (`props.width ?? props.image?.width`, etc.) — mixing an explicit `src` with an `image` prop for its OTHER metadata fields is a supported, intentional pattern, not just for when `src` itself is provided via `image`. ### 3.2 `layout` — sizing presets - `'fixed'`: exact `width`×`height` box (`inline-block`), no responsive scaling — requires both `width` and `height` to take effect. - `'responsive'`: fills the container width, `aspect-ratio` preserved from `width`/`height` if both are known — ALSO auto-generates a `sizes` value (`` `(min-width: ${width}px) ${width}px, 100vw` ``) when `sizes` isn't given explicitly. - `'fill'`: `position: absolute; inset: 0` — fills a positioned parent; `width`/`height` become optional (only used for `blurhash`'s canvas dimensions and `aspect-ratio`, not for sizing the wrapper). - **Unset** (no `layout` prop at all): same visual sizing as `'responsive'` (fills container, aspect-ratio preserved) but WITHOUT the auto-`sizes` heuristic — you must pass `sizes` yourself if you want anything other than the browser's implicit 100% default. ### 3.3 `respectSaveData` — exact behavior On a save-data connection (`useNetworkAware().saveData`, Chromium-only — see 5.4) with this prop set: 1. `priority` is neutralized (`effectivePriority` becomes `false` even if `priority: true` was passed) — forcing eager/high-priority loading is the wrong call once the user asked to save data. 2. `src` is downgraded to the smallest URL it can actually find one for: the LOWEST-density entry of a `densities` MAP (not a plain density list — a list reuses one URL at every density, so there's nothing smaller to pick), or the smallest-`w` URL parsed out of `image.srcset` (via `pickSmallestSrcsetUrl`, section 9). No effect otherwise (falls through to the normal `src` resolution). 3. Any `densities`/CDN/server/manifest `srcset` is dropped from the rendered `` entirely (`effectiveRawSrcset`/`densities` both become `undefined` in this state) — **a browser that sees a `srcset` picks from it over plain `src` unconditionally**, so leaving one in place would silently undo the downgrade this exists for. Plain `widths` (which reuses the SAME url per candidate, browser-negotiated via the `w` descriptor) is unaffected either way — there's nothing to downgrade there. ### 3.4 `cdn` / `loader="server"` — src rewriting Both apply ONLY to a plain-string `src` (never a `SrcSet` object — the consumer already chose specific URLs by hand in that case) and both combine with `widths` the same way: instead of `widths`' usual "same URL, `w`-descriptor-negotiated" srcset, each candidate width gets its own distinct, CDN/server-transformed URL. **`cdn` wins if both are set** — an external CDN is assumed to already solve the problem; the local on-demand server is treated as the fallback for when there isn't one. See sections 7 and 8 for what each actually does to the URL. ### 3.5 Placeholder rendering priority (what actually shows before load) `placeholderColor` (no decode) → `placeholderMode: 'color'` (derives an average RGBA straight from the ThumbHash header, no full decode) → `placeholderMode: 'shimmer'` (animated skeleton, respects `prefers-reduced-motion`) → `placeholderMode: 'blur'`/unset: an explicit `placeholder`/`image.placeholder` (LQIP data URL) wins over a `thumbhash`-derived blur decode, which wins over a `blurhash`-rendered ``. **Blurhash and thumbhash/LQIP placeholders can render simultaneously in the DOM** (the canvas and the `` placeholder are independent `v-if` branches, not mutually exclusive) — in practice the LQIP/thumbhash `` layers visually on top since it's declared after the canvas in the template, but both mount if both are present. Blurhash is always decoded at a fixed 32px-wide thumbnail internally (height computed to preserve aspect ratio) regardless of the real image's resolution — CSS `filter: blur(20px)` + `transform: scale(1.05)` does the visual upscale. ### 3.6 SSR Server-side, `` renders a plain, single `` with native `loading="lazy"|"eager"` — none of the placeholder machinery, IntersectionObserver, or CDN/server URL rewriting logic mounts (those are all inside the `v-else` client branch). The `isSSR` check is `typeof window === 'undefined'`, evaluated once at module/setup time. --- ## 4. `useImage(options)` — the underlying composable ```ts function useImage(options: { src: string | SrcSet widths?: number[]; densities?: Densities; sizes?: string rawSrcset?: string // pre-built srcset string, e.g. from a manifest — ignored if densities or widths is set lazy?: boolean // default true rootMargin?: string; threshold?: number // defaults '200px', 0 fit?: ObjectFit // default 'cover' maxRetries?: number; retryDelay?: number // defaults 0, 1000 }): { status: Ref; isLoaded: ComputedRef; isError: ComputedRef imgAttrs: ComputedRef<{ src: string; srcset?: string; sizes?: string; style: { objectFit } }> observe: (el: Ref) => void onImgLoad(): void; onImgError(): void // wire to the 's @load/@error } ``` - `densities` takes priority over `widths` if somehow both are set — they can't be mixed in one real `srcset` (density and width descriptors are mutually exclusive per the HTML spec). - **Retry works by unmounting and remounting the `` element, NOT by changing the URL.** `onImgError()` (below `maxRetries`) schedules `status` to flip `'error'`-adjacent → `'idle'` → `'loading'` after an exponential-backoff delay (`retryDelay * 2^(attempt-1)`); `VImage`'s template only renders the `` while `status` is `'loading'` or `'loaded'` (`shouldRenderImg`), so the brief `'idle'` tick actually removes the element from the DOM, and the next `'loading'` tick re-creates it with the SAME `src` — which the browser re-fetches since its earlier attempt errored (not served from a fulfilled cache entry). A consumer building their own retry UI around `useImage` directly needs to replicate this v-if-on-status pattern themselves; simply re-reading `imgAttrs.src` won't trigger a re-fetch on its own. - Non-lazy (`lazy: false`): starts loading `onMounted`, unconditionally. - Lazy: starts loading once `isIntersecting` flips true AND `status` is still `'idle'` (guards against re-triggering after a manual retry already moved past idle). --- ## 5. Other composables ### 5.1 `useLazyLoad(options?)` — shared IntersectionObserver pool ```ts function useLazyLoad(options?: { rootMargin?: string /* '200px' */; threshold?: number /* 0 */ }): { isIntersecting: Ref observe: (el: Ref) => void } ``` Elements observed with the IDENTICAL `rootMargin`+`threshold` share ONE native `IntersectionObserver` (keyed `` `${rootMargin}|${threshold}` ``, `src/utils/observer-pool.ts`) — critical for pages with many images. **Always one-shot**: an element is `unobserve`d immediately after its first intersection, unconditionally — there is no "keep observing" option anywhere in this pool. SSR (`typeof window === 'undefined'`): `isIntersecting` starts `true` and `observe()` is a no-op — nothing is actually lazy on the server, matching ``'s own SSR branch (3.6). `observe()` may be called before the target element is mounted — it defers to the next microtask via `Promise.resolve().then(...)` if the ref is still `null` at call time. ### 5.2 `useBlurhash({ blurhash, width, height })` ```ts function useBlurhash(options: { blurhash: string; width: number; height: number }): Ref ``` Decodes at a fixed 32px-wide thumbnail (`height` scaled to preserve the `width`/`height` aspect ratio) `onMounted`, drawn via `putImageData`. An invalid `blurhash` string is caught and leaves the canvas blank (no thrown error). SSR: returns the (unpopulated) ref immediately, no decode attempted. ### 5.3 `useBreakpoints(localBreakpoints?)` ```ts function useBreakpoints(localBreakpoints?: BreakpointMap): { merged: ComputedRef // global (VImageKitPlugin) + local, local wins on key collision resolveMediaSources: (sources: ResponsiveSrc | undefined) => Array<{ media: string; src: string; type?: string }> } ``` `resolveMediaSources` silently DROPS a `sources` entry whose key has no matching breakpoint in `merged` (no warning). Output `` order — required because `` picks the FIRST matching source top to bottom — is: every `max-width` query ascending (mobile-first: smallest breakpoint first), then every `min-width` query DESCENDING (desktop-first: largest breakpoint first), then any other media-query form in original object-key order. Within one breakpoint entry that's a format `SrcSet` (not a plain URL), the emitted order is always avif → webp → fallback. ### 5.4 `useNetworkAware()` / `isSaveDataEnabled()` ```ts function useNetworkAware(): { saveData: Ref; effectiveType: Ref } function isSaveDataEnabled(): boolean // synchronous, non-reactive, safe to call outside setup() ``` Both read the non-standard `navigator.connection` (Network Information API) — Chromium-based browsers only; Firefox/Safari/SSR always read as `false`/`undefined` (fails open — there's no way to know, so nothing is ever incorrectly restricted). `useNetworkAware` updates reactively on the connection's `'change'` event. ### 5.5 `useServerRoute(localOverride?)` ```ts function useServerRoute(localOverride?: string): string ``` Resolution order: `localOverride` (e.g. `VImage`'s `loaderRoute` prop) → `VImageKitPlugin`/Nuxt-module-injected `serverRoute` → the hardcoded default `'/_vik/image'`. ### 5.6 `useImagePreloader()` ```ts function useImagePreloader(): { loaded: Ref; total: ComputedRef; progress: ComputedRef // 0-100, rounded isComplete: ComputedRef; errors: Ref // URLs that failed preload: (urls: string[]) => Promise // resolves once every url has settled (loaded or errored) — never rejects } ``` **No-ops entirely on a save-data connection** (`isSaveDataEnabled()` checked first, before touching any ref) — `preload()` resolves immediately, but `loaded`/`total`/`progress`/`isComplete` are NOT updated to reflect "skipped": they stay at whatever they were before the call (0/0/0/false on first use). A caller relying on `isComplete` to gate a UI transition needs its own save-data check if it must still transition when preloading was skipped. ### 5.7 `useBackgroundImage(src, options?)` ```ts function useBackgroundImage(src: string, options?: { placeholder?: string; densities?: number[]; type?: string // densities → CSS image-set(), the srcset-equivalent for backgrounds lazy?: boolean /* true */; rootMargin?: string; threshold?: number transition?: string /* '0.4s ease' */; backgroundSize?: string /* 'cover' */; backgroundPosition?: string /* 'center' */ }): { target: Ref; style: ComputedRef status: Ref; isLoaded: ComputedRef; isLoading: ComputedRef load(): void // manual trigger, e.g. when lazy: false wasn't used and you want to defer } ``` Uses `useLazyLoad`'s SAME shared observer pool (5.1) when `lazy: true`. `src` itself (the base URL, before `image-set()` wrapping) is captured ONCE at call time — not reactive to a later-changing `src` argument; create a new call (e.g. keyed by a `v-for` item id) if the URL can change. --- ## 6. `v-lazy-img` directive — NOT the same observer pool ```ts const vLazyImg: Directive ``` ```vue
``` Sets `background-image` (with a blurred/scaled placeholder swap, mirroring `useBackgroundImage`'s visual treatment) once the element intersects. **Creates its OWN dedicated `IntersectionObserver` per element** — it does NOT go through `observer-pool.ts`'s shared pool that `useLazyLoad`/ `useImage`/`useBackgroundImage`/`` all use. A page mixing many `v-lazy-img` elements with many ``s gets pooled observers for the latter but one-per-element for the former. `updated` skips reconnecting the observer if the resolved `src` is unchanged (even if the bound options OBJECT is a new reference) — avoids reactivity-loop churn on an unrelated parent re-render. No `IntersectionObserver` support (or SSR) → applies the image immediately, synchronously, no observer created at all. --- ## 7. CDN adapters (`@macrulez/vue-image-kit/cdn`) ```ts interface CdnAdapter { url(path: string, options?: CdnUrlOptions): string srcset(path: string, widths: number[], options?: CdnUrlOptions): string } interface CdnUrlOptions { width?; height?; quality?; format?: 'auto'|'webp'|'avif'|'jpg'|'png'; fit?: 'cover'|'contain'|'fill'|'inside'|'outside'; dpr?: number } ``` | Factory | Signature | Notes | |---|---|---| | `cloudinary` | `(opts: { cloudName: string; resourceType?: string /* 'image/upload' */ }) => CdnAdapter` | `q_auto`/`f_auto` by default; `fit` maps to Cloudinary's `c_fill`/`c_fit`/`c_scale`. | | `imgix` | `(baseUrl: string) => CdnAdapter` | | | `bunny` | `(baseUrl: string) => CdnAdapter` | Bunny CDN's Optimizer. | | `imagekit` | `(baseUrl: string) => CdnAdapter` | | | `sanity` | `(opts: { projectId: string; dataset: string }) => CdnAdapter` | | | `storyblok` | `() => CdnAdapter` | `.url()` parses a full Storyblok asset URL itself, not just a path. | | `contentful` | `() => CdnAdapter` | Same — parses a full URL. | | `gumlet` | `(baseUrl: string) => CdnAdapter` | | | `netlify` | `(opts?: { origin?: string }) => CdnAdapter` | Runs on YOUR domain — never auto-detected, always needs `AutoLoaderConfig.hosts`. | | `vercel` | `(opts?: { origin?: string }) => CdnAdapter` | Same — your domain, needs `hosts`. | | `cloudflare` | `(baseUrl: string) => CdnAdapter` | Same — your domain, needs `hosts`. | | `twicpics` | `(baseUrl: string) => CdnAdapter` | Same — your domain, needs `hosts`. | ```ts function autoLoader(url: string, opts?: CdnUrlOptions, config?: AutoLoaderConfig): string // Detects a CDN from the URL's hostname and rewrites it. Returns the URL UNCHANGED (safe passthrough) // when nothing matches — always safe to run over every src unconditionally. function autoSrcset(url: string, widths: number[], opts?: CdnUrlOptions, config?: AutoLoaderConfig): string | undefined // Same detection; returns undefined (not the input) when nothing matches — there's no sensible "unchanged srcset". interface AutoLoaderConfig { hosts?: Record } // for the your-own-domain providers above ``` Auto-detectable by hostname alone (no `hosts` config needed): Cloudinary (`res.cloudinary.com`), imgix (`*.imgix.net`), Bunny (`*.b-cdn.net`), ImageKit (`ik.imagekit.io`), Sanity (`cdn.sanity.io`), Storyblok (`a.storyblok.com`), Contentful (`images.ctfassets.net`), Gumlet (`*.gumlet.io`). **For Cloudinary/imgix/Bunny/ImageKit/Sanity/Gumlet specifically, a URL that already has a query string is treated as UNRECOGNIZED (bails out, returns unchanged)** — these adapters rebuild the URL from the pathname alone and have no way to merge an existing query (a signed/token'd URL, an existing transform, a cache-buster) with their own params, so they deliberately leave such a URL untouched rather than silently dropping the existing query. Storyblok/Contentful are exempt (they parse the whole URL themselves). --- ## 8. Self-hosted on-demand server (`@macrulez/vue-image-kit/server`) ```ts function createImageHandler(options: { root: string // REQUIRED — source images resolved AND CONFINED to this directory cacheDir?: string // default `/.vik-cache` maxAge?: number // default 31536000 (1 year) — Cache-Control max-age allowedWidths?: number[] // restrict `w` to exactly these; any other value → 400 maxWidth?: number // default 4000 — clamp ceiling when allowedWidths isn't set }): (req: IncomingMessage, res: ServerResponse) => Promise function buildImageUrl(src: string, opts?: { width?; format?: 'jpg'|'webp'|'avif'|'png'; quality? }, base?: string /* '/_vik/image' */): string ``` `GET {route}?src=&w=&format=&q=`. Requires the `sharp` peer dependency ONLY for an actual transform — a request with no `w`/ `format` at all, or targeting a `.gif`/`.svg` source, streams the original bytes through untouched (no `sharp` import attempted, works without it installed). Path-traversal-hardened: rejects a resolved path outside `root` BOTH lexically (before touching the filesystem) and via `realpath()` (defense against a symlink inside `root` pointing outside it). Cache key includes the source file's `mtime`+`size`, so replacing a file at the same path invalidates old cached transforms automatically. Cache writes are atomic (temp file + rename) — concurrent requests for the same not-yet-cached transform can't observe a half-written file. ETag + `If-None-Match` → 304 support built in; NOT marked `immutable` (the URL doesn't encode a content version) so a client re-validates after `maxAge` instead of caching forever. Wire it into: plain Node `http`, Express (`app.get(route, handler)`), Nitro/Nuxt (section 12 registers this automatically when `onDemandServer` is configured), or Vite dev middleware (section 13's `dev.onDemand`). --- ## 9. Srcset & preload utilities (root package) ```ts generateSrcset(src: string, widths: number[]): string // ' 400w, 800w, ...' — SAME url every candidate (server/CDN negotiates via `w`) generateSizes(sizes?: string): string // sizes ?? '100vw' generateDensitySrcset(src: string | Record, densities: number[]): string // ' 1x, 2x, ...' buildSizes(sizes: Record, breakpoints?: Record): string // { sm: '100vw', default: '33vw' } + { sm: '(max-width: 640px)' } → '(max-width: 640px) 100vw, 33vw' — 'default' key is the trailing no-condition fallback generatePreloadLink(href: string, options?: { srcset?; sizes?; type? }): string // '' string, for SSR injection pickSmallestSrcsetUrl(srcset: string): string | undefined // Scans a REAL srcset string for the smallest `w`-descriptor candidate. Returns undefined for an // unparseable string or a density-only (`1x`/`2x`) srcset (no `w` descriptors to compare). // Correctly ignores commas embedded in a CDN transform URL with no surrounding whitespace // (e.g. Cloudinary's "w_400,q_auto,f_auto" segment) — does NOT naively split on ','. ``` --- ## 10. Placeholder & accessibility utilities ### 10.1 `checkAltText(alt)` / `isDevMode()` ```ts function checkAltText(alt: unknown): string | null // human-readable issue, or null if nothing looks wrong function isDevMode(): boolean ``` Deliberately conservative — a real `alt=""` (decorative image, the documented way to mark one) NEVER triggers a warning; only `undefined`/ `null` (missing entirely), whitespace-only, or a value that looks like a bare filename (matches a common image extension) do. `` logs this via `console.warn` in dev only. `isDevMode()` checks `process.env.NODE_ENV` (NOT `import.meta.env.DEV`) deliberately — this file ships as part of the already-built library, so `import.meta.env.DEV` would have been statically inlined to `false` at THIS package's own build time and stayed that way forever; `process.env.NODE_ENV` survives unminified into the shipped dist and gets replaced correctly at the CONSUMING app's own build time instead (same reason Vue core itself checks it this way). Returns `false` (no warnings) if `process` doesn't exist at all — a bare, bundler-less browser `