feat: add vite-layers

This commit is contained in:
2026-06-07 17:34:31 +07:00
parent aa3148f4e4
commit ecc958c9f0
94 changed files with 4149 additions and 248 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
probe
.vite-layers
*.tgz
+243
View File
@@ -0,0 +1,243 @@
# vite-layers
Framework-agnostic **слои в стиле Nuxt**, портированные на чистый Vite: файловые оверрайды через
`extends` + мёрж конфигов, плюс побрендовый build-time dead-code elimination. Работает с любым
фреймворком — у ядра **нет фреймворк-зависимостей**; `vue()`/`react()`/и т.п. подключаются на уровне слоя.
Логика стека слоёв (порядок, дедуп, авто-скан, алиасы) портирована напрямую из исходников Nuxt
(`@nuxt/kit` `loadNuxtConfig` + `@nuxt/schema`), с тремя осознанными улучшениями.
## Установка
```bash
pnpm add -D vite-layers # peer-зависимость: vite ^8
```
## Использование
Каждое приложение/бренд — это директория с `app.config.ts` и однострочным `vite.config.ts`:
```ts
// apps/main/app.config.ts
import { defineLayerConfig } from 'vite-layers'
export default defineLayerConfig({
name: 'main',
features: { billing: true },
vite: { plugins: [vue()] }, // фреймворк-плагин живёт здесь, а не в ядре
})
// apps/brand/app.config.ts — только диффы
import { defineLayerConfig } from 'vite-layers'
export default defineLayerConfig({
name: 'brand',
extends: ['../main'],
features: { billing: false }, // бренд полностью убирает страницу billing
})
// apps/<любой>/vite.config.ts
import { buildViteConfig } from 'vite-layers'
export default buildViteConfig(import.meta.dirname)
```
Чтобы перекрыть файл, положите его по тому же относительному пути в слое с более высоким приоритетом
(`apps/brand/src/components/Header.vue` затеняет `apps/main/src/components/Header.vue`).
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
```ts
// __FEATURES__ типизируется сгенерированным .vite-layers/features.d.ts — declare не нужен
const routes = [
{ path: '/', component: () => import('@/pages/Home') },
...(__FEATURES__.billing ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
]
```
Тип `__FEATURES__` генерируется из `merged.features` в `.vite-layers/features.d.ts`, поэтому опечатка
(`__FEATURES__.biling`) — ошибка компиляции, а не молчком-falsy.
Флаги вшиваются через `define` и сворачиваются esbuild ещё **до** построения графа Rollup, поэтому
выключенная ветка и её `import()` физически не попадают в бандл. Дотированные литералы эмитятся на
любую глубину (`__FEATURES__.nested.enabled` тоже сворачивается). Правила, чтобы DCE сработало:
- обращайтесь **напрямую**`__FEATURES__.billing`; алиас/деструктуризация (`const f = __FEATURES__; f.billing`)
и динамический доступ (`__FEATURES__[name]`) не сворачиваются;
- гейт оборачивает сам `import()` (тернарник/`&&`/спред), а не `.filter` после — reachable-импорт не вырезается;
- ключи фич — валидные JS-идентификаторы (kebab/пробел доступны в рантайме через объект `__FEATURES__`,
но без DCE);
- в тестах продублируйте `define``vitest.config`) или гардите `globalThis.__FEATURES__ ?? {}`.
**Dev-режим.** В build флаги работают через `define` (+ DCE). В dev Vite 8 / rolldown-vite **не**
инлайнит пользовательский `define` в исходники, поэтому `vite-layers` сам подставляет `__FEATURES__`
в рантайме (dev-only плагин). Плюс при изменении любого `app.config.*` слоя dev-сервер
**автоматически перезапускается** (`app.config` грузится c12, вне графа Vite — сам он не следит) —
так фичи обновляются без ручного рестарта. В шаблонах `.vue` `__FEATURES__` напрямую использовать
нельзя — компилятор префиксует его в `_ctx.__FEATURES__` (define/рантайм-подстановка не матчат);
читайте флаг в `<script setup>` и используйте в шаблоне локальную переменную.
## Префиксы импортов
| Префикс | Куда резолвится | Примечания |
|---|---|---|
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
## Модель приоритета (из Nuxt)
`layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину;
авто-сканированные `layers/*` сортируются по убыванию (`Z` > `A`, выше числовой префикс — выше приоритет).
Коллизии решаются как **меньший индекс слоя выигрывает**. Конфиги мёржатся через `defu` (проект
выигрывает; массивы конкатятся).
## Слоёвые `public/`-ассеты (брендинг)
У каждого слоя может быть своя `public/` — резолвится **first-match по слоям**, как `@/`:
`brand/public/logo.svg` затеняет `main/public/logo.svg`, а `favicon.svg` из базы наследуется.
Удобно для лого/favicon/шрифтов per-brand. Работает и в dev (отдаётся через sirv по приоритету),
и в build (эмитится в `outDir`, верхний слой перетирает нижний). `publicDir` Vite при этом
отключается автоматически (он одиночный) — плагин берёт обслуживание на себя.
```
apps/main/public/{logo.svg, favicon.svg} # база
apps/brand/public/logo.svg # бренд перекрывает только лого
→ dist/brand/{logo.svg = brand, favicon.svg = main}
```
## Env-оверрайды слоёв
Слой в `app.config.ts` может переопределять себя по Vite `mode` (через c12 `$<env>`/`$env`):
```ts
export default defineLayerConfig({
features: { analytics: false },
$production: { features: { analytics: true } }, // применится при mode=production
})
```
## Опции
`buildViteConfig(appDir, options?)`:
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}``GenerateTsConfigOptions`.
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
## Хуки жизненного цикла
Как в Nuxt (на `unjs/hookable`): типизированные, **серийные в порядке слоёв (база первой)**,
**mutation-style** (хендлер мутирует общий аргумент). Хуки каждого слоя из `app.config.ts`
**накапливаются** (одноимённые из разных слоёв все выполняются), не перетираются.
```ts
export default defineLayerConfig({
hooks: {
'layers:resolved': (stack) => { stack.merged.features ??= {}; /* править merged/features/layers */ },
'vite:config': (ctx) => { ctx.config.plugins?.push(myPlugin()) }, // финальный Vite-конфиг
'tsconfig:generate': (ctx) => { ctx.tsconfig.compilerOptions!.strict = true }, // перед записью
},
})
```
| Хук | Аргумент | Когда |
|---|---|---|
| `layers:resolved` | `LayerStack` | после резолва стека (до чтения features/алиасов) |
| `vite:config` | `{ config, env, stack }` | финальный Vite-конфиг перед возвратом |
| `tsconfig:generate` | `{ appDir, tsconfig, stack }` | сгенерированный tsconfig перед записью |
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`.
## Улучшения над Nuxt/c12
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
чтобы дотянуться до базового файла. В Nuxt такого механизма нет.
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
`resolve`-хуке c12 обрывает рекурсию.
3. **Побрендовый DCE** — гейтированные динамические `import()` выпиливаются из бандлов выключенных
брендов через дотированные `__FEATURES__.<key>` defines (esbuild сворачивает литерал ещё до того,
как Rollup построит граф модулей).
## TypeScript (автогенерация tsconfig)
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` типизирует
`__FEATURES__`); `tsconfig.json` приложения его расширяет:
```jsonc
// apps/brand/tsconfig.json
{ "extends": "./.vite-layers/tsconfig.json" }
// сюда добавляются фреймворк-опции (для Vue: "jsx": "preserve", "jsxImportSource": "vue")
```
Сгенерированный `paths['@/*']`/`['~/*']` — это массив **`srcDir` всех слоёв в порядке приоритета**,
поэтому `tsc`/`vue-tsc` резолвит слоёвые импорты по first-existing-file — точно как рантайм-резолвер.
`~~`/`@@` → корень проекта; `#layers/<name>` → каждый слой.
**Два tsconfig — как в Nuxt (app + node).** `tsconfig.json` — для кода приложения (слои, DOM, `paths`);
`tsconfig.node.json` — для конфиг-файлов (`vite.config.*`/`app.config.*` всех слоёв) с node-типами,
без DOM и без слоёвых `paths`. Проверять оба:
```bash
vue-tsc --noEmit -p apps/brand # код приложения
vue-tsc --noEmit -p apps/brand/.vite-layers/tsconfig.node.json # конфиг-файлы (node)
```
**Настройка на уровне слоя** через поле `tsConfig` в `app.config.ts` (это pkg-types
[`TSConfig`](https://github.com/unjs/pkg-types)), мёржится по стеку как Nuxt `typescript.tsConfig`
сгенерированные `paths` всегда побеждают:
```ts
// apps/main/app.config.ts
export default defineLayerConfig({
name: 'main',
tsConfig: { compilerOptions: { jsxImportSource: 'vue', strict: true } }, // наследуется брендами
})
```
Ручная генерация для CI:
```bash
vite-layers prepare apps/brand # пишет apps/brand/.vite-layers/tsconfig.json
vue-tsc --noEmit -p apps/brand # или tsc --noEmit
```
Отключить авто-запись: `buildViteConfig(dir, { tsconfig: false })`. Добавьте `.vite-layers/` в `.gitignore`.
## API
- `buildViteConfig(appDir, options?)` — дефолтный экспорт для `vite.config.ts` (резолвер + автоген
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
- `resolveLayerStack(cwd)``{ merged, layers }` — резолвнутый упорядоченный стек.
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно).
- `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
## Пример (Vue)
`example/apps/{main,brand}` — запускаемое Vue-демо. `main` — база (`vue()` + страница `billing`),
`brand` расширяет её, перекрывает `AppHeader.vue` и выключает `billing`. Соберите оба и сравните:
```bash
npx vite build example/apps/main # эмитит чанки Home + Billing
npx vite build example/apps/brand # только Home (чанка Billing НЕТ → DCE); AppHeader перекрыт
```
Что демонстрирует демо:
- **Оверрайд:** `brand/src/components/AppHeader.vue` затеняет версию из `main` (`@/components/AppHeader.vue`).
- **DCE:** `features.billing: false` → динамический `import('@/pages/Billing.vue')` мёртв → чанк не эмитится.
- **Алиасы/резолвер:** `main.ts` тянет страницы и компонент через `@/…` сквозь слои.
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`), `vue-tsc` зелёный:
```bash
vite-layers prepare example/apps/brand
npx vue-tsc --noEmit -p example/apps/brand
```
## Тесты
```bash
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, defines, tsconfig
pnpm type-check
```
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// CLI for vite-layers. Loads the TypeScript source via jiti (no build step needed).
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { createJiti } from 'jiti'
const here = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const [cmd, appArg] = process.argv.slice(2)
if (cmd !== 'prepare') {
console.error('Usage: vite-layers prepare [appDir]')
process.exit(cmd ? 1 : 0)
}
const appDir = resolve(process.cwd(), appArg ?? '.')
const { writeTsConfig } = await jiti.import(resolve(here, '../src/tsconfig.ts'))
const file = await writeTsConfig(appDir)
console.log(`vite-layers: wrote ${file}`)
@@ -0,0 +1,7 @@
import { defineLayerConfig } from '../../../src/index.ts'
export default defineLayerConfig({
name: 'brand',
extends: ['../main'],
features: { billing: false }, // brand drops the billing page entirely (DCE)
})
+13
View File
@@ -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>vite-layers — brand</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1 @@
BRAND_LOGO_OVERRIDE
@@ -0,0 +1,9 @@
<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,4 @@
// Brand 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,3 @@
{
"extends": "./.vite-layers/tsconfig.json"
}
@@ -0,0 +1,3 @@
import { buildViteConfig } from '../../../src/index.ts'
export default buildViteConfig(import.meta.dirname)
@@ -0,0 +1,18 @@
import vue from '@vitejs/plugin-vue'
import { defineLayerConfig } from '../../../src/index.ts'
export default defineLayerConfig({
name: 'main',
features: { billing: true, p2p: true },
// The framework plugin lives in the layer config, not in vite-layers' core.
vite: {
plugins: [vue()],
build: { rolldownOptions: { input: { main: '@/main.ts' } } },
},
// Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig).
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue' } },
$production: {
features: { p2p: false },
}
})
+13
View File
@@ -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>vite-layers — main</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1 @@
SHARED_FAVICON
@@ -0,0 +1 @@
MAIN_LOGO_SVG
@@ -0,0 +1,7 @@
<script setup lang="ts">
const title = 'MAIN_HEADER'
</script>
<template>
<header>{{ title }}</header>
</template>
+27
View File
@@ -0,0 +1,27 @@
import { createApp, defineAsyncComponent, h, shallowRef, type Component } from 'vue'
const AppHeader = defineAsyncComponent(() => import('@/components/AppHeader.vue'))
// `__FEATURES__` is typed by the generated `.vite-layers/features.d.ts` — no manual `declare` needed.
// Pages are gated on build-time feature flags: a disabled page's dynamic import() is
// 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')
@@ -0,0 +1,3 @@
<template>
<main>BILLING_PAGE_HEAVY_MARKER</main>
</template>
@@ -0,0 +1,3 @@
<template>
<main>Home page</main>
</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)
+46
View File
@@ -0,0 +1,46 @@
{
"name": "vite-layers",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Framework-agnostic Nuxt-style layers (extends-based file override + config merge) ported to plain Vite.",
"engines": {
"node": ">=24.0.0"
},
"exports": {
".": "./src/index.ts"
},
"bin": {
"vite-layers": "./bin/vite-layers.mjs"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"example:build": "vite build example/apps/main && vite build example/apps/brand",
"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"
},
"peerDependencies": {
"vite": "^8.0.0"
},
"dependencies": {
"c12": "^3.3.4",
"defu": "^6.1.4",
"hookable": "^6.1.1",
"jiti": "^2.4.0",
"magic-string": "^0.30.21",
"pkg-types": "^2.3.1",
"sirv": "^3.0.2",
"tinyglobby": "^0.2.15",
"ufo": "^1.6.1"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.7",
"vue": "^3.5.35",
"vue-tsc": "^3.3.3"
}
}
+1187
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
import { basename, relative, resolve } from 'node:path'
import { loadConfig, type ConfigLayer } from 'c12'
import { createDefu } from 'defu'
import { glob } from 'tinyglobby'
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
import type { Layer, LayerConfig, LayerStack } from './types'
/** Identity helper for typed `app.config.ts` files. */
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.
* (See `@nuxt/kit` `loadNuxtConfig`.)
*/
const merger = createDefu((obj, key, value) => {
const target = obj as Record<PropertyKey, unknown>
if (Array.isArray(target[key]) && Array.isArray(value)) {
target[key] = (target[key] as unknown[]).concat(value)
return true
}
return false
})
/**
* Resolve the full layer stack for an app directory, faithfully porting Nuxt's
* `loadNuxtConfig` behavior on top of c12:
*
* 1. Auto-scan `layers/*` and prepend them (descending sort → "Z>A" / higher numeric prefix wins).
* 2. Load + merge the `extends` graph via c12 (`defu`, arrays concatenated, project wins).
* 3. Normalize: dedup layers by resolved `rootDir` (first-wins), resolve `srcDir` and layer name.
*
* Improvement over Nuxt/c12: a cycle-guard in c12's `resolve` hook. Raw c12 neither dedups nor
* detects cycles and will stack-overflow on a back-edge (`A→B→A`); Nuxt's own dedup runs only
* *after* c12's recursive walk, so it does not prevent the overflow. Returning a terminal empty
* layer the second time a source is seen cuts the recursion (returning null/undefined would fall
* back to c12's default resolution and still recurse).
*
* Pass `mode` (typically Vite's `env.mode`) to enable per-layer environment overrides — c12 applies
* a layer's `$development`/`$production`/`$env[mode]` block when `mode` matches (Nuxt parity).
*
* @returns layers ordered high→low priority; `layers[0]` is the project itself.
*/
export async function resolveLayerStack(
cwd: string,
opts: { mode?: string } = {},
): Promise<LayerStack> {
// 1) Auto-scan `layers/*` — descending sort so "Z"/higher numeric prefix wins, like Nuxt.
const localLayers = (await glob('layers/*', { onlyDirectories: true, cwd }))
.map(d => withTrailingSlash(resolve(cwd, d)))
.sort((a, b) => b.localeCompare(a))
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
const seen = new Set<string>()
const { config, layers = [] } = await loadConfig<LayerConfig>({
cwd,
configFile: 'app.config',
extend: { extendKey: ['_extends', 'extends'] },
overrides: { _extends: localLayers } as LayerConfig,
// Per-layer env overrides ($production/$development/$env). Undefined → c12 uses NODE_ENV.
// Do NOT set `omit$Keys` — it would strip `$meta`, which we read for layer names below.
envName: opts.mode,
rcFile: false,
packageJson: false,
globalRc: false,
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
resolve(id, opts) {
const abs = resolve(opts?.cwd ?? cwd, id)
if (seen.has(abs)) return { config: {}, cwd: abs }
seen.add(abs)
return undefined
},
})
// 3) Normalization — dedup by resolved rootDir (first-wins), resolve srcDir + name.
const all: ConfigLayer<LayerConfig>[] = layers.length ? layers : [{ config, cwd }]
const stack: Layer[] = []
const processed = new Set<string>()
const localRel = new Set(localLayers.map(l => relative(cwd, withoutTrailingSlash(l))))
for (const layer of all) {
const rawRoot = layer.config?.rootDir ?? layer.cwd
if (!rawRoot) continue
const rootDir = toPosix(rawRoot)
if (processed.has(rootDir)) continue
processed.add(rootDir)
const srcDir = toPosix(resolve(rootDir, layer.config?.srcDir ?? 'src'))
let name = layer.config?.$meta?.name ?? layer.config?.name
if (!name && layer.cwd && localRel.has(relative(cwd, layer.cwd))) {
name = basename(layer.cwd)
}
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
}
return { merged: config, layers: stack }
}
+84
View File
@@ -0,0 +1,84 @@
import { statSync } from 'node:fs'
import { resolve } from 'node:path'
import MagicString from 'magic-string'
import type { Plugin } from 'vite'
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.
*
* `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
* baked `__FEATURES__` `define` and aliases go stale. We watch each resolved layer's config file
* (including layers outside the project root via `watcher.add`) and call `server.restart()`, which
* re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → new `define`.
*/
export function configWatchPlugin(rootDirs: string[]): Plugin {
return {
name: 'vite-layers:config-watch',
apply: 'serve',
configureServer(server) {
const files = existingConfigFiles(rootDirs)
if (files.size === 0) return
server.watcher.add([...files]) // ensure extended layers outside the root are watched too
const onChange = (file: string) => {
if (!files.has(toPosix(file))) return
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
void server.restart()
}
server.watcher.on('change', onChange)
},
}
}
/** 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 }) }
},
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createHooks, type Hookable, type NestedHooks } from 'hookable'
import type { TSConfig } from 'pkg-types'
import type { ConfigEnv, UserConfig } from 'vite'
import type { Layer, LayerStack } from './types'
/** Hook handlers return nothing (mutation-style) — they may be async. */
export type HookResult = void | Promise<void>
export interface ViteConfigHookContext {
/** The fully-assembled Vite config (mutate in place, or replace `.config`). */
config: UserConfig
env: ConfigEnv
stack: LayerStack
}
export interface TsconfigHookContext {
appDir: string
/** The generated app/client tsconfig (mutate in place). */
tsconfig: TSConfig
/** The generated node tsconfig for config files (mutate in place). */
nodeTsconfig: TSConfig
stack: LayerStack
}
/**
* Lifecycle hooks (powered by `hookable`, like Nuxt). Handlers run **serially in layer order —
* base layers first** — and are **mutation-style**: they receive a shared argument and mutate it.
*/
export interface LayerHooks {
/** After the stack is resolved and all hooks are registered. Mutate `stack` (merged/layers/features). */
'layers:resolved': (stack: LayerStack) => HookResult
/** The final Vite config, just before it is returned from `buildViteConfig`. */
'vite:config': (ctx: ViteConfigHookContext) => HookResult
/** The generated tsconfig, just before it is written. */
'tsconfig:generate': (ctx: TsconfigHookContext) => HookResult
}
/** Declarative hook map accepted in `app.config.ts` (`hooks`) — supports nested/dotted keys. */
export type LayerHooksConfig = NestedHooks<LayerHooks>
export type LayerHookable = Hookable<LayerHooks>
/** Create an empty hookable instance for the layer lifecycle. */
export const createLayerHooks = (): LayerHookable => createHooks<LayerHooks>()
/**
* Register each layer's `hooks` onto the hookable, **base layers first** (so higher-priority
* layers' handlers run later), then the programmatic hooks last. Mirrors Nuxt's per-layer
* `addHooks` loop: functions can't be deep-merged, so same-name handlers **accumulate** instead of
* overwriting.
*
* @param layers stack layers ordered high→low priority (as returned by `resolveLayerStack`).
*/
export function registerLayerHooks(
hooks: LayerHookable,
layers: Pick<Layer, 'config'>[],
programmatic?: LayerHooksConfig,
): void {
for (const layer of [...layers].reverse()) {
if (layer.config.hooks) hooks.addHooks(layer.config.hooks)
}
if (programmatic) hooks.addHooks(programmatic)
}
/** Build a hookable from a stack's layer-declared hooks (used when no shared instance is provided). */
export function hooksFromStack(layers: Pick<Layer, 'config'>[]): LayerHookable {
const hooks = createLayerHooks()
registerLayerHooks(hooks, layers)
return hooks
}
+25
View File
@@ -0,0 +1,25 @@
export { defineLayerConfig, resolveLayerStack } from './config'
export { configWatchPlugin, featuresRuntimePlugin } from './dev'
export { publicLayersPlugin } from './public'
export {
createLayerHooks,
registerLayerHooks,
hooksFromStack,
type HookResult,
type LayerHookable,
type LayerHooks,
type LayerHooksConfig,
type TsconfigHookContext,
type ViteConfigHookContext,
} from './hooks'
export { DEFAULT_EXTENSIONS, layersResolver, type LayersResolverOptions } from './resolve'
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
export {
generateTsConfig,
writeTsConfig,
tsconfigPlugin,
featuresDts,
type GenerateTsConfigOptions,
type TSConfig,
} from './tsconfig'
export type { Layer, LayerConfig, LayerStack } from './types'
+153
View File
@@ -0,0 +1,153 @@
import { basename, resolve } from 'node:path'
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
import { resolveLayerStack } from './config'
import { configWatchPlugin, featuresRuntimePlugin } from './dev'
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
import { publicLayersPlugin } from './public'
import { layersResolver } from './resolve'
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
export interface BuildViteConfigOptions {
/** Extra Vite config merged at the very end (highest priority). */
vite?: UserConfig
/** Output directory. Default: `dist/<basename(appDir)>`. */
outDir?: string
/**
* Auto-generate `.vite-layers/tsconfig.json` on config resolution (dev + build).
* Pass options to customize, or `false` to disable. Default: enabled.
*/
tsconfig?: GenerateTsConfigOptions | false
/** Override the layered resolver's import prefixes / probed extensions. */
resolver?: { prefixes?: string[]; extensions?: string[] }
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
hooks?: LayerHooksConfig
}
/**
* `mergeConfig` concatenates arrays — including `plugins` — so a plugin added by several
* layers (e.g. a framework plugin in the base and re-declared in a brand) ends up duplicated.
* Dedupe by `plugin.name`, keeping the highest-priority (last-merged) instance in original order.
*/
function dedupePlugins(config: UserConfig): UserConfig {
if (!Array.isArray(config.plugins)) return config
const flat = (config.plugins as PluginOption[]).flat(Infinity as 1)
const indexByName = new Map<string, number>()
const out: PluginOption[] = []
for (const p of flat) {
const name = p && typeof p === 'object' && 'name' in p ? (p as { name?: unknown }).name : undefined
if (typeof name === 'string' && indexByName.has(name)) {
out[indexByName.get(name)!] = p // keep position, take later (higher-priority) instance
continue
}
if (typeof name === 'string') indexByName.set(name, out.length)
out.push(p)
}
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`:
*
* ```ts
* export default buildViteConfig(import.meta.dirname)
* ```
*
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins).
* `@/`/`~/` are handled by {@link layersResolver}, not as plain aliases.
* - `__FEATURES__` is defined from the merged `features` for build-time dead-code elimination.
*/
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
return defineConfig(async (env) => {
const stack = await resolveLayerStack(appDir, { mode: env.mode })
// Hooks: register each layer's `hooks` (base-first) + programmatic, then let `layers:resolved`
// mutate the stack (merged config / features / layers) before anything reads it.
const hooks = createLayerHooks()
registerLayerHooks(hooks, stack.layers, options.hooks)
await hooks.callHook('layers:resolved', stack)
const { merged, layers } = stack
const roots = layers.map(l => l.srcDir)
const project = layers[0]! // resolveLayerStack always returns at least the project layer
const alias: Record<string, string> = {
'~~': project.rootDir,
'@@': project.rootDir,
}
// `#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
let vite: UserConfig = {
resolve: { alias },
build: { outDir: options.outDir ?? `dist/${basename(appDir)}` },
}
// Layer fragments: low → high so higher-priority layers override.
for (const l of [...layers].reverse()) {
const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite
if (frag) vite = mergeConfig(vite, frag)
}
vite = dedupePlugins(vite)
const plugins: PluginOption[] = [
layersResolver({ roots, ...options.resolver }),
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
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)
]
if (options.tsconfig !== false) {
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
// and `tsconfig:generate` sees the same handlers).
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
}
vite = mergeConfig(vite, {
plugins,
define: featureDefines(merged.features),
})
if (options.vite) vite = mergeConfig(vite, options.vite)
// Final escape hatch: let hooks mutate (or replace) the assembled Vite config.
const ctx = { config: vite, env, stack }
await hooks.callHook('vite:config', ctx)
return ctx.config
})
}
export { dedupePlugins }
+54
View File
@@ -0,0 +1,54 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import sirv from 'sirv'
import type { Plugin } from 'vite'
const toPosix = (p: string) => p.replace(/\\/g, '/')
/** Recursively list files under a directory (absolute paths). */
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const abs = join(dir, name)
if (statSync(abs).isDirectory()) walk(abs, out)
else out.push(abs)
}
return out
}
/**
* 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`.
*
* 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),
* and emits the merged set into the build output (higher layers overwrite lower ones).
*
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
*/
export function publicLayersPlugin(publicDirs: string[]): Plugin {
const dirs = publicDirs.filter(existsSync) // high → low
return {
name: 'vite-layers:public',
config() {
// We serve/emit public ourselves, so turn off Vite's single-dir handling.
if (dirs.length > 0) return { publicDir: false }
},
configureServer(server) {
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
for (const dir of dirs) {
server.middlewares.use(sirv(dir, { dev: true, etag: true }))
}
},
generateBundle() {
// Build: merge low→high so higher layers overwrite — i.e. first-match-wins by priority.
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)
}
for (const [fileName, abs] of assets) {
this.emitFile({ type: 'asset', fileName, source: readFileSync(abs) })
}
},
}
}
+98
View File
@@ -0,0 +1,98 @@
import { statSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
export interface LayersResolverOptions {
/** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */
roots: string[]
/** Import prefixes treated as layered. Default: `@/`, `~/`. */
prefixes?: string[]
/** Extensions probed when the id has no explicit, existing file. */
extensions?: string[]
}
const toPosix = (p: string) => p.replace(/\\/g, '/')
const isFile = (p: string): boolean => {
try {
return statSync(p).isFile()
} catch {
return false
}
}
/**
* 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. 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 {
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options
const probe = (root: string, sub: string): string | null => {
const direct = resolve(root, sub)
if (isFile(direct)) return direct
for (const ext of extensions) {
const p = direct + ext
if (isFile(p)) return p
}
for (const ext of extensions) {
const p = resolve(direct, `index${ext}`)
if (isFile(p)) return p
}
return null
}
// 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
// candidate list is importer-independent (we pick the first candidate that isn't the importer).
const cache = new Map<string, string[]>()
const candidates = (sub: string): string[] => {
const cached = cache.get(sub)
if (cached) return cached
const list: string[] = []
for (const root of roots) {
const file = probe(root, sub)
if (file) list.push(toPosix(file))
}
cache.set(sub, list)
return list
}
return {
name: 'vite-layers:resolve',
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
configureServer(server) {
// A new/removed file can change which layer wins → drop the cache in dev.
const clear = () => cache.clear()
server.watcher.on('add', clear)
server.watcher.on('unlink', clear)
server.watcher.on('unlinkDir', clear)
},
resolveId(id, importer) {
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)
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
},
}
}
+207
View File
@@ -0,0 +1,207 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { relative, resolve } from 'node:path'
import { defu } from 'defu'
import { type TSConfig, writeTSConfig } from 'pkg-types'
import type { Plugin } from 'vite'
import { resolveLayerStack } from './config'
import { hooksFromStack, type LayerHookable } from './hooks'
import type { LayerStack } from './types'
export type { TSConfig } from 'pkg-types'
export interface GenerateTsConfigOptions {
/**
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
* wins). Does NOT override the generated `paths`, which always reflect the resolved layer stack.
*/
tsConfig?: TSConfig
/** Extra tsconfig merged over the generated **node** config (for config files). */
nodeTsConfig?: TSConfig
/** Directory to write into, relative to `appDir`. Default: `.vite-layers`. */
outDir?: string
/** Reuse an already-resolved stack (avoids a second `resolveLayerStack` per build). Internal. */
stack?: LayerStack
/** Shared hooks instance; if absent, one is built from the stack's layer hooks. Internal. */
hooks?: LayerHookable
}
const toPosix = (p: string) => p.replace(/\\/g, '/')
/** 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') || '.'
/** 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). */
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
target: 'ESNext',
module: 'ESNext',
moduleResolution: 'Bundler',
esModuleInterop: true,
skipLibCheck: true,
resolveJsonModule: true,
isolatedModules: true,
verbatimModuleSyntax: true,
strict: true,
noUncheckedIndexedAccess: true,
forceConsistentCasingInFileNames: true,
allowImportingTsExtensions: true,
noEmit: true,
}
/**
* Defaults for the node-environment config (`vite.config`/`app.config`): node-side, **no DOM lib**,
* **no layered `paths`** (config files don't use `@/`). Mirrors Nuxt's `tsconfig.node.json`.
*/
const NODE_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
...DEFAULT_COMPILER_OPTIONS,
lib: ['ESNext'],
paths: {},
}
/**
* Build the auto-generated tsconfig for an app's layer stack — a framework-agnostic port of Nuxt's
* `_generateTypes` (`@nuxt/kit` `packages/kit/src/template.ts`).
*
* The defining difference: because `@/` and `~/` are *layered* here (first-match across every
* layer's `srcDir`, see {@link layersResolver}), `paths['@/*']` is the array of ALL layer srcDirs in
* priority order. TypeScript resolves path arrays by first existing file, so `tsc` mirrors the
* runtime resolver exactly. (No `baseUrl` — deprecated in TS 6; since TS 5.0 `paths` resolve
* relative to the config that defines them, so a consuming tsconfig that `extends` this one
* resolves the relative paths from here.)
*
* Customize via each layer's `app.config.ts` `tsConfig` field (merged across the stack, like Nuxt's
* `typescript.tsConfig`) and/or `opts.tsConfig` (highest priority). Both are typed as pkg-types
* {@link TSConfig}.
*/
export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOptions = {}) {
const stack = opts.stack ?? (await resolveLayerStack(appDir))
const { merged, layers } = stack
const genDir = resolve(appDir, opts.outDir ?? '.vite-layers')
const srcStar = layers.map(l => `${rel(genDir, l.srcDir)}/*`) // [high … low]
const projectRoot = layers[0]!.rootDir
const paths: Record<string, string[]> = {
'@/*': srcStar,
'~/*': srcStar,
'~~': [rel(genDir, projectRoot)],
'@@': [rel(genDir, projectRoot)],
'~~/*': [`${rel(genDir, projectRoot)}/*`],
'@@/*': [`${rel(genDir, projectRoot)}/*`],
}
for (const l of layers) {
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
const star = `#layers/${l.name}/*`
if (star in paths) continue
paths[`#layers/${l.name}`] = [rel(genDir, l.rootDir)]
paths[star] = [`${rel(genDir, l.rootDir)}/*`]
}
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 —
// they belong to the node config below.
const base: TSConfig = {
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
exclude,
}
// Precedence (defu, first wins): opts.tsConfig → per-layer merged.tsConfig → generated defaults.
// `paths` is applied last — it is generated, not overridable.
const tsconfig = defu(opts.tsConfig, merged.tsConfig, base) as TSConfig
tsconfig.compilerOptions = { ...tsconfig.compilerOptions, paths }
// Node config: `vite.config`/`app.config` of every layer, node-side typings, no DOM, no paths.
const nodeBase: TSConfig = {
compilerOptions: { ...NODE_COMPILER_OPTIONS },
include: layers.flatMap((l) => {
const r = rel(genDir, l.rootDir)
return [`${r}/app.config.*`, `${r}/vite.config.*`]
}),
exclude,
}
const nodeTsconfig = defu(opts.nodeTsConfig, nodeBase) as TSConfig
// Escape hatch: let layer/programmatic hooks mutate the generated tsconfigs before they're written.
const ctx = { appDir, tsconfig, nodeTsconfig, stack }
await (opts.hooks ?? hooksFromStack(layers)).callHook('tsconfig:generate', ctx)
return {
tsconfig: ctx.tsconfig, // a hook may have mutated or replaced it
file: resolve(genDir, 'tsconfig.json'),
nodeTsconfig: ctx.nodeTsconfig,
nodeFile: resolve(genDir, 'tsconfig.node.json'),
genDir,
dts: featuresDts(merged.features),
dtsFile: resolve(genDir, 'features.d.ts'),
}
}
/**
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts}` (tsconfig via pkg-types
* `writeTSConfig`). Returns the tsconfig path.
*/
export async function writeTsConfig(appDir: string, opts?: GenerateTsConfigOptions): Promise<string> {
const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile } = await generateTsConfig(appDir, opts)
await mkdir(genDir, { recursive: true })
await Promise.all([
writeTSConfig(file, tsconfig),
writeTSConfig(nodeFile, nodeTsconfig),
writeFile(dtsFile, dts),
])
return file
}
/**
* Vite plugin that writes the generated tsconfig on `configResolved` (dev + build) — the
* framework-agnostic analogue of Nuxt's automatic `prepare:types`.
*/
export function tsconfigPlugin(appDir: string, opts?: GenerateTsConfigOptions): Plugin {
return {
name: 'vite-layers:tsconfig',
async configResolved() {
await writeTsConfig(appDir, opts)
},
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { TSConfig } from 'pkg-types'
import type { ConfigEnv, UserConfig } from 'vite'
import type { LayerHooksConfig } from './hooks'
/**
* A layer's declarative config, authored in `app.config.ts`.
* Mirrors the subset of Nuxt's layer config relevant to a framework-agnostic build.
*/
export interface LayerConfig {
/** Explicit layer name; used for the `#layers/<name>` alias. Falls back to the dir basename. */
name?: string
/** Absolute/relative root dir of the layer. Defaults to the layer's own directory. */
rootDir?: string
/** Source dir, resolved against `rootDir`. Default: `'src'`. */
srcDir?: string
/** Layers to extend: relative path, npm package, or git source (resolved by c12). */
extends?: string | string[]
/** Vite config fragment contributed by this layer (object or env-aware factory). */
vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
/** Build-time feature flags, exposed to app code as the `__FEATURES__` global. */
features?: Record<string, unknown>
/**
* tsconfig overrides contributed by this layer, merged across the stack into the generated
* `.vite-layers/tsconfig.json` (analogue of Nuxt's `typescript.tsConfig`). The generated
* `paths` always win. Typed as pkg-types {@link TSConfig}.
*/
tsConfig?: TSConfig
/**
* Lifecycle hooks (hookable). Accumulated across layers (base-first), not deep-merged — so
* same-name handlers from multiple layers all run. See {@link LayerHooks}.
*/
hooks?: LayerHooksConfig
/** c12 layer metadata; `$meta.name` takes precedence when deriving the layer name. */
$meta?: { name?: string }
/** Overrides applied when the resolved env (Vite `mode`) is `development`. */
$development?: Partial<LayerConfig>
/** Overrides applied when the resolved env (Vite `mode`) is `production`. */
$production?: Partial<LayerConfig>
/** Overrides keyed by env name (Vite `mode`), e.g. `{ staging: { features: {…} } }`. */
$env?: Record<string, Partial<LayerConfig>>
}
/** A fully resolved layer in the stack. */
export interface Layer {
/** Absolute root directory of the layer. */
rootDir: string
/** Absolute source directory (`rootDir`/`srcDir`). */
srcDir: string
/** Resolved layer name. */
name: string
/** The layer's own (unmerged) config. */
config: LayerConfig
}
export interface LayerStack {
/** Deep-merged config across the whole stack (defu, project wins). */
merged: LayerConfig
/** Layers ordered high→low priority; `layers[0]` is the project itself. */
layers: Layer[]
}
+54
View File
@@ -0,0 +1,54 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveLayerStack } from '../src/config'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p)
const toPosix = (p: string) => p.replace(/\\/g, '/')
const names = (s: { layers: { name: string }[] }) => s.layers.map(l => l.name)
describe('resolveLayerStack', () => {
it('orders the stack project-first (layers[0] = project), then extends depth-first', async () => {
const stack = await resolveLayerStack(fixture('stack/app'))
expect(names(stack)).toEqual(['app', 'base', 'core'])
expect(stack.layers[0].name).toBe('app')
})
it('merges configs with project winning on key collision (defu first-wins)', async () => {
const { merged } = await resolveLayerStack(fixture('stack/app'))
const features = merged.features as Record<string, unknown>
expect(features.shared).toBe('app') // app overrides base overrides core
expect(features).toMatchObject({ app: true, base: true, core: true })
})
it('resolves srcDir per layer (default "src")', async () => {
const stack = await resolveLayerStack(fixture('stack/app'))
expect(stack.layers[0].srcDir).toBe(toPosix(resolve(fixture('stack/app'), 'src')))
})
it('dedupes a diamond by rootDir (shared base appears once, first-wins position)', async () => {
const stack = await resolveLayerStack(fixture('diamond/app'))
expect(names(stack)).toEqual(['app', 'b', 'd', 'c'])
expect(names(stack).filter(n => n === 'd')).toHaveLength(1)
})
it('survives a cycle (A→B→A) without stack overflow [improvement over raw c12]', async () => {
const stack = await resolveLayerStack(fixture('cycle/x'))
expect(names(stack)).toEqual(['x', 'y'])
})
it('auto-scans layers/* with descending priority (Z > A / higher numeric prefix)', async () => {
const stack = await resolveLayerStack(fixture('autoscan'))
// project first, then 2.z-layer before 1.a-layer (descending sort)
expect(names(stack)).toEqual(['root', '2.z-layer', '1.a-layer'])
})
it('applies per-layer $production/$development overrides by Vite mode', async () => {
const dev = await resolveLayerStack(fixture('env/app'), { mode: 'development' })
const prod = await resolveLayerStack(fixture('env/app'), { mode: 'production' })
expect((dev.merged.features as Record<string, unknown>).flag).toBe('dev') // no $development block
expect((prod.merged.features as Record<string, unknown>).flag).toBe('prod') // $production wins
expect((prod.merged.features as Record<string, unknown>).shared).toBe(true) // base flags preserved
})
})
+73
View File
@@ -0,0 +1,73 @@
import { EventEmitter } from 'node:events'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { configWatchPlugin, featuresRuntimePlugin } from '../src/dev'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p)
function mockServer() {
const watcher = new EventEmitter() as EventEmitter & { add: (paths: string[]) => void }
watcher.add = vi.fn()
const restart = vi.fn()
const server = { watcher, restart, config: { logger: { info: vi.fn() } } }
return { server, watcher, restart }
}
const callConfigureServer = (plugin: { configureServer?: unknown }, server: unknown) =>
(plugin.configureServer as (s: unknown) => void)(server)
describe('configWatchPlugin', () => {
it('applies only in serve mode', () => {
expect(configWatchPlugin([]).apply).toBe('serve')
})
it('watches layer config files and restarts on change', () => {
const plugin = configWatchPlugin([fixture('stack/app'), fixture('stack/base')])
const { server, watcher, restart } = mockServer()
callConfigureServer(plugin, server)
expect(watcher.add).toHaveBeenCalled()
watcher.emit('change', resolve(fixture('stack/base'), 'app.config.ts'))
expect(restart).toHaveBeenCalledTimes(1)
})
it('ignores unrelated file changes', () => {
const plugin = configWatchPlugin([fixture('stack/app')])
const { server, watcher, restart } = mockServer()
callConfigureServer(plugin, server)
watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts'))
expect(restart).not.toHaveBeenCalled()
})
})
const runTransform = (
plugin: { transform?: unknown },
code: string,
id = '/app/src/x.ts',
): { code: unknown; map?: unknown } | null => {
const t = plugin.transform as
| ((this: unknown, c: string, i: string) => { code: unknown; map?: unknown } | null)
| 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()
})
})
+1
View File
@@ -0,0 +1 @@
export default { name: 'root' }
@@ -0,0 +1 @@
export default { features: { a: true } }
@@ -0,0 +1 @@
export default { features: { z: true } }
+1
View File
@@ -0,0 +1 @@
export default { name: 'x', extends: ['../y'] }
+1
View File
@@ -0,0 +1 @@
export default { name: 'y', extends: ['../x'] }
+1
View File
@@ -0,0 +1 @@
export default { name: 'app', extends: ['../b', '../c'] }
+1
View File
@@ -0,0 +1 @@
export default { name: 'b', extends: ['../d'] }
+1
View File
@@ -0,0 +1 @@
export default { name: 'c', extends: ['../d'] }
+1
View File
@@ -0,0 +1 @@
export default { name: 'd', features: { tags: ['d'] } }
+5
View File
@@ -0,0 +1,5 @@
export default {
name: 'app',
features: { flag: 'dev', shared: true },
$production: { features: { flag: 'prod' } },
}
+8
View File
@@ -0,0 +1,8 @@
export default {
name: 'app',
features: {
billing: false,
nested: { enabled: false, deep: { on: true } },
'kebab-flag': true,
},
}
+1
View File
@@ -0,0 +1 @@
HIGH_LOGO
@@ -0,0 +1 @@
LOW_ICON
+1
View File
@@ -0,0 +1 @@
LOW_LOGO
@@ -0,0 +1 @@
LOW_SHARED
@@ -0,0 +1 @@
<!-- base Footer -->
@@ -0,0 +1 @@
<!-- base Header -->
@@ -0,0 +1 @@
export const card = 'base'
@@ -0,0 +1 @@
<!-- brand Header (override) -->
+1
View File
@@ -0,0 +1 @@
export default { name: 'app', extends: ['../base'], features: { shared: 'app', app: true } }
+1
View File
@@ -0,0 +1 @@
export default { name: 'base', extends: ['../core'], features: { shared: 'base', base: true } }
+1
View File
@@ -0,0 +1 @@
export default { name: 'core', features: { shared: 'core', core: true }, vite: { define: { LVL: '"core"' } } }
@@ -0,0 +1,5 @@
export default {
name: 'app',
extends: ['../base'],
tsConfig: { compilerOptions: { strict: false, lib: ['ESNext'] } },
}
@@ -0,0 +1 @@
export default { name: 'base', tsConfig: { compilerOptions: { types: ['node'] } } }
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { createLayerHooks, registerLayerHooks } from '../src/hooks'
import type { Layer, LayerStack } from '../src/types'
const fakeStack = (): LayerStack => ({ merged: {}, layers: [] })
describe('layer hooks', () => {
it('accumulates layer hooks base-first, then programmatic, and runs serially', async () => {
const hooks = createLayerHooks()
const order: string[] = []
// layers are high→low; registration is base-first (reversed), programmatic last.
const layers: Pick<Layer, 'config'>[] = [
{ config: { hooks: { 'layers:resolved': () => void order.push('high') } } },
{ config: { hooks: { 'layers:resolved': () => void order.push('low') } } },
]
registerLayerHooks(hooks, layers, { 'layers:resolved': () => void order.push('prog') })
await hooks.callHook('layers:resolved', fakeStack())
expect(order).toEqual(['low', 'high', 'prog'])
})
it('handlers mutate the shared argument (mutation-style)', async () => {
const hooks = createLayerHooks()
const layers: Pick<Layer, 'config'>[] = [
{ config: { hooks: { 'layers:resolved': s => void ((s.merged.features ??= {}).x = 1) } } },
]
registerLayerHooks(hooks, layers)
const stack = fakeStack()
await hooks.callHook('layers:resolved', stack)
expect((stack.merged.features as Record<string, unknown>).x).toBe(1)
})
it('awaits async handlers serially', async () => {
const hooks = createLayerHooks()
const order: string[] = []
const layers: Pick<Layer, 'config'>[] = [
// high layer (registered last): async, must still complete before callHook resolves
{ config: { hooks: { 'layers:resolved': async () => { await Promise.resolve(); order.push('high') } } } },
{ config: { hooks: { 'layers:resolved': () => void order.push('low') } } },
]
registerLayerHooks(hooks, layers)
await hooks.callHook('layers:resolved', fakeStack())
expect(order).toEqual(['low', 'high'])
})
})
+95
View File
@@ -0,0 +1,95 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Plugin, UserConfig, UserConfigFnObject } from 'vite'
import { buildViteConfig, dedupePlugins } from '../src/kit'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p).replace(/\\/g, '/')
const env = { command: 'build', mode: 'production', isSsrBuild: false, isPreview: false } as const
async function build(appDir: string): Promise<UserConfig> {
const fn = (await buildViteConfig(appDir)) as UserConfigFnObject
return (await fn(env)) as UserConfig
}
describe('buildViteConfig', () => {
it('exposes merged features via __FEATURES__ define (for DCE)', async () => {
const cfg = await build(fixture('stack/app'))
const features = JSON.parse((cfg.define as Record<string, string>).__FEATURES__)
expect(features.shared).toBe('app')
expect(features).toMatchObject({ app: true, base: true, core: true })
})
it('emits dotted feature defines (for dead-code elimination of gated imports)', async () => {
const cfg = await build(fixture('stack/app'))
const define = cfg.define as Record<string, string>
// dotted entry is folded by esbuild to a literal → enables DCE of `__FEATURES__.x ? import() : []`
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 () => {
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'), {
hooks: {
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
'vite:config': ctx => void (ctx.config.define = { ...ctx.config.define, INJECTED: '"yes"' }),
},
})) as UserConfigFnObject
const cfg = (await fn(env)) as UserConfig
const define = cfg.define as Record<string, string>
expect(define['__FEATURES__.injected']).toBe('true') // layers:resolved ran before featureDefines
expect(define.INJECTED).toBe('"yes"') // vite:config ran at the very end
})
it('registers the layers resolver plugin', async () => {
const cfg = await build(fixture('stack/app'))
const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
expect(plugins.some(p => p?.name === 'vite-layers:resolve')).toBe(true)
})
it('sets ~~/@@ to the project rootDir and #layers/<name> per layer', async () => {
const cfg = await build(fixture('stack/app'))
const alias = (cfg.resolve as { alias: Record<string, string> }).alias
expect(alias['~~']).toBe(fixture('stack/app'))
expect(alias['@@']).toBe(fixture('stack/app'))
expect(alias['#layers/app']).toBe(fixture('stack/app'))
expect(alias['#layers/base']).toBe(fixture('stack/base'))
expect(alias['#layers/core']).toBe(fixture('stack/core'))
})
it('defaults outDir to dist/<app>', async () => {
const cfg = await build(fixture('stack/app'))
expect((cfg.build as { outDir: string }).outDir).toBe('dist/app')
})
})
describe('dedupePlugins', () => {
it('removes plugins sharing a name, keeping the later (higher-priority) instance in place', () => {
const a: Plugin = { name: 'vue', apply: 'build' }
const b: Plugin = { name: 'vue', apply: 'serve' }
const other: Plugin = { name: 'other' }
const out = dedupePlugins({ plugins: [a, other, b] }).plugins as Plugin[]
expect(out).toHaveLength(2)
expect(out[0]).toBe(b) // position of first 'vue', value of later one
expect(out[1]).toBe(other)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { publicLayersPlugin } from '../src/public'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p)
const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)()
function runGenerateBundle(p: { generateBundle?: unknown }): Record<string, string> {
const emitted: Record<string, string> = {}
const ctx = {
emitFile: ({ fileName, source }: { fileName: string; source: Buffer | string }) => {
emitted[fileName] = source.toString()
},
}
;(p.generateBundle as (this: unknown, ...a: unknown[]) => void).call(ctx, {}, {}, false)
return emitted
}
describe('publicLayersPlugin', () => {
const high = fixture('public/high/public')
const low = fixture('public/low/public')
it('disables Vite publicDir when layers have public/, otherwise no-op', () => {
expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false })
expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined()
})
it('emits assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
const emitted = runGenerateBundle(publicLayersPlugin([high, low]))
expect(emitted['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
expect(emitted['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
expect(emitted['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
})
})
+68
View File
@@ -0,0 +1,68 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { layersResolver } from '../src/resolve'
const here = dirname(fileURLToPath(import.meta.url))
const toPosix = (p: string) => p.replace(/\\/g, '/')
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p))
// roots ordered high→low priority: brand overrides base.
const roots = [fixture('brand/src'), fixture('base/src')]
const plugin = layersResolver({ roots })
const resolveId = (id: string, importer?: string): string | null =>
(plugin.resolveId as (id: string, importer?: string) => string | null)(id, importer)
describe('layersResolver', () => {
it('ignores non-layered ids', () => {
expect(resolveId('vue')).toBeNull()
expect(resolveId('./relative')).toBeNull()
expect(resolveId('#layers/base/x')).toBeNull()
})
it('resolves @/ to the highest-priority layer that has the file', () => {
expect(resolveId('@/components/Header.vue')).toBe(fixture('brand/src/components/Header.vue'))
})
it('falls through to a lower layer when the higher one lacks the file', () => {
expect(resolveId('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
})
it('supports the ~/ prefix identically', () => {
expect(resolveId('~/components/Header.vue')).toBe(fixture('brand/src/components/Header.vue'))
})
it('probes <path>/index<ext> when no direct file exists', () => {
expect(resolveId('@/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts'))
})
it('self-skips: an override importing itself reaches the base layer (super())', () => {
const brandHeader = fixture('brand/src/components/Header.vue')
const baseHeader = fixture('base/src/components/Header.vue')
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
})
it('returns null when nothing matches across layers', () => {
expect(resolveId('@/components/Missing.vue')).toBeNull()
})
it('preserves query suffixes (?inline / ?raw / ?vue&type=…)', () => {
expect(resolveId('@/components/Header.vue?vue&type=style&lang.css')).toBe(
`${fixture('brand/src/components/Header.vue')}?vue&type=style&lang.css`,
)
})
it('honors custom prefixes and extensions', () => {
const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] })
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
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
})
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
const p = layersResolver({ roots })
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
})
})
+134
View File
@@ -0,0 +1,134 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createLayerHooks } from '../src/hooks'
import { featuresDts, generateTsConfig } from '../src/tsconfig'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = (p: string) => resolve(here, 'fixtures', p)
describe('generateTsConfig', () => {
it('maps @/* and ~/* to every layer srcDir in priority order (first-match = runtime resolver)', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
// genDir is <app>/.vite-layers, so each src is one level up + the layer path
expect(paths['@/*']).toEqual([
'../src/*', // stack/app/src
'../../base/src/*', // stack/base/src
'../../core/src/*', // stack/core/src
])
expect(paths['~/*']).toEqual(paths['@/*'])
})
it('maps ~~/@@ to the project root (bare + wildcard)', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
expect(paths['~~']).toEqual(['..'])
expect(paths['~~/*']).toEqual(['../*'])
expect(paths['@@']).toEqual(paths['~~'])
})
it('emits #layers/<name>/* per layer rootDir', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
expect(paths['#layers/app/*']).toEqual(['../*'])
expect(paths['#layers/base/*']).toEqual(['../../base/*'])
expect(paths['#layers/core/*']).toEqual(['../../core/*'])
})
it('includes every layer srcDir glob', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
expect(tsconfig.include).toEqual(
expect.arrayContaining(['../src/**/*', '../../base/src/**/*', '../../core/src/**/*']),
)
})
it('sets framework-neutral defaults with no Vue/JSX specifics', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const co = tsconfig.compilerOptions!
expect(co.moduleResolution).toBe('Bundler')
expect(co.strict).toBe(true)
expect(co).not.toHaveProperty('baseUrl') // deprecated in TS 6; paths resolve relative to the file
expect(co).not.toHaveProperty('jsx')
expect(co).not.toHaveProperty('jsxImportSource')
})
it('merges per-layer `tsConfig` from app.config.ts across the stack (like Nuxt typescript.tsConfig)', async () => {
const { tsconfig } = await generateTsConfig(fixture('tsconfig-cfg/app'))
const co = tsconfig.compilerOptions as Record<string, unknown>
expect(co.strict).toBe(false) // app layer overrides the default `true`
expect(co.lib).toContain('ESNext') // from the app layer
expect(co.types).toContain('node') // inherited from the base layer
expect(co.moduleResolution).toBe('Bundler') // untouched default
expect((co.paths as Record<string, string[]>)['@/*']).toBeDefined()
})
it('opts.tsConfig wins over per-layer tsConfig and defaults, but never the generated paths', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'), {
tsConfig: { compilerOptions: { strict: false, jsx: 'preserve', paths: { evil: ['/hax'] } } },
})
const co = tsconfig.compilerOptions as Record<string, unknown>
expect(co.strict).toBe(false) // user wins over default
expect(co.jsx).toBe('preserve') // user can add options
const paths = co.paths as Record<string, string[]>
expect(paths.evil).toBeUndefined() // generated paths are authoritative
expect(paths['@/*']).toBeDefined()
})
it('generates a separate node tsconfig for config files (node-side, no DOM, no paths)', async () => {
const r = await generateTsConfig(fixture('stack/app'))
expect(r.nodeFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/tsconfig\.node\.json$/)
const co = r.nodeTsconfig.compilerOptions as Record<string, unknown>
expect(co.lib).toEqual(['ESNext']) // no DOM
expect(co.paths).toEqual({}) // config files don't use @/
expect(co.noEmit).toBe(true)
// includes app.config / vite.config of each layer (app + base + core)
expect(r.nodeTsconfig.include).toEqual(
expect.arrayContaining([
expect.stringMatching(/app\.config\.\*$/),
expect.stringMatching(/vite\.config\.\*$/),
]),
)
// ...and the app config no longer pulls in config files
expect((r.tsconfig.include ?? []).some(p => p.includes('app.config'))).toBe(false)
})
it('lets a tsconfig:generate hook mutate the node tsconfig', async () => {
const hooks = createLayerHooks()
hooks.hook('tsconfig:generate', ctx => void (ctx.nodeTsconfig.compilerOptions!.removeComments = true))
const r = await generateTsConfig(fixture('stack/app'), { hooks })
expect((r.nodeTsconfig.compilerOptions as Record<string, unknown>).removeComments).toBe(true)
})
it('includes ./features.d.ts and returns its generated content + path', async () => {
const r = await generateTsConfig(fixture('stack/app'))
expect(r.tsconfig.include).toContain('./features.d.ts')
expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/)
expect(r.dts).toContain('const __FEATURES__:')
})
it('reuses a provided stack instead of resolving again (O2)', async () => {
const stack = {
merged: { features: { onlyInFake: true } },
layers: [
{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'FAKELAYER', config: {} },
],
}
const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]>
expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used
expect(r.dts).toContain('onlyInFake: boolean')
})
})
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')
})
})
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": false,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "test", "vitest.config.ts"],
"exclude": ["node_modules", "probe", "**/fixtures/**"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
})