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

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

Когда я запускал @macrulez/vue-form-schema, идея была простой: форма — это массив объектов-схем, а не набор ref/computed на каждое поле, размазанный по компоненту. С тех пор пакет пожил в паре реальных проектов, и стало видно, каких кусков ему не хватает: типы приходилось дублировать вручную, под Nuxt не было официального модуля, тем оформления было немного, ошибки с бэкенда разбирались в каждом проекте заново. Новая версия закрывает всё это разом.

Что нового

  • Автовывод TypeScript-типов прямо из Zod/Yup/Valibot схемы — без useForm<Values>(...)
  • Официальный Nuxt-модуль с авто-импортами
  • Три новые UI-темы: shadcn-vue, PrimeVue, Naive UI
  • Поддержка стандартного OpenAPI / JSON Schema — не только собственного упрощённого формата
  • Маппинг серверных ошибок валидации (Laravel, DRF, произвольный формат) прямо в errors
  • Плагин для Vue DevTools — инспектор форм и таймлайн событий
  • Дискриминированные схемы — переключение целого набора полей по значению одного селектора

Ядро при этом осталось компактным — index.js весит 4.5 KB gzip, несмотря на семь новых точек входа вокруг него. Ниже — подробно про каждую фичу, и в конце — пара доработок, которые не попали в этот список, но без них релиз был бы неполным.


Автовывод TypeScript-типов из схемы

Раньше parseZod/parseYup/parseValibot возвращали обычный FieldDefinition[], никак не связанный с типами исходной схемы. Значения формы типизировались вручную:

ts Copy
// как было — тип дублируется руками
type FormValues = { username: string; age: number }

const fields = parseZod(schema)
const { values } = useForm<FormValues>({ schema: fields })

Схема в Zod уже содержит всю нужную информацию о типах — useForm<FormValues>(...) был чистым дублированием, которое к тому же ничем не защищено от рассинхронизации: поменяешь поле в Zod-схеме, забудешь поправить FormValues — TypeScript не заметит.

Теперь parseZod/parseYup/parseValibot возвращают FieldDefinition[], несущий на себе выведенный тип значений как brand-тип, и useForm подхватывает его сам:

ts Copy
import { z } from 'zod'
import { parseZod } from '@macrulez/vue-form-schema/zod'
import { useForm } from '@macrulez/vue-form-schema'

const schema = z.object({ username: z.string(), age: z.number() })
const fields = parseZod(schema)

const { values } = useForm({
  schema: fields,
  onSubmit: (data) => {
    data.username // string ✓ — выведено из schema, а не Record<string, unknown>
  },
})

Одна и та же строчка z.object({...}) теперь и источник валидации, и источник типов — рассинхронизации в принципе быть не может, потому что дублировать нечего. Работает так же для Yup (через InferType) и Valibot (через InferOutput). Явный useForm<Values>({ schema: fields }) по-прежнему поддерживается и переопределяет вывод, если он вдруг нужен — например, при более узком типе, чем выводит схема.

Для схем, написанных руками без Zod/Yup/Valibot — например, если она приходит с бэкенда как JSON и статически неизвестна на 100% — есть отдельный путь: defineSchema оборачивает литерал схемы, а InferValues<T> вытаскивает из него тип значений:

ts Copy
import { defineSchema } from '@macrulez/vue-form-schema'
import type { InferValues } from '@macrulez/vue-form-schema'

const schema = defineSchema([
  { type: 'text' as const, name: 'username' as const },
  { type: 'number' as const, name: 'age' as const },
  { type: 'checkbox' as const, name: 'agreed' as const },
] as const)

type Values = InferValues<typeof schema>
// { username: string; age: number; agreed: boolean }

const { values } = useForm<Values>({ schema })
// values.value.username — string ✓

Правило вывода простое: checkboxboolean, numbernumber, arrayunknown[], всё остальное — string. as const на массиве схемы обязателен — без него TypeScript схлопывает литеральные type/name до широких string, и вывести из них конкретные поля уже нельзя.

