diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c083850..0fa947f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- node: [18, 20, 22]
+ node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/.gitignore b/.gitignore
index 04f9a0d..b294ea6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
node_modules
dist
*.tgz
+package-lock.json
.DS_Store
diff --git a/assets/logo.svg b/assets/logo.svg
index 2ecedbd..bbdf66e 100644
--- a/assets/logo.svg
+++ b/assets/logo.svg
@@ -1,13 +1,11 @@
diff --git a/src/bundle.ts b/src/bundle.ts
index 35bde4f..8737bbd 100644
--- a/src/bundle.ts
+++ b/src/bundle.ts
@@ -81,14 +81,18 @@ function enqueue(fn: () => Promise): Promise {
})
}
-function buildGlobalsMap(
+export function buildGlobalsMap(
external: string[],
): Record {
const globals: Record = {}
for (const pkg of external) {
- // Strip scope prefix (@scope/pkg → pkg), then camelCase
- const bare = pkg.startsWith('@') ? pkg.split('/')[1] ?? pkg : pkg
- globals[pkg] = bare.replace(/[-.](\w)/g, (_, c: string) => c.toUpperCase())
+ // @scope/pkg → scopePkg, lodash-es → lodashEs
+ const name = pkg
+ .replace(/^@/, '')
+ .replace(/[/\-.]/g, ' ')
+ .trim()
+ .replace(/ (\w)/g, (_, c: string) => c.toUpperCase())
+ globals[pkg] = name
}
return globals
}
diff --git a/src/cache.ts b/src/cache.ts
index 1745bdc..3baa3a4 100644
--- a/src/cache.ts
+++ b/src/cache.ts
@@ -1,12 +1,13 @@
import fs from 'node:fs'
+import type { CookedQuery } from './types.js'
const cache = new Map()
-export function getCacheKey(
+export async function getCacheKey(
filepath: string,
- query: CookedQuery_Like,
-): string {
- const stat = fs.statSync(filepath)
+ query: CookedQuery,
+): Promise {
+ const stat = await fs.promises.stat(filepath)
const fingerprint = `${stat.mtimeMs}:${stat.size}`
const queryStr = stableStringify(query)
return `${filepath}:${queryStr}:${fingerprint}`
@@ -29,14 +30,13 @@ export function invalidateCache(filepath: string): void {
}
}
-type CookedQuery_Like = Record
-
-function stableStringify(obj: CookedQuery_Like): string {
+function stableStringify(obj: CookedQuery): string {
+ const record = obj as unknown as Record
return JSON.stringify(
- Object.keys(obj)
+ Object.keys(record)
.sort()
.reduce>((acc, key) => {
- acc[key] = obj[key]
+ acc[key] = record[key]
return acc
}, {}),
)
diff --git a/src/index.ts b/src/index.ts
index de48ff4..df73721 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,5 @@
import fs from 'node:fs'
-import type { Plugin, ResolvedConfig } from 'vite'
+import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { parseCookedQuery } from './parse-query.js'
import { compileCode } from './transform.js'
import { bundleCode } from './bundle.js'
@@ -11,8 +11,11 @@ export { parseCookedQuery } from './parse-query.js'
export { compileCode } from './transform.js'
export { bundleCode } from './bundle.js'
+const HMR_ACCEPT = `\nif (import.meta.hot) { import.meta.hot.accept() }\n`
+
export default function cookedPlugin(options?: CookedOptions): Plugin {
let resolvedConfig: ResolvedConfig
+ let server: ViteDevServer | undefined
return {
name: 'vite-plugin-cooked',
@@ -22,9 +25,19 @@ export default function cookedPlugin(options?: CookedOptions): Plugin {
resolvedConfig = config
},
- configureServer(server) {
+ configureServer(_server) {
+ server = _server
server.watcher.on('change', (changedPath) => {
invalidateCache(changedPath)
+
+ // Invalidate cooked modules that depend on the changed file
+ for (const [, mod] of server!.moduleGraph.idToModuleMap) {
+ if (!mod.id) continue
+ const parsed = parseCookedQuery(mod.id)
+ if (parsed && parsed.filepath === changedPath) {
+ server!.moduleGraph.invalidateModule(mod)
+ }
+ }
})
},
@@ -62,14 +75,12 @@ export default function cookedPlugin(options?: CookedOptions): Plugin {
this.addWatchFile(filepath)
- const cacheKey = getCacheKey(
- filepath,
- query as unknown as Record,
- )
+ const cacheKey = await getCacheKey(filepath, query)
const cached = getFromCache(cacheKey)
if (cached) {
+ const code = 'export default ' + JSON.stringify(cached)
return {
- code: 'export default ' + JSON.stringify(cached),
+ code: server ? code + HMR_ACCEPT : code,
moduleType: 'js',
}
}
@@ -97,8 +108,9 @@ export default function cookedPlugin(options?: CookedOptions): Plugin {
setCache(cacheKey, result)
+ const code = 'export default ' + JSON.stringify(result)
return {
- code: 'export default ' + JSON.stringify(result),
+ code: server ? code + HMR_ACCEPT : code,
moduleType: 'js',
}
},
diff --git a/src/transform.ts b/src/transform.ts
index ce7db62..1a790b5 100644
--- a/src/transform.ts
+++ b/src/transform.ts
@@ -34,10 +34,13 @@ async function transformWithEsbuild(
format: 'esm',
minify,
...(target ? { target } : {}),
- ...(query.banner ? { banner: query.banner } : {}),
})
- return result.code
+ let output = result.code
+ if (query.banner) {
+ output = query.banner + '\n' + output
+ }
+ return output
}
async function transformWithOxc(
diff --git a/test/index.test.ts b/test/index.test.ts
index d5c92a5..87a4aa9 100644
--- a/test/index.test.ts
+++ b/test/index.test.ts
@@ -2,7 +2,7 @@ import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { parseCookedQuery } from '../src/parse-query.js'
import { compileCode } from '../src/transform.js'
-import { bundleCode } from '../src/bundle.js'
+import { bundleCode, buildGlobalsMap } from '../src/bundle.js'
import { getCacheKey, getFromCache, invalidateCache, setCache } from '../src/cache.js'
import cookedPlugin from '../src/index.js'
import type { CookedQuery } from '../src/types.js'
@@ -367,10 +367,17 @@ describe('external', () => {
minify: false,
external: ['lodash-es'],
})
- // IIFE should reference the global variable
+ // IIFE should reference the global variable (lodash-es → lodashEs)
expect(code).toContain('lodashEs')
})
+ it('buildGlobalsMap preserves scope in global name', () => {
+ const globals = buildGlobalsMap(['@emotion/react', '@scope/my-pkg', 'lodash-es'])
+ expect(globals['@emotion/react']).toBe('emotionReact')
+ expect(globals['@scope/my-pkg']).toBe('scopeMyPkg')
+ expect(globals['lodash-es']).toBe('lodashEs')
+ })
+
it('external=* + format=iife throws', async () => {
await expect(
bundleCode({
@@ -434,9 +441,15 @@ describe('external', () => {
describe('cache', () => {
it('returns cached result on second call', async () => {
const filepath = fixture('basic.ts')
- const query = { to: 'js', minify: false, format: 'es', nobundle: false }
+ const query = {
+ to: 'js' as const,
+ minify: false,
+ format: 'es' as const,
+ nobundle: false,
+ external: null,
+ }
- const key = getCacheKey(filepath, query)
+ const key = await getCacheKey(filepath, query)
expect(getFromCache(key)).toBeUndefined()
setCache(key, 'cached-code')
@@ -446,21 +459,35 @@ describe('cache', () => {
expect(getFromCache(key)).toBeUndefined()
})
- it('generates different keys for different queries', () => {
+ it('generates different keys for different queries', async () => {
const filepath = fixture('basic.ts')
- const key1 = getCacheKey(filepath, { to: 'js', minify: false })
- const key2 = getCacheKey(filepath, { to: 'js', minify: true })
+ const key1 = await getCacheKey(filepath, {
+ to: 'js',
+ minify: false,
+ nobundle: false,
+ external: null,
+ })
+ const key2 = await getCacheKey(filepath, {
+ to: 'js',
+ minify: true,
+ nobundle: false,
+ external: null,
+ })
expect(key1).not.toBe(key2)
})
- it('generates different keys for different external values', () => {
+ it('generates different keys for different external values', async () => {
const filepath = fixture('basic.ts')
- const key1 = getCacheKey(filepath, {
+ const key1 = await getCacheKey(filepath, {
to: 'js',
+ minify: false,
+ nobundle: false,
external: null,
})
- const key2 = getCacheKey(filepath, {
+ const key2 = await getCacheKey(filepath, {
to: 'js',
+ minify: false,
+ nobundle: false,
external: ['lodash-es'],
})
expect(key1).not.toBe(key2)
@@ -639,4 +666,72 @@ describe('plugin hooks', () => {
/\?to=ts cannot be used with bundle mode/,
)
})
+
+ it('load does not include HMR code without dev server', async () => {
+ const plugin = cookedPlugin()
+ const load = plugin.load as Function
+ const addWatchFile = vi.fn()
+
+ const id = fixture('basic.ts') + '?to=js&nobundle'
+ const result = await load.call({ addWatchFile }, id)
+
+ expect(result.code).not.toContain('import.meta.hot')
+ })
+
+ it('load includes HMR accept code after configureServer', async () => {
+ const plugin = cookedPlugin()
+ const configureServer = plugin.configureServer as Function
+ const load = plugin.load as Function
+ const addWatchFile = vi.fn()
+
+ // Simulate dev server
+ const mockServer = {
+ watcher: { on: vi.fn() },
+ moduleGraph: { idToModuleMap: new Map() },
+ }
+ configureServer(mockServer)
+
+ const id = fixture('with-types.ts') + '?to=js&nobundle'
+ const result = await load.call({ addWatchFile }, id)
+
+ expect(result.code).toContain('export default ')
+ expect(result.code).toContain('import.meta.hot')
+ expect(result.code).toContain('import.meta.hot.accept()')
+ })
+
+ it('configureServer invalidates cooked modules on file change', () => {
+ const plugin = cookedPlugin()
+ const configureServer = plugin.configureServer as Function
+
+ const invalidateModule = vi.fn()
+ const cookedModId = fixture('basic.ts') + '?to=js'
+ const otherModId = '/some/other.ts?to=js'
+
+ const mockMod = { id: cookedModId }
+ const otherMod = { id: otherModId }
+ const idToModuleMap = new Map([
+ [cookedModId, mockMod],
+ [otherModId, otherMod],
+ ])
+
+ let changeHandler: Function
+ const mockServer = {
+ watcher: {
+ on: vi.fn((event: string, handler: Function) => {
+ if (event === 'change') changeHandler = handler
+ }),
+ },
+ moduleGraph: { idToModuleMap, invalidateModule },
+ }
+
+ configureServer(mockServer)
+
+ // Trigger file change for the cooked source file
+ changeHandler!(fixture('basic.ts'))
+
+ // Should invalidate the matching cooked module
+ expect(invalidateModule).toHaveBeenCalledWith(mockMod)
+ // Should NOT invalidate unrelated modules
+ expect(invalidateModule).not.toHaveBeenCalledWith(otherMod)
+ })
})