@dvukovic/style-guide 0.16.0 → 0.18.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.
@@ -1,2 +1,4 @@
1
1
  /** @type {import("@eslint/config-helpers").Config} */
2
2
  export const dvukovic: import("@eslint/config-helpers").Config;
3
+ /** @type {import("@eslint/config-helpers").Config} */
4
+ export const dvukovicNextjs: import("@eslint/config-helpers").Config;
@@ -0,0 +1,19 @@
1
+ export namespace nextjsExportName {
2
+ function create(context: any): {
3
+ ExportDefaultDeclaration?: undefined;
4
+ } | {
5
+ ExportDefaultDeclaration(node: any): void;
6
+ };
7
+ namespace meta {
8
+ namespace docs {
9
+ let description: string;
10
+ }
11
+ let fixable: string;
12
+ namespace messages {
13
+ let anonymous: string;
14
+ let mismatch: string;
15
+ }
16
+ let schema: never[];
17
+ let type: string;
18
+ }
19
+ }
@@ -0,0 +1,30 @@
1
+ export namespace noCrossModuleImports {
2
+ function create(context: any): {
3
+ CallExpression?: undefined;
4
+ ImportDeclaration?: undefined;
5
+ ImportExpression?: undefined;
6
+ } | {
7
+ CallExpression(node: any): void;
8
+ ImportDeclaration(node: any): void;
9
+ ImportExpression(node: any): void;
10
+ };
11
+ namespace meta {
12
+ namespace docs {
13
+ let description: string;
14
+ }
15
+ namespace messages {
16
+ let noCrossModuleImports: string;
17
+ }
18
+ let schema: {
19
+ additionalProperties: boolean;
20
+ properties: {
21
+ modulesDirectory: {
22
+ default: string;
23
+ type: string;
24
+ };
25
+ };
26
+ type: string;
27
+ }[];
28
+ let type: string;
29
+ }
30
+ }
@@ -0,0 +1,16 @@
1
+ export namespace noInstanceofError {
2
+ function create(context: any): {
3
+ BinaryExpression(node: any): void;
4
+ };
5
+ namespace meta {
6
+ namespace docs {
7
+ let description: string;
8
+ }
9
+ let fixable: string;
10
+ namespace messages {
11
+ let forbidden: string;
12
+ }
13
+ let schema: never[];
14
+ let type: string;
15
+ }
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dvukovic/style-guide",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "My own style guide",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,6 +31,8 @@ export function generateESLintConfig(options) {
31
31
  }
32
32
 