Технический нюанс, который стоил лишнего часа отладки: для трёх адаптеров понадобились не одна общая перегрузка, а три отдельных — сигнатуры z.infer, yup.InferType и v.InferOutput несовместимы структурно на уровне дженериков, и попытка написать один универсальный overload на всех троих упиралась в ошибки вывода типов там, где TypeScript просто не мог сопоставить конструкции разных библиотек друг с другом.


Nuxt-модуль

Раньше подключение к Nuxt-проекту ничем не отличалось от обычного Vue: import { useForm } from '@macrulez/vue-form-schema' в каждом файле, как в любом другом проекте. Работало, но не по духу Nuxt, где авто-импорты — это база.

@macrulez/nuxt-vue-form-schema — полноценный Nuxt-модуль, собранный через @nuxt/module-builder, с собственным playground-приложением для локальной разработки самого модуля:

bash Copy
npm install @macrulez/nuxt-vue-form-schema
ts Copy
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@macrulez/nuxt-vue-form-schema'],
})

После этого useForm, useFieldArray, все встроенные валидаторы (required, minLength, email, sameAs и остальные) и схемные адаптеры (parseZod, parseYup, parseValibot) доступны в любом компоненте без единого import. Схема всё та же, FormRenderer — тот же, просто на одну строчку boilerplate меньше в каждом файле, где используется форма — а в реальном проекте таких файлов обычно не один и не два.

Само ядро пакета изначально писалось SSR-safe — никаких прямых обращений к window/document вне явных проверок — так что для Nuxt-модуля не пришлось городить отдельный клиентский рантайм: composables работают одинаково что на сервере при рендеринге, что в браузере.


Три новые UI-темы: shadcn-vue, PrimeVue, Naive UI

FormRenderer изначально был один — BEM-классы (vfs-input, vfs-field__label), свой CSS, никакой привязки к конкретной дизайн-системе. Позже появилась Tailwind-тема. Теперь их четыре, и подключаются они одинаково — просто другой импорт при той же схеме, том же useForm, тех же валидаторах:

ts Copy
import { ShadcnFormRenderer } from '@macrulez/vue-form-schema/ui/shadcn'
import { PrimeVueFormRenderer } from '@macrulez/vue-form-schema/ui/primevue'
import { NaiveFormRenderer } from '@macrulez/vue-form-schema/ui/naive'
vue Copy
<ShadcnFormRenderer :form="form" submit-label="Сохранить" />

Это стало возможным без переписывания ядра, потому что FormRenderer с самого начала не знал о конкретных полях — он получает ComponentMap (какой компонент рисовать для какого type) и просто перебирает видимые поля схемы. Новая тема — это набор компонентов с тем же контрактом пропсов (field, modelValue, error, touched) и двумя emit (update:modelValue, blur), подставленный в тот же ComponentMap.

Три темы получились устроены принципиально по-разному, и это стоит проговорить отдельно, иначе легко ошибиться с ожиданиями:

Тема Peer-зависимости Что это на самом деле
ui/primevue primevue ^4 | ^5 Настоящие обёртки вокруг родных компонентов PrimeVue — InputText, Select, RadioButton, DatePicker, Message
ui/naive naive-ui ^2.38 Настоящие обёртки вокруг родных компонентов Naive UI — NInput, NSelect, NFormItem, NDatePicker
ui/shadcn Tailwind CSS Tailwind-разметка со словарём классов shadcn/ui (border-input, bg-primary, text-destructive)

ui/primevue и ui/naive требуют установленную и настроенную библиотеку — для PrimeVue ещё и зарегистрированный плагин (app.use(PrimeVue, { theme: { preset: Aura } })). ui/shadcn устроена иначе: это не обёртка вокруг импортируемых компонентов, потому что импортировать в shadcn-vue попросту нечего — библиотека распространяется как copy-paste исходники через CLI (npx shadcn-vue init), а не как npm-пакет готовых компонентов. Тема подключается как Tailwind-разметка, которая корректно выглядит в проекте, где уже настроены токены темы shadcn-vue — то есть зависимость здесь не от библиотеки, а от конфигурации Tailwind.

Поле файла (type: 'file') в PrimeVue- и Naive-темах реализовано отдельной минималистичной drag-and-drop зоной, а не через штатные <FileUpload>/<NUpload> — те компоненты изначально заточены под немедленную загрузку на сервер по мере выбора файла, а здесь нужно было просто собрать File-объекты и передать их наружу вместе с остальными данными формы при сабмите.


