diff --git a/packages/init/src/init.ts b/packages/init/src/init.ts index 53d97e70a9a..7a8f9576a08 100644 --- a/packages/init/src/init.ts +++ b/packages/init/src/init.ts @@ -6,6 +6,7 @@ import initConfig from "../deno.json" with { type: "json" }; // Keep these as is, as we replace these version in our release script const FRESH_VERSION = "2.2.2"; +const FRESH_LINT_VERSION = "0.1.0"; const FRESH_TAILWIND_VERSION = "1.0.0"; const FRESH_VITE_PLUGIN = "1.0.0"; const PREACT_VERSION = "10.28.3"; @@ -569,6 +570,7 @@ if (Deno.args.includes("build")) { update: "deno run -A -r jsr:@fresh/update .", }, lint: { + plugins: ["jsr:@fresh/lint"], rules: { tags: ["fresh", "recommended"], }, @@ -577,6 +579,7 @@ if (Deno.args.includes("build")) { imports: { "@/": "./", "fresh": `jsr:@fresh/core@^${freshVersion}`, + "@fresh/lint": `jsr:@fresh/lint@^${FRESH_LINT_VERSION}`, "preact": `npm:preact@^${PREACT_VERSION}`, "@preact/signals": `npm:@preact/signals@^${PREACT_SIGNALS_VERSION}`, } as Record, diff --git a/packages/lint/README.md b/packages/lint/README.md new file mode 100644 index 00000000000..c6aa75c7f21 --- /dev/null +++ b/packages/lint/README.md @@ -0,0 +1,29 @@ +# Fresh Lint rules + +This is a plugin with custom rules specifically for Fresh. + +## Usage + +1. Install the Fresh lint plugin + ```sh + deno add jsr:@fresh/lint + ``` +2. Configure the plugin in `deno.json` + ```json deno.json + { + "lint": { + "plugins": ["@fresh/lint"], + "rules": { + "include": ["fresh/[lint-rule]"] + } + } + } + ``` +3. You can now start linting Fresh code! 🎉 + +## Rules + +| Rule ID | Description | +| ----------------------- | ---------------------------------------------------------------------------------- | +| `handler-export` | Warn when exporting `handlers` over `handler` | +| `server-event-handlers` | Warn when code attempts to use event handlers or functions as props in server code | diff --git a/packages/lint/deno.json b/packages/lint/deno.json new file mode 100644 index 00000000000..4e879003744 --- /dev/null +++ b/packages/lint/deno.json @@ -0,0 +1,14 @@ +{ + "name": "@fresh/lint", + "version": "0.0.0", + "license": "MIT", + "exports": "./src/plugin.ts", + "publish": { + "include": [ + "src/**/*.ts", + "deno.json", + "README.md" + ], + "exclude": ["src/**/*.test.ts"] + } +} diff --git a/packages/lint/src/plugin.ts b/packages/lint/src/plugin.ts new file mode 100644 index 00000000000..d2465500a5c --- /dev/null +++ b/packages/lint/src/plugin.ts @@ -0,0 +1,41 @@ +import * as handlerExport from "./rules/handler-export.ts"; +import * as serverEventHandlers from "./rules/server-event-handlers.ts"; + +/** Expected shape of each lint rule module */ +export interface RuleModule { + /** The name of the lint rule, which becomes `fresh/` */ + RULE_NAME: string; + /** Rule implementation */ + rule: Deno.lint.Rule; +} + +/** + * Plugin for Fresh linting rules. + * + * For a full list of rules, see {@linkcode rules}. + * + * Enable lint rules by updating `deno.json`, each rule + * should be prefixed with `fresh/`. + * + * @example + * ```json deno.json + * { + * "lint": { + * "plugins": ["@fresh/lint"], + * "rules": { + * "include": ["fresh/test"] + * } + * } + * } + * ``` + */ +const plugin: Deno.lint.Plugin = { + name: "fresh", + rules: createRules(handlerExport, serverEventHandlers), +}; + +function createRules(...modules: RuleModule[]): Deno.lint.Plugin["rules"] { + return Object.fromEntries(modules.map((mod) => [mod.RULE_NAME, mod.rule])); +} + +export default plugin; diff --git a/packages/lint/src/rules/handler-export.test.ts b/packages/lint/src/rules/handler-export.test.ts new file mode 100644 index 00000000000..6cc8b0c5982 --- /dev/null +++ b/packages/lint/src/rules/handler-export.test.ts @@ -0,0 +1,51 @@ +import { expect } from "@std/expect"; +import { testPlugin } from "../test-utils.ts"; +import * as testRule from "./handler-export.ts"; + +const okCases = new Set<[filename: string, code: string]>([ + ["file:///foo.jsx", "const handler = {}"], + ["file:///foo.jsx", "function handler() {}"], + ["file:///foo.jsx", "export const handler = {}"], + ["file:///foo.jsx", "export const handlers = {}"], + ["file:///foo.jsx", "export function handlers() {}"], + ["file:///routes/foo.jsx", "export const handler = {}"], + ["file:///routes/foo.jsx", "export function handler() {}"], + ["file:///routes/foo.jsx", "export async function handler() {}"], + ["file:///routes/foo.jsx", "export const handler = define.handlers({});"], + ["file:///C:/www/routes/foo.jsx", "export const handler = {}"], +]); + +const errCases = new Set<[file: string, code: string, range: [number, number]]>( + [ + ["file:///routes/index.tsx", "export const handlers = {}", [13, 21]], + ["file:///routes/index.tsx", "export function handlers() {}", [16, 24]], + ["file:///routes/index.tsx", "export async function handlers() {}", [ + 22, + 30, + ]], + ["file:///C:/www/routes/foo.jsx", "export const handlers = {}", [13, 21]], + ], +); + +Deno.test("fresh/handler-export - ok", () => { + for (const [file, code] of okCases) { + const diagnostics = Deno.lint.runPlugin(testPlugin(testRule), file, code); + + expect(diagnostics.length).toBe(0); + } +}); + +Deno.test("fresh/handler-export - err", () => { + for (const [file, code, range] of errCases) { + const [d, ...rest] = Deno.lint.runPlugin(testPlugin(testRule), file, code); + + expect(rest.length).toBe(0); + expect(d.fix).toEqual([]); + expect(d.range).toEqual(range); + expect(d.id).toBe("fresh/handler-export"); + expect(d.message).toBe( + 'Fresh routes must export "handler" instead of "handlers".', + ); + expect(d.hint).toBe('Did you mean "handler"?'); + } +}); diff --git a/packages/lint/src/rules/handler-export.ts b/packages/lint/src/rules/handler-export.ts new file mode 100644 index 00000000000..52c1900e756 --- /dev/null +++ b/packages/lint/src/rules/handler-export.ts @@ -0,0 +1,42 @@ +import { NO_VISITOR, pathSegments } from "../utils.ts"; + +/** + * Reports routes using an incorrect export name for handlers. + * + * @example + * ```tsx + * // routes/index.ts + * export const handlers = () => {}; + * // ^^^^^^^^ should be "handler" + * ``` + * + * @module + */ + +export const RULE_NAME = "handler-export"; + +const MESSAGE = 'Fresh routes must export "handler" instead of "handlers".'; +const HINT = 'Did you mean "handler"?'; + +const HANDLERS_NAME = "handlers"; +const HANDLERS_EXPORT_SELECTOR = + `ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > Identifier[name=${HANDLERS_NAME}], + ExportNamedDeclaration > FunctionDeclaration > Identifier[name=${HANDLERS_NAME}]`; + +export const rule: Deno.lint.Rule = { + create(ctx) { + // Ignore files outside `routes/` dir + if (!pathSegments(ctx.filename).isRoute()) return NO_VISITOR; + + return { + [HANDLERS_EXPORT_SELECTOR](node: Deno.lint.Identifier) { + ctx.report({ + message: MESSAGE, + hint: HINT, + node, + range: node.range, + }); + }, + }; + }, +}; diff --git a/packages/lint/src/rules/server-event-handlers.test.ts b/packages/lint/src/rules/server-event-handlers.test.ts new file mode 100644 index 00000000000..84c9ac4f68c --- /dev/null +++ b/packages/lint/src/rules/server-event-handlers.test.ts @@ -0,0 +1,59 @@ +import { expect } from "@std/expect"; +import { testPlugin } from "../test-utils.ts"; +import * as testRule from "./server-event-handlers.ts"; + +const okCases = new Set<[filename: string, code: string]>([ + ["file:///foo.jsx", " {}} />"], + ["file:///foo.jsx", "