Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -79,6 +80,7 @@
"jsxPrecompileSkipElements": ["a", "img", "source", "body", "html", "head"]
},
"lint": {
"plugins": ["./src/lint_plugin.ts"],
"rules": {
"exclude": ["no-window"],
"include": ["no-console"]
Expand Down
159 changes: 159 additions & 0 deletions src/lint_plugin.ts
Original file line number Diff line number Diff line change
@@ -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 (
* <div>
* Counter is at {count}.{" "}
* <button onClick={() => setCount(count + 1)}>+</button>
* </div>
* );
* }
* ```
*
* @example Valid
* ```tsx
* // islands/my-island.tsx
* import { useSignal } from "@preact/signals";
*
* export default function MyIsland() {
* const count = useSignal(0);
*
* return (
* <div>
* Counter is at {count}.{" "}
* <button onClick={() => (count.value += 1)}>+</button>
* </div>
* );
* }
* ```
*/
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;
191 changes: 191 additions & 0 deletions src/lint_plugin_test.ts
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
</div>
);
}
`;

const VALID_CODE = `
import { useSignal } from "@preact/signals";

export default function App() {
const count = useSignal(0);
return (
<div>
<button onClick={() => (count.value += 1)}>{count}</button>
</div>
);
}
`;

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