chore: migrate from ESLint to Biome for linting (#3694)

Replace ESLint with Biome for ~14x faster linting (~360ms vs ~5s).

- Add biome.json with rules matching ESLint parity (Next.js, a11y,
  TypeScript, unused vars/imports)
- Remove eslint, @typescript-eslint/*, eslint-config-next,
  eslint-plugin-jsx-a11y, @eslint/* from deps
- Remove eslint.config.mjs
- Update lint script to: tsgo --noEmit && biome check .
- Fix 11 real code issues caught by Biome (banned types, explicit any,
  unsafe finally, unreachable code, shadowed names)
- Disable Biome formatter (Prettier stays for Tailwind class sorting)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-03-30 22:25:04 +08:00
committed by GitHub
parent b4207bd742
commit 956c71cd7b
37 changed files with 272 additions and 2231 deletions
@@ -24,3 +24,6 @@
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
## Workflow
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
@@ -0,0 +1,11 @@
---
name: Always rebase before PR
description: Rebase to origin/main before creating pull requests
type: feedback
---
Always rebase the branch onto origin/main before creating a pull request.
**Why:** The user wants PRs to be up-to-date with main to avoid merge conflicts and keep a clean history.
**How to apply:** Before running `gh pr create`, always run `git fetch origin && git rebase origin/main` first. If there are conflicts, resolve them before proceeding.
+1 -1
View File
@@ -22,7 +22,7 @@ pnpm tauri:dev:test # Start Tauri app with webdriver
pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # ESLint
pnpm lint # Biome (linter) + tsgo (type check)
pnpm format # Prettier (runs from monorepo root)
pnpm format:check # Check formatting without writing
+125
View File
@@ -0,0 +1,125 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": true },
"formatter": { "enabled": false },
"assist": { "enabled": false },
"css": { "linter": { "enabled": false } },
"linter": {
"enabled": true,
"includes": [
"**",
"!.next/**",
"!.open-next/**",
"!.wrangler/**",
"!.claude/**",
"!dist/**",
"!out/**",
"!build/**",
"!public/**",
"!src-tauri/**",
"!next-env.d.ts",
"!i18next-scanner.config.cjs"
],
"rules": {
"recommended": true,
"a11y": {
"recommended": true,
"useKeyWithClickEvents": "off",
"useKeyWithMouseEvents": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off",
"useSemanticElements": "off",
"noAriaHiddenOnFocusable": "off",
"noInteractiveElementToNoninteractiveRole": "off",
"noNoninteractiveElementToInteractiveRole": "off",
"noNoninteractiveElementInteractions": "off",
"noStaticElementInteractions": "off",
"noNoninteractiveTabindex": "off",
"useButtonType": "off",
"useAriaPropsSupportedByRole": "off",
"useFocusableInteractive": "off",
"noAutofocus": "off"
},
"complexity": {
"noForEach": "off",
"noStaticOnlyClass": "off",
"noUselessSwitchCase": "off",
"noUselessFragments": "off",
"noUselessCatch": "off",
"useLiteralKeys": "off",
"useOptionalChain": "off",
"noThisInStatic": "off",
"useArrowFunction": "off",
"noUselessEscapeInRegex": "off"
},
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "warn",
"useExhaustiveDependencies": "off",
"useHookAtTopLevel": "error",
"useJsxKeyInIterable": "error",
"noChildrenProp": "error",
"noNextAsyncClientComponent": "warn",
"noSwitchDeclarations": "off",
"noUndeclaredVariables": "off",
"noEmptyCharacterClassInRegex": "off",
"useParseIntRadix": "off",
"noEmptyPattern": "off"
},
"nursery": {
"noBeforeInteractiveScriptOutsideDocument": "warn",
"noDuplicateEnumValues": "error",
"noSyncScripts": "warn",
"useInlineScriptId": "error"
},
"performance": {
"noImgElement": "off",
"noUnwantedPolyfillio": "warn",
"useGoogleFontPreconnect": "warn"
},
"security": {
"noBlankTarget": "off",
"noDangerouslySetInnerHtmlWithChildren": "off",
"noDangerouslySetInnerHtml": "off"
},
"style": {
"noNonNullAssertion": "off",
"useImportType": "off",
"noParameterAssign": "off",
"useDefaultParameterLast": "off",
"noUselessElse": "off",
"noHeadElement": "warn",
"noCommonJs": "off",
"useFilenamingConvention": "off",
"useNamingConvention": "off",
"noUnusedTemplateLiteral": "off",
"useTemplate": "off",
"useExponentiationOperator": "off",
"useNodejsImportProtocol": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noArrayIndexKey": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noDocumentImportInPage": "error",
"noHeadImportInDocument": "error",
"useGoogleFontDisplay": "warn",
"noCommentText": "error",
"noDuplicateJsxProps": "error",
"noAsyncPromiseExecutor": "off",
"noImplicitAnyLet": "off",
"noControlCharactersInRegex": "off",
"noEmptyBlockStatements": "off",
"useIterableCallbackReturn": "off",
"noGlobalIsNan": "off",
"noConfusingVoidType": "off",
"noConstEnum": "off"
}
}
},
"javascript": {
"globals": ["React"]
}
}
-41
View File
@@ -1,41 +0,0 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import next from 'eslint-config-next';
import nextVitals from 'eslint-config-next/core-web-vitals';
import tseslint from 'eslint-config-next/typescript';
import jsxA11y from 'eslint-plugin-jsx-a11y';
const eslintConfig = defineConfig([
...tseslint,
...next,
...nextVitals,
{
rules: {
...jsxA11y.configs.recommended.rules,
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
globalIgnores([
'node_modules/**',
'.next/**',
'.open-next/**',
'.wrangler/**',
'.claude/**',
'dist/**',
'out/**',
'build/**',
'public/**',
'src-tauri/**',
'next-env.d.ts',
'i18next-scanner.config.cjs',
]),
]);
export default eslintConfig;
+1 -6
View File
@@ -15,7 +15,7 @@
"start-web:vinext": "dotenv -e .env.web -- vinext start",
"build-tauri": "dotenv -e .env.tauri -- next build",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "tsgo --noEmit && eslint --cache .",
"lint": "tsgo --noEmit && biome check .",
"test": "dotenv -e .env -e .env.test.local vitest",
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
"test:tauri": "bash scripts/test-tauri.sh",
@@ -193,8 +193,6 @@
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@typescript/native-preview": "7.0.0-dev.20260312.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-rsc": "^0.5.21",
@@ -211,9 +209,6 @@
"cpx2": "^8.0.0",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-plugin-jsx-a11y": "^6.10.2",
"i18next-scanner": "^4.6.0",
"jsdom": "^28.1.0",
"mkdirp": "^3.0.1",
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import React from 'react';
import NumberInput from '@/components/settings/NumberInput';
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import React from 'react';
import { StaticListRow } from '@/app/reader/components/sidebar/TOCItem';
import { TOCItem } from '@/libs/document';
@@ -45,8 +45,7 @@ export const setupSupabaseMocks = async (
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
})),
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
} as unknown as ReturnType<typeof supabase.from>);
vi.mocked(createSupabaseAdminClient).mockReturnValue({
from: vi.fn(() => ({
@@ -102,6 +101,5 @@ export const setupSupabaseMocks = async (
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
})),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
} as unknown as ReturnType<typeof createSupabaseAdminClient>);
};
@@ -211,7 +211,7 @@ describe('createBookGroups', () => {
expect(john?.books).toHaveLength(1);
expect(jane?.books).toHaveLength(1);
expect(john?.books[0]!.hash).toBe(jane?.books[0]!.hash);
expect(john?.books[0]?.hash).toBe(jane?.books[0]?.hash);
});
it('should leave books without author as ungrouped', () => {
@@ -1103,8 +1103,7 @@ describe('proofreadTransformer', () => {
enabled: true,
isRegex: false,
caseSensitive: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
order: undefined as any,
order: 0,
wholeWord: true,
},
];
@@ -4,10 +4,13 @@ import { TxtToEpubConverter } from '@/utils/txt';
/**
* Access private createChapterRegexps via a thin test subclass.
*/
type TxtConverterPrivateAPI = {
createChapterRegexps(language: string): RegExp[];
};
class TestableConverter extends TxtToEpubConverter {
getChapterRegexps(language: string): RegExp[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this as any).createChapterRegexps(language);
return (this as unknown as TxtConverterPrivateAPI).createChapterRegexps(language);
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ interface ErrorPageProps {
reset: () => void;
}
export default function Error({ error, reset }: ErrorPageProps) {
export default function ErrorPage({ error, reset }: ErrorPageProps) {
const _ = useTranslation();
const { appService } = useEnv();
const [browserInfo, setBrowserInfo] = useState('');
@@ -149,8 +149,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
return available;
}
return available;
}
return true;
};
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import {
RiFolderOpenLine,
RiCheckboxCircleFill,
+1 -1
View File
@@ -892,7 +892,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
(libraryBooks.some((book) => !book.deletedAt) ? (
<OverlayScrollbarsComponent
defer
aria-label=''
aria-label='Library bookshelf'
ref={osRef}
className='flex-grow'
options={{ scrollbars: { autoHide: 'scroll' } }}
-1
View File
@@ -483,7 +483,6 @@ export default function BrowserPage() {
console.error('Download error:', e);
throw e;
}
return;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, state.baseURL, appService, libraryLoaded],
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { RiTranslateAi } from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
@@ -118,7 +118,7 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
const templateData = {
title: bookTitle,
author: bookAuthor,
exportDate: new Date().getTime(),
exportDate: Date.now(),
chapters: sortedGroups.map((group) => ({
title: group.label || _('Untitled'),
annotations: group.booknotes.map((note) => ({
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useRef } from 'react';
import { useRef } from 'react';
import { MdInfoOutline } from 'react-icons/md';
import { Book } from '@/types/book';
import { useThemeStore } from '@/store/themeStore';
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import {
MdPlayArrow,
MdOutlinePause,
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { useState, ChangeEvent, useEffect } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { TbChevronCompactDown, TbChevronCompactUp } from 'react-icons/tb';
import { RiVoiceAiFill } from 'react-icons/ri';
@@ -49,7 +49,7 @@ const useBooksManager = () => {
};
const getNextBookKey = (bookKey: string) => {
const index = bookKeys.findIndex((key) => key === bookKey);
const index = bookKeys.indexOf(bookKey);
const nextIndex = (index + 1) % bookKeys.length;
return bookKeys[nextIndex]!;
};
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import React, { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { isMacPlatform } from '@/services/environment';
import { getShortcutsForDisplay } from '@/helpers/shortcuts';
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
import { createContext, useState, useContext, ReactNode, useEffect } from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '@/utils/supabase';
import posthog from 'posthog-js';
@@ -39,7 +39,7 @@ export type StripeAvailablePlan = AvailablePlan & {
export const fetchStripePlans = async () => {
const response = await fetch(WEB_STRIPE_PLANS_URL);
const data = await response.json();
return data && data instanceof Array ? data : [];
return data && Array.isArray(data) ? data : [];
};
export const createStripeCheckoutSession = async (
+1 -1
View File
@@ -121,7 +121,7 @@ export async function GET(req: NextRequest) {
// TODO: Remove this hotfix for the initial race condition for books sync
if (results.books?.length === 0 && since.getTime() < 1000) {
const dummyHash = '00000000000000000000000000000000';
const now = new Date().getTime();
const now = Date.now();
results.books.push({
user_id: user.id,
id: dummyHash,
+5 -2
View File
@@ -3,8 +3,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
// Helper method to wait for a middleware to execute before continuing
// And to throw an error when an error happens in a middleware
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export const runMiddleware = (req: NextApiRequest, res: NextApiResponse, fn: Function) => {
export const runMiddleware = (
req: NextApiRequest,
res: NextApiResponse,
fn: (req: NextApiRequest, res: NextApiResponse, cb: (result: unknown) => void) => void,
) => {
return new Promise((resolve, reject) => {
fn(req, res, (result: unknown) => {
if (result instanceof Error) {
+2 -2
View File
@@ -17,8 +17,8 @@ const createReaderWindow = (appService: AppService, url: string) => {
center: true,
resizable: true,
title: appService.isMacOSApp ? '' : 'Readest',
decorations: appService.isMacOSApp ? true : false,
transparent: appService.isMacOSApp ? false : true,
decorations: !!appService.isMacOSApp,
transparent: !appService.isMacOSApp,
shadow: appService.isMacOSApp ? undefined : true,
titleBarStyle: appService.isMacOSApp ? 'overlay' : undefined,
});
+2 -6
View File
@@ -207,9 +207,7 @@ export class ParagraphIterator {
this.#index = i;
return block;
}
} catch {
continue;
}
} catch {}
}
return this.first();
}
@@ -232,9 +230,7 @@ export class ParagraphIterator {
this.#index = i;
return block;
}
} catch {
continue;
}
} catch {}
}
try {
@@ -36,5 +36,3 @@ workerContext.onmessage = async (event: MessageEvent<TxtConverterWorkerRequest>)
workerContext.postMessage(response);
}
};
export {};
+1 -1
View File
@@ -43,7 +43,7 @@ export default defineConfig({
},
test: {
include: ['src/**/*.browser.test.ts'],
onConsoleLog(log, type) {
onConsoleLog(_log, type) {
if (type === 'stdout') return false;
},
browser: {
+1 -4
View File
@@ -14,11 +14,8 @@
},
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.9.1",
"@biomejs/biome": "^2.4.9",
"@sindresorhus/tsconfig": "^6.0.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.1.6",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
+95 -2137
View File
File diff suppressed because it is too large Load Diff