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([]); + }); +});