Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/init/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"],
},
Expand All @@ -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<string, string>,
Expand Down
29 changes: 29 additions & 0 deletions packages/lint/README.md
Original file line number Diff line number Diff line change
@@ -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 |
14 changes: 14 additions & 0 deletions packages/lint/deno.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
41 changes: 41 additions & 0 deletions packages/lint/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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>` */
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/<rule_name>`.
*
* @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;
51 changes: 51 additions & 0 deletions packages/lint/src/rules/handler-export.test.ts
Original file line number Diff line number Diff line change
@@ -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"?');
}
});
42 changes: 42 additions & 0 deletions packages/lint/src/rules/handler-export.ts
Original file line number Diff line number Diff line change
@@ -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,
});
},
};
},
};
59 changes: 59 additions & 0 deletions packages/lint/src/rules/server-event-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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", "<Foo onClick={() => {}} />"],
["file:///foo.jsx", "<button onClick={() => {}} />"],
["file:///foo.jsx", "<button onClick={function () {}} />"],
["file:///foo.jsx", "<button onclick={function () {}} />"],
["file:///foo.jsx", "<button onClick=\"console.log('hey')\" />"],
["file:///foo.jsx", '<button online="foo" />'],
["file:///foo.jsx", "<x-foo onClick=\"console.log('hey')\" />"],
[
"file:///routes/foo/(_islands)/foo.jsx",
"<button onClick={function () {}} />",
],
]);

const errCases = new Set<[file: string, code: string, range: [number, number]]>(
[
["file:///routes/index.tsx", "<button onClick={() => {}} />", [8, 26]],
["file:///routes/index.tsx", "<button onTouchMove={() => {}} />", [8, 30]],
[
"file:///routes/index.tsx",
`<button onTouchMove={"console.log('hey')"} />`,
[8, 42],
],
["file:///routes/index.tsx", "<foo-button foo={() => {}} />", [12, 26]],
["file:///routes/index.tsx", "<foo-button foo={function () {}} />", [
12,
32,
]],
],
);

Deno.test("fresh/server-event-handlers - ok", () => {
for (const [file, code] of okCases) {
const diagnostics = Deno.lint.runPlugin(testPlugin(testRule), file, code);

expect(diagnostics.length).toBe(0);
}
});

Deno.test("fresh/server-event-handlers - 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/server-event-handlers");
expect(d.message).toBe(
"Server components cannot install client side event handlers.",
);
expect(d.hint).toBe(
"Remove this property or turn the enclosing component into an island",
);
}
});
60 changes: 60 additions & 0 deletions packages/lint/src/rules/server-event-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NO_VISITOR, pathSegments } from "../utils.ts";

/**
* Reports server components that install client side event handlers.
*
* Disallows `on*` attributes for JSX components inside the
* `routes/` directory, as these components are rendered on the server.
*
* It will also warn when passing a function as prop to a custom element,
* as functions cannot be serialized to HTML on the server.
*
* @example
* ```tsx
* // routes/index.ts
* <button onClick={() => {}} />
* // ^^^^^^^^^^^^^^^^^^ invalid handler
*
* <MyComponent handler={() => {}} />
* // ^^^^^^^^^^^^^^^^^^ invalid handler
* ```
*
* The `(_islands)` directory is excluded from this lint rule.
*
* @module
*/

export const RULE_NAME = "server-event-handlers";

const MESSAGE = "Server components cannot install client side event handlers.";
const HINT =
"Remove this property or turn the enclosing component into an island";

// Note: This selector will match any function passed as a prop to a custom element, not just event handlers.
const CUSTOM_ELEMENT_FN_EXPR_ATTR_SELECTOR =
'JSXOpeningElement[name.type="JSXIdentifier"][name.name=/-/] > JSXAttribute[name.type="JSXIdentifier"]:has(> JSXExpressionContainer[expression.type=/^(FunctionExpression|ArrowFunctionExpression)$/])';
const HTML_ELEMENT_ON_ATTR_SELECTOR =
'JSXOpeningElement[name.type="JSXIdentifier"][name.name=/^[a-z]+$/] > JSXAttribute[name.type="JSXIdentifier"][name.name=/^on/]';

export const rule: Deno.lint.Rule = {
create(ctx) {
const path = pathSegments(ctx.filename);

// Ignore island components or components outside `routes/` dir
if (path.isIsland() || !path.isRoute()) return NO_VISITOR;

const reportNode = (node: Deno.lint.JSXAttribute) => {
ctx.report({
message: MESSAGE,
hint: HINT,
node,
range: node.range,
});
};

return {
[CUSTOM_ELEMENT_FN_EXPR_ATTR_SELECTOR]: reportNode,
[HTML_ELEMENT_ON_ATTR_SELECTOR]: reportNode,
};
},
};
9 changes: 9 additions & 0 deletions packages/lint/src/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { RuleModule } from "./plugin.ts";

/** Create a test lint plugin for the given rule */
export function testPlugin({ rule, RULE_NAME }: RuleModule): Deno.lint.Plugin {
return {
name: `fresh`,
rules: { [RULE_NAME]: rule },
};
}
19 changes: 19 additions & 0 deletions packages/lint/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Utility for e.g. ignored files to avoid visiting any AST nodes */
export const NO_VISITOR: Deno.lint.LintVisitor = Object.freeze({});

/** Utility for inspecting the path segments of a `file://` path */
export function pathSegments(filename: string) {
// TODO: Make this folder respect Fresh config?
const ROUTES_DIR = "routes";
const ISLANDS_DIR = "(_islands)";
const segments = new Set(filename.split("/"));

return {
isRoute() {
return segments.has(ROUTES_DIR);
},
isIsland() {
return segments.has(ISLANDS_DIR);
},
};
}
5 changes: 5 additions & 0 deletions packages/update/src/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { walk } from "@std/fs/walk";
export const SyntaxKind = tsmorph.ts.SyntaxKind;

export const FRESH_VERSION = "2.2.2";
export const FRESH_LINT_VERSION = "0.1.0";
export const PREACT_VERSION = "10.28.3";
export const PREACT_SIGNALS_VERSION = "2.7.1";

Expand Down Expand Up @@ -142,6 +143,10 @@ export async function updateProject(dir: string) {
delete config.imports["@preact/signals-core"];
delete config.imports["preact-render-to-string"];

if (config.imports["@fresh/lint"]) {
config.imports["@fresh/lint"] = `jsr:@fresh/lint@^${FRESH_LINT_VERSION}`;
}

// We should always use a lockfile going forwards
if ("lock" in config) {
delete config.lock;
Expand Down
3 changes: 3 additions & 0 deletions www/deno.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"lint": {
"plugins": ["../packages/lint/src/plugin.ts"]
},
"tasks": {
"start": "deno serve -A _fresh/server.js",
"dev": "vite",
Expand Down
Loading