33
33
  if (framework === "next") {
34
+ imports.push("react")
35
+ configs.push("react()")
34
36
  imports.push("next")
35
37
  configs.push("next()")
36
38
  ignores.push("out")
@@ -26,11 +26,13 @@ export function generateScripts(tools, packageManager) {
26
26
  if (tools.includes("cspell")) {
27
27
  scripts["lint:cspell"] = "cspell --no-progress --no-summary --unique '**'"
28
28
  lintParts.push(`${runner} lint:cspell`)
29
+ fixParts.push(`${runner} lint:cspell`)
29
30
  }
30
31
 
31
32
  if (tools.includes("knip")) {
32
33
  scripts["lint:knip"] = "knip --cache"
33
34
  lintParts.push(`${runner} lint:knip`)
35
+ fixParts.push(`${runner} lint:knip`)
34
36
  }
35
37
 
36
38
  if (lintParts.length > 0) {
@@ -1,7 +1,8 @@
1
1
  import { ALL_JS_TS_FILES } from "../file-patterns.js"
2
+ import { dvukovicNextjs } from "../plugins/dvukovic.js"
2
3
  import { next as nextPlugin } from "../plugins/next.js"
3
4
 
4
- export const nextConfig = [nextPlugin]
5
+ export const nextConfig = [nextPlugin, dvukovicNextjs]
5
6
 
6
7
  /**
7
8
  * Next.js framework configuration
@@ -1,21 +1,41 @@
1
1
  import { documentTodos } from "../rules/document-todos/document-todos.js"
2
+ import { nextjsExportName } from "../rules/nextjs-export-name/nextjs-export-name.js"
2
3
  import { noCommentedOutCode } from "../rules/no-commented-out-code/no-commented-out-code.js"
4
+ import { noCrossModuleImports } from "../rules/no-cross-module-imports/no-cross-module-imports.js"
5
+ import { noInstanceofError } from "../rules/no-instanceof-error/no-instanceof-error.js"
3
6
  import { noT } from "../rules/no-t/no-t.js"
4
7
 
8
+ const dvukovicPlugin = {
9
+ rules: {
10
+ "document-todos": documentTodos,
11
+ "nextjs-export-name": nextjsExportName,
12
+ "no-commented-out-code": noCommentedOutCode,
13
+ "no-cross-module-imports": noCrossModuleImports,
14
+ "no-instanceof-error": noInstanceofError,
15
+ "no-t": noT,
16
+ },
17
+ }
18
+
5
19
  /** @type {import("@eslint/config-helpers").Config} */
6
20
  export const dvukovic = {
7
21
  plugins: {
8
- dvukovic: {
9
- rules: {
10
- "document-todos": documentTodos,
11
- "no-commented-out-code": noCommentedOutCode,
12
- "no-t": noT,
13
- },
14
- },
22
+ dvukovic: dvukovicPlugin,
15
23
  },
16
24
  rules: {
17
25
  "dvukovic/document-todos": ["error", { url: "http" }],
18
26
  "dvukovic/no-commented-out-code": "error",
27
+ "dvukovic/no-instanceof-error": "error",
19
28
  "dvukovic/no-t": "error",
20
29
  },
21
30
  }
31
+
32
+ /** @type {import("@eslint/config-helpers").Config} */
33
+ export const dvukovicNextjs = {
34
+ plugins: {
35
+ dvukovic: dvukovicPlugin,
36
+ },
37
+ rules: {
38
+ "dvukovic/nextjs-export-name": "error",
39
+ "dvukovic/no-cross-module-imports": "error",
40
+ },
41
+ }
@@ -0,0 +1,130 @@
1
+ import path from "node:path"
2
+
3
+ const SPECIAL_FILES = new Map([
4
+ ["page", "Page"],
5
+ ["layout", "Layout"],
6
+ ["loading", "Loading"],
7
+ ["error", "Error"],
8
+ ["not-found", "NotFound"],
9
+ ["template", "Template"],
10
+ ["default", "Default"],
11
+ ["global-error", "GlobalError"],
12
+ ])
13
+
14
+ function getExpectedName(filename) {
15
+ const basename = path.basename(filename)
16
+ const nameWithoutExtension = basename.replace(/\.[^.]+$/, "")
17
+
18
+ return SPECIAL_FILES.get(nameWithoutExtension) ?? null
19
+ }
20
+
21
+ export const nextjsExportName = {
22
+ create(context) {
23
+ const expectedName = getExpectedName(context.filename)
24
+
25
+ if (!expectedName) {
26
+ return {}
27
+ }
28
+
29
+ return {
30
+ ExportDefaultDeclaration(node) {
31
+ const declaration = node.declaration
32
+
33
+ if (
34
+ declaration.type === "FunctionDeclaration" ||
35
+ declaration.type === "ClassDeclaration"
36
+ ) {
37
+ if (!declaration.id) {
38
+ context.report({
39
+ data: { expectedName },
40
+ messageId: "anonymous",
41
+ node,
42
+ })
43
+
44
+ return
45
+ }
46
+
47
+ if (declaration.id.name !== expectedName) {
48
+ context.report({
49
+ data: {
50
+ actualName: declaration.id.name,
51
+ expectedName,
52
+ },
53
+ fix(fixer) {
54
+ return fixer.replaceText(declaration.id, expectedName)
55
+ },
56
+ messageId: "mismatch",
57
+ node,
58
+ })
59
+ }
60
+
61
+ return
62
+ }
63
+
64
+ if (declaration.type === "Identifier") {
65
+ const scope = context.sourceCode.getScope(node)
66
+ const variable = scope.set.get(declaration.name)
67
+
68
+ if (!variable) {
69
+ return
70
+ }
71
+
72
+ const definition = variable.defs[0]
73
+
74
+ if (!definition) {
75
+ return
76
+ }
77
+
78
+ if (declaration.name !== expectedName) {
79
+ context.report({
80
+ data: {
81
+ actualName: declaration.name,
82
+ expectedName,
83
+ },
84
+ fix(fixer) {
85
+ const replaced = new Set()
86
+ const fixes = []
87
+
88
+ if (definition.name) {
89
+ fixes.push(fixer.replaceText(definition.name, expectedName))
90
+ replaced.add(definition.name.range[0])
91
+ }
92
+
93
+ const unreplacedReferences = variable.references.filter(
94
+ (reference) => {
95
+ return !replaced.has(reference.identifier.range[0])
96
+ },
97
+ )
98
+
99
+ for (const reference of unreplacedReferences) {
100
+ fixes.push(
101
+ fixer.replaceText(reference.identifier, expectedName),
102
+ )
103
+ }
104
+
105
+ return fixes
106
+ },
107
+ messageId: "mismatch",
108
+ node,
109
+ })
110
+ }
111
+ }
112
+ },
113
+ }
114
+ },
115
+ meta: {
116
+ docs: {
117
+ description:
118
+ "Enforces that default exports in Next.js special files match the expected PascalCase name derived from the filename.",
119
+ },
120
+ fixable: "code",
121
+ messages: {
122
+ anonymous:
123
+ "Default export in this file must be a named function. Expected name: `{{ expectedName }}`.",
124
+ mismatch:
125
+ "Default export `{{ actualName }}` does not match the expected name `{{ expectedName }}` for this file.",
126
+ },
127
+ schema: [],
128
+ type: "problem",
129
+ },
130
+ }
@@ -0,0 +1,110 @@
1
+ import path from "node:path"
2
+
3
+ const DEFAULT_MODULES_DIRECTORY = "src/modules"
4
+
5
+ function getModuleName(filePath, modulesDirectory) {
6
+ const normalizedPath = filePath.replaceAll("\\", "/")
7
+ const normalizedModulesDirectory = modulesDirectory.replaceAll("\\", "/")
8
+
9
+ const modulesIndex = normalizedPath.indexOf(`${normalizedModulesDirectory}/`)
10
+
11
+ if (modulesIndex === -1) {
12
+ return null
13
+ }
14
+
15
+ const afterModules = normalizedPath.slice(modulesIndex + normalizedModulesDirectory.length + 1)
16
+
17
+ const moduleName = afterModules.split("/")[0]
18
+
19
+ return moduleName ?? null
20
+ }
21
+
22
+ export const noCrossModuleImports = {
23
+ create(context) {
24
+ const modulesDirectory = context.options[0]?.modulesDirectory ?? DEFAULT_MODULES_DIRECTORY
25
+
26
+ const currentModule = getModuleName(context.filename, modulesDirectory)
27
+
28
+ if (!currentModule) {
29
+ return {}
30
+ }
31
+
32
+ function checkImportSource(node, source) {
33
+ if (!source || typeof source !== "string") {
34
+ return
35
+ }
36
+
37
+ if (!source.startsWith(".")) {
38
+ return
39
+ }
40
+
41
+ const resolvedPath = path.resolve(path.dirname(context.filename), source)
42
+ const targetModule = getModuleName(resolvedPath, modulesDirectory)
43
+
44
+ if (!targetModule) {
45
+ return
46
+ }
47
+
48
+ if (targetModule !== currentModule) {
49
+ context.report({
50
+ data: {
51
+ currentModule,
52
+ targetModule,
53
+ },
54
+ messageId: "noCrossModuleImports",
55
+ node,
56
+ })
57
+ }
58
+ }
59
+
60
+ return {
61
+ CallExpression(node) {
62
+ // eslint-disable-next-line baseline-js/use-baseline -- false positive: AST node property, not Function.arguments
63
+ if (node.callee.name !== "require" || node.arguments.length === 0) {
64
+ return
65
+ }
66
+
67
+ const argument = node.arguments[0] // eslint-disable-line baseline-js/use-baseline -- false positive: AST node property, not Function.arguments
68
+
69
+ if (argument.type !== "Literal" || typeof argument.value !== "string") {
70
+ return
71
+ }
72
+
73
+ checkImportSource(node, argument.value)
74
+ },
75
+ ImportDeclaration(node) {
76
+ checkImportSource(node, node.source.value)
77
+ },
78
+ ImportExpression(node) {
79
+ if (node.source.type !== "Literal" || typeof node.source.value !== "string") {
80
+ return
81
+ }
82
+
83
+ checkImportSource(node, node.source.value)
84
+ },
85
+ }
86
+ },
87
+ meta: {
88
+ docs: {
89
+ description:
90
+ "Prevents importing from one module into another inside the modules directory to enforce module isolation.",
91
+ },
92
+ messages: {
93
+ noCrossModuleImports:
94
+ 'Importing from module "{{targetModule}}" is not allowed inside module "{{currentModule}}".',
95
+ },
96
+ schema: [
97
+ {
98
+ additionalProperties: false,
99
+ properties: {
100
+ modulesDirectory: {
101
+ default: DEFAULT_MODULES_DIRECTORY,
102
+ type: "string",
103
+ },
104
+ },
105
+ type: "object",
106
+ },
107
+ ],
108
+ type: "problem",
109
+ },
110
+ }
@@ -0,0 +1,36 @@
1
+ export const noInstanceofError = {
2
+ create(context) {
3
+ return {
4
+ BinaryExpression(node) {
5
+ if (
6
+ node.operator === "instanceof" &&
7
+ node.right.type === "Identifier" &&
8
+ node.right.name === "Error"
9
+ ) {
10
+ context.report({
11
+ fix(fixer) {
12
+ const leftText = context.sourceCode.getText(node.left)
13
+
14
+ return fixer.replaceText(node, `Error.isError(${leftText})`)
15
+ },
16
+ messageId: "forbidden",
17
+ node,
18
+ })
19
+ }
20
+ },
21
+ }
22
+ },
23
+ meta: {
24
+ docs: {
25
+ description:
26
+ "Forbids `instanceof Error` in favor of `Error.isError()` which works across realms.",
27
+ },
28
+ fixable: "code",
29
+ messages: {
30
+ forbidden:
31
+ "Use `Error.isError()` instead of `instanceof Error`. It works across realms where `instanceof` fails.",
32
+ },
33
+ schema: [],
34
+ type: "problem",
35
+ },
36
+ }