feat: add feature plugin tests and validation for feature flags

This commit is contained in:
2026-06-21 03:14:19 +07:00
parent ecc958c9f0
commit 1ee76faf55
65 changed files with 4992 additions and 415 deletions
+1
View File
@@ -1 +1,2 @@
.DS_Store .DS_Store
.claude
+126 -37
View File
@@ -45,34 +45,32 @@ export default buildViteConfig(import.meta.dirname)
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться): Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
```ts ```ts
// __FEATURES__ типизируется сгенерированным .vite-layers/features.d.ts — declare не нужен import { feature } from '#feature' // алиас регистрирует vite-layers; типы — из .vite-layers/features.d.ts
const routes = [ const routes = [
{ path: '/', component: () => import('@/pages/Home') }, { path: '/', component: () => import('@/pages/Home') },
...(__FEATURES__.billing ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []), ...(feature('billing') ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
] ]
``` ```
Тип `__FEATURES__` генерируется из `merged.features` в `.vite-layers/features.d.ts`, поэтому опечатка `feature('key')`**компайл-тайм макрос**: плагин заменяет вызов на литерал значения флага,
(`__FEATURES__.biling`) — ошибка компиляции, а не молчком-falsy. **одинаково в dev и в build** (один AST-transform, без расхождений). Подставленный `false` делает ветку
статически мёртвой, и Rollup/rolldown вырезает её вместе с `import()` — чанк выключенной фичи не эмитится.
Тип `feature` генерируется из `merged.features` **литеральными типами**, поэтому опечатка ключа
(`feature('biling')`) — ошибка компиляции.
Флаги вшиваются через `define` и сворачиваются esbuild ещё **до** построения графа Rollup, поэтому Правила (они **enforced**: нарушение валит сборку — и в dev, и в build, ничего не «протекает» молча):
выключенная ветка и её `import()` физически не попадают в бандл. Дотированные литералы эмитятся на
любую глубину (`__FEATURES__.nested.enabled` тоже сворачивается). Правила, чтобы DCE сработало:
- обращайтесь **напрямую**`__FEATURES__.billing`; алиас/деструктуризация (`const f = __FEATURES__; f.billing`) - ключ — строковый литерал: `feature('billing')`, не `feature(name)`;
и динамический доступ (`__FEATURES__[name]`) не сворачиваются; - вызывайте напрямую — без алиасов (`const f = feature`), деструктуризации и передачи как значения;
- гейт оборачивает сам `import()` (тернарник/`&&`/спред), а не `.filter` после — reachable-импорт не вырезается; - вложенные флаги — дотированным ключом: `feature('payments.stripe')`;
- ключи фич — валидные JS-идентификаторы (kebab/пробел доступны в рантайме через объект `__FEATURES__`, - ключ должен существовать в `merged.features` (иначе — ошибка сборки).
но без DCE);
- в тестах продублируйте `define``vitest.config`) или гардите `globalThis.__FEATURES__ ?? {}`.
**Dev-режим.** В build флаги работают через `define` (+ DCE). В dev Vite 8 / rolldown-vite **не** Тестам ничего дублировать не нужно — тот же transform работает в Vitest (плагин — часть конфига).
инлайнит пользовательский `define` в исходники, поэтому `vite-layers` сам подставляет `__FEATURES__` В `.vue`-шаблоне напрямую `feature('x')` использовать нельзя (компилятор делает из него `_ctx.feature`
в рантайме (dev-only плагин). Плюс при изменении любого `app.config.*` слоя dev-сервер это не вызов макроса): читайте флаг в `<script setup>`/JSX и используйте в шаблоне локальную переменную.
**автоматически перезапускается** (`app.config` грузится c12, вне графа Vite — сам он не следит) — При изменении любого `app.config.*` dev-сервер **автоматически перезапускается** (`app.config` грузится
так фичи обновляются без ручного рестарта. В шаблонах `.vue` `__FEATURES__` напрямую использовать c12, вне графа Vite — сам он не следит), подхватывая новые значения флагов.
нельзя — компилятор префиксует его в `_ctx.__FEATURES__` (define/рантайм-подстановка не матчат);
читайте флаг в `<script setup>` и используйте в шаблоне локальную переменную.
## Префиксы импортов ## Префиксы импортов
@@ -81,6 +79,7 @@ const routes = [
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` | | `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias | | `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени | | `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
| `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы |
## Модель приоритета (из Nuxt) ## Модель приоритета (из Nuxt)
@@ -114,12 +113,19 @@ export default defineLayerConfig({
}) })
``` ```
**Объявляйте все ключи фич в базовом `features`** (как `analytics` выше), а `$env`-блоки используйте
только чтобы менять их **значения**. Ключ, существующий лишь в `$production`, будет «неизвестен» в dev
(`feature('x')` → ошибка сборки) и не попадёт в типы. Литеральные типы в `features.d.ts` отражают тот
`mode`, в котором их сгенерировали (dev/build/`prepare`) — поэтому флаг с разными значениями по mode
типизируется значением текущего mode; держите ключи в базе для предсказуемости.
## Опции ## Опции
`buildViteConfig(appDir, options?)`: `buildViteConfig(appDir, options?)`:
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}``GenerateTsConfigOptions`. - `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}``GenerateTsConfigOptions`.
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`). - `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых. - `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
- `devtools: false` — не монтировать панели в Vite DevTools (см. ниже). По умолчанию включено.
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет). - `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
## Хуки жизненного цикла ## Хуки жизненного цикла
@@ -147,6 +153,38 @@ export default defineLayerConfig({
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`. `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 ## Улучшения над Nuxt/c12
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`), 1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
@@ -154,15 +192,17 @@ export default defineLayerConfig({
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt 2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
`resolve`-хуке c12 обрывает рекурсию. `resolve`-хуке c12 обрывает рекурсию.
3. **Побрендовый DCE** — гейтированные динамические `import()` выпиливаются из бандлов выключенных 3. **Побрендовый DCE через `feature()`-макрос** — гейтированные `import()` выпиливаются из бандлов
брендов через дотированные `__FEATURES__.<key>` defines (esbuild сворачивает литерал ещё до того, выключенных брендов: AST-transform заменяет `feature('key')` на литерал (**один механизм для dev и
как Rollup построит граф модулей). build**), а любое не-сворачиваемое использование (алиас, динамический/неизвестный ключ) валит
сборку с понятной ошибкой — вместо молчаливой деградации DCE, как при `define`-подходе Nuxt.
## TypeScript (автогенерация tsconfig) ## TypeScript (автогенерация tsconfig)
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` типизирует `<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` аугментирует
`__FEATURES__`); `tsconfig.json` приложения его расширяет: модуль `#feature` литеральными типами `feature()`, а сгенерированный `paths['#feature']` резолвит сам
макрос); `tsconfig.json` приложения его расширяет:
```jsonc ```jsonc
// apps/brand/tsconfig.json // apps/brand/tsconfig.json
@@ -210,34 +250,83 @@ vue-tsc --noEmit -p apps/brand # или tsc --noEmit
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить). tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
- `resolveLayerStack(cwd)``{ merged, layers }` — резолвнутый упорядоченный стек. - `resolveLayerStack(cwd)``{ merged, layers }` — резолвнутый упорядоченный стек.
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно). - `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. - `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`. - `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
- `feature(key)` (импорт из `#feature` / `vite-layers/feature`) — компайл-тайм макрос флагов; `featurePlugin(features)` — сам плагин (можно отдельно).
- `layersDevtoolsPlugin(data)` (импорт из `vite-layers/devtools`) — панели для Vite DevTools (автоматически подключаются `buildViteConfig`).
## Пример (Vue) ## Пример (Vue + Tailwind)
`example/apps/{main,brand}` — запускаемое Vue-демо. `main` — база (`vue()` + страница `billing`), `example/apps/{main,brand,aurora}` — запускаемое мультибрендовое демо. Общий «каркас» (шапка, подвал,
`brand` расширяет её, перекрывает `AppHeader.vue` и выключает `billing`. Соберите оба и сравните: страница профиля, страница биллинга, роутер, входной Tailwind-CSS) живёт только в базовом слое `main`;
каждый бренд меняет ровно **три** вещи — логотип, файл темы Tailwind и лендинг:
| Приложение | Слой | Тема | DCE-страница `billing` | Beta-плашка |
|---|---|---|---|---|
| `main` (Acme) | база | светлая, индиго | ✅ есть | ✅ (в dev) |
| `brand` (Northwind) | `extends ../main` | светлая, изумруд | ❌ вырезана | ✅ (унаследована) |
| `aurora` (Aurora) | `extends ../main` | **тёмная**, роза/небо | ✅ есть | ❌ выключена |
```bash ```bash
npx vite build example/apps/main # эмитит чанки Home + Billing pnpm example:dev # дев-сервер Acme (бренды: npx vite example/apps/{brand,aurora})
npx vite build example/apps/brand # только Home (чанка Billing НЕТ → DCE); AppHeader перекрыт 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'`,
который идёт через слоёвый резолвер.
Что демонстрирует демо: Что демонстрирует демо:
- **Оверрайд:** `brand/src/components/AppHeader.vue` затеняет версию из `main` (`@/components/AppHeader.vue`). - **Общий каркас:** `AppHeader`, `AppFooter`, `Profile`, `Billing`, роутер есть только в `main`, но
- **DCE:** `features.billing: false` → динамический `import('@/pages/Billing.vue')` мёртв → чанк не эмитится. рендерятся во всех брендах через `@/…`. У `brand` больше нет своего `AppHeader` — шапка общая.
- **Алиасы/резолвер:** `main.ts` тянет страницы и компонент через `@/…` сквозь слои. - **Перекраска темой:** один и тот же `Profile`/`Billing` рендерится светлым у Acme/Northwind и тёмным
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`), `vue-tsc` зелёный: у Aurora — разница только в `theme.css` (10 токенов цвета/радиуса/шрифта).
- **Перекрытие ассета:** `*/public/logo.svg` затеняется послойно; `favicon.svg` наследуется из базы.
- **DCE:** у `brand` `features.billing: false``feature('billing')` сворачивается в `false`
`import('@/pages/Billing.vue')` мёртв → чанк `Billing-*.js` не эмитится (и ссылки в навигации нет):
```bash ```bash
vite-layers prepare example/apps/brand ls example/apps/main/dist/main/assets | grep -i billing # Billing-*.js есть
npx vue-tsc --noEmit -p example/apps/brand 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 ```bash
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, defines, tsconfig pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, feature()-макрос, tsconfig
pnpm type-check 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`).
+8 -4
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
// CLI for vite-layers. Loads the TypeScript source via jiti (no build step needed). // CLI for vite-layers. In a source checkout it loads the TypeScript source via jiti (no build step
// needed); a published package ships `dist` (not `src`), so it falls back to the built `dist/index.js`.
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { createJiti } from 'jiti'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const [cmd, appArg] = process.argv.slice(2) const [cmd, appArg] = process.argv.slice(2)
@@ -14,7 +14,11 @@ if (cmd !== 'prepare') {
process.exit(cmd ? 1 : 0) process.exit(cmd ? 1 : 0)
} }
const srcEntry = resolve(here, '../src/tsconfig.ts')
const { writeTsConfig } = existsSync(srcEntry)
? await (await import('jiti')).createJiti(import.meta.url).import(srcEntry)
: await import(resolve(here, '../dist/index.js'))
const appDir = resolve(process.cwd(), appArg ?? '.') const appDir = resolve(process.cwd(), appArg ?? '.')
const { writeTsConfig } = await jiti.import(resolve(here, '../src/tsconfig.ts'))
const file = await writeTsConfig(appDir) const file = await writeTsConfig(appDir)
console.log(`vite-layers: wrote ${file}`) console.log(`vite-layers: wrote ${file}`)
@@ -0,0 +1,10 @@
import { defineLayerConfig } from '../../../src/index.ts'
// Aurora — a dark-themed brand. Like Northwind it inherits the whole shell from `main` and changes
// only its logo, theme.css and Landing. It keeps the billing page but turns off the beta accent,
// showing that each brand toggles a different subset of features.
export default defineLayerConfig({
name: 'aurora',
extends: ['../main'],
features: { betaBanner: false }, // keeps `billing` (inherited true); drops the beta pill
})
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aurora — Cloud Platform</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="28" viewBox="0 0 124 28" fill="none" role="img" aria-label="Aurora">
<defs>
<linearGradient id="au" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#fb7185"/>
<stop offset="1" stop-color="#38bdf8"/>
</linearGradient>
</defs>
<path d="M3 22 a11 11 0 0 1 22 0" fill="none" stroke="url(#au)" stroke-width="3" stroke-linecap="round"/>
<path d="M7.5 22 a6.5 6.5 0 0 1 13 0" fill="none" stroke="url(#au)" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<circle cx="14" cy="22" r="1.8" fill="#fb7185"/>
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#eef1f8">Aurora</text>
</svg>

After

Width:  |  Height:  |  Size: 762 B

@@ -0,0 +1,14 @@
/* Aurora — a dark brand. The same shared header, footer, profile and billing pages render dark
purely because every token below flips; not one component is overridden. Rose primary, sky accent. */
:root {
--c-canvas: #0a0f1f;
--c-surface: #121829;
--c-ink: #eef1f8;
--c-muted: #9aa3bd;
--c-line: #232b42;
--c-brand: #fb7185;
--c-on-brand: #1a1022;
--c-accent: #38bdf8;
--c-radius: 1rem;
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@@ -0,0 +1,4 @@
// Aurora has no bootstrap logic of its own — it reuses the base layer's entry.
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
import '@/main.ts'
@@ -0,0 +1,67 @@
<script setup lang="ts">
// Aurora's landing — its own dark, neon take. Same shared shell around it; only this page, the
// logo and the theme tokens are Aurora's.
const pillars = [
{ kbd: '01', title: 'Realtime by default', body: 'Streams, presence and sync primitives baked into the runtime.' },
{ kbd: '02', title: 'Edge native', body: 'Deploy to 30+ regions and serve from the closest one automatically.' },
{ kbd: '03', title: 'Zero-config scale', body: 'From first request to a million — no capacity planning, ever.' },
]
const metrics = ['30+ regions', '4ms cold start', '∞ concurrency', '24/7 support']
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div
class="pointer-events-none absolute -top-40 left-1/2 h-96 w-[42rem] -translate-x-1/2 rounded-full bg-linear-to-br from-brand to-accent opacity-25 blur-3xl"
aria-hidden="true"
/>
<div class="mx-auto max-w-3xl px-6 pt-24 pb-12 text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs font-medium text-muted">
<span class="h-1.5 w-1.5 rounded-full bg-brand" /> Aurora 2.0 is live
</span>
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink sm:text-6xl">
Ship at the speed of
<span class="bg-linear-to-br from-brand to-accent bg-clip-text text-transparent">light</span>
</h1>
<p class="mx-auto mt-5 max-w-xl text-balance text-lg text-muted">
Aurora is the realtime cloud for builders who refuse to wait. Push code, watch it go global in seconds.
</p>
<div class="mt-8 flex items-center justify-center gap-3">
<a href="#/billing" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
Launch a project
</a>
<a href="#/profile" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:opacity-90">
View console
</a>
</div>
</div>
<!-- Metric marquee -->
<div class="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-x-8 gap-y-2 px-6 pb-16 text-sm text-muted">
<span v-for="m in metrics" :key="m" class="flex items-center gap-2">
<span class="h-1 w-1 rounded-full bg-accent" /> {{ m }}
</span>
</div>
</section>
<!-- Pillars -->
<section class="mx-auto max-w-5xl px-6 pb-24">
<div class="grid gap-5 sm:grid-cols-3">
<article v-for="p in pillars" :key="p.kbd" class="rounded-card border border-line bg-surface p-6">
<span class="font-mono text-sm text-brand">{{ p.kbd }}</span>
<h3 class="mt-3 text-lg font-semibold text-ink">{{ p.title }}</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">{{ p.body }}</p>
</article>
</div>
<div class="mt-10 overflow-hidden rounded-card border border-line bg-linear-to-br from-brand/15 to-accent/10 px-8 py-14 text-center">
<h2 class="text-balance text-3xl font-semibold tracking-tight text-ink">Your next deploy is one command away</h2>
<a href="#/billing" class="mt-7 inline-block rounded-full bg-brand px-6 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
Start building
</a>
</div>
</section>
</div>
</template>
@@ -0,0 +1,3 @@
{
"extends": "./.vite-layers/tsconfig.json"
}
@@ -0,0 +1,3 @@
import { buildViteConfig } from '../../../src/index.ts'
export default buildViteConfig(import.meta.dirname)
@@ -1,5 +1,8 @@
import { defineLayerConfig } from '../../../src/index.ts' import { defineLayerConfig } from '../../../src/index.ts'
// Northwind — a reseller brand. It inherits the entire shell (header, footer, profile, router,
// Tailwind setup) from `main` and changes only three things: its logo, its theme.css tokens and
// its Landing page. It also drops the billing page entirely.
export default defineLayerConfig({ export default defineLayerConfig({
name: 'brand', name: 'brand',
extends: ['../main'], extends: ['../main'],
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-layers — brand</title> <title>Northwind — Cloud Platform</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+11 -1
View File
@@ -1 +1,11 @@
BRAND_LOGO_OVERRIDE <svg xmlns="http://www.w3.org/2000/svg" width="148" height="28" viewBox="0 0 148 28" fill="none" role="img" aria-label="Northwind">
<defs>
<linearGradient id="nw" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#10b981"/>
<stop offset="1" stop-color="#0ea5e9"/>
</linearGradient>
</defs>
<circle cx="14" cy="14" r="12" fill="url(#nw)"/>
<path d="M14 6 L17 17 L14 14.5 L11 17 Z" fill="#fff"/>
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#0b231b">Northwind</text>
</svg>

Before

Width:  |  Height:  |  Size: 19 B

After

Width:  |  Height:  |  Size: 590 B

@@ -0,0 +1,14 @@
/* Northwind — a reseller brand. Light, emerald→sky, with rounder corners. This file is the only
styling difference from Acme: it shadows main/src/assets/theme.css through the layer resolver. */
:root {
--c-canvas: #f6fbf8;
--c-surface: #ffffff;
--c-ink: #0b231b;
--c-muted: #5b7a6e;
--c-line: #dcebe3;
--c-brand: #10b981;
--c-on-brand: #ffffff;
--c-accent: #0ea5e9;
--c-radius: 1.25rem;
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@@ -1,9 +0,0 @@
<script setup lang="ts">
const title = 'BRAND_HEADER_OVERRIDE'
const p2p = __FEATURES__.p2p
</script>
<template>
<header>{{ title }}</header>
<p v-if="p2p">p2p</p>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
// Northwind's landing — overrides main/src/pages/Landing.vue via the layer resolver. Its own copy,
// layout and imagery; the surrounding header/footer/profile stay shared with every other brand.
const points = [
'Bring your own cloud — deploy into your existing accounts.',
'Granular roles and SSO included on every tier.',
'Usage-based pricing that scales down as well as up.',
'Migrate from anywhere with first-party importers.',
]
const regions = ['us-east', 'eu-west', 'ap-south', 'sa-east']
</script>
<template>
<div>
<!-- Split hero -->
<section class="mx-auto grid max-w-6xl items-center gap-12 px-6 pt-16 pb-20 lg:grid-cols-2">
<div>
<span class="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs font-medium text-muted">
<span class="h-1.5 w-1.5 rounded-full bg-brand" /> Trusted by 2,400+ teams
</span>
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink">
Infrastructure that feels like home
</h1>
<p class="mt-5 max-w-lg text-balance text-lg text-muted">
Northwind runs your apps where your data already lives. No lock-in, no surprises just a calmer way to operate.
</p>
<div class="mt-8 flex items-center gap-3">
<a href="#/profile" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
Open dashboard
</a>
<a href="#/" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:bg-ink/4">
Book a demo
</a>
</div>
</div>
<!-- Product preview card -->
<div class="rounded-card border border-line bg-surface p-2 shadow-xl shadow-brand/5">
<div class="rounded-[calc(var(--c-radius)-0.5rem)] bg-linear-to-br from-brand to-accent p-6 text-on-brand">
<p class="text-sm opacity-80">Cluster health</p>
<p class="mt-1 text-3xl font-semibold">All systems nominal</p>
<div class="mt-6 grid grid-cols-4 gap-2">
<div v-for="r in regions" :key="r" class="rounded-xl bg-on-brand/15 px-2 py-3 text-center backdrop-blur">
<p class="text-[11px] uppercase tracking-wide opacity-80">{{ r }}</p>
<p class="mt-1 text-sm font-semibold">99.9%</p>
</div>
</div>
</div>
<div class="flex items-center justify-between px-4 py-3">
<p class="text-sm text-muted">Last deploy</p>
<p class="text-sm font-medium text-ink">2 minutes ago</p>
</div>
</div>
</section>
<!-- Value checklist -->
<section class="border-y border-line bg-surface">
<div class="mx-auto max-w-6xl px-6 py-16">
<h2 class="max-w-xl text-balance text-3xl font-semibold tracking-tight text-ink">Built for teams that own their stack</h2>
<ul class="mt-8 grid gap-4 sm:grid-cols-2">
<li v-for="p in points" :key="p" class="flex items-start gap-3 rounded-card border border-line bg-canvas p-4">
<span class="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-brand/10 text-brand">
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
</span>
<span class="text-sm text-ink">{{ p }}</span>
</li>
</ul>
</div>
</section>
<!-- CTA -->
<section class="mx-auto max-w-6xl px-6 py-20 text-center">
<h2 class="text-balance text-3xl font-semibold tracking-tight text-ink">Move in this week</h2>
<p class="mx-auto mt-3 max-w-md text-balance text-muted">Import your first workload and we'll match your current bill for 90 days.</p>
<a href="#/profile" class="mt-7 inline-block rounded-full bg-brand px-6 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
Get started free
</a>
</section>
</div>
</template>
+19 -8
View File
@@ -1,18 +1,29 @@
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwind from '@tailwindcss/vite'
import { DevTools } from '@vitejs/devtools'
import { defineLayerConfig } from '../../../src/index.ts' import { defineLayerConfig } from '../../../src/index.ts'
export default defineLayerConfig({ export default defineLayerConfig({
name: 'main', name: 'main',
features: { billing: true, p2p: true }, // `billing` gates a whole page (build-time DCE); `betaBanner` toggles a UI accent and is
// The framework plugin lives in the layer config, not in vite-layers' core. // turned off in production via the `$production` env override below.
vite: { features: { billing: true, betaBanner: true },
plugins: [vue()],
// Framework + CSS plugins live in the layer config, not in vite-layers' core. Brands inherit them.
// The Vite DevTools hub is added in dev only (`DevTools()` is async → a Promise<Plugin[]>, a valid
// plugin entry); vite-layers auto-mounts its Layers / Features / Resolver / Public & TS panels into
// it via `buildViteConfig`. Build output is untouched (the hub is excluded when command is 'build').
vite: ({ command }) => ({
plugins: [vue(), tailwind(), command === 'serve' && DevTools()],
build: { rolldownOptions: { input: { main: '@/main.ts' } } }, build: { rolldownOptions: { input: { main: '@/main.ts' } } },
}, }),
// Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig). // Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig).
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue' } }, // `vite/client` supplies ambient module decls for the CSS side-effect imports (`*.css`) and
// `import.meta.env`. It flows into every brand's generated app tsconfig too.
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue', types: ['vite/client'] } },
$production: { $production: {
features: { p2p: false }, features: { betaBanner: false }, // the beta accent never ships to prod
} },
}) })
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-layers — main</title> <title>Acme — Cloud Platform</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -1 +1,12 @@
SHARED_FAVICON <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<!-- Shared favicon: inherited by every brand (only logo.svg is overridden per brand). -->
<defs>
<linearGradient id="fav" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#fav)"/>
<rect x="8" y="8" width="11" height="11" rx="3" fill="#fff" opacity="0.55"/>
<rect x="13" y="13" width="11" height="11" rx="3" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 14 B

After

Width:  |  Height:  |  Size: 588 B

+12 -1
View File
@@ -1 +1,12 @@
MAIN_LOGO_SVG <svg xmlns="http://www.w3.org/2000/svg" width="112" height="28" viewBox="0 0 112 28" fill="none" role="img" aria-label="Acme">
<defs>
<linearGradient id="acme" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect x="2" y="4" width="13" height="13" rx="4" fill="url(#acme)" opacity="0.35"/>
<rect x="5.5" y="7.5" width="13" height="13" rx="4" fill="url(#acme)" opacity="0.65"/>
<rect x="9" y="11" width="13" height="13" rx="4" fill="url(#acme)"/>
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#18181b">Acme</text>
</svg>

Before

Width:  |  Height:  |  Size: 13 B

After

Width:  |  Height:  |  Size: 719 B

+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useRoute } from '@/router'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
// The whole shell — header, footer, routed page — is shared by every brand. Brands change only
// their logo, their theme.css tokens, and their Landing page.
const { current } = useRoute()
</script>
<template>
<div class="flex min-h-dvh flex-col bg-canvas font-sans text-ink antialiased">
<AppHeader />
<main class="flex-1">
<component :is="current" v-if="current" />
</main>
<AppFooter />
</div>
</template>
@@ -0,0 +1,15 @@
/* Acme — the base brand. Light, indigo→violet. Every other brand ships its own copy of this
file at the same path; the layer resolver picks the highest-priority one, recoloring the
shared header, footer, profile and billing pages without touching a single component. */
:root {
--c-canvas: #fbfbfd;
--c-surface: #ffffff;
--c-ink: #18181b;
--c-muted: #6b7280;
--c-line: #ececf1;
--c-brand: #6366f1;
--c-on-brand: #ffffff;
--c-accent: #a855f7;
--c-radius: 0.875rem;
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@@ -0,0 +1,49 @@
<script setup lang="ts">
// Shared by every brand — identical structure, only recolored by the active theme.css.
const columns = [
{ title: 'Product', links: ['Overview', 'Features', 'Pricing', 'Changelog'] },
{ title: 'Company', links: ['About', 'Careers', 'Blog', 'Contact'] },
{ title: 'Resources', links: ['Docs', 'Guides', 'API', 'Status'] },
{ title: 'Legal', links: ['Privacy', 'Terms', 'Security', 'Cookies'] },
]
const year = new Date().getFullYear()
// Layered public/ asset — bind so it stays a runtime URL (see AppHeader for the why).
const logo = '/logo.svg'
</script>
<template>
<footer class="border-t border-line bg-surface">
<div class="mx-auto max-w-6xl px-6 py-14">
<div class="grid gap-10 md:grid-cols-[1.4fr_repeat(4,1fr)]">
<div class="max-w-xs">
<img :src="logo" alt="Logo" class="h-7 w-auto" />
<p class="mt-4 text-sm leading-relaxed text-muted">
The platform layer your whole organization builds on. Ship faster, on infrastructure you trust.
</p>
</div>
<div v-for="col in columns" :key="col.title">
<h3 class="text-sm font-semibold text-ink">{{ col.title }}</h3>
<ul class="mt-3 space-y-2.5">
<li v-for="link in col.links" :key="link">
<a href="#/" class="text-sm text-muted transition-colors hover:text-ink">{{ link }}</a>
</li>
</ul>
</div>
</div>
<div class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-line pt-6 sm:flex-row">
<p class="text-sm text-muted">&copy; {{ year }} built on vite-layers.</p>
<div class="flex items-center gap-2">
<span
v-for="dot in 3"
:key="dot"
class="h-8 w-8 rounded-full border border-line bg-canvas"
aria-hidden="true"
/>
</div>
</div>
</div>
</footer>
</template>
@@ -1,7 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
const title = 'MAIN_HEADER' import { feature } from '#feature'
import { routes, useRoute } from '@/router'
// One header for every brand. The only thing that differs per brand is `/logo.svg` (a layered
// public/ asset) and the theme tokens behind the utility classes.
const { currentPath } = useRoute()
// Read the build-time flag in <script> (a compiled `_ctx.feature` in a template is not a macro
// call); use the local in the template. Folds to a literal, so the pill is DCE'd out in production.
const beta = feature('betaBanner')
// `/logo.svg` is a layered public/ asset served at runtime — bind it (not a static `src`) so the
// Vue compiler keeps it a URL instead of trying to resolve it as a module at build time.
const logo = '/logo.svg'
</script> </script>
<template> <template>
<header>{{ title }}</header> <header class="sticky top-0 z-20 border-b border-line bg-surface/80 backdrop-blur">
<div class="mx-auto flex h-16 max-w-6xl items-center gap-5 px-6">
<a href="#/" class="flex shrink-0 items-center gap-2">
<img :src="logo" alt="Logo" class="h-7 w-auto" />
</a>
<span
v-if="beta"
class="hidden rounded-full border border-brand/30 bg-brand/10 px-2 py-0.5 text-xs font-semibold tracking-wide text-brand sm:inline"
>
BETA
</span>
<nav class="ml-2 hidden items-center gap-1 md:flex">
<a
v-for="r in routes"
:key="r.path"
:href="`#${r.path}`"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
:class="currentPath === r.path ? 'bg-ink/6 text-ink' : 'text-muted hover:text-ink'"
>
{{ r.label }}
</a>
</nav>
<div class="ml-auto flex items-center gap-3">
<a href="#/profile" class="hidden text-sm font-medium text-muted transition-colors hover:text-ink sm:block">
Sign in
</a>
<a
href="#/"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90"
>
Get started
</a>
</div>
</div>
</header>
</template> </template>
+8 -25
View File
@@ -1,27 +1,10 @@
import { createApp, defineAsyncComponent, h, shallowRef, type Component } from 'vue' import { createApp } from 'vue'
const AppHeader = defineAsyncComponent(() => import('@/components/AppHeader.vue')) import App from '@/App.vue'
// `__FEATURES__` is typed by the generated `.vite-layers/features.d.ts` — no manual `declare` needed. // `@/style.css` (Tailwind entry) lives only in the base layer, so every brand shares it.
// `@/theme.css` is layer-resolved: each brand ships its own token file that shadows the base's,
// recoloring the whole shared UI. Imported after style.css so its :root vars win the cascade.
import '@/style.css'
import '@/assets/theme.css'
// Pages are gated on build-time feature flags: a disabled page's dynamic import() is createApp(App).mount('#app')
// statically dead, so its chunk is never emitted (per-brand dead-code elimination).
const routes = [
{ path: '/', component: () => import('@/pages/Home.vue') },
...(__FEATURES__.billing
? [{ path: '/billing', component: () => import('@/pages/Billing.vue') }]
: []),
]
// A tiny hash router so `routes` (and thus the gated import) is actually reachable.
const current = shallowRef<Component | null>(null)
async function navigate() {
const path = location.hash.slice(1) || '/'
const route = routes.find(r => r.path === path) ?? routes[0]
current.value = route ? ((await route.component()).default as Component) : null
}
window.addEventListener('hashchange', navigate)
void navigate()
createApp({
render: () => h('div', [h(AppHeader), current.value ? h(current.value) : null]),
}).mount('#app')
@@ -1,3 +1,71 @@
<script setup lang="ts">
// BILLING_PAGE_HEAVY_MARKER — grep the build output for this string: it appears in Acme's and
// Aurora's bundles (billing: true) but NOT in Northwind's, whose `billing: false` makes the gated
// import() in router.ts statically dead, so this whole chunk is dead-code-eliminated.
const invoices = [
{ id: 'INV-2048', date: 'Jun 01, 2026', amount: '$240.00', status: 'Paid' },
{ id: 'INV-2031', date: 'May 01, 2026', amount: '$240.00', status: 'Paid' },
{ id: 'INV-2014', date: 'Apr 01, 2026', amount: '$180.00', status: 'Paid' },
]
const usage = [
{ label: 'Seats', used: 18, total: 25 },
{ label: 'Projects', used: 42, total: 50 },
{ label: 'Storage', used: 312, total: 500, unit: 'GB' },
]
</script>
<template> <template>
<main>BILLING_PAGE_HEAVY_MARKER</main> <div class="mx-auto max-w-3xl px-6 py-12">
<h1 class="text-2xl font-semibold tracking-tight text-ink">Billing</h1>
<p class="mt-1 text-sm text-muted">Manage your plan, usage, and invoices.</p>
<section class="mt-8 overflow-hidden rounded-card border border-line bg-surface">
<div class="flex items-center justify-between gap-4 bg-linear-to-br from-brand to-accent p-6 text-on-brand">
<div>
<p class="text-sm/none opacity-80">Current plan</p>
<p class="mt-1 text-2xl font-semibold">Team $240/mo</p>
</div>
<button class="rounded-full bg-on-brand/15 px-4 py-2 text-sm font-semibold backdrop-blur transition hover:bg-on-brand/25">
Upgrade
</button>
</div>
<div class="grid gap-6 p-6 sm:grid-cols-3">
<div v-for="u in usage" :key="u.label">
<div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-ink">{{ u.label }}</span>
<span class="text-xs text-muted">{{ u.used }}{{ u.unit ? '' : '' }} / {{ u.total }} {{ u.unit }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-ink/10">
<div class="h-full rounded-full bg-brand" :style="{ width: `${(u.used / u.total) * 100}%` }" />
</div>
</div>
</div>
</section>
<section class="mt-6 rounded-card border border-line bg-surface">
<h2 class="border-b border-line px-6 py-4 text-base font-semibold text-ink">Invoices</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-left text-muted">
<th class="px-6 py-3 font-medium">Invoice</th>
<th class="px-6 py-3 font-medium">Date</th>
<th class="px-6 py-3 font-medium">Amount</th>
<th class="px-6 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="inv in invoices" :key="inv.id">
<td class="px-6 py-3 font-medium text-ink">{{ inv.id }}</td>
<td class="px-6 py-3 text-muted">{{ inv.date }}</td>
<td class="px-6 py-3 text-ink">{{ inv.amount }}</td>
<td class="px-6 py-3">
<span class="rounded-full bg-brand/10 px-2.5 py-0.5 text-xs font-semibold text-brand">{{ inv.status }}</span>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template> </template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
// Acme's landing — the base version. Each brand ships its own Landing.vue at this path; the layer
// resolver picks the highest-priority one, so the marketing page is fully brand-owned while the
// header, footer and profile around it stay shared.
const features = [
{ title: 'Layered by design', body: 'Compose every surface from shared layers. Override only what a brand needs.' },
{ title: 'Ship in minutes', body: 'Push to production on infrastructure that scales from prototype to fleet.' },
{ title: 'Observability built in', body: 'Traces, logs and metrics on by default — no agents to wire up.' },
{ title: 'Secure by default', body: 'SSO, audit logs and fine-grained roles included in every plan.' },
]
const stats = [
{ value: '99.99%', label: 'Uptime SLA' },
{ value: '120ms', label: 'p95 latency' },
{ value: '8,000+', label: 'Teams' },
]
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div
class="pointer-events-none absolute inset-x-0 -top-32 mx-auto h-72 max-w-4xl rounded-full bg-linear-to-br from-brand to-accent opacity-20 blur-3xl"
aria-hidden="true"
/>
<div class="mx-auto max-w-3xl px-6 pt-20 pb-16 text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs font-medium text-muted">
<span class="h-1.5 w-1.5 rounded-full bg-brand" /> Now with edge deployments
</span>
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink sm:text-6xl">
The cloud platform your team grows into
</h1>
<p class="mx-auto mt-5 max-w-xl text-balance text-lg text-muted">
Acme gives you the building blocks to launch, scale and operate modern apps without stitching together a dozen tools.
</p>
<div class="mt-8 flex items-center justify-center gap-3">
<a href="#/" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
Start for free
</a>
<a href="#/billing" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:bg-ink/4">
See pricing
</a>
</div>
</div>
</section>
<!-- Stats -->
<section class="mx-auto max-w-5xl px-6">
<div class="grid grid-cols-3 gap-4 rounded-card border border-line bg-surface p-8">
<div v-for="s in stats" :key="s.label" class="text-center">
<p class="text-3xl font-semibold tracking-tight text-ink">{{ s.value }}</p>
<p class="mt-1 text-sm text-muted">{{ s.label }}</p>
</div>
</div>
</section>
<!-- Features -->
<section class="mx-auto max-w-5xl px-6 py-20">
<h2 class="max-w-2xl text-balance text-3xl font-semibold tracking-tight text-ink">
Everything you need, nothing you don't
</h2>
<div class="mt-10 grid gap-5 sm:grid-cols-2">
<article v-for="f in features" :key="f.title" class="rounded-card border border-line bg-surface p-6">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-brand/10 text-brand">
<span class="h-2.5 w-2.5 rounded-full bg-brand" />
</div>
<h3 class="mt-4 text-lg font-semibold text-ink">{{ f.title }}</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">{{ f.body }}</p>
</article>
</div>
</section>
<!-- CTA band -->
<section class="mx-auto max-w-5xl px-6 pb-24">
<div class="overflow-hidden rounded-card bg-linear-to-br from-brand to-accent px-8 py-14 text-center text-on-brand">
<h2 class="text-balance text-3xl font-semibold tracking-tight">Ready to build on Acme?</h2>
<p class="mx-auto mt-3 max-w-md text-balance opacity-90">Spin up your first project in under five minutes. No card required.</p>
<a href="#/profile" class="mt-7 inline-block rounded-full bg-on-brand px-6 py-2.5 text-sm font-semibold text-brand transition hover:opacity-90">
Create your workspace
</a>
</div>
</section>
</div>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { reactive } from 'vue'
// This page is identical for every brand — it lives only in the base layer and is inherited as-is.
// It still adopts each brand's colors and corner radius because it's styled entirely with theme
// tokens, so the same component renders light on Acme/Northwind and dark on Aurora.
const form = reactive({
name: 'Robin Avery',
email: 'robin@example.com',
timezone: 'Europe/Berlin',
role: 'Owner',
})
const timezones = ['Europe/Berlin', 'Europe/London', 'America/New_York', 'Asia/Tokyo']
const notifications = reactive([
{ id: 'product', label: 'Product updates', desc: 'New features and improvements.', on: true },
{ id: 'security', label: 'Security alerts', desc: 'Sign-ins and credential changes.', on: true },
{ id: 'billing', label: 'Billing receipts', desc: 'Invoices and payment notices.', on: false },
])
const initials = 'RA'
</script>
<template>
<div class="mx-auto max-w-3xl px-6 py-12">
<header class="flex items-center gap-4">
<div
class="grid h-16 w-16 place-items-center rounded-card bg-linear-to-br from-brand to-accent text-xl font-semibold text-on-brand"
>
{{ initials }}
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ form.name }}</h1>
<p class="text-sm text-muted">{{ form.email }}</p>
</div>
<span
class="ml-auto rounded-full border border-brand/30 bg-brand/10 px-3 py-1 text-xs font-semibold text-brand"
>
{{ form.role }}
</span>
</header>
<section class="mt-8 rounded-card border border-line bg-surface p-6">
<h2 class="text-base font-semibold text-ink">Account details</h2>
<p class="mt-1 text-sm text-muted">Update your personal information and workspace defaults.</p>
<div class="mt-6 grid gap-5 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-medium text-ink">Display name</span>
<input
v-model="form.name"
type="text"
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
/>
</label>
<label class="block">
<span class="text-sm font-medium text-ink">Email</span>
<input
v-model="form.email"
type="email"
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
/>
</label>
<label class="block sm:col-span-2">
<span class="text-sm font-medium text-ink">Timezone</span>
<select
v-model="form.timezone"
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
>
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
</select>
</label>
</div>
</section>
<section class="mt-6 rounded-card border border-line bg-surface p-6">
<h2 class="text-base font-semibold text-ink">Notifications</h2>
<ul class="mt-4 divide-y divide-line">
<li v-for="n in notifications" :key="n.id" class="flex items-center justify-between gap-4 py-4">
<div>
<p class="text-sm font-medium text-ink">{{ n.label }}</p>
<p class="text-sm text-muted">{{ n.desc }}</p>
</div>
<button
type="button"
role="switch"
:aria-checked="n.on"
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
:class="n.on ? 'bg-brand' : 'bg-ink/15'"
@click="n.on = !n.on"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-surface shadow transition-all"
:class="n.on ? 'left-5.5' : 'left-0.5'"
/>
</button>
</li>
</ul>
</section>
<div class="mt-6 flex items-center justify-end gap-3">
<button type="button" class="rounded-full px-4 py-2 text-sm font-medium text-muted transition hover:text-ink">
Cancel
</button>
<button
type="button"
class="rounded-full bg-brand px-5 py-2 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90"
>
Save changes
</button>
</div>
</div>
</template>
@@ -0,0 +1,41 @@
import { markRaw, shallowRef, type Component } from 'vue'
import { feature } from '#feature'
export interface AppRoute {
path: string
label: string
component: () => Promise<{ default: Component }>
}
// Pages gated on build-time feature flags via the `feature()` macro (typed by the generated
// `.vite-layers/features.d.ts`). A disabled page's dynamic import() is statically dead — its branch
// folds away and the chunk is never emitted (per-brand dead-code elimination), in dev and build alike.
export const routes: AppRoute[] = [
{ path: '/', label: 'Overview', component: () => import('@/pages/Landing.vue') },
{ path: '/profile', label: 'Profile', component: () => import('@/pages/Profile.vue') },
...(feature('billing')
? [{ path: '/billing', label: 'Billing', component: () => import('@/pages/Billing.vue') }]
: []),
]
const current = shallowRef<Component | null>(null)
const currentPath = shallowRef('/')
async function navigate() {
const path = location.hash.slice(1) || '/'
const route = routes.find(r => r.path === path) ?? routes[0]!
currentPath.value = route.path
current.value = markRaw((await route.component()).default)
}
let started = false
/** Minimal hash router shared by every brand — keeps the demo dependency-free. */
export function useRoute() {
if (!started) {
started = true
window.addEventListener('hashchange', navigate)
void navigate()
}
return { current, currentPath }
}
@@ -0,0 +1,23 @@
@import "tailwindcss" source(none);
/* Scan every layer's source for class names. `@source` is relative to this file, which always
resolves to apps/main/src/style.css (it lives only in the base layer), so `../../` is the
apps/ directory — i.e. main + every brand. CWD-independent, unlike Tailwind's auto-detection. */
@source "../../";
/* Map Tailwind's design tokens onto runtime CSS variables. `inline` makes each utility reference
the variable directly — e.g. `bg-brand` becomes `background-color: var(--c-brand)` — so a brand's
theme.css (loaded after this file through the layer resolver) restyles the entire shared UI with
no change to a single component. The --c-* values are defined per brand in `@/theme.css`. */
@theme inline {
--color-canvas: var(--c-canvas);
--color-surface: var(--c-surface);
--color-ink: var(--c-ink);
--color-muted: var(--c-muted);
--color-line: var(--c-line);
--color-brand: var(--c-brand);
--color-on-brand: var(--c-on-brand);
--color-accent: var(--c-accent);
--radius-card: var(--c-radius);
--font-sans: var(--c-font);
}
+42 -3
View File
@@ -8,35 +8,74 @@
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts",
"./feature": "./src/feature.ts",
"./devtools": "./src/devtools.ts"
}, },
"bin": { "bin": {
"vite-layers": "./bin/vite-layers.mjs" "vite-layers": "./bin/vite-layers.mjs"
}, },
"files": [
"dist",
"bin"
],
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./feature": {
"types": "./dist/feature.d.ts",
"import": "./dist/feature.js"
},
"./devtools": {
"types": "./dist/devtools.d.ts",
"import": "./dist/devtools.js"
}
}
},
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"example:build": "vite build example/apps/main && vite build example/apps/brand", "build": "tsdown",
"example:check": "node bin/vite-layers.mjs prepare example/apps/main && node bin/vite-layers.mjs prepare example/apps/brand && vue-tsc --noEmit -p example/apps/main && vue-tsc --noEmit -p example/apps/main/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/brand && vue-tsc --noEmit -p example/apps/brand/.vite-layers/tsconfig.node.json" "prepack": "tsdown",
"example:build": "vite build example/apps/main && vite build example/apps/brand && vite build example/apps/aurora",
"example:main": "vite example/apps/main",
"example:brand": "vite example/apps/brand",
"example:aurora": "vite example/apps/aurora",
"example:check": "node bin/vite-layers.mjs prepare example/apps/main && node bin/vite-layers.mjs prepare example/apps/brand && node bin/vite-layers.mjs prepare example/apps/aurora && vue-tsc --noEmit -p example/apps/main && vue-tsc --noEmit -p example/apps/main/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/brand && vue-tsc --noEmit -p example/apps/brand/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/aurora && vue-tsc --noEmit -p example/apps/aurora/.vite-layers/tsconfig.node.json"
}, },
"peerDependencies": { "peerDependencies": {
"@vitejs/devtools-kit": "^0.3.0",
"vite": "^8.0.0" "vite": "^8.0.0"
}, },
"peerDependenciesMeta": {
"@vitejs/devtools-kit": {
"optional": true
}
},
"dependencies": { "dependencies": {
"c12": "^3.3.4", "c12": "^3.3.4",
"defu": "^6.1.4", "defu": "^6.1.4",
"hookable": "^6.1.1", "hookable": "^6.1.1",
"jiti": "^2.4.0", "jiti": "^2.4.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"oxc-parser": "^0.137.0",
"pkg-types": "^2.3.1", "pkg-types": "^2.3.1",
"sirv": "^3.0.2", "sirv": "^3.0.2",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"ufo": "^1.6.1" "ufo": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@vitejs/devtools": "0.3.3",
"@vitejs/devtools-kit": "0.3.3",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"tailwindcss": "^4.3.1",
"tsdown": "^0.22.3",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"vite": "^8.0.14", "vite": "^8.0.14",
"vitest": "^4.1.7", "vitest": "^4.1.7",
+1742 -11
View File
File diff suppressed because it is too large Load Diff
+14 -11
View File
@@ -3,18 +3,12 @@ import { loadConfig, type ConfigLayer } from 'c12'
import { createDefu } from 'defu' import { createDefu } from 'defu'
import { glob } from 'tinyglobby' import { glob } from 'tinyglobby'
import { withoutTrailingSlash, withTrailingSlash } from 'ufo' import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
import type { Layer, LayerConfig, LayerStack } from './types' import type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
import { toPosix } from './util'
/** Identity helper for typed `app.config.ts` files. */ /** Identity helper for typed `app.config.ts` files. */
export const defineLayerConfig = (config: LayerConfig): LayerConfig => config export const defineLayerConfig = (config: LayerConfig): LayerConfig => config
/**
* Normalize to forward slashes. c12 returns `cwd` posix-style while node `resolve()` is
* OS-native (backslashes on Windows); paths must be canonicalized before they are compared
* for dedup or emitted into a Vite config (where posix is conventional).
*/
const toPosix = (p: string) => p.replace(/\\/g, '/')
/** /**
* Port of Nuxt's layer merger: arrays are concatenated rather than replaced. * Port of Nuxt's layer merger: arrays are concatenated rather than replaced.
* (See `@nuxt/kit` `loadNuxtConfig`.) * (See `@nuxt/kit` `loadNuxtConfig`.)
@@ -59,6 +53,12 @@ export async function resolveLayerStack(
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source. // 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
const seen = new Set<string>() const seen = new Set<string>()
// Capture the extends DAG as c12 walks it. c12 consumes (strips) the `extends`/`_extends` keys from
// each resolved layer's config — only the project's survive — so the parent→child edges can't be
// reconstructed from the resolved configs afterwards. The `resolve` hook fires once per extend edge
// (incl. nested, diamond, and auto-scanned `_extends`), with `opts.cwd` = the extending layer's dir.
const edges: LayerEdge[] = []
const { config, layers = [] } = await loadConfig<LayerConfig>({ const { config, layers = [] } = await loadConfig<LayerConfig>({
cwd, cwd,
configFile: 'app.config', configFile: 'app.config',
@@ -71,8 +71,11 @@ export async function resolveLayerStack(
packageJson: false, packageJson: false,
globalRc: false, globalRc: false,
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig, merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
resolve(id, opts) { resolve(id, ropts) {
const abs = resolve(opts?.cwd ?? cwd, id) const from = toPosix(withoutTrailingSlash(ropts?.cwd ?? cwd))
const abs = resolve(ropts?.cwd ?? cwd, id)
const to = toPosix(withoutTrailingSlash(abs))
if (to !== from) edges.push({ from, to, source: id }) // skip c12's self-resolution of the root
if (seen.has(abs)) return { config: {}, cwd: abs } if (seen.has(abs)) return { config: {}, cwd: abs }
seen.add(abs) seen.add(abs)
return undefined return undefined
@@ -101,5 +104,5 @@ export async function resolveLayerStack(
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} }) stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
} }
return { merged: config, layers: stack } return { merged: config, layers: stack, edges }
} }
+18 -61
View File
@@ -1,84 +1,41 @@
import { statSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import MagicString from 'magic-string'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { toPosix } from './util'
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'] const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
const toPosix = (p: string) => p.replace(/\\/g, '/')
const existingConfigFiles = (rootDirs: string[]): Set<string> => {
const files = new Set<string>()
for (const dir of rootDirs) {
for (const ext of CONFIG_EXTENSIONS) {
const file = resolve(dir, `app.config${ext}`)
try {
if (statSync(file).isFile()) files.add(toPosix(file))
} catch {
// not present in this layer
}
}
}
return files
}
/** /**
* Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes. * Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes.
* *
* `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file * `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file
* dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the * dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the
* baked `__FEATURES__` `define` and aliases go stale. We watch each resolved layer's config file * `feature()` values baked into transformed modules and the resolved aliases go stale. We watch each
* (including layers outside the project root via `watcher.add`) and call `server.restart()`, which * resolved layer's config file (including layers outside the project root via `watcher.add`) and call
* re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → new `define`. * `server.restart()`, which re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → a new
* `featurePlugin` with the updated flag values.
*/ */
export function configWatchPlugin(rootDirs: string[]): Plugin { export function configWatchPlugin(rootDirs: string[]): Plugin {
return { return {
name: 'vite-layers:config-watch', name: 'vite-layers:config-watch',
apply: 'serve', apply: 'serve',
configureServer(server) { configureServer(server) {
const files = existingConfigFiles(rootDirs) // Every POSSIBLE `app.config.*` path (existing or not), so creating a config in a layer that
if (files.size === 0) return // had none — or deleting one — also restarts, not just edits to configs present at startup.
server.watcher.add([...files]) // ensure extended layers outside the root are watched too const candidates = new Set<string>()
for (const dir of rootDirs) {
for (const ext of CONFIG_EXTENSIONS) candidates.add(toPosix(resolve(dir, `app.config${ext}`)))
}
if (candidates.size === 0) return
// chokidar watches an absent path for creation too, so `add`/`unlink` fire for a config that
// appears/disappears later (incl. layers outside the project root).
server.watcher.add([...candidates])
const onChange = (file: string) => { const onEvent = (file: string) => {
if (!files.has(toPosix(file))) return if (!candidates.has(toPosix(file))) return
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true }) server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
void server.restart() void server.restart()
} }
server.watcher.on('change', onChange) for (const event of ['add', 'change', 'unlink'] as const) server.watcher.on(event, onEvent)
},
}
}
/** Matches a standalone `__FEATURES__` reference (not a `.__FEATURES__` property access). */
const STANDALONE_FEATURES_RE = /(?<![.\w$])__FEATURES__\b/
/**
* Dev-only plugin: make `__FEATURES__` resolve at runtime in the dev server.
*
* Vite 8 / rolldown-vite does **not** inline user `define` into dev-served source modules (only
* `import.meta.env` is special-cased), so `__FEATURES__` would be an undefined global in dev. For
* production, `define` (with DCE) still does the job; here we prepend a module-local
* `const __FEATURES__ = {…}` to each served module that references the global, so feature flags have
* correct values in dev — and pick up edits after a config-change restart (see {@link configWatchPlugin}).
*
* Only standalone references are handled (not `_ctx.__FEATURES__` from Vue templates — same as
* `define`); gate features in `<script>`, not in template expressions.
*/
export function featuresRuntimePlugin(features: Record<string, unknown> = {}): Plugin {
const json = JSON.stringify(features)
return {
name: 'vite-layers:features-runtime',
apply: 'serve',
transform(code, id) {
if (id.includes('/node_modules/') || !STANDALONE_FEATURES_RE.test(code)) return null
// NOTE: rolldown's *native* magic-string (the transform `meta.magicString` in the rolldown
// docs) is NOT surfaced by Vite plugins — `meta` is `{ inMap, moduleType, ssr }` with no
// `magicString` in dev or build. So we use the npm `magic-string` fallback the rolldown docs
// recommend for non-native hosts; it also produces clean cross-platform sourcemaps.
// Prepend on line 1 (keeps line numbers); module-local const shadows the missing global.
const s = new MagicString(code)
s.prepend(`const __FEATURES__=${json};`)
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
}, },
} }
} }
+795
View File
@@ -0,0 +1,795 @@
import { readdirSync, statSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import type { ConfigEnv } from 'vite'
import type {
DevToolsServerCommandInput,
DevToolsViewGroup,
DevToolsViewJsonRender,
JsonRenderElement,
JsonRenderSpec,
PluginWithDevTools,
ViteDevToolsNodeContext,
} from '@vitejs/devtools-kit'
import { flattenFeatures } from './features'
import type { LayeredResolution, ResolveRecord } from './resolve'
import { generateTsConfig, type GenerateTsConfigOptions } from './tsconfig'
import type { LayerStack } from './types'
import { toPosix } from './util'
// ---------------------------------------------------------------------------------------------
// This module imports **only types** from `@vitejs/devtools-kit` — they are erased at emit, so the
// plugin has no runtime dependency on the kit and is fully inert unless the `@vitejs/devtools` hub
// mounts it and calls `setup`. The kit's `defineRpcFunction`/`defineDockEntry`/`defineCommand` are
// pure identity helpers and `register()` accepts plain objects, so we hand-build the (typed) specs.
//
// We import the real kit types (rather than re-declaring a local subset) on purpose: when the hub is
// present it augments `vite`'s `Plugin` with `devtools`, and the real types keep our `setup`
// signature consistent with that augmentation. The kit ships transitively with `@vitejs/devtools`, so
// any project using these panels already has it; type-checking vite-layers needs the kit present
// (an optional peer in the install sense, required in the type-check sense for this raw-source pkg).
// ---------------------------------------------------------------------------------------------
const NS = 'vite-layers'
const GROUP_ID = NS
const PANEL = {
layers: `${NS}:layers`,
features: `${NS}:features`,
resolver: `${NS}:resolver`,
assets: `${NS}:assets`,
} as const
const RPC = {
refresh: `${NS}:refresh`,
resolve: `${NS}:resolve`,
clearLog: `${NS}:clear-log`,
} as const
const ICON = {
group: 'ph:stack-duotone',
layers: 'ph:stack-duotone',
features: 'ph:toggle-right-duotone',
resolver: 'ph:signpost-duotone',
assets: 'ph:images-duotone',
refresh: 'ph:arrows-clockwise-duotone',
} as const
/** Data the devtools panel needs, captured by `buildViteConfig` at config-resolution time. */
export interface LayersDevtoolsData {
/** The app directory (the cwd `resolveLayerStack` was called with). */
appDir: string
/** The Vite env (`command`/`mode`) the config was built for. */
env: ConfigEnv
/** The resolved, in-effect layer stack (after `layers:resolved` hooks). */
stack: LayerStack
/** The live layered resolution — shared with `vite-layers:resolve` so the panel sees real data. */
resolution: LayeredResolution
/** tsconfig generation options, or `false` when autogen is disabled. */
tsconfig: GenerateTsConfigOptions | false
}
// ---------------------------------------------------------------------------------------------
// Snapshot — a plain, serializable view of the stack the spec builders render. Cheap to recompute,
// so `vite-layers:refresh` just rebuilds it (re-walking `public/`, re-reading the resolution log).
// ---------------------------------------------------------------------------------------------
interface LayerRow {
index: number
name: string
project: boolean
extends: string
rootDir: string
srcDir: string
}
interface FeatureRow {
key: string
value: string
type: string
kind: 'leaf' | 'group'
enabled: boolean
}
// These two row shapes are handed to `DataTable` as-is (not re-mapped at the call site), so they
// carry an index signature to satisfy the renderer's `Record<string, unknown>` row type.
interface PublicRow {
path: string
winner: string
shadowedBy: string
[key: string]: unknown
}
interface HookRow {
hook: string
layer: string
[key: string]: unknown
}
type TsconfigInfo =
| { enabled: false }
| { enabled: true; paths: Record<string, string[]>; appJson: string; nodeJson: string; dts: string }
interface Snapshot {
projectName: string
appDir: string
mode: string
command: string
layers: LayerRow[]
mergedTree: Record<string, unknown>
features: FeatureRow[]
rawFeatures: Record<string, unknown>
featureLeafCount: number
featureDisabledCount: number
publicAssets: PublicRow[]
publicLayerCount: number
hooks: HookRow[]
tsconfig: TsconfigInfo
inheritanceTree: string
}
/** Recursively list files under a directory (absolute paths); `[]` if it isn't a directory. */
function walk(dir: string, out: string[] = []): string[] {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return out
}
for (const name of entries) {
const abs = join(dir, name)
// Guard each stat: a broken symlink or a file unlinked between readdir and stat (a real TOCTOU
// window under the dev watcher) throws ENOENT — skip it instead of failing the whole snapshot.
let isDir: boolean
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
if (isDir) walk(abs, out)
else out.push(abs)
}
return out
}
const asArray = (v: unknown): string[] =>
v == null ? [] : (Array.isArray(v) ? v : [v]).map(String)
const TRAILING_SLASHES_RE = /\/+$/
const noTrailing = (p: string) => p.replace(TRAILING_SLASHES_RE, '')
/**
* Render the layer **extends graph** as a box-drawing tree (for a monospace CodeBlock).
*
* The resolved stack is a flat priority order; the *structure* comes from `stack.edges` — the
* parent→child edges captured during resolution (c12 strips the `extends` keys from resolved configs,
* so they can't be read back afterwards). Diamonds (a layer reached via two parents) are drawn once
* and marked `↑ above` on repeat — no infinite recursion. Edges whose target isn't a stack layer (npm
* / git sources) become `(external)` leaves, and any layer never reached from the project is listed
* below so the view stays complete. With no `edges` (a hand-built stack) only the project is drawn.
*/
export function inheritanceTreeText(stack: LayerStack): string {
const { layers, edges = [] } = stack
const byRoot = new Map<string, number>()
layers.forEach((l, i) => byRoot.set(noTrailing(toPosix(l.rootDir)), i))
// Group edges by the (normalized) directory they extend FROM, preserving walk order.
const childEdges = new Map<string, Array<{ index: number } | { external: string }>>()
for (const e of edges) {
const fromKey = noTrailing(toPosix(e.from))
const idx = byRoot.get(noTrailing(toPosix(e.to)))
const list = childEdges.get(fromKey) ?? childEdges.set(fromKey, []).get(fromKey)!
list.push(idx !== undefined ? { index: idx } : { external: e.source })
}
const childrenOf = (i: number) => childEdges.get(noTrailing(toPosix(layers[i]!.rootDir))) ?? []
const lines: string[] = []
const seen = new Set<number>()
const render = (i: number, prefix: string, isLast: boolean, isRoot: boolean) => {
const connector = isRoot ? '' : isLast ? '└── ' : '├── '
const repeated = seen.has(i)
const tag = isRoot ? ' (project · highest priority)' : ''
lines.push(`${prefix}${connector}${layers[i]!.name} #${i}${tag}${repeated ? ' ↑ above' : ''}`)
if (repeated) return
seen.add(i)
const kids = childrenOf(i)
const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ')
kids.forEach((k, ci) => {
const last = ci === kids.length - 1
if ('index' in k) render(k.index, childPrefix, last, false)
else lines.push(`${childPrefix}${last ? '└── ' : '├── '}${k.external} (external)`)
})
}
render(0, '', true, true)
const orphans = layers.map((_, i) => i).filter(i => !seen.has(i))
if (orphans.length) {
lines.push('')
lines.push('not reached via extends (e.g. auto-scanned layers/*):')
for (const i of orphans) lines.push(`${layers[i]!.name} #${i}`)
}
return lines.join('\n')
}
async function collectSnapshot(data: LayersDevtoolsData): Promise<Snapshot> {
const { layers, merged } = data.stack
const layerRows: LayerRow[] = layers.map((l, index) => ({
index,
name: l.name,
project: index === 0,
extends: asArray(l.config.extends).join(', ') || '—',
rootDir: l.rootDir,
srcDir: l.srcDir,
}))
// Features: every dotted path (groups + leaves). Leaves drive DCE; a falsy leaf is the value the
// `feature()` macro folds to `false`, killing its branch + chunk.
const flat = flattenFeatures((merged.features ?? {}) as Record<string, unknown>)
const features: FeatureRow[] = flat.map(([key, value]) => {
const group = value != null && typeof value === 'object'
return {
key,
value: JSON.stringify(value) ?? String(value),
type: Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value,
kind: group ? 'group' : 'leaf',
enabled: Boolean(value),
}
})
const leaves = features.filter(f => f.kind === 'leaf')
// Public assets: walk each layer's `public/` high→low; the first layer to hold a path wins, the
// rest are shadowed — mirrors `publicLayersPlugin`'s first-match-by-priority resolution.
const publicLayers = layers
.map(l => ({ name: l.name, dir: resolve(l.rootDir, 'public') }))
.filter(p => {
try {
return statSync(p.dir).isDirectory()
} catch {
return false
}
})
const byPath = new Map<string, string[]>()
for (const { name, dir } of publicLayers) {
for (const abs of walk(dir)) {
const rel = toPosix(relative(dir, abs))
;(byPath.get(rel) ?? byPath.set(rel, []).get(rel)!).push(name)
}
}
const publicAssets: PublicRow[] = [...byPath.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, names]) => ({
path,
winner: names[0]!,
shadowedBy: names.slice(1).join(', ') || '—',
}))
// Per-layer lifecycle hooks (base-first display order, like registration).
const hooks: HookRow[] = []
for (const l of [...layers].reverse()) {
for (const hook of Object.keys(l.config.hooks ?? {})) hooks.push({ hook, layer: l.name })
}
// Curated merged view for the Tree — omit `vite` (functions/plugins) and `hooks` (functions),
// which aren't serializable and aren't useful as a tree.
const mergedTree: Record<string, unknown> = {
name: merged.name,
extends: merged.extends,
srcDir: merged.srcDir,
features: merged.features ?? {},
tsConfig: merged.tsConfig,
vite: merged.vite ? '[Vite config fragment — see Vite DevTools]' : undefined,
hooks: hooks.length ? hooks.map(h => `${h.hook} (${h.layer})`) : undefined,
}
let tsconfig: TsconfigInfo = { enabled: false }
if (data.tsconfig !== false) {
const opts = typeof data.tsconfig === 'object' ? data.tsconfig : {}
const gen = await generateTsConfig(data.appDir, { ...opts, stack: data.stack })
tsconfig = {
enabled: true,
paths: (gen.tsconfig.compilerOptions?.paths ?? {}) as Record<string, string[]>,
appJson: JSON.stringify(gen.tsconfig, null, 2),
nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2),
dts: gen.dts,
}
}
return {
projectName: layers[0]?.name ?? 'app',
appDir: data.appDir,
mode: data.env.mode,
command: data.env.command,
layers: layerRows,
mergedTree,
features,
rawFeatures: (merged.features ?? {}) as Record<string, unknown>,
featureLeafCount: leaves.length,
featureDisabledCount: leaves.filter(f => !f.enabled).length,
publicAssets,
publicLayerCount: publicLayers.length,
hooks,
tsconfig,
inheritanceTree: inheritanceTreeText(data.stack),
}
}
/** Which layer (by name) owns a resolved file — found by matching the file against layer `srcDir`s. */
function layerOf(stack: LayerStack, file: string | null): string {
if (!file) return '—'
const f = toPosix(file.split('?')[0]!)
for (const l of stack.layers) {
const src = toPosix(l.srcDir)
if (f === src || f.startsWith(`${src}/`)) return l.name
}
return '?'
}
// ---------------------------------------------------------------------------------------------
// Spec builder — a tiny DSL over the flat `{ root, elements }` json-render shape. Each `add*`
// returns the generated element id, so panels compose by nesting calls.
// ---------------------------------------------------------------------------------------------
interface Column {
key: string
label: string
width?: string
}
class Spec {
private readonly elements: Record<string, JsonRenderElement> = {}
private readonly state: Record<string, unknown> = {}
private n = 0
private add(node: JsonRenderElement): string {
const id = `e${this.n++}`
this.elements[id] = node
return id
}
setState(key: string, value: unknown): this {
this.state[key] = value
return this
}
vstack(children: string[], gap = 12, padding?: number): string {
return this.add({ type: 'Stack', props: { direction: 'vertical', gap, padding }, children })
}
hstack(children: string[], props: Record<string, unknown> = {}): string {
return this.add({ type: 'Stack', props: { direction: 'horizontal', gap: 8, align: 'center', ...props }, children })
}
card(title: string, children: string[], collapsible = false): string {
return this.add({ type: 'Card', props: { title, collapsible }, children })
}
text(content: string, variant?: 'heading' | 'body' | 'caption' | 'code'): string {
return this.add({ type: 'Text', props: { content, variant } })
}
badge(text: string, variant: 'default' | 'info' | 'success' | 'warning' | 'error' = 'default', title?: string): string {
return this.add({ type: 'Badge', props: { text, variant, title } })
}
divider(label?: string): string {
return this.add({ type: 'Divider', props: { label } })
}
kvTable(entries: Array<{ key: string; value: string }>, title?: string): string {
return this.add({ type: 'KeyValueTable', props: { title, entries } })
}
dataTable(columns: Column[], rows: Array<Record<string, unknown>>, maxHeight = '360px'): string {
return this.add({ type: 'DataTable', props: { columns, rows, maxHeight } })
}
tree(data: unknown, expandLevel = 1): string {
return this.add({ type: 'Tree', props: { data, expandLevel } })
}
code(code: string, filename?: string, maxHeight = '320px'): string {
return this.add({ type: 'CodeBlock', props: { code, filename, maxHeight } })
}
button(label: string, action: string, opts: { icon?: string; variant?: string; params?: Record<string, unknown> } = {}): string {
return this.add({
type: 'Button',
props: { label, icon: opts.icon, variant: opts.variant ?? 'secondary' },
on: { press: { action, params: opts.params } },
})
}
textInput(stateKey: string, placeholder: string): string {
return this.add({ type: 'TextInput', props: { placeholder, value: { $bindState: `/${stateKey}` } } })
}
build(root: string): JsonRenderSpec {
return { root, elements: this.elements, state: this.state }
}
}
/** A header row: a heading on the left, a Refresh button on the right. */
function header(s: Spec, title: string, subtitle: string): string {
const left = s.vstack([s.text(title, 'heading'), s.text(subtitle, 'caption')], 2)
const refresh = s.button('Refresh', RPC.refresh, { icon: ICON.refresh })
return s.hstack([left, refresh], { justify: 'space-between' })
}
// ---------------------------------------------------------------------------------------------
// Panels
// ---------------------------------------------------------------------------------------------
function buildLayersSpec(snap: Snapshot): JsonRenderSpec {
const s = new Spec()
const sections: string[] = [
header(s, 'Layers', `${snap.layers.length} layers · ${snap.projectName} · ${snap.command}/${snap.mode}`),
]
// Headline visual: the extends graph drawn as a tree (the structure the flat stack flattens away).
sections.push(
s.card('Inheritance (extends graph)', [
s.code(snap.inheritanceTree, 'extends graph'),
s.text('Reconstructed from each layers extends. Diamonds drawn once (↑ above); external (npm/git) sources marked.', 'caption'),
]),
)
sections.push(
s.card('Layer stack (high → low priority)', [
s.dataTable(
[
{ key: 'index', label: '#', width: '36px' },
{ key: 'name', label: 'Name' },
{ key: 'role', label: 'Role', width: '90px' },
{ key: 'extends', label: 'Extends' },
{ key: 'srcDir', label: 'srcDir' },
],
snap.layers.map(l => ({
index: l.index,
name: l.name,
role: l.project ? 'project' : 'layer',
extends: l.extends,
srcDir: l.srcDir,
})),
),
s.text('layers[0] is the project (highest priority); collisions resolve to the smaller index.', 'caption'),
]),
)
sections.push(s.card('Merged config', [s.tree(prune(snap.mergedTree), 2)], true))
if (snap.hooks.length) {
sections.push(
s.card(
'Lifecycle hooks',
[
s.dataTable(
[
{ key: 'hook', label: 'Hook' },
{ key: 'layer', label: 'Declared by' },
],
snap.hooks,
'200px',
),
s.text('Hooks run serially, base layer first.', 'caption'),
],
true,
),
)
}
return s.build(s.vstack(sections, 14, 12))
}
function buildFeaturesSpec(snap: Snapshot): JsonRenderSpec {
const s = new Spec()
const sections: string[] = [
header(
s,
'Features',
`${snap.featureLeafCount} flags · ${snap.featureDisabledCount} disabled (dead-code eliminated)`,
),
]
if (snap.features.length === 0) {
sections.push(s.text('No feature flags defined in any layer.', 'caption'))
} else {
sections.push(
s.card('Flags (merged, high → low priority)', [
s.dataTable(
[
{ key: 'status', label: '', width: '30px' },
{ key: 'key', label: 'Key' },
{ key: 'value', label: 'Value' },
{ key: 'type', label: 'Type', width: '70px' },
{ key: 'dce', label: 'feature()', width: '150px' },
],
snap.features.map(f => ({
status: f.kind === 'group' ? '▸' : f.enabled ? '●' : '○',
key: f.key,
value: f.value,
type: f.type,
dce:
f.kind === 'group'
? '(group)'
: f.enabled
? 'kept'
: 'branch eliminated',
})),
),
]),
)
sections.push(s.card('Raw feature tree', [s.tree(snap.rawFeatures, 3)], true))
}
sections.push(
s.card(
'About dead-code elimination',
[
s.text(
"feature('key') is replaced by the flag's literal at compile time (dev + build alike). " +
'A disabled flag folds to false, so its branch — and any import() inside it — is statically ' +
'dead and the chunk is never emitted. An unknown key fails the build.',
'caption',
),
],
true,
),
)
return s.build(s.vstack(sections, 14, 12))
}
interface ResolveResult {
id: string
sub: string
query: string
candidates: string[]
error?: string
}
function buildResolverSpec(data: LayersDevtoolsData, query: string, result: ResolveResult | null): JsonRenderSpec {
const s = new Spec()
s.setState('query', query)
const sections: string[] = [
s.vstack([s.text('Resolver', 'heading'), s.text(`Prefixes: ${data.resolution.prefixes.join(' ')}`, 'caption')], 2),
]
// Playground: type a layered id, resolve it across the stack.
const input = s.textInput('query', 'e.g. @/components/AppHeader.vue')
const go = s.button('Resolve', RPC.resolve, { icon: ICON.resolver, variant: 'primary', params: { id: { $state: '/query' } } })
sections.push(s.card('Playground', [s.hstack([input, go]), ...resolveResultEls(s, data, result)]))
// Live log of real @/ ~/ resolutions seen this session.
const records = data.resolution.records()
const logChildren: string[] = [
s.hstack([s.text(`Live resolutions (${records.length})`, 'body'), s.button('Clear', RPC.clearLog, { icon: 'ph:eraser-duotone' })], {
justify: 'space-between',
}),
]
if (records.length === 0) {
logChildren.push(s.text('No layered imports resolved yet — load the app to populate this.', 'caption'))
} else {
logChildren.push(s.dataTable(
[
{ key: 'id', label: 'Import' },
{ key: 'resolves', label: 'Resolves to (layer)', width: '150px' },
{ key: 'via', label: 'Via', width: '120px' },
{ key: 'n', label: '#cand', width: '60px' },
],
records.map(r => ({
id: r.id,
resolves: layerOf(data.stack, r.resolved),
via: recordVia(r),
n: r.candidates.length,
})),
'300px',
))
}
sections.push(s.card('Live log', logChildren))
return s.build(s.vstack(sections, 14, 12))
}
/** Describe how a record resolved: a normal import, a `super()` self-import, or unresolved. */
function recordVia(r: ResolveRecord): string {
if (r.resolved === null) return 'unresolved'
if (r.selfIndex < 0) return 'top match'
return `super() #${r.selfIndex + 1}`
}
function resolveResultEls(s: Spec, data: LayersDevtoolsData, result: ResolveResult | null): string[] {
if (!result) return [s.text('Enter a layered import above and press Resolve.', 'caption')]
if (result.error) return [s.badge(result.error, 'error')]
if (result.candidates.length === 0) {
return [s.badge(`No file matches "${result.id}" in any layer.`, 'warning')]
}
const winner = result.candidates[0]!
return [
s.hstack([s.text('Resolves to', 'caption'), s.badge(layerOf(data.stack, winner), 'success', winner)]),
s.dataTable(
[
{ key: 'pri', label: '#', width: '36px' },
{ key: 'status', label: '', width: '90px' },
{ key: 'layer', label: 'Layer', width: '110px' },
{ key: 'file', label: 'File' },
],
result.candidates.map((file, i) => ({
pri: i,
status: i === 0 ? 'winner' : 'shadowed',
layer: layerOf(data.stack, file),
file,
})),
'240px',
),
s.text('A self-import (an override importing its own path) would super()-skip to the next row down.', 'caption'),
]
}
function buildAssetsSpec(snap: Snapshot): JsonRenderSpec {
const s = new Spec()
const sections: string[] = [
header(s, 'Public & TS', `${snap.publicAssets.length} assets across ${snap.publicLayerCount} public/ dirs`),
]
const publicChildren: string[] =
snap.publicAssets.length === 0
? [s.text('No layer has a public/ directory.', 'caption')]
: [
s.dataTable(
[
{ key: 'path', label: 'Asset' },
{ key: 'winner', label: 'Served from', width: '120px' },
{ key: 'shadowedBy', label: 'Shadows', width: '140px' },
],
snap.publicAssets,
'260px',
),
s.text('Higher-priority layers win; the winner is served in dev and emitted to the build output.', 'caption'),
]
sections.push(s.card('Layered public/ assets', publicChildren))
if (snap.tsconfig.enabled) {
const ts = snap.tsconfig
sections.push(
s.card(
'Generated tsconfig paths',
[
s.kvTable(
Object.entries(ts.paths).map(([key, value]) => ({ key, value: value.join(' • ') })),
),
s.text('@/ and ~/ map to every layer srcDir in priority order — tsc mirrors the runtime resolver.', 'caption'),
],
),
)
sections.push(s.card('.vite-layers/tsconfig.json', [s.code(ts.appJson, 'tsconfig.json')], true))
sections.push(s.card('.vite-layers/tsconfig.node.json', [s.code(ts.nodeJson, 'tsconfig.node.json')], true))
sections.push(s.card('.vite-layers/features.d.ts', [s.code(ts.dts, 'features.d.ts')], true))
} else {
sections.push(s.card('TypeScript', [s.text('tsconfig autogeneration is disabled (tsconfig: false).', 'caption')]))
}
return s.build(s.vstack(sections, 14, 12))
}
/** Drop `undefined` values so the merged-config Tree stays tidy. */
function prune(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v
return out
}
// ---------------------------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------------------------
/** Build an `action`-type RPC definition. `register` takes the plain object (the kit's
* `defineRpcFunction` is identity); the parameter type is a broad conditional, so cast once here. */
type RpcDefinition = Parameters<ViteDevToolsNodeContext['rpc']['register']>[0]
const action = (name: string, handler: (params?: Record<string, unknown>) => void | Promise<void>): RpcDefinition =>
({ name, type: 'action', setup: () => ({ handler }) }) as unknown as RpcDefinition
/**
* Run the layered resolver against a single id, for the playground. Uses `parse` + `candidates`
* directly (not `resolveId`) so a manual query doesn't pollute the live log, and so we can show the
* full candidate stack rather than just the winner.
*/
function runResolve(data: LayersDevtoolsData, rawId: unknown): ResolveResult {
const id = String(rawId ?? '').trim()
const parsed = data.resolution.parse(id)
if (!id) return { id, sub: '', query: '', candidates: [], error: 'Enter an import id.' }
if (!parsed) {
return {
id,
sub: '',
query: '',
candidates: [],
error: `Not a layered id — must start with one of: ${data.resolution.prefixes.join(', ')}`,
}
}
return { id, sub: parsed.sub, query: parsed.query, candidates: data.resolution.candidates(parsed.sub) }
}
/**
* The vite-layers DevTools integration: four json-render panels (Layers, Features, Resolver, Public &
* TS) grouped under a single dock button, plus a refresh command and an init message. Server-rendered
* JSON specs — no client bundle, keeping vite-layers buildless. Inert unless the `@vitejs/devtools`
* hub mounts it; `buildViteConfig` attaches it by default (disable with `devtools: false`).
*/
export function layersDevtoolsPlugin(data: LayersDevtoolsData): PluginWithDevTools {
return {
name: 'vite-layers:devtools',
devtools: {
async setup(ctx: ViteDevToolsNodeContext) {
let snap = await collectSnapshot(data)
let lastQuery = ''
let lastResult: ResolveResult | null = null
const layersUi = ctx.createJsonRenderer(buildLayersSpec(snap))
const featuresUi = ctx.createJsonRenderer(buildFeaturesSpec(snap))
const resolverUi = ctx.createJsonRenderer(buildResolverSpec(data, lastQuery, lastResult))
const assetsUi = ctx.createJsonRenderer(buildAssetsSpec(snap))
// A single dock button collapsing the four panels (orphan-tolerant: if the host doesn't
// render groups, the entries fall back to top-level — no loss of access).
ctx.docks.register({ id: GROUP_ID, type: 'group', title: 'vite-layers', icon: ICON.group, category: 'app' } satisfies DevToolsViewGroup)
const entry = (id: string, title: string, icon: string, ui: typeof layersUi, order: number) => {
// Typed as the full entry interface (not the narrow literal) so the returned handle's
// `update(patch)` accepts base-entry fields like `badge`.
const view: DevToolsViewJsonRender = {
id,
title,
icon,
type: 'json-render',
ui,
groupId: GROUP_ID,
category: 'app',
defaultOrder: order,
}
return ctx.docks.register(view)
}
entry(PANEL.layers, 'Layers', ICON.layers, layersUi, 40)
entry(PANEL.resolver, 'Resolver', ICON.resolver, resolverUi, 30)
entry(PANEL.assets, 'Public & TS', ICON.assets, assetsUi, 20)
const featuresEntry = entry(PANEL.features, 'Features', ICON.features, featuresUi, 35)
const featuresBadge = () => (snap.featureDisabledCount > 0 ? String(snap.featureDisabledCount) : undefined)
featuresEntry.update({ badge: featuresBadge() })
const refresh = async () => {
snap = await collectSnapshot(data)
await Promise.all([
layersUi.updateSpec(buildLayersSpec(snap)),
featuresUi.updateSpec(buildFeaturesSpec(snap)),
assetsUi.updateSpec(buildAssetsSpec(snap)),
resolverUi.updateSpec(buildResolverSpec(data, lastQuery, lastResult)),
])
featuresEntry.update({ badge: featuresBadge() })
}
ctx.rpc.register(action(RPC.refresh, refresh))
ctx.rpc.register(action(RPC.resolve, async (params) => {
lastResult = runResolve(data, params?.id)
lastQuery = lastResult.id
await resolverUi.updateSpec(buildResolverSpec(data, lastQuery, lastResult))
}))
ctx.rpc.register(action(RPC.clearLog, async () => {
data.resolution.clearRecords()
await resolverUi.updateSpec(buildResolverSpec(data, lastQuery, lastResult))
}))
ctx.commands.register({
id: RPC.refresh,
title: 'vite-layers: Refresh panels',
icon: ICON.refresh,
handler: refresh,
} satisfies DevToolsServerCommandInput)
void ctx.messages.add({
id: `${NS}:ready`,
message: `vite-layers: ${snap.layers.length} layers, ${snap.featureLeafCount - snap.featureDisabledCount}/${snap.featureLeafCount} features enabled`,
level: 'info',
category: NS,
})
},
},
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Public entry for the build-time feature macro. Import it as `#feature` (vite-layers
* auto-registers the alias and the tsconfig `paths` entry) or as `vite-layers/feature`:
*
* ```ts
* import { feature } from '#feature'
*
* const routes = [
* { path: '/', component: () => import('@/pages/Home') },
* feature('billing') && { path: '/billing', component: () => import('@/pages/Billing') },
* ].filter(Boolean)
* ```
*
* `feature('billing')` is replaced by the flag's literal value at compile time — **identically in
* dev and build** — so a disabled branch (and any `import()` inside it) is statically dead and is
* dropped from the bundle. The rules below are enforced: a violation fails the build (in dev and
* build alike), it never silently ships.
*
* - the argument must be a string literal: `feature('billing')`, never `feature(name)`;
* - call it directly — no aliasing (`const f = feature`), destructuring, or passing it as a value;
* - the key must exist in the merged `features` (a typo is also a TypeScript error).
*
* Nested flags are addressed with a dotted key: `feature('payments.stripe')`.
*
* This module has no runtime: every call is compiled away. The stub below only throws if a call
* survives — i.e. the vite-layers plugin did not run on this module.
*/
/** Augmented by the generated `.vite-layers/features.d.ts` with the project's flags + literal types. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LayerFeatures {}
type FeatureKey = [keyof LayerFeatures] extends [never] ? string : keyof LayerFeatures
export function feature<K extends FeatureKey>(
key: K,
): K extends keyof LayerFeatures ? LayerFeatures[K] : unknown
export function feature(key: string): unknown {
throw new Error(
`vite-layers: feature(${JSON.stringify(key)}) was not compiled away. ` +
'Make sure the vite-layers plugin is active and call feature() directly with a string-literal key.',
)
}
+479
View File
@@ -0,0 +1,479 @@
import MagicString from 'magic-string'
import { parseSync } from 'oxc-parser'
import type { Plugin } from 'vite'
/**
* The reserved import specifier for the feature macro. vite-layers auto-registers this as a Vite
* alias (→ `src/feature.ts`) and a tsconfig `paths` entry, so consumers need no extra config.
* `vite-layers/feature` is also accepted (the published subpath export) for tooling that bypasses
* the alias.
*/
export const FEATURE_MODULE = '#feature'
const FEATURE_SPECIFIERS = new Set([FEATURE_MODULE, 'vite-layers/feature'])
/** Matches an import/export `from '#feature'|'vite-layers/feature'` clause. Used to decide whether a
* parse failure must fail the build (the module really uses the macro) rather than be skipped. */
const FEATURE_FROM_RE = /\bfrom\s*['"](?:#feature|vite-layers\/feature)['"]/
/** Code-filter regex for the rolldown `transform` hook filter (see {@link featurePlugin}). A superset
* of "actually imports the macro" — it just gates the JS round-trip; the handler decides precisely. */
const MACRO_CODE_RE = /#feature|vite-layers\/feature/
// ---------------------------------------------------------------------------------------------
// Feature tree helpers (shared by the transform and the type generator so they never disagree).
// ---------------------------------------------------------------------------------------------
/**
* Flatten a (possibly nested) feature object into every dotted path — both intermediate objects and
* leaves — paired with its value. `{ payments: { stripe: true } }` →
* `[['payments', {stripe:true}], ['payments.stripe', true]]`. The transform resolves a `feature()`
* key by exact lookup here, and the type generator emits one interface member per entry, so the
* accepted keys and the substituted values are guaranteed to match.
*/
export function flattenFeatures(features: Record<string, unknown>): Array<[string, unknown]> {
const out: Array<[string, unknown]> = []
const walk = (obj: Record<string, unknown>, prefix: string) => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
out.push([key, v])
if (v && typeof v === 'object' && !Array.isArray(v)) walk(v as Record<string, unknown>, key)
}
}
walk(features, '')
return out
}
const isPlainObject = (v: unknown): boolean => {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
const proto = Object.getPrototypeOf(v)
return proto === Object.prototype || proto === null
}
/**
* Returns a human description if `v` is NOT a JSON-like value the macro can fold into source (and
* the type generator into a literal type), else `null`. Recurses arrays/plain objects. Rejects
* bigint (JSON.stringify throws), functions/symbols (not serializable), non-finite numbers
* (`NaN`/`Infinity` → invalid TS + JSON `null`), and non-plain objects (Date/Map/RegExp/… would be
* coerced or crash). Keeping this strict means a bad flag fails fast with a clear message instead of
* crashing the transform or silently shipping a wrong value.
*/
function unsupportedValue(v: unknown): string | null {
if (v === null || v === undefined) return null
if (typeof v === 'boolean' || typeof v === 'string') return null
if (typeof v === 'number') return Number.isFinite(v) ? null : `non-finite number (${v})`
if (typeof v === 'bigint') return 'bigint'
if (typeof v === 'function') return 'function'
if (typeof v === 'symbol') return 'symbol'
if (Array.isArray(v)) {
for (const el of v) {
const bad = unsupportedValue(el)
if (bad) return bad
}
return null
}
if (isPlainObject(v)) {
for (const val of Object.values(v as Record<string, unknown>)) {
const bad = unsupportedValue(val)
if (bad) return bad
}
return null
}
return `non-plain object (${Object.prototype.toString.call(v)})`
}
/**
* Flatten + validate the feature tree. Throws (clear, fail-fast) on a dotted-key collision (an
* explicit `'a.b'` key clashing with a nested `a.b` path — they would produce a duplicate `.d.ts`
* member and an order-dependent wrong substitution) or an unsupported value type. Shared by the
* transform and the type generator so both reject the same inputs identically.
*/
export function validateFeatures(features: Record<string, unknown>): Array<[string, unknown]> {
const flat = flattenFeatures(features)
const seen = new Set<string>()
for (const [key, value] of flat) {
if (seen.has(key)) {
throw new Error(
`vite-layers: feature flag key '${key}' is defined twice — an explicit dotted key and a nested ` +
`path collide. Use one form, not both.`,
)
}
seen.add(key)
const bad = unsupportedValue(value)
if (bad) {
throw new Error(
`vite-layers: feature flag '${key}' has an unsupported value type (${bad}). Flags must be ` +
`JSON-like: boolean, finite number, string, null, plain object, or array of those.`,
)
}
}
return flat
}
/** A property name that can be written unquoted in a TS type literal / object key. */
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
const tsKey = (k: string) => (IDENTIFIER_RE.test(k) ? k : JSON.stringify(k))
/**
* Render a value as a TS **literal** type (not the widened base type): `false`, `2`, `"app"`,
* `readonly [...]`, `{ … }`. Emitting the literal makes the macro's return type the exact value the
* transform substitutes, so editors show the real flag value and `keyof` typo-checks the key.
*/
function tsType(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (Array.isArray(value)) {
return value.length ? `readonly [${value.map(tsType).join(', ')}]` : 'readonly []'
}
switch (typeof value) {
case 'boolean':
return value ? 'true' : 'false'
case 'number':
return String(value)
case 'string':
return JSON.stringify(value)
case 'object': {
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, never>'
return `{ ${entries.map(([k, v]) => `${tsKey(k)}: ${tsType(v)}`).join('; ')} }`
}
default:
return 'unknown'
}
}
/**
* Generate the `.d.ts` that augments {@link LayerFeatures} so `feature('key')` is typed with the
* flag's literal value and an unknown key (`feature('biling')`) is a compile error. The augmentation
* targets the `#feature` module, which vite-layers maps to `src/feature.ts` via the generated
* tsconfig `paths`.
*/
export function featuresDts(features: Record<string, unknown> = {}): string {
const members = validateFeatures(features).map(([k, v]) => ` ${tsKey(k)}: ${tsType(v)}`)
return [
'// AUTO-GENERATED by vite-layers — do not edit.',
`import '${FEATURE_MODULE}'`,
'',
`declare module '${FEATURE_MODULE}' {`,
' interface LayerFeatures {',
...members,
' }',
'}',
'',
].join('\n')
}
// ---------------------------------------------------------------------------------------------
// The transform: replace `feature('key')` with a literal, fail the build on any other use.
// ---------------------------------------------------------------------------------------------
/** `?…&lang.<ext>` query that Vue/Vite append to SFC sub-modules. Module-scope (not re-created per call). */
const LANG_QUERY_RE = /[?&]lang\.(\w+)/
/** Pick the dialect for oxc from the module id (handles `.vue?…&lang.tsx` query ids). */
function langFromId(id: string): 'js' | 'jsx' | 'ts' | 'tsx' {
const queryLang = id.match(LANG_QUERY_RE)?.[1]
const clean = id.split('?', 1)[0]!
const ext = queryLang ?? clean.slice(clean.lastIndexOf('.') + 1)
if (ext === 'tsx') return 'tsx'
if (ext === 'jsx') return 'jsx'
if (ext === 'js' || ext === 'mjs' || ext === 'cjs') return 'js'
// .ts/.mts/.cts and anything unknown → TS (a superset; the common case for app code).
return 'ts'
}
type AnyNode = { type: string; start: number; end: number } & Record<string, unknown>
const CHILD_SKIP = new Set(['type', 'start', 'end', 'range', 'loc'])
/** Iterate a node's child AST nodes (oxc emits an ESTree-shaped tree). */
function eachChild(node: AnyNode, fn: (child: AnyNode) => void) {
for (const key in node) {
if (CHILD_SKIP.has(key)) continue
const child = node[key]
if (Array.isArray(child)) {
for (const c of child) if (c && typeof (c as AnyNode).type === 'string') fn(c as AnyNode)
} else if (child && typeof (child as AnyNode).type === 'string') {
fn(child as AnyNode)
}
}
}
/** Collect the names bound by a binding pattern (Identifier / Object / Array / default / rest). */
function patternNames(node: AnyNode | null | undefined, add: (name: string) => void): void {
if (!node || typeof node.type !== 'string') return
switch (node.type) {
case 'Identifier':
add(node.name as string)
break
case 'ObjectPattern':
for (const p of (node.properties as AnyNode[]) ?? []) {
patternNames((p.type === 'RestElement' ? p.argument : p.value) as AnyNode, add)
}
break
case 'ArrayPattern':
for (const el of (node.elements as (AnyNode | null)[]) ?? []) patternNames(el, add)
break
case 'AssignmentPattern':
patternNames(node.left as AnyNode, add)
break
case 'RestElement':
patternNames(node.argument as AnyNode, add)
break
}
}
/** Lexical (block-scoped) bindings declared directly in a statement list: let/const/class/function. */
function collectLexical(stmts: AnyNode[], add: (n: string) => void): void {
for (const st of stmts ?? []) {
if (st.type === 'VariableDeclaration' && st.kind !== 'var') {
for (const d of st.declarations as AnyNode[]) patternNames(d.id as AnyNode, add)
} else if ((st.type === 'FunctionDeclaration' || st.type === 'ClassDeclaration') && st.id) {
add((st.id as AnyNode).name as string)
}
}
}
/** Function-scoped bindings hoisted in a body: `var` (at any depth) + nested function-decl names. */
function collectHoisted(stmts: AnyNode[], add: (n: string) => void): void {
const visit = (node: AnyNode) => {
const t = node.type
if (t === 'FunctionDeclaration') {
if (node.id) add((node.id as AnyNode).name as string)
return // its body is a nested scope
}
if (t === 'FunctionExpression' || t === 'ArrowFunctionExpression' || t === 'ClassDeclaration' || t === 'ClassExpression') {
return // nested scope — its vars belong there
}
if (t === 'VariableDeclaration') {
if (node.kind === 'var') for (const d of node.declarations as AnyNode[]) patternNames(d.id as AnyNode, add)
return
}
eachChild(node, visit)
}
for (const s of stmts ?? []) visit(s)
}
const SCOPE_NODES = new Set([
'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression',
'BlockStatement', 'StaticBlock', 'CatchClause',
'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement',
])
/** The subset of `locals` (macro binding names) that this scope node re-binds, shadowing the import. */
function scopeBindings(node: AnyNode, locals: Set<string>): Set<string> {
const bound = new Set<string>()
const add = (n: string) => {
if (locals.has(n)) bound.add(n)
}
const t = node.type
if (t === 'FunctionDeclaration' || t === 'FunctionExpression' || t === 'ArrowFunctionExpression') {
for (const p of (node.params as AnyNode[]) ?? []) patternNames(p, add)
if (t === 'FunctionExpression' && node.id) add((node.id as AnyNode).name as string)
const body = node.body as AnyNode | undefined
if (body?.type === 'BlockStatement') collectHoisted(body.body as AnyNode[], add)
} else if (t === 'CatchClause') {
patternNames(node.param as AnyNode, add)
} else if (t === 'BlockStatement' || t === 'StaticBlock') {
collectLexical(node.body as AnyNode[], add)
} else if (t === 'ForStatement' || t === 'ForInStatement' || t === 'ForOfStatement') {
const head = (t === 'ForStatement' ? node.init : node.left) as AnyNode | null
if (head?.type === 'VariableDeclaration' && head.kind !== 'var') {
for (const d of head.declarations as AnyNode[]) patternNames(d.id as AnyNode, add)
}
} else if (t === 'SwitchStatement') {
for (const c of (node.cases as AnyNode[]) ?? []) collectLexical(c.consequent as AnyNode[], add)
}
return bound
}
/** Extract a string key from a `feature(arg)` argument — a plain string literal or a `\`literal\``. */
function stringKey(arg: AnyNode | undefined): string | undefined {
if (!arg) return undefined
if ((arg.type === 'Literal' || arg.type === 'StringLiteral') && typeof arg.value === 'string') {
return arg.value
}
if (arg.type === 'TemplateLiteral') {
const exprs = arg.expressions as unknown[]
const quasis = arg.quasis as Array<{ value: { cooked?: string } }>
// A single static chunk with a valid cooked value; an invalid escape (`\unicode`) makes cooked
// null → treat as not-a-string-literal so it routes to the clear "string-literal key" error.
if (exprs.length === 0 && quasis.length === 1 && typeof quasis[0]!.value.cooked === 'string') {
return quasis[0]!.value.cooked
}
}
return undefined
}
/** A primitive substitutes bare; an object/array is parenthesized so it is always an expression. */
function literalOf(value: unknown): string {
if (value === undefined) return 'undefined'
if (value !== null && typeof value === 'object') return `(${JSON.stringify(value)})`
return JSON.stringify(value)
}
const isImportSource = (node: AnyNode, sources: Set<string>): boolean => {
const src = node.source as { value?: unknown } | undefined
return typeof src?.value === 'string' && sources.has(src.value)
}
/**
* Build-time feature flags via the `feature('key')` macro — one mechanism for dev **and** build.
*
* The transform parses every module that imports `feature` (from `#feature` / `vite-layers/feature`),
* replaces each `feature('key')` call with the flag's literal value, and removes the now-unused
* import. Replacing a disabled flag's call with `false` lets Rollup/rolldown tree-shake the dead
* branch — including any `import()` inside it — so the chunk is never emitted.
*
* Anything other than a direct call with a known string-literal key (aliasing, destructuring,
* dynamic key, unknown key) is a **hard error** via `this.error`, surfaced with a code frame in dev
* (browser overlay + terminal) and as a failed build — the misuse can never silently ship. Because
* the same substitution runs in dev, dev is a faithful oracle for the build result.
*
* @param features the merged feature flags (high→low layer priority already applied).
*/
export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
const flat = new Map(validateFeatures(features))
return {
name: 'vite-layers:features',
// `enforce: 'post'` so we always run *after* every framework/TS transform (a Vue SFC's
// `<script setup>` compiled to JS, JSX→JS, TS→JS) — never on raw, unparseable `.vue`/JSX source —
// and *before* Vite's import-analysis rewrites specifiers, so the macro import is still
// `#feature`/`vite-layers/feature`. This makes the pass independent of plugin array order: the
// old no-enforce version could run before `@vitejs/plugin-vue`, fail to parse the raw SFC, and
// silently skip the `feature()` calls in `<script setup>` — exactly the silent miss this avoids.
enforce: 'post',
// Hook filter (rolldown): the bundler only calls this transform for non-node_modules modules whose
// code references the macro module — every other module skips the JS round-trip entirely.
// https://rolldown.rs/in-depth/why-plugin-hook-filter . The handler repeats the guards so it stays
// correct on hosts that don't apply the filter (plain Rollup / older dev pipelines).
transform: {
filter: { id: { exclude: /node_modules/ }, code: MACRO_CODE_RE },
handler(code, id) {
if (id.includes('/node_modules/')) return null
if (!code.includes(FEATURE_MODULE) && !code.includes('vite-layers/feature')) return null
// A real macro module that fails to parse must NEVER be skipped silently — its feature() calls
// would ship uncompiled. oxc reports syntax errors in `errors` (it does not throw) and yields an
// empty/partial body, which otherwise looks like "no macro here". So: when the module references
// `#feature` in a from-clause, any parse failure is a hard build error; if `#feature` only shows
// up in a string/comment, stay out of the way and let the rest of the pipeline proceed.
let result: ReturnType<typeof parseSync>
try {
result = parseSync(id.split('?', 1)[0]!, code, { sourceType: 'module', lang: langFromId(id) })
} catch (err) {
if (FEATURE_FROM_RE.test(code)) {
this.error(`vite-layers: could not parse ${id} to compile its feature() calls — ${(err as Error)?.message ?? err}`)
}
return null
}
if (result.errors?.length && FEATURE_FROM_RE.test(code)) {
this.error(`vite-layers: ${id} has syntax errors; cannot safely compile its feature() calls — ${result.errors[0]?.message ?? ''}`)
}
const program = result.program as unknown as AnyNode
// Pass 1: collect the local binding name(s) imported from our module, and the import nodes.
const importDecls: AnyNode[] = []
const locals = new Set<string>()
for (const node of program.body as AnyNode[]) {
if (node.type === 'ImportDeclaration' && isImportSource(node, FEATURE_SPECIFIERS)) {
if (node.importKind === 'type') continue // `import type { feature }` — fully erased, ignore
importDecls.push(node)
for (const spec of node.specifiers as AnyNode[]) {
if (spec.importKind === 'type') continue // `import { type feature }` — erased
const imported = spec.imported as { name?: string; value?: string } | undefined
if (spec.type === 'ImportSpecifier' && (imported?.name ?? imported?.value) === 'feature') {
locals.add((spec.local as { name: string }).name)
} else if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
// A default/namespace import can only be used via dynamic property access, which the
// transform cannot fold — fail the build now rather than letting it throw at runtime.
this.error(
`vite-layers: import the named { feature } macro from '${FEATURE_MODULE}' — ` +
'default and namespace imports are not supported (they defeat dead-code elimination).',
spec.start,
)
}
}
} else if (
(node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') &&
isImportSource(node, FEATURE_SPECIFIERS)
) {
this.error('vite-layers: re-exporting the `feature` macro is not supported — import and call it directly.', node.start)
}
}
if (locals.size === 0) return null
// Pass 2: every reference to the binding (that isn't shadowed by a local of the same name)
// must be a direct `feature('known-key')` call; anything else is a hard error.
const s = new MagicString(code)
const edits: Array<[number, number, string]> = []
const handleRef = (node: AnyNode, parent: AnyNode | null) => {
if (parent) {
// Binding/declaration positions and non-reference uses of the name — not macro calls.
if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier') return
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
if (parent.type === 'Property' && parent.key === node && !parent.computed && !parent.shorthand) return
if ((parent.type === 'PropertyDefinition' || parent.type === 'MethodDefinition') && parent.key === node && !parent.computed) return
// `feature:` labels / `break feature` — not references.
if ((parent.type === 'LabeledStatement' || parent.type === 'BreakStatement' || parent.type === 'ContinueStatement') && parent.label === node) return
// TS type positions (`typeof feature`, `feature` as a type) are erased and never affect DCE.
if (parent.type === 'TSTypeQuery' || parent.type === 'TSTypeReference' || parent.type === 'TSQualifiedName') return
}
if (parent && parent.type === 'CallExpression' && parent.callee === node && !parent.optional) {
const args = parent.arguments as AnyNode[]
const key = args.length === 1 ? stringKey(args[0]) : undefined
if (key === undefined) {
this.error("vite-layers: feature() takes a single string-literal key, e.g. feature('billing').", node.start)
}
if (!flat.has(key)) {
const known = [...flat.keys()].map(k => `'${k}'`).join(', ') || '(none defined)'
this.error(`vite-layers: unknown feature flag '${key}'. Known flags: ${known}.`, args[0]!.start)
}
edits.push([parent.start, parent.end, literalOf(flat.get(key))])
} else {
this.error(
'vite-layers: `feature` is a compile-time macro — call it directly with a string-literal key. ' +
'Aliasing, destructuring, or passing it as a value defeats dead-code elimination and is not allowed.',
node.start,
)
}
}
// Scope-aware descent: a reference is the macro only if no enclosing scope re-binds its name
// (so an unrelated local `feature` param/const/catch/… is left untouched, not falsely rejected).
const descend = (node: AnyNode, parent: AnyNode | null, shadow: Set<string>) => {
let childShadow = shadow
if (SCOPE_NODES.has(node.type)) {
const bound = scopeBindings(node, locals)
if (bound.size) {
childShadow = new Set(shadow)
for (const n of bound) childShadow.add(n)
}
}
if (node.type === 'Identifier' && locals.has(node.name as string) && !shadow.has(node.name as string)) {
handleRef(node, parent)
}
// Inline the child walk (instead of `eachChild(node, child => …)`) so this hot recursion
// allocates no per-node closure — `descend` is called once per AST node on a macro module.
for (const key in node) {
if (CHILD_SKIP.has(key)) continue
const child = node[key]
if (Array.isArray(child)) {
for (const c of child) if (c && typeof (c as AnyNode).type === 'string') descend(c as AnyNode, node, childShadow)
} else if (child && typeof (child as AnyNode).type === 'string') {
descend(child as AnyNode, node, childShadow)
}
}
}
descend(program, null, new Set())
for (const [start, end, text] of edits) s.overwrite(start, end, text)
for (const decl of importDecls) s.remove(decl.start, decl.end)
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
},
},
}
}
+13 -4
View File
@@ -1,5 +1,6 @@
export { defineLayerConfig, resolveLayerStack } from './config' export { defineLayerConfig, resolveLayerStack } from './config'
export { configWatchPlugin, featuresRuntimePlugin } from './dev' export { configWatchPlugin } from './dev'
export { FEATURE_MODULE, featurePlugin, featuresDts, flattenFeatures } from './features'
export { publicLayersPlugin } from './public' export { publicLayersPlugin } from './public'
export { export {
createLayerHooks, createLayerHooks,
@@ -12,14 +13,22 @@ export {
type TsconfigHookContext, type TsconfigHookContext,
type ViteConfigHookContext, type ViteConfigHookContext,
} from './hooks' } from './hooks'
export { DEFAULT_EXTENSIONS, layersResolver, type LayersResolverOptions } from './resolve' export {
createLayeredResolution,
DEFAULT_EXTENSIONS,
layersResolver,
type LayeredResolution,
type LayersResolverOptions,
type ParsedLayeredId,
type ResolveRecord,
} from './resolve'
export { layersDevtoolsPlugin, type LayersDevtoolsData } from './devtools'
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit' export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
export { export {
generateTsConfig, generateTsConfig,
writeTsConfig, writeTsConfig,
tsconfigPlugin, tsconfigPlugin,
featuresDts,
type GenerateTsConfigOptions, type GenerateTsConfigOptions,
type TSConfig, type TSConfig,
} from './tsconfig' } from './tsconfig'
export type { Layer, LayerConfig, LayerStack } from './types' export type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
+49 -45
View File
@@ -1,11 +1,23 @@
import { existsSync } from 'node:fs'
import { basename, resolve } from 'node:path' import { basename, resolve } from 'node:path'
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite' import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
import { resolveLayerStack } from './config' import { resolveLayerStack } from './config'
import { configWatchPlugin, featuresRuntimePlugin } from './dev' import { configWatchPlugin } from './dev'
import { layersDevtoolsPlugin } from './devtools'
import { FEATURE_MODULE, featurePlugin } from './features'
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks' import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
import { publicLayersPlugin } from './public' import { publicLayersPlugin } from './public'
import { layersResolver } from './resolve' import { createLayeredResolution, layersResolver } from './resolve'
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig' import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
import { toPosix } from './util'
/**
* Absolute path to the `feature` macro entry, aliased as `#feature` (see {@link featurePlugin}).
* Resolved next to this module — `feature.ts` when running from source (dev/tests), `feature.js`
* after a `tsdown` build — so the alias always points at a real file in either layout.
*/
const FEATURE_ENTRY = resolve(import.meta.dirname, 'feature')
const FEATURE_FILE = toPosix(existsSync(`${FEATURE_ENTRY}.ts`) ? `${FEATURE_ENTRY}.ts` : `${FEATURE_ENTRY}.js`)
export interface BuildViteConfigOptions { export interface BuildViteConfigOptions {
/** Extra Vite config merged at the very end (highest priority). */ /** Extra Vite config merged at the very end (highest priority). */
@@ -21,6 +33,12 @@ export interface BuildViteConfigOptions {
resolver?: { prefixes?: string[]; extensions?: string[] } resolver?: { prefixes?: string[]; extensions?: string[] }
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */ /** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
hooks?: LayerHooksConfig hooks?: LayerHooksConfig
/**
* Mount the vite-layers panels (stack / features / resolver / public+ts) in Vite DevTools.
* Requires the `@vitejs/devtools` hub in the plugin list; the integration is inert without it.
* Enabled by default — pass `false` to skip it (and the resolver's resolution-log recording).
*/
devtools?: boolean
} }
/** /**
@@ -45,40 +63,6 @@ function dedupePlugins(config: UserConfig): UserConfig {
return { ...config, plugins: out } return { ...config, plugins: out }
} }
/** A member-expression define key segment must be a plain JS identifier. */
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
/**
* Build the `define` map for feature flags. Emits the whole `__FEATURES__` object (for runtime
* reads) plus a dotted entry for **every nested path** whose segments are valid identifiers
* (`__FEATURES__.billing`, `__FEATURES__.nested.enabled`, …).
*
* The dotted entries are what make dead-code elimination work: esbuild folds a replaced literal
* (`false ? import('…') : []` → `[]`) and drops the dynamic import *before* Rollup builds the
* module graph, so the page's chunk is never emitted. A member access on an object literal
* (`{"enabled":false}.enabled`) is NOT folded, so the object form alone does not DCE — which is why
* we walk recursively and emit a literal at every depth.
*
* Keys that are not valid identifiers (e.g. `'kebab-flag'`) are skipped rather than emitted: a
* dotted define with such a segment is an `INVALID_DEFINE_CONFIG` build error, and you cannot fold
* a bracket access anyway. The key still lives inside the whole-object `__FEATURES__` for runtime.
*/
function featureDefines(features: Record<string, unknown> = {}): Record<string, string> {
const define: Record<string, string> = { __FEATURES__: JSON.stringify(features) }
const walk = (obj: Record<string, unknown>, prefix: string) => {
for (const [key, value] of Object.entries(obj)) {
if (!IDENTIFIER_RE.test(key)) continue
const path = `${prefix}.${key}`
define[path] = JSON.stringify(value)
if (value && typeof value === 'object' && !Array.isArray(value)) {
walk(value as Record<string, unknown>, path)
}
}
}
walk(features, '__FEATURES__')
return define
}
/** /**
* Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`: * Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`:
* *
@@ -87,9 +71,11 @@ function featureDefines(features: Record<string, unknown> = {}): Record<string,
* ``` * ```
* *
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`. * - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins). * - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins);
* `@/`/`~/` are handled by {@link layersResolver}, not as plain aliases. * `#feature` → the {@link featurePlugin} macro entry. `@/`/`~/` are handled by
* - `__FEATURES__` is defined from the merged `features` for build-time dead-code elimination. * {@link layersResolver}, not as plain aliases.
* - Build-time feature flags are compiled by {@link featurePlugin} (the `feature('key')` macro),
* one mechanism for dev and build.
*/ */
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) { export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
return defineConfig(async (env) => { return defineConfig(async (env) => {
@@ -103,11 +89,20 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
const { merged, layers } = stack const { merged, layers } = stack
const roots = layers.map(l => l.srcDir) const roots = layers.map(l => l.srcDir)
const devtoolsEnabled = options.devtools !== false
// One shared resolution drives both the resolver plugin and the devtools resolver panel, so the
// panel introspects the exact same candidate cache and resolution log. The log is only recorded
// when devtools is enabled AND in dev (`serve`) — the panel can't mount during a build, so a
// production build does zero per-import recording work.
const recordLog = devtoolsEnabled && env.command === 'serve'
const resolution = createLayeredResolution({ roots, ...options.resolver, record: recordLog ? 200 : 0 })
const project = layers[0]! // resolveLayerStack always returns at least the project layer const project = layers[0]! // resolveLayerStack always returns at least the project layer
const alias: Record<string, string> = { const alias: Record<string, string> = {
'~~': project.rootDir, '~~': project.rootDir,
'@@': project.rootDir, '@@': project.rootDir,
[FEATURE_MODULE]: FEATURE_FILE, // `#feature` → the macro entry (compiled away by featurePlugin)
} }
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins). // `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins).
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
@@ -125,21 +120,30 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
vite = dedupePlugins(vite) vite = dedupePlugins(vite)
const plugins: PluginOption[] = [ const plugins: PluginOption[] = [
layersResolver({ roots, ...options.resolver }), layersResolver(resolution),
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change
featuresRuntimePlugin(merged.features), // dev: supply __FEATURES__ at runtime (define is build-only here) featurePlugin(merged.features), // compile `feature('key')` → literal (dev + build, one mechanism)
] ]
if (options.tsconfig !== false) { if (options.tsconfig !== false) {
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve // Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
// and `tsconfig:generate` sees the same handlers). // and `tsconfig:generate` sees the same handlers).
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks })) plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
} }
if (devtoolsEnabled) {
// Inert unless the `@vitejs/devtools` hub mounts it (uses only *type* imports from the kit).
plugins.push(
layersDevtoolsPlugin({
appDir,
env,
stack,
resolution,
tsconfig: options.tsconfig === false ? false : (options.tsconfig ?? {}),
}),
)
}
vite = mergeConfig(vite, { vite = mergeConfig(vite, { plugins })
plugins,
define: featureDefines(merged.features),
})
if (options.vite) vite = mergeConfig(vite, options.vite) if (options.vite) vite = mergeConfig(vite, options.vite)
+46 -14
View File
@@ -1,32 +1,56 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
import { join, relative } from 'node:path' import { dirname, join, relative, resolve } from 'node:path'
import sirv from 'sirv' import sirv from 'sirv'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { toPosix } from './util'
const toPosix = (p: string) => p.replace(/\\/g, '/')
/** Recursively list files under a directory (absolute paths). */ /** Recursively list files under a directory (absolute paths). */
function walk(dir: string, out: string[] = []): string[] { function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
const abs = join(dir, name) const abs = join(dir, name)
if (statSync(abs).isDirectory()) walk(abs, out) // Skip a broken symlink / file removed mid-walk (ENOENT) rather than aborting the public copy.
let isDir: boolean
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
if (isDir) walk(abs, out)
else out.push(abs) else out.push(abs)
} }
return out return out
} }
/**
* Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority
* layer wins each key — i.e. first-match-wins by priority (`brand/public/logo.svg` shadows `main`'s).
*/
function mergePublic(dirs: string[]): Map<string, string> {
const assets = new Map<string, string>()
for (const dir of [...dirs].reverse()) {
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
}
return assets
}
/** /**
* Layered static assets: each layer may have a `public/` directory, resolved **first-match across * Layered static assets: each layer may have a `public/` directory, resolved **first-match across
* layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`. * layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`.
* *
* Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in * Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in
* `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins), * `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins),
* and emits the merged set into the build output (higher layers overwrite lower ones). * and copies the merged set into the build output (higher layers overwrite lower ones).
*
* Build-time copy is streamed file-by-file through the OS (`copyFileSync`) rather than buffered via
* `emitFile`, so peak memory stays flat regardless of total asset size — large fonts/videos on a
* memory-constrained CI won't OOM, matching Vite's own `publicDir` copy.
* *
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority. * @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
*/ */
export function publicLayersPlugin(publicDirs: string[]): Plugin { export function publicLayersPlugin(publicDirs: string[]): Plugin {
const dirs = publicDirs.filter(existsSync) // high → low const dirs = publicDirs.filter(existsSync) // high → low
let outDir = ''
let copyPublic = true
return { return {
name: 'vite-layers:public', name: 'vite-layers:public',
@@ -34,20 +58,28 @@ export function publicLayersPlugin(publicDirs: string[]): Plugin {
// We serve/emit public ourselves, so turn off Vite's single-dir handling. // We serve/emit public ourselves, so turn off Vite's single-dir handling.
if (dirs.length > 0) return { publicDir: false } if (dirs.length > 0) return { publicDir: false }
}, },
configResolved(config) {
outDir = resolve(config.root, config.build.outDir)
// Respect Vite's own opt-out (e.g. SSR builds set this false to skip public copy).
copyPublic = config.build.copyPublicDir !== false
},
configureServer(server) { configureServer(server) {
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss. // Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
for (const dir of dirs) { for (const dir of dirs) {
server.middlewares.use(sirv(dir, { dev: true, etag: true })) server.middlewares.use(sirv(dir, { dev: true, etag: true }))
} }
}, },
generateBundle() { writeBundle(options) {
// Build: merge low→high so higher layers overwrite — i.e. first-match-wins by priority. // Build: copy each file straight to disk via the OS instead of holding every asset's bytes in
const assets = new Map<string, string>() // memory at once — peak RSS stays flat no matter how large the public set is.
for (const dir of [...dirs].reverse()) { if (!copyPublic || dirs.length === 0) return
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs) // writeBundle fires once per output; only the one targeting the main outDir copies the assets
} // (a secondary/SSR output has a different dir and is skipped).
for (const [fileName, abs] of assets) { if (options.dir && resolve(options.dir) !== outDir) return
this.emitFile({ type: 'asset', fileName, source: readFileSync(abs) }) for (const [fileName, abs] of mergePublic(dirs)) {
const dest = join(outDir, fileName)
mkdirSync(dirname(dest), { recursive: true })
copyFileSync(abs, dest)
} }
}, },
} }
+155 -30
View File
@@ -1,6 +1,7 @@
import { statSync } from 'node:fs' import { statSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { toPosix } from './util'
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */ /** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue'] export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
@@ -12,9 +13,62 @@ export interface LayersResolverOptions {
prefixes?: string[] prefixes?: string[]
/** Extensions probed when the id has no explicit, existing file. */ /** Extensions probed when the id has no explicit, existing file. */
extensions?: string[] extensions?: string[]
/**
* Keep a bounded, de-duplicated log of the last N resolutions for introspection (the devtools
* resolver panel reads it). `0`/omitted disables recording — zero overhead on the hot path.
*/
record?: number
} }
const toPosix = (p: string) => p.replace(/\\/g, '/') /** A single recorded resolution — what the resolver saw for one `@/`/`~/` import. */
export interface ResolveRecord {
/** The original import id (prefix + sub-path + query). */
id: string
/** The importer module (query-stripped), if any. */
importer?: string
/** The file the id resolved to (with query), or `null` if nothing matched. */
resolved: string | null
/** All candidate files across layers, high→low priority (importer-independent). */
candidates: string[]
/** Index of the importer within `candidates` (`-1` when it isn't a self-import). */
selfIndex: number
}
/** A parsed layered id: its matched prefix, the prefix-stripped sub-path, and any query suffix. */
export interface ParsedLayeredId {
prefix: string
sub: string
query: string
}
/**
* The reusable core of the layered resolver — the pure resolution logic, decoupled from the Vite
* plugin shell so it can be shared. {@link layersResolver} wraps one of these in a plugin; the
* devtools integration reuses the *same instance* (via {@link createLayeredResolution} in
* `buildViteConfig`) to introspect candidates and the live resolution log without re-implementing
* the probing, the cache, or the `super()` semantics.
*/
export interface LayeredResolution {
readonly roots: string[]
readonly prefixes: string[]
readonly extensions: string[]
/** Split a layered id into prefix/sub/query, or `null` if no prefix matches. */
parse: (id: string) => ParsedLayeredId | null
/** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */
candidates: (sub: string) => string[]
/** Resolve a layered id (super()/self-skip + query preservation). `null` if not layered / no match. */
resolveId: (id: string, importer?: string) => string | null
/** Drop the candidate cache (call when files are added/removed — which layer wins can change). */
clear: () => void
/** Recorded resolutions, newest first (empty unless `record` was enabled). */
records: () => ResolveRecord[]
/** Clear the resolution log (the candidate cache is untouched). */
clearRecords: () => void
}
/** RegExp metacharacters — escaped when building a RegExp from a literal string (e.g. layer prefixes). */
const REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g
const escapeRegExp = (s: string) => s.replace(REGEXP_META_RE, '\\$&')
const isFile = (p: string): boolean => { const isFile = (p: string): boolean => {
try { try {
@@ -25,19 +79,12 @@ const isFile = (p: string): boolean => {
} }
/** /**
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's * Build the shared resolution core (probing + cache + `super()` + optional recording). Stateless
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`, * across importers: the candidate list for a sub-path is importer-independent, so `super()` works by
* it probes each source root in priority order and returns the first match. * locating the importer's position in the list and taking the next entry down.
*
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
* then `<path>/index<ext>`.
*
* Improvement over Nuxt: **self-skip** gives `super()` semantics. If the first match is the
* importing file itself, resolution continues to the next (lower-priority) layer — so an
* override at `@/components/Foo.vue` can import `@/components/Foo.vue` to reach the base file.
*/ */
export function layersResolver(options: LayersResolverOptions): Plugin { export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options
const probe = (root: string, sub: string): string | null => { const probe = (root: string, sub: string): string | null => {
const direct = resolve(root, sub) const direct = resolve(root, sub)
@@ -54,8 +101,8 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
} }
// Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots // Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots
// (high→low priority). Saves the per-import `statSync` storm; self-skip stays correct because the // (high→low priority). Saves the per-import `statSync` storm; the list is importer-independent, so
// candidate list is importer-independent (we pick the first candidate that isn't the importer). // super() stays correct — we locate the importer's position in it and take the next entry down.
const cache = new Map<string, string[]>() const cache = new Map<string, string[]>()
const candidates = (sub: string): string[] => { const candidates = (sub: string): string[] => {
const cached = cache.get(sub) const cached = cache.get(sub)
@@ -69,30 +116,108 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
return list return list
} }
const parse = (id: string): ParsedLayeredId | null => {
const prefix = prefixes.find(p => id.startsWith(p))
if (!prefix) return null
const q = id.indexOf('?')
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
return { prefix, sub, query }
}
// Bounded, de-duplicated resolution log (devtools). Keyed by a JSON-encoded `[id, importer]` pair
// (collision-proof, unlike a delimiter string) so repeated resolves of the same import (HMR re-runs)
// update one entry instead of flooding the log; a Map preserves insertion order, and re-inserting
// moves the entry to the end (most-recent-last).
const log = new Map<string, ResolveRecord>()
const remember = (rec: ResolveRecord) => {
const key = JSON.stringify([rec.id, rec.importer ?? null]) // collision-proof composite key
if (log.has(key)) log.delete(key)
log.set(key, rec)
while (log.size > record) log.delete(log.keys().next().value!)
}
return {
roots,
prefixes,
extensions,
parse,
candidates,
resolveId(id, importer) {
const parsed = parse(id)
if (!parsed) return null
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
// super(): if the importer is one of the candidates (an override importing its own layered
// path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so it resolves to
// the highest-priority match (index 0). Note: "first candidate that isn't me" would be wrong —
// for a shadowed middle layer it jumps UP to a higher override, and a top↔mid self-import chain
// would cycle. Position-aware skip makes super() correct through a deep extends chain.
const list = candidates(parsed.sub)
const selfIndex = self ? list.indexOf(self) : -1
const next = list[selfIndex + 1]
const resolved = next ? next + parsed.query : null
if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex })
return resolved
},
clear() {
cache.clear()
},
records() {
return [...log.values()].reverse()
},
clearRecords() {
log.clear()
},
}
}
/** True if the argument is an already-built {@link LayeredResolution} rather than raw options. */
const isResolution = (v: LayersResolverOptions | LayeredResolution): v is LayeredResolution =>
typeof (v as LayeredResolution).resolveId === 'function'
/**
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
* it probes each source root in priority order and returns the first match.
*
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
* then `<path>/index<ext>`.
*
* Improvement over Nuxt: **self-skip** gives `super()` semantics at any depth. When the importer is
* itself one of the matches (an override importing its own layered path), resolution continues to the
* **next-lower** layer — so an override at `@/components/Foo.vue` can import `@/components/Foo.vue` to
* reach the layer beneath it. This composes through a deep `extends` chain: top→mid→base each resolve
* one step down, so multi-level overrides can each call `super()`.
*
* Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a
* pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects
* the exact same cache and resolution log this plugin produces.
*/
export function layersResolver(source: LayersResolverOptions | LayeredResolution): Plugin {
const resolution = isResolution(source) ? source : createLayeredResolution(source)
// Hook filter (rolldown): a RegExp matching the layered prefixes, so the bundler only invokes
// resolveId for `@/`/`~/` ids — every other specifier skips the JS round-trip. (resolveId filters
// accept only RegExp ids, not string globs.) https://rolldown.rs/in-depth/why-plugin-hook-filter
const idFilter = new RegExp(`^(?:${resolution.prefixes.map(escapeRegExp).join('|')})`)
return { return {
name: 'vite-layers:resolve', name: 'vite-layers:resolve',
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
configureServer(server) { configureServer(server) {
// A new/removed file can change which layer wins → drop the cache in dev. // A new/removed file can change which layer wins → drop the cache in dev.
const clear = () => cache.clear() const clear = () => resolution.clear()
server.watcher.on('add', clear) server.watcher.on('add', clear)
server.watcher.on('unlink', clear) server.watcher.on('unlink', clear)
server.watcher.on('unlinkDir', clear) server.watcher.on('unlinkDir', clear)
}, },
resolveId(id, importer) { resolveId: {
const prefix = prefixes.find(p => id.startsWith(p)) filter: { id: idFilter },
if (!prefix) return null handler(id, importer) {
return resolution.resolveId(id, importer)
const q = id.indexOf('?') },
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
for (const file of candidates(sub)) {
if (file === self) continue // self-skip → fall through to the base layer (super())
return file + query
}
return null
}, },
} }
} }
+13 -47
View File
@@ -4,11 +4,16 @@ import { defu } from 'defu'
import { type TSConfig, writeTSConfig } from 'pkg-types' import { type TSConfig, writeTSConfig } from 'pkg-types'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { resolveLayerStack } from './config' import { resolveLayerStack } from './config'
import { FEATURE_MODULE, featuresDts } from './features'
import { hooksFromStack, type LayerHookable } from './hooks' import { hooksFromStack, type LayerHookable } from './hooks'
import type { LayerStack } from './types' import type { LayerStack } from './types'
import { toPosix } from './util'
export type { TSConfig } from 'pkg-types' export type { TSConfig } from 'pkg-types'
/** Absolute path (no extension) to the `feature` macro entry — mapped to `#feature` in `paths`. */
const FEATURE_FILE = resolve(import.meta.dirname, 'feature')
export interface GenerateTsConfigOptions { export interface GenerateTsConfigOptions {
/** /**
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this * Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
@@ -25,52 +30,10 @@ export interface GenerateTsConfigOptions {
hooks?: LayerHookable hooks?: LayerHookable
} }
const toPosix = (p: string) => p.replace(/\\/g, '/') /** A path not already starting with `.` — {@link rel} prefixes it with `./`. */
const LEADING_NON_DOT_RE = /^([^.])/
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */ /** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(/^([^.])/, './$1') || '.' const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(LEADING_NON_DOT_RE, './$1') || '.'
/** A property name that can be written unquoted in a TS type literal. */
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
/** Render a JSON-ish value as a TS type literal (boolean/number/string → type, object → recurse). */
function tsType(value: unknown): string {
if (value === null) return 'null'
if (Array.isArray(value)) return 'readonly unknown[]'
switch (typeof value) {
case 'boolean':
return 'boolean'
case 'number':
return 'number'
case 'string':
return 'string'
case 'object': {
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, never>'
const body = entries
.map(([k, v]) => `${IDENTIFIER_RE.test(k) ? k : JSON.stringify(k)}: ${tsType(v)}`)
.join('; ')
return `{ ${body} }`
}
default:
return 'unknown'
}
}
/**
* Generate a `.d.ts` that types the `__FEATURES__` global from the merged feature flags, so a typo
* (`__FEATURES__.biling`) is a compile error instead of a silently-falsy runtime value.
*/
export function featuresDts(features: Record<string, unknown> = {}): string {
return [
'// AUTO-GENERATED by vite-layers — do not edit.',
'export {}',
'declare global {',
` const __FEATURES__: ${tsType(features)}`,
'}',
'',
].join('\n')
}
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */ /** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = { const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
@@ -128,6 +91,9 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
'@@': [rel(genDir, projectRoot)], '@@': [rel(genDir, projectRoot)],
'~~/*': [`${rel(genDir, projectRoot)}/*`], '~~/*': [`${rel(genDir, projectRoot)}/*`],
'@@/*': [`${rel(genDir, projectRoot)}/*`], '@@/*': [`${rel(genDir, projectRoot)}/*`],
// `#feature` → the macro entry, so tsc/vue-tsc resolve `import { feature } from '#feature'` and
// the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers.
[FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)],
} }
for (const l of layers) { for (const l of layers) {
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig. // first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
@@ -139,8 +105,8 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))] const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))]
// App/client config: layer src trees + the typed __FEATURES__ global. Config files are NOT here — // App/client config: layer src trees + the typed `feature()` flags (features.d.ts). Config files
// they belong to the node config below. // are NOT here — they belong to the node config below.
const base: TSConfig = { const base: TSConfig = {
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS }, compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)], include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
+18 -1
View File
@@ -17,7 +17,7 @@ export interface LayerConfig {
extends?: string | string[] extends?: string | string[]
/** Vite config fragment contributed by this layer (object or env-aware factory). */ /** Vite config fragment contributed by this layer (object or env-aware factory). */
vite?: UserConfig | ((env: ConfigEnv) => UserConfig) vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
/** Build-time feature flags, exposed to app code as the `__FEATURES__` global. */ /** Build-time feature flags, read in app code via the `feature('key')` macro (`#feature`). */
features?: Record<string, unknown> features?: Record<string, unknown>
/** /**
* tsconfig overrides contributed by this layer, merged across the stack into the generated * tsconfig overrides contributed by this layer, merged across the stack into the generated
@@ -52,9 +52,26 @@ export interface Layer {
config: LayerConfig config: LayerConfig
} }
/**
* A single edge of the resolved `extends` graph — the layer at `from` (a directory) extends the one
* resolved at `to`. `source` is the raw `extends` entry (relative path, npm package, or git source).
* Captured during resolution because c12 strips the extend keys from the resolved layer configs.
*/
export interface LayerEdge {
from: string
to: string
source: string
}
export interface LayerStack { export interface LayerStack {
/** Deep-merged config across the whole stack (defu, project wins). */ /** Deep-merged config across the whole stack (defu, project wins). */
merged: LayerConfig merged: LayerConfig
/** Layers ordered high→low priority; `layers[0]` is the project itself. */ /** Layers ordered high→low priority; `layers[0]` is the project itself. */
layers: Layer[] layers: Layer[]
/**
* Parent→child `extends` edges captured during resolution (directories, posix, no trailing slash),
* in walk order. Lets tooling rebuild the inheritance DAG the flat `layers` order flattens away.
* Optional: synthetic stacks built by hand may omit it.
*/
edges?: LayerEdge[]
} }
+8
View File
@@ -0,0 +1,8 @@
/**
* Normalize a path to forward slashes (POSIX-style). c12 returns posix-style `cwd`s while Node's
* `path` helpers are OS-native (backslashes on Windows); paths must be canonicalized to forward
* slashes before they are compared for dedup or emitted into a Vite config/alias, where posix is
* conventional. Shared by every module so the rule lives in exactly one place.
*/
const SEPARATOR_RE = /\\/g
export const toPosix = (p: string): string => p.replace(SEPARATOR_RE, '/')
+8 -30
View File
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { configWatchPlugin, featuresRuntimePlugin } from '../src/dev' import { configWatchPlugin } from '../src/dev'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p) const fixture = (p: string) => resolve(here, 'fixtures', p)
@@ -39,35 +39,13 @@ describe('configWatchPlugin', () => {
watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts')) watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts'))
expect(restart).not.toHaveBeenCalled() expect(restart).not.toHaveBeenCalled()
}) })
})
const runTransform = ( it('restarts when a config is newly added to a layer that had none', () => {
plugin: { transform?: unknown }, const plugin = configWatchPlugin([fixture('stack/app')])
code: string, const { server, watcher, restart } = mockServer()
id = '/app/src/x.ts', callConfigureServer(plugin, server)
): { code: unknown; map?: unknown } | null => { // app.config.js does not exist at startup, but it is a candidate path → `add` must restart.
const t = plugin.transform as watcher.emit('add', resolve(fixture('stack/app'), 'app.config.js'))
| ((this: unknown, c: string, i: string) => { code: unknown; map?: unknown } | null) expect(restart).toHaveBeenCalledTimes(1)
| undefined
return t ? t.call({}, code, id) : null
}
describe('featuresRuntimePlugin', () => {
it('applies only in serve mode', () => {
expect(featuresRuntimePlugin({}).apply).toBe('serve')
})
it('prepends a module-local __FEATURES__ with a rolldown-generated sourcemap', () => {
const out = runTransform(featuresRuntimePlugin({ billing: true }), 'export const x = __FEATURES__.billing')
const code = String(out?.code)
expect(code).toContain('const __FEATURES__={"billing":true};')
expect(code).toContain('export const x = __FEATURES__.billing')
expect((out?.map as { mappings?: string })?.mappings).toBeTruthy() // real sourcemap
})
it('ignores property access (_ctx.__FEATURES__) and node_modules', () => {
const p = featuresRuntimePlugin({ billing: true })
expect(runTransform(p, 'const a = _ctx.__FEATURES__.billing')).toBeNull()
expect(runTransform(p, 'export const x = __FEATURES__.billing', '/x/node_modules/y.js')).toBeNull()
}) })
}) })
+250
View File
@@ -0,0 +1,250 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import type { JsonRenderElement, JsonRenderSpec, ViteDevToolsNodeContext } from '@vitejs/devtools-kit'
import { resolveLayerStack } from '../src/config'
import { inheritanceTreeText, layersDevtoolsPlugin, type LayersDevtoolsData } from '../src/devtools'
import { createLayeredResolution } from '../src/resolve'
import type { LayerStack } from '../src/types'
const here = dirname(fileURLToPath(import.meta.url))
const toPosix = (p: string) => p.replace(/\\/g, '/')
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'devtools', p))
const env = { command: 'serve', mode: 'development', isSsrBuild: false, isPreview: false } as const
/**
* Validate a json-render spec is renderable: the root exists, every referenced child id exists, and
* every action wired to a button is one our plugin actually registers. Catches the classic broken
* spec (a dangling child id) that would render as a blank panel.
*/
function assertValidSpec(spec: JsonRenderSpec, registeredActions: Set<string>) {
expect(spec.elements[spec.root], `root "${spec.root}" missing`).toBeTruthy()
const visit = (el: JsonRenderElement) => {
for (const childId of el.children ?? []) {
expect(spec.elements[childId], `dangling child id "${childId}"`).toBeTruthy()
}
const press = (el.on as { press?: { action?: string } } | undefined)?.press
if (press?.action) expect(registeredActions.has(press.action), `unknown action "${press.action}"`).toBe(true)
}
for (const el of Object.values(spec.elements)) visit(el)
}
interface RendererHandle {
spec: JsonRenderSpec
updateSpec: (s: JsonRenderSpec) => void
updateState: (s: Record<string, unknown>) => void
_stateKey: string
}
/** A minimal stand-in for the kit's node context — records what the plugin registers. */
function makeCtx() {
const docks: Array<{ entry: Record<string, unknown>; patches: Array<Record<string, unknown>> }> = []
const rpc = new Map<string, (params?: Record<string, unknown>) => unknown>()
const commands: Array<Record<string, unknown>> = []
const messages: Array<Record<string, unknown>> = []
const renderers: RendererHandle[] = []
const ctx = {
createJsonRenderer(spec: JsonRenderSpec): RendererHandle {
const handle: RendererHandle = {
spec,
_stateKey: `state:${renderers.length}`,
updateSpec(s) {
handle.spec = s
},
updateState() {},
}
renderers.push(handle)
return handle
},
docks: {
register(entry: Record<string, unknown>) {
const rec = { entry, patches: [] as Array<Record<string, unknown>> }
docks.push(rec)
return { update: (patch: Record<string, unknown>) => rec.patches.push(patch) }
},
},
rpc: {
register(def: { name: string; setup: () => { handler: (p?: Record<string, unknown>) => unknown } }) {
rpc.set(def.name, def.setup().handler)
},
},
commands: {
register(cmd: Record<string, unknown>) {
commands.push(cmd)
return { id: cmd.id, update() {}, unregister() {} }
},
},
messages: {
add(input: Record<string, unknown>) {
messages.push(input)
return Promise.resolve({ id: String(input.id ?? ''), entry: input, update: async () => undefined, dismiss: async () => {} })
},
},
}
return { ctx: ctx as unknown as ViteDevToolsNodeContext, docks, rpc, commands, messages, renderers }
}
describe('layersDevtoolsPlugin', () => {
let stack: LayerStack
let data: LayersDevtoolsData
beforeAll(async () => {
stack = await resolveLayerStack(fixture('app'))
data = {
appDir: fixture('app'),
env,
stack,
resolution: createLayeredResolution({ roots: stack.layers.map(l => l.srcDir), record: 50 }),
tsconfig: {},
}
})
it('returns a Vite plugin carrying a devtools.setup hook', () => {
const plugin = layersDevtoolsPlugin(data)
expect(plugin.name).toBe('vite-layers:devtools')
expect(typeof plugin.devtools?.setup).toBe('function')
})
it('resolves the expected two-layer fixture stack (app over base)', () => {
expect(stack.layers.map(l => l.name)).toEqual(['app', 'base'])
expect(stack.merged.features).toMatchObject({ billing: false, shared: 'base' })
})
it('registers a group + four json-render panels, all with valid specs', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
const ids = m.docks.map(d => d.entry.id)
expect(ids).toContain('vite-layers')
expect(ids).toEqual(expect.arrayContaining(['vite-layers:layers', 'vite-layers:features', 'vite-layers:resolver', 'vite-layers:assets']))
const group = m.docks.find(d => d.entry.id === 'vite-layers')!.entry
expect(group.type).toBe('group')
const panels = m.docks.filter(d => d.entry.type === 'json-render')
expect(panels).toHaveLength(4)
for (const p of panels) expect(p.entry.groupId).toBe('vite-layers')
const actions = new Set(m.rpc.keys())
for (const h of m.renderers) assertValidSpec(h.spec, actions)
})
it('registers the refresh / resolve / clear-log actions and a refresh command', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
expect([...m.rpc.keys()]).toEqual(
expect.arrayContaining(['vite-layers:refresh', 'vite-layers:resolve', 'vite-layers:clear-log']),
)
expect(m.commands.some(c => c.id === 'vite-layers:refresh')).toBe(true)
})
it('badges the Features panel with the disabled-flag count', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
const featuresDock = m.docks.find(d => d.entry.id === 'vite-layers:features')!
// `billing` is the only leaf flag disabled in the merged stack.
expect(featuresDock.patches.some(p => p.badge === '1')).toBe(true)
})
it('emits an init message summarizing the stack', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
expect(m.messages).toHaveLength(1)
expect(m.messages[0]).toMatchObject({ level: 'info', category: 'vite-layers' })
expect(String(m.messages[0]!.message)).toContain('2 layers')
})
it('resolve action computes the candidate stack and rebuilds the resolver panel', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
const resolverDock = m.docks.find(d => d.entry.id === 'vite-layers:resolver')!
const resolverUi = resolverDock.entry.ui as RendererHandle
await m.rpc.get('vite-layers:resolve')!({ id: '@/components/Header.vue' })
const json = JSON.stringify(resolverUi.spec)
// app/Header.vue wins, base/Header.vue is shadowed — both candidate files appear.
expect(json).toContain(toPosix(resolve(fixture('app'), 'src/components/Header.vue')))
expect(json).toContain(toPosix(resolve(fixture('base'), 'src/components/Header.vue')))
expect(json).toContain('winner')
expect(json).toContain('shadowed')
const actions = new Set(m.rpc.keys())
assertValidSpec(resolverUi.spec, actions)
})
it('reports a friendly error for a non-layered id', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
const resolverUi = m.docks.find(d => d.entry.id === 'vite-layers:resolver')!.entry.ui as RendererHandle
await m.rpc.get('vite-layers:resolve')!({ id: 'vue' })
expect(JSON.stringify(resolverUi.spec)).toContain('Not a layered id')
})
it('works with tsconfig autogen disabled', async () => {
const m = makeCtx()
await layersDevtoolsPlugin({ ...data, tsconfig: false }).devtools!.setup!(m.ctx)
const assetsUi = m.docks.find(d => d.entry.id === 'vite-layers:assets')!.entry.ui as RendererHandle
expect(JSON.stringify(assetsUi.spec)).toContain('disabled')
})
it('renders the inheritance tree into the Layers panel', async () => {
const m = makeCtx()
await layersDevtoolsPlugin(data).devtools!.setup!(m.ctx)
const layersUi = m.docks.find(d => d.entry.id === 'vite-layers:layers')!.entry.ui as RendererHandle
const json = JSON.stringify(layersUi.spec)
expect(json).toContain('Inheritance (extends graph)')
expect(json).toContain('└── base')
})
})
describe('inheritanceTreeText', () => {
let appStack: LayerStack
beforeAll(async () => {
appStack = await resolveLayerStack(fixture('app'))
})
it('draws a simple chain (app extends base)', () => {
const tree = inheritanceTreeText(appStack)
const lines = tree.split('\n')
expect(lines[0]).toMatch(/^app {2}#0 {3}\(project/)
expect(lines[1]).toBe('└── base #1')
})
it('draws a diamond once, marking the repeated node with ↑ above (no infinite recursion)', async () => {
const stack = await resolveLayerStack(toPosix(resolve(here, 'fixtures', 'diamond', 'app')))
const tree = inheritanceTreeText(stack)
// app → b → d, and app → c → d (d is the diamond tip, reached twice)
expect(tree).toContain('├── b')
expect(tree).toContain('└── c')
expect(tree).toContain('↑ above') // d's second occurrence is collapsed, not re-expanded
// d is drawn exactly once in full + once as a back-reference
expect(tree.match(/^.*── d {2}#\d/gm)?.length).toBe(2)
})
it('marks an edge to a non-layer (npm/git) target as external', () => {
const synthetic: LayerStack = {
merged: {},
layers: [{ name: 'app', rootDir: '/x/app', srcDir: '/x/app/src', config: {} }],
edges: [{ from: '/x/app', to: '/x/node_modules/some-npm-layer', source: 'some-npm-layer' }],
}
expect(inheritanceTreeText(synthetic)).toContain('some-npm-layer (external)')
})
it('lists layers not reached via the edge graph (auto-scan fallback)', () => {
const synthetic: LayerStack = {
merged: {},
layers: [
{ name: 'app', rootDir: '/x/app', srcDir: '/x/app/src', config: {} },
{ name: 'scanned', rootDir: '/x/scanned', srcDir: '/x/scanned/src', config: {} },
],
edges: [], // nothing links to `scanned`
}
const tree = inheritanceTreeText(synthetic)
expect(tree).toContain('not reached via extends')
expect(tree).toContain('• scanned #1')
})
})
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import type { Plugin } from 'vite'
import { FEATURE_MODULE, featurePlugin, featuresDts, flattenFeatures } from '../src/features'
// Minimal TransformPluginContext stand-in: `this.error` throws (as it does for a real build failure).
const ctx = {
error(msg: string | { message: string }): never {
throw new Error(typeof msg === 'string' ? msg : msg.message)
},
}
function transform(features: Record<string, unknown>, code: string, id = '/app/src/x.ts') {
const t = featurePlugin(features).transform as Plugin['transform']
const handler = (typeof t === 'function' ? t : t!.handler) as (
this: unknown,
code: string,
id: string,
) => { code: string; map?: unknown } | null
return handler.call(ctx, code, id)
}
describe('featurePlugin', () => {
it("replaces feature('key') with the flag literal and removes the import", () => {
const out = transform({ billing: false }, `import { feature } from '#feature'\nexport const r = feature('billing') ? 1 : 2\n`)
expect(out).not.toBeNull()
expect(out!.code).toContain('export const r = false ? 1 : 2')
expect(out!.code).not.toContain("from '#feature'")
expect((out!.map as { mappings?: string }).mappings).toBeTruthy()
})
it('resolves nested dotted keys', () => {
const out = transform({ nested: { deep: { on: true } } }, `import { feature } from '#feature'\nconst a = feature('nested.deep.on')\n`)
expect(out!.code).toContain('const a = true')
})
it('substitutes an object-valued key as a parenthesized literal (valid in any position)', () => {
const out = transform({ nested: { on: true } }, `import { feature } from '#feature'\nconst a = feature('nested')\n`)
expect(out!.code).toContain('const a = ({"on":true})')
})
it('honours an import alias (import { feature as f })', () => {
const out = transform({ billing: true }, `import { feature as f } from '#feature'\nconst a = f('billing')\n`)
expect(out!.code).toContain('const a = true')
})
it('also accepts the vite-layers/feature specifier', () => {
const out = transform({ billing: true }, `import { feature } from 'vite-layers/feature'\nconst a = feature('billing')\n`)
expect(out!.code).toContain('const a = true')
})
it('parses TSX and gates JSX expressions', () => {
const out = transform({ billing: false }, `import { feature } from '#feature'\nexport const n = feature('billing') && 1\n`, '/app/src/x.tsx')
expect(out!.code).toContain('export const n = false && 1')
})
it('fails the build on a dynamic (non-literal) key', () => {
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nconst k = 'billing'\nexport const a = feature(k)\n`))
.toThrow(/single string-literal key/)
})
it('fails the build when the macro is aliased / passed as a value', () => {
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nexport const g = feature\n`))
.toThrow(/compile-time macro/)
})
it('fails the build on an unknown flag', () => {
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nexport const a = feature('bling')\n`))
.toThrow(/unknown feature flag 'bling'/)
})
it('fails the build on re-exporting the macro', () => {
expect(() => transform({ billing: true }, `export { feature } from '#feature'\n`))
.toThrow(/re-exporting the `feature` macro/)
})
it('fails the build on a default import of the macro', () => {
expect(() => transform({ billing: true }, `import feature from '#feature'\nconst a = feature('billing')\n`))
.toThrow(/named \{ feature \}/)
})
it('fails the build on a namespace import of the macro', () => {
expect(() => transform({ billing: true }, `import * as F from '#feature'\nconst a = F.feature('billing')\n`))
.toThrow(/named \{ feature \}/)
})
it('leaves modules without the macro import untouched (even if the token appears in a string)', () => {
expect(transform({ billing: true }, 'export const x = 1\n')).toBeNull()
expect(transform({ billing: true }, `export const s = 'mentions #feature in a string'\n`)).toBeNull()
})
it('fails loudly (never silently skips) when a module that imports the macro fails to parse', () => {
// oxc reports errors without throwing and yields an empty body — which must NOT look like "no macro".
expect(() => transform({ billing: true }, `import { feature } from '#feature'\nconst x = @@@ broken(((`))
.toThrow(/syntax error|could not parse/i)
})
it('does not over-fail: a broken module that only mentions #feature in a string is left alone', () => {
expect(transform({ billing: true }, `const s = 'see #feature'\nconst x = @@@ broken(((`)).toBeNull()
})
it('skips node_modules', () => {
expect(transform({ billing: true }, `import { feature } from '#feature'\nconst a = feature('billing')\n`, '/x/node_modules/y.js')).toBeNull()
})
it('leaves an unrelated local named `feature` (param/const) untouched — no false positive', () => {
const arrow = transform({ billing: true }, `import { feature } from '#feature'\nexport const a = feature('billing')\nexport const xs = [1].map(feature => feature + 1)\n`)
expect(arrow!.code).toContain('export const a = true') // the real macro call still folds
expect(arrow!.code).toContain('feature => feature + 1') // the shadowing param is left alone
const local = transform({ billing: true }, `import { feature } from '#feature'\nexport function f(){ const feature = () => 1; return feature() }\nexport const a = feature('billing')\n`)
expect(local!.code).toContain('const feature = () => 1; return feature()')
expect(local!.code).toContain('export const a = true')
})
it('allows type-position references (typeof feature) and ignores `import type`', () => {
const out = transform({ billing: true }, `import { feature } from '#feature'\ntype T = typeof feature\nexport const a = feature('billing')\n`)
expect(out!.code).toContain('export const a = true') // the value call folds; the type query is skipped
// a pure `import type { feature }` is erased — nothing to compile
expect(transform({ billing: true }, `import type { feature } from '#feature'\nexport type T = typeof feature\n`)).toBeNull()
})
it('fails the build (never silently mis-substitutes) on a malformed template-literal key', () => {
// An untagged template with a bad escape is a parse error → caught loudly; if it ever parsed
// with a null cooked value, stringKey routes it to the string-literal-key error instead.
expect(() => transform({ billing: true }, 'import { feature } from \'#feature\'\nconst a = feature(`\\unicode`)\n'))
.toThrow(/vite-layers/)
})
})
describe('feature value validation', () => {
it('rejects unsupported value types with a clear error (plugin + dts)', () => {
for (const features of [{ a: 1n }, { a: () => 1 }, { a: Number.NaN }, { a: Number.POSITIVE_INFINITY }, { a: Symbol('x') }, { a: new Date() }]) {
expect(() => featurePlugin(features as Record<string, unknown>)).toThrow(/unsupported value type/)
expect(() => featuresDts(features as Record<string, unknown>)).toThrow(/unsupported value type/)
}
})
it('rejects a dotted key colliding with a nested path', () => {
expect(() => featurePlugin({ 'a.b': 1, a: { b: 2 } })).toThrow(/defined twice/)
expect(() => featuresDts({ 'a.b': 1, a: { b: 2 } })).toThrow(/defined twice/)
})
it('accepts JSON-like values (bool, finite number, string, null, plain object, array)', () => {
expect(() => featurePlugin({ a: true, b: 1.5, c: 'x', d: null, e: { f: 1 }, g: ['x', 2] })).not.toThrow()
})
})
describe('featuresDts', () => {
it('augments LayerFeatures on #feature with literal types and dotted keys', () => {
const dts = featuresDts({ billing: false, nested: { enabled: true }, 'kebab-flag': true, count: 2 })
expect(dts).toContain(`import '${FEATURE_MODULE}'`)
expect(dts).toContain(`declare module '${FEATURE_MODULE}'`)
expect(dts).toContain('interface LayerFeatures')
expect(dts).toContain('billing: false') // literal, not widened `boolean`
expect(dts).toContain('nested: { enabled: true }')
expect(dts).toContain('"nested.enabled": true') // dotted leaf key for direct DCE access
expect(dts).toContain('"kebab-flag": true') // non-identifier keys are now fully supported
expect(dts).toContain('count: 2')
})
it('renders an empty augmentation when there are no features', () => {
expect(featuresDts({})).toContain('interface LayerFeatures {\n }')
})
})
describe('flattenFeatures', () => {
it('emits both intermediate and leaf dotted paths in order', () => {
expect(flattenFeatures({ a: { b: 1 }, c: true })).toEqual([
['a', { b: 1 }],
['a.b', 1],
['c', true],
])
})
})
+5
View File
@@ -0,0 +1,5 @@
export default {
name: 'app',
extends: ['../base'],
features: { billing: false }, // overrides base — disabled leaf → DCE
}
@@ -0,0 +1 @@
<svg><!-- app logo --></svg>

After

Width:  |  Height:  |  Size: 29 B

@@ -0,0 +1 @@
<template><header>app header</header></template>
+7
View File
@@ -0,0 +1,7 @@
export default {
name: 'base',
features: { billing: true, shared: 'base', nested: { on: true } },
hooks: {
'layers:resolved': () => {},
},
}
@@ -0,0 +1 @@
<svg><!-- base favicon --></svg>

After

Width:  |  Height:  |  Size: 33 B

@@ -0,0 +1 @@
<svg><!-- base logo --></svg>

After

Width:  |  Height:  |  Size: 30 B

@@ -0,0 +1 @@
<template><footer>base footer</footer></template>
@@ -0,0 +1 @@
<template><header>base header</header></template>
@@ -0,0 +1 @@
<template><div>billing</div></template>
@@ -0,0 +1 @@
<!-- deep/base --><template><span>base</span></template>
@@ -0,0 +1 @@
<!-- deep/mid --><template><span>mid</span></template>
@@ -0,0 +1 @@
<!-- deep/top --><template><span>top</span></template>
+28 -31
View File
@@ -13,41 +13,37 @@ async function build(appDir: string): Promise<UserConfig> {
return (await fn(env)) as UserConfig return (await fn(env)) as UserConfig
} }
const featCtx = {
error(m: string | { message: string }): never {
throw new Error(typeof m === 'string' ? m : m.message)
},
}
const runTransform = (plugin: Plugin, code: string, id = '/app/src/x.ts') => {
const t = plugin.transform as Plugin['transform']
const handler = (typeof t === 'function' ? t : t!.handler) as (
this: unknown,
c: string,
i: string,
) => { code?: unknown } | null
return handler.call(featCtx, code, id)
}
describe('buildViteConfig', () => { describe('buildViteConfig', () => {
it('exposes merged features via __FEATURES__ define (for DCE)', async () => { it('registers the feature macro plugin and aliases #feature to the macro entry', async () => {
const cfg = await build(fixture('stack/app')) const cfg = await build(fixture('stack/app'))
const features = JSON.parse((cfg.define as Record<string, string>).__FEATURES__) const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
expect(features.shared).toBe('app') expect(plugins.some(p => p?.name === 'vite-layers:features')).toBe(true)
expect(features).toMatchObject({ app: true, base: true, core: true }) const alias = (cfg.resolve as { alias: Record<string, string> }).alias
expect(alias['#feature']).toMatch(/\/src\/feature\.ts$/)
}) })
it('emits dotted feature defines (for dead-code elimination of gated imports)', async () => { it('emits no __FEATURES__ define (flags compile via the feature() macro, not define)', async () => {
const cfg = await build(fixture('stack/app')) const cfg = await build(fixture('stack/app'))
const define = cfg.define as Record<string, string> const define = (cfg.define ?? {}) as Record<string, string>
// dotted entry is folded by esbuild to a literal → enables DCE of `__FEATURES__.x ? import() : []` expect(Object.keys(define).some(k => k.startsWith('__FEATURES__'))).toBe(false)
expect(define['__FEATURES__.shared']).toBe('"app"')
expect(define['__FEATURES__.app']).toBe('true')
}) })
it('emits dotted defines at every nesting depth (so nested flags also DCE)', async () => { it('compiles feature() against the merged flags; layers:resolved mutates them first, vite:config runs last', async () => {
const cfg = await build(fixture('features/app'))
const define = cfg.define as Record<string, string>
expect(define['__FEATURES__.billing']).toBe('false')
expect(define['__FEATURES__.nested.enabled']).toBe('false') // deep leaf → foldable → DCE-able
expect(define['__FEATURES__.nested.deep.on']).toBe('true')
expect(define['__FEATURES__.nested']).toBe('{"enabled":false,"deep":{"on":true}}') // intermediate object too
})
it('skips non-identifier feature keys in dotted defines (avoids INVALID_DEFINE_CONFIG crash)', async () => {
const cfg = await build(fixture('features/app'))
const define = cfg.define as Record<string, string>
// a dotted define with `kebab-flag` would crash the build; it is skipped here…
expect(define['__FEATURES__.kebab-flag']).toBeUndefined()
// …but still readable at runtime via the whole-object define.
expect(JSON.parse(define.__FEATURES__)['kebab-flag']).toBe(true)
})
it('runs lifecycle hooks: layers:resolved mutates features (before define), vite:config mutates config', async () => {
const fn = (await buildViteConfig(fixture('stack/app'), { const fn = (await buildViteConfig(fixture('stack/app'), {
hooks: { hooks: {
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true), 'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
@@ -55,9 +51,10 @@ describe('buildViteConfig', () => {
}, },
})) as UserConfigFnObject })) as UserConfigFnObject
const cfg = (await fn(env)) as UserConfig const cfg = (await fn(env)) as UserConfig
const define = cfg.define as Record<string, string> const feat = (cfg.plugins as Plugin[]).flat(Infinity as 1).find(p => (p as Plugin)?.name === 'vite-layers:features') as Plugin
expect(define['__FEATURES__.injected']).toBe('true') // layers:resolved ran before featureDefines const out = runTransform(feat, `import { feature } from '#feature'\nexport const a = feature('injected')\n`)
expect(define.INJECTED).toBe('"yes"') // vite:config ran at the very end expect(String(out?.code)).toContain('export const a = true') // layers:resolved ran before the macro read features
expect((cfg.define as Record<string, string>).INJECTED).toBe('"yes"') // vite:config ran at the very end
}) })
it('registers the layers resolver plugin', async () => { it('registers the layers resolver plugin', async () => {
+52 -15
View File
@@ -1,6 +1,8 @@
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path' import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { describe, expect, it } from 'vitest' import { dirname, join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { publicLayersPlugin } from '../src/public' import { publicLayersPlugin } from '../src/public'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
@@ -8,30 +10,65 @@ const fixture = (p: string) => resolve(here, 'fixtures', p)
const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)() const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)()
function runGenerateBundle(p: { generateBundle?: unknown }): Record<string, string> { type ResolvedishConfig = { root: string; build: { outDir: string; copyPublicDir?: boolean } }
const emitted: Record<string, string> = {}
const ctx = { /** List every file written under `dir` as `posixRelativePath → contents`. */
emitFile: ({ fileName, source }: { fileName: string; source: Buffer | string }) => { function snapshot(dir: string): Record<string, string> {
emitted[fileName] = source.toString() const out: Record<string, string> = {}
}, const walk = (d: string) => {
for (const name of readdirSync(d, { withFileTypes: true })) {
const abs = join(d, name.name)
if (name.isDirectory()) walk(abs)
else out[resolve(abs).slice(resolve(dir).length + 1).replace(/\\/g, '/')] = readFileSync(abs, 'utf8')
} }
;(p.generateBundle as (this: unknown, ...a: unknown[]) => void).call(ctx, {}, {}, false) }
return emitted walk(dir)
return out
}
/** Drive the build-time hooks (configResolved → writeBundle) and return what landed on disk. */
function runBuild(
p: { configResolved?: unknown; writeBundle?: unknown },
outDir: string,
{ copyPublicDir, writeDir }: { copyPublicDir?: boolean; writeDir?: string } = {},
): Record<string, string> {
const cfg: ResolvedishConfig = { root: '/', build: { outDir, copyPublicDir } }
;(p.configResolved as (c: ResolvedishConfig) => void)(cfg)
;(p.writeBundle as (this: unknown, o: { dir?: string }) => void).call({}, { dir: writeDir ?? outDir })
return snapshot(outDir)
} }
describe('publicLayersPlugin', () => { describe('publicLayersPlugin', () => {
const high = fixture('public/high/public') const high = fixture('public/high/public')
const low = fixture('public/low/public') const low = fixture('public/low/public')
let outDir: string
beforeEach(() => {
outDir = mkdtempSync(join(tmpdir(), 'vite-layers-public-'))
})
afterEach(() => {
rmSync(outDir, { recursive: true, force: true })
})
it('disables Vite publicDir when layers have public/, otherwise no-op', () => { it('disables Vite publicDir when layers have public/, otherwise no-op', () => {
expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false }) expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false })
expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined() expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined()
}) })
it('emits assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => { it('copies assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
const emitted = runGenerateBundle(publicLayersPlugin([high, low])) const written = runBuild(publicLayersPlugin([high, low]), outDir)
expect(emitted['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer expect(written['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
expect(emitted['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer expect(written['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
expect(emitted['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer expect(written['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
})
it('skips the copy when Vite opts out (copyPublicDir: false)', () => {
const written = runBuild(publicLayersPlugin([high, low]), outDir, { copyPublicDir: false })
expect(written).toEqual({})
})
it('only copies for the output targeting the main outDir', () => {
const written = runBuild(publicLayersPlugin([high, low]), outDir, { writeDir: join(outDir, 'server') })
expect(written).toEqual({})
}) })
}) })
+99 -5
View File
@@ -1,17 +1,24 @@
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { layersResolver } from '../src/resolve' import type { Plugin } from 'vite'
import { createLayeredResolution, layersResolver } from '../src/resolve'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
const toPosix = (p: string) => p.replace(/\\/g, '/') const toPosix = (p: string) => p.replace(/\\/g, '/')
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p)) const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p))
// `resolveId` is now a filtered object hook (`{ filter, handler }`); call its handler.
const callResolveId = (plugin: Plugin, id: string, importer?: string): string | null => {
const h = plugin.resolveId
const fn = (typeof h === 'function' ? h : h?.handler) as (id: string, importer?: string) => string | null
return fn(id, importer)
}
// roots ordered high→low priority: brand overrides base. // roots ordered high→low priority: brand overrides base.
const roots = [fixture('brand/src'), fixture('base/src')] const roots = [fixture('brand/src'), fixture('base/src')]
const plugin = layersResolver({ roots }) const plugin = layersResolver({ roots })
const resolveId = (id: string, importer?: string): string | null => const resolveId = (id: string, importer?: string): string | null => callResolveId(plugin, id, importer)
(plugin.resolveId as (id: string, importer?: string) => string | null)(id, importer)
describe('layersResolver', () => { describe('layersResolver', () => {
it('ignores non-layered ids', () => { it('ignores non-layered ids', () => {
@@ -42,6 +49,27 @@ describe('layersResolver', () => {
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader) expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
}) })
describe('super() through a deep (3-layer) extends chain', () => {
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
const dp = layersResolver({ roots: deepRoots })
const drid = (id: string, importer?: string) => callResolveId(dp, id, importer)
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
it('a normal import resolves to the highest layer', () => {
expect(drid('@/components/Widget.vue')).toBe(W('top'))
})
it('super() resolves to the NEXT-LOWER layer at every level (never upward)', () => {
expect(drid('@/components/Widget.vue', W('top'))).toBe(W('mid'))
// the regression guard: a shadowed middle layer must reach `base`, not jump back up to `top`
expect(drid('@/components/Widget.vue', W('mid'))).toBe(W('base'))
})
it('super() from the lowest layer resolves to null (nothing beneath it)', () => {
expect(drid('@/components/Widget.vue', W('base'))).toBeNull()
})
})
it('returns null when nothing matches across layers', () => { it('returns null when nothing matches across layers', () => {
expect(resolveId('@/components/Missing.vue')).toBeNull() expect(resolveId('@/components/Missing.vue')).toBeNull()
}) })
@@ -54,15 +82,81 @@ describe('layersResolver', () => {
it('honors custom prefixes and extensions', () => { it('honors custom prefixes and extensions', () => {
const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] }) const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] })
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id) const rid = (id: string) => callResolveId(p, id)
expect(rid('#/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts')) // index probe, .ts only expect(rid('#/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts')) // index probe, .ts only
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
}) })
it('caches candidates (repeated resolveId is stable, served from cache)', () => { it('caches candidates (repeated resolveId is stable, served from cache)', () => {
const p = layersResolver({ roots }) const p = layersResolver({ roots })
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id) const rid = (id: string) => callResolveId(p, id)
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue')) expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue')) expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
}) })
}) })
describe('createLayeredResolution (introspection core)', () => {
it('parse() splits prefix/sub/query and rejects non-layered ids', () => {
const r = createLayeredResolution({ roots })
expect(r.parse('@/components/Header.vue?raw')).toEqual({ prefix: '@/', sub: 'components/Header.vue', query: '?raw' })
expect(r.parse('vue')).toBeNull()
expect(r.parse('#layers/base/x')).toBeNull()
})
it('candidates() lists every matching file across layers, high→low', () => {
const r = createLayeredResolution({ roots })
expect(r.candidates('components/Header.vue')).toEqual([
fixture('brand/src/components/Header.vue'),
fixture('base/src/components/Header.vue'),
])
expect(r.candidates('components/Footer.vue')).toEqual([fixture('base/src/components/Footer.vue')])
expect(r.candidates('components/Missing.vue')).toEqual([])
})
it('records resolutions only when enabled, newest-first, de-duplicated by id+importer', () => {
const off = createLayeredResolution({ roots })
off.resolveId('@/components/Header.vue')
expect(off.records()).toEqual([]) // recording disabled by default
const r = createLayeredResolution({ roots, record: 10 })
r.resolveId('@/components/Header.vue')
r.resolveId('@/components/Footer.vue')
r.resolveId('@/components/Header.vue') // repeat → updates the existing entry, no duplicate
const recs = r.records()
expect(recs).toHaveLength(2)
expect(recs[0]!.id).toBe('@/components/Header.vue') // most-recent first
expect(recs[0]!.candidates).toEqual([
fixture('brand/src/components/Header.vue'),
fixture('base/src/components/Header.vue'),
])
expect(recs[0]!.selfIndex).toBe(-1) // a normal (non-self) import
r.clearRecords()
expect(r.records()).toEqual([])
})
it('records a super() self-import with the importer position', () => {
const r = createLayeredResolution({ roots, record: 10 })
const brandHeader = fixture('brand/src/components/Header.vue')
expect(r.resolveId('@/components/Header.vue', brandHeader)).toBe(fixture('base/src/components/Header.vue'))
expect(r.records()[0]!.selfIndex).toBe(0) // importer is the top candidate → super() skips to #1
})
it('keeps the log bounded to the record size', () => {
const r = createLayeredResolution({ roots, record: 2 })
r.resolveId('@/components/Header.vue')
r.resolveId('@/components/Footer.vue')
r.resolveId('@/components/Missing.vue')
expect(r.records()).toHaveLength(2) // oldest (Header) evicted
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
})
it('the plugin and a shared resolution stay in sync', () => {
const shared = createLayeredResolution({ roots, record: 10 })
const plugin = layersResolver(shared)
callResolveId(plugin, '@/components/Header.vue')
// the resolution the plugin wraps recorded the resolveId the plugin handled
expect(shared.records()).toHaveLength(1)
expect(shared.records()[0]!.resolved).toBe(fixture('brand/src/components/Header.vue'))
})
})
+9 -15
View File
@@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { createLayerHooks } from '../src/hooks' import { createLayerHooks } from '../src/hooks'
import { featuresDts, generateTsConfig } from '../src/tsconfig' import { generateTsConfig } from '../src/tsconfig'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p) const fixture = (p: string) => resolve(here, 'fixtures', p)
@@ -104,7 +104,13 @@ describe('generateTsConfig', () => {
const r = await generateTsConfig(fixture('stack/app')) const r = await generateTsConfig(fixture('stack/app'))
expect(r.tsconfig.include).toContain('./features.d.ts') expect(r.tsconfig.include).toContain('./features.d.ts')
expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/) expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/)
expect(r.dts).toContain('const __FEATURES__:') expect(r.dts).toContain(`declare module '#feature'`)
})
it('maps #feature to the macro entry so tsc resolves the feature() import', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
expect(paths['#feature']?.[0]).toMatch(/\/src\/feature$/)
}) })
it('reuses a provided stack instead of resolving again (O2)', async () => { it('reuses a provided stack instead of resolving again (O2)', async () => {
@@ -117,18 +123,6 @@ describe('generateTsConfig', () => {
const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never }) const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]> const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]>
expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used
expect(r.dts).toContain('onlyInFake: boolean') expect(r.dts).toContain('onlyInFake: true') // literal type, from the fake stack's features
})
})
describe('featuresDts', () => {
it('renders a typed __FEATURES__ global (nested, primitives, quoted non-identifier keys)', () => {
const dts = featuresDts({ billing: true, nested: { enabled: false }, 'kebab-flag': true, count: 2 })
expect(dts).toContain('declare global')
expect(dts).toContain('const __FEATURES__:')
expect(dts).toContain('billing: boolean')
expect(dts).toContain('nested: { enabled: boolean }')
expect(dts).toContain('"kebab-flag": boolean')
expect(dts).toContain('count: number')
}) })
}) })
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'tsdown'
/**
* Library build for publishing. In this repo dev stays **buildless** — the example apps and tests
* import `../src/*` directly, and the package's top-level `exports` point at `./src/*.ts`. This build
* produces the `dist/` that `publishConfig.exports` points at (see package.json), so consumers get
* compiled ESM + `.d.ts` while local development keeps running straight off source.
*
* One entry per public subpath (`.`, `./feature`, `./devtools`). All runtime deps and the `vite` /
* `@vitejs/devtools-kit` peers are externalized automatically (tsdown externalizes dependencies and
* peerDependencies), so only vite-layers' own code is bundled.
*/
export default defineConfig({
entry: {
index: 'src/index.ts',
feature: 'src/feature.ts',
devtools: 'src/devtools.ts',
},
format: 'esm',
platform: 'node',
target: 'node24',
dts: true,
sourcemap: true,
clean: true,
outExtensions: () => ({ js: '.js', dts: '.d.ts' }),
publint: 'ci-only',
})