vue-image-kit 1.1.0: от art direction до собственного image-сервера

20.08.2026
vue-image-kit 1.1.0: от art direction до собственного image-сервера

vue-image-kit начинался как один компонент <VImage> с ленивой загрузкой и плейсхолдерами. В релизе 1.1.0 пакет прошёл путь от «набора удобных пропов» до «стека с собственной серверной частью». Список того, что попало в этот релиз, получился длиннее, чем казалось на старте:

  • 6 новых пропов <VImage>: image, priority, layout, respectSaveData, cdn, loader="server"
  • поддержка SVG и анимированного GIF в CLI/Vite-пайплайне
  • art direction и переключение форматов (AVIF/WebP) на одном брейкпоинте одновременно
  • новый composable useNetworkAware() и уважение к Save-Data
  • авто-детект 8 CDN-провайдеров по URL + прямая интеграция с <VImage>
  • собственный self-hosted image-сервер на замену CDN — обработчик, Vite dev-middleware, Nuxt Nitro-роут
  • инкрементальная генерация в CLI (пропуск неизменившихся исходников по mtime/хешу)
  • бюджет размера бандла, зашитый в CI
  • сводка экономии в CLI-отчёте, dev-предупреждение об alt, e2e-покрытие новых пропов
  • отдельное Nuxt demo-приложение для живой проверки всего перечисленного

Ниже — подробный разбор по темам.


image — проп для прямого потребления вывода CLI

Раньше манифест CLI или ?vik-импорт нужно было руками расписывать по пропам <VImage>: src, srcset, width, height, blurhash… Теперь весь объект можно скормить компоненту одним пропом:

vue Copy
<script setup lang="ts">
import meta from './hero.jpg?vik'
</script>

<template>
  <VImage :image="meta" alt="Hero" />
</template>

Поля image заполняют src/width/height/blurhash/thumbhash/placeholder/sizes, но только там, где соответствующий явный проп не задан — явный проп всегда побеждает. webp/avif из манифеста автоматически превращаются в <picture>. Заодно useImage() получил опцию rawSrcset, чтобы уже готовая строка srcset из манифеста использовалась как есть, а не пересчитывалась заново из widths.

priority — честный шорткат для LCP-изображения

Аналог priority из Next.js: один булев проп вместо трёх отдельных.

vue Copy
<VImage src="/hero.jpg" alt="Hero" priority />
<!-- эквивалент :lazy="false" fetchpriority="high" decoding="sync" -->

Важный момент по дизайну: это не автоматическое определение LCP-изображения — надёжно определить его на клиенте до первой отрисовки в лёгкой библиотеке нереально. Поэтому priority — явный маркер, который проставляет разработчик, а не магия. В README это прямо написано, а не обещано больше, чем есть на самом деле.

SVG и анимированный GIF в CLI/Vite-пайплайне

Раньше эти два формата в --formats тихо игнорировались. Теперь:

  • SVG копируется как есть (он и так resolution-independent), sharp используется только чтобы вытащить width/height для манифеста.
  • GIF копируется как fallback-вариант, и если webp есть в --formats — дополнительно перекодируется в анимированный WebP (sharp(path, { animated: true }).webp({ quality, loop: 0 })). AVIF для анимации сознательно пропущен — поддержка анимации в libavif пока слишком нестабильна, чтобы на неё полагаться. LQIP/BlurHash/ThumbHash по-прежнему считаются с первого кадра.

Заодно устранён технический долг: SUPPORTED_EXTS был продублирован между CLI и Vite-плагином — теперь экспортируется один раз из processor.ts.


Network-aware loading — уважение к Save-Data

Новый useNetworkAware() читает navigator.connection (Chromium-only API), реактивно следит за событием change, безопасен на SSR (saveData=false на сервере):

ts Copy
const { saveData, effectiveType } = useNetworkAware()

