feat: add support for #super imports in vite-layers
This commit is contained in:
+27
-2
@@ -77,10 +77,34 @@ c12, вне графа Vite — сам он не следит), подхваты
|
||||
| Префикс | Куда резолвится | Примечания |
|
||||
|---|---|---|
|
||||
| `@/…`, `~/…` | первый совпавший файл по `srcDir` слоёв, high→low | слоёвый резолвер; **self-skip** даёт `super()` |
|
||||
| `#super`, `#super/…` | первый совпавший файл **строго ниже** слоя импортёра | явный `super()` — предпочтительная форма, см. ниже |
|
||||
| `~~/…`, `@@/…` | `rootDir` проекта | обычный alias |
|
||||
| `#layers/<name>/…` | `rootDir` соответствующего слоя | обычный alias, first-wins по имени |
|
||||
| `#feature` | entry макроса `feature('key')` | алиас регистрируется автоматически; вызовы сворачиваются в литералы |
|
||||
|
||||
## `super()`: доступ к затенённому файлу
|
||||
|
||||
Оверрайд часто хочет не заменить базовый файл целиком, а обернуть его. Для этого есть `#super`:
|
||||
|
||||
```ts
|
||||
// apps/brand/src/main.ts — бренд без собственного bootstrap: реиспользует entry базы
|
||||
import '#super' // мой же путь (main.ts), слоем ниже → apps/main/src/main.ts
|
||||
|
||||
// apps/brand/src/components/Header.vue — оборачиваем базовый компонент
|
||||
import BaseHeader from '#super/components/Header.vue'
|
||||
```
|
||||
|
||||
- `#super/<path>` — резолвит `<path>` начиная со слоя **строго ниже** слоя импортёра
|
||||
(работает из любого файла слоя, не только из одноимённого оверрайда);
|
||||
- голый `#super` — сахар для «мой собственный путь, слоем ниже».
|
||||
|
||||
`#super/*` типизируется в сгенерированном tsconfig (`paths` → слои ниже проектного), так что
|
||||
go-to-definition ведёт в правильный файл; голая форма покрыта ambient-декларацией
|
||||
(`.vite-layers/super.d.ts`) — типов не даёт, но и ошибок не создаёт (подходит для side-effect
|
||||
импортов). Legacy-форма — self-import собственного пути (`import '@/main.ts'` изнутри `main.ts`,
|
||||
Nuxt-парити) — продолжает работать, но не грепается, меняет смысл при копировании в другой файл и
|
||||
резолвится TypeScript'ом в самого себя; в новом коде используйте `#super`.
|
||||
|
||||
## Модель приоритета (из Nuxt)
|
||||
|
||||
`layers[0]` — это сам проект (высший приоритет); далее `extends` слева-направо, в глубину;
|
||||
@@ -187,8 +211,9 @@ UI рисуется целиком на сервере (json-render спеки `
|
||||
|
||||
## Улучшения над Nuxt/c12
|
||||
|
||||
1. **`super()` через self-skip** — оверрайд может импортировать собственный путь (`@/components/X`),
|
||||
чтобы дотянуться до базового файла. В Nuxt такого механизма нет.
|
||||
1. **`super()`** — явный `#super`/`#super/<path>` (типизированный, greppable) плюс self-skip
|
||||
(оверрайд может импортировать собственный путь `@/components/X`, чтобы дотянуться до базового
|
||||
файла). В Nuxt такого механизма нет.
|
||||
2. **Cycle-guard** — голый c12 уходит в stack overflow на обратном ребре (`A→B→A`); дедуп Nuxt
|
||||
срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в
|
||||
`resolve`-хуке c12 обрывает рекурсию.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Aurora has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||
import '@/main.ts'
|
||||
// Aurora has no bootstrap logic of its own — `#super` re-resolves this file's own path
|
||||
// (main.ts) from the next-lower layer, i.e. main/src/main.ts.
|
||||
import '#super'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Brand has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||
import '@/main.ts'
|
||||
// Brand has no bootstrap logic of its own — `#super` re-resolves this file's own path
|
||||
// (main.ts) from the next-lower layer, i.e. main/src/main.ts.
|
||||
import '#super'
|
||||
|
||||
+12
-39
@@ -1,5 +1,5 @@
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { statSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import type { ConfigEnv } from 'vite'
|
||||
import type {
|
||||
DevToolsServerCommandInput,
|
||||
@@ -14,7 +14,7 @@ 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'
|
||||
import { toPosix, walkFiles } from './util'
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// This module imports **only types** from `@vitejs/devtools-kit` — they are erased at emit, so the
|
||||
@@ -100,11 +100,10 @@ interface HookRow {
|
||||
}
|
||||
type TsconfigInfo =
|
||||
| { enabled: false }
|
||||
| { enabled: true; paths: Record<string, string[]>; appJson: string; nodeJson: string; dts: string }
|
||||
| { enabled: true; paths: Record<string, string[]>; appJson: string; nodeJson: string; dts: string; superDts: string }
|
||||
|
||||
interface Snapshot {
|
||||
projectName: string
|
||||
appDir: string
|
||||
mode: string
|
||||
command: string
|
||||
layers: LayerRow[]
|
||||
@@ -120,30 +119,6 @@ interface Snapshot {
|
||||
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)
|
||||
|
||||
@@ -247,7 +222,7 @@ async function collectSnapshot(data: LayersDevtoolsData): Promise<Snapshot> {
|
||||
})
|
||||
const byPath = new Map<string, string[]>()
|
||||
for (const { name, dir } of publicLayers) {
|
||||
for (const abs of walk(dir)) {
|
||||
for (const abs of walkFiles(dir)) {
|
||||
const rel = toPosix(relative(dir, abs))
|
||||
;(byPath.get(rel) ?? byPath.set(rel, []).get(rel)!).push(name)
|
||||
}
|
||||
@@ -288,12 +263,12 @@ async function collectSnapshot(data: LayersDevtoolsData): Promise<Snapshot> {
|
||||
appJson: JSON.stringify(gen.tsconfig, null, 2),
|
||||
nodeJson: JSON.stringify(gen.nodeTsconfig, null, 2),
|
||||
dts: gen.dts,
|
||||
superDts: gen.superDts,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectName: layers[0]?.name ?? 'app',
|
||||
appDir: data.appDir,
|
||||
mode: data.env.mode,
|
||||
command: data.env.command,
|
||||
layers: layerRows,
|
||||
@@ -538,8 +513,6 @@ function buildFeaturesSpec(snap: Snapshot): JsonRenderSpec {
|
||||
|
||||
interface ResolveResult {
|
||||
id: string
|
||||
sub: string
|
||||
query: string
|
||||
candidates: string[]
|
||||
error?: string
|
||||
}
|
||||
@@ -588,9 +561,10 @@ function buildResolverSpec(data: LayersDevtoolsData, query: string, result: Reso
|
||||
return s.build(s.vstack(sections, 14, 12))
|
||||
}
|
||||
|
||||
/** Describe how a record resolved: a normal import, a `super()` self-import, or unresolved. */
|
||||
/** Describe how a record resolved: a normal import, `#super`, a `super()` self-import, or unresolved. */
|
||||
function recordVia(r: ResolveRecord): string {
|
||||
if (r.resolved === null) return 'unresolved'
|
||||
if (r.id.startsWith('#super')) return '#super'
|
||||
if (r.selfIndex < 0) return 'top match'
|
||||
return `super() #${r.selfIndex + 1}`
|
||||
}
|
||||
@@ -619,7 +593,7 @@ function resolveResultEls(s: Spec, data: LayersDevtoolsData, result: ResolveResu
|
||||
})),
|
||||
'240px',
|
||||
),
|
||||
s.text('A self-import (an override importing its own path) would super()-skip to the next row down.', 'caption'),
|
||||
s.text('#super (or a self-import of an override’s own path) skips to the next row down.', 'caption'),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -662,6 +636,7 @@ function buildAssetsSpec(snap: Snapshot): JsonRenderSpec {
|
||||
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))
|
||||
sections.push(s.card('.vite-layers/super.d.ts', [s.code(ts.superDts, 'super.d.ts')], true))
|
||||
} else {
|
||||
sections.push(s.card('TypeScript', [s.text('tsconfig autogeneration is disabled (tsconfig: false).', 'caption')]))
|
||||
}
|
||||
@@ -693,18 +668,16 @@ const action = (name: string, handler: (params?: Record<string, unknown>) => voi
|
||||
*/
|
||||
function runResolve(data: LayersDevtoolsData, rawId: unknown): ResolveResult {
|
||||
const id = String(rawId ?? '').trim()
|
||||
if (!id) return { id, candidates: [], error: 'Enter an import id.' }
|
||||
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) }
|
||||
return { id, candidates: data.resolution.candidates(parsed.sub) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+34
-32
@@ -353,29 +353,29 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
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 {
|
||||
// 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) {
|
||||
} 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)) {
|
||||
}
|
||||
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
|
||||
}
|
||||
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[]) {
|
||||
// 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)
|
||||
@@ -400,15 +400,15 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
) {
|
||||
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
|
||||
}
|
||||
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]> = []
|
||||
// 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) => {
|
||||
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
|
||||
@@ -439,11 +439,11 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
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>) => {
|
||||
// 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)
|
||||
@@ -466,13 +466,15 @@ export function featurePlugin(features: Record<string, unknown> = {}): Plugin {
|
||||
descend(child as AnyNode, node, childShadow)
|
||||
}
|
||||
}
|
||||
}
|
||||
descend(program, null, new Set())
|
||||
}
|
||||
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)
|
||||
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 }) }
|
||||
// 'boundary' maps token boundaries instead of every character — far cheaper than `true`,
|
||||
// accurate enough to step over a substituted feature() call.
|
||||
return { code: s.toString(), map: s.generateMap({ source: id, hires: 'boundary' }) }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
createLayeredResolution,
|
||||
DEFAULT_EXTENSIONS,
|
||||
layersResolver,
|
||||
SUPER_MODULE,
|
||||
type LayeredResolution,
|
||||
type LayersResolverOptions,
|
||||
type ParsedLayeredId,
|
||||
|
||||
@@ -104,16 +104,17 @@ export function buildViteConfig(appDir: string, options: BuildViteConfigOptions
|
||||
'@@': 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
|
||||
// Low→high iteration order: for aliases the highest-priority layer overwrites (first-wins by
|
||||
// name), for config fragments it merges last (higher layers override).
|
||||
const lowToHigh = [...layers].reverse()
|
||||
for (const l of lowToHigh) 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()) {
|
||||
for (const l of lowToHigh) {
|
||||
const frag = typeof l.config.vite === 'function' ? l.config.vite(env) : l.config.vite
|
||||
if (frag) vite = mergeConfig(vite, frag)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
||||
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import sirv from 'sirv'
|
||||
import type { Plugin } from 'vite'
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
import { toPosix, walkFiles } from './util'
|
||||
|
||||
/**
|
||||
* Merged `relativePath → sourceAbs` map across all layers. Walked low→high so the higher-priority
|
||||
@@ -28,7 +11,7 @@ function walk(dir: string, out: string[] = []): string[] {
|
||||
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)
|
||||
for (const abs of walkFiles(dir)) assets.set(toPosix(relative(dir, abs)), abs)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
+104
-33
@@ -6,6 +6,22 @@ import { toPosix } from './util'
|
||||
/** Default resolvable extensions — mirrors Nuxt's `nuxt.options.extensions`. */
|
||||
export const DEFAULT_EXTENSIONS = ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
|
||||
|
||||
/**
|
||||
* The explicit `super()` import specifier: `#super/<path>` resolves `<path>` from the layer
|
||||
* **strictly below** the importer's; bare `#super` is sugar for "my own layer-relative path, one
|
||||
* layer down". Unlike the implicit self-import form it is greppable, survives copy-paste, and
|
||||
* `#super/*` is typed in the generated tsconfig.
|
||||
*/
|
||||
export const SUPER_MODULE = '#super'
|
||||
const SUPER_PREFIX = `${SUPER_MODULE}/`
|
||||
const SUPER_QUERY = `${SUPER_MODULE}?`
|
||||
|
||||
/** Strip a `?query` suffix (allocation-free `split('?')[0]` — resolveId is per-import hot). */
|
||||
const stripQuery = (s: string): string => {
|
||||
const q = s.indexOf('?')
|
||||
return q < 0 ? s : s.slice(0, q)
|
||||
}
|
||||
|
||||
export interface LayersResolverOptions {
|
||||
/** Source roots ordered high→low priority (typically `layers.map(l => l.srcDir)`). */
|
||||
roots: string[]
|
||||
@@ -52,14 +68,18 @@ 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. */
|
||||
/** Split a layered id into prefix/sub/query (`#super/` counts as a prefix), or `null`. */
|
||||
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. */
|
||||
/** Resolve a layered id (`#super` + self-skip `super()` + 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). */
|
||||
/** Drop the whole candidate cache. Prefer the targeted invalidate* methods in dev. */
|
||||
clear: () => void
|
||||
/** Drop only the cache entries one added/removed file can affect (its sub + ext/index probe subs). */
|
||||
invalidateFile: (file: string) => void
|
||||
/** Drop the cache entries under (or probing into) a removed directory. */
|
||||
invalidateDir: (dir: string) => void
|
||||
/** Recorded resolutions, newest first (empty unless `record` was enabled). */
|
||||
records: () => ResolveRecord[]
|
||||
/** Clear the resolution log (the candidate cache is untouched). */
|
||||
@@ -86,6 +106,10 @@ const isFile = (p: string): boolean => {
|
||||
export function createLayeredResolution(options: LayersResolverOptions): LayeredResolution {
|
||||
const { roots, prefixes = ['@/', '~/'], extensions = DEFAULT_EXTENSIONS, record = 0 } = options
|
||||
|
||||
// Layer index of a (posix, query-stripped) file, or -1 if outside every root.
|
||||
const posixRoots = roots.map(r => toPosix(r))
|
||||
const layerOf = (file: string): number => posixRoots.findIndex(r => file.startsWith(`${r}/`))
|
||||
|
||||
const probe = (root: string, sub: string): string | null => {
|
||||
const direct = resolve(root, sub)
|
||||
if (isFile(direct)) return direct
|
||||
@@ -117,7 +141,13 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
|
||||
}
|
||||
|
||||
const parse = (id: string): ParsedLayeredId | null => {
|
||||
const prefix = prefixes.find(p => id.startsWith(p))
|
||||
// `#super/…` and bare `#super` always parse (bare → prefix `#super`, sub ''), so the devtools
|
||||
// playground can list their candidates like any layered id.
|
||||
const prefix = id.startsWith(SUPER_PREFIX)
|
||||
? SUPER_PREFIX
|
||||
: id === SUPER_MODULE || id.startsWith(SUPER_QUERY)
|
||||
? SUPER_MODULE
|
||||
: 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
|
||||
@@ -125,13 +155,11 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
|
||||
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).
|
||||
// Bounded, de-duplicated resolution log (devtools). NUL-joined key: collision-proof (paths can't
|
||||
// contain NUL), cheaper than JSON.stringify. Re-inserting moves an entry to the end (most-recent).
|
||||
const log = new Map<string, ResolveRecord>()
|
||||
const remember = (rec: ResolveRecord) => {
|
||||
const key = JSON.stringify([rec.id, rec.importer ?? null]) // collision-proof composite key
|
||||
const key = `${rec.id}\0${rec.importer ?? '\0'}`
|
||||
if (log.has(key)) log.delete(key)
|
||||
log.set(key, rec)
|
||||
while (log.size > record) log.delete(log.keys().next().value!)
|
||||
@@ -147,24 +175,66 @@ export function createLayeredResolution(options: LayersResolverOptions): Layered
|
||||
const parsed = parse(id)
|
||||
if (!parsed) return null
|
||||
|
||||
const self = importer ? toPosix(importer.split('?')[0]!) : undefined
|
||||
const self = importer ? toPosix(stripQuery(importer)) : undefined
|
||||
let list: string[]
|
||||
let next: string | undefined
|
||||
let selfIndex: number
|
||||
|
||||
if (parsed.prefix === SUPER_PREFIX || parsed.prefix === SUPER_MODULE) {
|
||||
// #super — explicit super(): first candidate in a layer STRICTLY BELOW the importer's, so it
|
||||
// works from any file, not just an override of the same path.
|
||||
const myLayer = self ? layerOf(self) : -1
|
||||
if (self === undefined || myLayer < 0) return null // importer outside the stack
|
||||
// bare `#super` (sub === '') → the importer's own sub-path
|
||||
list = candidates(parsed.sub || self.slice(posixRoots[myLayer]!.length + 1))
|
||||
next = list.find(f => layerOf(f) > myLayer)
|
||||
selfIndex = list.indexOf(self)
|
||||
} else {
|
||||
// super() via self-skip: 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 stays correct at any depth.
|
||||
list = candidates(parsed.sub)
|
||||
selfIndex = self ? list.indexOf(self) : -1
|
||||
next = list[selfIndex + 1]
|
||||
}
|
||||
|
||||
// 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()
|
||||
},
|
||||
invalidateFile(file) {
|
||||
const f = toPosix(file)
|
||||
for (const root of posixRoots) {
|
||||
// every root, not just the first — with nested roots a file has a different sub per root
|
||||
if (!f.startsWith(`${root}/`)) continue
|
||||
const sub = f.slice(root.length + 1)
|
||||
cache.delete(sub)
|
||||
for (const ext of extensions) {
|
||||
if (!sub.endsWith(ext)) continue
|
||||
const bare = sub.slice(0, -ext.length) // extension probe: `@/foo` ← foo.ts
|
||||
cache.delete(bare)
|
||||
if (bare.endsWith('/index')) cache.delete(bare.slice(0, -'/index'.length)) // `@/dir` ← dir/index.ts
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
invalidateDir(dir) {
|
||||
const d = toPosix(dir)
|
||||
for (const root of posixRoots) {
|
||||
if (!d.startsWith(`${root}/`)) continue
|
||||
const sub = d.slice(root.length + 1)
|
||||
const prefix = `${sub}/`
|
||||
for (const key of cache.keys()) {
|
||||
// `key === sub`: `@/dir` may have resolved via dir/index.*
|
||||
if (key === sub || key.startsWith(prefix)) cache.delete(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
records() {
|
||||
return [...log.values()].reverse()
|
||||
},
|
||||
@@ -186,11 +256,10 @@ const isResolution = (v: LayersResolverOptions | LayeredResolution): v is Layere
|
||||
* 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()`.
|
||||
* Improvement over Nuxt: `super()` semantics at any depth, in two forms — the explicit
|
||||
* `#super`/`#super/<path>` (preferred: greppable and typed, see {@link SUPER_MODULE}) and the
|
||||
* implicit self-skip (Nuxt-parity: an override importing its own layered path resolves to the
|
||||
* next-lower layer). Both compose through a deep `extends` chain, one step down per layer.
|
||||
*
|
||||
* Accepts either {@link LayersResolverOptions} (builds its own {@link LayeredResolution}) or a
|
||||
* pre-built resolution — `buildViteConfig` passes a shared instance so the devtools panel introspects
|
||||
@@ -198,20 +267,22 @@ const isResolution = (v: LayersResolverOptions | LayeredResolution): v is Layere
|
||||
*/
|
||||
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('|')})`)
|
||||
// Hook filter (rolldown): only layered prefixes + `#super` reach the JS handler; every other
|
||||
// specifier skips the round-trip. https://rolldown.rs/in-depth/why-plugin-hook-filter
|
||||
const idFilter = new RegExp(
|
||||
`^(?:${resolution.prefixes.map(escapeRegExp).join('|')}|${SUPER_MODULE}(?:/|\\?|$))`,
|
||||
)
|
||||
|
||||
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 = () => resolution.clear()
|
||||
server.watcher.on('add', clear)
|
||||
server.watcher.on('unlink', clear)
|
||||
server.watcher.on('unlinkDir', clear)
|
||||
// A new/removed file can change which layer wins. Targeted invalidation, not a full clear —
|
||||
// dev codegen (typed-router, dts emitters) would otherwise wipe the cache on every emit.
|
||||
const invalidateFile = (file: string) => resolution.invalidateFile(file)
|
||||
server.watcher.on('add', invalidateFile)
|
||||
server.watcher.on('unlink', invalidateFile)
|
||||
server.watcher.on('unlinkDir', dir => resolution.invalidateDir(dir))
|
||||
},
|
||||
resolveId: {
|
||||
filter: { id: idFilter },
|
||||
|
||||
@@ -30,6 +30,20 @@ export interface GenerateTsConfigOptions {
|
||||
hooks?: LayerHookable
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient declaration for bare `#super` — importer-relative, so `paths` cannot express it; an empty
|
||||
* ambient module at least makes side-effect imports type-check. MUST stay a **script** d.ts (no
|
||||
* top-level import/export): inside a module file `declare module` becomes an augmentation of an
|
||||
* unresolvable specifier (TS2664).
|
||||
*/
|
||||
const SUPER_DTS = [
|
||||
'// AUTO-GENERATED by vite-layers — do not edit.',
|
||||
"// Bare `#super` re-resolves the importer's own path one layer down; it is importer-relative, so",
|
||||
"// it cannot be typed. Use `#super/<path>` for typed super() imports (see generated `paths`).",
|
||||
"declare module '#super' {}",
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
/** 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. */
|
||||
@@ -95,6 +109,10 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
|
||||
// the generated `features.d.ts` augmentation. Matches the alias buildViteConfig registers.
|
||||
[FEATURE_MODULE]: [rel(genDir, FEATURE_FILE)],
|
||||
}
|
||||
// `#super/*` → the layers BELOW the project layer: exact for project-layer overrides (the common
|
||||
// case), one layer too high for a middle-layer file — still better than the self-import form,
|
||||
// which TS resolves to the importer itself. Bare `#super` is covered by SUPER_DTS instead.
|
||||
if (layers.length > 1) paths['#super/*'] = layers.slice(1).map(l => `${rel(genDir, l.srcDir)}/*`)
|
||||
for (const l of layers) {
|
||||
// first-wins on duplicate names, mirroring the `#layers/<name>` alias in buildViteConfig.
|
||||
const star = `#layers/${l.name}/*`
|
||||
@@ -109,7 +127,7 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
|
||||
// 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)}/**/*`)],
|
||||
include: ['./features.d.ts', './super.d.ts', ...layers.map(l => `${rel(genDir, l.srcDir)}/**/*`)],
|
||||
exclude,
|
||||
}
|
||||
|
||||
@@ -141,20 +159,24 @@ export async function generateTsConfig(appDir: string, opts: GenerateTsConfigOpt
|
||||
genDir,
|
||||
dts: featuresDts(merged.features),
|
||||
dtsFile: resolve(genDir, 'features.d.ts'),
|
||||
superDts: SUPER_DTS,
|
||||
superDtsFile: resolve(genDir, 'super.d.ts'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts}` (tsconfig via pkg-types
|
||||
* `writeTSConfig`). Returns the tsconfig path.
|
||||
* Generate and write `<appDir>/.vite-layers/{tsconfig.json,features.d.ts,super.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)
|
||||
const { tsconfig, file, nodeTsconfig, nodeFile, genDir, dts, dtsFile, superDts, superDtsFile } =
|
||||
await generateTsConfig(appDir, opts)
|
||||
await mkdir(genDir, { recursive: true })
|
||||
await Promise.all([
|
||||
writeTSConfig(file, tsconfig),
|
||||
writeTSConfig(nodeFile, nodeTsconfig),
|
||||
writeFile(dtsFile, dts),
|
||||
writeFile(superDtsFile, superDts),
|
||||
])
|
||||
return file
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readdirSync, statSync, type Dirent } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -6,3 +9,31 @@
|
||||
*/
|
||||
const SEPARATOR_RE = /\\/g
|
||||
export const toPosix = (p: string): string => p.replace(SEPARATOR_RE, '/')
|
||||
|
||||
/**
|
||||
* Recursively list files under a directory (absolute paths); `[]` if it isn't a directory.
|
||||
* withFileTypes: half the syscalls of a per-entry statSync walk; only symlinks still need a stat
|
||||
* (to follow them), and a broken one / a file unlinked mid-walk (ENOENT) is skipped, not fatal.
|
||||
*/
|
||||
export function walkFiles(dir: string, out: string[] = []): string[] {
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const abs = join(dir, entry.name)
|
||||
let isDir = entry.isDirectory()
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
isDir = statSync(abs).isDirectory()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (isDir) walkFiles(abs, out)
|
||||
else out.push(abs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Plugin } from 'vite'
|
||||
import { createLayeredResolution, layersResolver } from '../src/resolve'
|
||||
|
||||
@@ -70,6 +72,55 @@ describe('layersResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('#super — the explicit super() specifier', () => {
|
||||
const brandHeader = fixture('brand/src/components/Header.vue')
|
||||
const baseHeader = fixture('base/src/components/Header.vue')
|
||||
|
||||
it('bare #super re-resolves the importer’s own path from the next-lower layer', () => {
|
||||
expect(resolveId('#super', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('#super/<path> resolves any path from strictly below the importer’s layer', () => {
|
||||
// the importer is NOT an override of Header — the implicit self-skip can't express this
|
||||
expect(resolveId('#super/components/Header.vue', fixture('brand/src/main.ts'))).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('never resolves to the importer’s own layer (strictly below, not first match)', () => {
|
||||
expect(resolveId('#super/components/Header.vue', brandHeader)).toBe(baseHeader)
|
||||
})
|
||||
|
||||
it('returns null from the lowest layer / for a path absent below', () => {
|
||||
expect(resolveId('#super', baseHeader)).toBeNull()
|
||||
// Footer.vue exists only in base — from a brand importer it resolves DOWN to base's copy…
|
||||
expect(resolveId('#super/components/Footer.vue', brandHeader)).toBe(fixture('base/src/components/Footer.vue'))
|
||||
// …but from base itself there is nothing beneath.
|
||||
expect(resolveId('#super/components/Footer.vue', fixture('base/src/components/Footer.vue'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the importer is outside the layer stack (or absent)', () => {
|
||||
expect(resolveId('#super')).toBeNull()
|
||||
expect(resolveId('#super/components/Header.vue', '/somewhere/else/file.ts')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves query suffixes on both forms', () => {
|
||||
expect(resolveId('#super?raw', brandHeader)).toBe(`${baseHeader}?raw`)
|
||||
expect(resolveId('#super/components/Header.vue?vue&type=style', brandHeader)).toBe(`${baseHeader}?vue&type=style`)
|
||||
})
|
||||
|
||||
it('probes extensions/index like any layered id', () => {
|
||||
expect(resolveId('#super/widgets/Card', brandHeader)).toBe(fixture('base/src/widgets/Card/index.ts'))
|
||||
})
|
||||
|
||||
it('composes through a deep (3-layer) chain, one step down per layer', () => {
|
||||
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
|
||||
const dp = layersResolver({ roots: deepRoots })
|
||||
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
|
||||
expect(callResolveId(dp, '#super', W('top'))).toBe(W('mid'))
|
||||
expect(callResolveId(dp, '#super', W('mid'))).toBe(W('base'))
|
||||
expect(callResolveId(dp, '#super', W('base'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when nothing matches across layers', () => {
|
||||
expect(resolveId('@/components/Missing.vue')).toBeNull()
|
||||
})
|
||||
@@ -87,6 +138,15 @@ describe('layersResolver', () => {
|
||||
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
|
||||
})
|
||||
|
||||
it('the hook filter matches #super ids but not lookalikes', () => {
|
||||
const h = plugin.resolveId as { filter: { id: RegExp } }
|
||||
expect(h.filter.id.test('#super')).toBe(true)
|
||||
expect(h.filter.id.test('#super/components/Header.vue')).toBe(true)
|
||||
expect(h.filter.id.test('#super?raw')).toBe(true)
|
||||
expect(h.filter.id.test('#superstition')).toBe(false)
|
||||
expect(h.filter.id.test('@/components/Header.vue')).toBe(true)
|
||||
})
|
||||
|
||||
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
|
||||
const p = layersResolver({ roots })
|
||||
const rid = (id: string) => callResolveId(p, id)
|
||||
@@ -103,6 +163,15 @@ describe('createLayeredResolution (introspection core)', () => {
|
||||
expect(r.parse('#layers/base/x')).toBeNull()
|
||||
})
|
||||
|
||||
it('parse() accepts #super/ as a pseudo-prefix (so devtools can show its candidate stack)', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
expect(r.parse('#super/components/Header.vue?raw')).toEqual({
|
||||
prefix: '#super/',
|
||||
sub: 'components/Header.vue',
|
||||
query: '?raw',
|
||||
})
|
||||
})
|
||||
|
||||
it('candidates() lists every matching file across layers, high→low', () => {
|
||||
const r = createLayeredResolution({ roots })
|
||||
expect(r.candidates('components/Header.vue')).toEqual([
|
||||
@@ -151,6 +220,78 @@ describe('createLayeredResolution (introspection core)', () => {
|
||||
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
|
||||
})
|
||||
|
||||
describe('targeted invalidation (invalidateFile / invalidateDir)', () => {
|
||||
// Real temp layers — invalidation is about reacting to FS changes, fixtures can't change.
|
||||
let tmp: string
|
||||
const setup = () => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'vite-layers-inv-'))
|
||||
const brandSrc = join(tmp, 'brand/src')
|
||||
const baseSrc = join(tmp, 'base/src')
|
||||
mkdirSync(join(baseSrc, 'components'), { recursive: true })
|
||||
mkdirSync(join(brandSrc, 'components'), { recursive: true })
|
||||
writeFileSync(join(baseSrc, 'components/Button.ts'), 'export default 1')
|
||||
return { brandSrc, baseSrc, r: createLayeredResolution({ roots: [brandSrc, baseSrc] }) }
|
||||
}
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }))
|
||||
|
||||
it('a new file in a higher layer changes the winner after invalidateFile', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) // cached
|
||||
const override = join(brandSrc, 'components/Button.ts')
|
||||
writeFileSync(override, 'export default 2')
|
||||
// control: the stale cache still serves the old winner…
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
|
||||
r.invalidateFile(override)
|
||||
// …and the targeted invalidation flips it (extension-probe sub `components/Button` was dropped)
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
|
||||
})
|
||||
|
||||
it('leaves unrelated cache entries warm and ignores files outside every root', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
writeFileSync(join(baseSrc, 'components/Other.ts'), 'export default 3')
|
||||
r.resolveId('@/components/Other')
|
||||
const before = r.candidates('components/Other')
|
||||
r.invalidateFile(join(brandSrc, 'components/Button.ts')) // different sub
|
||||
r.invalidateFile(join(tmp, 'elsewhere/file.ts')) // outside every root — no-op
|
||||
expect(r.candidates('components/Other')).toBe(before) // same array identity → still cached
|
||||
})
|
||||
|
||||
it('a deleted file falls back to the lower layer after invalidateFile', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
const override = join(brandSrc, 'components/Button.ts')
|
||||
writeFileSync(override, 'export default 2')
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
|
||||
unlinkSync(override)
|
||||
r.invalidateFile(override)
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
|
||||
})
|
||||
|
||||
it('invalidateFile on an index file also drops the bare-dir and dir/index subs', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
mkdirSync(join(baseSrc, 'widgets/Card'), { recursive: true })
|
||||
writeFileSync(join(baseSrc, 'widgets/Card/index.ts'), 'export default 1')
|
||||
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(baseSrc, 'widgets/Card/index.ts')))
|
||||
const override = join(brandSrc, 'widgets/Card/index.ts')
|
||||
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
|
||||
writeFileSync(override, 'export default 2')
|
||||
r.invalidateFile(override)
|
||||
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(override))
|
||||
})
|
||||
|
||||
it('invalidateDir drops everything under the dir including its own index sub', () => {
|
||||
const { brandSrc, baseSrc, r } = setup()
|
||||
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
|
||||
writeFileSync(join(brandSrc, 'widgets/Card/index.ts'), 'export default 2')
|
||||
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(brandSrc, 'widgets/Card/index.ts')))
|
||||
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
|
||||
rmSync(join(brandSrc, 'widgets'), { recursive: true })
|
||||
r.invalidateDir(join(brandSrc, 'widgets'))
|
||||
expect(r.resolveId('@/widgets/Card')).toBeNull() // dir gone, nothing below
|
||||
const warm = r.candidates('components/Button')
|
||||
expect(r.candidates('components/Button')).toBe(warm) // unrelated entry untouched
|
||||
})
|
||||
})
|
||||
|
||||
it('the plugin and a shared resolution stay in sync', () => {
|
||||
const shared = createLayeredResolution({ roots, record: 10 })
|
||||
const plugin = layersResolver(shared)
|
||||
|
||||
@@ -107,6 +107,32 @@ describe('generateTsConfig', () => {
|
||||
expect(r.dts).toContain(`declare module '#feature'`)
|
||||
})
|
||||
|
||||
it('maps #super/* to the layers BELOW the project layer (explicit super() is typed)', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
// project layer (app) excluded — super() never resolves to the importer's own layer
|
||||
expect(paths['#super/*']).toEqual(['../../base/src/*', '../../core/src/*'])
|
||||
})
|
||||
|
||||
it('omits #super/* for a single-layer stack (paths forbids empty substitution arrays)', async () => {
|
||||
const stack = {
|
||||
merged: {},
|
||||
layers: [{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'app', config: {} }],
|
||||
}
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
|
||||
expect(tsconfig.compilerOptions!.paths).not.toHaveProperty('#super/*')
|
||||
})
|
||||
|
||||
it('emits an ambient super.d.ts so a bare `import "#super"` type-checks', async () => {
|
||||
const r = await generateTsConfig(fixture('stack/app'))
|
||||
expect(r.tsconfig.include).toContain('./super.d.ts')
|
||||
expect(r.superDtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/super\.d\.ts$/)
|
||||
expect(r.superDts).toContain(`declare module '#super' {}`)
|
||||
// must stay a SCRIPT file: a top-level import would turn `declare module` into an augmentation
|
||||
// of an unresolvable specifier (TS2664)
|
||||
expect(r.superDts).not.toMatch(/^\s*(import|export)\b/m)
|
||||
})
|
||||
|
||||
it('maps #feature to the macro entry so tsc resolves the feature() import', async () => {
|
||||
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
|
||||
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
|
||||
|
||||
Reference in New Issue
Block a user