OpenAPI и стандартный JSON Schema

До сих пор в пакете был только parseJSON — собственный, намеренно упрощённый формат схемы. Удобно, если схему пишешь руками специально под форму, но бесполезно, если бэкенд уже отдаёт настоящий JSON Schema через OpenAPI/Swagger (а любой NestJS, FastAPI или Laravel с l5-swagger отдаёт). Приходилось либо дублировать описание полей вручную, либо городить свою прослойку-трансляцию.

Теперь есть прямой путь через целый OpenAPI-документ:

ts Copy
import { parseOpenAPI } from '@macrulez/vue-form-schema/openapi'

// openapiDocument — полный OpenAPI-документ, например с /openapi.json
const fields = parseOpenAPI(openapiDocument, { path: '/users', method: 'post' })
// или сразу по JSON pointer в components.schemas, если путь не важен:
const fields2 = parseOpenAPI(openapiDocument, '#/components/schemas/User')

const { values } = useForm({ schema: fields })

Или на голой JSON Schema, без обёртки OpenAPI вообще:

ts Copy
import { parseJSONSchema } from '@macrulez/vue-form-schema/openapi'

const fields = parseJSONSchema({
  type: 'object',
  properties: {
    name: { type: 'string', minLength: 2 },
    age: { type: 'integer', minimum: 0 },
    role: { type: 'string', enum: ['admin', 'user'] },
  },
  required: ['name', 'role'],
} as const)

const { values } = useForm({ schema: fields })
// values.value.role типизирован как 'admin' | 'user' — выведено из схемы с `as const`

Поддержан практичный подкапот спецификации: type (включая массив вроде ['string', 'null']), properties + required, items для массивов (при этом объектные элементы массива получают свои properties как безпрефиксные fields строки — по тем же соглашениям об именовании, что и обычные array-поля), enum/constselect, format (email, date/date-time, uri/url), ограничения длины и диапазона, локальные $ref.

Осознанно не поддержаны oneOf/anyOf/allOf, additionalProperties, patternProperties, внешние $ref, tuple-форма items — полный JSON Schema это отдельная, гораздо более объёмная спецификация, и тащить её всю ради формы избыточно. Свойства с такими конструкциями не бросают исключение — они просто откатываются к обычному текстовому полю без нераспознанного ограничения, чтобы одно экзотическое свойство где-то в глубине схемы не ломало парсинг всей формы.

parseJSONSchema выводит тип значений из схемы-литерала так же, как defineSchema — нужен as const. parseOpenAPI статически вывести тип не может (форма извлечённой схемы зависит от аргумента path/selector, который вычисляется в рантайме), но если в проекте уже есть сгенерированные типы из OpenAPI-документа — например, через openapi-typescript — можно передать явный типовой аргумент: parseOpenAPI<CreateUserRequest>(document, '#/components/schemas/User').


Серверные ошибки валидации

Частая и скучная боль: бэкенд вернул 422 с ошибками по полям, и в каждом новом проекте это разбирается заново — свой формат ответа, свой код распаковки, своя логика, куда именно класть текст ошибки. applyServerErrors закрывает это одной функцией:

ts Copy
import { applyServerErrors } from '@macrulez/vue-form-schema'

const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(form.values.value) })
if (!res.ok) {
  const { formErrors } = applyServerErrors(form, await res.json(), { format: 'laravel' })
  if (formErrors.length) toast.error(formErrors[0]) // ошибки, не привязанные к конкретному полю
}

Из коробки поддержаны три формата:

Формат Форма ответа
'laravel' { message, errors: { field: ["msg", ...], "nested.field": [...] } }
'drf' { field: ["msg"], nested: { field: ["msg"] } } — плоские и вложенные сериализаторы Django REST Framework, авто-приводятся к dot-path ключам; non_field_errors/detail уходят в formErrors, а не привязываются к полю
'flat' { field: "msg" | ["msg", ...] } — дефолт, подходит для большинства самописных API

