feat: add feature plugin tests and validation for feature flags
@@ -1 +1,2 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
.claude
|
||||||
@@ -45,34 +45,32 @@ export default buildViteConfig(import.meta.dirname)
|
|||||||
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
|
Гейтите опциональные страницы так, чтобы выключенные **исчезали из бандла** (а не просто переставали роутиться):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// __FEATURES__ типизируется сгенерированным .vite-layers/features.d.ts — declare не нужен
|
import { feature } from '#feature' // алиас регистрирует vite-layers; типы — из .vite-layers/features.d.ts
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: () => import('@/pages/Home') },
|
{ path: '/', component: () => import('@/pages/Home') },
|
||||||
...(__FEATURES__.billing ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
|
...(feature('billing') ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
Тип `__FEATURES__` генерируется из `merged.features` в `.vite-layers/features.d.ts`, поэтому опечатка
|
`feature('key')` — **компайл-тайм макрос**: плагин заменяет вызов на литерал значения флага,
|
||||||
(`__FEATURES__.biling`) — ошибка компиляции, а не молчком-falsy.
|
**одинаково в dev и в build** (один AST-transform, без расхождений). Подставленный `false` делает ветку
|
||||||
|
статически мёртвой, и Rollup/rolldown вырезает её вместе с `import()` — чанк выключенной фичи не эмитится.
|
||||||
|
Тип `feature` генерируется из `merged.features` **литеральными типами**, поэтому опечатка ключа
|
||||||
|
(`feature('biling')`) — ошибка компиляции.
|
||||||
|
|
||||||
Флаги вшиваются через `define` и сворачиваются esbuild ещё **до** построения графа Rollup, поэтому
|
Правила (они **enforced**: нарушение валит сборку — и в dev, и в build, ничего не «протекает» молча):
|
||||||
выключенная ветка и её `import()` физически не попадают в бандл. Дотированные литералы эмитятся на
|
|
||||||
любую глубину (`__FEATURES__.nested.enabled` тоже сворачивается). Правила, чтобы DCE сработало:
|
|
||||||
|
|
||||||
- обращайтесь **напрямую** — `__FEATURES__.billing`; алиас/деструктуризация (`const f = __FEATURES__; f.billing`)
|
- ключ — строковый литерал: `feature('billing')`, не `feature(name)`;
|
||||||
и динамический доступ (`__FEATURES__[name]`) не сворачиваются;
|
- вызывайте напрямую — без алиасов (`const f = feature`), деструктуризации и передачи как значения;
|
||||||
- гейт оборачивает сам `import()` (тернарник/`&&`/спред), а не `.filter` после — reachable-импорт не вырезается;
|
- вложенные флаги — дотированным ключом: `feature('payments.stripe')`;
|
||||||
- ключи фич — валидные JS-идентификаторы (kebab/пробел доступны в рантайме через объект `__FEATURES__`,
|
- ключ должен существовать в `merged.features` (иначе — ошибка сборки).
|
||||||
но без DCE);
|
|
||||||
- в тестах продублируйте `define` (в `vitest.config`) или гардите `globalThis.__FEATURES__ ?? {}`.
|
|
||||||
|
|
||||||
**Dev-режим.** В build флаги работают через `define` (+ DCE). В dev Vite 8 / rolldown-vite **не**
|
Тестам ничего дублировать не нужно — тот же transform работает в Vitest (плагин — часть конфига).
|
||||||
инлайнит пользовательский `define` в исходники, поэтому `vite-layers` сам подставляет `__FEATURES__`
|
В `.vue`-шаблоне напрямую `feature('x')` использовать нельзя (компилятор делает из него `_ctx.feature` —
|
||||||
в рантайме (dev-only плагин). Плюс при изменении любого `app.config.*` слоя dev-сервер
|
это не вызов макроса): читайте флаг в `<script setup>`/JSX и используйте в шаблоне локальную переменную.
|
||||||
**автоматически перезапускается** (`app.config` грузится c12, вне графа Vite — сам он не следит) —
|
При изменении любого `app.config.*` dev-сервер **автоматически перезапускается** (`app.config` грузится
|
||||||
так фичи обновляются без ручного рестарта. В шаблонах `.vue` `__FEATURES__` напрямую использовать
|
c12, вне графа Vite — сам он не следит), подхватывая новые значения флагов.
|
||||||
нельзя — компилятор префиксует его в `_ctx.__FEATURES__` (define/рантайм-подстановка не матчат);
|
|
||||||
читайте флаг в `<script setup>` и используйте в шаблоне локальную переменную.
|
|
||||||
|
|
||||||
## Префиксы импортов
|
## Префиксы импортов
|
||||||
|
|
||||||
@@ -81,6 +79,7 @@ const routes = [
|
|||||||
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
|
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
|
||||||
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
|
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
|
||||||
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
|
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
|
||||||
|
| `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы |
|
||||||
|
|
||||||
## Модель приоритета (из Nuxt)
|
## Модель приоритета (из Nuxt)
|
||||||
|
|
||||||
@@ -114,12 +113,19 @@ export default defineLayerConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Объявляйте все ключи фич в базовом `features`** (как `analytics` выше), а `$env`-блоки используйте
|
||||||
|
только чтобы менять их **значения**. Ключ, существующий лишь в `$production`, будет «неизвестен» в dev
|
||||||
|
(`feature('x')` → ошибка сборки) и не попадёт в типы. Литеральные типы в `features.d.ts` отражают тот
|
||||||
|
`mode`, в котором их сгенерировали (dev/build/`prepare`) — поэтому флаг с разными значениями по mode
|
||||||
|
типизируется значением текущего mode; держите ключи в базе для предсказуемости.
|
||||||
|
|
||||||
## Опции
|
## Опции
|
||||||
|
|
||||||
`buildViteConfig(appDir, options?)`:
|
`buildViteConfig(appDir, options?)`:
|
||||||
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}` — `GenerateTsConfigOptions`.
|
- `tsconfig: false` — выключить автоген tsconfig; `tsconfig: {...}` — `GenerateTsConfigOptions`.
|
||||||
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
|
- `resolver: { prefixes?, extensions? }` — сменить слоёвые префиксы / расширения резолвера (напр. добавить `.svelte`).
|
||||||
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
|
- `hooks: {...}` — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
|
||||||
|
- `devtools: false` — не монтировать панели в Vite DevTools (см. ниже). По умолчанию включено.
|
||||||
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
|
- `outDir`, `vite` — выходная папка и финальный Vite-фрагмент (высший приоритет).
|
||||||
|
|
||||||
## Хуки жизненного цикла
|
## Хуки жизненного цикла
|
||||||
@@ -147,6 +153,38 @@ export default defineLayerConfig({
|
|||||||
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
|
Программно: `buildViteConfig(dir, { hooks: { … } })`. Низкоуровнево экспортируются
|
||||||
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`.
|
`createLayerHooks`/`registerLayerHooks`/`hooksFromStack` и типы `LayerHooks`/`LayerHookable`.
|
||||||
|
|
||||||
|
## DevTools
|
||||||
|
|
||||||
|
vite-layers умеет показывать свой резолвнутый стек прямо в [Vite DevTools](https://devtools.vite.dev)
|
||||||
|
(`@vitejs/devtools`). Добавьте хаб в dev — `buildViteConfig` сам подмонтирует панели; без хаба плагин
|
||||||
|
**инертен** (используются только *типы* из `@vitejs/devtools-kit`, никакой рантайм-зависимости):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/main/app.config.ts
|
||||||
|
import { DevTools } from '@vitejs/devtools' // peer-зависимость только для dev
|
||||||
|
export default defineLayerConfig({
|
||||||
|
vite: ({ command }) => ({
|
||||||
|
plugins: [vue(), command === 'serve' && DevTools()], // хаб только в dev
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Четыре панели (свёрнуты под одной кнопкой `vite-layers`):
|
||||||
|
|
||||||
|
| Панель | Что показывает |
|
||||||
|
|---|---|
|
||||||
|
| **Layers** | **дерево наследования** (`extends`-граф box-drawing, с ромбами и авто-сканом), резолвнутый стек high→low, мёрж-конфиг (Tree), накопленные хуки |
|
||||||
|
| **Features** | мёрж-флаги с литеральными значениями и статусом DCE (`kept` / `branch eliminated`); бейдж = число выключенных |
|
||||||
|
| **Resolver** | плейграунд `@/…` (показывает кандидатов по слоям + победителя) и **живой лог** реальных слоёвых резолвов сессии (включая `super()`-self-skip) |
|
||||||
|
| **Public & TS** | слоёвые `public/`-ассеты (кто кого затеняет) и сгенерированные `tsconfig.json` / `tsconfig.node.json` / `features.d.ts` |
|
||||||
|
|
||||||
|
UI рисуется целиком на сервере (json-render спеки `@vitejs/devtools-kit`) — **клиентский бандл не
|
||||||
|
нужен**, vite-layers остаётся buildless. Панели читают тот же стек и тот же резолвер-кэш, что и сборка
|
||||||
|
(`createLayeredResolution` шарится между резолвер-плагином и панелью), поэтому показанное — ровно то,
|
||||||
|
что действует. При первом заходе DevTools попросит авторизовать браузер (разовый prompt в терминале).
|
||||||
|
|
||||||
|
Плагин можно подключить и вручную — `layersDevtoolsPlugin` экспортируется из `vite-layers/devtools`.
|
||||||
|
|
||||||
## Улучшения над Nuxt/c12
|
## Улучшения над Nuxt/c12
|
||||||
|
|
||||||
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
|
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
|
||||||
@@ -154,15 +192,17 @@ export default defineLayerConfig({
|
|||||||
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
|
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
|
||||||
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
|
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
|
||||||
`resolve`-хуке c12 обрывает рекурсию.
|
`resolve`-хуке c12 обрывает рекурсию.
|
||||||
3. **Побрендовый DCE** — гейтированные динамические `import()` выпиливаются из бандлов выключенных
|
3. **Побрендовый DCE через `feature()`-макрос** — гейтированные `import()` выпиливаются из бандлов
|
||||||
брендов через дотированные `__FEATURES__.<key>` defines (esbuild сворачивает литерал ещё до того,
|
выключенных брендов: AST-transform заменяет `feature('key')` на литерал (**один механизм для dev и
|
||||||
как Rollup построит граф модулей).
|
build**), а любое не-сворачиваемое использование (алиас, динамический/неизвестный ключ) валит
|
||||||
|
сборку с понятной ошибкой — вместо молчаливой деградации DCE, как при `define`-подходе Nuxt.
|
||||||
|
|
||||||
## TypeScript (автогенерация tsconfig)
|
## TypeScript (автогенерация tsconfig)
|
||||||
|
|
||||||
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
|
Framework-agnostic порт Nuxt `prepare:types`. `buildViteConfig` пишет на каждом dev/build
|
||||||
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` типизирует
|
`<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts}` (`features.d.ts` аугментирует
|
||||||
`__FEATURES__`); `tsconfig.json` приложения его расширяет:
|
модуль `#feature` литеральными типами `feature()`, а сгенерированный `paths['#feature']` резолвит сам
|
||||||
|
макрос); `tsconfig.json` приложения его расширяет:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// apps/brand/tsconfig.json
|
// apps/brand/tsconfig.json
|
||||||
@@ -210,34 +250,83 @@ vue-tsc --noEmit -p apps/brand # или tsc --noEmit
|
|||||||
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
|
tsconfig; `options.tsconfig: false` — отключить, `options.tsconfig: {...}` — настроить).
|
||||||
- `resolveLayerStack(cwd)` → `{ merged, layers }` — резолвнутый упорядоченный стек.
|
- `resolveLayerStack(cwd)` → `{ merged, layers }` — резолвнутый упорядоченный стек.
|
||||||
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно).
|
- `layersResolver({ roots, prefixes?, extensions? })` — Vite-плагин резолвера (можно отдельно).
|
||||||
|
- `createLayeredResolution({ roots, prefixes?, extensions?, record? })` — чистое ядро резолвера
|
||||||
|
(`parse`/`candidates`/`resolveId`/`records`); шарится между резолвер-плагином и DevTools-панелью.
|
||||||
- `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
|
- `generateTsConfig(appDir, opts?)` / `writeTsConfig(appDir, opts?)` / `tsconfigPlugin(appDir, opts?)` — генерация tsconfig.
|
||||||
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
|
- `defineLayerConfig(config)` — типизированный хелпер для `app.config.ts`.
|
||||||
|
- `feature(key)` (импорт из `#feature` / `vite-layers/feature`) — компайл-тайм макрос флагов; `featurePlugin(features)` — сам плагин (можно отдельно).
|
||||||
|
- `layersDevtoolsPlugin(data)` (импорт из `vite-layers/devtools`) — панели для Vite DevTools (автоматически подключаются `buildViteConfig`).
|
||||||
|
|
||||||
## Пример (Vue)
|
## Пример (Vue + Tailwind)
|
||||||
|
|
||||||
`example/apps/{main,brand}` — запускаемое Vue-демо. `main` — база (`vue()` + страница `billing`),
|
`example/apps/{main,brand,aurora}` — запускаемое мультибрендовое демо. Общий «каркас» (шапка, подвал,
|
||||||
`brand` расширяет её, перекрывает `AppHeader.vue` и выключает `billing`. Соберите оба и сравните:
|
страница профиля, страница биллинга, роутер, входной Tailwind-CSS) живёт только в базовом слое `main`;
|
||||||
|
каждый бренд меняет ровно **три** вещи — логотип, файл темы Tailwind и лендинг:
|
||||||
|
|
||||||
|
| Приложение | Слой | Тема | DCE-страница `billing` | Beta-плашка |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `main` (Acme) | база | светлая, индиго | ✅ есть | ✅ (в dev) |
|
||||||
|
| `brand` (Northwind) | `extends ../main` | светлая, изумруд | ❌ вырезана | ✅ (унаследована) |
|
||||||
|
| `aurora` (Aurora) | `extends ../main` | **тёмная**, роза/небо | ✅ есть | ❌ выключена |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx vite build example/apps/main # эмитит чанки Home + Billing
|
pnpm example:dev # дев-сервер Acme (бренды: npx vite example/apps/{brand,aurora})
|
||||||
npx vite build example/apps/brand # только Home (чанка Billing НЕТ → DCE); AppHeader перекрыт
|
pnpm example:build # билд всех трёх — сравните эмитнутые чанки
|
||||||
|
pnpm example:check # prepare + vue-tsc для всех трёх (код приложения + node-конфиги)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Как меняется тема.** Общий `@/style.css` (только в базе) подключает Tailwind и мапит токены на
|
||||||
|
runtime-переменные через `@theme inline` (`--color-brand: var(--c-brand)` и т.д.). Каждый бренд кладёт
|
||||||
|
свой `@/assets/theme.css` с `:root { --c-* }`; слоёвый резолвер выбирает версию верхнего слоя — и весь
|
||||||
|
общий UI перекрашивается, без правки единого компонента. `@tailwindcss/vite` резолвит CSS-`@import`
|
||||||
|
своим резолвером (мимо слоёв), поэтому тему подключаем **JS-импортом** `import '@/assets/theme.css'`,
|
||||||
|
который идёт через слоёвый резолвер.
|
||||||
|
|
||||||
Что демонстрирует демо:
|
Что демонстрирует демо:
|
||||||
|
|
||||||
- **Оверрайд:** `brand/src/components/AppHeader.vue` затеняет версию из `main` (`@/components/AppHeader.vue`).
|
- **Общий каркас:** `AppHeader`, `AppFooter`, `Profile`, `Billing`, роутер есть только в `main`, но
|
||||||
- **DCE:** `features.billing: false` → динамический `import('@/pages/Billing.vue')` мёртв → чанк не эмитится.
|
рендерятся во всех брендах через `@/…`. У `brand` больше нет своего `AppHeader` — шапка общая.
|
||||||
- **Алиасы/резолвер:** `main.ts` тянет страницы и компонент через `@/…` сквозь слои.
|
- **Перекраска темой:** один и тот же `Profile`/`Billing` рендерится светлым у Acme/Northwind и тёмным
|
||||||
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`), `vue-tsc` зелёный:
|
у Aurora — разница только в `theme.css` (10 токенов цвета/радиуса/шрифта).
|
||||||
|
- **Перекрытие ассета:** `*/public/logo.svg` затеняется послойно; `favicon.svg` наследуется из базы.
|
||||||
|
- **DCE:** у `brand` `features.billing: false` → `feature('billing')` сворачивается в `false` →
|
||||||
|
`import('@/pages/Billing.vue')` мёртв → чанк `Billing-*.js` не эмитится (и ссылки в навигации нет):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vite-layers prepare example/apps/brand
|
ls example/apps/main/dist/main/assets | grep -i billing # Billing-*.js есть
|
||||||
npx vue-tsc --noEmit -p example/apps/brand
|
ls example/apps/brand/dist/brand/assets | grep -i billing # пусто → DCE
|
||||||
|
ls example/apps/aurora/dist/aurora/assets | grep -i billing # Billing-*.js есть
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Разные наборы фич:** `brand` убирает биллинг, но оставляет beta-плашку; `aurora` — наоборот.
|
||||||
|
`$production` гасит beta-плашку в проде у всех (env-оверрайд слоя).
|
||||||
|
- **tsconfig:** `app.config.ts` правит tsconfig (`jsxImportSource: 'vue'`, `types: ['vite/client']`),
|
||||||
|
`vue-tsc` зелёный для всех трёх:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vite-layers prepare example/apps/aurora
|
||||||
|
npx vue-tsc --noEmit -p example/apps/aurora
|
||||||
```
|
```
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, defines, tsconfig
|
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, feature()-макрос, tsconfig
|
||||||
pnpm type-check
|
pnpm type-check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Разработка **buildless** — example-приложения и тесты импортируют `src/*` напрямую, а `exports`
|
||||||
|
пакета указывают на `./src/*.ts`. Для публикации `pnpm build` (tsdown) собирает ESM + `.d.ts` в
|
||||||
|
`dist/` по одному выходу на каждый сабпас (`.`, `./feature`, `./devtools`); зависимости и peer'ы
|
||||||
|
(`vite`, `@vitejs/devtools-kit`) внешние. `publishConfig.exports` переключает пакет на `dist/` —
|
||||||
|
`prepack` пересобирает автоматически, в tarball едут только `dist/` + `bin/` (проверено `publint`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build # tsdown → dist/{index,feature,devtools}.{js,d.ts}
|
||||||
|
pnpm pack # prepack-сборка + публикуемый tarball (dist + bin)
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI `vite-layers prepare` и алиас `#feature` работают в обоих режимах: из исходников (`feature.ts`,
|
||||||
|
jiti) и из собранного `dist/` (`feature.js`).
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// CLI for vite-layers. Loads the TypeScript source via jiti (no build step needed).
|
// CLI for vite-layers. In a source checkout it loads the TypeScript source via jiti (no build step
|
||||||
|
// needed); a published package ships `dist` (not `src`), so it falls back to the built `dist/index.js`.
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { createJiti } from 'jiti'
|
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const jiti = createJiti(import.meta.url)
|
|
||||||
|
|
||||||
const [cmd, appArg] = process.argv.slice(2)
|
const [cmd, appArg] = process.argv.slice(2)
|
||||||
|
|
||||||
@@ -14,7 +14,11 @@ if (cmd !== 'prepare') {
|
|||||||
process.exit(cmd ? 1 : 0)
|
process.exit(cmd ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const srcEntry = resolve(here, '../src/tsconfig.ts')
|
||||||
|
const { writeTsConfig } = existsSync(srcEntry)
|
||||||
|
? await (await import('jiti')).createJiti(import.meta.url).import(srcEntry)
|
||||||
|
: await import(resolve(here, '../dist/index.js'))
|
||||||
|
|
||||||
const appDir = resolve(process.cwd(), appArg ?? '.')
|
const appDir = resolve(process.cwd(), appArg ?? '.')
|
||||||
const { writeTsConfig } = await jiti.import(resolve(here, '../src/tsconfig.ts'))
|
|
||||||
const file = await writeTsConfig(appDir)
|
const file = await writeTsConfig(appDir)
|
||||||
console.log(`vite-layers: wrote ${file}`)
|
console.log(`vite-layers: wrote ${file}`)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineLayerConfig } from '../../../src/index.ts'
|
||||||
|
|
||||||
|
// Aurora — a dark-themed brand. Like Northwind it inherits the whole shell from `main` and changes
|
||||||
|
// only its logo, theme.css and Landing. It keeps the billing page but turns off the beta accent,
|
||||||
|
// showing that each brand toggles a different subset of features.
|
||||||
|
export default defineLayerConfig({
|
||||||
|
name: 'aurora',
|
||||||
|
extends: ['../main'],
|
||||||
|
features: { betaBanner: false }, // keeps `billing` (inherited true); drops the beta pill
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Aurora — Cloud Platform</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="28" viewBox="0 0 124 28" fill="none" role="img" aria-label="Aurora">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="au" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#fb7185"/>
|
||||||
|
<stop offset="1" stop-color="#38bdf8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M3 22 a11 11 0 0 1 22 0" fill="none" stroke="url(#au)" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M7.5 22 a6.5 6.5 0 0 1 13 0" fill="none" stroke="url(#au)" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<circle cx="14" cy="22" r="1.8" fill="#fb7185"/>
|
||||||
|
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#eef1f8">Aurora</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 762 B |
@@ -0,0 +1,14 @@
|
|||||||
|
/* Aurora — a dark brand. The same shared header, footer, profile and billing pages render dark
|
||||||
|
purely because every token below flips; not one component is overridden. Rose primary, sky accent. */
|
||||||
|
:root {
|
||||||
|
--c-canvas: #0a0f1f;
|
||||||
|
--c-surface: #121829;
|
||||||
|
--c-ink: #eef1f8;
|
||||||
|
--c-muted: #9aa3bd;
|
||||||
|
--c-line: #232b42;
|
||||||
|
--c-brand: #fb7185;
|
||||||
|
--c-on-brand: #1a1022;
|
||||||
|
--c-accent: #38bdf8;
|
||||||
|
--c-radius: 1rem;
|
||||||
|
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Aurora has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||||
|
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||||
|
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||||
|
import '@/main.ts'
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Aurora's landing — its own dark, neon take. Same shared shell around it; only this page, the
|
||||||
|
// logo and the theme tokens are Aurora's.
|
||||||
|
const pillars = [
|
||||||
|
{ kbd: '01', title: 'Realtime by default', body: 'Streams, presence and sync primitives baked into the runtime.' },
|
||||||
|
{ kbd: '02', title: 'Edge native', body: 'Deploy to 30+ regions and serve from the closest one automatically.' },
|
||||||
|
{ kbd: '03', title: 'Zero-config scale', body: 'From first request to a million — no capacity planning, ever.' },
|
||||||
|
]
|
||||||
|
const metrics = ['30+ regions', '4ms cold start', '∞ concurrency', '24/7 support']
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -top-40 left-1/2 h-96 w-[42rem] -translate-x-1/2 rounded-full bg-linear-to-br from-brand to-accent opacity-25 blur-3xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="mx-auto max-w-3xl px-6 pt-24 pb-12 text-center">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs font-medium text-muted">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-brand" /> Aurora 2.0 is live
|
||||||
|
</span>
|
||||||
|
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink sm:text-6xl">
|
||||||
|
Ship at the speed of
|
||||||
|
<span class="bg-linear-to-br from-brand to-accent bg-clip-text text-transparent">light</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mx-auto mt-5 max-w-xl text-balance text-lg text-muted">
|
||||||
|
Aurora is the realtime cloud for builders who refuse to wait. Push code, watch it go global in seconds.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex items-center justify-center gap-3">
|
||||||
|
<a href="#/billing" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||||
|
Launch a project
|
||||||
|
</a>
|
||||||
|
<a href="#/profile" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:opacity-90">
|
||||||
|
View console
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metric marquee -->
|
||||||
|
<div class="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-x-8 gap-y-2 px-6 pb-16 text-sm text-muted">
|
||||||
|
<span v-for="m in metrics" :key="m" class="flex items-center gap-2">
|
||||||
|
<span class="h-1 w-1 rounded-full bg-accent" /> {{ m }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pillars -->
|
||||||
|
<section class="mx-auto max-w-5xl px-6 pb-24">
|
||||||
|
<div class="grid gap-5 sm:grid-cols-3">
|
||||||
|
<article v-for="p in pillars" :key="p.kbd" class="rounded-card border border-line bg-surface p-6">
|
||||||
|
<span class="font-mono text-sm text-brand">{{ p.kbd }}</span>
|
||||||
|
<h3 class="mt-3 text-lg font-semibold text-ink">{{ p.title }}</h3>
|
||||||
|
<p class="mt-2 text-sm leading-relaxed text-muted">{{ p.body }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 overflow-hidden rounded-card border border-line bg-linear-to-br from-brand/15 to-accent/10 px-8 py-14 text-center">
|
||||||
|
<h2 class="text-balance text-3xl font-semibold tracking-tight text-ink">Your next deploy is one command away</h2>
|
||||||
|
<a href="#/billing" class="mt-7 inline-block rounded-full bg-brand px-6 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||||
|
Start building
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.vite-layers/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildViteConfig } from '../../../src/index.ts'
|
||||||
|
|
||||||
|
export default buildViteConfig(import.meta.dirname)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { defineLayerConfig } from '../../../src/index.ts'
|
import { defineLayerConfig } from '../../../src/index.ts'
|
||||||
|
|
||||||
|
// Northwind — a reseller brand. It inherits the entire shell (header, footer, profile, router,
|
||||||
|
// Tailwind setup) from `main` and changes only three things: its logo, its theme.css tokens and
|
||||||
|
// its Landing page. It also drops the billing page entirely.
|
||||||
export default defineLayerConfig({
|
export default defineLayerConfig({
|
||||||
name: 'brand',
|
name: 'brand',
|
||||||
extends: ['../main'],
|
extends: ['../main'],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>vite-layers — brand</title>
|
<title>Northwind — Cloud Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -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 vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwind from '@tailwindcss/vite'
|
||||||
|
import { DevTools } from '@vitejs/devtools'
|
||||||
import { defineLayerConfig } from '../../../src/index.ts'
|
import { defineLayerConfig } from '../../../src/index.ts'
|
||||||
|
|
||||||
export default defineLayerConfig({
|
export default defineLayerConfig({
|
||||||
name: 'main',
|
name: 'main',
|
||||||
features: { billing: true, p2p: true },
|
// `billing` gates a whole page (build-time DCE); `betaBanner` toggles a UI accent and is
|
||||||
// The framework plugin lives in the layer config, not in vite-layers' core.
|
// turned off in production via the `$production` env override below.
|
||||||
vite: {
|
features: { billing: true, betaBanner: true },
|
||||||
plugins: [vue()],
|
|
||||||
|
// Framework + CSS plugins live in the layer config, not in vite-layers' core. Brands inherit them.
|
||||||
|
// The Vite DevTools hub is added in dev only (`DevTools()` is async → a Promise<Plugin[]>, a valid
|
||||||
|
// plugin entry); vite-layers auto-mounts its Layers / Features / Resolver / Public & TS panels into
|
||||||
|
// it via `buildViteConfig`. Build output is untouched (the hub is excluded when command is 'build').
|
||||||
|
vite: ({ command }) => ({
|
||||||
|
plugins: [vue(), tailwind(), command === 'serve' && DevTools()],
|
||||||
build: { rolldownOptions: { input: { main: '@/main.ts' } } },
|
build: { rolldownOptions: { input: { main: '@/main.ts' } } },
|
||||||
},
|
}),
|
||||||
|
|
||||||
// Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig).
|
// Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig).
|
||||||
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue' } },
|
// `vite/client` supplies ambient module decls for the CSS side-effect imports (`*.css`) and
|
||||||
|
// `import.meta.env`. It flows into every brand's generated app tsconfig too.
|
||||||
|
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue', types: ['vite/client'] } },
|
||||||
|
|
||||||
$production: {
|
$production: {
|
||||||
features: { p2p: false },
|
features: { betaBanner: false }, // the beta accent never ships to prod
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>vite-layers — main</title>
|
<title>Acme — Cloud Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
SHARED_FAVICON
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<!-- Shared favicon: inherited by every brand (only logo.svg is overridden per brand). -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fav" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6366f1"/>
|
||||||
|
<stop offset="1" stop-color="#a855f7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="8" fill="url(#fav)"/>
|
||||||
|
<rect x="8" y="8" width="11" height="11" rx="3" fill="#fff" opacity="0.55"/>
|
||||||
|
<rect x="13" y="13" width="11" height="11" rx="3" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 14 B After Width: | Height: | Size: 588 B |
@@ -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">
|
<script setup lang="ts">
|
||||||
const title = 'MAIN_HEADER'
|
import { feature } from '#feature'
|
||||||
|
import { routes, useRoute } from '@/router'
|
||||||
|
|
||||||
|
// One header for every brand. The only thing that differs per brand is `/logo.svg` (a layered
|
||||||
|
// public/ asset) and the theme tokens behind the utility classes.
|
||||||
|
const { currentPath } = useRoute()
|
||||||
|
|
||||||
|
// Read the build-time flag in <script> (a compiled `_ctx.feature` in a template is not a macro
|
||||||
|
// call); use the local in the template. Folds to a literal, so the pill is DCE'd out in production.
|
||||||
|
const beta = feature('betaBanner')
|
||||||
|
|
||||||
|
// `/logo.svg` is a layered public/ asset served at runtime — bind it (not a static `src`) so the
|
||||||
|
// Vue compiler keeps it a URL instead of trying to resolve it as a module at build time.
|
||||||
|
const logo = '/logo.svg'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>{{ title }}</header>
|
<header class="sticky top-0 z-20 border-b border-line bg-surface/80 backdrop-blur">
|
||||||
|
<div class="mx-auto flex h-16 max-w-6xl items-center gap-5 px-6">
|
||||||
|
<a href="#/" class="flex shrink-0 items-center gap-2">
|
||||||
|
<img :src="logo" alt="Logo" class="h-7 w-auto" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="beta"
|
||||||
|
class="hidden rounded-full border border-brand/30 bg-brand/10 px-2 py-0.5 text-xs font-semibold tracking-wide text-brand sm:inline"
|
||||||
|
>
|
||||||
|
BETA
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<nav class="ml-2 hidden items-center gap-1 md:flex">
|
||||||
|
<a
|
||||||
|
v-for="r in routes"
|
||||||
|
:key="r.path"
|
||||||
|
:href="`#${r.path}`"
|
||||||
|
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||||
|
:class="currentPath === r.path ? 'bg-ink/6 text-ink' : 'text-muted hover:text-ink'"
|
||||||
|
>
|
||||||
|
{{ r.label }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-3">
|
||||||
|
<a href="#/profile" class="hidden text-sm font-medium text-muted transition-colors hover:text-ink sm:block">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#/"
|
||||||
|
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
import { createApp, defineAsyncComponent, h, shallowRef, type Component } from 'vue'
|
import { createApp } from 'vue'
|
||||||
const AppHeader = defineAsyncComponent(() => import('@/components/AppHeader.vue'))
|
import App from '@/App.vue'
|
||||||
|
|
||||||
// `__FEATURES__` is typed by the generated `.vite-layers/features.d.ts` — no manual `declare` needed.
|
// `@/style.css` (Tailwind entry) lives only in the base layer, so every brand shares it.
|
||||||
|
// `@/theme.css` is layer-resolved: each brand ships its own token file that shadows the base's,
|
||||||
|
// recoloring the whole shared UI. Imported after style.css so its :root vars win the cascade.
|
||||||
|
import '@/style.css'
|
||||||
|
import '@/assets/theme.css'
|
||||||
|
|
||||||
// Pages are gated on build-time feature flags: a disabled page's dynamic import() is
|
createApp(App).mount('#app')
|
||||||
// statically dead, so its chunk is never emitted (per-brand dead-code elimination).
|
|
||||||
const routes = [
|
|
||||||
{ path: '/', component: () => import('@/pages/Home.vue') },
|
|
||||||
...(__FEATURES__.billing
|
|
||||||
? [{ path: '/billing', component: () => import('@/pages/Billing.vue') }]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
|
|
||||||
// A tiny hash router so `routes` (and thus the gated import) is actually reachable.
|
|
||||||
const current = shallowRef<Component | null>(null)
|
|
||||||
async function navigate() {
|
|
||||||
const path = location.hash.slice(1) || '/'
|
|
||||||
const route = routes.find(r => r.path === path) ?? routes[0]
|
|
||||||
current.value = route ? ((await route.component()).default as Component) : null
|
|
||||||
}
|
|
||||||
window.addEventListener('hashchange', navigate)
|
|
||||||
void navigate()
|
|
||||||
|
|
||||||
createApp({
|
|
||||||
render: () => h('div', [h(AppHeader), current.value ? h(current.value) : null]),
|
|
||||||
}).mount('#app')
|
|
||||||
|
|||||||
@@ -1,3 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// BILLING_PAGE_HEAVY_MARKER — grep the build output for this string: it appears in Acme's and
|
||||||
|
// Aurora's bundles (billing: true) but NOT in Northwind's, whose `billing: false` makes the gated
|
||||||
|
// import() in router.ts statically dead, so this whole chunk is dead-code-eliminated.
|
||||||
|
const invoices = [
|
||||||
|
{ id: 'INV-2048', date: 'Jun 01, 2026', amount: '$240.00', status: 'Paid' },
|
||||||
|
{ id: 'INV-2031', date: 'May 01, 2026', amount: '$240.00', status: 'Paid' },
|
||||||
|
{ id: 'INV-2014', date: 'Apr 01, 2026', amount: '$180.00', status: 'Paid' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const usage = [
|
||||||
|
{ label: 'Seats', used: 18, total: 25 },
|
||||||
|
{ label: 'Projects', used: 42, total: 50 },
|
||||||
|
{ label: 'Storage', used: 312, total: 500, unit: 'GB' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>BILLING_PAGE_HEAVY_MARKER</main>
|
<div class="mx-auto max-w-3xl px-6 py-12">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-ink">Billing</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted">Manage your plan, usage, and invoices.</p>
|
||||||
|
|
||||||
|
<section class="mt-8 overflow-hidden rounded-card border border-line bg-surface">
|
||||||
|
<div class="flex items-center justify-between gap-4 bg-linear-to-br from-brand to-accent p-6 text-on-brand">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm/none opacity-80">Current plan</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold">Team — $240/mo</p>
|
||||||
|
</div>
|
||||||
|
<button class="rounded-full bg-on-brand/15 px-4 py-2 text-sm font-semibold backdrop-blur transition hover:bg-on-brand/25">
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 p-6 sm:grid-cols-3">
|
||||||
|
<div v-for="u in usage" :key="u.label">
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-sm font-medium text-ink">{{ u.label }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ u.used }}{{ u.unit ? '' : '' }} / {{ u.total }} {{ u.unit }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 overflow-hidden rounded-full bg-ink/10">
|
||||||
|
<div class="h-full rounded-full bg-brand" :style="{ width: `${(u.used / u.total) * 100}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-card border border-line bg-surface">
|
||||||
|
<h2 class="border-b border-line px-6 py-4 text-base font-semibold text-ink">Invoices</h2>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-muted">
|
||||||
|
<th class="px-6 py-3 font-medium">Invoice</th>
|
||||||
|
<th class="px-6 py-3 font-medium">Date</th>
|
||||||
|
<th class="px-6 py-3 font-medium">Amount</th>
|
||||||
|
<th class="px-6 py-3 font-medium">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-line">
|
||||||
|
<tr v-for="inv in invoices" :key="inv.id">
|
||||||
|
<td class="px-6 py-3 font-medium text-ink">{{ inv.id }}</td>
|
||||||
|
<td class="px-6 py-3 text-muted">{{ inv.date }}</td>
|
||||||
|
<td class="px-6 py-3 text-ink">{{ inv.amount }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span class="rounded-full bg-brand/10 px-2.5 py-0.5 text-xs font-semibold text-brand">{{ inv.status }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Acme's landing — the base version. Each brand ships its own Landing.vue at this path; the layer
|
||||||
|
// resolver picks the highest-priority one, so the marketing page is fully brand-owned while the
|
||||||
|
// header, footer and profile around it stay shared.
|
||||||
|
const features = [
|
||||||
|
{ title: 'Layered by design', body: 'Compose every surface from shared layers. Override only what a brand needs.' },
|
||||||
|
{ title: 'Ship in minutes', body: 'Push to production on infrastructure that scales from prototype to fleet.' },
|
||||||
|
{ title: 'Observability built in', body: 'Traces, logs and metrics on by default — no agents to wire up.' },
|
||||||
|
{ title: 'Secure by default', body: 'SSO, audit logs and fine-grained roles included in every plan.' },
|
||||||
|
]
|
||||||
|
const stats = [
|
||||||
|
{ value: '99.99%', label: 'Uptime SLA' },
|
||||||
|
{ value: '120ms', label: 'p95 latency' },
|
||||||
|
{ value: '8,000+', label: 'Teams' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 -top-32 mx-auto h-72 max-w-4xl rounded-full bg-linear-to-br from-brand to-accent opacity-20 blur-3xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="mx-auto max-w-3xl px-6 pt-20 pb-16 text-center">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs font-medium text-muted">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-brand" /> Now with edge deployments
|
||||||
|
</span>
|
||||||
|
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink sm:text-6xl">
|
||||||
|
The cloud platform your team grows into
|
||||||
|
</h1>
|
||||||
|
<p class="mx-auto mt-5 max-w-xl text-balance text-lg text-muted">
|
||||||
|
Acme gives you the building blocks to launch, scale and operate modern apps — without stitching together a dozen tools.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex items-center justify-center gap-3">
|
||||||
|
<a href="#/" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||||
|
Start for free
|
||||||
|
</a>
|
||||||
|
<a href="#/billing" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:bg-ink/4">
|
||||||
|
See pricing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section class="mx-auto max-w-5xl px-6">
|
||||||
|
<div class="grid grid-cols-3 gap-4 rounded-card border border-line bg-surface p-8">
|
||||||
|
<div v-for="s in stats" :key="s.label" class="text-center">
|
||||||
|
<p class="text-3xl font-semibold tracking-tight text-ink">{{ s.value }}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{{ s.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<section class="mx-auto max-w-5xl px-6 py-20">
|
||||||
|
<h2 class="max-w-2xl text-balance text-3xl font-semibold tracking-tight text-ink">
|
||||||
|
Everything you need, nothing you don't
|
||||||
|
</h2>
|
||||||
|
<div class="mt-10 grid gap-5 sm:grid-cols-2">
|
||||||
|
<article v-for="f in features" :key="f.title" class="rounded-card border border-line bg-surface p-6">
|
||||||
|
<div class="grid h-10 w-10 place-items-center rounded-xl bg-brand/10 text-brand">
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-brand" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-4 text-lg font-semibold text-ink">{{ f.title }}</h3>
|
||||||
|
<p class="mt-2 text-sm leading-relaxed text-muted">{{ f.body }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA band -->
|
||||||
|
<section class="mx-auto max-w-5xl px-6 pb-24">
|
||||||
|
<div class="overflow-hidden rounded-card bg-linear-to-br from-brand to-accent px-8 py-14 text-center text-on-brand">
|
||||||
|
<h2 class="text-balance text-3xl font-semibold tracking-tight">Ready to build on Acme?</h2>
|
||||||
|
<p class="mx-auto mt-3 max-w-md text-balance opacity-90">Spin up your first project in under five minutes. No card required.</p>
|
||||||
|
<a href="#/profile" class="mt-7 inline-block rounded-full bg-on-brand px-6 py-2.5 text-sm font-semibold text-brand transition hover:opacity-90">
|
||||||
|
Create your workspace
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
// This page is identical for every brand — it lives only in the base layer and is inherited as-is.
|
||||||
|
// It still adopts each brand's colors and corner radius because it's styled entirely with theme
|
||||||
|
// tokens, so the same component renders light on Acme/Northwind and dark on Aurora.
|
||||||
|
const form = reactive({
|
||||||
|
name: 'Robin Avery',
|
||||||
|
email: 'robin@example.com',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
role: 'Owner',
|
||||||
|
})
|
||||||
|
|
||||||
|
const timezones = ['Europe/Berlin', 'Europe/London', 'America/New_York', 'Asia/Tokyo']
|
||||||
|
|
||||||
|
const notifications = reactive([
|
||||||
|
{ id: 'product', label: 'Product updates', desc: 'New features and improvements.', on: true },
|
||||||
|
{ id: 'security', label: 'Security alerts', desc: 'Sign-ins and credential changes.', on: true },
|
||||||
|
{ id: 'billing', label: 'Billing receipts', desc: 'Invoices and payment notices.', on: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
const initials = 'RA'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-3xl px-6 py-12">
|
||||||
|
<header class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="grid h-16 w-16 place-items-center rounded-card bg-linear-to-br from-brand to-accent text-xl font-semibold text-on-brand"
|
||||||
|
>
|
||||||
|
{{ initials }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ form.name }}</h1>
|
||||||
|
<p class="text-sm text-muted">{{ form.email }}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ml-auto rounded-full border border-brand/30 bg-brand/10 px-3 py-1 text-xs font-semibold text-brand"
|
||||||
|
>
|
||||||
|
{{ form.role }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-8 rounded-card border border-line bg-surface p-6">
|
||||||
|
<h2 class="text-base font-semibold text-ink">Account details</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted">Update your personal information and workspace defaults.</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-5 sm:grid-cols-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-ink">Display name</span>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-ink">Email</span>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block sm:col-span-2">
|
||||||
|
<span class="text-sm font-medium text-ink">Timezone</span>
|
||||||
|
<select
|
||||||
|
v-model="form.timezone"
|
||||||
|
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
|
||||||
|
>
|
||||||
|
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-card border border-line bg-surface p-6">
|
||||||
|
<h2 class="text-base font-semibold text-ink">Notifications</h2>
|
||||||
|
<ul class="mt-4 divide-y divide-line">
|
||||||
|
<li v-for="n in notifications" :key="n.id" class="flex items-center justify-between gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-ink">{{ n.label }}</p>
|
||||||
|
<p class="text-sm text-muted">{{ n.desc }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="n.on"
|
||||||
|
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
|
||||||
|
:class="n.on ? 'bg-brand' : 'bg-ink/15'"
|
||||||
|
@click="n.on = !n.on"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 h-5 w-5 rounded-full bg-surface shadow transition-all"
|
||||||
|
:class="n.on ? 'left-5.5' : 'left-0.5'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-3">
|
||||||
|
<button type="button" class="rounded-full px-4 py-2 text-sm font-medium text-muted transition hover:text-ink">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-brand px-5 py-2 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { markRaw, shallowRef, type Component } from 'vue'
|
||||||
|
import { feature } from '#feature'
|
||||||
|
|
||||||
|
export interface AppRoute {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
component: () => Promise<{ default: Component }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages gated on build-time feature flags via the `feature()` macro (typed by the generated
|
||||||
|
// `.vite-layers/features.d.ts`). A disabled page's dynamic import() is statically dead — its branch
|
||||||
|
// folds away and the chunk is never emitted (per-brand dead-code elimination), in dev and build alike.
|
||||||
|
export const routes: AppRoute[] = [
|
||||||
|
{ path: '/', label: 'Overview', component: () => import('@/pages/Landing.vue') },
|
||||||
|
{ path: '/profile', label: 'Profile', component: () => import('@/pages/Profile.vue') },
|
||||||
|
...(feature('billing')
|
||||||
|
? [{ path: '/billing', label: 'Billing', component: () => import('@/pages/Billing.vue') }]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const current = shallowRef<Component | null>(null)
|
||||||
|
const currentPath = shallowRef('/')
|
||||||
|
|
||||||
|
async function navigate() {
|
||||||
|
const path = location.hash.slice(1) || '/'
|
||||||
|
const route = routes.find(r => r.path === path) ?? routes[0]!
|
||||||
|
currentPath.value = route.path
|
||||||
|
current.value = markRaw((await route.component()).default)
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = false
|
||||||
|
|
||||||
|
/** Minimal hash router shared by every brand — keeps the demo dependency-free. */
|
||||||
|
export function useRoute() {
|
||||||
|
if (!started) {
|
||||||
|
started = true
|
||||||
|
window.addEventListener('hashchange', navigate)
|
||||||
|
void navigate()
|
||||||
|
}
|
||||||
|
return { current, currentPath }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@import "tailwindcss" source(none);
|
||||||
|
|
||||||
|
/* Scan every layer's source for class names. `@source` is relative to this file, which always
|
||||||
|
resolves to apps/main/src/style.css (it lives only in the base layer), so `../../` is the
|
||||||
|
apps/ directory — i.e. main + every brand. CWD-independent, unlike Tailwind's auto-detection. */
|
||||||
|
@source "../../";
|
||||||
|
|
||||||
|
/* Map Tailwind's design tokens onto runtime CSS variables. `inline` makes each utility reference
|
||||||
|
the variable directly — e.g. `bg-brand` becomes `background-color: var(--c-brand)` — so a brand's
|
||||||
|
theme.css (loaded after this file through the layer resolver) restyles the entire shared UI with
|
||||||
|
no change to a single component. The --c-* values are defined per brand in `@/theme.css`. */
|
||||||
|
@theme inline {
|
||||||
|
--color-canvas: var(--c-canvas);
|
||||||
|
--color-surface: var(--c-surface);
|
||||||
|
--color-ink: var(--c-ink);
|
||||||
|
--color-muted: var(--c-muted);
|
||||||
|
--color-line: var(--c-line);
|
||||||
|
--color-brand: var(--c-brand);
|
||||||
|
--color-on-brand: var(--c-on-brand);
|
||||||
|
--color-accent: var(--c-accent);
|
||||||
|
--radius-card: var(--c-radius);
|
||||||
|
--font-sans: var(--c-font);
|
||||||
|
}
|
||||||
@@ -8,35 +8,74 @@
|
|||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./feature": "./src/feature.ts",
|
||||||
|
"./devtools": "./src/devtools.ts"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite-layers": "./bin/vite-layers.mjs"
|
"vite-layers": "./bin/vite-layers.mjs"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"bin"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./feature": {
|
||||||
|
"types": "./dist/feature.d.ts",
|
||||||
|
"import": "./dist/feature.js"
|
||||||
|
},
|
||||||
|
"./devtools": {
|
||||||
|
"types": "./dist/devtools.d.ts",
|
||||||
|
"import": "./dist/devtools.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"example:build": "vite build example/apps/main && vite build example/apps/brand",
|
"build": "tsdown",
|
||||||
"example:check": "node bin/vite-layers.mjs prepare example/apps/main && node bin/vite-layers.mjs prepare example/apps/brand && vue-tsc --noEmit -p example/apps/main && vue-tsc --noEmit -p example/apps/main/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/brand && vue-tsc --noEmit -p example/apps/brand/.vite-layers/tsconfig.node.json"
|
"prepack": "tsdown",
|
||||||
|
"example:build": "vite build example/apps/main && vite build example/apps/brand && vite build example/apps/aurora",
|
||||||
|
"example:main": "vite example/apps/main",
|
||||||
|
"example:brand": "vite example/apps/brand",
|
||||||
|
"example:aurora": "vite example/apps/aurora",
|
||||||
|
"example:check": "node bin/vite-layers.mjs prepare example/apps/main && node bin/vite-layers.mjs prepare example/apps/brand && node bin/vite-layers.mjs prepare example/apps/aurora && vue-tsc --noEmit -p example/apps/main && vue-tsc --noEmit -p example/apps/main/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/brand && vue-tsc --noEmit -p example/apps/brand/.vite-layers/tsconfig.node.json && vue-tsc --noEmit -p example/apps/aurora && vue-tsc --noEmit -p example/apps/aurora/.vite-layers/tsconfig.node.json"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@vitejs/devtools-kit": "^0.3.0",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitejs/devtools-kit": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "^3.3.4",
|
"c12": "^3.3.4",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
"hookable": "^6.1.1",
|
"hookable": "^6.1.1",
|
||||||
"jiti": "^2.4.0",
|
"jiti": "^2.4.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
|
"oxc-parser": "^0.137.0",
|
||||||
"pkg-types": "^2.3.1",
|
"pkg-types": "^2.3.1",
|
||||||
"sirv": "^3.0.2",
|
"sirv": "^3.0.2",
|
||||||
"tinyglobby": "^0.2.15",
|
"tinyglobby": "^0.2.15",
|
||||||
"ufo": "^1.6.1"
|
"ufo": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
|
"@vitejs/devtools": "0.3.3",
|
||||||
|
"@vitejs/devtools-kit": "0.3.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"tailwindcss": "^4.3.1",
|
||||||
|
"tsdown": "^0.22.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.0.14",
|
"vite": "^8.0.14",
|
||||||
"vitest": "^4.1.7",
|
"vitest": "^4.1.7",
|
||||||
|
|||||||
@@ -3,18 +3,12 @@ import { loadConfig, type ConfigLayer } from 'c12'
|
|||||||
import { createDefu } from 'defu'
|
import { createDefu } from 'defu'
|
||||||
import { glob } from 'tinyglobby'
|
import { glob } from 'tinyglobby'
|
||||||
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
|
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
|
||||||
import type { Layer, LayerConfig, LayerStack } from './types'
|
import type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
|
||||||
|
import { toPosix } from './util'
|
||||||
|
|
||||||
/** Identity helper for typed `app.config.ts` files. */
|
/** Identity helper for typed `app.config.ts` files. */
|
||||||
export const defineLayerConfig = (config: LayerConfig): LayerConfig => config
|
export const defineLayerConfig = (config: LayerConfig): LayerConfig => config
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize to forward slashes. c12 returns `cwd` posix-style while node `resolve()` is
|
|
||||||
* OS-native (backslashes on Windows); paths must be canonicalized before they are compared
|
|
||||||
* for dedup or emitted into a Vite config (where posix is conventional).
|
|
||||||
*/
|
|
||||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of Nuxt's layer merger: arrays are concatenated rather than replaced.
|
* Port of Nuxt's layer merger: arrays are concatenated rather than replaced.
|
||||||
* (See `@nuxt/kit` `loadNuxtConfig`.)
|
* (See `@nuxt/kit` `loadNuxtConfig`.)
|
||||||
@@ -59,6 +53,12 @@ export async function resolveLayerStack(
|
|||||||
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
|
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
// Capture the extends DAG as c12 walks it. c12 consumes (strips) the `extends`/`_extends` keys from
|
||||||
|
// each resolved layer's config — only the project's survive — so the parent→child edges can't be
|
||||||
|
// reconstructed from the resolved configs afterwards. The `resolve` hook fires once per extend edge
|
||||||
|
// (incl. nested, diamond, and auto-scanned `_extends`), with `opts.cwd` = the extending layer's dir.
|
||||||
|
const edges: LayerEdge[] = []
|
||||||
|
|
||||||
const { config, layers = [] } = await loadConfig<LayerConfig>({
|
const { config, layers = [] } = await loadConfig<LayerConfig>({
|
||||||
cwd,
|
cwd,
|
||||||
configFile: 'app.config',
|
configFile: 'app.config',
|
||||||
@@ -71,8 +71,11 @@ export async function resolveLayerStack(
|
|||||||
packageJson: false,
|
packageJson: false,
|
||||||
globalRc: false,
|
globalRc: false,
|
||||||
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
|
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
|
||||||
resolve(id, opts) {
|
resolve(id, ropts) {
|
||||||
const abs = resolve(opts?.cwd ?? cwd, id)
|
const from = toPosix(withoutTrailingSlash(ropts?.cwd ?? cwd))
|
||||||
|
const abs = resolve(ropts?.cwd ?? cwd, id)
|
||||||
|
const to = toPosix(withoutTrailingSlash(abs))
|
||||||
|
if (to !== from) edges.push({ from, to, source: id }) // skip c12's self-resolution of the root
|
||||||
if (seen.has(abs)) return { config: {}, cwd: abs }
|
if (seen.has(abs)) return { config: {}, cwd: abs }
|
||||||
seen.add(abs)
|
seen.add(abs)
|
||||||
return undefined
|
return undefined
|
||||||
@@ -101,5 +104,5 @@ export async function resolveLayerStack(
|
|||||||
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
|
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { merged: config, layers: stack }
|
return { merged: config, layers: stack, edges }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,41 @@
|
|||||||
import { statSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import MagicString from 'magic-string'
|
|
||||||
import type { Plugin } from 'vite'
|
import type { Plugin } from 'vite'
|
||||||
|
import { toPosix } from './util'
|
||||||
|
|
||||||
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
|
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
|
||||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
const existingConfigFiles = (rootDirs: string[]): Set<string> => {
|
|
||||||
const files = new Set<string>()
|
|
||||||
for (const dir of rootDirs) {
|
|
||||||
for (const ext of CONFIG_EXTENSIONS) {
|
|
||||||
const file = resolve(dir, `app.config${ext}`)
|
|
||||||
try {
|
|
||||||
if (statSync(file).isFile()) files.add(toPosix(file))
|
|
||||||
} catch {
|
|
||||||
// not present in this layer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes.
|
* Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes.
|
||||||
*
|
*
|
||||||
* `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file
|
* `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file
|
||||||
* dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the
|
* dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the
|
||||||
* baked `__FEATURES__` `define` and aliases go stale. We watch each resolved layer's config file
|
* `feature()` values baked into transformed modules and the resolved aliases go stale. We watch each
|
||||||
* (including layers outside the project root via `watcher.add`) and call `server.restart()`, which
|
* resolved layer's config file (including layers outside the project root via `watcher.add`) and call
|
||||||
* re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → new `define`.
|
* `server.restart()`, which re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → a new
|
||||||
|
* `featurePlugin` with the updated flag values.
|
||||||
*/
|
*/
|
||||||
export function configWatchPlugin(rootDirs: string[]): Plugin {
|
export function configWatchPlugin(rootDirs: string[]): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'vite-layers:config-watch',
|
name: 'vite-layers:config-watch',
|
||||||
apply: 'serve',
|
apply: 'serve',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
const files = existingConfigFiles(rootDirs)
|
// Every POSSIBLE `app.config.*` path (existing or not), so creating a config in a layer that
|
||||||
if (files.size === 0) return
|
// had none — or deleting one — also restarts, not just edits to configs present at startup.
|
||||||
server.watcher.add([...files]) // ensure extended layers outside the root are watched too
|
const candidates = new Set<string>()
|
||||||
|
for (const dir of rootDirs) {
|
||||||
|
for (const ext of CONFIG_EXTENSIONS) candidates.add(toPosix(resolve(dir, `app.config${ext}`)))
|
||||||
|
}
|
||||||
|
if (candidates.size === 0) return
|
||||||
|
// chokidar watches an absent path for creation too, so `add`/`unlink` fire for a config that
|
||||||
|
// appears/disappears later (incl. layers outside the project root).
|
||||||
|
server.watcher.add([...candidates])
|
||||||
|
|
||||||
const onChange = (file: string) => {
|
const onEvent = (file: string) => {
|
||||||
if (!files.has(toPosix(file))) return
|
if (!candidates.has(toPosix(file))) return
|
||||||
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
|
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
|
||||||
void server.restart()
|
void server.restart()
|
||||||
}
|
}
|
||||||
server.watcher.on('change', onChange)
|
for (const event of ['add', 'change', 'unlink'] as const) server.watcher.on(event, onEvent)
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Matches a standalone `__FEATURES__` reference (not a `.__FEATURES__` property access). */
|
|
||||||
const STANDALONE_FEATURES_RE = /(?<![.\w$])__FEATURES__\b/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dev-only plugin: make `__FEATURES__` resolve at runtime in the dev server.
|
|
||||||
*
|
|
||||||
* Vite 8 / rolldown-vite does **not** inline user `define` into dev-served source modules (only
|
|
||||||
* `import.meta.env` is special-cased), so `__FEATURES__` would be an undefined global in dev. For
|
|
||||||
* production, `define` (with DCE) still does the job; here we prepend a module-local
|
|
||||||
* `const __FEATURES__ = {…}` to each served module that references the global, so feature flags have
|
|
||||||
* correct values in dev — and pick up edits after a config-change restart (see {@link configWatchPlugin}).
|
|
||||||
*
|
|
||||||
* Only standalone references are handled (not `_ctx.__FEATURES__` from Vue templates — same as
|
|
||||||
* `define`); gate features in `<script>`, not in template expressions.
|
|
||||||
*/
|
|
||||||
export function featuresRuntimePlugin(features: Record<string, unknown> = {}): Plugin {
|
|
||||||
const json = JSON.stringify(features)
|
|
||||||
return {
|
|
||||||
name: 'vite-layers:features-runtime',
|
|
||||||
apply: 'serve',
|
|
||||||
transform(code, id) {
|
|
||||||
if (id.includes('/node_modules/') || !STANDALONE_FEATURES_RE.test(code)) return null
|
|
||||||
// NOTE: rolldown's *native* magic-string (the transform `meta.magicString` in the rolldown
|
|
||||||
// docs) is NOT surfaced by Vite plugins — `meta` is `{ inMap, moduleType, ssr }` with no
|
|
||||||
// `magicString` in dev or build. So we use the npm `magic-string` fallback the rolldown docs
|
|
||||||
// recommend for non-native hosts; it also produces clean cross-platform sourcemaps.
|
|
||||||
// Prepend on line 1 (keeps line numbers); module-local const shadows the missing global.
|
|
||||||
const s = new MagicString(code)
|
|
||||||
s.prepend(`const __FEATURES__=${json};`)
|
|
||||||
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { defineLayerConfig, resolveLayerStack } from './config'
|
||||||
export { configWatchPlugin, featuresRuntimePlugin } from './dev'
|
export { configWatchPlugin } from './dev'
|
||||||
|
export { FEATURE_MODULE, featurePlugin, featuresDts, flattenFeatures } from './features'
|
||||||
export { publicLayersPlugin } from './public'
|
export { publicLayersPlugin } from './public'
|
||||||
export {
|
export {
|
||||||
createLayerHooks,
|
createLayerHooks,
|
||||||
@@ -12,14 +13,22 @@ export {
|
|||||||
type TsconfigHookContext,
|
type TsconfigHookContext,
|
||||||
type ViteConfigHookContext,
|
type ViteConfigHookContext,
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
export { DEFAULT_EXTENSIONS, layersResolver, type LayersResolverOptions } from './resolve'
|
export {
|
||||||
|
createLayeredResolution,
|
||||||
|
DEFAULT_EXTENSIONS,
|
||||||
|
layersResolver,
|
||||||
|
type LayeredResolution,
|
||||||
|
type LayersResolverOptions,
|
||||||
|
type ParsedLayeredId,
|
||||||
|
type ResolveRecord,
|
||||||
|
} from './resolve'
|
||||||
|
export { layersDevtoolsPlugin, type LayersDevtoolsData } from './devtools'
|
||||||
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
|
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
|
||||||
export {
|
export {
|
||||||
generateTsConfig,
|
generateTsConfig,
|
||||||
writeTsConfig,
|
writeTsConfig,
|
||||||
tsconfigPlugin,
|
tsconfigPlugin,
|
||||||
featuresDts,
|
|
||||||
type GenerateTsConfigOptions,
|
type GenerateTsConfigOptions,
|
||||||
type TSConfig,
|
type TSConfig,
|
||||||
} from './tsconfig'
|
} from './tsconfig'
|
||||||
export type { Layer, LayerConfig, LayerStack } from './types'
|
export type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
import { basename, resolve } from 'node:path'
|
import { basename, resolve } from 'node:path'
|
||||||
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
|
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
|
||||||
import { resolveLayerStack } from './config'
|
import { resolveLayerStack } from './config'
|
||||||
import { configWatchPlugin, featuresRuntimePlugin } from './dev'
|
import { configWatchPlugin } from './dev'
|
||||||
|
import { layersDevtoolsPlugin } from './devtools'
|
||||||
|
import { FEATURE_MODULE, featurePlugin } from './features'
|
||||||
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
|
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
|
||||||
import { publicLayersPlugin } from './public'
|
import { publicLayersPlugin } from './public'
|
||||||
import { layersResolver } from './resolve'
|
import { createLayeredResolution, layersResolver } from './resolve'
|
||||||
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
|
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
|
||||||
|
import { toPosix } from './util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the `feature` macro entry, aliased as `#feature` (see {@link featurePlugin}).
|
||||||
|
* Resolved next to this module — `feature.ts` when running from source (dev/tests), `feature.js`
|
||||||
|
* after a `tsdown` build — so the alias always points at a real file in either layout.
|
||||||
|
*/
|
||||||
|
const FEATURE_ENTRY = resolve(import.meta.dirname, 'feature')
|
||||||
|
const FEATURE_FILE = toPosix(existsSync(`${FEATURE_ENTRY}.ts`) ? `${FEATURE_ENTRY}.ts` : `${FEATURE_ENTRY}.js`)
|
||||||
|
|
||||||
export interface BuildViteConfigOptions {
|
export interface BuildViteConfigOptions {
|
||||||
/** Extra Vite config merged at the very end (highest priority). */
|
/** Extra Vite config merged at the very end (highest priority). */
|
||||||
@@ -21,6 +33,12 @@ export interface BuildViteConfigOptions {
|
|||||||
resolver?: { prefixes?: string[]; extensions?: string[] }
|
resolver?: { prefixes?: string[]; extensions?: string[] }
|
||||||
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
|
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
|
||||||
hooks?: LayerHooksConfig
|
hooks?: LayerHooksConfig
|
||||||
|
/**
|
||||||
|
* Mount the vite-layers panels (stack / features / resolver / public+ts) in Vite DevTools.
|
||||||
|
* Requires the `@vitejs/devtools` hub in the plugin list; the integration is inert without it.
|
||||||
|
* Enabled by default — pass `false` to skip it (and the resolver's resolution-log recording).
|
||||||
|
*/
|
||||||
|
devtools?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,40 +63,6 @@ function dedupePlugins(config: UserConfig): UserConfig {
|
|||||||
return { ...config, plugins: out }
|
return { ...config, plugins: out }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A member-expression define key segment must be a plain JS identifier. */
|
|
||||||
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the `define` map for feature flags. Emits the whole `__FEATURES__` object (for runtime
|
|
||||||
* reads) plus a dotted entry for **every nested path** whose segments are valid identifiers
|
|
||||||
* (`__FEATURES__.billing`, `__FEATURES__.nested.enabled`, …).
|
|
||||||
*
|
|
||||||
* The dotted entries are what make dead-code elimination work: esbuild folds a replaced literal
|
|
||||||
* (`false ? import('…') : []` → `[]`) and drops the dynamic import *before* Rollup builds the
|
|
||||||
* module graph, so the page's chunk is never emitted. A member access on an object literal
|
|
||||||
* (`{"enabled":false}.enabled`) is NOT folded, so the object form alone does not DCE — which is why
|
|
||||||
* we walk recursively and emit a literal at every depth.
|
|
||||||
*
|
|
||||||
* Keys that are not valid identifiers (e.g. `'kebab-flag'`) are skipped rather than emitted: a
|
|
||||||
* dotted define with such a segment is an `INVALID_DEFINE_CONFIG` build error, and you cannot fold
|
|
||||||
* a bracket access anyway. The key still lives inside the whole-object `__FEATURES__` for runtime.
|
|
||||||
*/
|
|
||||||
function featureDefines(features: Record<string, unknown> = {}): Record<string, string> {
|
|
||||||
const define: Record<string, string> = { __FEATURES__: JSON.stringify(features) }
|
|
||||||
const walk = (obj: Record<string, unknown>, prefix: string) => {
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
if (!IDENTIFIER_RE.test(key)) continue
|
|
||||||
const path = `${prefix}.${key}`
|
|
||||||
define[path] = JSON.stringify(value)
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
walk(value as Record<string, unknown>, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(features, '__FEATURES__')
|
|
||||||
return define
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`:
|
* Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`:
|
||||||
*
|
*
|
||||||
@@ -87,9 +71,11 @@ function featureDefines(features: Record<string, unknown> = {}): Record<string,
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
|
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
|
||||||
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins).
|
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins);
|
||||||
* `@/`/`~/` are handled by {@link layersResolver}, not as plain aliases.
|
* `#feature` → the {@link featurePlugin} macro entry. `@/`/`~/` are handled by
|
||||||
* - `__FEATURES__` is defined from the merged `features` for build-time dead-code elimination.
|
* {@link layersResolver}, not as plain aliases.
|
||||||
|
* - Build-time feature flags are compiled by {@link featurePlugin} (the `feature('key')` macro),
|
||||||
|
* one mechanism for dev and build.
|
||||||
*/
|
*/
|
||||||
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
|
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
|
||||||
return defineConfig(async (env) => {
|
return defineConfig(async (env) => {
|
||||||
@@ -103,11 +89,20 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
|
|||||||
|
|
||||||
const { merged, layers } = stack
|
const { merged, layers } = stack
|
||||||
const roots = layers.map(l => l.srcDir)
|
const roots = layers.map(l => l.srcDir)
|
||||||
|
const devtoolsEnabled = options.devtools !== false
|
||||||
|
|
||||||
|
// One shared resolution drives both the resolver plugin and the devtools resolver panel, so the
|
||||||
|
// panel introspects the exact same candidate cache and resolution log. The log is only recorded
|
||||||
|
// when devtools is enabled AND in dev (`serve`) — the panel can't mount during a build, so a
|
||||||
|
// production build does zero per-import recording work.
|
||||||
|
const recordLog = devtoolsEnabled && env.command === 'serve'
|
||||||
|
const resolution = createLayeredResolution({ roots, ...options.resolver, record: recordLog ? 200 : 0 })
|
||||||
|
|
||||||
const project = layers[0]! // resolveLayerStack always returns at least the project layer
|
const project = layers[0]! // resolveLayerStack always returns at least the project layer
|
||||||
const alias: Record<string, string> = {
|
const alias: Record<string, string> = {
|
||||||
'~~': project.rootDir,
|
'~~': project.rootDir,
|
||||||
'@@': project.rootDir,
|
'@@': project.rootDir,
|
||||||
|
[FEATURE_MODULE]: FEATURE_FILE, // `#feature` → the macro entry (compiled away by featurePlugin)
|
||||||
}
|
}
|
||||||
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins).
|
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins).
|
||||||
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
|
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
|
||||||
@@ -125,21 +120,30 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
|
|||||||
vite = dedupePlugins(vite)
|
vite = dedupePlugins(vite)
|
||||||
|
|
||||||
const plugins: PluginOption[] = [
|
const plugins: PluginOption[] = [
|
||||||
layersResolver({ roots, ...options.resolver }),
|
layersResolver(resolution),
|
||||||
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
|
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
|
||||||
configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change
|
configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change
|
||||||
featuresRuntimePlugin(merged.features), // dev: supply __FEATURES__ at runtime (define is build-only here)
|
featurePlugin(merged.features), // compile `feature('key')` → literal (dev + build, one mechanism)
|
||||||
]
|
]
|
||||||
if (options.tsconfig !== false) {
|
if (options.tsconfig !== false) {
|
||||||
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
|
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
|
||||||
// and `tsconfig:generate` sees the same handlers).
|
// and `tsconfig:generate` sees the same handlers).
|
||||||
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
|
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
|
||||||
}
|
}
|
||||||
|
if (devtoolsEnabled) {
|
||||||
|
// Inert unless the `@vitejs/devtools` hub mounts it (uses only *type* imports from the kit).
|
||||||
|
plugins.push(
|
||||||
|
layersDevtoolsPlugin({
|
||||||
|
appDir,
|
||||||
|
env,
|
||||||
|
stack,
|
||||||
|
resolution,
|
||||||
|
tsconfig: options.tsconfig === false ? false : (options.tsconfig ?? {}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
vite = mergeConfig(vite, {
|
vite = mergeConfig(vite, { plugins })
|
||||||
plugins,
|
|
||||||
define: featureDefines(merged.features),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (options.vite) vite = mergeConfig(vite, options.vite)
|
if (options.vite) vite = mergeConfig(vite, options.vite)
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,56 @@
|
|||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
||||||
import { join, relative } from 'node:path'
|
import { dirname, join, relative, resolve } from 'node:path'
|
||||||
import sirv from 'sirv'
|
import sirv from 'sirv'
|
||||||
import type { Plugin } from 'vite'
|
import type { Plugin } from 'vite'
|
||||||
|
import { toPosix } from './util'
|
||||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
/** Recursively list files under a directory (absolute paths). */
|
/** Recursively list files under a directory (absolute paths). */
|
||||||
function walk(dir: string, out: string[] = []): string[] {
|
function walk(dir: string, out: string[] = []): string[] {
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
const abs = join(dir, name)
|
const abs = join(dir, name)
|
||||||
if (statSync(abs).isDirectory()) walk(abs, out)
|
// Skip a broken symlink / file removed mid-walk (ENOENT) rather than aborting the public copy.
|
||||||
|
let isDir: boolean
|
||||||
|
try {
|
||||||
|
isDir = statSync(abs).isDirectory()
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isDir) walk(abs, out)
|
||||||
else out.push(abs)
|
else out.push(abs)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority
|
||||||
|
* layer wins each key — i.e. first-match-wins by priority (`brand/public/logo.svg` shadows `main`'s).
|
||||||
|
*/
|
||||||
|
function mergePublic(dirs: string[]): Map<string, string> {
|
||||||
|
const assets = new Map<string, string>()
|
||||||
|
for (const dir of [...dirs].reverse()) {
|
||||||
|
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
|
||||||
|
}
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layered static assets: each layer may have a `public/` directory, resolved **first-match across
|
* Layered static assets: each layer may have a `public/` directory, resolved **first-match across
|
||||||
* layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`.
|
* layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`.
|
||||||
*
|
*
|
||||||
* Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in
|
* Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in
|
||||||
* `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins),
|
* `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins),
|
||||||
* and emits the merged set into the build output (higher layers overwrite lower ones).
|
* and copies the merged set into the build output (higher layers overwrite lower ones).
|
||||||
|
*
|
||||||
|
* Build-time copy is streamed file-by-file through the OS (`copyFileSync`) rather than buffered via
|
||||||
|
* `emitFile`, so peak memory stays flat regardless of total asset size — large fonts/videos on a
|
||||||
|
* memory-constrained CI won't OOM, matching Vite's own `publicDir` copy.
|
||||||
*
|
*
|
||||||
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
|
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
|
||||||
*/
|
*/
|
||||||
export function publicLayersPlugin(publicDirs: string[]): Plugin {
|
export function publicLayersPlugin(publicDirs: string[]): Plugin {
|
||||||
const dirs = publicDirs.filter(existsSync) // high → low
|
const dirs = publicDirs.filter(existsSync) // high → low
|
||||||
|
let outDir = ''
|
||||||
|
let copyPublic = true
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'vite-layers:public',
|
name: 'vite-layers:public',
|
||||||
@@ -34,20 +58,28 @@ export function publicLayersPlugin(publicDirs: string[]): Plugin {
|
|||||||
// We serve/emit public ourselves, so turn off Vite's single-dir handling.
|
// We serve/emit public ourselves, so turn off Vite's single-dir handling.
|
||||||
if (dirs.length > 0) return { publicDir: false }
|
if (dirs.length > 0) return { publicDir: false }
|
||||||
},
|
},
|
||||||
|
configResolved(config) {
|
||||||
|
outDir = resolve(config.root, config.build.outDir)
|
||||||
|
// Respect Vite's own opt-out (e.g. SSR builds set this false to skip public copy).
|
||||||
|
copyPublic = config.build.copyPublicDir !== false
|
||||||
|
},
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
|
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
server.middlewares.use(sirv(dir, { dev: true, etag: true }))
|
server.middlewares.use(sirv(dir, { dev: true, etag: true }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
generateBundle() {
|
writeBundle(options) {
|
||||||
// Build: merge low→high so higher layers overwrite — i.e. first-match-wins by priority.
|
// Build: copy each file straight to disk via the OS instead of holding every asset's bytes in
|
||||||
const assets = new Map<string, string>()
|
// memory at once — peak RSS stays flat no matter how large the public set is.
|
||||||
for (const dir of [...dirs].reverse()) {
|
if (!copyPublic || dirs.length === 0) return
|
||||||
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
|
// writeBundle fires once per output; only the one targeting the main outDir copies the assets
|
||||||
}
|
// (a secondary/SSR output has a different dir and is skipped).
|
||||||
for (const [fileName, abs] of assets) {
|
if (options.dir && resolve(options.dir) !== outDir) return
|
||||||
this.emitFile({ type: 'asset', fileName, source: readFileSync(abs) })
|
for (const [fileName, abs] of mergePublic(dirs)) {
|
||||||
|
const dest = join(outDir, fileName)
|
||||||
|
mkdirSync(dirname(dest), { recursive: true })
|
||||||
|
copyFileSync(abs, dest)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { statSync } from 'node:fs'
|
import { statSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import type { Plugin } from 'vite'
|
import type { Plugin } from 'vite'
|
||||||
|
import { toPosix } from './util'
|
||||||
|
|
||||||
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
||||||
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
|
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
|
||||||
@@ -12,9 +13,62 @@ export interface LayersResolverOptions {
|
|||||||
prefixes?: string[]
|
prefixes?: string[]
|
||||||
/** Extensions probed when the id has no explicit, existing file. */
|
/** Extensions probed when the id has no explicit, existing file. */
|
||||||
extensions?: string[]
|
extensions?: string[]
|
||||||
|
/**
|
||||||
|
* Keep a bounded, de-duplicated log of the last N resolutions for introspection (the devtools
|
||||||
|
* resolver panel reads it). `0`/omitted disables recording — zero overhead on the hot path.
|
||||||
|
*/
|
||||||
|
record?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
/** A single recorded resolution — what the resolver saw for one `@/`/`~/` import. */
|
||||||
|
export interface ResolveRecord {
|
||||||
|
/** The original import id (prefix + sub-path + query). */
|
||||||
|
id: string
|
||||||
|
/** The importer module (query-stripped), if any. */
|
||||||
|
importer?: string
|
||||||
|
/** The file the id resolved to (with query), or `null` if nothing matched. */
|
||||||
|
resolved: string | null
|
||||||
|
/** All candidate files across layers, high→low priority (importer-independent). */
|
||||||
|
candidates: string[]
|
||||||
|
/** Index of the importer within `candidates` (`-1` when it isn't a self-import). */
|
||||||
|
selfIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed layered id: its matched prefix, the prefix-stripped sub-path, and any query suffix. */
|
||||||
|
export interface ParsedLayeredId {
|
||||||
|
prefix: string
|
||||||
|
sub: string
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reusable core of the layered resolver — the pure resolution logic, decoupled from the Vite
|
||||||
|
* plugin shell so it can be shared. {@link layersResolver} wraps one of these in a plugin; the
|
||||||
|
* devtools integration reuses the *same instance* (via {@link createLayeredResolution} in
|
||||||
|
* `buildViteConfig`) to introspect candidates and the live resolution log without re-implementing
|
||||||
|
* the probing, the cache, or the `super()` semantics.
|
||||||
|
*/
|
||||||
|
export interface LayeredResolution {
|
||||||
|
readonly roots: string[]
|
||||||
|
readonly prefixes: string[]
|
||||||
|
readonly extensions: string[]
|
||||||
|
/** Split a layered id into prefix/sub/query, or `null` if no prefix matches. */
|
||||||
|
parse: (id: string) => ParsedLayeredId | null
|
||||||
|
/** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */
|
||||||
|
candidates: (sub: string) => string[]
|
||||||
|
/** Resolve a layered id (super()/self-skip + query preservation). `null` if not layered / no match. */
|
||||||
|
resolveId: (id: string, importer?: string) => string | null
|
||||||
|
/** Drop the candidate cache (call when files are added/removed — which layer wins can change). */
|
||||||
|
clear: () => void
|
||||||
|
/** Recorded resolutions, newest first (empty unless `record` was enabled). */
|
||||||
|
records: () => ResolveRecord[]
|
||||||
|
/** Clear the resolution log (the candidate cache is untouched). */
|
||||||
|
clearRecords: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RegExp metacharacters — escaped when building a RegExp from a literal string (e.g. layer prefixes). */
|
||||||
|
const REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g
|
||||||
|
const escapeRegExp = (s: string) => s.replace(REGEXP_META_RE, '\\$&')
|
||||||
|
|
||||||
const isFile = (p: string): boolean => {
|
const isFile = (p: string): boolean => {
|
||||||
try {
|
try {
|
||||||
@@ -25,19 +79,12 @@ const isFile = (p: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
|
* Build the shared resolution core (probing + cache + `super()` + optional recording). Stateless
|
||||||
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
|
* across importers: the candidate list for a sub-path is importer-independent, so `super()` works by
|
||||||
* it probes each source root in priority order and returns the first match.
|
* locating the importer's position in the list and taking the next entry down.
|
||||||
*
|
|
||||||
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
|
|
||||||
* then `<path>/index<ext>`.
|
|
||||||
*
|
|
||||||
* Improvement over Nuxt: **self-skip** gives `super()` semantics. If the first match is the
|
|
||||||
* importing file itself, resolution continues to the next (lower-priority) layer — so an
|
|
||||||
* override at `@/components/Foo.vue` can import `@/components/Foo.vue` to reach the base file.
|
|
||||||
*/
|
*/
|
||||||
export function layersResolver(options: LayersResolverOptions): Plugin {
|
export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
|
||||||
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options
|
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options
|
||||||
|
|
||||||
const probe = (root: string, sub: string): string | null => {
|
const probe = (root: string, sub: string): string | null => {
|
||||||
const direct = resolve(root, sub)
|
const direct = resolve(root, sub)
|
||||||
@@ -54,8 +101,8 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots
|
// Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots
|
||||||
// (high→low priority). Saves the per-import `statSync` storm; self-skip stays correct because the
|
// (high→low priority). Saves the per-import `statSync` storm; the list is importer-independent, so
|
||||||
// candidate list is importer-independent (we pick the first candidate that isn't the importer).
|
// super() stays correct — we locate the importer's position in it and take the next entry down.
|
||||||
const cache = new Map<string, string[]>()
|
const cache = new Map<string, string[]>()
|
||||||
const candidates = (sub: string): string[] => {
|
const candidates = (sub: string): string[] => {
|
||||||
const cached = cache.get(sub)
|
const cached = cache.get(sub)
|
||||||
@@ -69,30 +116,108 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parse = (id: string): ParsedLayeredId | null => {
|
||||||
|
const prefix = prefixes.find(p => id.startsWith(p))
|
||||||
|
if (!prefix) return null
|
||||||
|
const q = id.indexOf('?')
|
||||||
|
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
|
||||||
|
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
|
||||||
|
return { prefix, sub, query }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded, de-duplicated resolution log (devtools). Keyed by a JSON-encoded `[id, importer]` pair
|
||||||
|
// (collision-proof, unlike a delimiter string) so repeated resolves of the same import (HMR re-runs)
|
||||||
|
// update one entry instead of flooding the log; a Map preserves insertion order, and re-inserting
|
||||||
|
// moves the entry to the end (most-recent-last).
|
||||||
|
const log = new Map<string, ResolveRecord>()
|
||||||
|
const remember = (rec: ResolveRecord) => {
|
||||||
|
const key = JSON.stringify([rec.id, rec.importer ?? null]) // collision-proof composite key
|
||||||
|
if (log.has(key)) log.delete(key)
|
||||||
|
log.set(key, rec)
|
||||||
|
while (log.size > record) log.delete(log.keys().next().value!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roots,
|
||||||
|
prefixes,
|
||||||
|
extensions,
|
||||||
|
parse,
|
||||||
|
candidates,
|
||||||
|
resolveId(id, importer) {
|
||||||
|
const parsed = parse(id)
|
||||||
|
if (!parsed) return null
|
||||||
|
|
||||||
|
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
|
||||||
|
|
||||||
|
// super(): if the importer is one of the candidates (an override importing its own layered
|
||||||
|
// path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so it resolves to
|
||||||
|
// the highest-priority match (index 0). Note: "first candidate that isn't me" would be wrong —
|
||||||
|
// for a shadowed middle layer it jumps UP to a higher override, and a top↔mid self-import chain
|
||||||
|
// would cycle. Position-aware skip makes super() correct through a deep extends chain.
|
||||||
|
const list = candidates(parsed.sub)
|
||||||
|
const selfIndex = self ? list.indexOf(self) : -1
|
||||||
|
const next = list[selfIndex + 1]
|
||||||
|
const resolved = next ? next + parsed.query : null
|
||||||
|
|
||||||
|
if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex })
|
||||||
|
return resolved
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
cache.clear()
|
||||||
|
},
|
||||||
|
records() {
|
||||||
|
return [...log.values()].reverse()
|
||||||
|
},
|
||||||
|
clearRecords() {
|
||||||
|
log.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the argument is an already-built {@link LayeredResolution} rather than raw options. */
|
||||||
|
const isResolution = (v: LayersResolverOptions | LayeredResolution): v is LayeredResolution =>
|
||||||
|
typeof (v as LayeredResolution).resolveId === 'function'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
|
||||||
|
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
|
||||||
|
* it probes each source root in priority order and returns the first match.
|
||||||
|
*
|
||||||
|
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
|
||||||
|
* then `<path>/index<ext>`.
|
||||||
|
*
|
||||||
|
* Improvement over Nuxt: **self-skip** gives `super()` semantics at any depth. When the importer is
|
||||||
|
* itself one of the matches (an override importing its own layered path), resolution continues to the
|
||||||
|
* **next-lower** layer — so an override at `@/components/Foo.vue` can import `@/components/Foo.vue` to
|
||||||
|
* reach the layer beneath it. This composes through a deep `extends` chain: top→mid→base each resolve
|
||||||
|
* one step down, so multi-level overrides can each call `super()`.
|
||||||
|
*
|
||||||
|
* Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a
|
||||||
|
* pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects
|
||||||
|
* the exact same cache and resolution log this plugin produces.
|
||||||
|
*/
|
||||||
|
export function layersResolver(source: LayersResolverOptions | LayeredResolution): Plugin {
|
||||||
|
const resolution = isResolution(source) ? source : createLayeredResolution(source)
|
||||||
|
// Hook filter (rolldown): a RegExp matching the layered prefixes, so the bundler only invokes
|
||||||
|
// resolveId for `@/`/`~/` ids — every other specifier skips the JS round-trip. (resolveId filters
|
||||||
|
// accept only RegExp ids, not string globs.) https://rolldown.rs/in-depth/why-plugin-hook-filter
|
||||||
|
const idFilter = new RegExp(`^(?:${resolution.prefixes.map(escapeRegExp).join('|')})`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'vite-layers:resolve',
|
name: 'vite-layers:resolve',
|
||||||
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
|
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
// A new/removed file can change which layer wins → drop the cache in dev.
|
// A new/removed file can change which layer wins → drop the cache in dev.
|
||||||
const clear = () => cache.clear()
|
const clear = () => resolution.clear()
|
||||||
server.watcher.on('add', clear)
|
server.watcher.on('add', clear)
|
||||||
server.watcher.on('unlink', clear)
|
server.watcher.on('unlink', clear)
|
||||||
server.watcher.on('unlinkDir', clear)
|
server.watcher.on('unlinkDir', clear)
|
||||||
},
|
},
|
||||||
resolveId(id, importer) {
|
resolveId: {
|
||||||
const prefix = prefixes.find(p => id.startsWith(p))
|
filter: { id: idFilter },
|
||||||
if (!prefix) return null
|
handler(id, importer) {
|
||||||
|
return resolution.resolveId(id, importer)
|
||||||
const q = id.indexOf('?')
|
},
|
||||||
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
|
|
||||||
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
|
|
||||||
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
|
|
||||||
|
|
||||||
for (const file of candidates(sub)) {
|
|
||||||
if (file === self) continue // self-skip → fall through to the base layer (super())
|
|
||||||
return file + query
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import { defu } from 'defu'
|
|||||||
import { type TSConfig, writeTSConfig } from 'pkg-types'
|
import { type TSConfig, writeTSConfig } from 'pkg-types'
|
||||||
import type { Plugin } from 'vite'
|
import type { Plugin } from 'vite'
|
||||||
import { resolveLayerStack } from './config'
|
import { resolveLayerStack } from './config'
|
||||||
|
import { FEATURE_MODULE, featuresDts } from './features'
|
||||||
import { hooksFromStack, type LayerHookable } from './hooks'
|
import { hooksFromStack, type LayerHookable } from './hooks'
|
||||||
import type { LayerStack } from './types'
|
import type { LayerStack } from './types'
|
||||||
|
import { toPosix } from './util'
|
||||||
|
|
||||||
export type { TSConfig } from 'pkg-types'
|
export type { TSConfig } from 'pkg-types'
|
||||||
|
|
||||||
|
/** Absolute path (no extension) to the `feature` macro entry — mapped to `#feature` in `paths`. */
|
||||||
|
const FEATURE_FILE = resolve(import.meta.dirname, 'feature')
|
||||||
|
|
||||||
export interface GenerateTsConfigOptions {
|
export interface GenerateTsConfigOptions {
|
||||||
/**
|
/**
|
||||||
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
|
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
|
||||||
@@ -25,52 +30,10 @@ export interface GenerateTsConfigOptions {
|
|||||||
hooks?: LayerHookable
|
hooks?: LayerHookable
|
||||||
}
|
}
|
||||||
|
|
||||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
/** A path not already starting with `.` — {@link rel} prefixes it with `./`. */
|
||||||
|
const LEADING_NON_DOT_RE = /^([^.])/
|
||||||
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
|
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
|
||||||
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(/^([^.])/, './$1') || '.'
|
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(LEADING_NON_DOT_RE, './$1') || '.'
|
||||||
|
|
||||||
/** A property name that can be written unquoted in a TS type literal. */
|
|
||||||
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
|
||||||
|
|
||||||
/** Render a JSON-ish value as a TS type literal (boolean/number/string → type, object → recurse). */
|
|
||||||
function tsType(value: unknown): string {
|
|
||||||
if (value === null) return 'null'
|
|
||||||
if (Array.isArray(value)) return 'readonly unknown[]'
|
|
||||||
switch (typeof value) {
|
|
||||||
case 'boolean':
|
|
||||||
return 'boolean'
|
|
||||||
case 'number':
|
|
||||||
return 'number'
|
|
||||||
case 'string':
|
|
||||||
return 'string'
|
|
||||||
case 'object': {
|
|
||||||
const entries = Object.entries(value as Record<string, unknown>)
|
|
||||||
if (entries.length === 0) return 'Record<string, never>'
|
|
||||||
const body = entries
|
|
||||||
.map(([k, v]) => `${IDENTIFIER_RE.test(k) ? k : JSON.stringify(k)}: ${tsType(v)}`)
|
|
||||||
.join('; ')
|
|
||||||
return `{ ${body} }`
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a `.d.ts` that types the `__FEATURES__` global from the merged feature flags, so a typo
|
|
||||||
* (`__FEATURES__.biling`) is a compile error instead of a silently-falsy runtime value.
|
|
||||||
*/
|
|
||||||
export function featuresDts(features: Record<string, unknown> = {}): string {
|
|
||||||
return [
|
|
||||||
'// AUTO-GENERATED by vite-layers — do not edit.',
|
|
||||||
'export {}',
|
|
||||||
'declare global {',
|
|
||||||
` const __FEATURES__: ${tsType(features)}`,
|
|
||||||
'}',
|
|
||||||
'',
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
|
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
|
||||||
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
|
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
|
||||||
@@ -128,6 +91,9 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
|
|||||||
'@@': [rel(genDir, projectRoot)],
|
'@@': [rel(genDir, projectRoot)],
|
||||||
'~~/*': [`${rel(genDir, projectRoot)}/*`],
|
'~~/*': [`${rel(genDir, projectRoot)}/*`],
|
||||||
'@@/*': [`${rel(genDir, projectRoot)}/*`],
|
'@@/*': [`${rel(genDir, projectRoot)}/*`],
|
||||||
|
// `#feature` → the macro entry, so tsc/vue-tsc resolve `import { feature } from '#feature'` and
|
||||||
|
// the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers.
|
||||||
|
[FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)],
|
||||||
}
|
}
|
||||||
for (const l of layers) {
|
for (const l of layers) {
|
||||||
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
|
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
|
||||||
@@ -139,8 +105,8 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
|
|||||||
|
|
||||||
const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))]
|
const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))]
|
||||||
|
|
||||||
// App/client config: layer src trees + the typed __FEATURES__ global. Config files are NOT here —
|
// App/client config: layer src trees + the typed `feature()` flags (features.d.ts). Config files
|
||||||
// they belong to the node config below.
|
// are NOT here — they belong to the node config below.
|
||||||
const base: TSConfig = {
|
const base: TSConfig = {
|
||||||
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
|
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
|
||||||
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
|
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface LayerConfig {
|
|||||||
extends?: string | string[]
|
extends?: string | string[]
|
||||||
/** Vite config fragment contributed by this layer (object or env-aware factory). */
|
/** Vite config fragment contributed by this layer (object or env-aware factory). */
|
||||||
vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
|
vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
|
||||||
/** Build-time feature flags, exposed to app code as the `__FEATURES__` global. */
|
/** Build-time feature flags, read in app code via the `feature('key')` macro (`#feature`). */
|
||||||
features?: Record<string, unknown>
|
features?: Record<string, unknown>
|
||||||
/**
|
/**
|
||||||
* tsconfig overrides contributed by this layer, merged across the stack into the generated
|
* tsconfig overrides contributed by this layer, merged across the stack into the generated
|
||||||
@@ -52,9 +52,26 @@ export interface Layer {
|
|||||||
config: LayerConfig
|
config: LayerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single edge of the resolved `extends` graph — the layer at `from` (a directory) extends the one
|
||||||
|
* resolved at `to`. `source` is the raw `extends` entry (relative path, npm package, or git source).
|
||||||
|
* Captured during resolution because c12 strips the extend keys from the resolved layer configs.
|
||||||
|
*/
|
||||||
|
export interface LayerEdge {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface LayerStack {
|
export interface LayerStack {
|
||||||
/** Deep-merged config across the whole stack (defu, project wins). */
|
/** Deep-merged config across the whole stack (defu, project wins). */
|
||||||
merged: LayerConfig
|
merged: LayerConfig
|
||||||
/** Layers ordered high→low priority; `layers[0]` is the project itself. */
|
/** Layers ordered high→low priority; `layers[0]` is the project itself. */
|
||||||
layers: Layer[]
|
layers: Layer[]
|
||||||
|
/**
|
||||||
|
* Parent→child `extends` edges captured during resolution (directories, posix, no trailing slash),
|
||||||
|
* in walk order. Lets tooling rebuild the inheritance DAG the flat `layers` order flattens away.
|
||||||
|
* Optional: synthetic stacks built by hand may omit it.
|
||||||
|
*/
|
||||||
|
edges?: LayerEdge[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { fileURLToPath } from 'node:url'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { configWatchPlugin, featuresRuntimePlugin } from '../src/dev'
|
import { configWatchPlugin } from '../src/dev'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||||
@@ -39,35 +39,13 @@ describe('configWatchPlugin', () => {
|
|||||||
watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts'))
|
watcher.emit('change', resolve(fixture('stack/app'), 'src', 'whatever.ts'))
|
||||||
expect(restart).not.toHaveBeenCalled()
|
expect(restart).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const runTransform = (
|
it('restarts when a config is newly added to a layer that had none', () => {
|
||||||
plugin: { transform?: unknown },
|
const plugin = configWatchPlugin([fixture('stack/app')])
|
||||||
code: string,
|
const { server, watcher, restart } = mockServer()
|
||||||
id = '/app/src/x.ts',
|
callConfigureServer(plugin, server)
|
||||||
): { code: unknown; map?: unknown } | null => {
|
// app.config.js does not exist at startup, but it is a candidate path → `add` must restart.
|
||||||
const t = plugin.transform as
|
watcher.emit('add', resolve(fixture('stack/app'), 'app.config.js'))
|
||||||
| ((this: unknown, c: string, i: string) => { code: unknown; map?: unknown } | null)
|
expect(restart).toHaveBeenCalledTimes(1)
|
||||||
| undefined
|
|
||||||
return t ? t.call({}, code, id) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('featuresRuntimePlugin', () => {
|
|
||||||
it('applies only in serve mode', () => {
|
|
||||||
expect(featuresRuntimePlugin({}).apply).toBe('serve')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prepends a module-local __FEATURES__ with a rolldown-generated sourcemap', () => {
|
|
||||||
const out = runTransform(featuresRuntimePlugin({ billing: true }), 'export const x = __FEATURES__.billing')
|
|
||||||
const code = String(out?.code)
|
|
||||||
expect(code).toContain('const __FEATURES__={"billing":true};')
|
|
||||||
expect(code).toContain('export const x = __FEATURES__.billing')
|
|
||||||
expect((out?.map as { mappings?: string })?.mappings).toBeTruthy() // real sourcemap
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores property access (_ctx.__FEATURES__) and node_modules', () => {
|
|
||||||
const p = featuresRuntimePlugin({ billing: true })
|
|
||||||
expect(runTransform(p, 'const a = _ctx.__FEATURES__.billing')).toBeNull()
|
|
||||||
expect(runTransform(p, 'export const x = __FEATURES__.billing', '/x/node_modules/y.js')).toBeNull()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
return (await fn(env)) as UserConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const featCtx = {
|
||||||
|
error(m: string | { message: string }): never {
|
||||||
|
throw new Error(typeof m === 'string' ? m : m.message)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const runTransform = (plugin: Plugin, code: string, id = '/app/src/x.ts') => {
|
||||||
|
const t = plugin.transform as Plugin['transform']
|
||||||
|
const handler = (typeof t === 'function' ? t : t!.handler) as (
|
||||||
|
this: unknown,
|
||||||
|
c: string,
|
||||||
|
i: string,
|
||||||
|
) => { code?: unknown } | null
|
||||||
|
return handler.call(featCtx, code, id)
|
||||||
|
}
|
||||||
|
|
||||||
describe('buildViteConfig', () => {
|
describe('buildViteConfig', () => {
|
||||||
it('exposes merged features via __FEATURES__ define (for DCE)', async () => {
|
it('registers the feature macro plugin and aliases #feature to the macro entry', async () => {
|
||||||
const cfg = await build(fixture('stack/app'))
|
const cfg = await build(fixture('stack/app'))
|
||||||
const features = JSON.parse((cfg.define as Record<string, string>).__FEATURES__)
|
const plugins = (cfg.plugins as Plugin[]).flat(Infinity as 1) as Plugin[]
|
||||||
expect(features.shared).toBe('app')
|
expect(plugins.some(p => p?.name === 'vite-layers:features')).toBe(true)
|
||||||
expect(features).toMatchObject({ app: true, base: true, core: true })
|
const alias = (cfg.resolve as { alias: Record<string, string> }).alias
|
||||||
|
expect(alias['#feature']).toMatch(/\/src\/feature\.ts$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits dotted feature defines (for dead-code elimination of gated imports)', async () => {
|
it('emits no __FEATURES__ define (flags compile via the feature() macro, not define)', async () => {
|
||||||
const cfg = await build(fixture('stack/app'))
|
const cfg = await build(fixture('stack/app'))
|
||||||
const define = cfg.define as Record<string, string>
|
const define = (cfg.define ?? {}) as Record<string, string>
|
||||||
// dotted entry is folded by esbuild to a literal → enables DCE of `__FEATURES__.x ? import() : []`
|
expect(Object.keys(define).some(k => k.startsWith('__FEATURES__'))).toBe(false)
|
||||||
expect(define['__FEATURES__.shared']).toBe('"app"')
|
|
||||||
expect(define['__FEATURES__.app']).toBe('true')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits dotted defines at every nesting depth (so nested flags also DCE)', async () => {
|
it('compiles feature() against the merged flags; layers:resolved mutates them first, vite:config runs last', async () => {
|
||||||
const cfg = await build(fixture('features/app'))
|
|
||||||
const define = cfg.define as Record<string, string>
|
|
||||||
expect(define['__FEATURES__.billing']).toBe('false')
|
|
||||||
expect(define['__FEATURES__.nested.enabled']).toBe('false') // deep leaf → foldable → DCE-able
|
|
||||||
expect(define['__FEATURES__.nested.deep.on']).toBe('true')
|
|
||||||
expect(define['__FEATURES__.nested']).toBe('{"enabled":false,"deep":{"on":true}}') // intermediate object too
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips non-identifier feature keys in dotted defines (avoids INVALID_DEFINE_CONFIG crash)', async () => {
|
|
||||||
const cfg = await build(fixture('features/app'))
|
|
||||||
const define = cfg.define as Record<string, string>
|
|
||||||
// a dotted define with `kebab-flag` would crash the build; it is skipped here…
|
|
||||||
expect(define['__FEATURES__.kebab-flag']).toBeUndefined()
|
|
||||||
// …but still readable at runtime via the whole-object define.
|
|
||||||
expect(JSON.parse(define.__FEATURES__)['kebab-flag']).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs lifecycle hooks: layers:resolved mutates features (before define), vite:config mutates config', async () => {
|
|
||||||
const fn = (await buildViteConfig(fixture('stack/app'), {
|
const fn = (await buildViteConfig(fixture('stack/app'), {
|
||||||
hooks: {
|
hooks: {
|
||||||
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
|
'layers:resolved': s => void ((s.merged.features ??= {}).injected = true),
|
||||||
@@ -55,9 +51,10 @@ describe('buildViteConfig', () => {
|
|||||||
},
|
},
|
||||||
})) as UserConfigFnObject
|
})) as UserConfigFnObject
|
||||||
const cfg = (await fn(env)) as UserConfig
|
const cfg = (await fn(env)) as UserConfig
|
||||||
const define = cfg.define as Record<string, string>
|
const feat = (cfg.plugins as Plugin[]).flat(Infinity as 1).find(p => (p as Plugin)?.name === 'vite-layers:features') as Plugin
|
||||||
expect(define['__FEATURES__.injected']).toBe('true') // layers:resolved ran before featureDefines
|
const out = runTransform(feat, `import { feature } from '#feature'\nexport const a = feature('injected')\n`)
|
||||||
expect(define.INJECTED).toBe('"yes"') // vite:config ran at the very end
|
expect(String(out?.code)).toContain('export const a = true') // layers:resolved ran before the macro read features
|
||||||
|
expect((cfg.define as Record<string, string>).INJECTED).toBe('"yes"') // vite:config ran at the very end
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers the layers resolver plugin', async () => {
|
it('registers the layers resolver plugin', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { publicLayersPlugin } from '../src/public'
|
import { publicLayersPlugin } from '../src/public'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -8,30 +10,65 @@ const fixture = (p: string) => resolve(here, 'fixtures', p)
|
|||||||
|
|
||||||
const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)()
|
const callConfig = (p: { config?: unknown }) => (p.config as () => unknown)()
|
||||||
|
|
||||||
function runGenerateBundle(p: { generateBundle?: unknown }): Record<string, string> {
|
type ResolvedishConfig = { root: string; build: { outDir: string; copyPublicDir?: boolean } }
|
||||||
const emitted: Record<string, string> = {}
|
|
||||||
const ctx = {
|
/** List every file written under `dir` as `posixRelativePath → contents`. */
|
||||||
emitFile: ({ fileName, source }: { fileName: string; source: Buffer | string }) => {
|
function snapshot(dir: string): Record<string, string> {
|
||||||
emitted[fileName] = source.toString()
|
const out: Record<string, string> = {}
|
||||||
},
|
const walk = (d: string) => {
|
||||||
|
for (const name of readdirSync(d, { withFileTypes: true })) {
|
||||||
|
const abs = join(d, name.name)
|
||||||
|
if (name.isDirectory()) walk(abs)
|
||||||
|
else out[resolve(abs).slice(resolve(dir).length + 1).replace(/\\/g, '/')] = readFileSync(abs, 'utf8')
|
||||||
}
|
}
|
||||||
;(p.generateBundle as (this: unknown, ...a: unknown[]) => void).call(ctx, {}, {}, false)
|
}
|
||||||
return emitted
|
walk(dir)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive the build-time hooks (configResolved → writeBundle) and return what landed on disk. */
|
||||||
|
function runBuild(
|
||||||
|
p: { configResolved?: unknown; writeBundle?: unknown },
|
||||||
|
outDir: string,
|
||||||
|
{ copyPublicDir, writeDir }: { copyPublicDir?: boolean; writeDir?: string } = {},
|
||||||
|
): Record<string, string> {
|
||||||
|
const cfg: ResolvedishConfig = { root: '/', build: { outDir, copyPublicDir } }
|
||||||
|
;(p.configResolved as (c: ResolvedishConfig) => void)(cfg)
|
||||||
|
;(p.writeBundle as (this: unknown, o: { dir?: string }) => void).call({}, { dir: writeDir ?? outDir })
|
||||||
|
return snapshot(outDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('publicLayersPlugin', () => {
|
describe('publicLayersPlugin', () => {
|
||||||
const high = fixture('public/high/public')
|
const high = fixture('public/high/public')
|
||||||
const low = fixture('public/low/public')
|
const low = fixture('public/low/public')
|
||||||
|
let outDir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
outDir = mkdtempSync(join(tmpdir(), 'vite-layers-public-'))
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(outDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
it('disables Vite publicDir when layers have public/, otherwise no-op', () => {
|
it('disables Vite publicDir when layers have public/, otherwise no-op', () => {
|
||||||
expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false })
|
expect(callConfig(publicLayersPlugin([high, low]))).toEqual({ publicDir: false })
|
||||||
expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined()
|
expect(callConfig(publicLayersPlugin([fixture('public/none/public')]))).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
|
it('copies assets first-match-wins (higher overrides, lower fills gaps, nested ok)', () => {
|
||||||
const emitted = runGenerateBundle(publicLayersPlugin([high, low]))
|
const written = runBuild(publicLayersPlugin([high, low]), outDir)
|
||||||
expect(emitted['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
|
expect(written['logo.svg']).toBe('HIGH_LOGO') // overridden by the higher layer
|
||||||
expect(emitted['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
|
expect(written['shared.txt']).toBe('LOW_SHARED') // inherited from the lower layer
|
||||||
expect(emitted['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
|
expect(written['img/icon.svg']).toBe('LOW_ICON') // nested, from the lower layer
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the copy when Vite opts out (copyPublicDir: false)', () => {
|
||||||
|
const written = runBuild(publicLayersPlugin([high, low]), outDir, { copyPublicDir: false })
|
||||||
|
expect(written).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only copies for the output targeting the main outDir', () => {
|
||||||
|
const written = runBuild(publicLayersPlugin([high, low]), outDir, { writeDir: join(outDir, 'server') })
|
||||||
|
expect(written).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { layersResolver } from '../src/resolve'
|
import type { Plugin } from 'vite'
|
||||||
|
import { createLayeredResolution, layersResolver } from '../src/resolve'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||||
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p))
|
const fixture = (p: string) => toPosix(resolve(here, 'fixtures', 'resolve', p))
|
||||||
|
|
||||||
|
// `resolveId` is now a filtered object hook (`{ filter, handler }`); call its handler.
|
||||||
|
const callResolveId = (plugin: Plugin, id: string, importer?: string): string | null => {
|
||||||
|
const h = plugin.resolveId
|
||||||
|
const fn = (typeof h === 'function' ? h : h?.handler) as (id: string, importer?: string) => string | null
|
||||||
|
return fn(id, importer)
|
||||||
|
}
|
||||||
|
|
||||||
// roots ordered high→low priority: brand overrides base.
|
// roots ordered high→low priority: brand overrides base.
|
||||||
const roots = [fixture('brand/src'), fixture('base/src')]
|
const roots = [fixture('brand/src'), fixture('base/src')]
|
||||||
const plugin = layersResolver({ roots })
|
const plugin = layersResolver({ roots })
|
||||||
const resolveId = (id: string, importer?: string): string | null =>
|
const resolveId = (id: string, importer?: string): string | null => callResolveId(plugin, id, importer)
|
||||||
(plugin.resolveId as (id: string, importer?: string) => string | null)(id, importer)
|
|
||||||
|
|
||||||
describe('layersResolver', () => {
|
describe('layersResolver', () => {
|
||||||
it('ignores non-layered ids', () => {
|
it('ignores non-layered ids', () => {
|
||||||
@@ -42,6 +49,27 @@ describe('layersResolver', () => {
|
|||||||
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
|
expect(resolveId('@/components/Header.vue', brandHeader)).toBe(baseHeader)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('super() through a deep (3-layer) extends chain', () => {
|
||||||
|
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
|
||||||
|
const dp = layersResolver({ roots: deepRoots })
|
||||||
|
const drid = (id: string, importer?: string) => callResolveId(dp, id, importer)
|
||||||
|
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
|
||||||
|
|
||||||
|
it('a normal import resolves to the highest layer', () => {
|
||||||
|
expect(drid('@/components/Widget.vue')).toBe(W('top'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('super() resolves to the NEXT-LOWER layer at every level (never upward)', () => {
|
||||||
|
expect(drid('@/components/Widget.vue', W('top'))).toBe(W('mid'))
|
||||||
|
// the regression guard: a shadowed middle layer must reach `base`, not jump back up to `top`
|
||||||
|
expect(drid('@/components/Widget.vue', W('mid'))).toBe(W('base'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('super() from the lowest layer resolves to null (nothing beneath it)', () => {
|
||||||
|
expect(drid('@/components/Widget.vue', W('base'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('returns null when nothing matches across layers', () => {
|
it('returns null when nothing matches across layers', () => {
|
||||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -54,15 +82,81 @@ describe('layersResolver', () => {
|
|||||||
|
|
||||||
it('honors custom prefixes and extensions', () => {
|
it('honors custom prefixes and extensions', () => {
|
||||||
const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] })
|
const p = layersResolver({ roots, prefixes: ['#/'], extensions: ['.ts'] })
|
||||||
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
|
const rid = (id: string) => callResolveId(p, id)
|
||||||
expect(rid('#/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts')) // index probe, .ts only
|
expect(rid('#/widgets/Card')).toBe(fixture('base/src/widgets/Card/index.ts')) // index probe, .ts only
|
||||||
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
|
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
|
||||||
})
|
})
|
||||||
|
|
||||||
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
|
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
|
||||||
const p = layersResolver({ roots })
|
const p = layersResolver({ roots })
|
||||||
const rid = (id: string) => (p.resolveId as (id: string) => string | null)(id)
|
const rid = (id: string) => callResolveId(p, id)
|
||||||
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
|
expect(rid('@/components/Header.vue')).toBe(rid('@/components/Header.vue'))
|
||||||
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
expect(rid('@/components/Footer.vue')).toBe(fixture('base/src/components/Footer.vue'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createLayeredResolution (introspection core)', () => {
|
||||||
|
it('parse() splits prefix/sub/query and rejects non-layered ids', () => {
|
||||||
|
const r = createLayeredResolution({ roots })
|
||||||
|
expect(r.parse('@/components/Header.vue?raw')).toEqual({ prefix: '@/', sub: 'components/Header.vue', query: '?raw' })
|
||||||
|
expect(r.parse('vue')).toBeNull()
|
||||||
|
expect(r.parse('#layers/base/x')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('candidates() lists every matching file across layers, high→low', () => {
|
||||||
|
const r = createLayeredResolution({ roots })
|
||||||
|
expect(r.candidates('components/Header.vue')).toEqual([
|
||||||
|
fixture('brand/src/components/Header.vue'),
|
||||||
|
fixture('base/src/components/Header.vue'),
|
||||||
|
])
|
||||||
|
expect(r.candidates('components/Footer.vue')).toEqual([fixture('base/src/components/Footer.vue')])
|
||||||
|
expect(r.candidates('components/Missing.vue')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records resolutions only when enabled, newest-first, de-duplicated by id+importer', () => {
|
||||||
|
const off = createLayeredResolution({ roots })
|
||||||
|
off.resolveId('@/components/Header.vue')
|
||||||
|
expect(off.records()).toEqual([]) // recording disabled by default
|
||||||
|
|
||||||
|
const r = createLayeredResolution({ roots, record: 10 })
|
||||||
|
r.resolveId('@/components/Header.vue')
|
||||||
|
r.resolveId('@/components/Footer.vue')
|
||||||
|
r.resolveId('@/components/Header.vue') // repeat → updates the existing entry, no duplicate
|
||||||
|
const recs = r.records()
|
||||||
|
expect(recs).toHaveLength(2)
|
||||||
|
expect(recs[0]!.id).toBe('@/components/Header.vue') // most-recent first
|
||||||
|
expect(recs[0]!.candidates).toEqual([
|
||||||
|
fixture('brand/src/components/Header.vue'),
|
||||||
|
fixture('base/src/components/Header.vue'),
|
||||||
|
])
|
||||||
|
expect(recs[0]!.selfIndex).toBe(-1) // a normal (non-self) import
|
||||||
|
|
||||||
|
r.clearRecords()
|
||||||
|
expect(r.records()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a super() self-import with the importer position', () => {
|
||||||
|
const r = createLayeredResolution({ roots, record: 10 })
|
||||||
|
const brandHeader = fixture('brand/src/components/Header.vue')
|
||||||
|
expect(r.resolveId('@/components/Header.vue', brandHeader)).toBe(fixture('base/src/components/Header.vue'))
|
||||||
|
expect(r.records()[0]!.selfIndex).toBe(0) // importer is the top candidate → super() skips to #1
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the log bounded to the record size', () => {
|
||||||
|
const r = createLayeredResolution({ roots, record: 2 })
|
||||||
|
r.resolveId('@/components/Header.vue')
|
||||||
|
r.resolveId('@/components/Footer.vue')
|
||||||
|
r.resolveId('@/components/Missing.vue')
|
||||||
|
expect(r.records()).toHaveLength(2) // oldest (Header) evicted
|
||||||
|
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the plugin and a shared resolution stay in sync', () => {
|
||||||
|
const shared = createLayeredResolution({ roots, record: 10 })
|
||||||
|
const plugin = layersResolver(shared)
|
||||||
|
callResolveId(plugin, '@/components/Header.vue')
|
||||||
|
// the resolution the plugin wraps recorded the resolveId the plugin handled
|
||||||
|
expect(shared.records()).toHaveLength(1)
|
||||||
|
expect(shared.records()[0]!.resolved).toBe(fixture('brand/src/components/Header.vue'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createLayerHooks } from '../src/hooks'
|
import { createLayerHooks } from '../src/hooks'
|
||||||
import { featuresDts, generateTsConfig } from '../src/tsconfig'
|
import { generateTsConfig } from '../src/tsconfig'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
const fixture = (p: string) => resolve(here, 'fixtures', p)
|
||||||
@@ -104,7 +104,13 @@ describe('generateTsConfig', () => {
|
|||||||
const r = await generateTsConfig(fixture('stack/app'))
|
const r = await generateTsConfig(fixture('stack/app'))
|
||||||
expect(r.tsconfig.include).toContain('./features.d.ts')
|
expect(r.tsconfig.include).toContain('./features.d.ts')
|
||||||
expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/)
|
expect(r.dtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/features\.d\.ts$/)
|
||||||
expect(r.dts).toContain('const __FEATURES__:')
|
expect(r.dts).toContain(`declare module '#feature'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps #feature to the macro entry so tsc resolves the feature() import', async () => {
|
||||||
|
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||||
|
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||||
|
expect(paths['#feature']?.[0]).toMatch(/\/src\/feature$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses a provided stack instead of resolving again (O2)', async () => {
|
it('reuses a provided stack instead of resolving again (O2)', async () => {
|
||||||
@@ -117,18 +123,6 @@ describe('generateTsConfig', () => {
|
|||||||
const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
|
const r = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
|
||||||
const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]>
|
const paths = r.tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||||
expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used
|
expect(Object.keys(paths)).toContain('#layers/FAKELAYER/*') // proves the fake stack was used
|
||||||
expect(r.dts).toContain('onlyInFake: boolean')
|
expect(r.dts).toContain('onlyInFake: true') // literal type, from the fake stack's features
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('featuresDts', () => {
|
|
||||||
it('renders a typed __FEATURES__ global (nested, primitives, quoted non-identifier keys)', () => {
|
|
||||||
const dts = featuresDts({ billing: true, nested: { enabled: false }, 'kebab-flag': true, count: 2 })
|
|
||||||
expect(dts).toContain('declare global')
|
|
||||||
expect(dts).toContain('const __FEATURES__:')
|
|
||||||
expect(dts).toContain('billing: boolean')
|
|
||||||
expect(dts).toContain('nested: { enabled: boolean }')
|
|
||||||
expect(dts).toContain('"kebab-flag": boolean')
|
|
||||||
expect(dts).toContain('count: number')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||