Skip to content

Commit ef642ec

Browse files
committed
feat: add Fresh lint plugin
1 parent 647d6f9 commit ef642ec

12 files changed

Lines changed: 335 additions & 0 deletions

File tree

packages/init/src/init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from "@std/path";
44

55
// Keep these as is, as we replace these version in our release script
66
const FRESH_VERSION = "2.0.0-alpha.59";
7+
const FRESH_LINT_VERSION = "0.0.0";
78
const FRESH_TAILWIND_VERSION = "0.0.1-alpha.9";
89
const PREACT_VERSION = "10.27.1";
910
const PREACT_SIGNALS_VERSION = "2.3.1";
@@ -511,13 +512,15 @@ if (Deno.args.includes("build")) {
511512
update: "deno run -A -r jsr:@fresh/update .",
512513
},
513514
lint: {
515+
plugins: ["jsr:@fresh/lint"],
514516
rules: {
515517
tags: ["fresh", "recommended"],
516518
},
517519
},
518520
exclude: ["**/_fresh/*"],
519521
imports: {
520522
"fresh": `jsr:@fresh/core@^${FRESH_VERSION}`,
523+
"@fresh/lint": `jsr:@fresh/lint@^${FRESH_LINT_VERSION}`,
521524
"preact": `npm:preact@^${PREACT_VERSION}`,
522525
"@preact/signals": `npm:@preact/signals@^${PREACT_SIGNALS_VERSION}`,
523526
} as Record<string, string>,

packages/lint/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Fresh Lint rules
2+
3+
This is a plugin with custom rules specifically for Fresh.
4+
5+
## Usage
6+
7+
1. Install the Fresh lint plugin
8+
```sh
9+
deno add jsr:@fresh/lint
10+
```
11+
2. Configure the plugin in `deno.json`
12+
```json deno.json
13+
{
14+
"lint": {
15+
"plugins": ["@fresh/lint"],
16+
"rules": {
17+
"include": ["fresh/[lint-rule]"]
18+
}
19+
}
20+
}
21+
```
22+
3. You can now start linting Fresh code! 🎉
23+
24+
## Rules
25+
26+
TODO: COMING SOON!

packages/lint/deno.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "@fresh/lint",
3+
"version": "0.0.0",
4+
"license": "MIT",
5+
"exports": "./src/plugin.ts",
6+
"publish": {
7+
"include": [
8+
"src/**/*.ts",
9+
"deno.json",
10+
"README.md"
11+
]
12+
}
13+
}

