feat: add feature plugin tests and validation for feature flags

This commit is contained in:
2026-06-21 03:14:19 +07:00
parent ecc958c9f0
commit 1ee76faf55
65 changed files with 4992 additions and 415 deletions
+14 -11
View File
@@ -3,18 +3,12 @@ import { loadConfig, type ConfigLayer } from 'c12'
import { createDefu } from 'defu'
import { glob } from 'tinyglobby'
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
import type { Layer, LayerConfig, LayerStack } from './types'
import type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
import { toPosix } from './util'
/** Identity helper for typed `app.config.ts` files. */
export const defineLayerConfig = (config: LayerConfig): LayerConfig => config
/**
* Normalize to forward slashes. c12 returns `cwd` posix-style while node `resolve()` is
* OS-native (backslashes on Windows); paths must be canonicalized before they are compared
* for dedup or emitted into a Vite config (where posix is conventional).
*/
const toPosix = (p: string) => p.replace(/\\/g, '/')
/**
* Port of Nuxt's layer merger: arrays are concatenated rather than replaced.
* (See `@nuxt/kit` `loadNuxtConfig`.)
@@ -59,6 +53,12 @@ export async function resolveLayerStack(
// 2) Cycle-guard [improvement]: terminate the recursion on a repeated source.
const seen = new Set<string>()
// Capture the extends DAG as c12 walks it. c12 consumes (strips) the `extends`/`_extends` keys from
// each resolved layer's config — only the project's survive — so the parent→child edges can't be
// reconstructed from the resolved configs afterwards. The `resolve` hook fires once per extend edge
// (incl. nested, diamond, and auto-scanned `_extends`), with `opts.cwd` = the extending layer's dir.
const edges: LayerEdge[] = []
const { config, layers = [] } = await loadConfig<LayerConfig>({
cwd,
configFile: 'app.config',
@@ -71,8 +71,11 @@ export async function resolveLayerStack(
packageJson: false,
globalRc: false,
merger: merger as (...sources: Array<LayerConfig | null | undefined>) => LayerConfig,
resolve(id, opts) {
const abs = resolve(opts?.cwd ?? cwd, id)
resolve(id, ropts) {
const from = toPosix(withoutTrailingSlash(ropts?.cwd ?? cwd))
const abs = resolve(ropts?.cwd ?? cwd, id)
const to = toPosix(withoutTrailingSlash(abs))
if (to !== from) edges.push({ from, to, source: id }) // skip c12's self-resolution of the root
if (seen.has(abs)) return { config: {}, cwd: abs }
seen.add(abs)
return undefined
@@ -101,5 +104,5 @@ export async function resolveLayerStack(
stack.push({ rootDir, srcDir, name: name ?? basename(rootDir), config: layer.config ?? {} })
}
return { merged: config, layers: stack }
return { merged: config, layers: stack, edges }
}
+18 -61
View File
@@ -1,84 +1,41 @@
import { statSync } from 'node:fs'
import { resolve } from 'node:path'
import MagicString from 'magic-string'
import type { Plugin } from 'vite'
import { toPosix } from './util'
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
const toPosix = (p: string) => p.replace(/\\/g, '/')
const existingConfigFiles = (rootDirs: string[]): Set<string> => {
const files = new Set<string>()
for (const dir of rootDirs) {
for (const ext of CONFIG_EXTENSIONS) {
const file = resolve(dir, `app.config${ext}`)
try {
if (statSync(file).isFile()) files.add(toPosix(file))
} catch {
// not present in this layer
}
}
}
return files
}
/**
* Dev-only plugin: restart the Vite server when any layer's `app.config.*` changes.
*
* `app.config.ts` is loaded out-of-band by c12 (not part of Vite's module graph or config-file
* dependencies), so Vite never restarts on its own when you edit feature flags / layer config — the
* baked `__FEATURES__` `define` and aliases go stale. We watch each resolved layer's config file
* (including layers outside the project root via `watcher.add`) and call `server.restart()`, which
* re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → new `define`.
* `feature()` values baked into transformed modules and the resolved aliases go stale. We watch each
* resolved layer's config file (including layers outside the project root via `watcher.add`) and call
* `server.restart()`, which re-runs `buildViteConfig` → `resolveLayerStack` (c12 reads fresh) → a new
* `featurePlugin` with the updated flag values.
*/
export function configWatchPlugin(rootDirs: string[]): Plugin {
return {
name: 'vite-layers:config-watch',
apply: 'serve',
configureServer(server) {
const files = existingConfigFiles(rootDirs)
if (files.size === 0) return
server.watcher.add([...files]) // ensure extended layers outside the root are watched too
// Every POSSIBLE `app.config.*` path (existing or not), so creating a config in a layer that
// had none — or deleting one — also restarts, not just edits to configs present at startup.
const candidates = new Set<string>()
for (const dir of rootDirs) {
for (const ext of CONFIG_EXTENSIONS) candidates.add(toPosix(resolve(dir, `app.config${ext}`)))
}
if (candidates.size === 0) return
// chokidar watches an absent path for creation too, so `add`/`unlink` fire for a config that
// appears/disappears later (incl. layers outside the project root).
server.watcher.add([...candidates])
const onChange = (file: string) => {
if (!files.has(toPosix(file))) return
const onEvent = (file: string) => {
if (!candidates.has(toPosix(file))) return
server.config.logger.info('[vite-layers] app config changed — restarting…', { timestamp: true })
void server.restart()
}
server.watcher.on('change', onChange)
},
}
}
/** Matches a standalone `__FEATURES__` reference (not a `.__FEATURES__` property access). */
const STANDALONE_FEATURES_RE = /(?<![.\w$])__FEATURES__\b/
/**
* Dev-only plugin: make `__FEATURES__` resolve at runtime in the dev server.
*
* Vite 8 / rolldown-vite does **not** inline user `define` into dev-served source modules (only
* `import.meta.env` is special-cased), so `__FEATURES__` would be an undefined global in dev. For
* production, `define` (with DCE) still does the job; here we prepend a module-local
* `const __FEATURES__ = {…}` to each served module that references the global, so feature flags have
* correct values in dev — and pick up edits after a config-change restart (see {@link configWatchPlugin}).
*
* Only standalone references are handled (not `_ctx.__FEATURES__` from Vue templates — same as
* `define`); gate features in `<script>`, not in template expressions.
*/
export function featuresRuntimePlugin(features: Record<string, unknown> = {}): Plugin {
const json = JSON.stringify(features)
return {
name: 'vite-layers:features-runtime',
apply: 'serve',
transform(code, id) {
if (id.includes('/node_modules/') || !STANDALONE_FEATURES_RE.test(code)) return null
// NOTE: rolldown's *native* magic-string (the transform `meta.magicString` in the rolldown
// docs) is NOT surfaced by Vite plugins — `meta` is `{ inMap, moduleType, ssr }` with no
// `magicString` in dev or build. So we use the npm `magic-string` fallback the rolldown docs
// recommend for non-native hosts; it also produces clean cross-platform sourcemaps.
// Prepend on line 1 (keeps line numbers); module-local const shadows the missing global.
const s = new MagicString(code)
s.prepend(`const __FEATURES__=${json};`)
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
for (const event of ['add', 'change', 'unlink'] as const) server.watcher.on(event, onEvent)
},
}
}
+795
View File
@@ -0,0 +1,795 @@
import { readdirSync, statSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import type { ConfigEnv } from 'vite'
import type {
DevToolsServerCommandInput,
DevToolsViewGroup,
DevToolsViewJsonRender,
JsonRenderElement,
JsonRenderSpec,
PluginWithDevTools,
ViteDevToolsNodeContext,
} from '@vitejs/devtools-kit'
import { flattenFeatures } from './features'
import type { LayeredResolution, ResolveRecord } from './resolve'
import { generateTsConfig, type GenerateTsConfigOptions } from './tsconfig'
import type { LayerStack } from './types'
import { toPosix } from './util'
// ---------------------------------------------------------------------------------------------
// This module imports **only types** from `@vitejs/devtools-kit` — they are erased at emit, so the
// plugin has no runtime dependency on the kit and is fully inert unless the `@vitejs/devtools` hub
// mounts it and calls `setup`. The kit's `defineRpcFunction`/`defineDockEntry`/`defineCommand` are
// pure identity helpers and `register()` accepts plain objects, so we hand-build the (typed) specs.
//
// We import the real kit types (rather than re-declaring a local subset) on purpose: when the hub is
// present it augments `vite`'s `Plugin` with `devtools`, and the real types keep our `setup`
// signature consistent with that augmentation. The kit ships transitively with `@vitejs/devtools`, so
// any project using these panels already has it; type-checking vite-layers needs the kit present
// (an optional peer in the install sense, required in the type-check sense for this raw-source pkg).
// ---------------------------------------------------------------------------------------------
const NS = 'vite-layers'
const GROUP_ID = NS
const PANEL = {
layers: `${NS}:layers`,
features: `${NS}:features`,
resolver: `${NS}:resolver`,
assets: `${NS}:assets`,
} as const
const RPC = {
refresh: `${NS}:refresh`,
resolve: `${NS}:resolve`,
clearLog: `${NS}:clear-log`,
} as const
const ICON = {
group: 'ph:stack-duotone',
layers: 'ph:stack-duotone',
features: 'ph:toggle-right-duotone',
resolver: 'ph:signpost-duotone',
assets: 'ph:images-duotone',
refresh: 'ph:arrows-clockwise-duotone',
} as const
/** Data the devtools panel needs, captured by `buildViteConfig` at config-resolution time. */
export interface LayersDevtoolsData {
/** The app directory (the cwd `resolveLayerStack` was called with). */
appDir: string
/** The Vite env (`command`/`mode`) the config was built for. */
env: ConfigEnv
/** The resolved, in-effect layer stack (after `layers:resolved` hooks). */
stack: LayerStack
/** The live layered resolution — shared with `vite-layers:resolve` so the panel sees real data. */
resolution: LayeredResolution
/** tsconfig generation options, or `false` when autogen is disabled. */
tsconfig: GenerateTsConfigOptions | false
}
// ---------------------------------------------------------------------------------------------
// Snapshot — a plain, serializable view of the stack the spec builders render. Cheap to recompute,
// so `vite-layers:refresh` just rebuilds it (re-walking `public/`, re-reading the resolution log).
// ---------------------------------------------------------------------------------------------
interface LayerRow {
index: number
name: string
project: boolean
extends: string
rootDir: string
srcDir: string
}
interface FeatureRow {
key: string
value: string
type: string
kind: 'leaf' | 'group'
enabled: boolean
}
// These two row shapes are handed to `DataTable` as-is (not re-mapped at the call site), so they
// carry an index signature to satisfy the renderer's `Record<string, unknown>` row type.
interface PublicRow {
path: string
winner: string
shadowedBy: string
[key: string]: unknown
}
interface HookRow {
hook: string
layer: string
[key: string]: unknown
}
type TsconfigInfo =
| { enabled: false }
| { enabled: true; paths: Record<string, string[]>; appJson: string; nodeJson: string; dts: string }
interface Snapshot {
projectName: string
appDir: string
mode: string
command: string
layers: LayerRow[]
mergedTree: Record<string, unknown>
features: FeatureRow[]
rawFeatures: Record<string, unknown>
featureLeafCount: number
featureDisabledCount: number
publicAssets: PublicRow[]
publicLayerCount: number
hooks: HookRow[]
tsconfig: TsconfigInfo
inheritanceTree: string
}
/** Recursively list files under a directory (absolute paths); `[]` if it isn't a directory. */
function walk(dir: string, out: string[] = []): string[] {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return out
}
for (const name of entries) {
const abs = join(dir, name)
// Guard each stat: a broken symlink or a file unlinked between readdir and stat (a real TOCTOU
// window under the dev watcher) throws ENOENT — skip it instead of failing the whole snapshot.
let isDir: boolean
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
if (isDir) walk(abs, out)
else out.push(abs)
}
return out
}
const asArray = (v: unknown): string[] =>
v == null ? [] : (Array.isArray(v) ? v : [v]).map(String)
const TRAILING_SLASHES_RE = /\/+$/
const noTrailing = (p: string) => p.replace(TRAILING_SLASHES_RE, '')
/**
* Render the layer **extends graph** as a box-drawing tree (for a monospace CodeBlock).
*
* The resolved stack is a flat priority order; the *structure* comes from `stack.edges` — the
* parent→child edges captured during resolution (c12 strips the `extends` keys from resolved configs,
* so they can't be read back afterwards). Diamonds (a layer reached via two parents) are drawn once
* and marked `↑ above` on repeat — no infinite recursion. Edges whose target isn't a stack layer (npm
* / git sources) become `(external)` leaves, and any layer never reached from the project is listed
* below so the view stays complete. With no `edges` (a hand-built stack) only the project is drawn.
*/
export function inheritanceTreeText(stack: LayerStack): string {
const { layers, edges = [] } = stack
const byRoot = new Map<string, number>()
layers.forEach((l, i) => byRoot.set(noTrailing(toPosix(l.rootDir)), i))
// Group edges by the (normalized) directory they extend FROM, preserving walk order.
const childEdges = new Map<string, Array<{ index: number } | { external: string }>>()
for (const e of edges) {
const fromKey = noTrailing(toPosix(e.from))
const idx = byRoot.get(noTrailing(toPosix(e.to)))
const list = childEdges.get(fromKey) ?? childEdges.set(fromKey, []).get(fromKey)!
list.push(idx !== undefined ? { index: idx } : { external: e.source })
}
const childrenOf = (i: number) => childEdges.get(noTrailing(toPosix(layers[i]!.rootDir))) ?? []
const lines: string[] = []
const seen = new Set<number>()
const render = (i: number, prefix: string, isLast: boolean, isRoot: boolean) => {
const connector = isRoot ? '' : isLast ? '└── ' : '├── '
const repeated = seen.has(i)
const tag = isRoot ? ' (project · highest priority)' : ''
lines.push(`${prefix}${connector}${layers[i]!.name} #${i}${tag}${repeated ? ' ↑ above' : ''}`)
if (repeated) return
seen.add(i)
const kids = childrenOf(i)
const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ')
kids.forEach((k, ci) => {
const last = ci === kids.length - 1
if ('index' in k) render(k.index, childPrefix, last, false)
else lines.push(`${childPrefix}${last ? '└── ' : '├── '}${k.external} (external)`)
})
}
render(0, '', true, true)
const orphans = layers.map((_, i) => i).filter(i => !seen.has(i))
if (orphans.length) {
lines.push('')
lines.push('not reached via extends (e.g. auto-scanned layers/*):')
for (const i of orphans) lines.push(`${layers[i]!.name} #${i}`)
}
return lines.join('\n')
}
async function collectSnapshot(data: LayersDevtoolsData): Promise<Snapshot> {
const { layers, merged } = data.stack
const layerRows: LayerRow[] = layers.map((l, index) => ({
index,
name: l.name,
project: index === 0,
extends: asArray(l.config.extends).join(', ') || '—',
rootDir: l.rootDir,
srcDir: l.srcDir,
}))
// Features: every dotted path (groups + leaves). Leaves drive DCE; a falsy leaf is the value the
// `feature()` macro folds to `false`, killing its branch + chunk.
const flat = flattenFeatures((merged.features ?? {}) as Record<string, unknown>)
const features: FeatureRow[] = flat.map(([key, value]) => {
const group = value != null && typeof value === 'object'
return {
key,
value: JSON.stringify(value) ?? String(value),
type: Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value,
kind: group ? 'group' : 'leaf',
enabled: Boolean(value),
}
})
const leaves = features.filter(f => f.kind === 'leaf')
// Public assets: walk each layer's `public/` high→low; the first layer to hold a path wins, the
// rest are shadowed — mirrors `publicLayersPlugin`'s first-match-by-priority resolution.
const publicLayers = layers
.map(l => ({ name: l.name, dir: resolve(l.rootDir, 'public') }))
.filter(p => {
try {
return statSync(p.dir).isDirectory()
} catch {
return false
}
})
const byPath = new Map<string, string[]>()
for (const { name, dir } of publicLayers) {
for (const abs of walk(dir)) {
const rel = toPosix(relative(dir, abs))
;(byPath.get(rel) ?? byPath.set(rel, []).get(rel)!).push(name)
}
}
const publicAssets: PublicRow[] = [...byPath.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, names]) => ({
path,
winner: names[0]!,
shadowedBy: names.slice(1).join(', ') || '—',
}))
// Per-layer lifecycle hooks (base-first display order, like registration).
const hooks: HookRow[] = []
for (const l of [...layers].reverse()) {
for (const hook of Object.keys(l.config.hooks ?? {})) hooks.push({ hook, layer: l.name })
}
// Curated merged view for the Tree — omit `vite` (functions/plugins) and `hooks` (functions),
// which aren't serializable and aren't useful as a tree.
const mergedTree: Record<string, unknown> = {
name: merged.name,
extends: merged.extends,
srcDir: merged.srcDir,
features: merged.features ?? {},
tsConfig: merged.tsConfig,
vite: merged.vite ? '[Vite config fragment — see Vite DevTools]' : undefined,
hooks: hooks.length ? hooks.map(h => `${h.hook} (${h.layer})`) : undefined,
}
let tsconfig: TsconfigInfo = { enabled: false }
if (data.tsconfig !== false) {
const opts = typeof data.tsconfig === 'object' ? data.tsconfig : {}
const gen = await generateTsConfig(data.appDir, { ...opts, stack: data.stack })
tsconfig = {
enabled: true,
paths: (gen.tsconfig.compilerOptions?.paths ?? {}) as Record<string, string[]>,
appJson: JSON.stringify(gen.tsconfig, null, 2),
nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2),
dts: gen.dts,
}
}
return {
projectName: layers[0]?.name ?? 'app',
appDir: data.appDir,
mode: data.env.mode,
command: data.env.command,
layers: layerRows,
mergedTree,
features,
rawFeatures: (merged.features ?? {}) as Record<string, unknown>,
featureLeafCount: leaves.length,
featureDisabledCount: leaves.filter(f => !f.enabled).length,
publicAssets,
publicLayerCount: publicLayers.length,
hooks,
tsconfig,
inheritanceTree: inheritanceTreeText(data.stack),
}
}
/** Which layer (by name) owns a resolved file — found by matching the file against layer `srcDir`s. */
function layerOf(stack: LayerStack, file: string | null): string {
if (!file) return '—'
const f = toPosix(file.split('?')[0]!)
for (const l of stack.layers) {
const src = toPosix(l.srcDir)
if (f === src || f.startsWith(`${src}/`)) return l.name
}
return '?'
}
// ---------------------------------------------------------------------------------------------
// Spec builder — a tiny DSL over the flat `{ root, elements }` json-render shape. Each `add*`
// returns the generated element id, so panels compose by nesting calls.
// ---------------------------------------------------------------------------------------------
interface Column {
key: string
label: string
width?: string
}
class Spec {
private readonly elements: Record<string, JsonRenderElement> = {}
private readonly state: Record<string, unknown> = {}
private n = 0
private add(node: JsonRenderElement): string {
const id = `e${this.n++}`
this.elements[id] = node
return id
}
setState(key: string, value: unknown): this {
this.state[key] = value
return this
}
vstack(children: string[], gap = 12, padding?: number): string {
return this.add({ type: 'Stack', props: { direction: 'vertical', gap, padding }, children })
}
hstack(children: string[], props: Record<string, unknown> = {}): string {
return this.add({ type: 'Stack', props: { direction: 'horizontal', gap: 8, align: 'center', ...props }, children })
}
card(title: string, children: string[], collapsible = false): string {
return this.add({ type: 'Card', props: { title, collapsible }, children })
}
text(content: string, variant?: 'heading' | 'body' | 'caption' | 'code'): string {
return this.add({ type: 'Text', props: { content, variant } })
}
badge(text: string, variant: 'default' | 'info' | 'success' | 'warning' | 'error' = 'default', title?: string): string {
return this.add({ type: 'Badge', props: { text, variant, title } })
}
divider(label?: string): string {
return this.add({ type: 'Divider', props: { label } })
}
kvTable(entries: Array<{ key: string; value: string }>, title?: string): string {
return this.add({ type: 'KeyValueTable', props: { title, entries } })
}
dataTable(columns: Column[], rows: Array<Record<string, unknown>>, maxHeight = '360px'): string {
return this.add({ type: 'DataTable', props: { columns, rows, maxHeight } })
}
tree(data: unknown, expandLevel = 1): string {
return this.add({ type: 'Tree', props: { data, expandLevel } })
}
code(code: string, filename?: string, maxHeight = '320px'): string {
return this.add({ type: 'CodeBlock', props: { code, filename, maxHeight } })
}
button(label: string, action: string, opts: { icon?: string; variant?: string; params?: Record<string, unknown> } = {}): string {
return this.add({
type: 'Button',
props: { label, icon: opts.icon, variant: opts.variant ?? 'secondary' },
on: { press: { action, params: opts.params } },
})
}
textInput(stateKey: string, placeholder: string): string {
return this.add({ type: 'TextInput', props: { placeholder, value: { $bindState: `/${stateKey}` } } })
}
build(root: string): JsonRenderSpec {
return { root, elements: this.elements, state: this.state }
}
}
/** A header row: a heading on the left, a Refresh button on the right. */
function header(s: Spec, title: string, subtitle: string): string {
const left = s.vstack([s.text(title, 'heading'), s.text(subtitle, 'caption')], 2)
const refresh = s.button('Refresh', RPC.refresh, { icon: ICON.refresh })
return s.hstack([left, refresh], { justify: 'space-between' })
}
// ---------------------------------------------------------------------------------------------
// Panels
// ---------------------------------------------------------------------------------------------
function buildLayersSpec(snap: Snapshot): JsonRenderSpec {
const s = new Spec()
const sections: string[] = [
header(s, 'Layers', `${snap.layers.length} layers · ${snap.projectName} · ${snap.command}/${snap.mode}`),
]
// Headline visual: the extends graph drawn as a tree (the structure the flat stack flattens away).
sections.push(
s.card('Inheritance (extends graph)', [
s.code(snap.inheritanceTree, 'extends graph'),
s.text('Reconstructed from each layers extends. Diamonds drawn once (↑ above); external (npm/git) sources marked.', 'caption'),
]),
)
sections.push(
s.card('Layer stack (high → low priority)', [
s.dataTable(
[
{ key: 'index', label: '#', width: '36px' },
{ key: 'name', label: 'Name' },
{ key: 'role', label: 'Role', width: '90px' },
{ key: 'extends', label: 'Extends' },
{ key: 'srcDir', label: 'srcDir' },
],
snap.layers.map(l => ({
index: l.index,
name: l.name,
role: l.project ? 'project' : 'layer',
extends: l.extends,
srcDir: l.srcDir,
})),
),
s.text('layers[0] is the project (highest priority); collisions resolve to the smaller index.', 'caption'),
]),
)
sections.push(s.card('Merged config', [s.tree(prune(snap.mergedTree), 2)], true))
if (snap.hooks.length) {
sections.push(
s.card(
'Lifecycle hooks',
[
s.dataTable(
[
{ key: 'hook', label: 'Hook' },
{ key: 'layer', label: 'Declared by' },
],
snap.hooks,
'200px',
),
s.text('Hooks run serially, base layer first.', 'caption'),
],
true,
),
)
}
return s.build(s.vstack(sections, 14, 12))
}
function buildFeaturesSpec(snap: Snapshot): JsonRenderSpec {
const s = new Spec()
const sections: string[] = [
header(
s,
'Features',
`${snap.featureLeafCount} flags · ${snap.featureDisabledCount} disabled (dead-code eliminated)`,
),
]
if (snap.features.length === 0) {
sections.push(s.text('No feature flags defined in any layer.', 'caption'))
} else {
sections.push(
s.card('Flags (merged, high → low priority)', [
s.dataTable(
[
{ key: 'status', label: '', width: '30px' },
{ key: 'key', label: 'Key' },
{ key: 'value', label: 'Value' },
{ key: 'type', label: 'Type', width: '70px' },
{ key: 'dce', label: 'feature()', width: '150px' },
],
snap.features.map(f => ({
status: f.kind === 'group' ? '▸' : f.enabled ? '●' : '○',
key: f.key,
value: f.value,
type: f.type,
dce:
f.kind === 'group'
? '(group)'
: f.enabled
? 'kept'
: 'branch eliminated',
})),
),
]),
)
sections.push(s.card('Raw feature tree', [s.tree(snap.rawFeatures, 3)], true))
}
sections.push(
s.card(
'About dead-code elimination',
[
s.text(
"feature('key') is replaced by the flag's literal at compile time (dev + build alike). " +
'A disabled flag folds to false, so its branch — and any import() inside it — is statically ' +
'dead and the chunk is never emitted. An unknown key fails the build.',
'caption',
),
],
true,
),
)
return s.build(s.vstack(sections, 14, 12))
}
interface ResolveResult {
id: string
sub: string
query: string
candidates: string[]
error?: string
}
function buildResolverSpec(data: LayersDevtoolsData, query: string, result: ResolveResult | null): JsonRenderSpec {
const s = new Spec()
s.setState('query', query)
const sections: string[] = [
s.vstack([s.text('Resolver', 'heading'), s.text(`Prefixes: ${data.resolution.prefixes.join(' ')}`, 'caption')], 2),
]
// Playground: type a layered id, resolve it across the stack.
const input = s.textInput('query', 'e.g. @/components/AppHeader.vue')
const go = s.button('Resolve', RPC.resolve, { icon: ICON.resolver, variant: 'primary', params: { id: { $state: '/query' } } })
sections.push(s.card('Playground', [s.hstack([input, go]), ...resolveResultEls(s, data, result)]))
// Live log of real @/ ~/ resolutions seen this session.
const records = data.resolution.records()
const logChildren: string[] = [
s.hstack([s.text(`Live resolutions (${records.length})`, 'body'), s.button('Clear', RPC.clearLog, { icon: 'ph:eraser-duotone' })], {
justify: 'space-between',
}),
]
if (records.length === 0) {
logChildren.push(s.text('No layered imports resolved yet — load the app to populate this.', 'caption'))
} else {
logChildren.push(s.dataTable(
[
{ key: 'id', label: 'Import' },
{ key: 'resolves', label: 'Resolves to (layer)', width: '150px' },
{ key: 'via', label: 'Via', width: '120px' },
{ key: 'n', label: '#cand', width: '60px' },
],
records.map(r => ({
id: r.id,
resolves: layerOf(data.stack, r.resolved),
via: recordVia(r),
n: r.candidates.length,
})),
'300px',
))
}
sections.push(s.card('Live log', logChildren))
return s.build(s.vstack(sections, 14, 12))
}
/** Describe how a record resolved: a normal import, a `super()` self-import, or unresolved. */
function recordVia(r: ResolveRecord): string {
if (r.resolved === null) return 'unresolved'
if (r.selfIndex < 0) return 'top match'
return `super() #${r.selfIndex + 1}`
}
function resolveResultEls(s: Spec, data: LayersDevtoolsData, result: ResolveResult | null): string[] {
if (!result) return [s.text('Enter a layered import above and press Resolve.', 'caption')]
if (result.error) return [s.badge(result.error, 'error')]
if (result.candidates.length === 0) {
return [s.badge(`No file matches "${result.id}" in any layer.`, 'warning')]
}
const winner = result.candidates[0]!
return [
s.hstack([s.text('Resolves to', 'caption'), s.badge(layerOf(data.stack, winner), 'success', winner)]),
s.dataTable(
[
{ key: 'pri', label: '#', width: '36px' },
{ key: 'status', label: '', width: '90px' },
{ key: 'layer', label: 'Layer', width: '110px' },
{ key: 'file', label: 'File' },
],
result.candidates.map((file, i) => ({
pri: i,
status: i === 0 ? 'winner' : 'shadowed',
layer: layerOf(data.stack, file),
file,
})),
'240px',
),
s.text('A self-import (an override importing its own path) would super()-skip to the next row down.', 'caption'),
]
}
function buildAssetsSpec(snap: Snapshot): JsonRenderSpec {
const s = new Spec()
const sections: string[] = [
header(s, 'Public & TS', `${snap.publicAssets.length} assets across ${snap.publicLayerCount} public/ dirs`),
]
const publicChildren: string[] =
snap.publicAssets.length === 0
? [s.text('No layer has a public/ directory.', 'caption')]
: [
s.dataTable(
[
{ key: 'path', label: 'Asset' },
{ key: 'winner', label: 'Served from', width: '120px' },
{ key: 'shadowedBy', label: 'Shadows', width: '140px' },
],
snap.publicAssets,
'260px',
),
s.text('Higher-priority layers win; the winner is served in dev and emitted to the build output.', 'caption'),
]
sections.push(s.card('Layered public/ assets', publicChildren))
if (snap.tsconfig.enabled) {
const ts = snap.tsconfig
sections.push(
s.card(
'Generated tsconfig paths',
[
s.kvTable(
Object.entries(ts.paths).map(([key, value]) => ({ key, value: value.join(' • ') })),
),
s.text('@/ and ~/ map to every layer srcDir in priority order — tsc mirrors the runtime resolver.', 'caption'),
],
),
)
sections.push(s.card('.vite-layers/tsconfig.json', [s.code(ts.appJson, 'tsconfig.json')], true))
sections.push(s.card('.vite-layers/tsconfig.node.json', [s.code(ts.nodeJson, 'tsconfig.node.json')], true))
sections.push(s.card('.vite-layers/features.d.ts', [s.code(ts.dts, 'features.d.ts')], true))
} else {
sections.push(s.card('TypeScript', [s.text('tsconfig autogeneration is disabled (tsconfig: false).', 'caption')]))
}
return s.build(s.vstack(sections, 14, 12))
}
/** Drop `undefined` values so the merged-config Tree stays tidy. */
function prune(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v
return out
}
// ---------------------------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------------------------
/** Build an `action`-type RPC definition. `register` takes the plain object (the kit's
* `defineRpcFunction` is identity); the parameter type is a broad conditional, so cast once here. */
type RpcDefinition = Parameters<ViteDevToolsNodeContext['rpc']['register']>[0]
const action = (name: string, handler: (params?: Record<string, unknown>) => void | Promise<void>): RpcDefinition =>
({ name, type: 'action', setup: () => ({ handler }) }) as unknown as RpcDefinition
/**
* Run the layered resolver against a single id, for the playground. Uses `parse` + `candidates`
* directly (not `resolveId`) so a manual query doesn't pollute the live log, and so we can show the
* full candidate stack rather than just the winner.
*/
function runResolve(data: LayersDevtoolsData, rawId: unknown): ResolveResult {
const id = String(rawId ?? '').trim()
const parsed = data.resolution.parse(id)
if (!id) return { id, sub: '', query: '', candidates: [], error: 'Enter an import id.' }
if (!parsed) {
return {
id,
sub: '',
query: '',
candidates: [],
error: `Not a layered id — must start with one of: ${data.resolution.prefixes.join(', ')}`,
}
}
return { id, sub: parsed.sub, query: parsed.query, candidates: data.resolution.candidates(parsed.sub) }
}
/**
* The vite-layers DevTools integration: four json-render panels (Layers, Features, Resolver, Public &
* TS) grouped under a single dock button, plus a refresh command and an init message. Server-rendered
* JSON specs — no client bundle, keeping vite-layers buildless. Inert unless the `@vitejs/devtools`
* hub mounts it; `buildViteConfig` attaches it by default (disable with `devtools: false`).
*/
export function layersDevtoolsPlugin(data: LayersDevtoolsData): PluginWithDevTools {
return {
name: 'vite-layers:devtools',
devtools: {
async setup(ctx: ViteDevToolsNodeContext) {
let snap = await collectSnapshot(data)
let lastQuery = ''
let lastResult: ResolveResult | null = null
const layersUi = ctx.createJsonRenderer(buildLayersSpec(snap))
const featuresUi = ctx.createJsonRenderer(buildFeaturesSpec(snap))
const resolverUi = ctx.createJsonRenderer(buildResolverSpec(data, lastQuery, lastResult))
const assetsUi = ctx.createJsonRenderer(buildAssetsSpec(snap))
// A single dock button collapsing the four panels (orphan-tolerant: if the host doesn't
// render groups, the entries fall back to top-level — no loss of access).
ctx.docks.register({ id: GROUP_ID, type: 'group', title: 'vite-layers', icon: ICON.group, category: 'app' } satisfies DevToolsViewGroup)
const entry = (id: string, title: string, icon: string, ui: typeof layersUi, order: number) => {
// Typed as the full entry interface (not the narrow literal) so the returned handle's
// `update(patch)` accepts base-entry fields like `badge`.
const view: DevToolsViewJsonRender = {
id,
title,
icon,
type: 'json-render',
ui,
groupId: GROUP_ID,
category: 'app',
defaultOrder: order,
}
return ctx.docks.register(view)
}
entry(PANEL.layers, 'Layers', ICON.layers, layersUi, 40)
entry(PANEL.resolver, 'Resolver', ICON.resolver, resolverUi, 30)
entry(PANEL.assets, 'Public & TS', ICON.assets, assetsUi, 20)
const featuresEntry = entry(PANEL.features, 'Features', ICON.features, featuresUi, 35)
const featuresBadge = () => (snap.featureDisabledCount > 0 ? String(snap.featureDisabledCount) : undefined)
featuresEntry.update({ badge: featuresBadge() })
const refresh = async () => {
snap = await collectSnapshot(data)
await Promise.all([
layersUi.updateSpec(buildLayersSpec(snap)),
featuresUi.updateSpec(buildFeaturesSpec(snap)),
assetsUi.updateSpec(buildAssetsSpec(snap)),
resolverUi.updateSpec(buildResolverSpec(data, lastQuery, lastResult)),
])
featuresEntry.update({ badge: featuresBadge() })
}
ctx.rpc.register(action(RPC.refresh, refresh))
ctx.rpc.register(action(RPC.resolve, async (params) => {
lastResult = runResolve(data, params?.id)
lastQuery = lastResult.id
await resolverUi.updateSpec(buildResolverSpec(data, lastQuery, lastResult))
}))
ctx.rpc.register(action(RPC.clearLog, async () => {
data.resolution.clearRecords()
await resolverUi.updateSpec(buildResolverSpec(data, lastQuery, lastResult))
}))
ctx.commands.register({
id: RPC.refresh,
title: 'vite-layers: Refresh panels',
icon: ICON.refresh,
handler: refresh,
} satisfies DevToolsServerCommandInput)
void ctx.messages.add({
id: `${NS}:ready`,
message: `vite-layers: ${snap.layers.length} layers, ${snap.featureLeafCount - snap.featureDisabledCount}/${snap.featureLeafCount} features enabled`,
level: 'info',
category: NS,
})
},
},
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Public entry for the build-time feature macro. Import it as `#feature` (vite-layers
* auto-registers the alias and the tsconfig `paths` entry) or as `vite-layers/feature`:
*
* ```ts
* import { feature } from '#feature'
*
* const routes = [
* { path: '/', component: () => import('@/pages/Home') },
* feature('billing') && { path: '/billing', component: () => import('@/pages/Billing') },
* ].filter(Boolean)
* ```
*
* `feature('billing')` is replaced by the flag's literal value at compile time — **identically in
* dev and build** — so a disabled branch (and any `import()` inside it) is statically dead and is
* dropped from the bundle. The rules below are enforced: a violation fails the build (in dev and
* build alike), it never silently ships.
*
* - the argument must be a string literal: `feature('billing')`, never `feature(name)`;
* - call it directly — no aliasing (`const f = feature`), destructuring, or passing it as a value;
* - the key must exist in the merged `features` (a typo is also a TypeScript error).
*
* Nested flags are addressed with a dotted key: `feature('payments.stripe')`.
*
* This module has no runtime: every call is compiled away. The stub below only throws if a call
* survives — i.e. the vite-layers plugin did not run on this module.
*/
/** Augmented by the generated `.vite-layers/features.d.ts` with the project's flags + literal types. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LayerFeatures {}
type FeatureKey = [keyof LayerFeatures] extends [never] ? string : keyof LayerFeatures
export function feature<K extends FeatureKey>(
key: K,
): K extends keyof LayerFeatures ? LayerFeatures[K] : unknown
export function feature(key: string): unknown {
throw new Error(
`vite-layers: feature(${JSON.stringify(key)}) was not compiled away. ` +
'Make sure the vite-layers plugin is active and call feature() directly with a string-literal key.',
)
}
+479
View File
@@ -0,0 +1,479 @@
import MagicString from 'magic-string'
import { parseSync } from 'oxc-parser'
import type { Plugin } from 'vite'
/**
* The reserved import specifier for the feature macro. vite-layers auto-registers this as a Vite
* alias (→ `src/feature.ts`) and a tsconfig `paths` entry, so consumers need no extra config.
* `vite-layers/feature` is also accepted (the published subpath export) for tooling that bypasses
* the alias.
*/
export const FEATURE_MODULE = '#feature'
const FEATURE_SPECIFIERS = new Set([FEATURE_MODULE, 'vite-layers/feature'])
/** Matches an import/export `from '#feature'|'vite-layers/feature'` clause. Used to decide whether a
* parse failure must fail the build (the module really uses the macro) rather than be skipped. */
const FEATURE_FROM_RE = /\bfrom\s*['"](?:#feature|vite-layers\/feature)['"]/
/** Code-filter regex for the rolldown `transform` hook filter (see {@link featurePlugin}). A superset
* of "actually imports the macro" — it just gates the JS round-trip; the handler decides precisely. */
const MACRO_CODE_RE = /#feature|vite-layers\/feature/
// ---------------------------------------------------------------------------------------------
// Feature tree helpers (shared by the transform and the type generator so they never disagree).
// ---------------------------------------------------------------------------------------------
/**
* Flatten a (possibly nested) feature object into every dotted path — both intermediate objects and
* leaves — paired with its value. `{ payments: { stripe: true } }` →
* `[['payments', {stripe:true}], ['payments.stripe', true]]`. The transform resolves a `feature()`
* key by exact lookup here, and the type generator emits one interface member per entry, so the
* accepted keys and the substituted values are guaranteed to match.
*/
export function flattenFeatures(features: Record<string, unknown>): Array<[string, unknown]> {
const out: Array<[string, unknown]> = []
const walk = (obj: Record<string, unknown>, prefix: string) => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
out.push([key, v])
if (v && typeof v === 'object' && !Array.isArray(v)) walk(v as Record<string, unknown>, key)
}
}
walk(features, '')
return out
}
const isPlainObject = (v: unknown): boolean => {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
const proto = Object.getPrototypeOf(v)
return proto === Object.prototype || proto === null
}
/**
* Returns a human description if `v` is NOT a JSON-like value the macro can fold into source (and
* the type generator into a literal type), else `null`. Recurses arrays/plain objects. Rejects
* bigint (JSON.stringify throws), functions/symbols (not serializable), non-finite numbers
* (`NaN`/`Infinity` → invalid TS + JSON `null`), and non-plain objects (Date/Map/RegExp/… would be
* coerced or crash). Keeping this strict means a bad flag fails fast with a clear message instead of
* crashing the transform or silently shipping a wrong value.
*/
function unsupportedValue(v: unknown): string | null {
if (v === null || v === undefined) return null
if (typeof v === 'boolean' || typeof v === 'string') return null
if (typeof v === 'number') return Number.isFinite(v) ? null : `non-finite number (${v})`
if (typeof v === 'bigint') return 'bigint'
if (typeof v === 'function') return 'function'
if (typeof v === 'symbol') return 'symbol'
if (Array.isArray(v)) {
for (const el of v) {
const bad = unsupportedValue(el)
if (bad) return bad
}
return null
}
if (isPlainObject(v)) {
for (const val of Object.values(v as Record<string, unknown>)) {
const bad = unsupportedValue(val)
if (bad) return bad
}
return null
}
return `non-plain object (${Object.prototype.toString.call(v)})`
}
/**
* Flatten + validate the feature tree. Throws (clear, fail-fast) on a dotted-key collision (an
* explicit `'a.b'` key clashing with a nested `a.b` path — they would produce a duplicate `.d.ts`
* member and an order-dependent wrong substitution) or an unsupported value type. Shared by the
* transform and the type generator so both reject the same inputs identically.
*/
export function validateFeatures(features: Record<string, unknown>): Array<[string, unknown]> {
const flat = flattenFeatures(features)
const seen = new Set<string>()
for (const [key, value] of flat) {
if (seen.has(key)) {
throw new Error(
`vite-layers: feature flag key '${key}' is defined twice — an explicit dotted key and a nested ` +
`path collide. Use one form, not both.`,
)
}
seen.add(key)
const bad = unsupportedValue(value)
if (bad) {
throw new Error(
`vite-layers: feature flag '${key}' has an unsupported value type (${bad}). Flags must be ` +
`JSON-like: boolean, finite number, string, null, plain object, or array of those.`,
)
}
}
return flat
}
/** A property name that can be written unquoted in a TS type literal / object key. */
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
const tsKey = (k: string) => (IDENTIFIER_RE.test(k) ? k : JSON.stringify(k))
/**
* Render a value as a TS **literal** type (not the widened base type): `false`, `2`, `"app"`,
* `readonly [...]`, `{ … }`. Emitting the literal makes the macro's return type the exact value the
* transform substitutes, so editors show the real flag value and `keyof` typo-checks the key.
*/
function tsType(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (Array.isArray(value)) {
return value.length ? `readonly [${value.map(tsType).join(', ')}]` : 'readonly []'
}
switch (typeof value) {
case 'boolean':
return value ? 'true' : 'false'
case 'number':
return String(value)
case 'string':
return JSON.stringify(value)
case 'object': {
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, never>'
return `{ ${entries.map(([k, v]) => `${tsKey(k)}: ${tsType(v)}`).join('; ')} }`
}
default:
return 'unknown'
}
}
/**
* Generate the `.d.ts` that augments {@link LayerFeatures} so `feature('key')` is typed with the
* flag's literal value and an unknown key (`feature('biling')`) is a compile error. The augmentation
* targets the `#feature` module, which vite-layers maps to `src/feature.ts` via the generated
* tsconfig `paths`.
*/
export function featuresDts(features: Record<string, unknown> = {}): string {
const members = validateFeatures(features).map(([k, v]) => ` ${tsKey(k)}: ${tsType(v)}`)
return [
'// AUTO-GENERATED by vite-layers — do not edit.',
`import '${FEATURE_MODULE}'`,
'',
`declare module '${FEATURE_MODULE}' {`,
' interface LayerFeatures {',
...members,
' }',
'}',
'',
].join('\n')
}
// ---------------------------------------------------------------------------------------------
// The transform: replace `feature('key')` with a literal, fail the build on any other use.
// ---------------------------------------------------------------------------------------------
/** `?…&lang.<ext>` query that Vue/Vite append to SFC sub-modules. Module-scope (not re-created per call). */
const LANG_QUERY_RE = /[?&]lang\.(\w+)/
/** Pick the dialect for oxc from the module id (handles `.vue?…&lang.tsx` query ids). */
function langFromId(id: string): 'js' | 'jsx' | 'ts' | 'tsx' {
const queryLang = id.match(LANG_QUERY_RE)?.[1]
const clean = id.split('?', 1)[0]!
const ext = queryLang ?? clean.slice(clean.lastIndexOf('.') + 1)
if (ext === 'tsx') return 'tsx'
if (ext === 'jsx') return 'jsx'
if (ext === 'js' || ext === 'mjs' || ext === 'cjs') return 'js'
// .ts/.mts/.cts and anything unknown → TS (a superset; the common case for app code).
return 'ts'
}
type AnyNode = { type: string; start: number; end: number } & Record<string, unknown>
const CHILD_SKIP = new Set(['type', 'start', 'end', 'range', 'loc'])
/** Iterate a node's child AST nodes (oxc emits an ESTree-shaped tree). */
function eachChild(node: AnyNode, fn: (child: AnyNode) => void) {
for (const key in node) {
if (CHILD_SKIP.has(key)) continue
const child = node[key]
if (Array.isArray(child)) {
for (const c of child) if (c && typeof (c as AnyNode).type === 'string') fn(c as AnyNode)
} else if (child && typeof (child as AnyNode).type === 'string') {
fn(child as AnyNode)
}
}
}
/** Collect the names bound by a binding pattern (Identifier / Object / Array / default / rest). */
function patternNames(node: AnyNode | null | undefined, add: (name: string) => void): void {
if (!node || typeof node.type !== 'string') return
switch (node.type) {
case 'Identifier':
add(node.name as string)
break
case 'ObjectPattern':
for (const p of (node.properties as AnyNode[]) ?? []) {
patternNames((p.type === 'RestElement' ? p.argument : p.value) as AnyNode, add)
}
break
case 'ArrayPattern':
for (const el of (node.elements as (AnyNode | null)[]) ?? []) patternNames(el, add)
break
case 'AssignmentPattern':
patternNames(node.left as AnyNode, add)
break
case 'RestElement':
patternNames(node.argument as AnyNode, add)
break
}
}
/** Lexical (block-scoped) bindings declared directly in a statement list: let/const/class/function. */
function collectLexical(stmts: AnyNode[], add: (n: string) => void): void {
for (const st of stmts ?? []) {
if (st.type === 'VariableDeclaration' && st.kind !== 'var') {
for (const d of st.declarations as AnyNode[]) patternNames(d.id as AnyNode, add)
} else if ((st.type === 'FunctionDeclaration' || st.type === 'ClassDeclaration') && st.id) {
add((st.id as AnyNode).name as string)
}
}
}
/** Function-scoped bindings hoisted in a body: `var` (at any depth) + nested function-decl names. */
function collectHoisted(stmts: AnyNode[], add: (n: string) => void): void {
const visit = (node: AnyNode) => {
const t = node.type
if (t === 'FunctionDeclaration') {
if (node.id) add((node.id as AnyNode).name as string)
return // its body is a nested scope
}
if (t === 'FunctionExpression' || t === 'ArrowFunctionExpression' || t === 'ClassDeclaration' || t === 'ClassExpression') {
return // nested scope — its vars belong there
}
if (t === 'VariableDeclaration') {
if (node.kind === 'var') for (const d of node.declarations as AnyNode[]) patternNames(d.id as AnyNode, add)
return
}
eachChild(node, visit)
}
for (const s of stmts ?? []) visit(s)
}
const SCOPE_NODES = new Set([
'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression',
'BlockStatement', 'StaticBlock', 'CatchClause',
'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement',
])
/** The subset of `locals` (macro binding names) that this scope node re-binds, shadowing the import. */
function scopeBindings(node: AnyNode, locals: Set<string>): Set<string> {
const bound = new Set<string>()
const add = (n: string) => {
if (locals.has(n)) bound.add(n)
}
const t = node.type
if (t === 'FunctionDeclaration' || t === 'FunctionExpression' || t === 'ArrowFunctionExpression') {
for (const p of (node.params as AnyNode[]) ?? []) patternNames(p, add)
if (t === 'FunctionExpression' && node.id) add((node.id as AnyNode).name as string)
const body = node.body as AnyNode | undefined
if (body?.type === 'BlockStatement') collectHoisted(body.body as AnyNode[], add)
} else if (t === 'CatchClause') {
patternNames(node.param as AnyNode, add)
} else if (t === 'BlockStatement' || t === 'StaticBlock') {
collectLexical(node.body as AnyNode[], add)
} else if (t === 'ForStatement' || t === 'ForInStatement' || t === 'ForOfStatement') {
const head = (t === 'ForStatement' ? node.init : node.left) as AnyNode | null
if (head?.type === 'VariableDeclaration' && head.kind !== 'var') {
for (const d of head.declarations as AnyNode[]) patternNames(d.id as AnyNode, add)
}
} else if (t === 'SwitchStatement') {
for (const c of (node.cases as AnyNode[]) ?? []) collectLexical(c.consequent as AnyNode[], add)
}
return bound
}
/** Extract a string key from a `feature(arg)` argument — a plain string literal or a `\`literal\``. */
function stringKey(arg: AnyNode | undefined): string | undefined {
if (!arg) return undefined
if ((arg.type === 'Literal' || arg.type === 'StringLiteral') && typeof arg.value === 'string') {
return arg.value
}
if (arg.type === 'TemplateLiteral') {
const exprs = arg.expressions as unknown[]
const quasis = arg.quasis as Array<{ value: { cooked?: string } }>
// A single static chunk with a valid cooked value; an invalid escape (`\unicode`) makes cooked
// null → treat as not-a-string-literal so it routes to the clear "string-literal key" error.
if (exprs.length === 0 && quasis.length === 1 && typeof quasis[0]!.value.cooked === 'string') {
return quasis[0]!.value.cooked
}
}
return undefined
}
/** A primitive substitutes bare; an object/array is parenthesized so it is always an expression. */
function literalOf(value: unknown): string {
if (value === undefined) return 'undefined'
if (value !== null && typeof value === 'object') return `(${JSON.stringify(value)})`
return JSON.stringify(value)
}
const isImportSource = (node: AnyNode, sources: Set<string>): boolean => {
const src = node.source as { value?: unknown } | undefined
return typeof src?.value === 'string' && sources.has(src.value)
}
/**
* Build-time feature flags via the `feature('key')` macro — one mechanism for dev **and** build.
*
* The transform parses every module that imports `feature` (from `#feature` / `vite-layers/feature`),
* replaces each `feature('key')` call with the flag's literal value, and removes the now-unused
* import. Replacing a disabled flag's call with `false` lets Rollup/rolldown tree-shake the dead
* branch — including any `import()` inside it — so the chunk is never emitted.
*
* Anything other than a direct call with a known string-literal key (aliasing, destructuring,
* dynamic key, unknown key) is a **hard error** via `this.error`, surfaced with a code frame in dev
* (browser overlay + terminal) and as a failed build — the misuse can never silently ship. Because
* the same substitution runs in dev, dev is a faithful oracle for the build result.
*
* @param features the merged feature flags (high→low layer priority already applied).
*/
export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
const flat = new Map(validateFeatures(features))
return {
name: 'vite-layers:features',
// `enforce: 'post'` so we always run *after* every framework/TS transform (a Vue SFC's
// `<script setup>` compiled to JS, JSX→JS, TS→JS) — never on raw, unparseable `.vue`/JSX source —
// and *before* Vite's import-analysis rewrites specifiers, so the macro import is still
// `#feature`/`vite-layers/feature`. This makes the pass independent of plugin array order: the
// old no-enforce version could run before `@vitejs/plugin-vue`, fail to parse the raw SFC, and
// silently skip the `feature()` calls in `<script setup>` — exactly the silent miss this avoids.
enforce: 'post',
// Hook filter (rolldown): the bundler only calls this transform for non-node_modules modules whose
// code references the macro module — every other module skips the JS round-trip entirely.
// https://rolldown.rs/in-depth/why-plugin-hook-filter . The handler repeats the guards so it stays
// correct on hosts that don't apply the filter (plain Rollup / older dev pipelines).
transform: {
filter: { id: { exclude: /node_modules/ }, code: MACRO_CODE_RE },
handler(code, id) {
if (id.includes('/node_modules/')) return null
if (!code.includes(FEATURE_MODULE) && !code.includes('vite-layers/feature')) return null
// A real macro module that fails to parse must NEVER be skipped silently — its feature() calls
// would ship uncompiled. oxc reports syntax errors in `errors` (it does not throw) and yields an
// empty/partial body, which otherwise looks like "no macro here". So: when the module references
// `#feature` in a from-clause, any parse failure is a hard build error; if `#feature` only shows
// up in a string/comment, stay out of the way and let the rest of the pipeline proceed.
let result: ReturnType<typeof parseSync>
try {
result = parseSync(id.split('?', 1)[0]!, code, { sourceType: 'module', lang: langFromId(id) })
} catch (err) {
if (FEATURE_FROM_RE.test(code)) {
this.error(`vite-layers: could not parse ${id} to compile its feature() calls — ${(err as Error)?.message ?? err}`)
}
return null
}
if (result.errors?.length && FEATURE_FROM_RE.test(code)) {
this.error(`vite-layers: ${id} has syntax errors; cannot safely compile its feature() calls — ${result.errors[0]?.message ?? ''}`)
}
const program = result.program as unknown as AnyNode
// Pass 1: collect the local binding name(s) imported from our module, and the import nodes.
const importDecls: AnyNode[] = []
const locals = new Set<string>()
for (const node of program.body as AnyNode[]) {
if (node.type === 'ImportDeclaration' && isImportSource(node, FEATURE_SPECIFIERS)) {
if (node.importKind === 'type') continue // `import type { feature }` — fully erased, ignore
importDecls.push(node)
for (const spec of node.specifiers as AnyNode[]) {
if (spec.importKind === 'type') continue // `import { type feature }` — erased
const imported = spec.imported as { name?: string; value?: string } | undefined
if (spec.type === 'ImportSpecifier' && (imported?.name ?? imported?.value) === 'feature') {
locals.add((spec.local as { name: string }).name)
} else if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
// A default/namespace import can only be used via dynamic property access, which the
// transform cannot fold — fail the build now rather than letting it throw at runtime.
this.error(
`vite-layers: import the named { feature } macro from '${FEATURE_MODULE}' — ` +
'default and namespace imports are not supported (they defeat dead-code elimination).',
spec.start,
)
}
}
} else if (
(node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') &&
isImportSource(node, FEATURE_SPECIFIERS)
) {
this.error('vite-layers: re-exporting the `feature` macro is not supported — import and call it directly.', node.start)
}
}
if (locals.size === 0) return null
// Pass 2: every reference to the binding (that isn't shadowed by a local of the same name)
// must be a direct `feature('known-key')` call; anything else is a hard error.
const s = new MagicString(code)
const edits: Array<[number, number, string]> = []
const handleRef = (node: AnyNode, parent: AnyNode | null) => {
if (parent) {
// Binding/declaration positions and non-reference uses of the name — not macro calls.
if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportNamespaceSpecifier') return
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
if (parent.type === 'Property' && parent.key === node && !parent.computed && !parent.shorthand) return
if ((parent.type === 'PropertyDefinition' || parent.type === 'MethodDefinition') && parent.key === node && !parent.computed) return
// `feature:` labels / `break feature` — not references.
if ((parent.type === 'LabeledStatement' || parent.type === 'BreakStatement' || parent.type === 'ContinueStatement') && parent.label === node) return
// TS type positions (`typeof feature`, `feature` as a type) are erased and never affect DCE.
if (parent.type === 'TSTypeQuery' || parent.type === 'TSTypeReference' || parent.type === 'TSQualifiedName') return
}
if (parent && parent.type === 'CallExpression' && parent.callee === node && !parent.optional) {
const args = parent.arguments as AnyNode[]
const key = args.length === 1 ? stringKey(args[0]) : undefined
if (key === undefined) {
this.error("vite-layers: feature() takes a single string-literal key, e.g. feature('billing').", node.start)
}
if (!flat.has(key)) {
const known = [...flat.keys()].map(k => `'${k}'`).join(', ') || '(none defined)'
this.error(`vite-layers: unknown feature flag '${key}'. Known flags: ${known}.`, args[0]!.start)
}
edits.push([parent.start, parent.end, literalOf(flat.get(key))])
} else {
this.error(
'vite-layers: `feature` is a compile-time macro — call it directly with a string-literal key. ' +
'Aliasing, destructuring, or passing it as a value defeats dead-code elimination and is not allowed.',
node.start,
)
}
}
// Scope-aware descent: a reference is the macro only if no enclosing scope re-binds its name
// (so an unrelated local `feature` param/const/catch/… is left untouched, not falsely rejected).
const descend = (node: AnyNode, parent: AnyNode | null, shadow: Set<string>) => {
let childShadow = shadow
if (SCOPE_NODES.has(node.type)) {
const bound = scopeBindings(node, locals)
if (bound.size) {
childShadow = new Set(shadow)
for (const n of bound) childShadow.add(n)
}
}
if (node.type === 'Identifier' && locals.has(node.name as string) && !shadow.has(node.name as string)) {
handleRef(node, parent)
}
// Inline the child walk (instead of `eachChild(node, child => …)`) so this hot recursion
// allocates no per-node closure — `descend` is called once per AST node on a macro module.
for (const key in node) {
if (CHILD_SKIP.has(key)) continue
const child = node[key]
if (Array.isArray(child)) {
for (const c of child) if (c && typeof (c as AnyNode).type === 'string') descend(c as AnyNode, node, childShadow)
} else if (child && typeof (child as AnyNode).type === 'string') {
descend(child as AnyNode, node, childShadow)
}
}
}
descend(program, null, new Set())
for (const [start, end, text] of edits) s.overwrite(start, end, text)
for (const decl of importDecls) s.remove(decl.start, decl.end)
return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
},
},
}
}
+13 -4
View File
@@ -1,5 +1,6 @@
export { defineLayerConfig, resolveLayerStack } from './config'
export { configWatchPlugin, featuresRuntimePlugin } from './dev'
export { configWatchPlugin } from './dev'
export { FEATURE_MODULE, featurePlugin, featuresDts, flattenFeatures } from './features'
export { publicLayersPlugin } from './public'
export {
createLayerHooks,
@@ -12,14 +13,22 @@ export {
type TsconfigHookContext,
type ViteConfigHookContext,
} from './hooks'
export { DEFAULT_EXTENSIONS, layersResolver, type LayersResolverOptions } from './resolve'
export {
createLayeredResolution,
DEFAULT_EXTENSIONS,
layersResolver,
type LayeredResolution,
type LayersResolverOptions,
type ParsedLayeredId,
type ResolveRecord,
} from './resolve'
export { layersDevtoolsPlugin, type LayersDevtoolsData } from './devtools'
export { buildViteConfig, dedupePlugins, type BuildViteConfigOptions } from './kit'
export {
generateTsConfig,
writeTsConfig,
tsconfigPlugin,
featuresDts,
type GenerateTsConfigOptions,
type TSConfig,
} from './tsconfig'
export type { Layer, LayerConfig, LayerStack } from './types'
export type { Layer, LayerConfig, LayerEdge, LayerStack } from './types'
+49 -45
View File
@@ -1,11 +1,23 @@
import { existsSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import { defineConfig, mergeConfig, type PluginOption, type UserConfig } from 'vite'
import { resolveLayerStack } from './config'
import { configWatchPlugin, featuresRuntimePlugin } from './dev'
import { configWatchPlugin } from './dev'
import { layersDevtoolsPlugin } from './devtools'
import { FEATURE_MODULE, featurePlugin } from './features'
import { createLayerHooks, registerLayerHooks, type LayerHooksConfig } from './hooks'
import { publicLayersPlugin } from './public'
import { layersResolver } from './resolve'
import { createLayeredResolution, layersResolver } from './resolve'
import { tsconfigPlugin, type GenerateTsConfigOptions } from './tsconfig'
import { toPosix } from './util'
/**
* Absolute path to the `feature` macro entry, aliased as `#feature` (see {@link featurePlugin}).
* Resolved next to this module — `feature.ts` when running from source (dev/tests), `feature.js`
* after a `tsdown` build — so the alias always points at a real file in either layout.
*/
const FEATURE_ENTRY = resolve(import.meta.dirname, 'feature')
const FEATURE_FILE = toPosix(existsSync(`${FEATURE_ENTRY}.ts`) ? `${FEATURE_ENTRY}.ts` : `${FEATURE_ENTRY}.js`)
export interface BuildViteConfigOptions {
/** Extra Vite config merged at the very end (highest priority). */
@@ -21,6 +33,12 @@ export interface BuildViteConfigOptions {
resolver?: { prefixes?: string[]; extensions?: string[] }
/** Programmatic lifecycle hooks, registered after (so running after) all layer hooks. */
hooks?: LayerHooksConfig
/**
* Mount the vite-layers panels (stack / features / resolver / public+ts) in Vite DevTools.
* Requires the `@vitejs/devtools` hub in the plugin list; the integration is inert without it.
* Enabled by default — pass `false` to skip it (and the resolver's resolution-log recording).
*/
devtools?: boolean
}
/**
@@ -45,40 +63,6 @@ function dedupePlugins(config: UserConfig): UserConfig {
return { ...config, plugins: out }
}
/** A member-expression define key segment must be a plain JS identifier. */
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
/**
* Build the `define` map for feature flags. Emits the whole `__FEATURES__` object (for runtime
* reads) plus a dotted entry for **every nested path** whose segments are valid identifiers
* (`__FEATURES__.billing`, `__FEATURES__.nested.enabled`, …).
*
* The dotted entries are what make dead-code elimination work: esbuild folds a replaced literal
* (`false ? import('…') : []` → `[]`) and drops the dynamic import *before* Rollup builds the
* module graph, so the page's chunk is never emitted. A member access on an object literal
* (`{"enabled":false}.enabled`) is NOT folded, so the object form alone does not DCE — which is why
* we walk recursively and emit a literal at every depth.
*
* Keys that are not valid identifiers (e.g. `'kebab-flag'`) are skipped rather than emitted: a
* dotted define with such a segment is an `INVALID_DEFINE_CONFIG` build error, and you cannot fold
* a bracket access anyway. The key still lives inside the whole-object `__FEATURES__` for runtime.
*/
function featureDefines(features: Record<string, unknown> = {}): Record<string, string> {
const define: Record<string, string> = { __FEATURES__: JSON.stringify(features) }
const walk = (obj: Record<string, unknown>, prefix: string) => {
for (const [key, value] of Object.entries(obj)) {
if (!IDENTIFIER_RE.test(key)) continue
const path = `${prefix}.${key}`
define[path] = JSON.stringify(value)
if (value && typeof value === 'object' && !Array.isArray(value)) {
walk(value as Record<string, unknown>, path)
}
}
}
walk(features, '__FEATURES__')
return define
}
/**
* Build a Vite config from an app's layer stack. Drop-in for `vite.config.ts`:
*
@@ -87,9 +71,11 @@ function featureDefines(features: Record<string, unknown> = {}): Record<string,
* ```
*
* - Layer `vite` fragments are merged low→high (high overrides), mirroring Nuxt's `.reverse()`.
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins).
* `@/`/`~/` are handled by {@link layersResolver}, not as plain aliases.
* - `__FEATURES__` is defined from the merged `features` for build-time dead-code elimination.
* - Aliases: `~~`/`@@` → project rootDir; `#layers/<name>` → each layer's rootDir (first-wins);
* `#feature` → the {@link featurePlugin} macro entry. `@/`/`~/` are handled by
* {@link layersResolver}, not as plain aliases.
* - Build-time feature flags are compiled by {@link featurePlugin} (the `feature('key')` macro),
* one mechanism for dev and build.
*/
export function buildViteConfig(appDir: string, options: BuildViteConfigOptions = {}) {
return defineConfig(async (env) => {
@@ -103,11 +89,20 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
const { merged, layers } = stack
const roots = layers.map(l => l.srcDir)
const devtoolsEnabled = options.devtools !== false
// One shared resolution drives both the resolver plugin and the devtools resolver panel, so the
// panel introspects the exact same candidate cache and resolution log. The log is only recorded
// when devtools is enabled AND in dev (`serve`) — the panel can't mount during a build, so a
// production build does zero per-import recording work.
const recordLog = devtoolsEnabled && env.command === 'serve'
const resolution = createLayeredResolution({ roots, ...options.resolver, record: recordLog ? 200 : 0 })
const project = layers[0]! // resolveLayerStack always returns at least the project layer
const alias: Record<string, string> = {
'~~': project.rootDir,
'@@': project.rootDir,
[FEATURE_MODULE]: FEATURE_FILE, // `#feature` → the macro entry (compiled away by featurePlugin)
}
// `#layers/<name>` → layer rootDir. Iterate low→high so the highest-priority layer wins (first-wins).
for (const l of [...layers].reverse()) alias[`#layers/${l.name}`] = l.rootDir
@@ -125,21 +120,30 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
vite = dedupePlugins(vite)
const plugins: PluginOption[] = [
layersResolver({ roots, ...options.resolver }),
layersResolver(resolution),
publicLayersPlugin(layers.map(l => resolve(l.rootDir, 'public'))), // layered public/ assets
configWatchPlugin(layers.map(l => l.rootDir)), // dev: restart on app.config change
featuresRuntimePlugin(merged.features), // dev: supply __FEATURES__ at runtime (define is build-only here)
featurePlugin(merged.features), // compile `feature('key')` → literal (dev + build, one mechanism)
]
if (options.tsconfig !== false) {
// Reuse the already-resolved stack + shared hooks (so the tsconfig plugin doesn't re-resolve
// and `tsconfig:generate` sees the same handlers).
plugins.push(tsconfigPlugin(appDir, { ...options.tsconfig, stack, hooks }))
}
if (devtoolsEnabled) {
// Inert unless the `@vitejs/devtools` hub mounts it (uses only *type* imports from the kit).
plugins.push(
layersDevtoolsPlugin({
appDir,
env,
stack,
resolution,
tsconfig: options.tsconfig === false ? false : (options.tsconfig ?? {}),
}),
)
}
vite = mergeConfig(vite, {
plugins,
define: featureDefines(merged.features),
})
vite = mergeConfig(vite, { plugins })
if (options.vite) vite = mergeConfig(vite, options.vite)
+46 -14
View File
@@ -1,32 +1,56 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import sirv from 'sirv'
import type { Plugin } from 'vite'
const toPosix = (p: string) => p.replace(/\\/g, '/')
import { toPosix } from './util'
/** Recursively list files under a directory (absolute paths). */
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const abs = join(dir, name)
if (statSync(abs).isDirectory()) walk(abs, out)
// Skip a broken symlink / file removed mid-walk (ENOENT) rather than aborting the public copy.
let isDir: boolean
try {
isDir = statSync(abs).isDirectory()
} catch {
continue
}
if (isDir) walk(abs, out)
else out.push(abs)
}
return out
}
/**
* Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority
* layer wins each key — i.e. first-match-wins by priority (`brand/public/logo.svg` shadows `main`'s).
*/
function mergePublic(dirs: string[]): Map<string, string> {
const assets = new Map<string, string>()
for (const dir of [...dirs].reverse()) {
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
}
return assets
}
/**
* Layered static assets: each layer may have a `public/` directory, resolved **first-match across
* layers** (higher-priority layer wins) — e.g. `brand/public/logo.svg` shadows `main/public/logo.svg`.
*
* Vite's `publicDir` is a single directory, so this plugin takes over: it disables the built-in
* `publicDir`, serves all layers' `public/` in priority order in dev (sirv chain — first hit wins),
* and emits the merged set into the build output (higher layers overwrite lower ones).
* and copies the merged set into the build output (higher layers overwrite lower ones).
*
* Build-time copy is streamed file-by-file through the OS (`copyFileSync`) rather than buffered via
* `emitFile`, so peak memory stays flat regardless of total asset size — large fonts/videos on a
* memory-constrained CI won't OOM, matching Vite's own `publicDir` copy.
*
* @param publicDirs candidate `<rootDir>/public` directories ordered high→low priority.
*/
export function publicLayersPlugin(publicDirs: string[]): Plugin {
const dirs = publicDirs.filter(existsSync) // high → low
let outDir = ''
let copyPublic = true
return {
name: 'vite-layers:public',
@@ -34,20 +58,28 @@ export function publicLayersPlugin(publicDirs: string[]): Plugin {
// We serve/emit public ourselves, so turn off Vite's single-dir handling.
if (dirs.length > 0) return { publicDir: false }
},
configResolved(config) {
outDir = resolve(config.root, config.build.outDir)
// Respect Vite's own opt-out (e.g. SSR builds set this false to skip public copy).
copyPublic = config.build.copyPublicDir !== false
},
configureServer(server) {
// Dev: probe each layer's public/ in priority order; sirv calls next() on miss.
for (const dir of dirs) {
server.middlewares.use(sirv(dir, { dev: true, etag: true }))
}
},
generateBundle() {
// Build: merge low→high so higher layers overwrite — i.e. first-match-wins by priority.
const assets = new Map<string, string>()
for (const dir of [...dirs].reverse()) {
for (const abs of walk(dir)) assets.set(toPosix(relative(dir, abs)), abs)
}
for (const [fileName, abs] of assets) {
this.emitFile({ type: 'asset', fileName, source: readFileSync(abs) })
writeBundle(options) {
// Build: copy each file straight to disk via the OS instead of holding every asset's bytes in
// memory at once — peak RSS stays flat no matter how large the public set is.
if (!copyPublic || dirs.length === 0) return
// writeBundle fires once per output; only the one targeting the main outDir copies the assets
// (a secondary/SSR output has a different dir and is skipped).
if (options.dir && resolve(options.dir) !== outDir) return
for (const [fileName, abs] of mergePublic(dirs)) {
const dest = join(outDir, fileName)
mkdirSync(dirname(dest), { recursive: true })
copyFileSync(abs, dest)
}
},
}
+155 -30
View File
@@ -1,6 +1,7 @@
import { statSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
import { toPosix } from './util'
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
@@ -12,9 +13,62 @@ export interface LayersResolverOptions {
prefixes?: string[]
/** Extensions probed when the id has no explicit, existing file. */
extensions?: string[]
/**
* Keep a bounded, de-duplicated log of the last N resolutions for introspection (the devtools
* resolver panel reads it). `0`/omitted disables recording — zero overhead on the hot path.
*/
record?: number
}
const toPosix = (p: string) => p.replace(/\\/g, '/')
/** A single recorded resolution — what the resolver saw for one `@/`/`~/` import. */
export interface ResolveRecord {
/** The original import id (prefix + sub-path + query). */
id: string
/** The importer module (query-stripped), if any. */
importer?: string
/** The file the id resolved to (with query), or `null` if nothing matched. */
resolved: string | null
/** All candidate files across layers, high→low priority (importer-independent). */
candidates: string[]
/** Index of the importer within `candidates` (`-1` when it isn't a self-import). */
selfIndex: number
}
/** A parsed layered id: its matched prefix, the prefix-stripped sub-path, and any query suffix. */
export interface ParsedLayeredId {
prefix: string
sub: string
query: string
}
/**
* The reusable core of the layered resolver — the pure resolution logic, decoupled from the Vite
* plugin shell so it can be shared. {@link layersResolver} wraps one of these in a plugin; the
* devtools integration reuses the *same instance* (via {@link createLayeredResolution} in
* `buildViteConfig`) to introspect candidates and the live resolution log without re-implementing
* the probing, the cache, or the `super()` semantics.
*/
export interface LayeredResolution {
readonly roots: string[]
readonly prefixes: string[]
readonly extensions: string[]
/** Split a layered id into prefix/sub/query, or `null` if no prefix matches. */
parse: (id: string) => ParsedLayeredId | null
/** Ordered candidate files for a prefix-stripped sub-path, high→low priority. Cached. */
candidates: (sub: string) => string[]
/** Resolve a layered id (super()/self-skip + query preservation). `null` if not layered / no match. */
resolveId: (id: string, importer?: string) => string | null
/** Drop the candidate cache (call when files are added/removed — which layer wins can change). */
clear: () => void
/** Recorded resolutions, newest first (empty unless `record` was enabled). */
records: () => ResolveRecord[]
/** Clear the resolution log (the candidate cache is untouched). */
clearRecords: () => void
}
/** RegExp metacharacters — escaped when building a RegExp from a literal string (e.g. layer prefixes). */
const REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g
const escapeRegExp = (s: string) => s.replace(REGEXP_META_RE, '\\$&')
const isFile = (p: string): boolean => {
try {
@@ -25,19 +79,12 @@ const isFile = (p: string): boolean => {
}
/**
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
* it probes each source root in priority order and returns the first match.
*
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
* then `<path>/index<ext>`.
*
* Improvement over Nuxt: **self-skip** gives `super()` semantics. If the first match is the
* importing file itself, resolution continues to the next (lower-priority) layer — so an
* override at `@/components/Foo.vue` can import `@/components/Foo.vue` to reach the base file.
* Build the shared resolution core (probing + cache + `super()` + optional recording). Stateless
* across importers: the candidate list for a sub-path is importer-independent, so `super()` works by
* locating the importer's position in the list and taking the next entry down.
*/
export function layersResolver(options: LayersResolverOptions): Plugin {
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS } = options
export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options
const probe = (root: string, sub: string): string | null => {
const direct = resolve(root, sub)
@@ -54,8 +101,8 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
}
// Cache: `sub` (prefix- and query-stripped) → ordered list of matching files across roots
// (high→low priority). Saves the per-import `statSync` storm; self-skip stays correct because the
// candidate list is importer-independent (we pick the first candidate that isn't the importer).
// (high→low priority). Saves the per-import `statSync` storm; the list is importer-independent, so
// super() stays correct — we locate the importer's position in it and take the next entry down.
const cache = new Map<string, string[]>()
const candidates = (sub: string): string[] => {
const cached = cache.get(sub)
@@ -69,30 +116,108 @@ export function layersResolver(options: LayersResolverOptions): Plugin {
return list
}
const parse = (id: string): ParsedLayeredId | null => {
const prefix = prefixes.find(p => id.startsWith(p))
if (!prefix) return null
const q = id.indexOf('?')
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
return { prefix, sub, query }
}
// Bounded, de-duplicated resolution log (devtools). Keyed by a JSON-encoded `[id, importer]` pair
// (collision-proof, unlike a delimiter string) so repeated resolves of the same import (HMR re-runs)
// update one entry instead of flooding the log; a Map preserves insertion order, and re-inserting
// moves the entry to the end (most-recent-last).
const log = new Map<string, ResolveRecord>()
const remember = (rec: ResolveRecord) => {
const key = JSON.stringify([rec.id, rec.importer ?? null]) // collision-proof composite key
if (log.has(key)) log.delete(key)
log.set(key, rec)
while (log.size > record) log.delete(log.keys().next().value!)
}
return {
roots,
prefixes,
extensions,
parse,
candidates,
resolveId(id, importer) {
const parsed = parse(id)
if (!parsed) return null
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
// super(): if the importer is one of the candidates (an override importing its own layered
// path), resolve to the NEXT-LOWER layer; a normal importer isn't in the list, so it resolves to
// the highest-priority match (index 0). Note: "first candidate that isn't me" would be wrong —
// for a shadowed middle layer it jumps UP to a higher override, and a top↔mid self-import chain
// would cycle. Position-aware skip makes super() correct through a deep extends chain.
const list = candidates(parsed.sub)
const selfIndex = self ? list.indexOf(self) : -1
const next = list[selfIndex + 1]
const resolved = next ? next + parsed.query : null
if (record > 0) remember({ id, importer: self, resolved, candidates: list, selfIndex })
return resolved
},
clear() {
cache.clear()
},
records() {
return [...log.values()].reverse()
},
clearRecords() {
log.clear()
},
}
}
/** True if the argument is an already-built {@link LayeredResolution} rather than raw options. */
const isResolution = (v: LayersResolverOptions | LayeredResolution): v is LayeredResolution =>
typeof (v as LayeredResolution).resolveId === 'function'
/**
* Framework-agnostic, layered file resolver — the plain-Vite replacement for Nuxt's
* Vue-specific component/page/composable scanners. For an id like `@/components/Foo.vue`,
* it probes each source root in priority order and returns the first match.
*
* Probing mirrors Nuxt's `_resolvePathGranularly`: the path as-is, then `<path><ext>`,
* then `<path>/index<ext>`.
*
* Improvement over Nuxt: **self-skip** gives `super()` semantics at any depth. When the importer is
* itself one of the matches (an override importing its own layered path), resolution continues to the
* **next-lower** layer — so an override at `@/components/Foo.vue` can import `@/components/Foo.vue` to
* reach the layer beneath it. This composes through a deep `extends` chain: top→mid→base each resolve
* one step down, so multi-level overrides can each call `super()`.
*
* Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a
* pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects
* the exact same cache and resolution log this plugin produces.
*/
export function layersResolver(source: LayersResolverOptions | LayeredResolution): Plugin {
const resolution = isResolution(source) ? source : createLayeredResolution(source)
// Hook filter (rolldown): a RegExp matching the layered prefixes, so the bundler only invokes
// resolveId for `@/`/`~/` ids — every other specifier skips the JS round-trip. (resolveId filters
// accept only RegExp ids, not string globs.) https://rolldown.rs/in-depth/why-plugin-hook-filter
const idFilter = new RegExp(`^(?:${resolution.prefixes.map(escapeRegExp).join('|')})`)
return {
name: 'vite-layers:resolve',
enforce: 'pre', // before Vite core resolve; `@/`/`~/` are intentionally NOT registered as aliases
configureServer(server) {
// A new/removed file can change which layer wins → drop the cache in dev.
const clear = () => cache.clear()
const clear = () => resolution.clear()
server.watcher.on('add', clear)
server.watcher.on('unlink', clear)
server.watcher.on('unlinkDir', clear)
},
resolveId(id, importer) {
const prefix = prefixes.find(p => id.startsWith(p))
if (!prefix) return null
const q = id.indexOf('?')
const query = q < 0 ? '' : id.slice(q) // preserve `?inline`/`?raw`/`?url`/… suffixes
const sub = (q < 0 ? id : id.slice(0, q)).slice(prefix.length)
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
for (const file of candidates(sub)) {
if (file === self) continue // self-skip → fall through to the base layer (super())
return file + query
}
return null
resolveId: {
filter: { id: idFilter },
handler(id, importer) {
return resolution.resolveId(id, importer)
},
},
}
}
+13 -47
View File
@@ -4,11 +4,16 @@ import { defu } from 'defu'
import { type TSConfig, writeTSConfig } from 'pkg-types'
import type { Plugin } from 'vite'
import { resolveLayerStack } from './config'
import { FEATURE_MODULE, featuresDts } from './features'
import { hooksFromStack, type LayerHookable } from './hooks'
import type { LayerStack } from './types'
import { toPosix } from './util'
export type { TSConfig } from 'pkg-types'
/** Absolute path (no extension) to the `feature` macro entry — mapped to `#feature` in `paths`. */
const FEATURE_FILE = resolve(import.meta.dirname, 'feature')
export interface GenerateTsConfigOptions {
/**
* Extra tsconfig merged over the per-layer `tsConfig` and the generated defaults (defu — this
@@ -25,52 +30,10 @@ export interface GenerateTsConfigOptions {
hooks?: LayerHookable
}
const toPosix = (p: string) => p.replace(/\\/g, '/')
/** A path not already starting with `.` — {@link rel} prefixes it with `./`. */
const LEADING_NON_DOT_RE = /^([^.])/
/** Port of Nuxt's `relativeWithDot`: guarantees a leading `./`, returns `.` for the self case. */
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(/^([^.])/, './$1') || '.'
/** A property name that can be written unquoted in a TS type literal. */
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
/** Render a JSON-ish value as a TS type literal (boolean/number/string → type, object → recurse). */
function tsType(value: unknown): string {
if (value === null) return 'null'
if (Array.isArray(value)) return 'readonly unknown[]'
switch (typeof value) {
case 'boolean':
return 'boolean'
case 'number':
return 'number'
case 'string':
return 'string'
case 'object': {
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, never>'
const body = entries
.map(([k, v]) => `${IDENTIFIER_RE.test(k) ? k : JSON.stringify(k)}: ${tsType(v)}`)
.join('; ')
return `{ ${body} }`
}
default:
return 'unknown'
}
}
/**
* Generate a `.d.ts` that types the `__FEATURES__` global from the merged feature flags, so a typo
* (`__FEATURES__.biling`) is a compile error instead of a silently-falsy runtime value.
*/
export function featuresDts(features: Record<string, unknown> = {}): string {
return [
'// AUTO-GENERATED by vite-layers — do not edit.',
'export {}',
'declare global {',
` const __FEATURES__: ${tsType(features)}`,
'}',
'',
].join('\n')
}
const rel = (from: string, to: string) => toPosix(relative(from, to)).replace(LEADING_NON_DOT_RE, './$1') || '.'
/** Framework-neutral compiler defaults (a subset of Nuxt's, minus Vue/JSX specifics). */
const DEFAULT_COMPILER_OPTIONS: TSConfig['compilerOptions'] = {
@@ -128,6 +91,9 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
'@@': [rel(genDir, projectRoot)],
'~~/*': [`${rel(genDir, projectRoot)}/*`],
'@@/*': [`${rel(genDir, projectRoot)}/*`],
// `#feature` → the macro entry, so tsc/vue-tsc resolve `import { feature } from '#feature'` and
// the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers.
[FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)],
}
for (const l of layers) {
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
@@ -139,8 +105,8 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
const exclude = [rel(genDir, resolve(appDir, 'node_modules')), rel(genDir, resolve(appDir, 'dist'))]
// App/client config: layer src trees + the typed __FEATURES__ global. Config files are NOT here —
// they belong to the node config below.
// App/client config: layer src trees + the typed `feature()` flags (features.d.ts). Config files
// are NOT here — they belong to the node config below.
const base: TSConfig = {
compilerOptions: { ...DEFAULT_COMPILER_OPTIONS },
include: ['./features.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
+18 -1
View File
@@ -17,7 +17,7 @@ export interface LayerConfig {
extends?: string | string[]
/** Vite config fragment contributed by this layer (object or env-aware factory). */
vite?: UserConfig | ((env: ConfigEnv) => UserConfig)
/** Build-time feature flags, exposed to app code as the `__FEATURES__` global. */
/** Build-time feature flags, read in app code via the `feature('key')` macro (`#feature`). */
features?: Record<string, unknown>
/**
* tsconfig overrides contributed by this layer, merged across the stack into the generated
@@ -52,9 +52,26 @@ export interface Layer {
config: LayerConfig
}
/**
* A single edge of the resolved `extends` graph — the layer at `from` (a directory) extends the one
* resolved at `to`. `source` is the raw `extends` entry (relative path, npm package, or git source).
* Captured during resolution because c12 strips the extend keys from the resolved layer configs.
*/
export interface LayerEdge {
from: string
to: string
source: string
}
export interface LayerStack {
/** Deep-merged config across the whole stack (defu, project wins). */
merged: LayerConfig
/** Layers ordered high→low priority; `layers[0]` is the project itself. */
layers: Layer[]
/**
* Parent→child `extends` edges captured during resolution (directories, posix, no trailing slash),
* in walk order. Lets tooling rebuild the inheritance DAG the flat `layers` order flattens away.
* Optional: synthetic stacks built by hand may omit it.
*/
edges?: LayerEdge[]
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Normalize a path to forward slashes (POSIX-style). c12 returns posix-style `cwd`s while Node's
* `path` helpers are OS-native (backslashes on Windows); paths must be canonicalized to forward
* slashes before they are compared for dedup or emitted into a Vite config/alias, where posix is
* conventional. Shared by every module so the rule lives in exactly one place.
*/
const SEPARATOR_RE = /\\/g
export const toPosix = (p: string): string => p.replace(SEPARATOR_RE, '/')