feat: add vite-layers
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { basename, relative, resolve } from 'node:path'
|
||||
import { loadConfig, type ConfigLayer } from 'c12'
|
||||
import { createDefu } from 'defu'
|
||||
import { glob } from 'tinyglobby'
|
||||
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
|
||||
import type { Layer, LayerConfig, LayerStack } from './types'
|
||||
|
||||
/** Identity helper for typed `app.config.ts` files. */
|
||||
export const defineLayerConfig = (config: LayerConfig): LayerConfig => config
|
||||
|
||||
/**
|
||||
* Normalize to forward slashes. c12 returns `cwd` posix-style while node `resolve()` is
|
||||
* OS-native (backslashes on Windows); paths must be canonicalized before they are compared
|
||||
* for dedup or emitted into a Vite config (where posix is conventional).
|
||||
*/
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
/**
|
||||
* Port of Nuxt's layer merger: arrays are concatenated rather than replaced.
|
||||
* (See `@nuxt/kit` `loadNuxtConfig`.)
|
||||
*/
|
||||
const merger = createDefu((obj, key, value) => {
|
||||
const target = obj as Record<PropertyKey, unknown>
|
||||
if (Array.isArray(target[key]) && Array.isArray(value)) {
|
||||
target[key] = (target[key] as unknown[]).concat(value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the full layer stack for an app directory, faithfully porting Nuxt's
|
||||
* `loadNuxtConfig` behavior on top of c12:
|
||||
*
|
||||
* 1. Auto-scan `layers/*` and prepend them (descending sort → "Z>A" / higher numeric prefix wins).
|
||||
* 2. Load + merge the `extends` graph via c12 (`defu`, arrays concatenated, project wins).
|
||||
* 3. Normalize: dedup layers by resolved `rootDir` (first-wins), resolve `srcDir` and layer name.
|
||||
*
|
||||
* Improvement over Nuxt/c12: a cycle-guard in c12's `resolve` hook. Raw c12 neither dedups nor
|
||||
* detects cycles and will stack-overflow on a back-edge (`A→B→A`); Nuxt's own dedup runs only
|
||||
* *after* c12's recursive walk, so it does not prevent the overflow. Returning a terminal empty
|
||||
* layer the second time a source is seen cuts the recursion (returning null/undefined would fall
|
||||
* back to c12's default resolution and still recurse).
|
||||
*
|
||||
* Pass `mode` (typically Vite's `env.mode`) to enable per-layer environment overrides — c12 applies
|
||||
* a layer's `$development`/`$production`/`$env[mode]` block when `mode` matches (Nuxt parity).
|
||||
*
|
||||
* @returns layers ordered high→low priority; `layers[0]` is the project itself.
|
||||
*/
|
||||
export async function resolveLayerStack(
|
||||
cwd: string,
|
||||
opts: { mode?: string } = {},
|
||||
): Promise<LayerStack> {
|
||||
// 1) Auto-scan `layers/*` — descending sort so "Z"/higher numeric prefix wins, like Nuxt.
|
||||
const localLayers = (await glob('layers/*', { onlyDirectories: true, cwd }))
|
||||
.map(d => withTrailingSlash(resolve(cwd, d)))
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
|
||||
const seen = new Set<string>()
|
||||
|
||||
const { config, layers = [] } = await loadConfig<LayerConfig>({
|
||||
cwd,
|
||||
configFile: 'app.config',
|
||||
extend: { extendKey: ['_extends', 'extends'] },
|
||||
overrides: { _extends: localLayers } as LayerConfig,
|
||||
// Per-layer env overrides ($production/$development/$env). Undefined → c12 uses NODE_ENV.
|
||||
// Do NOT set `omit$Keys` — it would strip `$meta`, which we read for layer names below.
|
||||
envName: opts.mode,
|
||||
rcFile: false,
|
||||
packageJson: false,
|
||||
globalRc: false,
|
||||
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
|
||||
resolve(id, opts) {
|
||||
const abs = resolve(opts?.cwd ?? cwd, id)
|
||||
if (seen.has(abs)) return { config: {}, cwd: abs }
|
||||
seen.add(abs)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
// 3) Normalization — dedup by resolved rootDir (first-wins), resolve srcDir + name.
|
||||
const all: ConfigLayer<LayerConfig>[] = layers.length ? layers : [{ config, cwd }]
|
||||
const stack: Layer[] = []
|
||||
const processed = new Set<string>()
|
||||
const localRel = new Set(localLayers.map(l => relative(cwd, withoutTrailingSlash(l))))
|
||||
|
||||
for (const layer of all) {
|
||||
const rawRoot = layer.config?.rootDir ?? layer.cwd
|
||||
if (!rawRoot) continue
|
||||
const rootDir = toPosix(rawRoot)
|
||||
if (processed.has(rootDir)) continue
|
||||
processed.add(rootDir)
|
||||
|
||||
const srcDir = toPosix(resolve(rootDir, layer.config?.srcDir ?? 'src'))
|
||||
let name = layer.config?.$meta?.name ?? layer.config?.name
|
||||
if (!name && layer.cwd && localRel.has(relative(cwd, layer.cwd))) {
|
||||
name = basename(layer.cwd)
|
||||
}
|
||||
|
||||
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
|
||||
}
|
||||
|
||||
return { merged: config, layers: stack }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import MagicString from 'magic-string'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const existingConfigFiles = (rootDirs: string[]): Set<string> => {
|
||||
const files = new Set<string>()
|
||||
for (const dir of rootDirs) {
|
||||
for (const ext of CONFIG_EXTENSIONS) {
|
||||
const file = resolve(dir, `app.config${ext}`)
|
||||
try {
|
||||
if (statSync(file).isFile()) files.add(toPosix(file))
|
||||
} catch {
|
||||
// not present in this layer
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes.
|
||||
*
|
||||
* `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file
|
||||
* dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the
|
||||
* baked `__FEATURES__` `define` and aliases go stale. We watch each resolved layer's config file
|
||||
* (including layers outside the project root via `watcher.add`) and call `server.restart()`, which
|
||||
* re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → new `define`.
|
||||
*/
|
||||
export function configWatchPlugin(rootDirs: string[]): Plugin {
|
||||
return {
|
||||
name: 'vite-layers:config-watch',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
const files = existingConfigFiles(rootDirs)
|
||||
if (files.size === 0) return
|
||||
server.watcher.add([...files]) // ensure extended layers outside the root are watched too
|
||||
|
||||
const onChange = (file: string) => {
|
||||
if (!files.has(toPosix(file))) return
|
||||
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
|
||||
void server.restart()
|
||||
}
|
||||
server.watcher.on('change', onChange)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches a standalone `__FEATURES__` reference (not a `.__FEATURES__` property access). */
|
||||
const STANDALONE_FEATURES_RE = /(?<![.\w$])__FEATURES__\b/
|
||||
|
||||
/**
|
||||
* Dev-only plugin: make `__FEATURES__` resolve at runtime in the dev server.
|
||||
*
|
||||
* Vite 8 / rolldown-vite does **not** inline user `define` into dev-served source modules (only
|
||||
* `import.meta.env` is special-cased), so `__FEATURES__` would be an undefined global in dev. For
|
||||
* production, `define` (with DCE) still does the job; here we prepend a module-local
|
||||
* `const __FEATURES__ = {…}` to each served module that references the global, so feature flags have
|
||||
* correct values in dev — and pick up edits after a config-change restart (see {@link configWatchPlugin}).
|
||||
*
|
||||
* Only standalone references are handled (not `_ctx.__FEATURES__` from Vue templates — same as
|
||||
* `define`); gate features in `<script>`, not in template expressions.
|
||||
*/
|
||||
export function featuresRuntimePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
const json = JSON.stringify(features)
|
||||
return {
|
||||
name: 'vite-layers:features-runtime',
|
||||
apply: 'serve',
|
||||
transform(code, id) {
|
||||
if (id.includes('/node_modules/') || !STANDALONE_FEATURES_RE.test(code)) return null
|
||||
// NOTE: rolldown's *native* magic-string (the transform `meta.magicString` in the rolldown
|
||||
// docs) is NOT surfaced by Vite plugins — `meta` is `{ inMap, moduleType, ssr }` with no
|
||||
// `magicString` in dev or build. So we use the npm `magic-string` fallback the rolldown docs
|
||||
// recommend for non-native hosts; it also produces clean cross-platform sourcemaps.
|
||||
// Prepend on line 1 (keeps line numbers); module-local const shadows the missing global.
|
||||
const s = new MagicString(code)
|
||||
s.prepend(`const __FEATURES__=${json};`)
|
||||
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHooks, type Hookable, type NestedHooks } from 'hookable'
|
||||
import type { TSConfig } from 'pkg-types'
|
||||
import type { ConfigEnv, UserConfig } from 'vite'
|
||||
import type { Layer, LayerStack } from './types'
|
||||
|
||||
/** Hook handlers return nothing (mutation-style) — they may be async. */
|
||||
export type HookResult = void | Promise<void>
|
||||
|
||||
export interface ViteConfigHookContext {
|
||||
/** The fully-assembled Vite config (mutate in place, or replace `.config`). */
|
||||
config: UserConfig
|
||||
env: ConfigEnv
|
||||
stack: LayerStack
|
||||
}
|
||||
|
||||
export interface TsconfigHookContext {
|
||||
appDir: string
|
||||
/** The generated app/client tsconfig (mutate in place). */
|
||||
tsconfig: TSConfig
|
||||
/** The generated node tsconfig for config files (mutate in place). */
|
||||
nodeTsconfig: TSConfig
|
||||
stack: LayerStack
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hooks (powered by `hookable`, like Nuxt). Handlers run **serially in layer order —
|
||||
* base layers first** — and are **mutation-style**: they receive a shared argument and mutate it.
|
||||
*/
|
||||
export interface LayerHooks {
|
||||
/** After the stack is resolved and all hooks are registered. Mutate `stack` (merged/layers/features). */
|
||||
'layers:resolved': (stack: LayerStack) => HookResult
|
||||
/** The final Vite config, just before it is returned from `buildViteConfig`. */
|
||||
'vite:config': (ctx: ViteConfigHookContext) => HookResult
|
||||
/** The generated tsconfig, just before it is written. */
|
||||
'tsconfig:generate': (ctx: TsconfigHookContext) => HookResult
|
||||
}
|
||||
|
||||
/** Declarative hook map accepted in `app.config.ts` (`hooks`) — supports nested/dotted keys. */
|
||||
export type LayerHooksConfig = NestedHooks<LayerHooks>
|
||||
|
||||
export type LayerHookable = Hookable<LayerHooks>
|
||||
|
||||
/** Create an empty hookable instance for the layer lifecycle. */
|
||||
export const createLayerHooks = (): LayerHookable => createHooks<LayerHooks>()
|
||||
|
||||
/**
|
||||
* Register each layer's `hooks` onto the hookable, **base layers first** (so higher-priority
|
||||
* layers' handlers run later), then the programmatic hooks last. Mirrors Nuxt's per-layer
|
||||
* `addHooks` loop: functions can't be deep-merged, so same-name handlers **accumulate** instead of
|
||||
* overwriting.
|
||||
*
|
||||
* @param layers stack layers ordered high→low priority (as returned by `resolveLayerStack`).
|
||||
*/
|
||||
export function registerLayerHooks(
|
||||
hooks: LayerHookable,
|
||||
layers: Pick<Layer, 'config'>[],
|
||||
programmatic?: LayerHooksConfig,
|
||||
): void {
|
||||
for (const layer of [...layers].reverse()) {
|
||||
if (layer.config.hooks) hooks.addHooks(layer.config.hooks)
|
||||
}
|
||||
if (programmatic) hooks.addHooks(programmatic)
|
||||
}
|
||||
|
||||
/** Build a hookable from a stack's layer-declared hooks (used when no shared instance is provided). */
|
||||
export function hooksFromStack(layers: Pick<Layer, 'config'>[]): LayerHookable {
|
||||
const hooks = createLayerHooks()
|
||||
registerLayerHooks(hooks, layers)
|
||||
return hooks
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export { defineLayerConfig, resolveLayerStack } from './config'
|
||||
export { configWatchPlugin, featuresRuntimePlugin } from './dev'
|
||||
export { publicLayersPlugin } from './public'
|
||||
export {
|
||||
createLayerHooks,
|
||||
registerLayerHooks,
|
||||
hooksFromStack,
|
||||
type HookResult,
|
||||
type LayerHookable,
|
||||
type LayerHooks,
|
||||
type LayerHooksConfig,
|
||||
type TsconfigHookContext,
|
||||
type ViteConfigHookContext,
|
||||
} from './hooks'
|
||||
export { DEFAULT_EXTENSIONS, layersResolver, type LayersResolverOptions } from './resolve'
|
||||
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
|
||||
export {
|
||||
generateTsConfig,
|
||||
writeTsConfig,
|
||||
tsconfigPlugin,
|
||||
featuresDts,
|
||||
type GenerateTsConfigOptions,
|
||||
type TSConfig,
|
||||
} from './tsconfig'
|
||||
export type { Layer, LayerConfig, LayerStack } from './types'
|
||||
@@ -0,0 +1,153 @@
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
|
||||
import { resolveLayerStack } from './config'
|
||||
import { configWatchPlugin, featuresRuntimePlugin } from './dev'
|
||||
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
|
||||
import { publicLayersPlugin } from './public'
|
||||
import { layersResolver } from './resolve'
|
||||
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
|
||||
|
||||
export interface BuildViteConfigOptions {
|
||||
/** Extra Vite config merged at the very end (highest priority). */
|
||||
vite?: UserConfig
|
||||
/** Output directory. Default: `dist/<basename(appDir)>`. */
|
||||
outDir?: string
|
||||
/**
|
||||
* Auto-generate `.vite-layers/tsconfig.json` on config resolution (dev + build).
|
||||
* Pass options to customize, or `false` to disable. Default: enabled.
|
||||
*/
|
||||
tsconfig?: GenerateTsConfigOptions | false
|
||||
/** Override the layered resolver's import prefixes / probed extensions. */
|
||||
resolver?: { prefixes?: string[]; extensions?: string[] }
|
||||
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
|
||||
hooks?: LayerHooksConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* `mergeConfig` concatenates arrays — including `plugins` — so a plugin added by several
|
||||
* layers (e.g. a framework plugin in the base and re-declared in a brand) ends up duplicated.
|
||||
* Dedupe by `plugin.name`, keeping the highest-priority (last-merged) instance in original order.
|
||||
*/
|
||||
function dedupePlugins(config: UserConfig): UserConfig {
|
||||
if (!Array.isArray(config.plugins)) return config
|
||||
const flat = (config.plugins as PluginOption[]).flat(Infinity as 1)
|
||||
const indexByName = new Map<string, number>()
|
||||
const out: PluginOption[] = []
|
||||
for (const p of flat) {
|
||||
const name = p && typeof p === 'object' && 'name' in p ? (p as { name?: unknown }).name : undefined
|
||||
if (typeof name === 'string' && indexByName.has(name)) {
|
||||
out[indexByName.get(name)!] = p // keep position, take later (higher-priority) instance
|
||||
continue
|
||||
}
|
||||
if (typeof name === 'string') indexByName.set(name, out.length)
|
||||
out.push(p)
|
||||
}
|
||||
return { ...config, plugins: out }
|
||||
}
|
||||
|
||||
/** A member-expression define key segment must be a plain JS identifier. */
|
||||
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
/**
|
||||
* Build the `define` map for feature flags. Emits the whole `__FEATURES__` object (for runtime
|
||||
* reads) plus a dotted entry for **every nested path** whose segments are valid identifiers
|
||||
* (`__FEATURES__.billing`, `__FEATURES__.nested.enabled`, …).
|
||||
*
|
||||
* The dotted entries are what make dead-code elimination work: esbuild folds a replaced literal
|
||||
* (`false ? import('…') : []` → `[]`) and drops the dynamic import *before* Rollup builds the
|
||||
* module graph, so the page's chunk is never emitted. A member access on an object literal
|
||||
* (`{"enabled":false}.enabled`) is NOT folded, so the object form alone does not DCE — which is why
|
||||
* we walk recursively and emit a literal at every depth.
|
||||
*
|
||||
* Keys that are not valid identifiers (e.g. `'kebab-flag'`) are skipped rather than emitted: a
|
||||
* dotted define with such a segment is an `INVALID_DEFINE_CONFIG` build error, and you cannot fold
|
||||
* a bracket access anyway. The key still lives inside the whole-object `__FEATURES__` for runtime.
|
||||
*/
|
||||
function featureDefines(features: Record<string, unknown> = {}): Record<string, string> {
|
||||
const define: Record<string, string> = { __FEATURES__: JSON.stringify(features) }
|
||||
const walk = (obj: Record<string, unknown>, prefix: string) => {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (!IDENTIFIER_RE.test(key)) continue
|
||||
const path = `${prefix}.${key}`
|
||||
define[path] = JSON.stringify(value)
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
walk(value as Record<string, unknown>, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(features, '__FEATURES__')
|
||||
return define
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`:
|
||||
*
|
||||
* ```ts
|
||||
* export default buildViteConfig(import.meta.dirname)
|
||||
* ```
|
||||
*
|
||||
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
|
||||
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins).
|
||||
* `@/`/`~/` are handled by {@link layersResolver}, not as plain aliases.
|
||||
* - `__FEATURES__` is defined from the merged `features` for build-time dead-code elimination.
|
||||
*/
|
||||
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
|
||||
return defineConfig(async (env) => {
|
||||
const stack = await resolveLayerStack(appDir, { mode: env.mode })
|
||||
|
||||
// Hooks: register each layer's `hooks` (base-first) + programmatic, then let `layers:resolved`
|
||||
// mutate the stack (merged config / features / layers) before anything reads it.
|
||||
const hooks = createLayerHooks()
|
||||
registerLayerHooks(hooks, stack.layers, options.hooks)
|
||||
await hooks.callHook('layers:resolved', stack)
|
||||
|
||||
const { merged, layers } = stack
|
||||
const roots = layers.map(l => l.srcDir)
|
||||
|
||||
const project = layers[0]! // resolveLayerStack always returns at least the project layer
|
||||
const alias: Record<string, string> = {
|
||||
'~~': project.rootDir,
|
||||
'@@': project.rootDir,
|
||||
}
|
||||
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins).
|
||||
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
|
||||
|
||||
let vite: UserConfig = {
|
||||
resolve: { alias },
|
||||
build: { outDir: options.outDir ?? `dist/${basename(appDir)}` },
|
||||
}
|
||||
|
||||
// Layer fragments: low → high so higher-priority layers override.
|
||||
for (const l of [...layers].reverse()) {
|
||||
const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite
|
||||
if (frag) vite = mergeConfig(vite, frag)
|
||||
}
|
||||
vite = dedupePlugins(vite)
|
||||
|
||||
const plugins: PluginOption[] = [
|
||||
layersResolver({ roots, ...options.resolver }),
|
||||
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
|
||||
configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change
|
||||
featuresRuntimePlugin(merged.features), // dev: supply __FEATURES__ at runtime (define is build-only here)
|
||||
]
|
||||
if (options.tsconfig !== false) {
|
||||
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
|
||||
// and `tsconfig:generate` sees the same handlers).
|
||||
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
|
||||
}
|
||||
|
||||
vite = mergeConfig(vite, {
|
||||
plugins,
|
||||
define: featureDefines(merged.features),
|
||||
})
|
||||
|
||||
if (options.vite) vite = mergeConfig(vite, options.vite)
|
||||
|
||||
// Final escape hatch: let hooks mutate (or replace) the assembled Vite config.
|
||||
const ctx = { config: vite, env, stack }
|
||||
await hooks.callHook('vite:config', ctx)
|
||||
return ctx.config
|
||||
})
|
||||
}
|
||||
|
||||
export { dedupePlugins }
|
||||
@@ -0,0 +1,54 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import sirv from 'sirv'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
/** Recursively list files under a directory (absolute paths). */
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const abs = join(dir, name)
|
||||
if (statSync(abs).isDirectory()) walk(abs, out)
|
||||
else out.push(abs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Layered static assets: each layer may have a `public/` directory, resolved **first-match across
|
||||
* layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`.
|
||||
*
|
||||
* Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in
|
||||
* `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins),
|
||||
* and emits the merged set into the build output (higher layers overwrite lower ones).
|
||||
*
|
||||
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
|
||||
*/
|
||||
export function publicLayersPlugin(publicDirs: string[]): Plugin {
|
||||
const dirs = publicDirs.filter(existsSync) // high → low
|
||||
|
||||
return {
|
||||
name: 'vite-layers:public',
|
||||
config() {
|
||||
// We serve/emit public ourselves, so turn off Vite's single-dir handling.
|
||||
if (dirs.length > 0) return { publicDir: false }
|
||||
},
|
||||
configureServer(server) {
|
||||
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
|
||||
for (const dir of dirs) {
|
||||
server.middlewares.use(sirv(dir, { dev: true, etag: true }))
|
||||
}
|
||||
},
|
||||
generateBundle() {
|
||||
// Build: merge low→high so higher layers overwrite — i.e. first-match-wins by priority.
|
||||
const assets = new Map<string, string>()
|
||||
for (const dir of [...dirs].reverse()) {
|
||||
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
|
||||
}
|
||||
for (const [fileName, abs] of assets) {
|
||||
this.emitFile({ type: 'asset', fileName, source: readFileSync(abs) })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
||||
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
|
||||
|
||||
export interface LayersResolverOptions {
|
||||
/** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */
|
||||
roots: string[]
|
||||
/** Import prefixes treated as layered. Default: `@/`, `~/`. */
|
||||
prefixes?: string[]
|
||||
/** Extensions probed when the id has no explicit, existing file. */
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const isFile = (p: string): boolean => {
|
||||
try {
|
||||
return statSync(p).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
|
||||
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
|
||||
* it probes each source root in priority order and returns the first match.
|
||||
*
|
||||
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
|
||||
* then `<path>/index<ext>`.
|
||||
*
|
||||
* Improvement over Nuxt: **self-skip** gives `super()` semantics. If the first match is the
|
||||
* importing file itself, resolution continues to the next (lower-priority) layer — so an
|
||||
* override at `@/components/Foo.vue` can import `@/components/Foo.vue` to reach the base file.
|
||||
*/
|
||||
export function layersResolver(options: LayersResolverOptions): Plugin {
|
||||
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options
|
||||
|
||||
const probe = (root: string, sub: string): string | null => {
|
||||
const direct = resolve(root, sub)
|
||||
if (isFile(direct)) return direct
|
||||
for (const ext of extensions) {
|
||||
const p = direct + ext
|
||||
if (isFile(p)) return p
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
const p = resolve(direct, `index${ext}`)
|
||||
if (isFile(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots
|
||||
// (high→low priority). Saves the per-import `statSync` storm; self-skip stays correct because the
|
||||
// candidate list is importer-independent (we pick the first candidate that isn't the importer).
|
||||
const cache = new Map<string, string[]>()
|
||||
const candidates = (sub: string): string[] => {
|
||||
const cached = cache.get(sub)
|
||||
if (cached) return cached
|
||||
const list: string[] = []
|
||||
for (const root of roots) {
|
||||
const file = probe(root, sub)
|
||||
if (file) list.push(toPosix(file))
|
||||
}
|
||||
cache.set(sub, list)
|
||||
return list
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-layers:resolve',
|
||||
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
|
||||
configureServer(server) {
|
||||
// A new/removed file can change which layer wins → drop the cache in dev.
|
||||
const clear = () => cache.clear()
|
||||
server.watcher.on('add', clear)
|
||||
server.watcher.on('unlink', clear)
|
||||
server.watcher.on('unlinkDir', clear)
|
||||
},
|
||||
resolveId(id, importer) {
|
||||
const prefix = prefixes.find(p => id.startsWith(p))
|
||||
if (!prefix) return null
|
||||
|
||||
const q = id.indexOf('?')
|
||||
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
|
||||
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
|
||||
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
|
||||
|
||||
for (const file of candidates(sub)) {
|
||||
if (file === self) continue // self-skip → fall through to the base layer (super())
|
||||
return file + query
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { defu } from 'defu'
|
||||
import { type TSConfig, writeTSConfig } from 'pkg-types'
|
||||
import type { Plugin } from 'vite'
|
||||
import { resolveLayerStack } from './config'
|
||||
import { hooksFromStack, type LayerHookable } from './hooks'
|
||||
import type { LayerStack } from './types'
|
||||
|
||||
export type { TSConfig } from 'pkg-types'
|
||||
|
||||
export interface GenerateTsConfigOptions {
|
||||
/**
|
||||
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
|
||||
* wins). Does NOT override the generated `paths`, which always reflect the resolved layer stack.
|
||||
*/
|
||||
tsConfig?: TSConfig
|
||||
/** Extra tsconfig merged over the generated **node** config (for config files). */
|
||||
nodeTsConfig?: TSConfig
|
||||
/** Directory to write into, relative to `appDir`. Default: `.vite-layers`. */
|
||||
outDir?: string
|
||||
/** Reuse an already-resolved stack (avoids a second `resolveLayerStack` per build). Internal. */
|
||||
stack?: LayerStack
|
||||
/** Shared hooks instance; if absent, one is built from the stack's layer hooks. Internal. */
|
||||
hooks?: LayerHookable
|
||||
}
|
||||
|
||||
const toPosix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
|
||||
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(/^([^.])/, './$1') || '.'
|
||||
|
||||
/** A property name that can be written unquoted in a TS type literal. */
|
||||
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
/** Render a JSON-ish value as a TS type literal (boolean/number/string → type, object → recurse). */
|
||||
function tsType(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'readonly unknown[]'
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
return 'boolean'
|
||||
case 'number':
|
||||
return 'number'
|
||||
case 'string':
|
||||
return 'string'
|
||||
case 'object': {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length === 0) return 'Record<string, never>'
|
||||
const body = entries
|
||||
.map(([k, v]) => `${IDENTIFIER_RE.test(k) ? k : JSON.stringify(k)}: ${tsType(v)}`)
|
||||
.join('; ')
|
||||
return `{ ${body} }`
|
||||
}
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a `.d.ts` that types the `__FEATURES__` global from the merged feature flags, so a typo
|
||||
* (`__FEATURES__.biling`) is a compile error instead of a silently-falsy runtime value.
|
||||
*/
|
||||
export function featuresDts(features: Record<string, unknown> = {}): string {
|
||||
return [
|
||||
'// AUTO-GENERATED by vite-layers — do not edit.',
|
||||
'export {}',
|
||||
'declare global {',
|
||||
` const __FEATURES__: ${tsType(features)}`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
|
||||
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
|
||||
target: 'ESNext',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
verbatimModuleSyntax: true,
|
||||
strict: true,
|
||||
noUncheckedIndexedAccess: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
allowImportingTsExtensions: true,
|
||||
noEmit: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults for the node-environment config (`vite.config`/`app.config`): node-side, **no DOM lib**,
|
||||
* **no layered `paths`** (config files don't use `@/`). Mirrors Nuxt's `tsconfig.node.json`.
|
||||
*/
|
||||
const NODE_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
|
||||
...DEFAULT_COMPILER_OPTIONS,
|
||||
lib: ['ESNext'],
|
||||
paths: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the auto-generated tsconfig for an app's layer stack — a framework-agnostic port of Nuxt's
|
||||
* `_generateTypes` (`@nuxt/kit` `packages/kit/src/template.ts`).
|
||||
*
|
||||
* The defining difference: because `@/` and `~/` are *layered* here (first-match across every
|
||||
* layer's `srcDir`, see {@link layersResolver}), `paths['@/*']` is the array of ALL layer srcDirs in
|
||||
* priority order. TypeScript resolves path arrays by first existing file, so `tsc` mirrors the
|
||||
* runtime resolver exactly. (No `baseUrl` — deprecated in TS 6; since TS 5.0 `paths` resolve
|
||||
* relative to the config that defines them, so a consuming tsconfig that `extends` this one
|
||||
* resolves the relative paths from here.)
|
||||
*
|
||||
* Customize via each layer's `app.config.ts` `tsConfig` field (merged across the stack, like Nuxt's
|
||||
* `typescript.tsConfig`) and/or `opts.tsConfig` (highest priority). Both are typed as pkg-types
|
||||
* {@link TSConfig}.
|
||||
*/
|
||||
export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOptions = {}) {
|
||||
const stack = opts.stack ?? (await resolveLayerStack(appDir))
|
||||
const { merged, layers } = stack
|
||||
const genDir = resolve(appDir, opts.outDir ?? '.vite-layers')
|
||||
|
||||
const srcStar = layers.map(l => `${rel(genDir, l.srcDir)}/*`) // [high … low]
|
||||
const projectRoot = layers[0]!.rootDir
|
||||
const paths: Record<string, string[]> = {
|
||||
'@/*': srcStar,
|
||||
'~/*': srcStar,
|
||||
'~~': [rel(genDir, projectRoot)],
|
||||
'@@': [rel(genDir, projectRoot)],
|
||||
'~~/*': [`${rel(genDir, projectRoot)}/*`],
|
||||
'@@/*': [`${rel(genDir, projectRoot)}/*`],
|
||||
}
|
||||
for (const l of layers) {
|
||||
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
|
||||
const star = `#layers/${l.name}/*`
|
||||
if (star in paths) continue
|
||||
paths[`#layers/${l.name}`] = [rel(genDir, l.rootDir)]
|
||||
paths[star] = [`${rel(genDir, l.rootDir)}/*`]
|
||||
}
|
||||
|
||||
const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))]
|
||||
|
||||
// App/client config: layer src trees + the typed __FEATURES__ global. Config files are NOT here —
|
||||
// they belong to the node config below.
|
||||
const base: TSConfig = {
|
||||
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
|
||||
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
|
||||
exclude,
|
||||
}
|
||||
|
||||
// Precedence (defu, first wins): opts.tsConfig → per-layer merged.tsConfig → generated defaults.
|
||||
// `paths` is applied last — it is generated, not overridable.
|
||||
const tsconfig = defu(opts.tsConfig, merged.tsConfig, base) as TSConfig
|
||||
tsconfig.compilerOptions = { ...tsconfig.compilerOptions, paths }
|
||||
|
||||
// Node config: `vite.config`/`app.config` of every layer, node-side typings, no DOM, no paths.
|
||||
const nodeBase: TSConfig = {
|
||||
compilerOptions: { ...NODE_COMPILER_OPTIONS },
|
||||
include: layers.flatMap((l) => {
|
||||
const r = rel(genDir, l.rootDir)
|
||||
return [`${r}/app.config.*`, `${r}/vite.config.*`]
|
||||
}),
|
||||
exclude,
|
||||
}
|
||||
const nodeTsconfig = defu(opts.nodeTsConfig, nodeBase) as TSConfig
|
||||
|
||||
// Escape hatch: let layer/programmatic hooks mutate the generated tsconfigs before they're written.
|
||||
const ctx = { appDir, tsconfig, nodeTsconfig, stack }
|
||||
await (opts.hooks ?? hooksFromStack(layers)).callHook('tsconfig:generate', ctx)
|
||||
|
||||
return {
|
||||
tsconfig: ctx.tsconfig, // a hook may have mutated or replaced it
|
||||
file: resolve(genDir, 'tsconfig.json'),
|
||||
nodeTsconfig: ctx.nodeTsconfig,
|
||||
nodeFile: resolve(genDir, 'tsconfig.node.json'),
|
||||
genDir,
|
||||
dts: featuresDts(merged.features),
|
||||
dtsFile: resolve(genDir, 'features.d.ts'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts}` (tsconfig via pkg-types
|
||||
* `writeTSConfig`). Returns the tsconfig path.
|
||||
*/
|
||||
export async function writeTsConfig(appDir: string, opts?: GenerateTsConfigOptions): Promise<string> {
|
||||
const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile } = await generateTsConfig(appDir, opts)
|
||||
await mkdir(genDir, { recursive: true })
|
||||
await Promise.all([
|
||||
writeTSConfig(file, tsconfig),
|
||||
writeTSConfig(nodeFile, nodeTsconfig),
|
||||
writeFile(dtsFile, dts),
|
||||
])
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that writes the generated tsconfig on `configResolved` (dev + build) — the
|
||||
* framework-agnostic analogue of Nuxt's automatic `prepare:types`.
|
||||
*/
|
||||
export function tsconfigPlugin(appDir: string, opts?: GenerateTsConfigOptions): Plugin {
|
||||
return {
|
||||
name: 'vite-layers:tsconfig',
|
||||
async configResolved() {
|
||||
await writeTsConfig(appDir, opts)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TSConfig } from 'pkg-types'
|
||||
import type { ConfigEnv, UserConfig } from 'vite'
|
||||
import type { LayerHooksConfig } from './hooks'
|
||||
|
||||
/**
|
||||
* A layer's declarative config, authored in `app.config.ts`.
|
||||
* Mirrors the subset of Nuxt's layer config relevant to a framework-agnostic build.
|
||||
*/
|
||||
export interface LayerConfig {
|
||||
/** Explicit layer name; used for the `#layers/<name>` alias. Falls back to the dir basename. */
|
||||
name?: string
|
||||
/** Absolute/relative root dir of the layer. Defaults to the layer's own directory. */
|
||||
rootDir?: string
|
||||
/** Source dir, resolved against `rootDir`. Default: `'src'`. */
|
||||
srcDir?: string
|
||||
/** Layers to extend: relative path, npm package, or git source (resolved by c12). */
|
||||
extends?: string | string[]
|
||||
/** Vite config fragment contributed by this layer (object or env-aware factory). */
|
||||
vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
|
||||
/** Build-time feature flags, exposed to app code as the `__FEATURES__` global. */
|
||||
features?: Record<string, unknown>
|
||||
/**
|
||||
* tsconfig overrides contributed by this layer, merged across the stack into the generated
|
||||
* `.vite-layers/tsconfig.json` (analogue of Nuxt's `typescript.tsConfig`). The generated
|
||||
* `paths` always win. Typed as pkg-types {@link TSConfig}.
|
||||
*/
|
||||
tsConfig?: TSConfig
|
||||
/**
|
||||
* Lifecycle hooks (hookable). Accumulated across layers (base-first), not deep-merged — so
|
||||
* same-name handlers from multiple layers all run. See {@link LayerHooks}.
|
||||
*/
|
||||
hooks?: LayerHooksConfig
|
||||
/** c12 layer metadata; `$meta.name` takes precedence when deriving the layer name. */
|
||||
$meta?: { name?: string }
|
||||
/** Overrides applied when the resolved env (Vite `mode`) is `development`. */
|
||||
$development?: Partial<LayerConfig>
|
||||
/** Overrides applied when the resolved env (Vite `mode`) is `production`. */
|
||||
$production?: Partial<LayerConfig>
|
||||
/** Overrides keyed by env name (Vite `mode`), e.g. `{ staging: { features: {…} } }`. */
|
||||
$env?: Record<string, Partial<LayerConfig>>
|
||||
}
|
||||
|
||||
/** A fully resolved layer in the stack. */
|
||||
export interface Layer {
|
||||
/** Absolute root directory of the layer. */
|
||||
rootDir: string
|
||||
/** Absolute source directory (`rootDir`/`srcDir`). */
|
||||
srcDir: string
|
||||
/** Resolved layer name. */
|
||||
name: string
|
||||
/** The layer's own (unmerged) config. */
|
||||
config: LayerConfig
|
||||
}
|
||||
|
||||
export interface LayerStack {
|
||||
/** Deep-merged config across the whole stack (defu, project wins). */
|
||||
merged: LayerConfig
|
||||
/** Layers ordered high→low priority; `layers[0]` is the project itself. */
|
||||
layers: Layer[]
|
||||
}
|
||||
Reference in New Issue
Block a user