From 7cdb62a3a4a691ebdbc6e63bb5ad4e7cf5737270 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sat, 2 Aug 2025 21:30:38 +0200 Subject: [PATCH 01/11] feat: add Fresh lint plugin --- packages/init/src/init.ts | 3 + packages/lint/README.md | 26 ++++++++ packages/lint/deno.json | 13 ++++ packages/lint/src/plugin.ts | 41 +++++++++++++ .../lint/src/rules/handler-export.test.ts | 51 ++++++++++++++++ packages/lint/src/rules/handler-export.ts | 43 ++++++++++++++ .../src/rules/server-event-handlers.test.ts | 59 +++++++++++++++++++ .../lint/src/rules/server-event-handlers.ts | 53 +++++++++++++++++ packages/lint/src/test-utils.ts | 9 +++ packages/lint/src/utils.ts | 21 +++++++ packages/update/src/update.ts | 7 ++- www/deno.json | 3 + 12 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 packages/lint/README.md create mode 100644 packages/lint/deno.json create mode 100644 packages/lint/src/plugin.ts create mode 100644 packages/lint/src/rules/handler-export.test.ts create mode 100644 packages/lint/src/rules/handler-export.ts create mode 100644 packages/lint/src/rules/server-event-handlers.test.ts create mode 100644 packages/lint/src/rules/server-event-handlers.ts create mode 100644 packages/lint/src/test-utils.ts create mode 100644 packages/lint/src/utils.ts diff --git a/packages/init/src/init.ts b/packages/init/src/init.ts index 53d97e70a9a..df1367f88f6 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.0.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..a0b91626b17 --- /dev/null +++ b/packages/lint/README.md @@ -0,0 +1,26 @@ +# 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 + +TODO: COMING SOON! diff --git a/packages/lint/deno.json b/packages/lint/deno.json new file mode 100644 index 00000000000..09a35fdcd9f --- /dev/null +++ b/packages/lint/deno.json @@ -0,0 +1,13 @@ +{ + "name": "@fresh/lint", + "version": "0.0.0", + "license": "MIT", + "exports": "./src/plugin.ts", + "publish": { + "include": [ + "src/**/*.ts", + "deno.json", + "README.md" + ] + } +} 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..f3c2e2a495e --- /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 middlewares must be exported as "handler" but got "handlers" instead.', + ); + 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..01116b4c4ee --- /dev/null +++ b/packages/lint/src/rules/handler-export.ts @@ -0,0 +1,43 @@ +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 middlewares must be exported as "handler" but got "handlers" instead.'; +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", "