Для всего остального — произвольная функция-маппер (raw) => ({ fieldErrors, formErrors }). Сама функция принимает ещё пару опций: touch (по умолчанию true — сразу помечает поля с ошибками как touched, чтобы они не ждали blur для показа) и merge (по умолчанию true — добавляет ошибки к уже существующим в errors, а не затирает их целиком).

Отдельно стоит сказать про стыковку с клиентской валидацией, потому что здесь можно было бы наворотить лишней синхронизации, а вместо этого не пришлось менять в ядре useForm ни строчки: errors и так обычный писабельный Ref, и клиентская валидация уже перезаписывает errors.value[field] при каждой ревалидации конкретного поля — на blur по умолчанию, на каждый символ при validateOn: 'input'. Серверная ошибка «естественно» вытесняется свежим клиентским результатом, как только пользователь снова взаимодействует с полем. submit() тоже полностью пересчитывает errors при каждой попытке отправки, так что протухшая серверная ошибка физически не может пережить следующий сабмит.


Плагин для Vue DevTools

Состояние формы обычно живёт и умирает внутри одного компонента — снаружи для отладки оно недоступно, и когда что-то идёт не так, единственный инструмент — console.log в обработчике. vue-form-schema/devtools добавляет форму как первоклассную сущность в расширение Vue DevTools: кастомный инспектор Forms со списком всех активных useForm()-инстансов на странице (values/errors/touched/isValid/isDirty, вживую, обновляется реактивно) и таймлайн-слой с событиями setField/touch/submit/submitSuccess/submitError/reset/asyncValidate.

ts Copy
// main.ts — только для dev, динамический импорт, чтобы @vue/devtools-api
// никогда не попал в прод-бандл
const app = createApp(App)
if (import.meta.env.DEV) {
  const { installFormDevtools } = await import('@macrulez/vue-form-schema/devtools')
  installFormDevtools(app)
}
app.mount('#app')

Архитектурно это осознанно два раздельных слоя. Внутри самого ядра — маленький реестр активных форм и событийная шина (Map/Set, без единой зависимости от @vue/devtools-api): useForm регистрирует себя при создании и эмитит события в ключевых точках. Отдельно — точка входа vue-form-schema/devtools, где @vue/devtools-api вообще единственный раз импортируется во всём пакете. Если плагин не подключён — например, в проде — эмиссия события из useForm схлопывается до одной проверки Set.size === 0, то есть цена «на случай, если DevTools когда-нибудь понадобятся» практически нулевая, и @vue/devtools-api остаётся опциональной peer-зависимостью, а не тянется в основной бандл.


Дискриминированные схемы

Последняя фича релиза — и, пожалуй, самая интересная с архитектурной точки зрения. Частый кейс в реальных формах: набор полей меняется целиком в зависимости от значения одного поля-селектора — способ оплаты, тип адреса, тип документа, тип юрлица. До сих пор это собиралось вручную, полем за полем:

ts Copy
// как было — visible прописывается на каждом поле отдельно
const schema = [
  { type: 'radio', name: 'paymentMethod', options: [/* ... */] },
  { type: 'text', name: 'cardNumber', visible: (v) => v.paymentMethod === 'card' },
  { type: 'text', name: 'cvc', visible: (v) => v.paymentMethod === 'card' },
  { type: 'email', name: 'paypalEmail', visible: (v) => v.paymentMethod === 'paypal' },
]

Работает, но многословно, легко забыть одно условие при добавлении нового поля в вариант, и никакой типовой безопасности между вариантами — ничто не мешает случайно объявить поле, которое видно сразу в двух вариантах, или не видно ни в одном. discriminatedFields(discriminatorName, variants) строит эту развязку за один вызов:

ts Copy
import { discriminatedFields, useForm } from '@macrulez/vue-form-schema'

const schema = [
  {
    type: 'radio' as const,
    name: 'paymentMethod',
    label: 'Способ оплаты',
    options: [
      { label: 'Карта', value: 'card' },
      { label: 'PayPal', value: 'paypal' },
    ],
  },
  ...discriminatedFields('paymentMethod', {
    card: [
      { type: 'text' as const, name: 'cardNumber', label: 'Номер карты', required: true },
      { type: 'text' as const, name: 'cvc', label: 'CVC', required: true },
    ],
    paypal: [
      { type: 'email' as const, name: 'paypalEmail', label: 'PayPal email', required: true },
    ],
  }),
]

