Skip to content

Commit 669bc5e

Browse files
committed
refactor(info): load nuxt.config natively and make jiti an optional peer
1 parent 297bf24 commit 669bc5e

7 files changed

Lines changed: 216 additions & 26 deletions

File tree

‎knip.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
"ignoreDependencies": [
3737
"@bomb.sh/tab"
3838
]
39+
},
40+
"packages/nuxi": {
41+
"ignoreDependencies": [
42+
"jiti"
43+
]
3944
}
4045
}
4146
}

‎packages/nuxi/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"devDependencies": {
3636
"@types/node": "^24.13.3",
3737
"citty": "^0.2.2",
38+
"jiti": "^2.7.0",
3839
"picocolors": "^1.1.1",
3940
"rollup-plugin-visualizer": "^7.0.1",
4041
"std-env": "^4.2.0",

‎packages/nuxt-cli/package.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@
3434
},
3535
"peerDependencies": {
3636
"@nuxt/schema": "^4.4.6",
37+
"jiti": "^2.7.0",
3738
"serve": "^14.0.0"
3839
},
3940
"peerDependenciesMeta": {
4041
"@nuxt/schema": {
4142
"optional": true
4243
},
44+
"jiti": {
45+
"optional": true
46+
},
4347
"serve": {
4448
"optional": true
4549
}
@@ -56,7 +60,6 @@
5660
"fzf": "^0.5.2",
5761
"get-port-please": "^3.2.0",
5862
"giget": "^3.3.0",
59-
"jiti": "^2.7.0",
6063
"magicast": "^0.5.3",
6164
"nypm": "^0.6.8",
6265
"obug": "^2.1.1",
@@ -87,6 +90,7 @@
8790
"@types/node": "^24.13.3",
8891
"h3": "^1.15.11",
8992
"h3-next": "npm:h3@^2.0.1-rc.25",
93+
"jiti": "^2.7.0",
9094
"nitro": "^3.0.1-alpha.2",
9195
"nitropack": "^2.13.4",
9296
"rollup": "^4.62.2",

‎packages/nuxt-cli/src/commands/info.ts‎

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { resolveCatalogEntry } from '../utils/catalog'
1919
import{formatInfoBox}from'../utils/formatting'
2020
import{tryResolveNuxt}from'../utils/kit'
2121
import{logger}from'../utils/logger'
22+
import{getNuxtConfig}from'../utils/nuxt-config'
2223
import{getPackageManagerVersion}from'../utils/packageManagers'
2324
import{resolveRootDir}from'../utils/paths'
2425
import{rootDirArgs}from'./_shared'
@@ -206,25 +207,3 @@ function normalizeConfigModule(
206207
}
207208
returnnull
208209
}
209-
210-
asyncfunctiongetNuxtConfig(rootDir: string){
211-
try{
212-
const{ createJiti }=awaitimport('jiti')
213-
constjiti=createJiti(rootDir,{
214-
interopDefault: true,
215-
// allow using `~` and `@` in `nuxt.config`
216-
alias: {
217-
'~': rootDir,
218-
'@': rootDir,
219-
},
220-
})
221-
;(globalThisasany).defineNuxtConfig=(c: any)=>c
222-
constresult=awaitjiti.import('./nuxt.config',{default: true})asNuxtConfig
223-
delete(globalThisasany).defineNuxtConfig
224-
returnresult
225-
}
226-
catch{
227-
// TODO: Show error as warning if it is not 404
228-
return{}
229-
}
230-
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
importtype{NuxtConfig}from'@nuxt/schema'
2+
3+
importprocessfrom'node:process'
4+
import{pathToFileURL}from'node:url'
5+
6+
import{resolveModulePath}from'exsolve'
7+
import{join}from'pathe'
8+
9+
constCONFIG_EXTENSIONS=['.js','.ts','.mjs','.cjs','.mts','.cts']
10+
11+
/**
12+
* Errors that mean the config needs a loader rather than that it is broken:
13+
* unresolvable specifiers (`~`/`@` aliases), TypeScript that cannot be stripped
14+
* (`enum`, `namespace`, parameter properties), and Node versions predating
15+
* built-in type stripping.
16+
*/
17+
constLOADER_REQUIRED_CODES=newSet([
18+
'ERR_MODULE_NOT_FOUND',
19+
'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX',
20+
'ERR_UNKNOWN_FILE_EXTENSION',
21+
])
22+
23+
exportasyncfunctiongetNuxtConfig(rootDir: string){
24+
constconfigFile=resolveModulePath('./nuxt.config',{
25+
try: true,
26+
from: pathToFileURL(join(rootDir,'/')).href,
27+
extensions: CONFIG_EXTENSIONS,
28+
})
29+
if(!configFile){
30+
return{}
31+
}
32+
33+
;(globalThisasany).defineNuxtConfig=(c: any)=>c
34+
try{
35+
try{
36+
returnawaitimportWithoutTypelessWarning(pathToFileURL(configFile).href)
37+
}
38+
catch(error){
39+
if(!LOADER_REQUIRED_CODES.has((errorasNodeJS.ErrnoException).code!)){
40+
throwerror
41+
}
42+
returnawaitimportConfigWithJiti(rootDir)
43+
}
44+
}
45+
catch{
46+
// TODO: Show error as warning if it is not 404
47+
return{}
48+
}
49+
finally{
50+
delete(globalThisasany).defineNuxtConfig
51+
}
52+
}
53+
54+
/**
55+
* Node warns when it has to reparse a `.ts` file as ESM because the nearest
56+
* `package.json` has no `type`. Reading the config is an internal detail of
57+
* `nuxt info` and the warning describes our loader rather than the user's
58+
* project, so it is dropped for the duration of the import.
59+
*/
60+
asyncfunctionimportWithoutTypelessWarning(href: string): Promise<NuxtConfig>{
61+
constemitWarning=process.emitWarning
62+
process.emitWarning=(warning: string|Error, ...args: any[])=>{
63+
constcode=args.find(arg=>typeofarg==='object'&&arg)?.code??args[1]
64+
if(code!=='MODULE_TYPELESS_PACKAGE_JSON'){
65+
(emitWarningasany)(warning, ...args)
66+
}
67+
}
68+
try{
69+
returnawaitimport(href).then(m=>m.default)asNuxtConfig
70+
}
71+
finally{
72+
process.emitWarning=emitWarning
73+
}
74+
}
75+
76+
/**
77+
* `jiti` is an optional peer dependency, so it is only present when the project
78+
* (or something in its tree) depends on it. Nuxt does, so this is the normal path
79+
* for configs that native `import()` cannot handle.
80+
*/
81+
asyncfunctionimportConfigWithJiti(rootDir: string){
82+
const{ createJiti }=awaitimport('jiti')
83+
constjiti=createJiti(rootDir,{
84+
interopDefault: true,
85+
// allow using `~` and `@` in `nuxt.config`
86+
alias: {
87+
'~': rootDir,
88+
'@': rootDir,
89+
},
90+
})
91+
returnawaitjiti.import('./nuxt.config',{default: true})asNuxtConfig
92+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import{execFile}from'node:child_process'
2+
import{mkdtemp,writeFile}from'node:fs/promises'
3+
import{tmpdir}from'node:os'
4+
import{join}from'node:path'
5+
importprocessfrom'node:process'
6+
import{fileURLToPath}from'node:url'
7+
import{promisify}from'node:util'
8+
9+
import{describe,expect,it}from'vitest'
10+
11+
constexecFileAsync=promisify(execFile)
12+
13+
constloaderPath=fileURLToPath(newURL('../../../src/utils/nuxt-config.ts',import.meta.url))
14+
15+
/**
16+
* `getNuxtConfig` picks between Node's own loader and `jiti` by inspecting the
17+
* error codes Node raises, and Vitest resolves dynamic imports through Vite
18+
* rather than Node. Every case therefore runs in a real Node process, otherwise
19+
* the branch under test is never the one taken.
20+
*/
21+
asyncfunctionloadConfig(files: Record<string,string>){
22+
constcwd=awaitmkdtemp(join(tmpdir(),'nuxi-nuxt-config-'))
23+
for(const[name,contents]ofObject.entries(files)){
24+
awaitwriteFile(join(cwd,name),contents,'utf8')
25+
}
26+
27+
constscript=`
28+
const { getNuxtConfig } = await import(${JSON.stringify(loaderPath)})
29+
process.stdout.write(JSON.stringify(await getNuxtConfig(${JSON.stringify(cwd)})))
30+
`
31+
const{ stdout, stderr }=awaitexecFileAsync(process.execPath,['--input-type=module','--eval',script],{ cwd })
32+
return{config: JSON.parse(stdout), stderr }
33+
}
34+
35+
describe('getNuxtConfig',()=>{
36+
it('should load a config with Node\'s own loader',async()=>{
37+
const{ config }=awaitloadConfig({
38+
'package.json': '{"name":"native","type":"module"}',
39+
'nuxt.config.ts': 'export default defineNuxtConfig({ modules: [\'@nuxt/image\'] })\n',
40+
})
41+
42+
expect(config).toMatchObject({modules: ['@nuxt/image']})
43+
})
44+
45+
it('should strip erasable types',async()=>{
46+
const{ config }=awaitloadConfig({
47+
'package.json': '{"name":"typed","type":"module"}',
48+
'nuxt.config.ts': 'const ssr: boolean = false\nexport default defineNuxtConfig({ ssr })\n',
49+
})
50+
51+
expect(config).toMatchObject({ssr: false})
52+
})
53+
54+
it('should fall back to `jiti` for aliases Node cannot resolve',async()=>{
55+
const{ config }=awaitloadConfig({
56+
'package.json': '{"name":"aliased","type":"module"}',
57+
'module.ts': 'export const mod = \'@nuxt/content\'\n',
58+
'nuxt.config.ts': 'import { mod } from \'~/module\'\n\nexport default defineNuxtConfig({ modules: [mod] })\n',
59+
})
60+
61+
expect(config).toMatchObject({modules: ['@nuxt/content']})
62+
})
63+
64+
it('should fall back to `jiti` for TypeScript that cannot be stripped',async()=>{
65+
const{ config }=awaitloadConfig({
66+
'package.json': '{"name":"enums","type":"module"}',
67+
'nuxt.config.ts': 'enum Mod { Image = \'@nuxt/image\' }\n\nexport default defineNuxtConfig({ modules: [Mod.Image] })\n',
68+
})
69+
70+
expect(config).toMatchObject({modules: ['@nuxt/image']})
71+
})
72+
73+
it('should load a plain JavaScript config',async()=>{
74+
const{ config }=awaitloadConfig({
75+
'package.json': '{"name":"plain","type":"module"}',
76+
'nuxt.config.mjs': 'export default defineNuxtConfig({ ssr: false })\n',
77+
})
78+
79+
expect(config).toMatchObject({ssr: false})
80+
})
81+
82+
it('should return an empty config when there is no config file',async()=>{
83+
const{ config }=awaitloadConfig({'package.json': '{"name":"bare","type":"module"}'})
84+
85+
expect(config).toEqual({})
86+
})
87+
88+
it('should return an empty config when the config throws',async()=>{
89+
const{ config }=awaitloadConfig({
90+
'package.json': '{"name":"broken","type":"module"}',
91+
'nuxt.config.ts': 'throw new Error(\'boom\')\n',
92+
})
93+
94+
expect(config).toEqual({})
95+
})
96+
97+
it('should not warn about module type for a config without `type: module`',async()=>{
98+
const{ config, stderr }=awaitloadConfig({
99+
'package.json': '{"name":"typeless"}',
100+
'nuxt.config.ts': 'export default defineNuxtConfig({ ssr: true })\n',
101+
})
102+
103+
expect(config).toMatchObject({ssr: true})
104+
expect(stderr).not.toContain('MODULE_TYPELESS_PACKAGE_JSON')
105+
})
106+
})

‎pnpm-lock.yaml‎

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)