@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
// как было — тип дублируется руками
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
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
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 ✓
Правило вывода простое: checkbox → boolean, number → number, array → unknown[], всё остальное — 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
npm install @macrulez/nuxt-vue-form-schema
ts
// 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
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
<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
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
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/const → select, 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
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
// 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
// как было — 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
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
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
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
<template>
<PrimeVueFormRenderer :form="form" submit-label="Оплатить" />
</template>
Три способа оплаты — три ветки discriminatedUnion, parseZod сразу разворачивает их в готовую схему с select-дискриминатором и visible на каждой ветке. clearOnHide: true гарантирует, что при переключении с «Счёт» на «Карта» реквизиты юрлица не улетят в onSubmit вместе с номером карты. А если платёжный шлюз отклонит операцию — applyServerErrors привяжет ответ прямо к полю формы, вместо общего тоста «что-то пошло не так», в котором пользователь не поймёт, что именно поправить.
Внутренняя админка на Nuxt: форма из спецификации бэкенда
В компании десятки административных экранов — карточка сотрудника, настройки отдела, права доступа. Бэкенд на NestJS уже отдаёт полную OpenAPI-спецификацию через /api/openapi.json, и дублировать описание каждой формы вручную — прямой путь к рассинхронизации, когда бэкендер добавит поле, а фронтендер об этом не узнает.
ts
// 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
<!-- 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
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
<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