diff --git a/deno.json b/deno.json
index 613064e591c..cdd1aa805fa 100644
--- a/deno.json
+++ b/deno.json
@@ -13,7 +13,8 @@
".": "./src/mod.ts",
"./runtime": "./src/runtime/shared.ts",
"./dev": "./src/dev/mod.ts",
- "./compat": "./src/compat/mod.ts"
+ "./compat": "./src/compat/mod.ts",
+ "./lint-plugin": "./src/lint_plugin.ts"
},
"tasks": {
"test": "deno test -A --parallel",
@@ -79,6 +80,7 @@
"jsxPrecompileSkipElements": ["a", "img", "source", "body", "html", "head"]
},
"lint": {
+ "plugins": ["./src/lint_plugin.ts"],
"rules": {
"exclude": ["no-window"],
"include": ["no-console"]
diff --git a/src/lint_plugin.ts b/src/lint_plugin.ts
new file mode 100644
index 00000000000..e82c0b5c846
--- /dev/null
+++ b/src/lint_plugin.ts
@@ -0,0 +1,159 @@
+function isComponentFile(path: string) {
+ return path.endsWith(".tsx") || path.endsWith(".jsx");
+}
+
+function isIslandFile(path: string) {
+ return path.includes("/islands/") ||
+ (path.includes("/routes/") && path.includes("/(_islands)/"));
+}
+
+/**
+ * Fresh lint plugin
+ *
+ * ## `fresh/handler-export`
+ *
+ * Checks correct naming for named Fresh middleware export. Files inside the
+ * `/routes/` folder can export middlewares that run before any rendering
+ * happens. They are expected to be available as a named export called handler.
+ * This rule checks for when the export was incorrectly named handlers instead
+ * of handler.
+ *
+ * @example Invalid
+ * ```ts
+ * // routes/index.tsx
+ * export const handlers = {
+ * GET() {},
+ * POST() {},
+ * };
+ * export function handlers() {}
+ * export async function handlers() {}
+ * ```
+ *
+ * @example Valid
+ * ```ts
+ * // routes/index.tsx
+ * export const handler = {
+ * GET() {},
+ * POST() {},
+ * };
+ * export function handler() {}
+ * export async function handler() {}
+ * ```
+ *
+ * ## `fresh/prefer-signals`
+ *
+ * Checks for the use of `useState()` from React. Fresh uses Preact and
+ * {@linkcode https://www.npmjs.com/package/@preact/signals | @preact/signals}
+ * for state management. This rule checks for the use of
+ * `useState()` and suggests using `signal()` or `useSignal()` instead. Signals
+ * are a more efficient and ergonomic way to manage state in Preact applications.
+ * For more information, see
+ * {@link https://preactjs.com/blog/introducing-signals/}
+ *
+ * @example Invalid
+ * ```tsx
+ * // islands/my-island.tsx
+ * import { useState } from "preact/hooks";
+ *
+ * export default function MyIsland() {
+ * const [count, setCount] = useState(0);
+ *
+ * return (
+ *
+ * Counter is at {count}.{" "}
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @example Valid
+ * ```tsx
+ * // islands/my-island.tsx
+ * import { useSignal } from "@preact/signals";
+ *
+ * export default function MyIsland() {
+ * const count = useSignal(0);
+ *
+ * return (
+ *
+ * Counter is at {count}.{" "}
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export default {
+ name: "fresh",
+ rules: {
+ "handler-export": {
+ create(context) {
+ return {
+ ExportNamedDeclaration(node) {
+ if (
+ /**
+ * Fresh only considers components in the `/routes/` folder to be
+ * server components.
+ */
+ !context.filename.includes("/routes/") ||
+ node.exportKind !== "value"
+ ) return;
+ let id: Deno.lint.Identifier;
+ if (
+ node.declaration?.type === "FunctionDeclaration" &&
+ node.declaration.id?.type === "Identifier"
+ ) {
+ id = node.declaration.id;
+ } else if (
+ node.declaration?.type === "VariableDeclaration" &&
+ node.declaration.declarations?.[0].id.type === "Identifier"
+ ) {
+ id = node.declaration.declarations[0].id;
+ } else {
+ return;
+ }
+ if (id.name === "handlers") {
+ // TODO(iuioiua): add fix
+ context.report({
+ node,
+ range: id.range,
+ hint: 'Did you mean "handler"?',
+ message:
+ `"Fresh middlewares must be exported as \`handler\` but got \`handlers\` instead."`,
+ });
+ }
+ },
+ };
+ },
+ },
+ "prefer-signals": {
+ create(context) {
+ return {
+ CallExpression(node) {
+ if (
+ !isComponentFile(context.filename) &&
+ !isIslandFile(context.filename)
+ ) {
+ return;
+ }
+ if (
+ node.callee.type === "Identifier" &&
+ node.callee.name === "useState"
+ ) {
+ // TODO(iuioiua): add fix
+ context.report({
+ node,
+ range: node.range,
+ hint:
+ "Use `signal()` or `useSignal()` from `npm:@preact/signals` instead.",
+ message:
+ "Prefer to use `signal()` or `useSignal()` instead of `useState()` for state management.",
+ });
+ }
+ },
+ };
+ },
+ },
+ },
+} as Deno.lint.Plugin;
diff --git a/src/lint_plugin_test.ts b/src/lint_plugin_test.ts
new file mode 100644
index 00000000000..7288ad148a4
--- /dev/null
+++ b/src/lint_plugin_test.ts
@@ -0,0 +1,191 @@
+import { expect } from "@std/expect/expect";
+import plugin from "./lint_plugin.ts";
+
+Deno.test("fresh lint plugin - fresh/handler-export", async (t) => {
+ const INVALID_CODE = `
+export const handlers = {
+ GET() {},
+ POST() {},
+};
+export function handlers() {}
+export async function handlers() {}
+`;
+
+ const VALID_CODE = `
+export const handler = {
+ GET() {},
+ POST() {},
+};
+export function handler() {}
+export async function handler() {}
+
+// Doesn't apply
+export class handlers {}
+export const foo = {};
+export function bar() {}
+`;
+
+ await t.step("ignores files not within a `/routes/` directory", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/not_routes/foo.ts",
+ INVALID_CODE,
+ );
+ expect(diagnostics).toEqual([]);
+ });
+
+ await t.step("passes valid code in `/routes/` directory", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/routes/foo.ts",
+ VALID_CODE,
+ );
+ expect(diagnostics).toEqual([]);
+ });
+
+ await t.step("fails invalid code in `/routes/` directory", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/routes/foo.ts",
+ INVALID_CODE,
+ );
+ expect(diagnostics).toEqual([
+ {
+ hint: 'Did you mean "handler"?',
+ message:
+ `"Fresh middlewares must be exported as \`handler\` but got \`handlers\` instead."`,
+ range: [1, 54],
+ fix: [],
+ id: "fresh/handler-export",
+ },
+ {
+ hint: 'Did you mean "handler"?',
+ message:
+ `"Fresh middlewares must be exported as \`handler\` but got \`handlers\` instead."`,
+ range: [55, 84],
+ fix: [],
+ id: "fresh/handler-export",
+ },
+ {
+ hint: 'Did you mean "handler"?',
+ message:
+ `"Fresh middlewares must be exported as \`handler\` but got \`handlers\` instead."`,
+ range: [85, 120],
+ fix: [],
+ id: "fresh/handler-export",
+ },
+ ]);
+ });
+});
+
+Deno.test("fresh lint plugin - fresh/prefer-signals", async (t) => {
+ const INVALID_CODE = `
+import { useState } from "preact/hooks";
+
+export default function App() {
+ const [count, setCount] = useState(0);
+ return (
+
+
+
+ );
+}
+`;
+
+ const VALID_CODE = `
+import { useSignal } from "@preact/signals";
+
+export default function App() {
+ const count = useSignal(0);
+ return (
+
+
+
+ );
+}
+`;
+
+ await t.step("passes invalid code in a `.tsx` file", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/islands/foo.tsx",
+ VALID_CODE,
+ );
+ expect(diagnostics).toEqual([]);
+ });
+
+ await t.step("passes valid code in a `.jsx` file", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/islands/foo.jsx",
+ VALID_CODE,
+ );
+ expect(diagnostics).toEqual([]);
+ });
+
+ await t.step("fails invalid code in a `.tsx` file", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/islands/foo.tsx",
+ INVALID_CODE,
+ );
+ expect(diagnostics).toEqual([
+ {
+ hint:
+ "Use `signal()` or `useSignal()` from `npm:@preact/signals` instead.",
+ message:
+ `Prefer to use \`signal()\` or \`useSignal()\` instead of \`useState()\` for state management.`,
+ range: [103, 114],
+ fix: [],
+ id: "fresh/prefer-signals",
+ },
+ ]);
+ });
+
+ await t.step("fails invalid code in a route's island file", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/routes/foo/(_islands)/bar.tsx",
+ INVALID_CODE,
+ );
+ expect(diagnostics).toEqual([
+ {
+ hint:
+ "Use `signal()` or `useSignal()` from `npm:@preact/signals` instead.",
+ message:
+ `Prefer to use \`signal()\` or \`useSignal()\` instead of \`useState()\` for state management.`,
+ range: [103, 114],
+ fix: [],
+ id: "fresh/prefer-signals",
+ },
+ ]);
+ });
+
+ await t.step("fails invalid code in a `.jsx` file", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/islands/foo.jsx",
+ INVALID_CODE,
+ );
+ expect(diagnostics).toEqual([
+ {
+ hint:
+ "Use `signal()` or `useSignal()` from `npm:@preact/signals` instead.",
+ message:
+ `Prefer to use \`signal()\` or \`useSignal()\` instead of \`useState()\` for state management.`,
+ range: [103, 114],
+ fix: [],
+ id: "fresh/prefer-signals",
+ },
+ ]);
+ });
+
+ await t.step("passes invalid code in a non-island file", () => {
+ const diagnostics = Deno.lint.runPlugin(
+ plugin,
+ "/foo.tsx",
+ VALID_CODE,
+ );
+ expect(diagnostics).toEqual([]);
+ });
+});