useImagePreloader().preload() теперь ничего не делает, если у пользователя включён Save-Data — предзагрузка становится no-op вместо того, чтобы жечь трафик человеку, который явно попросил браузер экономить.

На уровне компонента — новый опциональный проп respectSaveData:

vue Copy
<VImage
  src="/hero.jpg"
  alt="Hero"
  priority
  respect-save-data
/>

Когда он включён и обнаружен Save-Data: priority полностью нейтрализуется (как будто его не было), а src понижается до наименьшего доступного варианта — либо из карты densities, либо из srcset в image. Обычный widths тут намеренно не трогается: generateSrcset переиспользует один и тот же URL для каждого w-дескриптора, так что уменьшать там физически нечего — это задокументировано, а не тихо не работает.

Layout-пресеты + авто-sizes

Новый тип Layout = 'fixed' | 'responsive' | 'fill' и проп layout (по умолчанию undefined — старое поведение не меняется ни для одного существующего использования):

vue Copy
<!-- Точный бокс, без aspect-ratio -->
<VImage src="/icon.png" alt="" :width="64" :height="64" layout="fixed" />

<!-- Заполняет контейнер, sizes генерируется автоматически из width -->
<VImage src="/card.jpg" alt="Card" :width="400" :height="300" layout="responsive" />

<!-- position: absolute; inset: 0 — заполняет спозиционированного родителя -->
<div style="position: relative; aspect-ratio: 16/9;">
  <VImage src="/hero.jpg" alt="Hero" layout="fill" />
</div>

Для responsive авто-sizes генерируется как (min-width: {width}px) {width}px, 100vw и включается только тогда, когда ни явный проп sizes, ни image.sizes не заданы — явное всегда побеждает автоматическое.


Art direction и переключение форматов вместе

До 1.1.0 на одном брейкпоинте можно было задать либо другой кроп, либо другой формат — но не оба сразу. Тип ResponsiveSrc расширен до Record<string, string | SrcSet>, так что значение брейкпоинта может быть и строкой (как раньше), и объектом { avif?, webp?, fallback }:

vue Copy
<VImage
  :src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
  :sources="{
    sm: { avif: '/hero-mobile.avif', webp: '/hero-mobile.webp', fallback: '/hero-mobile.jpg' },
    md: '/hero-tablet.jpg',
  }"
  alt="Hero"
/>

Один брейкпоинт с SrcSet-объектом теперь разворачивается в несколько <source> под одним и тем же media — AVIF, потом WebP, потом fallback. Сортировка по-прежнему стабильна (Array.prototype.sort гарантированно stable по спеке), так что порядок внутри группы не ломается.

CDN: автоопределение провайдера и прямая интеграция с VImage

Новая функция autoLoader() из vue-image-kit/cdn смотрит на hostname и сама выбирает нужный из 12 адаптеров — не нужно руками указывать, какой это CDN:

ts Copy
import { autoLoader } from 'vue-image-kit/cdn'

autoLoader('https://res.cloudinary.com/demo/image/upload/photo.jpg', { width: 800 })
// → автоматически распознан Cloudinary, URL перестроен с w_800

8 провайдеров распознаются прямо по hostname-паттерну (Cloudinary, imgix, Bunny, ImageKit, Sanity, Storyblok, Contentful, Gumlet). Ещё 4 — Netlify, Vercel, Cloudflare, TwicPics — «свой домен», по URL их не отличить, поэтому они подключаются через config.hosts. На нераспознанном хосте autoLoader просто возвращает URL без изменений — никогда не бросает исключение.

То же самое подключено и прямо к компоненту — новый проп cdn:

vue Copy
<VImage
  src="https://res.cloudinary.com/demo/image/upload/photo.jpg"
  alt="Photo"
  :widths="[400, 800, 1200]"
  cdn
/>