const { fields } = useForm({ schema, clearOnHide: true })

Функция не создаёт само поле-селектор — его вы, как и раньше, объявляете отдельно (обычно select/radio) — а каждому полю каждого варианта проставляет visible, вычисляющий «значение селектора совпадает с ключом этого варианта». Если у поля уже был свой visible — например, дополнительное условие внутри варианта — новое условие комбинируется с ним через AND, а не затирает его: поле останется скрытым, даже если вариант активен, но собственное условие поля не выполнено. В паре с уже существовавшей опцией clearOnHide: true переключение варианта ещё и чистит значения скрытого варианта — если пользователь ввёл номер карты, а потом передумал и выбрал PayPal, cardNumber не улетит в onSubmit вместе с данными PayPal-варианта.

Отдельно добавлен нативный маппинг из уже существующих типов дискриминированных объединений в Zod и Valibot — не нужно даже вручную вызывать discriminatedFields, если схема валидации уже написана как z.discriminatedUnion:

ts Copy
const schema = z.discriminatedUnion('paymentMethod', [
  z.object({ paymentMethod: z.literal('card'), cardNumber: z.string(), cvc: z.string() }),
  z.object({ paymentMethod: z.literal('paypal'), paypalEmail: z.string().email() }),
])

const fields = parseZod(schema) // select-дискриминатор + оба варианта, visible уже проставлен

Так же работает v.variant(key, [...]) через parseValibot. Ограничение здесь тоже сознательное: маппинг работает только когда дискриминированное объединение — корневая схема, переданная в parseZod/parseValibot. Если оно вложено как свойство внутри большего z.object({...}), автоматического разворачивания не будет — для этого случая используйте discriminatedFields напрямую поверх уже распарсенных полей.

Забавный побочный эффект от работы над этой фичей: при написании тестов на нативный маппинг для Valibot всплыл давний, никем не замеченный баг в parseValibot — вложенные group-поля не получали префикс родительского имени в дочерних name (в отличие от parseZod/parseYup, которые всегда делали это правильно). Из-за этого поле address.city превращалось просто в city, и внутренняя логика чтения/записи значений по вложенному пути ломалась молча. Хуже того — существующий тест на эту функцию годами кодировал баговое поведение как ожидаемое, так что автоматический прогон тестов баг не ловил. Пришлось поправить и парсер, и сам тест, который до этого просто подтверждал неправильное поведение.


Сценарии использования пакета

Новые фичи редко раскрываются по отдельности — интереснее, когда несколько из них складываются в одну задачу. Три примера того, как это выглядит на практике.

Чекаут интернет-магазина: способ оплаты и ответ от платёжного шлюза

Классическая форма оплаты: физлицо платит картой или через PayPal, юрлицо — по счёту с реквизитами. Набор полей полностью разный, а схема валидации на бэкенде уже описана через z.discriminatedUnion — незачем описывать её второй раз на фронте.

ts Copy
import { z } from 'zod'
import { parseZod } from '@macrulez/vue-form-schema/zod'
import { applyServerErrors } from '@macrulez/vue-form-schema'
import { useForm } from '@macrulez/vue-form-schema'
import { PrimeVueFormRenderer } from '@macrulez/vue-form-schema/ui/primevue'

const checkoutSchema = z.discriminatedUnion('paymentMethod', [
  z.object({ paymentMethod: z.literal('card'), cardNumber: z.string(), cvc: z.string() }),
  z.object({ paymentMethod: z.literal('paypal'), paypalEmail: z.string().email() }),
  z.object({ paymentMethod: z.literal('invoice'), inn: z.string().min(10), companyName: z.string() }),
])

const form = useForm({
  schema: parseZod(checkoutSchema),
  clearOnHide: true,
  onSubmit: async (data) => {
    const res = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(data) })
    if (!res.ok) {
      // платёжный шлюз вернул 422 с ошибками по конкретным полям — например,
      // "карта отклонена" привяжется прямо к cardNumber
      applyServerErrors(form, await res.json(), { format: 'laravel' })
    }
  },
})
vue Copy
<template>
  <PrimeVueFormRenderer :form="form" submit-label="Оплатить" />
