# 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`).
