@scayle/eslint-auto-explicit-import 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # @scayle/eslint-auto-explicit-import
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SCAYLE GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @scayle/storefront-eslint-auto-explicit-import
2
+
3
+ An extension of the official `@nuxt/eslint` module to insert more explicit import statement
4
+ automatically using `eslint --fix` into a Nuxt-based SCAYLE Storefront project.
5
+
6
+ NOTE: Currently composables, utilities, helper functions and imported functions from packages will
7
+ be automatically inserted as imports. No components used within a `<template>` will be imported by it!
8
+
9
+ - Based on [nuxt-eslint-auto-explicit-import](https://github.com/antfu/nuxt-eslint-auto-explicit-import)
10
+ - Based on [eslint-plugin-unimport](https://github.com/antfu/eslint-plugin-unimport)
11
+ - [Official @nuxt/eslint module](https://eslint.nuxt.com/)
12
+ - [Official ESLint Documentation](https://eslint.org/docs/latest/)
13
+
14
+ ## Example
15
+
16
+ - [Example Commit on eslint-flat-config-viewer](https://github.com/antfu/eslint-flat-config-viewer/commit/0f8000851b4ac0d7f3ea5e49963c6d7248303b7b)
17
+
18
+ ## Details
19
+
20
+ ## Usage
21
+
22
+ Add local module to `nuxt.config.ts`:
23
+
24
+ ```ts
25
+ export default defineNuxtConfig({
26
+ modules: [
27
+ // Both are required
28
+ '@nuxt/eslint',
29
+ '@scayle/eslint-auto-explicit-import',
30
+ ],
31
+ })
32
+ ```
33
+
34
+ Followed by adding a dedicated `eslint` flat config if none exists:
35
+
36
+ ```js
37
+ // eslint.config.mjs
38
+ import withNuxt from './nuxt/eslint.config.mjs'
39
+
40
+ export default withNuxt({
41
+ // Your ESLint config
42
+ })
43
+ ```
44
+
45
+ Running `eslint` should now throw errors if a source file does use auto-import.
46
+ Adding `--fix` allows for automatically inserting / fixing of missing imports.
47
+
48
+ ```sh
49
+ # pnpm lint .
50
+ pnpm lint . --fix
51
+ ```
52
+
53
+ ## Troubleshooting
54
+
55
+ 1. Should a Vue component not have a dedicated `<script>` tag with some content within,
56
+ it might be possible that the included local `eslint-plugin-unimport` fails with a
57
+ `range` undefined error.
58
+ In this case it is necessary to manually add the missing `<script>` tag with some dummy content.
@@ -0,0 +1,392 @@
1
+ import { dirname, isAbsolute, relative } from 'pathe'
2
+ import { ESLintUtils } from '@typescript-eslint/utils'
3
+ import { analyze } from '@typescript-eslint/scope-manager'
4
+ import Debug from 'debug'
5
+
6
+ // Based on <https://github.com/antfu/eslint-plugin-unimport>
7
+
8
+ const version = '0.0.3'
9
+
10
+ const createRule = ESLintUtils.RuleCreator(
11
+ () => 'https://github.com/antfu/eslint-plugin-unimport',
12
+ )
13
+
14
+ function betterRelative(from, to) {
15
+ // eslint-disable-next-line sonarjs/single-char-in-character-classes
16
+ const r = relative(from, to).replace(/\.[\w]+/g, '')
17
+
18
+ if (r.startsWith('../')) {
19
+ return r
20
+ }
21
+
22
+ return `./${r}`
23
+ }
24
+
25
+ const debug = Debug('unimport:eslint')
26
+
27
+ function createImportsListeners(context, imports, onImportEntry) {
28
+ let _scopeManager
29
+ let _importsMap
30
+ const importedNames = /* @__PURE__ */ new Set()
31
+
32
+ function getScopeManager() {
33
+ if (!_scopeManager) {
34
+ _scopeManager = analyze(context.sourceCode.ast, {
35
+ sourceType: 'module',
36
+ })
37
+
38
+ _scopeManager.globalScope?.variables.forEach((node) => {
39
+ importedNames.add(node.name)
40
+ })
41
+ }
42
+
43
+ return _scopeManager
44
+ }
45
+
46
+ function getImportsMap() {
47
+ if (!_importsMap) {
48
+ _importsMap = /* @__PURE__ */ new Map()
49
+ imports.forEach((i) => {
50
+ _importsMap.set(i.as || i.name, i)
51
+ })
52
+ }
53
+
54
+ return _importsMap
55
+ }
56
+
57
+ // eslint-disable-next-line sonarjs/cognitive-complexity
58
+ function checkId(node) {
59
+ if (typeof node.name !== 'string') {
60
+ return
61
+ }
62
+
63
+ if (importedNames.has(node.name)) {
64
+ return
65
+ }
66
+
67
+ const importsMap = getImportsMap()
68
+ const item = importsMap.get(node.name)
69
+
70
+ if (!item) {
71
+ return
72
+ }
73
+
74
+ if (item.from === context.filename) {
75
+ return
76
+ }
77
+
78
+ const scopeManager = getScopeManager()
79
+
80
+ if (importedNames.has(node.name)) {
81
+ return
82
+ }
83
+
84
+ let parent = node.parent
85
+ let currentScope = null
86
+
87
+ while (parent && !currentScope) {
88
+ currentScope = scopeManager.acquire(parent)
89
+
90
+ if (currentScope) {
91
+ break
92
+ }
93
+
94
+ parent = parent.parent
95
+ }
96
+
97
+ if (!currentScope) {
98
+ currentScope = scopeManager.globalScope
99
+ }
100
+
101
+ const visited = /* @__PURE__ */ new Set()
102
+
103
+ while (true) {
104
+ if (!currentScope || visited.has(currentScope)) {
105
+ break
106
+ }
107
+
108
+ for (const ref of currentScope.variables) {
109
+ if (ref.name === node.name) {
110
+ return
111
+ }
112
+ }
113
+
114
+ visited.add(currentScope)
115
+ currentScope = currentScope.upper
116
+ }
117
+
118
+ importedNames.add(node.name)
119
+ onImportEntry(node, item)
120
+ }
121
+ return {
122
+ Identifier(node) {
123
+ if (/Declaration|Specifier|Property/.test(node.parent.type)) {
124
+ return
125
+ }
126
+
127
+ if (
128
+ node.parent.type === 'MemberExpression' &&
129
+ node.parent.object !== node
130
+ ) {
131
+ return
132
+ }
133
+
134
+ checkId(node)
135
+ },
136
+ ImportDeclaration(node) {
137
+ node.specifiers.forEach((s) => {
138
+ importedNames.add(s.local.name)
139
+ })
140
+ },
141
+ 'Program:exit': function() {
142
+ const vueTemplate = context.sourceCode.ast.templateBody
143
+
144
+ if (!vueTemplate) {
145
+ return
146
+ }
147
+
148
+ function visit(node) {
149
+ if (!node) {
150
+ return
151
+ }
152
+
153
+ const expressionNode = node
154
+
155
+ switch (expressionNode.type) {
156
+ case 'Identifier':
157
+ checkId(expressionNode)
158
+ return
159
+ case 'MemberExpression':
160
+ visit(expressionNode.object)
161
+ return
162
+ case 'CallExpression':
163
+ visit(expressionNode.callee)
164
+ for (const arg of expressionNode.arguments) {
165
+ visit(arg)
166
+ }
167
+ return
168
+ case 'ConditionalExpression':
169
+ visit(expressionNode.test)
170
+ visit(expressionNode.consequent)
171
+ visit(expressionNode.alternate)
172
+ return
173
+ case 'FunctionExpression':
174
+ case 'ArrowFunctionExpression':
175
+ for (const param of expressionNode.params) {
176
+ visit(param)
177
+ }
178
+ visit(expressionNode.body)
179
+ return
180
+ case 'LogicalExpression':
181
+ case 'BinaryExpression':
182
+ visit(expressionNode.left)
183
+ visit(expressionNode.right)
184
+ return
185
+ }
186
+
187
+ switch (node.type) {
188
+ case 'VText':
189
+ return
190
+ case 'VExpressionContainer':
191
+ return visit(node.expression)
192
+ case 'VElement':
193
+ for (const attr of node.startTag.attributes) {
194
+ visit(attr)
195
+ }
196
+ for (const child of node.children) {
197
+ visit(child)
198
+ }
199
+ return
200
+ case 'VAttribute':
201
+ visit(node.value)
202
+ return
203
+ }
204
+
205
+ if ('children' in node) {
206
+ for (const child of node.children) {
207
+ visit(child)
208
+ }
209
+
210
+ return
211
+ }
212
+
213
+ if ('body' in node) {
214
+ visit(node.body)
215
+
216
+ return
217
+ }
218
+
219
+ {
220
+ const { tokens, parent, range, loc, ...rest } = node
221
+ debug('Unknown VNode', rest)
222
+ }
223
+ }
224
+
225
+ visit(vueTemplate)
226
+ },
227
+ }
228
+ }
229
+
230
+ const importPathDirNames = [
231
+ '/node_modules/',
232
+ '/packages/storefront-nuxt/',
233
+ '/packages/omnichannel-nuxt/', // Package needs to export appropriate `#omnichannel/composables` alias first
234
+ ]
235
+
236
+ const validDirNames = {
237
+ storefrontNuxt: ['@scayle/storefront-nuxt', '/packages/storefront-nuxt'],
238
+ omnichannelNuxt: ['@scayle/omnichannel-nuxt', '/packages/omnichannel-nuxt'],
239
+ }
240
+
241
+ // Mapping normalized import paths of packages to pure package imports
242
+ // to avoid "Nuxt Build Error: [vite:load-fallback] Could not load (...)" if
243
+ // path can not properly be resolved within package dist directory.
244
+ const importsMap = {
245
+ 'vue-i18n/dist/vue-i18n': 'vue-i18n',
246
+ '@nuxtjs/i18n/dist/runtime/composables/index': '#i18n', // https://github.com/nuxt-modules/i18n/blob/main/package.json#L34C6-L34C11
247
+ 'nuxt/dist/pages/runtime/composables': '#imports', // Needs to be imported through #imports to properly work with TypeScript in the Nuxt context
248
+ 'nuxt-jsonld/dist/runtime/composable': '#imports', // Needs to be imported through #imports to properly work with TypeScript in the Nuxt context
249
+ '@nuxt/image/dist/runtime/composables': '#imports', // Needs to be imported through #imports to properly work with TypeScript in the Nuxt context
250
+ }
251
+
252
+ const transformPathToImport = (itemFrom, dirName) => {
253
+ const partialPath = itemFrom.split(dirName)[1]
254
+
255
+ // We check if we're trying to import composables from `@scayle/storefront-nuxt`
256
+ // or `@scayle/omnichannel-nuxt` and transform the import to their respective
257
+ // allowed import path or alias.
258
+ // NOTE: If a Nuxt project is part of a mono-repo setup and some of its used packages
259
+ // are located within the root repo, the package resolution using `mlly` will not
260
+ // resolve to a node_modules packages, but to the local package within the repository.
261
+ // https://github.com/unjs/mlly/issues/158
262
+ // For this reason we include a workaround that is able to check if we're within the
263
+ // mono-repo setup and transform the import paths appropriately.
264
+ if (
265
+ validDirNames.storefrontNuxt.some((element) => itemFrom.includes(element))
266
+ ) {
267
+ return '#storefront/composables'
268
+ }
269
+
270
+ if (
271
+ validDirNames.omnichannelNuxt.some((element) => itemFrom.includes(element))
272
+ ) {
273
+ return '#omnichannel/composables'
274
+ }
275
+
276
+ // `nuxt-module-builder@0.7.0` changed the file extension for files within
277
+ // the build `/dist/runtime/` directory from `.mjs` to `.js`.
278
+ // https://github.com/nuxt/module-builder/commit/dbd05bb
279
+ const normalizedImportPath = partialPath.replace(/mjs|js/gi, '')
280
+
281
+ return importsMap[normalizedImportPath] ?? normalizedImportPath
282
+ }
283
+
284
+ const createImportPaths = (item, context, srcAlias = '~') => {
285
+ const relativePath = betterRelative(
286
+ dirname(context.physicalFilename),
287
+ item.from,
288
+ )
289
+
290
+ if (!isAbsolute(item.from)) {
291
+ return item.from
292
+ }
293
+
294
+ // We're checking if the item.from path contains one of the relevant directory names
295
+ // that we want to have transformed to a "plain" package import or alias import instead.
296
+ const dirName = importPathDirNames.find((name) => relativePath.includes(name))
297
+
298
+ if (dirName) {
299
+ return transformPathToImport(relativePath, dirName)
300
+ }
301
+
302
+ return `${srcAlias}/${relativePath.replaceAll('../', '')}`
303
+ }
304
+
305
+ const autoInsert = createRule({
306
+ name: 'auto-insert',
307
+ meta: {
308
+ type: 'problem',
309
+ docs: {
310
+ description: 'Auto insert missing imports from unimport registry',
311
+ },
312
+ messages: {
313
+ missingImport:
314
+ `Unimport entry '{{name}}' from '{{from}}' is not imported.`,
315
+ },
316
+ schema: [{ type: 'array', items: { type: 'any' } }],
317
+ fixable: 'code',
318
+ },
319
+ defaultOptions: [],
320
+ create(context) {
321
+ return createImportsListeners(
322
+ context,
323
+ context.options[0] || [],
324
+ (node, item) => {
325
+ context.report({
326
+ node,
327
+ messageId: 'missingImport',
328
+ data: {
329
+ name: item.name,
330
+ from: createImportPaths(item, context),
331
+ },
332
+ fix(fixer) {
333
+ const resolvedFrom = createImportPaths(item, context)
334
+ const body = context.sourceCode.ast.body
335
+
336
+ let importName = ''
337
+
338
+ if (item.name === '*') {
339
+ importName = `* as ${item.as}`
340
+ } else if (item.name === 'default') {
341
+ // Default exports should be imported without curly braces
342
+ importName = item.as || item.name
343
+ } else if (!item.as || item.name === item.as) {
344
+ // Named exports should be imported with curly braces
345
+ importName = `{ ${item.name} }`
346
+ } else {
347
+ // Named exports with aliases should be imported with curly braces
348
+ importName = `{ ${item.name} as ${item.as} }`
349
+ }
350
+
351
+ return fixer.insertTextBefore(
352
+ body[0],
353
+ `import ${importName} from '${resolvedFrom}'
354
+ `,
355
+ )
356
+ },
357
+ })
358
+ },
359
+ )
360
+ },
361
+ })
362
+
363
+ const plugin = {
364
+ meta: {
365
+ name: 'unimport',
366
+ version,
367
+ },
368
+ rules: {
369
+ 'auto-insert': autoInsert,
370
+ },
371
+ }
372
+
373
+ function createAutoInsert(options) {
374
+ return {
375
+ name: 'unimport:auto-insert',
376
+ plugins: {
377
+ unimport: plugin,
378
+ },
379
+ files: options.include ?? [
380
+ '**/*.ts',
381
+ '**/*.(c|m)js',
382
+ '**/*.(m|c)?tsx?',
383
+ '**/*.vue',
384
+ ],
385
+ ignores: options.exclude ?? ['**/*.mdx?/**'],
386
+ rules: {
387
+ 'unimport/auto-insert': ['error', options.imports],
388
+ },
389
+ }
390
+ }
391
+
392
+ export { createAutoInsert, plugin as default }
@@ -0,0 +1,7 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ type ModuleOptions = object;
4
+ declare const _default: _nuxt_schema.NuxtModule<object, object, false>;
5
+
6
+ export { _default as default };
7
+ export type { ModuleOptions };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "storefront-eslint-auto-explicit-import",
3
+ "configKey": "storefront-eslint-auto-explicit-import",
4
+ "version": "0.2.0",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.1",
7
+ "unbuild": "3.5.0"
8
+ }
9
+ }
@@ -0,0 +1,46 @@
1
+ import { defineNuxtModule, createResolver, logger } from '@nuxt/kit';
2
+
3
+ const module = defineNuxtModule({
4
+ meta: {
5
+ name: "storefront-eslint-auto-explicit-import"
6
+ },
7
+ setup(_, nuxt) {
8
+ const { resolve } = createResolver(import.meta.url);
9
+ let unimport;
10
+ nuxt.hook("imports:context", (ctx) => {
11
+ unimport = ctx;
12
+ });
13
+ nuxt.hook("eslint:config:addons", (addons) => {
14
+ addons.push({
15
+ name: "storefront-eslint-auto-explicit-import",
16
+ async getConfigs() {
17
+ if (!unimport) {
18
+ logger.warn(
19
+ "unimport is not ready for storefront-eslint-auto-explicit-import"
20
+ );
21
+ }
22
+ return {
23
+ imports: [
24
+ {
25
+ from: resolve("./eslint-plugin-unimport.mjs"),
26
+ name: "createAutoInsert"
27
+ }
28
+ ],
29
+ configs: [
30
+ [
31
+ "// storefront-eslint-auto-explicit-import",
32
+ "createAutoInsert({",
33
+ ` imports: ${JSON.stringify(
34
+ await unimport?.getImports() || []
35
+ )}`,
36
+ "})"
37
+ ].join("\n")
38
+ ]
39
+ };
40
+ }
41
+ });
42
+ });
43
+ }
44
+ });
45
+
46
+ export { module as default };
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@scayle/eslint-auto-explicit-import",
3
+ "version": "0.2.0",
4
+ "description": "Automatically add explicit imports to ESLint",
5
+ "author": "SCAYLE Commerce Engine",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "repository": {
12
+ "url": "git+https://github.com/scayle/eslint-auto-explicit-import.git"
13
+ },
14
+ "keywords": [
15
+ "eslint",
16
+ "eslintplugin",
17
+ "eslint-plugin",
18
+ "import"
19
+ ],
20
+ "main": "./dist/module.mjs",
21
+ "type": "module",
22
+ "files": [
23
+ "CHANGELOG.md",
24
+ "dist/**",
25
+ "LICENSE"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/module.d.mts",
30
+ "default": "./dist/module.mjs"
31
+ }
32
+ },
33
+ "dependencies": {
34
+ "debug": "4.4.1",
35
+ "pathe": "2.0.3"
36
+ },
37
+ "devDependencies": {
38
+ "@nuxt/kit": "3.16.2",
39
+ "@typescript-eslint/scope-manager": "8.36.0",
40
+ "@typescript-eslint/utils": "8.36.0",
41
+ "dprint": "0.50.1",
42
+ "eslint": "9.31.0",
43
+ "typescript": "5.8.3",
44
+ "unbuild": "3.5.0",
45
+ "unimport": "0.1.2",
46
+ "@scayle/eslint-config-storefront": "4.5.14"
47
+ },
48
+ "engines": {
49
+ "node": ">= 20.0.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@nuxt/kit": ">=3.13.0"
53
+ },
54
+ "volta": {
55
+ "node": "22.17.0"
56
+ },
57
+ "scripts": {
58
+ "build": "nuxt-module-build build",
59
+ "lint": "eslint .",
60
+ "lint:ci": "eslint . --format gitlab",
61
+ "format": "dprint check",
62
+ "format:fix": "dprint fmt",
63
+ "typecheck": "tsc"
64
+ }
65
+ }