cdn принимает true (детект только по hostname) или объект с hosts (для «своего домена»). Новый autoSrcset() строит настоящий per-width srcset через .srcset() распознанного адаптера — так что каждый width-кандидат получает реально другой CDN-URL, а не один и тот же URL с разными w-дескрипторами, как это происходит с обычным widths. Цена — импорт всех 8 адаптеров теперь всегда попадает в основной бандл: ESM-бандл вырос на ~2.2 кБ гзип (10.8 → 13.0 кБ). Это поймал check:size — реальная цифра оказалась больше первоначальной оценки, бюджет подняли до 15/13 кБ осознанно, ради удобства использования.


Собственный image-сервер вместо CDN

Главное архитектурное дополнение релиза: пакет теперь умеет ресайзить изображения по запросу самостоятельно, без стороннего CDN.

ts Copy
// server.ts — plain Node
import { createServer } from 'node:http'
import { createImageHandler } from 'vue-image-kit/server'

const handleImage = createImageHandler({ root: './public' })

createServer((req, res) => {
  if (req.url?.startsWith('/_vik/image')) return handleImage(req, res)
  // ...остальная маршрутизация
}).listen(3000)

Как это устроено:

  • src=/w=/format=/q= парсятся из query-строки запроса.
  • src резолвится строго внутри root с защитой от path traversal — путь проверяется на то, что он реально остался внутри root, ещё до любого обращения к файловой системе.
  • Чистый passthrough (без w/format) стримит оригинальные байты без вызова sharp и без записи в кэш — работает для любого типа файла.
  • Иначе результат кэшируется на диске (по умолчанию <root>/.vik-cache) по sha256-хешу от (путь, ширина, формат, качество); повторные запросы отдаются прямо из кэша.
  • w клэмпится по maxWidth (по умолчанию 4000), либо, если задан allowedWidths, запрос с несовпадающей шириной отклоняется с 400.
  • 400 на некорректные параметры, 404 если файла нет, 403 при попытке traversal.

Тот же обработчик подключается прямо в Vite dev-сервер как middleware — картинки ресайзятся на лету во время vite dev, без отдельного шага сборки:

ts Copy
vueImageKit({
  dev: { onDemand: true }, // мостится на /_vik/image, работает только в dev
})

Живая проверка, не только моки: реальный vite dev был поднят и опрошен через curl — проверены 400/403/404, byte-identical passthrough, и настоящий ресайз (ширина выходного файла подтверждена через sharp().metadata() на файле, реально лежащем в кэше на диске). Именно так была поймана реальная ошибка: mimeFor() не отрезал ведущую точку от extname() перед поиском MIME-типа, из-за чего passthrough-ветка всегда отдавала application/octet-stream вместо настоящего Content-Type.

Компонентный аналог cdn, но для собственного сервера — новый проп loader="server":

vue Copy
<VImage src="/photo.jpg" alt="Photo" :widths="[400, 800]" loader="server" />

serverSrc/serverSrcset зеркалят cdnSrc/cdnSrcset — только строковый src, widths апгрейдится до настоящих per-candidate URL через buildImageUrl(). Импортируется отдельно из ../server/url.js, а не из общего ../server/index.js — последний реэкспортирует и handler.ts, который тянет node:http/node:fs/node:crypto. url.ts сам по себе не имеет Node-зависимостей и безопасен для браузерного бандла — проверено grep-ом по собранному dist/vue-image-kit.js на отсутствие этих модулей.

На стороне Nuxt — новая опция onDemandServer, регистрирующая реальный Nitro-роут через addServerHandler:

ts Copy
export default defineNuxtConfig({
  modules: ['vue-image-kit/nuxt'],
  vueImageKit: {
    onDemandServer: { root: 'public' },
  },
})

