Skip to content

Commit b65a80a

Browse files
antfuautofix-ci[bot]
andauthoredOct 14, 2024··
feat(eslint-plugin): new nuxt-config-keys-order rule (#491)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent cc5a568 commit b65a80a

File tree

10 files changed

+506
-31
lines changed

10 files changed

+506
-31
lines changed
 

‎docs/nuxt.config.ts

+18-18
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
export default defineNuxtConfig({
22
extends: '@nuxt/ui-pro',
33

4-
routeRules: {
5-
'/guide': { redirect: '/guide/getting-started' },
6-
},
7-
8-
site: {
9-
url: 'https://eslint.nuxt.com',
10-
},
11-
124
modules: [
135
'@nuxt/image',
146
'@nuxt/content',
@@ -20,6 +12,18 @@ export default defineNuxtConfig({
2012
'nuxt-og-image',
2113
],
2214

15+
$production: {
16+
nitro: {
17+
experimental: {
18+
wasm: true,
19+
},
20+
},
21+
},
22+
23+
site: {
24+
url: 'https://eslint.nuxt.com',
25+
},
26+
2327
colorMode: {
2428
preference: 'dark',
2529
},
@@ -28,6 +32,12 @@ export default defineNuxtConfig({
2832
icons: ['heroicons', 'simple-icons', 'ph'],
2933
},
3034

35+
routeRules: {
36+
'/guide': { redirect: '/guide/getting-started' },
37+
},
38+
39+
compatibilityDate: '2024-09-01',
40+
3141
nitro: {
3242
prerender: {
3343
routes: ['/api/search.json'],
@@ -46,14 +56,4 @@ export default defineNuxtConfig({
4656
}
4757
},
4858
},
49-
50-
$production: {
51-
nitro: {
52-
experimental: {
53-
wasm: true,
54-
},
55-
},
56-
},
57-
58-
compatibilityDate: '2024-09-01',
5959
})

‎packages/eslint-config/src/flat/configs/nuxt.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@ export default function nuxt(options: NuxtESLintConfigOptions): Linter.Config[]
1515
...(dirs.components?.map(componentsDir => join(componentsDir, `**/*.server.${GLOB_EXTS}`)) || []),
1616
].sort()
1717

18+
const {
19+
sortConfigKeys = !!(options.features?.stylistic),
20+
} = options.features?.nuxt || {}
21+
1822
const configs: Linter.Config[] = []
1923

2024
configs.push({
21-
name: 'nuxt/configs',
25+
name: 'nuxt/setup',
26+
plugins: {
27+
nuxt: nuxtPlugin,
28+
},
2229
languageOptions: {
2330
globals: {
2431
// Nuxt's runtime globals
@@ -38,13 +45,22 @@ export default function nuxt(options: NuxtESLintConfigOptions): Linter.Config[]
3845

3946
configs.push({
4047
name: 'nuxt/rules',
41-
plugins: {
42-
nuxt: nuxtPlugin,
43-
},
4448
rules: {
4549
'nuxt/prefer-import-meta': 'error',
4650
},
4751
})
4852

53+
if (sortConfigKeys) {
54+
configs.push({
55+
name: 'nuxt/sort-config',
56+
files: [
57+
'**/nuxt.config.?([cm])[jt]s?(x)',
58+
],
59+
rules: {
60+
'nuxt/nuxt-config-keys-order': 'error',
61+
},
62+
})
63+
}
64+
4965
return configs
5066
}

‎packages/eslint-config/src/flat/types.ts

+14
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export interface ToolingOptions {
2323
jsdoc?: boolean
2424
}
2525

26+
export interface NuxtSpecificOptions {
27+
/**
28+
* Sort keys in nuxt.config to maintain a consistent order
29+
*
30+
* @default true when `features.stylistic` is enabled
31+
*/
32+
sortConfigKeys?: boolean
33+
}
34+
2635
export interface NuxtESLintFeaturesOptions {
2736
/**
2837
* Setup basic JavaScript, TypeScript and Vue plugins and rules.
@@ -49,6 +58,11 @@ export interface NuxtESLintFeaturesOptions {
4958
*/
5059
stylistic?: boolean | StylisticCustomizeOptions<true>
5160

61+
/**
62+
* Options for Nuxt specific rules
63+
*/
64+
nuxt?: NuxtSpecificOptions
65+
5266
/**
5367
* Enable TypeScript support. Can also be an object to config the options.
5468
*

‎packages/eslint-config/src/flat/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function resolveOptions(
3434
stylistic: false,
3535
typescript: isPackageExists('typescript'),
3636
tooling: false,
37+
nuxt: {},
3738
...config.features,
3839
},
3940
dirs,

‎packages/eslint-config/test/__snapshots__/flat-compose.test.ts.snap

+103-3
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ exports[`flat config composition > custom src dirs 1`] = `
5757
"name": "nuxt/import/rules",
5858
},
5959
{
60-
"name": "nuxt/configs",
60+
"name": "nuxt/setup",
6161
},
6262
{
6363
"files": [
@@ -148,7 +148,7 @@ exports[`flat config composition > empty 1`] = `
148148
"name": "nuxt/import/rules",
149149
},
150150
{
151-
"name": "nuxt/configs",
151+
"name": "nuxt/setup",
152152
},
153153
{
154154
"files": [
@@ -185,7 +185,7 @@ exports[`flat config composition > empty 1`] = `
185185
exports[`flat config composition > non-standalone 1`] = `
186186
[
187187
{
188-
"name": "nuxt/configs",
188+
"name": "nuxt/setup",
189189
},
190190
{
191191
"files": [
@@ -218,3 +218,103 @@ exports[`flat config composition > non-standalone 1`] = `
218218
},
219219
]
220220
`;
221+
222+
exports[`flat config composition > with stylistic 1`] = `
223+
[
224+
{
225+
"ignores": [
226+
"**/node_modules",
227+
"**/*.iml",
228+
"**/.idea",
229+
"**/*.log",
230+
"**/.nuxt",
231+
"**/.output",
232+
"**/.yarn/cache",
233+
"**/.yarn/*state*",
234+
"**/dist",
235+
"**/.eslintcache",
236+
],
237+
"name": "gitignore",
238+
},
239+
{
240+
"ignores": [
241+
"**/dist",
242+
"**/node_modules",
243+
"**/.nuxt",
244+
"**/.output",
245+
"**/.vercel",
246+
"**/.netlify",
247+
"**/public",
248+
],
249+
},
250+
{
251+
"name": "nuxt/javascript",
252+
},
253+
{
254+
"name": "nuxt/typescript/setup",
255+
},
256+
{
257+
"files": [
258+
"**/*.ts",
259+
"**/*.tsx",
260+
"**/*.mts",
261+
"**/*.cts",
262+
"**/*.vue",
263+
],
264+
"name": "nuxt/typescript/rules",
265+
},
266+
{
267+
"name": "nuxt/vue/setup",
268+
},
269+
{
270+
"files": [
271+
"**/*.vue",
272+
],
273+
"name": "nuxt/vue/rules",
274+
},
275+
{
276+
"name": "nuxt/import/rules",
277+
},
278+
{
279+
"name": "nuxt/setup",
280+
},
281+
{
282+
"files": [
283+
"app/components/**/*.server.{js,ts,jsx,tsx,vue}",
284+
"app/layouts/**/*.{js,ts,jsx,tsx,vue}",
285+
"app/pages/**/*.{js,ts,jsx,tsx,vue}",
286+
"components/**/*.server.{js,ts,jsx,tsx,vue}",
287+
"layouts/**/*.{js,ts,jsx,tsx,vue}",
288+
"pages/**/*.{js,ts,jsx,tsx,vue}",
289+
],
290+
"name": "nuxt/vue/single-root",
291+
},
292+
{
293+
"name": "nuxt/rules",
294+
},
295+
{
296+
"files": [
297+
"**/nuxt.config.?([cm])[jt]s?(x)",
298+
],
299+
"name": "nuxt/sort-config",
300+
},
301+
{
302+
"name": "nuxt/stylistic",
303+
},
304+
{
305+
"files": [
306+
"app.{js,ts,jsx,tsx,vue}",
307+
"app/app.{js,ts,jsx,tsx,vue}",
308+
"app/components/*/**/*.{js,ts,jsx,tsx,vue}",
309+
"app/error.{js,ts,jsx,tsx,vue}",
310+
"app/layouts/**/*.{js,ts,jsx,tsx,vue}",
311+
"app/pages/**/*.{js,ts,jsx,tsx,vue}",
312+
"components/*/**/*.{js,ts,jsx,tsx,vue}",
313+
"error.{js,ts,jsx,tsx,vue}",
314+
"layouts/**/*.{js,ts,jsx,tsx,vue}",
315+
"pages/**/*.{js,ts,jsx,tsx,vue}",
316+
],
317+
"name": "nuxt/disables/routes",
318+
},
319+
]
320+
`;

‎packages/eslint-config/test/flat-compose.test.ts

+11
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,15 @@ describe('flat config composition', () => {
4343
expect(getFlatConfigDigest(configs))
4444
.toMatchSnapshot()
4545
})
46+
47+
it('with stylistic', async () => {
48+
const configs = await createConfigForNuxt({
49+
features: {
50+
stylistic: true,
51+
},
52+
})
53+
54+
expect(getFlatConfigDigest(configs))
55+
.toMatchSnapshot()
56+
})
4657
})
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { rule as preferImportMetaRule } from './prefer-import-meta'
2+
import { rule as nuxtConfigOrderKeysRule } from './nuxt-config-keys-order'
23

34
export default {
45
'prefer-import-meta': preferImportMetaRule,
6+
'nuxt-config-keys-order': nuxtConfigOrderKeysRule,
57
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import type { TSESTree as Tree, TSESLint } from '@typescript-eslint/utils'
2+
import { createRule } from '../utils'
3+
import { ORDER_KEYS } from './keys'
4+
5+
type MessageIds = 'default'
6+
7+
type Options = []
8+
9+
export const rule = createRule<MessageIds, Options>({
10+
name: 'nuxt-config-keys-order',
11+
meta: {
12+
type: 'suggestion',
13+
docs: {
14+
description: 'Prefer recommended order of Nuxt config properties',
15+
},
16+
schema: [],
17+
messages: {
18+
default: 'Expected config key "{{a}}" to come before "{{b}}"',
19+
},
20+
fixable: 'code',
21+
},
22+
defaultOptions: [],
23+
create(context) {
24+
if (!context.filename.match(/nuxt\.config\.[mc]?[jt]sx?$/)) {
25+
return {}
26+
}
27+
28+
return {
29+
ExportDefaultDeclaration(node) {
30+
let object: Tree.ObjectExpression | undefined
31+
if (node.declaration.type === 'ObjectExpression') {
32+
object = node.declaration
33+
}
34+
else if (node.declaration.type === 'CallExpression' && node.declaration.arguments[0].type === 'ObjectExpression') {
35+
object = node.declaration.arguments[0]
36+
}
37+
if (!object) {
38+
return
39+
}
40+
41+
const hasFixes = sort(context, object)
42+
if (!hasFixes) {
43+
const envProps = object.properties.filter(i => i.type === 'Property' && i.key.type === 'Identifier' && i.key.name.startsWith('$')) as Tree.Property[]
44+
for (const prop of envProps) {
45+
if (prop.value.type === 'ObjectExpression')
46+
sort(context, prop.value)
47+
}
48+
}
49+
},
50+
}
51+
},
52+
})
53+
54+
function sort(context: TSESLint.RuleContext<MessageIds, Options>, node: Tree.ObjectExpression) {
55+
return sortAst(
56+
context,
57+
node,
58+
node.properties as Tree.Property[],
59+
(prop) => {
60+
if (prop.type === 'Property')
61+
return getString(prop.key)
62+
return null
63+
},
64+
sortKeys,
65+
)
66+
}
67+
68+
function sortKeys(a: string, b: string) {
69+
const indexA = ORDER_KEYS.findIndex(k => typeof k === 'string' ? k === a : k.test(a))
70+
const indexB = ORDER_KEYS.findIndex(k => typeof k === 'string' ? k === b : k.test(b))
71+
if (indexA === -1 && indexB !== -1)
72+
return 1
73+
if (indexA !== -1 && indexB === -1)
74+
return -1
75+
if (indexA < indexB)
76+
return -1
77+
if (indexA > indexB)
78+
return 1
79+
return a.localeCompare(b)
80+
}
81+
82+
// Ported from https://github.com/gauben/eslint-plugin-command/blob/04efa47a2319a5f9afb395cf0efccc9cb111058d/src/commands/keep-sorted.ts#L138-L144
83+
function sortAst<T extends Tree.Node>(
84+
context: TSESLint.RuleContext<MessageIds, Options>,
85+
node: Tree.Node,
86+
list: T[],
87+
getName: (node: T) => string | (string | null)[] | null,
88+
sort: (a: string, b: string) => number = (a, b) => a.localeCompare(b),
89+
insertComma = true,
90+
) {
91+
const firstToken = context.sourceCode.getFirstToken(node)!
92+
const lastToken = context.sourceCode.getLastToken(node)!
93+
if (!firstToken || !lastToken)
94+
return false
95+
96+
if (list.length < 2)
97+
return false
98+
99+
const reordered = list.slice()
100+
const ranges = new Map<typeof list[number], [number, number, string]>()
101+
const names = new Map<typeof list[number], (string | null)[] | null>()
102+
103+
const rangeStart = Math.max(
104+
firstToken.range[1],
105+
context.sourceCode.getIndexFromLoc({
106+
line: list[0].loc.start.line,
107+
column: 0,
108+
}),
109+
)
110+
111+
let rangeEnd = rangeStart
112+
for (let i = 0; i < list.length; i++) {
113+
const item = list[i]
114+
let name = getName(item)
115+
if (typeof name === 'string')
116+
name = [name]
117+
names.set(item, name)
118+
119+
let lastRange = item.range[1]
120+
const nextToken = context.sourceCode.getTokenAfter(item)
121+
if (nextToken?.type === 'Punctuator' && nextToken.value === ',')
122+
lastRange = nextToken.range[1]
123+
const nextChar = context.sourceCode.getText()[lastRange]
124+
125+
// Insert comma if it's the last item without a comma
126+
let text = getTextOf(context.sourceCode, [rangeEnd, lastRange])
127+
if (nextToken === lastToken && insertComma)
128+
text += ','
129+
130+
// Include subsequent newlines
131+
if (nextChar === '\n') {
132+
lastRange++
133+
text += '\n'
134+
}
135+
136+
ranges.set(item, [rangeEnd, lastRange, text])
137+
rangeEnd = lastRange
138+
}
139+
140+
const segments: [number, number][] = []
141+
let segmentStart: number = -1
142+
for (let i = 0; i < list.length; i++) {
143+
if (names.get(list[i]) == null) {
144+
if (segmentStart > -1)
145+
segments.push([segmentStart, i])
146+
segmentStart = -1
147+
}
148+
else {
149+
if (segmentStart === -1)
150+
segmentStart = i
151+
}
152+
}
153+
if (segmentStart > -1 && segmentStart !== list.length - 1)
154+
segments.push([segmentStart, list.length])
155+
156+
for (const [start, end] of segments) {
157+
reordered.splice(
158+
start,
159+
end - start,
160+
...reordered
161+
.slice(start, end)
162+
.sort((a, b) => {
163+
const nameA: (string | null)[] = names.get(a)!
164+
const nameB: (string | null)[] = names.get(b)!
165+
166+
const length = Math.max(nameA.length, nameB.length)
167+
for (let i = 0; i < length; i++) {
168+
const a = nameA[i]
169+
const b = nameB[i]
170+
if (a == null || b == null || a === b)
171+
continue
172+
return sort(a, b)
173+
}
174+
return 0
175+
}),
176+
)
177+
}
178+
179+
const changed = reordered.some((prop, i) => prop !== list[i])
180+
if (!changed)
181+
return false
182+
183+
const newContent = reordered
184+
.map(i => ranges.get(i)![2])
185+
.join('')
186+
187+
// console.log({
188+
// reordered,
189+
// newContent,
190+
// oldContent: ctx.context.sourceCode.text.slice(rangeStart, rangeEnd),
191+
// })
192+
193+
context.report({
194+
node,
195+
messageId: 'default',
196+
data: {
197+
a: names.get(reordered[0])![0]!,
198+
b: names.get(reordered[1])![0]!,
199+
},
200+
fix(fixer) {
201+
return fixer.replaceTextRange([rangeStart, rangeEnd], newContent)
202+
},
203+
})
204+
}
205+
206+
function getTextOf(sourceCode: TSESLint.SourceCode, node?: Tree.Node | Tree.Token | Tree.Range | null) {
207+
if (!node)
208+
return ''
209+
if (Array.isArray(node))
210+
return sourceCode.text.slice(node[0], node[1])
211+
return sourceCode.getText(node)
212+
}
213+
214+
function getString(node: Tree.Node): string | null {
215+
if (node.type === 'Identifier')
216+
return node.name
217+
if (node.type === 'Literal')
218+
return String(node.raw)
219+
return null
220+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
export const OFFICIAL_MODULES = {
2+
client: [
3+
'site', // SEO module
4+
'colorMode',
5+
'content',
6+
'mdc',
7+
'ui',
8+
],
9+
10+
server: [
11+
'hub',
12+
],
13+
}
14+
15+
export const ORDER_KEYS = [
16+
// Ids
17+
'appId',
18+
'buildId',
19+
20+
// Extends
21+
'extends',
22+
'theme',
23+
24+
// Extensions
25+
'modules',
26+
'plugins',
27+
28+
// Env ($production, $development, $test)
29+
/^\$/,
30+
31+
// Nuxt Core Features
32+
'ssr',
33+
'pages',
34+
'components',
35+
'imports',
36+
'devtools',
37+
38+
// Client-side Integrations
39+
'app',
40+
'css',
41+
'vue',
42+
'router',
43+
'unhead',
44+
...OFFICIAL_MODULES.client,
45+
'spaLoadingTemplate',
46+
47+
// Runtime Configs
48+
'appConfig',
49+
'runtimeConfig',
50+
51+
// Dirs
52+
'dir',
53+
'rootDir',
54+
'srcDir',
55+
'appDir',
56+
'workspaceDir',
57+
'serverDir',
58+
'buildDir',
59+
'modulesDir',
60+
'analyzeDir',
61+
62+
// Resultions
63+
'alias',
64+
'extensions',
65+
'ignore',
66+
'ignoreOptions',
67+
'ignorePrefix',
68+
69+
// Build Pipeline Configs
70+
'builder',
71+
'build',
72+
'generate',
73+
'routeRules',
74+
'sourcemap',
75+
'optimization',
76+
77+
// Development
78+
'dev',
79+
'devServer',
80+
'watch',
81+
'watchers',
82+
83+
// Feature flags
84+
'future',
85+
'features',
86+
'experimental',
87+
'compatibilityDate',
88+
89+
// Nitro
90+
'nitro',
91+
...OFFICIAL_MODULES.server,
92+
'serverHandlers',
93+
'devServerHandlers',
94+
95+
// Tooling Integrations
96+
'vite',
97+
'webpack',
98+
'typescript',
99+
'postcss',
100+
101+
// Other Integrations
102+
'test',
103+
'telemetry',
104+
105+
// Logging
106+
'debug',
107+
'logLevel',
108+
109+
// Hooks
110+
'hooks',
111+
]

‎playground/nuxt.config.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ export default defineNuxtConfig({
33
'../packages/module/src/module',
44
],
55

6-
devtools: {
7-
enabled: true,
8-
},
9-
106
components: [
117
'~/components',
128
{ path: '~/components-prefixed', prefix: 'Prefix' },
139
],
1410

11+
devtools: {
12+
enabled: true,
13+
},
14+
15+
compatibilityDate: '2024-07-27',
16+
1517
eslint: {
1618
config: {
1719
// configFile: './eslint.nuxt.config.mjs',
@@ -22,6 +24,4 @@ export default defineNuxtConfig({
2224
fix: true,
2325
},
2426
},
25-
26-
compatibilityDate: '2024-07-27',
2727
})

0 commit comments

Comments
 (0)
Please sign in to comment.