</template>

Три способа оплаты — три ветки discriminatedUnion, parseZod сразу разворачивает их в готовую схему с select-дискриминатором и visible на каждой ветке. clearOnHide: true гарантирует, что при переключении с «Счёт» на «Карта» реквизиты юрлица не улетят в onSubmit вместе с номером карты. А если платёжный шлюз отклонит операцию — applyServerErrors привяжет ответ прямо к полю формы, вместо общего тоста «что-то пошло не так», в котором пользователь не поймёт, что именно поправить.

Внутренняя админка на Nuxt: форма из спецификации бэкенда

В компании десятки административных экранов — карточка сотрудника, настройки отдела, права доступа. Бэкенд на NestJS уже отдаёт полную OpenAPI-спецификацию через /api/openapi.json, и дублировать описание каждой формы вручную — прямой путь к рассинхронизации, когда бэкендер добавит поле, а фронтендер об этом не узнает.

ts Copy
// composables/useEntityForm.ts — используется на каждой странице админки,
// useForm доступен без импорта благодаря Nuxt-модулю
import { parseOpenAPI } from '@macrulez/vue-form-schema/openapi'

export async function useEntityForm(entityPath: string) {
  const spec = await $fetch('/api/openapi.json')
  const fields = parseOpenAPI(spec, { path: entityPath, method: 'post' })

  return useForm({ schema: fields, validateOn: 'blur' })
}
vue Copy
<!-- pages/admin/employees/new.vue -->
<script setup>
const form = await useEntityForm('/employees')
</script>

<template>
  <FormRenderer :form="form" submit-label="Создать" />
</template>

Форма создания сотрудника всегда соответствует актуальной спецификации бэкенда — без отдельного шага «не забыть поправить фронт после того как бэкендер добавил поле». Во время разработки открытый инспектор Forms в Vue DevTools сразу показывает, какие поля реально пришли из спеки и какие значения в них сейчас — удобно, когда форм на странице несколько и непонятно, какая из них не валидируется.

Заявка на подключение: физлицо или юрлицо, схема на Valibot

Форма заявки с двумя принципиально разными наборами реквизитов в зависимости от типа клиента, схема валидации написана на Valibot, а бэкенд на Django возвращает ошибки в формате DRF. Оформление — по корпоративной дизайн-системе на Naive UI.

ts Copy
import * as v from 'valibot'
import { parseValibot } from '@macrulez/vue-form-schema/valibot'
import { applyServerErrors, useForm } from '@macrulez/vue-form-schema'
import { NaiveFormRenderer } from '@macrulez/vue-form-schema/ui/naive'

const applicationSchema = v.variant('clientType', [
  v.object({
    clientType: v.literal('person'),
    passport: v.string(),
    birthDate: v.string(),
  }),
  v.object({
    clientType: v.literal('company'),
    inn: v.pipe(v.string(), v.minLength(10)),
    companyName: v.string(),
  }),
])

const form = useForm({
  schema: parseValibot(applicationSchema),
  clearOnHide: true,
  onSubmit: async (data) => {
    const res = await fetch('/api/applications', { method: 'POST', body: JSON.stringify(data) })
    if (!res.ok) applyServerErrors(form, await res.json(), { format: 'drf' })
  },
})
vue Copy
<template>
  <NaiveFormRenderer :form="form" submit-label="Отправить заявку" />
</template>

v.variant разворачивается тем же нативным маппингом, что и z.discriminatedUnion, — просто через parseValibot вместо parseZod. Формат ошибок 'drf' сам разбирает как плоские, так и вложенные сериализаторы Django и приводит ключи к dot-path — писать код распаковки ответа под конкретный бэкенд не пришлось вообще, только указать имя формата.


Мелкие доработки, которые не попали в список фич

Не всё в релизе — новая функциональность. Часть времени ушла на вещи, которые снаружи не видны как «фича», но без них релиз был бы менее надёжным.