Ещё один реальный баг, пойманный по пути: сборка Nuxt-модуля (vite.nuxt.config.ts) не помечала sharp и node:* как внешние зависимости. Как только в модуль добавился Node-only импорт, sharp со всем своим detect-libc целиком утянулся в бандл — 197 кБ лишнего веса в том, что должно быть тонкой обёрткой вокруг addServerHandler. Поймано не тестами, а внимательным чтением вывода сборки; исправлено добавлением sharp и /^node:/ во external-список, как уже было сделано в двух других Node-таргетных конфигах пакета.


Бюджет размера бандла в CI

README до этого хранил захардкоженные цифры размера бандла, которые быстро устарели. Теперь есть единственный источник правды — таблица, и скрипт scripts/check-bundle-size.cjs, который гзипует реальный dist/* и падает, если он превышает бюджет (сейчас 15/13/4 кБ для ESM/CJS/cdn). Подключен в CI сразу после npm run build как npm run check:size.


Сводка в CLI-отчёте

printBatchSummary() теперь печатает не только список файлов, но и итог: суммарный размер входа/выхода и «самый лёгкий доступный формат экономит ~N% относительно оригинала, в среднем». Специально не «весь output против всего input» — при нескольких ширинах и форматах на одно изображение output естественно в разы больше input, и это выглядело бы так, будто обработка сделала файлы хуже. Вместо этого каждое изображение сравнивается само с собой: самый лёгкий сгенерированный вариант против оригинала — именно то число, которое отвечает на реальный вопрос «насколько это можно ужать».


Dev-предупреждение об alt

Новый checkAltText() в dev-режиме предупреждает в консоли, если alt выглядит как ошибка — пустой, из одних пробелов, или похож на имя файла ("photo.jpg"). Осознанный alt="" (стандартный способ пометить декоративное изображение) предупреждение не триггерит.

Технический нюанс: проверка использует process.env.NODE_ENV !== 'production', а не import.meta.env.DEV. Причина — import.meta.env.DEV статически подставляется на этапе сборки самого пакета и навсегда застывает как false в опубликованном dist. process.env.NODE_ENV, наоборот, доживает до бандла приложения, которое пакет подключает, и корректно подменяется уже его бандлером. Vue core по той же причине делает так же.


E2E-покрытие новых фич

Появилась новая вкладка demo-приложения «Layout & priority» и два новых Playwright-спека: layout-fill.spec.ts (bounding box fill реально совпадает с родителем — то, что в принципе нельзя проверить в jsdom, чей layout-движок — no-op) и priority.spec.ts (настоящие атрибуты fetchpriority/decoding в браузере). respectSaveData и cdn пока без e2e — честно оставлено как todo, а не тихо пропущено.


Инкрементальная генерация в CLI

Раньше --watch и vite dev пересчитывали все исходники при каждом изменении хотя бы одного файла. Теперь состояние сохраняется в <output>/.vik-incremental.json: неизменившийся по mtime файл пропускается сразу, а если mtime разошёлся (например, после git checkout) — на помощь приходит content-hash. Изменение конфигурации (widths, formats, quality и т.д.) инвалидирует всё разом.

bash Copy
npx vue-image-kit generate --incremental

Автоматически включается под --watch и в vite dev (но не в vite build — прод-артефакт не должен рисковать устаревшим кэшем), если не задано явно ни флагом, ни конфигом.

Живая проверка на собранном CLI, не только юнит-тесты: чистый запуск обрабатывает всё; повторный запуск без изменений — пропускает оба изображения и репортит это; правка одного исходника — пересчитывается только он; смена --widths — «Config changed since last run» и пересчёт всего; реальный --watch-запуск без явного флага записал .vik-incremental.json, подтвердив, что авто-включение действительно срабатывает.


Nuxt demo-приложение

По словам автора задачи, «самый вкусный» пункт. Новая папка demo-nuxt/, отдельный package.json с nuxt как настоящей dev-зависимостью (не заглушкой), модуль зарегистрирован по относительному пути, потому что modules в nuxt.config.ts резолвится Nuxt/jiti ещё до того, как Vite (и его алиасы) вообще существуют.

Страница демо покрывает: компонент без единого явного импорта, art direction через breakpoints из конфига модуля, три авто-импортированных API с выводом их результата прямо на странице (чтобы curl серверного HTML доказывал, что addImports реально их подключил, а не просто типы совпали), loader="server" с кнопкой, дёргающей on-demand роут напрямую, и v-lazy-img.

Проверено вживую и в dev, и в проде: nuxt dev + curl по SSR HTML — все auto-import секции с реальными данными на месте; прямой запрос /_vik/image?src=...&w=300&format=webp — реальные 200, image/webp и (проверено через sharp по факту байт) настоящие 300×200 на выходе; .vik-cache/ появился под настроенным root. Затем nuxt build.output/, запуск собранного прод-сервера и повтор тех же проверок против него — потому что «работает в dev» не значит «работает в проде», и это два разных факта, которые нужно проверять отдельно.

Demo сразу настроен на полноценный DX, а не только «запускается»:

  • Полная типизация в IDE. demo-nuxt/tsconfig.json расширяет автосгенерированный .nuxt/tsconfig.json, так что и редактор, и tsc сразу видят типы всех auto-import composables и утилит — generateSrcset, useImagePreloader, buildSizes и остальные подсвечиваются с автодополнением, а не как неизвестные имена. Для vue-image-kit в исходниках модуля используется нативный Nuxt alias — единственный вариант, который пробрасывается сразу и в Vite, и в генерируемый tsconfig.json.
  • Тихий консольный вывод. При старте nuxt dev ничего не сыпется в консоль — прогрев Vite-клиента настроен так, чтобы не гоняться наперегонки с регистрацией внутренних Nuxt-алиасов, через хук vite:extendConfig.

Итог

  • 6 новых пропов <VImage>, новая серверная часть (vue-image-kit/server), Nuxt-модуль с реальным Nitro-роутом, авто-детект 8 CDN, инкрементальный CLI — и всё это с живой, а не только мокированной проверкой на каждом шаге.
  • 406 unit/component-тестов, 7/7 e2e, чистые lint/typecheck/build/check:size.
  • Бандл: vue-image-kit ESM 13.4 кБ gzip, CJS 11.6 кБ, vue-image-kit/cdn 2.4 кБ.

NPM: https://www.npmjs.com/package/@macrulez/vue-image-kit
GitHub: https://github.com/macrulezru/vue-image-kit

Читать далее

@macrulez/vue-form-schema 0.2.2: типы из Zod, Nuxt-модуль и дискриминированные поля

21.08.2026

Новая версия @macrulez/vue-form-schema приносит сразу семь крупных возможностей: автовывод TypeScript-типов из Zod/Yup/Valibot, официальный Nuxt-модуль, три новые UI-темы, поддержку OpenAPI, маппинг серверных ошибок, плагин для Vue DevTools и дискриминированные схемы.

Метки
vuevue-form-schemanuxttypescriptopensource

vue-storage-kit 0.2.0: React-поддержка и пять новых возможностей

23.08.2026

vue-storage-kit больше не завязан на Vue — под капотом появилось общее реактивное ядро, на котором теперь работает и React-хук. А вместе с этим — HMAC-подпись данных, undo/redo с историей значений, троттлинг записи и автоматическое восстановление после переполнения квоты хранилища.

Метки
vuereacttypescriptfrontendopensource

rest-pipeline-js 2.1.0: живое демо вместо документации и вычищенные гонки

23.08.2026

Новый релиз добавляет восемь новых возможностей — от умного джиттера в retry до WebSocket-стадий и офлайн-очереди — и показывает их не в README, а на живом CI/CD-дашборде и торговом терминале. Заодно закрыта дюжина гонок в конкурентном коде и наведён порядок в структуре src/. Обратной несовместимости нет.

Метки
javascripttypescriptopensourcenpmwebdev