feat: add feature plugin tests and validation for feature flags
@@ -1 +1,2 @@
|
||||
.DS_Store
|
||||
.claude
|
||||
@@ -45,34 +45,32 @@ export default buildViteConfig(import.meta.dirname)
|
||||
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
|
||||
|
||||
```ts
|
||||
// __FEATURES__ типизируется сгенерированным .vite-layers/features.d.ts — declare не нужен
|
||||
import { feature } from '#feature' // алиас регистрирует vite-layers; типы — из .vite-layers/features.d.ts
|
||||
|
||||
const routes = [
|
||||
{ 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`, поэтому опечатка
|
||||
(`__FEATURES__.biling`) — ошибка компиляции, а не молчком-falsy.
|
||||
`feature('key')` — **компайл-тайм макрос**: плагин заменяет вызов на литерал значения флага,
|
||||
**одинаково в dev и в build** (один AST-transform, без расхождений). Подставленный `false` делает ветку
|
||||
статически мёртвой, и Rollup/rolldown вырезает её вместе с `import()` — чанк выключенной фичи не эмитится.
|
||||
Тип `feature` генерируется из `merged.features` **литеральными типами**, поэтому опечатка ключа
|
||||
(`feature('biling')`) — ошибка компиляции.
|
||||
|
||||
Флаги вшиваются через `define` и сворачиваются esbuild ещё **до** построения графа Rollup, поэтому
|
||||
выключенная ветка и её `import()` физически не попадают в бандл. Дотированные литералы эмитятся на
|
||||
любую глубину (`__FEATURES__.nested.enabled` тоже сворачивается). Правила, чтобы DCE сработало:
|
||||
Правила (они **enforced**: нарушение валит сборку — и в dev, и в build, ничего не «протекает» молча):
|
||||
|
||||
- обращайтесь **напрямую** — `__FEATURES__.billing`; алиас/деструктуризация (`const f = __FEATURES__; f.billing`)
|
||||
и динамический доступ (`__FEATURES__[name]`) не сворачиваются;
|
||||
- гейт оборачивает сам `import()` (тернарник/`&&`/спред), а не `.filter` после — reachable-импорт не вырезается;
|
||||
- ключи фич — валидные JS-идентификаторы (kebab/пробел доступны в рантайме через объект `__FEATURES__`,
|
||||
но без DCE);
|
||||
- в тестах продублируйте `define` (в `vitest.config`) или гардите `globalThis.__FEATURES__ ?? {}`.
|
||||
- ключ — строковый литерал: `feature('billing')`, не `feature(name)`;
|
||||
- вызывайте напрямую — без алиасов (`const f = feature`), деструктуризации и передачи как значения;
|
||||
- вложенные флаги — дотированным ключом: `feature('payments.stripe')`;
|
||||
- ключ должен существовать в `merged.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>` и используйте в шаблоне локальную переменную.
|
||||
Тестам ничего дублировать не нужно — тот же transform работает в Vitest (плагин — часть конфига).
|
||||
В `.vue`-шаблоне напрямую `feature('x')` использовать нельзя (компилятор делает из него `_ctx.feature` —
|
||||
это не вызов макроса): читайте флаг в `<script setup>`/JSX и используйте в шаблоне локальную переменную.
|
||||
При изменении любого `app.config.*` dev-сервер **автоматически перезапускается** (`app.config` грузится
|
||||
c12, вне графа Vite — сам он не следит), подхватывая новые значения флагов.
|
||||
|
||||
## Префиксы импортов
|
||||
|
||||
@@ -81,6 +79,7 @@ const routes = [
|
||||
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
|
||||
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
|
||||
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
|
||||
| `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы |
|
||||
|
||||
## Модель приоритета (из 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?)`:
|
||||
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}` — `GenerateTsConfigOptions`.
|
||||
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
|
||||
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
|
||||
- `devtools: false` — не монтировать панели в Vite DevTools (см. ниже). По умолчанию включено.
|
||||
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
|
||||
|
||||
## Хуки жизненного цикла
|
||||
@@ -147,6 +153,38 @@ export default defineLayerConfig({
|
||||
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
|
||||
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`.
|
||||
|
||||
## DevTools
|
||||
|
||||
vite-layers умеет показывать свой резолвнутый стек прямо в [Vite DevTools](https://devtools.vite.dev)
|
||||
(`@vitejs/devtools`). Добавьте хаб в dev — `buildViteConfig` сам подмонтирует панели; без хаба плагин
|
||||
**инертен** (используются только *типы* из `@vitejs/devtools-kit`, никакой рантайм-зависимости):
|
||||
|
||||
```ts
|
||||
// apps/main/app.config.ts
|
||||
import { DevTools } from '@vitejs/devtools' // peer-зависимость только для dev
|
||||
export default defineLayerConfig({
|
||||
vite: ({ command }) => ({
|
||||
plugins: [vue(), command === 'serve' && DevTools()], // хаб только в dev
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Четыре панели (свёрнуты под одной кнопкой `vite-layers`):
|
||||
|
||||
| Панель | Что показывает |
|
||||
|---|---|
|
||||
| **Layers** | **дерево наследования** (`extends`-граф box-drawing, с ромбами и авто-сканом), резолвнутый стек high→low, мёрж-конфиг (Tree), накопленные хуки |
|
||||
| **Features** | мёрж-флаги с литеральными значениями и статусом DCE (`kept` / `branch eliminated`); бейдж = число выключенных |
|
||||
| **Resolver** | плейграунд `@/…` (показывает кандидатов по слоям + победителя) и **живой лог** реальных слоёвых резолвов сессии (включая `super()`-self-skip) |
|
||||
| **Public & TS** | слоёвые `public/`-ассеты (кто кого затеняет) и сгенерированные `tsconfig.json` / `tsconfig.node.json` / `features.d.ts` |
|
||||
|
||||
UI рисуется целиком на сервере (json-render спеки `@vitejs/devtools-kit`) — **клиентский бандл не
|
||||
нужен**, vite-layers остаётся buildless. Панели читают тот же стек и тот же резолвер-кэш, что и сборка
|
||||
(`createLayeredResolution` шарится между резолвер-плагином и панелью), поэтому показанное — ровно то,
|
||||
что действует. При первом заходе DevTools попросит авторизовать браузер (разовый prompt в терминале).
|
||||
|
||||
Плагин можно подключить и вручную — `layersDevtoolsPlugin` экспортируется из `vite-layers/devtools`.
|
||||
|
||||
## Улучшения над Nuxt/c12
|
||||
|
||||
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
|
||||
@@ -154,15 +192,17 @@ export default defineLayerConfig({
|
||||
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
|
||||
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
|
||||
`resolve`-хуке c12 обрывает рекурсию.
|
||||
3. **Побрендовый DCE** — гейтированные динамические `import()` выпиливаются из бандлов выключенных
|
||||
брендов через дотированные `__FEATURES__.<key>` defines (esbuild сворачивает литерал ещё до того,
|
||||
как Rollup построит граф модулей).
|
||||
3. **Побрендовый DCE через `feature()`-макрос** — гейтированные `import()` выпиливаются из бандлов
|
||||
выключенных брендов: AST-transform заменяет `feature('key')` на литерал (**один механизм для dev и
|
||||
build**), а любое не-сворачиваемое использование (алиас, динамический/неизвестный ключ) валит
|
||||
сборку с понятной ошибкой — вместо молчаливой деградации DCE, как при `define`-подходе Nuxt.
|
||||
|
||||
## TypeScript (автогенерация tsconfig)
|
||||
|
||||
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
|
||||
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` типизирует
|
||||
`__FEATURES__`); `tsconfig.json` приложения его расширяет:
|
||||
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` аугментирует
|
||||
модуль `#feature` литеральными типами `feature()`, а сгенерированный `paths['#feature']` резолвит сам
|
||||
макрос); `tsconfig.json` приложения его расширяет:
|
||||
|
||||
```jsonc
|
||||
// apps/brand/tsconfig.json
|
||||
@@ -210,34 +250,83 @@ vue-tsc --noEmit -p apps/brand # или tsc --noEmit
|
||||
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
|
||||
- `resolveLayerStack(cwd)` → `{ merged, layers }` — резолвнутый упорядоченный стек.
|
||||
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно).
|
||||
- `createLayeredResolution({ roots, prefixes?, extensions?, record? })` — чистое ядро резолвера
|
||||
(`parse`/`candidates`/`resolveId`/`records`); шарится между резолвер-плагином и DevTools-панелью.
|
||||
- `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
|
||||
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
|
||||
- `feature(key)` (импорт из `#feature` / `vite-layers/feature`) — компайл-тайм макрос флагов; `featurePlugin(features)` — сам плагин (можно отдельно).
|
||||
- `layersDevtoolsPlugin(data)` (импорт из `vite-layers/devtools`) — панели для Vite DevTools (автоматически подключаются `buildViteConfig`).
|
||||
|
||||
## Пример (Vue)
|
||||
## Пример (Vue + Tailwind)
|
||||
|
||||
`example/apps/{main,brand}` — запускаемое Vue-демо. `main` — база (`vue()` + страница `billing`),
|
||||
`brand` расширяет её, перекрывает `AppHeader.vue` и выключает `billing`. Соберите оба и сравните:
|
||||
`example/apps/{main,brand,aurora}` — запускаемое мультибрендовое демо. Общий «каркас» (шапка, подвал,
|
||||
страница профиля, страница биллинга, роутер, входной Tailwind-CSS) живёт только в базовом слое `main`;
|
||||
каждый бренд меняет ровно **три** вещи — логотип, файл темы Tailwind и лендинг:
|
||||
|
||||
| Приложение | Слой | Тема | DCE-страница `billing` | Beta-плашка |
|
||||
|---|---|---|---|---|
|
||||
| `main` (Acme) | база | светлая, индиго | ✅ есть | ✅ (в dev) |
|
||||
| `brand` (Northwind) | `extends ../main` | светлая, изумруд | ❌ вырезана | ✅ (унаследована) |
|
||||
| `aurora` (Aurora) | `extends ../main` | **тёмная**, роза/небо | ✅ есть | ❌ выключена |
|
||||
|
||||
```bash
|
||||
npx vite build example/apps/main # эмитит чанки Home + Billing
|
||||
npx vite build example/apps/brand # только Home (чанка Billing НЕТ → DCE); AppHeader перекрыт
|
||||
pnpm example:dev # дев-сервер Acme (бренды: npx vite example/apps/{brand,aurora})
|
||||
pnpm example:build # билд всех трёх — сравните эмитнутые чанки
|
||||
pnpm example:check # prepare + vue-tsc для всех трёх (код приложения + node-конфиги)
|
||||
```
|
||||
|
||||
**Как меняется тема.** Общий `@/style.css` (только в базе) подключает Tailwind и мапит токены на
|
||||
runtime-переменные через `@theme inline` (`--color-brand: var(--c-brand)` и т.д.). Каждый бренд кладёт
|
||||
свой `@/assets/theme.css` с `:root { --c-* }`; слоёвый резолвер выбирает версию верхнего слоя — и весь
|
||||
общий UI перекрашивается, без правки единого компонента. `@tailwindcss/vite` резолвит CSS-`@import`
|
||||
своим резолвером (мимо слоёв), поэтому тему подключаем **JS-импортом** `import '@/assets/theme.css'`,
|
||||
который идёт через слоёвый резолвер.
|
||||
|
||||
Что демонстрирует демо:
|
||||
|
||||
- **Оверрайд:** `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` зелёный:
|
||||
- **Общий каркас:** `AppHeader`, `AppFooter`, `Profile`, `Billing`, роутер есть только в `main`, но
|
||||
рендерятся во всех брендах через `@/…`. У `brand` больше нет своего `AppHeader` — шапка общая.
|
||||
- **Перекраска темой:** один и тот же `Profile`/`Billing` рендерится светлым у Acme/Northwind и тёмным
|
||||
у Aurora — разница только в `theme.css` (10 токенов цвета/радиуса/шрифта).
|
||||
- **Перекрытие ассета:** `*/public/logo.svg` затеняется послойно; `favicon.svg` наследуется из базы.
|
||||
- **DCE:** у `brand` `features.billing: false` → `feature('billing')` сворачивается в `false` →
|
||||
`import('@/pages/Billing.vue')` мёртв → чанк `Billing-*.js` не эмитится (и ссылки в навигации нет):
|
||||
|
||||
```bash
|
||||
vite-layers prepare example/apps/brand
|
||||
npx vue-tsc --noEmit -p example/apps/brand
|
||||
ls example/apps/main/dist/main/assets | grep -i billing # Billing-*.js есть
|
||||
ls example/apps/brand/dist/brand/assets | grep -i billing # пусто → DCE
|
||||
ls example/apps/aurora/dist/aurora/assets | grep -i billing # Billing-*.js есть
|
||||
```
|
||||
|
||||
- **Разные наборы фич:** `brand` убирает биллинг, но оставляет beta-плашку; `aurora` — наоборот.
|
||||
`$production` гасит beta-плашку в проде у всех (env-оверрайд слоя).
|
||||
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`, `types: ['vite/client']`),
|
||||
`vue-tsc` зелёный для всех трёх:
|
||||
|
||||
```bash
|
||||
vite-layers prepare example/apps/aurora
|
||||
npx vue-tsc --noEmit -p example/apps/aurora
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, defines, tsconfig
|
||||
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, feature()-макрос, tsconfig
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
Разработка **buildless** — example-приложения и тесты импортируют `src/*` напрямую, а `exports`
|
||||
пакета указывают на `./src/*.ts`. Для публикации `pnpm build` (tsdown) собирает ESM + `.d.ts` в
|
||||
`dist/` по одному выходу на каждый сабпас (`.`, `./feature`, `./devtools`); зависимости и peer'ы
|
||||
(`vite`, `@vitejs/devtools-kit`) внешние. `publishConfig.exports` переключает пакет на `dist/` —
|
||||
`prepack` пересобирает автоматически, в tarball едут только `dist/` + `bin/` (проверено `publint`).
|
||||
|
||||
```bash
|
||||
pnpm build # tsdown → dist/{index,feature,devtools}.{js,d.ts}
|
||||
pnpm pack # prepack-сборка + публикуемый tarball (dist + bin)
|
||||
```
|
||||
|
||||
CLI `vite-layers prepare` и алиас `#feature` работают в обоих режимах: из исходников (`feature.ts`,
|
||||
jiti) и из собранного `dist/` (`feature.js`).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/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 { 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)
|
||||
|
||||
@@ -14,7 +14,11 @@ if (cmd !== 'prepare') {
|
||||
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 { writeTsConfig } = await jiti.import(resolve(here, '../src/tsconfig.ts'))
|
||||
const file = await writeTsConfig(appDir)
|
||||
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'
|
||||
|
||||
// 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({
|
||||
name: 'brand',
|
||||
extends: ['../main'],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<title>Northwind — Cloud Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,18 +1,29 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwind from '@tailwindcss/vite'
|
||||
import { DevTools } from '@vitejs/devtools'
|
||||
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()],
|
||||
// `billing` gates a whole page (build-time DCE); `betaBanner` toggles a UI accent and is
|
||||
// turned off in production via the `$production` env override below.
|
||||
features: { billing: true, betaBanner: true },
|
||||
|
||||
// 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' } } },
|
||||
},
|
||||
}),
|
||||
|
||||
// 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: {
|
||||
features: { p2p: false },
|
||||
}
|
||||
features: { betaBanner: false }, // the beta accent never ships to prod
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<title>Acme — Cloud Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<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 |
@@ -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 |
@@ -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">© {{ 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">
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { createApp, defineAsyncComponent, h, shallowRef, type Component } from 'vue'
|
||||
const AppHeader = defineAsyncComponent(() => import('@/components/AppHeader.vue'))
|
||||
import { createApp } from '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
|
||||
// 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')
|
||||
createApp(App).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>
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,35 +8,74 @@
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./feature": "./src/feature.ts",
|
||||
"./devtools": "./src/devtools.ts"
|
||||
},
|
||||
"bin": {
|
||||
"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": {
|
||||
"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"
|
||||
"build": "tsdown",
|
||||
"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": {
|
||||
"@vitejs/devtools-kit": "^0.3.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitejs/devtools-kit": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"c12": "^3.3.4",
|
||||
"defu": "^6.1.4",
|
||||
"hookable": "^6.1.1",
|
||||
"jiti": "^2.4.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"oxc-parser": "^0.137.0",
|
||||
"pkg-types": "^2.3.1",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ufo": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/devtools": "0.3.3",
|
||||
"@vitejs/devtools-kit": "0.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tsdown": "^0.22.3",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.7",
|
||||
|
||||
@@ -3,18 +3,12 @@ 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'
|
||||
import type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
|
||||
import { toPosix } from './util'
|
||||
|
||||
/** 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`.)
|
||||
@@ -59,6 +53,12 @@ export async function resolveLayerStack(
|
||||
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
|
||||
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>({
|
||||
cwd,
|
||||
configFile: 'app.config',
|
||||
@@ -71,8 +71,11 @@ export async function resolveLayerStack(
|
||||
packageJson: false,
|
||||
globalRc: false,
|
||||
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
|
||||
resolve(id, opts) {
|
||||
const abs = resolve(opts?.cwd ?? cwd, id)
|
||||
resolve(id, ropts) {
|
||||
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 }
|
||||
seen.add(abs)
|
||||
return undefined
|
||||
@@ -101,5 +104,5 @@ export async function resolveLayerStack(
|
||||
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
|
||||
}
|
||||
|
||||
return { merged: config, layers: stack }
|
||||
return { merged: config, layers: stack, edges }
|
||||
}
|
||||
|
||||
@@ -1,84 +1,41 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import MagicString from 'magic-string'
|
||||
import type { Plugin } from 'vite'
|
||||
import { toPosix } from './util'
|
||||
|
||||
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`.
|
||||
* `feature()` values baked into transformed modules and the resolved 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) → a new
|
||||
* `featurePlugin` with the updated flag values.
|
||||
*/
|
||||
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
|
||||
// Every POSSIBLE `app.config.*` path (existing or not), so creating a config in a layer that
|
||||
// had none — or deleting one — also restarts, not just edits to configs present at startup.
|
||||
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) => {
|
||||
if (!files.has(toPosix(file))) return
|
||||
const onEvent = (file: string) => {
|
||||
if (!candidates.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 }) }
|
||||
for (const event of ['add', 'change', 'unlink'] as const) server.watcher.on(event, onEvent)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 layer’s 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,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
)
|
||||
}
|
||||
@@ -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 }) }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
createLayerHooks,
|
||||
@@ -12,14 +13,22 @@ export {
|
||||
type TsconfigHookContext,
|
||||
type ViteConfigHookContext,
|
||||
} 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 {
|
||||
generateTsConfig,
|
||||
writeTsConfig,
|
||||
tsconfigPlugin,
|
||||
featuresDts,
|
||||
type GenerateTsConfigOptions,
|
||||
type TSConfig,
|
||||
} from './tsconfig'
|
||||
export type { Layer, LayerConfig, LayerStack } from './types'
|
||||
export type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
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 { configWatchPlugin } from './dev'
|
||||
import { layersDevtoolsPlugin } from './devtools'
|
||||
import { FEATURE_MODULE, featurePlugin } from './features'
|
||||
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
|
||||
import { publicLayersPlugin } from './public'
|
||||
import { layersResolver } from './resolve'
|
||||
import { createLayeredResolution, layersResolver } from './resolve'
|
||||
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 {
|
||||
/** Extra Vite config merged at the very end (highest priority). */
|
||||
@@ -21,6 +33,12 @@ export interface BuildViteConfigOptions {
|
||||
resolver?: { prefixes?: string[]; extensions?: string[] }
|
||||
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 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`:
|
||||
*
|
||||
@@ -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()`.
|
||||
* - 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.
|
||||
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins);
|
||||
* `#feature` → the {@link featurePlugin} macro entry. `@/`/`~/` are handled by
|
||||
* {@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 = {}) {
|
||||
return defineConfig(async (env) => {
|
||||
@@ -103,11 +89,20 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
|
||||
|
||||
const { merged, layers } = stack
|
||||
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 alias: Record<string, string> = {
|
||||
'~~': 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).
|
||||
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)
|
||||
|
||||
const plugins: PluginOption[] = [
|
||||
layersResolver({ roots, ...options.resolver }),
|
||||
layersResolver(resolution),
|
||||
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)
|
||||
featurePlugin(merged.features), // compile `feature('key')` → literal (dev + build, one mechanism)
|
||||
]
|
||||
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 }))
|
||||
}
|
||||
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, {
|
||||
plugins,
|
||||
define: featureDefines(merged.features),
|
||||
})
|
||||
vite = mergeConfig(vite, { plugins })
|
||||
|
||||
if (options.vite) vite = mergeConfig(vite, options.vite)
|
||||
|
||||
|
||||
@@ -1,32 +1,56 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import sirv from 'sirv'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
import { toPosix } from './util'
|
||||
|
||||
/** 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)
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
* 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).
|
||||
* 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.
|
||||
*/
|
||||
export function publicLayersPlugin(publicDirs: string[]): Plugin {
|
||||
const dirs = publicDirs.filter(existsSync) // high → low
|
||||
let outDir = ''
|
||||
let copyPublic = true
|
||||
|
||||
return {
|
||||
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.
|
||||
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) {
|
||||
// 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) })
|
||||
writeBundle(options) {
|
||||
// Build: copy each file straight to disk via the OS instead of holding every asset's bytes in
|
||||
// memory at once — peak RSS stays flat no matter how large the public set is.
|
||||
if (!copyPublic || dirs.length === 0) return
|
||||
// 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).
|
||||
if (options.dir && resolve(options.dir) !== outDir) return
|
||||
for (const [fileName, abs] of mergePublic(dirs)) {
|
||||
const dest = join(outDir, fileName)
|
||||
mkdirSync(dirname(dest), { recursive: true })
|
||||
copyFileSync(abs, dest)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
import { toPosix } from './util'
|
||||
|
||||
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
||||
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
|
||||
@@ -12,9 +13,62 @@ export interface LayersResolverOptions {
|
||||
prefixes?: string[]
|
||||
/** Extensions probed when the id has no explicit, existing file. */
|
||||
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 => {
|
||||
try {
|
||||
@@ -25,19 +79,12 @@ const isFile = (p: string): boolean => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Build the shared resolution core (probing + cache + `super()` + optional recording). Stateless
|
||||
* across importers: the candidate list for a sub-path is importer-independent, so `super()` works by
|
||||
* locating the importer's position in the list and taking the next entry down.
|
||||
*/
|
||||
export function layersResolver(options: LayersResolverOptions): Plugin {
|
||||
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options
|
||||
export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
|
||||
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options
|
||||
|
||||
const probe = (root: string, sub: string): string | null => {
|
||||
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
|
||||
// (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).
|
||||
// (high→low priority). Saves the per-import `statSync` storm; the list is importer-independent, so
|
||||
// super() stays correct — we locate the importer's position in it and take the next entry down.
|
||||
const cache = new Map<string, string[]>()
|
||||
const candidates = (sub: string): string[] => {
|
||||
const cached = cache.get(sub)
|
||||
@@ -69,30 +116,108 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
|
||||
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 {
|
||||
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()
|
||||
const clear = () => resolution.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
|
||||
resolveId: {
|
||||
filter: { id: idFilter },
|
||||
handler(id, importer) {
|
||||
return resolution.resolveId(id, importer)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ import { defu } from 'defu'
|
||||
import { type TSConfig, writeTSConfig } from 'pkg-types'
|
||||
import type { Plugin } from 'vite'
|
||||
import { resolveLayerStack } from './config'
|
||||
import { FEATURE_MODULE, featuresDts } from './features'
|
||||
import { hooksFromStack, type LayerHookable } from './hooks'
|
||||
import type { LayerStack } from './types'
|
||||
import { toPosix } from './util'
|
||||
|
||||
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 {
|
||||
/**
|
||||
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
|
||||
@@ -25,52 +30,10 @@ export interface GenerateTsConfigOptions {
|
||||
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. */
|
||||
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')
|
||||
}
|
||||
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(LEADING_NON_DOT_RE, './$1') || '.'
|
||||
|
||||
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
|
||||
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)}/*`],
|
||||
// `#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) {
|
||||
// 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'))]
|
||||
|
||||
// App/client config: layer src trees + the typed __FEATURES__ global. Config files are NOT here —
|
||||
// they belong to the node config below.
|
||||
// App/client config: layer src trees + the typed `feature()` flags (features.d.ts). 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)}/**/*`)],
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface LayerConfig {
|
||||
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. */
|
||||
/** Build-time feature flags, read in app code via the `feature('key')` macro (`#feature`). */
|
||||
features?: Record<string, unknown>
|
||||
/**
|
||||
* tsconfig overrides contributed by this layer, merged across the stack into the generated
|
||||
@@ -52,9 +52,26 @@ export interface Layer {
|
||||
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 {
|
||||
/** 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[]
|
||||
/**
|
||||
* 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[]
|
||||
}
|
||||
|
||||
@@ -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, '/')
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import { configWatchPlugin } from '../src/dev'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
@@ -39,35 +39,13 @@ describe('configWatchPlugin', () => {
|
||||
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()
|
||||
it('restarts when a config is newly added to a layer that had none', () => {
|
||||
const plugin = configWatchPlugin([fixture('stack/app')])
|
||||
const { server, watcher, restart } = mockServer()
|
||||
callConfigureServer(plugin, server)
|
||||
// app.config.js does not exist at startup, but it is a candidate path → `add` must restart.
|
||||
watcher.emit('add', resolve(fixture('stack/app'), 'app.config.js'))
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -13,41 +13,37 @@ async function build(appDir: string): Promise<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', () => {
|
||||
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 features = JSON.parse((cfg.define as Record<string, string>).__FEATURES__)
|
||||
expect(features.shared).toBe('app')
|
||||
expect(features).toMatchObject({ app: true, base: true, core: true })
|
||||
const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
|
||||
expect(plugins.some(p => p?.name === 'vite-layers:features')).toBe(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 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')
|
||||
const define = (cfg.define ?? {}) as Record<string, string>
|
||||
expect(Object.keys(define).some(k => k.startsWith('__FEATURES__'))).toBe(false)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
it('compiles feature() against the merged flags; layers:resolved mutates them first, vite:config runs last', async () => {
|
||||
const fn = (await buildViteConfig(fixture('stack/app'), {
|
||||
hooks: {
|
||||
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
|
||||
@@ -55,9 +51,10 @@ describe('buildViteConfig', () => {
|
||||
},
|
||||
})) 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
|
||||
const feat = (cfg.plugins as Plugin[]).flat(Infinity as 1).find(p => (p as Plugin)?.name === 'vite-layers:features') as Plugin
|
||||
const out = runTransform(feat, `import { feature } from '#feature'\nexport const a = feature('injected')\n`)
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
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'
|
||||
|
||||
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)()
|
||||
|
||||
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()
|
||||
},
|
||||
type ResolvedishConfig = { root: string; build: { outDir: string; copyPublicDir?: boolean } }
|
||||
|
||||
/** List every file written under `dir` as `posixRelativePath → contents`. */
|
||||
function snapshot(dir: string): Record<string, string> {
|
||||
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', () => {
|
||||
const high = fixture('public/high/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', () => {
|
||||
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
|
||||
it('copies assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
|
||||
const written = runBuild(publicLayersPlugin([high, low]), outDir)
|
||||
expect(written['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
|
||||
expect(written['shared.txt']).toBe('LOW_SHARED') // inherited 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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
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 toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
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.
|
||||
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)
|
||||
const resolveId = (id: string, importer?: string): string | null => callResolveId(plugin, id, importer)
|
||||
|
||||
describe('layersResolver', () => {
|
||||
it('ignores non-layered ids', () => {
|
||||
@@ -42,6 +49,27 @@ describe('layersResolver', () => {
|
||||
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', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
@@ -54,15 +82,81 @@ describe('layersResolver', () => {
|
||||
|
||||
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)
|
||||
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('@/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)
|
||||
const rid = (id: string) => callResolveId(p, id)
|
||||
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import { generateTsConfig } from '../src/tsconfig'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||
@@ -104,7 +104,13 @@ describe('generateTsConfig', () => {
|
||||
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__:')
|
||||
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 () => {
|
||||
@@ -117,18 +123,6 @@ describe('generateTsConfig', () => {
|
||||
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')
|
||||
expect(r.dts).toContain('onlyInFake: true') // literal type, from the fake stack's features
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||