Предупреждения линтера — с двенадцати до нуля. Часть — обычный мусор, который накапливается по ходу разработки: неиспользуемые импорты в демо-страницах, забытая переменная в одном из примеров. Часть интереснее: четыре директивы // eslint-disable-next-line @typescript-eslint/no-explicit-any оказались мёртвыми — само правило no-explicit-any в какой-то момент было выключено глобально в конфиге проекта (это нужно ради безопасного разборщика строковых выражений внутри пакета, который сознательно использует any в паре мест), и точечные disable-комментарии, оставшиеся с более ранних версий кода, просто перестали быть нужны — но их никто не убрал, и линтер честно на них ругался как на бесполезные.

Неявный any, который пропускал vue-tsc, но валил сборку. Отдельная точка проверки типов (vue-tsc --noEmit) была чиста, а вот сама сборка через vite build спотыкалась на неявном any в инлайн-обработчике одного из PrimeVue-компонентов — типы события у стороннего компонента не выводились автоматически прямо внутри шаблона Vue. Проверка типов и сборка типов — не всегда одно и то же, и это тот случай, когда стоит гонять оба шага, а не полагаться на один из них.

Node 18 в CI и один неожиданный SyntaxError. После публикации проверка Nuxt-модуля в CI начала падать с SyntaxError: The requested module 'node:util' does not provide an export named 'styleText'. Причина оказалась в глубине транзитивных зависимостей: инструмент сборки Nuxt-модулей использует библиотеку, которая опирается на styleText из node:util — а эта функция появилась только в Node 20.12. В engines.node пакета и в матрице CI при этом до сих пор был заявлен Node 18, который вдобавок уже больше года как официально не поддерживается. Требование к версии Node поднято до >=20.12, а из CI-матрицы Node 18 убран в пользу актуальных 20.x и 22.x.

Апгрейд генератора .d.ts-файлов с третьей мажорной версии сразу на пятую. Затеян был ради чистой косметики — при каждой сборке в лог сыпалось предупреждение о том, что встроенная в плагин копия TypeScript устарела относительно версии, используемой в проекте. Апгрейд обернулся двумя граблями, которые проявились только на реальной сборке, а не на npm install. Во-первых, ключевая опция для объединения деклараций типов в новой архитектуре плагина была молча переименована — старое имя опции просто игнорировалось новой версией, из-за чего файлы деклараций типов для всех точек входа, кроме основной, перестали генерироваться вообще — без единой ошибки сборки, просто тихо пропадали из итогового пакета. Во-вторых, для разбора .vue-файлов потребовалась новая опциональная зависимость, которая раньше подтягивалась неявно через старую версию инструмента, а с новой версией нужна была уже явно.

Обе проблемы стоило бы поймать раньше, чем при публикации — хороший повод после подобных апгрейдов не полагаться на «зелёный» npm install, а реально прогонять сборку и проверять содержимое результата, а не только код завершения команды.

Пара находок поменьше, но тоже по делу: в демо-приложении пункт навигации оказался кликабельным <div> вместо <button> — недоступно с клавиатуры и для скринридеров, поправлено на семантическую кнопку с сохранением всей стилизации. И @microsoft/api-extractor, от которого напрямую зависит объединение .d.ts-файлов, держался в дереве зависимостей только как транзитивный опциональный peer — без явной записи в devDependencies пересборка в другом окружении могла его не подтянуть и сломать генерацию типов так же тихо, как в первый раз.


Итог

Семь новых возможностей в одной версии, а ядро по-прежнему укладывается в 4.5 KB gzip. 280 тестов в 20 файлах, 11 точек входа, ни одного предупреждения линтера. Самый практичный урок релиза — не про сами фичи, а про то, что апгрейд инструментов сборки и поддержка CI требуют такой же проверки на практике, как и код: молчаливо переименованная опция или забытая версия Node в матрице ломают всё ровно так же незаметно, как баг в бизнес-логике, только обнаруживаются обычно позже — уже после публикации.

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

Читать далее

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

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

20.08.2026

В версии 1.1.0 vue-image-kit дорос с «удобного компонента для картинок» до полноценного стека: авто-детект 8 CDN, self-hosted сервер ресайза на замену CDN, Nuxt-модуль с реальным Nitro-роутом, инкрементальная сборка CLI и ещё десяток менее заметных, но полезных вещей. Ниже — подробный разбор того, что попало в релиз и почему сделано именно так.

Метки
vuevue-image-kitnuxtimage-optimizationopensource