Repository: ota-meshi/eslint-plugin-astro (ts)

Issue:
Add new rule: disallow-exports-from-components
### Motivation

I am new to astro and ran into a slightly difficult to diagnose issue relating to [this issue](https://github.com/withastro/astro/issues/7264). While I was eventually able to identify and remove the problem it was non-obvious and took a fair amount of time. 

### Description

The rule should disallow any uses of the `export` keyword in an Astro component's component script/frontmatter if **values** are being exported.

Using the `export` keyword with **types** should still be permitted.

I expect this should probably be part of the `astro/base` or `astro/recommended` default configurations.

### Examples

```astro
---
{/* ✓ GOOD */}
const testConst = "some const"
function testFunc () {
    return "some func"
}
export type MyType = string | number

{/* ✗ BAD */}
export const testConst = "some const"
export function testFunc () {
    return "some func"
}
---

```


### Additional comments

I don't know if this is within the scope of what this library should do, but I thought I'd ask before working on a PR.

(I also may have completely misunderstood the underlying issue, but I _hope_ not.)

Pull request:
feat: add `astro/no-exports-from-components` rule
close #330

Patch:
diff --git a/.changeset/chilled-carrots-glow.md b/.changeset/chilled-carrots-glow.md
new file mode 100644
index 0000000..2fd7deb
--- /dev/null
+++ b/.changeset/chilled-carrots-glow.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-astro": minor
+---
+
+feat: add `astro/no-exports-from-components` rule
diff --git a/README.md b/README.md
index c62cc00..b518cc3 100644
--- a/README.md
+++ b/README.md
@@ -400,6 +400,7 @@ These rules relate to possible syntax or logic errors in Astro component code:
 | [astro/no-deprecated-astro-fetchcontent](https://ota-meshi.github.io/eslint-plugin-astro/rules/no-deprecated-astro-fetchcontent/) | disallow using deprecated `Astro.fetchContent()` | ⭐🔧 |
 | [astro/no-deprecated-astro-resolve](https://ota-meshi.github.io/eslint-plugin-astro/rules/no-deprecated-astro-resolve/) | disallow using deprecated `Astro.resolve()` | ⭐ |
 | [astro/no-deprecated-getentrybyslug](https://ota-meshi.github.io/eslint-plugin-astro/rules/no-deprecated-getentrybyslug/) | disallow using deprecated `getEntryBySlug()` | ⭐ |
+| [astro/no-exports-from-components](https://ota-meshi.github.io/eslint-plugin-astro/rules/no-exports-from-components/) | disallow value export |  |
 | [astro/no-unused-define-vars-in-style](https://ota-meshi.github.io/eslint-plugin-astro/rules/no-unused-define-vars-in-style/) | disallow unused `define:vars={...}` in `style` tag | ⭐ |
 | [astro/valid-compile](https://ota-meshi.github.io/eslint-plugin-astro/rules/valid-compile/) | disallow warnings when compiling. | ⭐ |
 
diff --git a/docs/rules.md b/docs/rules.md
index 7e604ba..bf91d00 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -19,6 +19,7 @@ These rules relate to possible syntax or logic errors in Astro component code:
 | [astro/no-deprecated-astro-fetchcontent](./rules/no-deprecated-astro-fetchcontent.md) | disallow using deprecated `Astro.fetchContent()` | ⭐🔧 |
 | [astro/no-deprecated-astro-resolve](./rules/no-deprecated-astro-resolve.md) | disallow using deprecated `Astro.resolve()` | ⭐ |
 | [astro/no-deprecated-getentrybyslug](./rules/no-deprecated-getentrybyslug.md) | disallow using deprecated `getEntryBySlug()` | ⭐ |
+| [astro/no-exports-from-components](./rules/no-exports-from-components.md) | disallow value export |  |
 | [astro/no-unused-define-vars-in-style](./rules/no-unused-define-vars-in-style.md) | disallow unused `define:vars={...}` in `style` tag | ⭐ |
 | [astro/valid-compile](./rules/valid-compile.md) | disallow warnings when compiling. | ⭐ |
 
diff --git a/docs/rules/no-exports-from-components.md b/docs/rules/no-exports-from-components.md
new file mode 100644
index 0000000..f1f6ebb
--- /dev/null
+++ b/docs/rules/no-exports-from-components.md
@@ -0,0 +1,41 @@
+---
+title: "astro/no-exports-from-components"
+description: "disallow value export"
+---
+
+# astro/no-exports-from-components
+
+> disallow value export
+
+- ❗ <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
+
+## 📖 Rule Details
+
+This rule reports value exports from Astro components.
+The use of typed exports are still allowed.
+
+<ESLintCodeBlock>
+
+<!--eslint-skip-->
+
+```astro
+---
+/* eslint astro/no-exports-from-components: "error" */
+/* ✓ GOOD */
+export type A = number | boolean
+/* ✗ BAD */
+export const x = 42
+---
+```
+
+</ESLintCodeBlock>
+
+## 🔧 Options
+
+Nothing.
+
+## 🔍 Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-astro/blob/main/src/rules/no-exports-from-components.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-astro/blob/main/tests/src/rules/no-exports-from-components.ts)
+- [Test fixture sources](https://github.com/ota-meshi/eslint-plugin-astro/tree/main/tests/fixtures/rules/no-exports-from-components)
diff --git a/src/rules/no-exports-from-components.ts b/src/rules/no-exports-from-components.ts
new file mode 100644
index 0000000..06a06eb
--- /dev/null
+++ b/src/rules/no-exports-from-components.ts
@@ -0,0 +1,68 @@
+import type { TSESTree } from "@typescript-eslint/types"
+import { createRule } from "../utils"
+import { getSourceCode } from "../utils/compat"
+
+export default createRule("no-exports-from-components", {
+  meta: {
+    docs: {
+      description: "disallow value export",
+      category: "Possible Errors",
+      // TODO: Switch to recommended: true, in next major version
+      recommended: false,
+    },
+    schema: [],
+    messages: {
+      disallowExport: "Exporting values from components is not allowed.",
+    },
+    type: "problem",
+  },
+  create(context) {
+    const sourceCode = getSourceCode(context)
+    if (!sourceCode.parserServices.isAstro) {
+      return {}
+    }
+
+    /**
+     * Verify for export declarations
+     */
+    function verifyDeclaration(
+      node:
+        | TSESTree.ExportDefaultDeclaration["declaration"]
+        | TSESTree.ExportNamedDeclaration["declaration"],
+    ) {
+      if (!node) return
+      if (node.type.startsWith("TS") && !node.type.endsWith("Expression")) {
+        return
+      }
+      context.report({
+        node,
+        messageId: "disallowExport",
+      })
+    }
+
+    return {
+      ExportAllDeclaration(node) {
+        if (node.exportKind === "type") return
+        context.report({
+          node,
+          messageId: "disallowExport",
+        })
+      },
+      ExportDefaultDeclaration(node) {
+        if (node.exportKind === "type") return
+        verifyDeclaration(node.declaration)
+      },
+      ExportNamedDeclaration(node) {
+        if (node.exportKind === "type") return
+        verifyDeclaration(node.declaration)
+        for (const spec of node.specifiers) {
+          if (spec.exportKind === "type") return
+          context.report({
+            node: spec,
+            messageId: "disallowExport",
+          })
+        }
+      },
+    }
+  },
+})
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index a0aa7e6..1cda35a 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -8,6 +8,7 @@ import noDeprecatedAstroCanonicalurl from "../rules/no-deprecated-astro-canonica
 import noDeprecatedAstroFetchcontent from "../rules/no-deprecated-astro-fetchcontent"
 import noDeprecatedAstroResolve from "../rules/no-deprecated-astro-resolve"
 import noDeprecatedGetentrybyslug from "../rules/no-deprecated-getentrybyslug"
+import noExportsFromComponents from "../rules/no-exports-from-components"
 import noSetHtmlDirective from "../rules/no-set-html-directive"
 import noSetTextDirective from "../rules/no-set-text-directive"
 import noUnusedCssSelector from "../rules/no-unused-css-selector"
@@ -26,6 +27,7 @@ export const rules = [
   noDeprecatedAstroFetchcontent,
   noDeprecatedAstroResolve,
   noDeprecatedGetentrybyslug,
+  noExportsFromComponents,
   noSetHtmlDirective,
   noSetTextDirective,
   noUnusedCssSelector,
diff --git a/tools/update-rulesets.ts b/tools/update-rulesets.ts
index 0504692..986e000 100644
--- a/tools/update-rulesets.ts
+++ b/tools/update-rulesets.ts
@@ -116,7 +116,7 @@ export default [
   {
     plugins: {
       get astro(): ESLint.Plugin {
-        // eslint-disable-next-line @typescript-eslint/no-require-imports -- ignore
+        // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- ignore
         return (plugin ??= require("../../plugin").plugin)
       },
     },


Test patch:
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/_config.json b/tests/fixtures/rules/no-exports-from-components/invalid/_config.json
new file mode 100644
index 0000000..0342354
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/_config.json
@@ -0,0 +1,7 @@
+{
+  "languageOptions": {
+    "parserOptions": {
+      "parser": "@typescript-eslint/parser"
+    }
+  }
+}
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-all-01-errors.json b/tests/fixtures/rules/no-exports-from-components/invalid/export-all-01-errors.json
new file mode 100644
index 0000000..c0648dd
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-all-01-errors.json
@@ -0,0 +1,7 @@
+[
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 2,
+    "column": 1
+  }
+]
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-all-01-input.astro b/tests/fixtures/rules/no-exports-from-components/invalid/export-all-01-input.astro
new file mode 100644
index 0000000..8c297cd
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-all-01-input.astro
@@ -0,0 +1,3 @@
+---
+export * from "./foo.js"
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-default-01-errors.json b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-01-errors.json
new file mode 100644
index 0000000..9532230
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-01-errors.json
@@ -0,0 +1,7 @@
+[
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 2,
+    "column": 16
+  }
+]
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-default-01-input.astro b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-01-input.astro
new file mode 100644
index 0000000..557553b
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-01-input.astro
@@ -0,0 +1,3 @@
+---
+export default {}
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-default-02-errors.json b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-02-errors.json
new file mode 100644
index 0000000..2f96e45
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-02-errors.json
@@ -0,0 +1,7 @@
+[
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 4,
+    "column": 16
+  }
+]
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-default-02-input.astro b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-02-input.astro
new file mode 100644
index 0000000..170dca0
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-02-input.astro
@@ -0,0 +1,5 @@
+---
+export default function fn(): boolean
+export default function fn(a: string): string
+export default function fn(a?: string): string | boolean {}
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-default-03-errors.json b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-03-errors.json
new file mode 100644
index 0000000..9532230
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-03-errors.json
@@ -0,0 +1,7 @@
+[
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 2,
+    "column": 16
+  }
+]
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/export-default-03-input.astro b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-03-input.astro
new file mode 100644
index 0000000..9c2d8d1
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/export-default-03-input.astro
@@ -0,0 +1,5 @@
+---
+export default class A {
+  private x(): void {}
+}
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/named-export01-errors.json b/tests/fixtures/rules/no-exports-from-components/invalid/named-export01-errors.json
new file mode 100644
index 0000000..79a1da7
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/named-export01-errors.json
@@ -0,0 +1,32 @@
+[
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 2,
+    "column": 10
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 3,
+    "column": 10
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 3,
+    "column": 13
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 4,
+    "column": 8
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 7,
+    "column": 8
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 10,
+    "column": 8
+  }
+]
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/named-export01-input.astro b/tests/fixtures/rules/no-exports-from-components/invalid/named-export01-input.astro
new file mode 100644
index 0000000..f6e9e33
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/named-export01-input.astro
@@ -0,0 +1,11 @@
+---
+export { A } from "./foo.js"
+export { B, B2 } from "./foo.js"
+export const C = {
+  // ...
+}
+export class D {
+  private x(): void {}
+}
+export function E(): void {}
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/named-export02-errors.json b/tests/fixtures/rules/no-exports-from-components/invalid/named-export02-errors.json
new file mode 100644
index 0000000..f936fb6
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/named-export02-errors.json
@@ -0,0 +1,32 @@
+[
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 13,
+    "column": 10
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 13,
+    "column": 13
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 13,
+    "column": 16
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 14,
+    "column": 10
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 14,
+    "column": 13
+  },
+  {
+    "message": "Exporting values from components is not allowed.",
+    "line": 14,
+    "column": 16
+  }
+]
diff --git a/tests/fixtures/rules/no-exports-from-components/invalid/named-export02-input.astro b/tests/fixtures/rules/no-exports-from-components/invalid/named-export02-input.astro
new file mode 100644
index 0000000..61e7f34
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/invalid/named-export02-input.astro
@@ -0,0 +1,15 @@
+---
+import { A } from "./foo.js"
+import { B, B2 } from "./foo.js"
+const C = {
+  // ...
+}
+class D {
+  private x(): void {}
+}
+
+function E(): void {}
+
+export { A, B, B2 }
+export { C, D, E }
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/valid/_config.json b/tests/fixtures/rules/no-exports-from-components/valid/_config.json
new file mode 100644
index 0000000..0342354
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/valid/_config.json
@@ -0,0 +1,7 @@
+{
+  "languageOptions": {
+    "parserOptions": {
+      "parser": "@typescript-eslint/parser"
+    }
+  }
+}
diff --git a/tests/fixtures/rules/no-exports-from-components/valid/export-all-01-input.astro b/tests/fixtures/rules/no-exports-from-components/valid/export-all-01-input.astro
new file mode 100644
index 0000000..2a3eaf7
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/valid/export-all-01-input.astro
@@ -0,0 +1,3 @@
+---
+export type * from "./foo.js"
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/valid/export-default-01-input.astro b/tests/fixtures/rules/no-exports-from-components/valid/export-default-01-input.astro
new file mode 100644
index 0000000..2221d24
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/valid/export-default-01-input.astro
@@ -0,0 +1,5 @@
+---
+export default interface A {
+  a: string
+}
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/valid/named-export01-input.astro b/tests/fixtures/rules/no-exports-from-components/valid/named-export01-input.astro
new file mode 100644
index 0000000..278ef71
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/valid/named-export01-input.astro
@@ -0,0 +1,14 @@
+---
+export { type A } from "./foo.js"
+export type { B, B2 } from "./foo.js"
+export type C = {
+  // ...
+}
+export interface D {
+  a(): void
+}
+export const enum E {
+  x = 1,
+  y = 2,
+}
+---
diff --git a/tests/fixtures/rules/no-exports-from-components/valid/named-export02-input.astro b/tests/fixtures/rules/no-exports-from-components/valid/named-export02-input.astro
new file mode 100644
index 0000000..7bf8116
--- /dev/null
+++ b/tests/fixtures/rules/no-exports-from-components/valid/named-export02-input.astro
@@ -0,0 +1,15 @@
+---
+import type { A } from "./foo.js"
+import type { B, B2 } from "./foo.js"
+type C = {
+  // ...
+}
+interface D {
+  a(): void
+}
+
+enum E {}
+
+export type { A, B, B2 }
+export { type C, type D, type E }
+---
diff --git a/tests/src/rules/no-exports-from-components.ts b/tests/src/rules/no-exports-from-components.ts
new file mode 100644
index 0000000..dae5fa2
--- /dev/null
+++ b/tests/src/rules/no-exports-from-components.ts
@@ -0,0 +1,16 @@
+import { RuleTester } from "../../utils/eslint-compat"
+import rule from "../../../src/rules/no-exports-from-components"
+import { loadTestCases } from "../../utils/utils"
+
+const tester = new RuleTester({
+  languageOptions: {
+    ecmaVersion: 2020,
+    sourceType: "module",
+  },
+})
+
+tester.run(
+  "no-exports-from-components",
+  rule as any,
+  loadTestCases("no-exports-from-components"),
+)
diff --git a/tests/utils/utils.ts b/tests/utils/utils.ts
index 277c069..a0fd47e 100644
--- a/tests/utils/utils.ts
+++ b/tests/utils/utils.ts
@@ -231,8 +231,14 @@ function getConfig(ruleName: string, inputFile: string) {
   }
   if (config && typeof config === "object") {
     return Object.assign(
-      { languageOptions: { parser: astroESLintParser } },
+      {},
       config,
+      {
+        languageOptions: {
+          parser: astroESLintParser,
+          ...config?.languageOptions,
+        },
+      },
       { code, filename },
     )
   }