packages/lint/src/plugin.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as handlerExport from "./rules/handler-export.ts";
2+
import * as serverEventHandlers from "./rules/server-event-handlers.ts";
3+
4+
/** Expected shape of each lint rule module */
5+
export interface RuleModule {
6+
/** The name of the lint rule, which becomes `fresh/<rule-name>` */
7+
RULE_NAME: string;
8+
/** Rule implementation */
9+
rule: Deno.lint.Rule;
10+
}
11+
12+
/**
13+
* Plugin for Fresh linting rules.
14+
*
15+
* For a full list of rules, see {@linkcode rules}.
16+
*
17+
* Enable lint rules by updating `deno.json`, each rule
18+
* should be prefixed with `fresh/<rule_name>`.
19+
*
20+
* @example
21+
* ```json deno.json
22+
* {
23+
* "lint": {
24+
* "plugins": ["@fresh/lint"],
25+
* "rules": {
26+
* "include": ["fresh/test"]
27+
* }
28+
* }
29+
* }
30+
* ```
31+
*/
32+
const plugin: Deno.lint.Plugin = {
33+
name: "fresh",
34+
rules: createRules(handlerExport, serverEventHandlers),
35+
};
36+
37+
function createRules(...modules: RuleModule[]): Deno.lint.Plugin["rules"] {
38+
return Object.fromEntries(modules.map((mod) => [mod.RULE_NAME, mod.rule]));
39+
}
40+
41+
export default plugin;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect } from "@std/expect";
2+
import { testPlugin } from "../test-utils.ts";
3+
import * as testRule from "./handler-export.ts";
4+
5+
const okCases = new Set<[filename: string, code: string]>([
6+
["file:///foo.jsx", "const handler = {}"],
7+
["file:///foo.jsx", "function handler() {}"],
8+
["file:///foo.jsx", "export const handler = {}"],
9+
["file:///foo.jsx", "export const handlers = {}"],
10+
["file:///foo.jsx", "export function handlers() {}"],
11+
["file:///routes/foo.jsx", "export const handler = {}"],
12+
["file:///routes/foo.jsx", "export function handler() {}"],
13+
["file:///routes/foo.jsx", "export async function handler() {}"],
14+
["file:///routes/foo.jsx", "export const handler = define.handlers({});"],
15+
["file:///C:/www/routes/foo.jsx", "export const handler = {}"],
16+
]);
17+
18+
const errCases = new Set<[file: string, code: string, range: [number, number]]>(
19+
[
20+
["file:///routes/index.tsx", "export const handlers = {}", [13, 21]],
21+
["file:///routes/index.tsx", "export function handlers() {}", [16, 24]],
22+
["file:///routes/index.tsx", "export async function handlers() {}", [
23+
22,
24+
30,
25+
]],
26+
["file:///C:/www/routes/foo.jsx", "export const handlers = {}", [13, 21]],
27+
],
28+
);
29+
30+
Deno.test("fresh/handler-export - ok", () => {
31+
for (const [file, code] of okCases) {
32+
const diagnostics = Deno.lint.runPlugin(testPlugin(testRule), file, code);
33+
34+
expect(diagnostics.length).toBe(0);
35+
}
36+
});
37+
38+
Deno.test("fresh/handler-export - err", () => {
39+
for (const [file, code, range] of errCases) {
40+
const [d, ...rest] = Deno.lint.runPlugin(testPlugin(testRule), file, code);
41+
42+
expect(rest.length).toBe(0);
43+
expect(d.fix).toEqual([]);
44+
expect(d.range).toEqual(range);
45+
expect(d.id).toBe("fresh/handler-export");
46+
expect(d.message).toBe(
47+
'Fresh middlewares must be exported as "handler" but got "handlers" instead.',
48+
);
49+
expect(d.hint).toBe('Did you mean "handler"?');
50+
}
51+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { NO_VISITOR, pathSegments } from "../utils.ts";
2+
3+
/**
4+
* Reports routes using an incorrect export name for handlers.
5+
*
6+
* @example
7+
* ```tsx
8+
* // routes/index.ts
9+
* export const handlers = () => {};
10+
* // ^^^^^^^^ should be "handler"
11+
* ```
12+
*
13+
* @module
14+
*/
15+
16+
export const RULE_NAME = "handler-export";
17+
18+
const MESSAGE =
19+
'Fresh middlewares must be exported as "handler" but got "handlers" instead.';
20+
const HINT = 'Did you mean "handler"?';
21+
22+
const HANDLERS_NAME = "handlers";
23+
const HANDLERS_EXPORT_SELECTOR =
24+
`ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > Identifier[name=${HANDLERS_NAME}],
25+
ExportNamedDeclaration > FunctionDeclaration > Identifier[name=${HANDLERS_NAME}]`;
26+
27+
export const rule: Deno.lint.Rule = {
28+
create(ctx) {
29+
// Ignore files outside `routes/` dir
30+
if (!pathSegments(ctx.filename).isRoute()) return NO_VISITOR;
31+
32+
return {
33+
[HANDLERS_EXPORT_SELECTOR](node: Deno.lint.Identifier) {
34+
ctx.report({
35+
message: MESSAGE,
36+
hint: HINT,
37+
node,
38+
range: node.range,
39+
});
40+
},
41+
};
42+
},
43+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect } from "@std/expect";
2+
import { testPlugin } from "../test-utils.ts";
3+
import * as testRule from "./server-event-handlers.ts";
4+
5+
const okCases = new Set<[filename: string, code: string]>([
6+
["file:///foo.jsx", "<Foo onClick={() => {}} />"],
7+
["file:///foo.jsx", "<button onClick={() => {}} />"],
8+
["file:///foo.jsx", "<button onClick={function () {}} />"],
9+
["file:///foo.jsx", "<button onclick={function () {}} />"],
10+
["file:///foo.jsx", "<button onClick=\"console.log('hey')\" />"],
11+
["file:///foo.jsx", '<button online="foo" />'],
12+
["file:///foo.jsx", "<x-foo onClick=\"console.log('hey')\" />"],
13+
[
14+
"file:///routes/foo/(_islands)/foo.jsx",
15+
"<button onClick={function () {}} />",
16+
],
17+
]);
18+
19+
const errCases = new Set<[file: string, code: string, range: [number, number]]>(
20+
[
21+
["file:///routes/index.tsx", "<button onClick={() => {}} />", [8, 26]],
22+
["file:///routes/index.tsx", "<button onTouchMove={() => {}} />", [8, 30]],
23+
[
24+
"file:///routes/index.tsx",
25+
`<button onTouchMove={"console.log('hey')"} />`,
26+
[8, 42],
27+
],
28+
["file:///routes/index.tsx", "<foo-button foo={() => {}} />", [12, 26]],
29+
["file:///routes/index.tsx", "<foo-button foo={function () {}} />", [
30+
12,
31+
32,
32+
]],
33+
],
34+
);
35+
36+
Deno.test("fresh/server-event-handlers - ok", () => {
37+
for (const [file, code] of okCases) {
38+
const diagnostics = Deno.lint.runPlugin(testPlugin(testRule), file, code);
39+
40+
expect(diagnostics.length).toBe(0);
41+
}
42+
});
43+
44+
Deno.test("fresh/server-event-handlers - err", () => {
45+
for (const [file, code, range] of errCases) {
46+
const [d, ...rest] = Deno.lint.runPlugin(testPlugin(testRule), file, code);
47+
48+
expect(rest.length).toBe(0);
49+
expect(d.fix).toEqual([]);
50+
expect(d.range).toEqual(range);
51+
expect(d.id).toBe("fresh/server-event-handlers");
52+
expect(d.message).toBe(
53+
"Server components cannot install client side event handlers.",
54+
);
55+
expect(d.hint).toBe(
56+
"Remove this property or turn the enclosing component into an island",
57+
);
58+
}
59+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NO_VISITOR, pathSegments } from "../utils.ts";
2+
3+
/**
4+
* Reports server components that install client side event handlers.
5+
*
6+
* Disallows `on*` attributes for JSX components inside the
7+
* `routes/` directory, as these components are rendered on the server.
8+
*
9+
* @example
10+
* ```tsx
11+
* // routes/index.ts
12+
* <button onClick={() => {}} />
13+
* // ^^^^^^^^^^^^^^^^^^ invalid handler
14+
* ```
15+
*
16+
* The `(_islands)` directory is excluded from this lint rule.
17+
*
18+
* @module
19+
*/
20+
21+
export const RULE_NAME = "server-event-handlers";
22+
23+
const MESSAGE = "Server components cannot install client side event handlers.";
24+
const HINT =
25+
"Remove this property or turn the enclosing component into an island";
26+
27+
const CUSTOM_ELEMENT_FN_EXPR_ATTR_SELECTOR =
28+
'JSXOpeningElement[name.type="JSXIdentifier"][name.name=/-/] > JSXAttribute[name.type="JSXIdentifier"]:has(> JSXExpressionContainer[expression.type=/^(FunctionExpression|ArrowFunctionExpression)$/])';
29+
const HTML_ELEMENT_ON_ATTR_SELECTOR =
30+
'JSXOpeningElement[name.type="JSXIdentifier"] > JSXAttribute[name.type="JSXIdentifier"][name.name=/^on/]';
31+
32+
export const rule: Deno.lint.Rule = {
33+
create(ctx) {
34+
const path = pathSegments(ctx.filename);
35+
36+
// Ignore island components or components outside `routes/` dir
37+
if (path.isIsland() || !path.isRoute()) return NO_VISITOR;
38+
39+
const reportNode = (node: Deno.lint.JSXAttribute) => {
40+
ctx.report({
41+
message: MESSAGE,
42+
hint: HINT,
43+
node,
44+
range: node.range,
45+
});
46+
};
47+
48+
return {
49+
[CUSTOM_ELEMENT_FN_EXPR_ATTR_SELECTOR]: reportNode,
50+
[HTML_ELEMENT_ON_ATTR_SELECTOR](node: Deno.lint.JSXAttribute) {
51+
const parent = node.parent.name;
52+
// Should already be a JSXIdentifier
53+
// Regex used on parent manually here, because there is an issue with character classes
54+
// See: https://github.com/denoland/deno/issues/30460
55+
if (parent.type === "JSXIdentifier" && /^[a-z]+$/.test(parent.name)) {
56+
reportNode(node);
57+
}
58+
},
59+
};
60+
},
61+
};

packages/lint/src/test-utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { RuleModule } from "./plugin.ts";
2+
3+
/** Create a test lint plugin for the given rule */
4+
export function testPlugin({ rule, RULE_NAME }: RuleModule): Deno.lint.Plugin {
5+
return {
6+
name: `fresh`,
7+
rules: { [RULE_NAME]: rule },
8+
};
9+
}

packages/lint/src/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { SEPARATOR_PATTERN } from "@std/path";
2+
3+
/** Utility for e.g. ignored files to avoid visiting any AST nodes */
4+
export const NO_VISITOR: Deno.lint.LintVisitor = Object.freeze({});
5+
6+
/** Utility for inspecting the path segments of a file */
7+
export function pathSegments(filename: string) {
8+
// TODO: Make this folder respect Fresh config?
9+
const ROUTES_DIR = "routes";
10+
const ISLANDS_DIR = "(_islands)";
11+
const segments = filename.split(SEPARATOR_PATTERN);
12+
13+
return {
14+
isRoute() {
15+
return segments.includes(ROUTES_DIR);
16+
},
17+
isIsland() {
18+
return segments.includes(ISLANDS_DIR);
19+
},
20+
};
21+
}

0 commit comments

Comments
 (0)