Files
snippets/vite-layers/README.md
T

358 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# vite-layers
Framework-agnostic **слои в стиле Nuxt**, портированные на чистый Vite: файловые оверрайды через
`extends` + мёрж конфигов, плюс побрендовый build-time dead-code elimination. Работает с любым
фреймворком — у ядра **нет фреймворк-зависимостей**; `vue()`/`react()`/и т.п. подключаются на уровне слоя.
Логика стека слоёв (порядок, дедуп, авто-скан, алиасы) портирована напрямую из исходников Nuxt
(`@nuxt/kit` `loadNuxtConfig` + `@nuxt/schema`), с тремя осознанными улучшениями.
## Установка
```bash
pnpm add -D vite-layers # peer-зависимость: vite ^8
```
## Использование
Каждое приложение/бренд — это директория с `app.config.ts` и однострочным `vite.config.ts`:
```ts
// apps/main/app.config.ts
import { defineLayerConfig } from 'vite-layers'
export default defineLayerConfig({
name: 'main',
features: { billing: true },
vite: { plugins: [vue()] }, // фреймворк-плагин живёт здесь, а не в ядре
})
// apps/brand/app.config.ts — только диффы
import { defineLayerConfig } from 'vite-layers'
export default defineLayerConfig({
name: 'brand',
extends: ['../main'],
features: { billing: false }, // бренд полностью убирает страницу billing
})
// apps/<любой>/vite.config.ts
import { buildViteConfig } from 'vite-layers'
export default buildViteConfig(import.meta.dirname)
```
Чтобы перекрыть файл, положите его по тому же относительному пути в слое с более высоким приоритетом
(`apps/brand/src/components/Header.vue` затеняет `apps/main/src/components/Header.vue`).
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
```ts
import { feature } from '#feature' // алиас регистрирует vite-layers; типы — из .vite-layers/features.d.ts
const routes = [
{ path: '/', component: () => import('@/pages/Home') },
...(feature('billing') ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
]
```
`feature('key')`**компайл-тайм макрос**: плагин заменяет вызов на литерал значения флага,
**одинаково в dev и в build** (один AST-transform, без расхождений). Подставленный `false` делает ветку
статически мёртвой, и Rollup/rolldown вырезает её вместе с `import()` — чанк выключенной фичи не эмитится.
Тип `feature` генерируется из `merged.features` **литеральными типами**, поэтому опечатка ключа
(`feature('biling')`) — ошибка компиляции.
Правила (они **enforced**: нарушение валит сборку — и в dev, и в build, ничего не «протекает» молча):
- ключ — строковый литерал: `feature('billing')`, не `feature(name)`;
- вызывайте напрямую — без алиасов (`const f = feature`), деструктуризации и передачи как значения;
- вложенные флаги — дотированным ключом: `feature('payments.stripe')`;
- ключ должен существовать в `merged.features` (иначе — ошибка сборки).
Тестам ничего дублировать не нужно — тот же transform работает в Vitest (плагин — часть конфига).
В `.vue`-шаблоне напрямую `feature('x')` использовать нельзя (компилятор делает из него `_ctx.feature`
это не вызов макроса): читайте флаг в `<script setup>`/JSX и используйте в шаблоне локальную переменную.
При изменении любого `app.config.*` dev-сервер **автоматически перезапускается** (`app.config` грузится
c12, вне графа Vite — сам он не следит), подхватывая новые значения флагов.
## Префиксы импортов
| Префикс | Куда резолвится | Примечания |
|---|---|---|
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
| `#super`, `#super/…` | первый совпавший файл **строго ниже** слоя импортёра | явный `super()` — предпочтительная форма, см. ниже |
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
| `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы |
## `super()`: доступ к затенённому файлу
Оверрайд часто хочет не заменить базовый файл целиком, а обернуть его. Для этого есть `#super`:
```ts
// apps/brand/src/main.ts — бренд без собственного bootstrap: реиспользует entry базы
import '#super' // мой же путь (main.ts), слоем ниже → apps/main/src/main.ts
// apps/brand/src/components/Header.vue — оборачиваем базовый компонент
import BaseHeader from '#super/components/Header.vue'
```
- `#super/<path>` — резолвит `<path>` начиная со слоя **строго ниже** слоя импортёра
(работает из любого файла слоя, не только из одноимённого оверрайда);
- голый `#super` — сахар для «мой собственный путь, слоем ниже».
`#super/*` типизируется в сгенерированном tsconfig (`paths` → слои ниже проектного), так что
go-to-definition ведёт в правильный файл; голая форма покрыта ambient-декларацией
(`.vite-layers/super.d.ts`) — типов не даёт, но и ошибок не создаёт (подходит для side-effect
импортов). Legacy-форма — self-import собственного пути (`import '@/main.ts'` изнутри `main.ts`,
Nuxt-парити) — продолжает работать, но не грепается, меняет смысл при копировании в другой файл и
резолвится TypeScript'ом в самого себя; в новом коде используйте `#super`.
## Модель приоритета (из Nuxt)
`layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину;
авто-сканированные `layers/*` сортируются по убыванию (`Z` > `A`, выше числовой префикс — выше приоритет).
Коллизии решаются как **меньший индекс слоя выигрывает**. Конфиги мёржатся через `defu` (проект
выигрывает; массивы конкатятся).
## Слоёвые `public/`-ассеты (брендинг)
У каждого слоя может быть своя `public/` — резолвится **first-match по слоям**, как `@/`:
`brand/public/logo.svg` затеняет `main/public/logo.svg`, а `favicon.svg` из базы наследуется.
Удобно для лого/favicon/шрифтов per-brand. Работает и в dev (отдаётся через sirv по приоритету),
и в build (эмитится в `outDir`, верхний слой перетирает нижний). `publicDir` Vite при этом
отключается автоматически (он одиночный) — плагин берёт обслуживание на себя.
```
apps/main/public/{logo.svg, favicon.svg} # база
apps/brand/public/logo.svg # бренд перекрывает только лого
→ dist/brand/{logo.svg = brand, favicon.svg = main}
```
## Env-оверрайды слоёв
Слой в `app.config.ts` может переопределять себя по Vite `mode` (через c12 `$<env>`/`$env`):
```ts
export default defineLayerConfig({
features: { analytics: false },
$production: { features: { analytics: true } }, // применится при mode=production
})
```
**Объявляйте все ключи фич в базовом `features`** (как `analytics` выше), а `$env`-блоки используйте
только чтобы менять их **значения**. Ключ, существующий лишь в `$production`, будет «неизвестен» в dev
(`feature('x')` → ошибка сборки) и не попадёт в типы. Литеральные типы в `features.d.ts` отражают тот
`mode`, в котором их сгенерировали (dev/build/`prepare`) — поэтому флаг с разными значениями по mode
типизируется значением текущего mode; держите ключи в базе для предсказуемости.
## Опции
`buildViteConfig(appDir, options?)`:
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}``GenerateTsConfigOptions`.
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
- `devtools: false` — не монтировать панели в Vite DevTools (см. ниже). По умолчанию включено.
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
## Хуки жизненного цикла
Как в Nuxt (на `unjs/hookable`): типизированные, **серийные в порядке слоёв (база первой)**,
**mutation-style** (хендлер мутирует общий аргумент). Хуки каждого слоя из `app.config.ts`
**накапливаются** (одноимённые из разных слоёв все выполняются), не перетираются.
```ts
export default defineLayerConfig({
hooks: {
'layers:resolved': (stack) => { stack.merged.features ??= {}; /* править merged/features/layers */ },
'vite:config': (ctx) => { ctx.config.plugins?.push(myPlugin()) }, // финальный Vite-конфиг
'tsconfig:generate': (ctx) => { ctx.tsconfig.compilerOptions!.strict = true }, // перед записью
},
})
```
| Хук | Аргумент | Когда |
|---|---|---|
| `layers:resolved` | `LayerStack` | после резолва стека (до чтения features/алиасов) |
| `vite:config` | `{ config, env, stack }` | финальный Vite-конфиг перед возвратом |
| `tsconfig:generate` | `{ appDir, tsconfig, stack }` | сгенерированный tsconfig перед записью |
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`.
## DevTools
vite-layers умеет показывать свой резолвнутый стек прямо в [Vite DevTools](https://devtools.vite.dev)
(`@vitejs/devtools`). Добавьте хаб в dev — `buildViteConfig` сам подмонтирует панели; без хаба плагин
**инертен** (используются только *типы* из `@vitejs/devtools-kit`, никакой рантайм-зависимости):
```ts
// apps/main/app.config.ts
import { DevTools } from '@vitejs/devtools' // peer-зависимость только для dev
export default defineLayerConfig({
vite: ({ command }) => ({
plugins: [vue(), command === 'serve' && DevTools()], // хаб только в dev
}),
})
```
Четыре панели (свёрнуты под одной кнопкой `vite-layers`):
| Панель | Что показывает |
|---|---|
| **Layers** | **дерево наследования** (`extends`-граф box-drawing, с ромбами и авто-сканом), резолвнутый стек high→low, мёрж-конфиг (Tree), накопленные хуки |
| **Features** | мёрж-флаги с литеральными значениями и статусом DCE (`kept` / `branch eliminated`); бейдж = число выключенных |
| **Resolver** | плейграунд `@/…` (показывает кандидатов по слоям + победителя) и **живой лог** реальных слоёвых резолвов сессии (включая `super()`-self-skip) |
| **Public & TS** | слоёвые `public/`-ассеты (кто кого затеняет) и сгенерированные `tsconfig.json` / `tsconfig.node.json` / `features.d.ts` |
UI рисуется целиком на сервере (json-render спеки `@vitejs/devtools-kit`) — **клиентский бандл не
нужен**, vite-layers остаётся buildless. Панели читают тот же стек и тот же резолвер-кэш, что и сборка
(`createLayeredResolution` шарится между резолвер-плагином и панелью), поэтому показанное — ровно то,
что действует. При первом заходе DevTools попросит авторизовать браузер (разовый prompt в терминале).
Плагин можно подключить и вручную — `layersDevtoolsPlugin` экспортируется из `vite-layers/devtools`.
## Улучшения над Nuxt/c12
1. **`super()`** — явный `#super`/`#super/<path>` (типизированный, greppable) плюс self-skip
(оверрайд может импортировать собственный путь `@/components/X`, чтобы дотянуться до базового
файла). В Nuxt такого механизма нет.
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
`resolve`-хуке c12 обрывает рекурсию.
3. **Побрендовый DCE через `feature()`-макрос** — гейтированные `import()` выпиливаются из бандлов
выключенных брендов: AST-transform заменяет `feature('key')` на литерал (**один механизм для dev и
build**), а любое не-сворачиваемое использование (алиас, динамический/неизвестный ключ) валит
сборку с понятной ошибкой — вместо молчаливой деградации DCE, как при `define`-подходе Nuxt.
## TypeScript (автогенерация tsconfig)
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` аугментирует
модуль `#feature` литеральными типами `feature()`, а сгенерированный `paths['#feature']` резолвит сам
макрос); `tsconfig.json` приложения его расширяет:
```jsonc
// apps/brand/tsconfig.json
{ "extends": "./.vite-layers/tsconfig.json" }
// сюда добавляются фреймворк-опции (для Vue: "jsx": "preserve", "jsxImportSource": "vue")
```
Сгенерированный `paths['@/*']`/`['~/*']` — это массив **`srcDir` всех слоёв в порядке приоритета**,
поэтому `tsc`/`vue-tsc` резолвит слоёвые импорты по first-existing-file — точно как рантайм-резолвер.
`~~`/`@@` → корень проекта; `#layers/<name>` → каждый слой.
**Два tsconfig — как в Nuxt (app + node).** `tsconfig.json` — для кода приложения (слои, DOM, `paths`);
`tsconfig.node.json` — для конфиг-файлов (`vite.config.*`/`app.config.*` всех слоёв) с node-типами,
без DOM и без слоёвых `paths`. Проверять оба:
```bash
vue-tsc --noEmit -p apps/brand # код приложения
vue-tsc --noEmit -p apps/brand/.vite-layers/tsconfig.node.json # конфиг-файлы (node)
```
**Настройка на уровне слоя** через поле `tsConfig` в `app.config.ts` (это pkg-types
[`TSConfig`](https://github.com/unjs/pkg-types)), мёржится по стеку как Nuxt `typescript.tsConfig`
сгенерированные `paths` всегда побеждают:
```ts
// apps/main/app.config.ts
export default defineLayerConfig({
name: 'main',
tsConfig: { compilerOptions: { jsxImportSource: 'vue', strict: true } }, // наследуется брендами
})
```
Ручная генерация для CI:
```bash
vite-layers prepare apps/brand # пишет apps/brand/.vite-layers/tsconfig.json
vue-tsc --noEmit -p apps/brand # или tsc --noEmit
```
Отключить авто-запись: `buildViteConfig(dir, { tsconfig: false })`. Добавьте `.vite-layers/` в `.gitignore`.
## API
- `buildViteConfig(appDir, options?)` — дефолтный экспорт для `vite.config.ts` (резолвер + автоген
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
- `resolveLayerStack(cwd)``{ merged, layers }` — резолвнутый упорядоченный стек.
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно).
- `createLayeredResolution({ roots, prefixes?, extensions?, record? })` — чистое ядро резолвера
(`parse`/`candidates`/`resolveId`/`records`); шарится между резолвер-плагином и DevTools-панелью.
- `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
- `feature(key)` (импорт из `#feature` / `vite-layers/feature`) — компайл-тайм макрос флагов; `featurePlugin(features)` — сам плагин (можно отдельно).
- `layersDevtoolsPlugin(data)` (импорт из `vite-layers/devtools`) — панели для Vite DevTools (автоматически подключаются `buildViteConfig`).
## Пример (Vue + Tailwind)
`example/apps/{main,brand,aurora}` — запускаемое мультибрендовое демо. Общий «каркас» (шапка, подвал,
страница профиля, страница биллинга, роутер, входной Tailwind-CSS) живёт только в базовом слое `main`;
каждый бренд меняет ровно **три** вещи — логотип, файл темы Tailwind и лендинг:
| Приложение | Слой | Тема | DCE-страница `billing` | Beta-плашка |
|---|---|---|---|---|
| `main` (Acme) | база | светлая, индиго | ✅ есть | ✅ (в dev) |
| `brand` (Northwind) | `extends ../main` | светлая, изумруд | ❌ вырезана | ✅ (унаследована) |
| `aurora` (Aurora) | `extends ../main` | **тёмная**, роза/небо | ✅ есть | ❌ выключена |
```bash
pnpm example:dev # дев-сервер Acme (бренды: npx vite example/apps/{brand,aurora})
pnpm example:build # билд всех трёх — сравните эмитнутые чанки
pnpm example:check # prepare + vue-tsc для всех трёх (код приложения + node-конфиги)
```
**Как меняется тема.** Общий `@/style.css` (только в базе) подключает Tailwind и мапит токены на
runtime-переменные через `@theme inline` (`--color-brand: var(--c-brand)` и т.д.). Каждый бренд кладёт
свой `@/assets/theme.css` с `:root { --c-* }`; слоёвый резолвер выбирает версию верхнего слоя — и весь
общий UI перекрашивается, без правки единого компонента. `@tailwindcss/vite` резолвит CSS-`@import`
своим резолвером (мимо слоёв), поэтому тему подключаем **JS-импортом** `import '@/assets/theme.css'`,
который идёт через слоёвый резолвер.
Что демонстрирует демо:
- **Общий каркас:** `AppHeader`, `AppFooter`, `Profile`, `Billing`, роутер есть только в `main`, но
рендерятся во всех брендах через `@/…`. У `brand` больше нет своего `AppHeader` — шапка общая.
- **Перекраска темой:** один и тот же `Profile`/`Billing` рендерится светлым у Acme/Northwind и тёмным
у Aurora — разница только в `theme.css` (10 токенов цвета/радиуса/шрифта).
- **Перекрытие ассета:** `*/public/logo.svg` затеняется послойно; `favicon.svg` наследуется из базы.
- **DCE:** у `brand` `features.billing: false``feature('billing')` сворачивается в `false`
`import('@/pages/Billing.vue')` мёртв → чанк `Billing-*.js` не эмитится (и ссылки в навигации нет):
```bash
ls example/apps/main/dist/main/assets | grep -i billing # Billing-*.js есть
ls example/apps/brand/dist/brand/assets | grep -i billing # пусто → DCE
ls example/apps/aurora/dist/aurora/assets | grep -i billing # Billing-*.js есть
```
- **Разные наборы фич:** `brand` убирает биллинг, но оставляет beta-плашку; `aurora` — наоборот.
`$production` гасит beta-плашку в проде у всех (env-оверрайд слоя).
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`, `types: ['vite/client']`),
`vue-tsc` зелёный для всех трёх:
```bash
vite-layers prepare example/apps/aurora
npx vue-tsc --noEmit -p example/apps/aurora
```
## Тесты
```bash
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, feature()-макрос, tsconfig
pnpm type-check
```
## Сборка
Разработка **buildless** — example-приложения и тесты импортируют `src/*` напрямую, а `exports`
пакета указывают на `./src/*.ts`. Для публикации `pnpm build` (tsdown) собирает ESM + `.d.ts` в
`dist/` по одному выходу на каждый сабпас (`.`, `./feature`, `./devtools`); зависимости и peer'ы
(`vite`, `@vitejs/devtools-kit`) внешние. `publishConfig.exports` переключает пакет на `dist/`
`prepack` пересобирает автоматически, в tarball едут только `dist/` + `bin/` (проверено `publint`).
```bash
pnpm build # tsdown → dist/{index,feature,devtools}.{js,d.ts}
pnpm pack # prepack-сборка + публикуемый tarball (dist + bin)
```
CLI `vite-layers prepare` и алиас `#feature` работают в обоих режимах: из исходников (`feature.ts`,
jiti) и из собранного `dist/` (`feature.js`).