chore: switch code formatter from Prettier to Biome (#4223)

Replace Prettier with Biome for formatting JS/TS/JSX/CSS/JSON. The CI
format check drops from ~23s to ~0.4s.

- Unify config into a single root biome.json (formatter + linter); the
  former apps/readest-app/biome.json was linter-only
- Mirror the old .prettierrc.json style: 100 line width, 2-space indent,
  LF, single quotes, trailing commas
- Enable the CSS tailwindDirectives parser for @apply in globals.css
- Convert // prettier-ignore comments to // biome-ignore format:
- Root scripts and lint-staged now run biome; apps/readest-app lint runs
  `biome lint` (lint-only) so formatting stays a separate CI step
- Drop prettier + prettier-plugin-tailwindcss dependencies

Markdown/YAML are no longer format-checked (Biome does not format them)
and Tailwind class sorting is no longer enforced.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-19 14:13:36 +08:00
committed by GitHub
parent 05da6bdf43
commit fe41c42ec5
19 changed files with 199 additions and 243 deletions
-40
View File
@@ -1,40 +0,0 @@
# Dependencies
node_modules
pnpm-lock.yaml
# Build Artifacts (Web & Rust)
.next
.open-next
.build
.tauri
out
build
dist
target
fastlane
.wrangler
# Autogenerated Tauri files
gen
**/autogenerated
**/schemas
# Submodules (External Repos)
packages
# Claude Code Skills & Config
apps/readest-app/.claude
# Vendored Assets (Generated/External Code)
apps/readest-app/public/*.js
apps/readest-app/public/vendor
apps/readest-app/src-tauri/plugins/tauri-plugin-turso
apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade
# Environment & Editor
.github
.env
.env.*
.vscode
.idea
*.log
-10
View File
@@ -1,10 +0,0 @@
{
"trailingComma": "all",
"printWidth": 100,
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"jsxSingleQuote": true,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}
+1 -1
View File
@@ -2,7 +2,7 @@
"recommendations": [
"ms-vscode.vscode-typescript-next",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"biomejs.biome",
"rust-lang.rust-analyzer"
]
}
+7 -6
View File
@@ -13,19 +13,20 @@
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"prettier.requireConfig": true,
"prettier.documentSelectors": ["**/*.{js,jsx,ts,tsx,css,json,md,html,yml}"]
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ Recommended Visual Studio Code plugins for development:
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
- VS Code ESLint extension (dbaeumer.vscode-eslint)
- Prettier - Code formatter (esbenp.prettier-vscode)
- Biome - Code formatter and linter (biomejs.biome)
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
### When you're done
+2 -2
View File
@@ -23,8 +23,8 @@ pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # Biome (linter) + tsgo (type check)
pnpm format # Prettier (runs from monorepo root)
pnpm format:check # Check formatting without writing
pnpm format # Biome formatter (runs from monorepo root)
pnpm format:check # Check formatting without writing (Biome)
# Rust
pnpm fmt:check # Check formatting Rust code (src-tauri)
+1 -1
View File
@@ -17,7 +17,7 @@
"dev-android": "tauri android build -t aarch64 -- --features devtools && adb install -r src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk",
"dev-ios": "tauri ios build -- --features devtools && ideviceinstaller -i src-tauri/gen/apple/build/arm64/Readest.ipa",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "tsgo --noEmit && biome check . && pnpm lint:lua",
"lint": "tsgo --noEmit && biome lint . && pnpm lint:lua",
"lint:lua": "node ../readest.koplugin/scripts/lint-koplugin.mjs",
"test:lua": "node ../readest.koplugin/scripts/test-koplugin.mjs",
"test": "dotenv -e .env -e .env.test.local -- vitest",
@@ -166,8 +166,9 @@ describe('publishSettingsIfChanged', () => {
});
test('publishes dictionarySettings.providerOrder when markExplicitProviderOrderPublish was called', async () => {
const { markExplicitProviderOrderPublish } =
await import('@/services/sync/replicaSettingsSync');
const { markExplicitProviderOrderPublish } = await import(
'@/services/sync/replicaSettingsSync'
);
markExplicitProviderOrderPublish();
await publishSettingsIfChanged(
makeSettings({
@@ -184,8 +185,9 @@ describe('publishSettingsIfChanged', () => {
});
test('explicit-publish opt-in is consumed after one publish (no carryover)', async () => {
const { markExplicitProviderOrderPublish } =
await import('@/services/sync/replicaSettingsSync');
const { markExplicitProviderOrderPublish } = await import(
'@/services/sync/replicaSettingsSync'
);
markExplicitProviderOrderPublish();
const settings1 = makeSettings({
dictionarySettings: {
@@ -352,8 +352,9 @@ describe('provider registry disabled handling', () => {
});
it('isTranslatorAvailable returns false for disabled providers', async () => {
const { getTranslator, isTranslatorAvailable } =
await import('@/services/translators/providers');
const { getTranslator, isTranslatorAvailable } = await import(
'@/services/translators/providers'
);
const yandex = getTranslator('yandex')!;
expect(isTranslatorAvailable(yandex, true)).toBe(false);
expect(isTranslatorAvailable(yandex, false)).toBe(false);
@@ -373,8 +374,9 @@ describe('provider registry disabled handling', () => {
});
it('getTranslatorDisplayLabel returns the plain label for healthy providers', async () => {
const { getTranslator, getTranslatorDisplayLabel } =
await import('@/services/translators/providers');
const { getTranslator, getTranslatorDisplayLabel } = await import(
'@/services/translators/providers'
);
const google = getTranslator('google')!;
expect(getTranslatorDisplayLabel(google, true, (s) => s)).toBe('Google Translate');
});
+100 -63
View File
@@ -76,23 +76,38 @@ describe('createChapterRegexps — Chinese (zh) regex matching', () => {
});
describe('第N[卷节回讲篇封本册部话] variants', () => {
it.each(['卷', '节', '回', '讲', '篇', '封', '本', '册', '部', '话'])(
'should match 第一%s',
(suffix) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n第一${suffix}\n`)).toBe(true);
},
);
it.each([
'',
'节',
'回',
'讲',
'篇',
'封',
'本',
'册',
'部',
'话',
])('should match 第一%s', (suffix) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n第一${suffix}\n`)).toBe(true);
});
});
describe('Chinese preface keywords', () => {
it.each(['楔子', '前言', '简介', '引言', '序言', '序章', '总论', '概论', '后记'])(
'should match %s',
(keyword) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n${keyword}\n`)).toBe(true);
},
);
it.each([
'楔子',
'前言',
'简介',
'引言',
'序言',
'序章',
'总论',
'概论',
'后记',
])('should match %s', (keyword) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n${keyword}\n`)).toBe(true);
});
it('should match 前言 with title', () => {
const regex = getFirstRegex('zh');
@@ -106,13 +121,15 @@ describe('createChapterRegexps — Chinese (zh) regex matching', () => {
});
describe('"chapter" keyword in Chinese context', () => {
it.each(['Chapter 1', 'CHAPTER 10', 'chapter.5', 'chapter 2: The Beginning'])(
'should match "%s" (case-insensitive)',
(heading) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n${heading}\n`)).toBe(true);
},
);
it.each([
'Chapter 1',
'CHAPTER 10',
'chapter.5',
'chapter 2: The Beginning',
])('should match "%s" (case-insensitive)', (heading) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n${heading}\n`)).toBe(true);
});
});
describe('番外 (bonus) prefix variants', () => {
@@ -167,13 +184,16 @@ describe('createChapterRegexps — Chinese (zh) second regex', () => {
// ---------------------------------------------------------------------------
describe('createChapterRegexps — English (en) regex matching', () => {
describe('Chapter keyword', () => {
it.each(['Chapter 1', 'Chapter 12', 'Chapter 999', 'CHAPTER 5', 'chapter 3'])(
'should match "%s"',
(heading) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${heading}\n`)).toBe(true);
},
);
it.each([
'Chapter 1',
'Chapter 12',
'Chapter 999',
'CHAPTER 5',
'chapter 3',
])('should match "%s"', (heading) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${heading}\n`)).toBe(true);
});
it('should match "Chapter 1: Title" with title', () => {
const regex = getFirstRegex('en');
@@ -187,21 +207,30 @@ describe('createChapterRegexps — English (en) regex matching', () => {
});
describe('Roman numeral chapters', () => {
it.each(['V', 'X', 'L', 'C', 'D', 'M'])(
'should match single Roman numeral "Chapter %s"',
(numeral) => {
const regex = getFirstRegex('en');
expect(regex.test(`\nChapter ${numeral}\n`)).toBe(true);
},
);
it.each([
'V',
'X',
'L',
'C',
'D',
'M',
])('should match single Roman numeral "Chapter %s"', (numeral) => {
const regex = getFirstRegex('en');
expect(regex.test(`\nChapter ${numeral}\n`)).toBe(true);
});
it.each(['II', 'III', 'IV', 'VII', 'XII', 'XIV', 'XLII'])(
'should match multi-char Roman numeral "Chapter %s"',
(numeral) => {
const regex = getFirstRegex('en');
expect(regex.test(`\nChapter ${numeral}\n`)).toBe(true);
},
);
it.each([
'II',
'III',
'IV',
'VII',
'XII',
'XIV',
'XLII',
])('should match multi-char Roman numeral "Chapter %s"', (numeral) => {
const regex = getFirstRegex('en');
expect(regex.test(`\nChapter ${numeral}\n`)).toBe(true);
});
it('should not match single "I" as Roman numeral (not in allowed set)', () => {
const regex = getFirstRegex('en');
@@ -223,31 +252,39 @@ describe('createChapterRegexps — English (en) regex matching', () => {
expect(regex.test(`\n${keyword} 1\n`)).toBe(true);
});
it.each(['Part', 'Section', 'Book', 'Volume', 'Act'])(
'should match "%s 3: A New Beginning" with title',
(keyword) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${keyword} 3: A New Beginning\n`)).toBe(true);
},
);
it.each([
'Part',
'Section',
'Book',
'Volume',
'Act',
])('should match "%s 3: A New Beginning" with title', (keyword) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${keyword} 3: A New Beginning\n`)).toBe(true);
});
});
describe('preface keywords', () => {
it.each(['Prologue', 'Epilogue', 'Introduction', 'Foreword', 'Preface', 'Afterword'])(
'should match "%s"',
(keyword) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${keyword}\n`)).toBe(true);
},
);
it.each([
'Prologue',
'Epilogue',
'Introduction',
'Foreword',
'Preface',
'Afterword',
])('should match "%s"', (keyword) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${keyword}\n`)).toBe(true);
});
it.each(['PROLOGUE', 'prologue', 'Prologue'])(
'should match "%s" case-insensitively',
(keyword) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${keyword}\n`)).toBe(true);
},
);
it.each([
'PROLOGUE',
'prologue',
'Prologue',
])('should match "%s" case-insensitively', (keyword) => {
const regex = getFirstRegex('en');
expect(regex.test(`\n${keyword}\n`)).toBe(true);
});
it('should match "Prologue: The Beginning"', () => {
const regex = getFirstRegex('en');
@@ -33,7 +33,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
@@ -424,12 +424,7 @@ export const getCacheStats = async (
};
export const pruneCache = async (
options: {
maxAge?: number;
maxEntries?: number;
maxSizeInBytes?: number;
dryRun?: boolean;
} = {},
options: { maxAge?: number; maxEntries?: number; maxSizeInBytes?: number; dryRun?: boolean } = {},
): Promise<number> => {
const { maxAge, maxEntries, maxSizeInBytes, dryRun = false } = options;
+1 -2
View File
@@ -340,8 +340,7 @@ export interface ViewSettingsConfig {
}
export interface ViewSettings
extends
BookLayout,
extends BookLayout,
BookStyle,
BookFont,
BookLanguage,
+1 -1
View File
@@ -13,7 +13,7 @@ import type { SelectedFile } from '@/hooks/useFileSelector';
export type AppPlatform = 'web' | 'tauri' | 'node';
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
// prettier-ignore
// biome-ignore format: keep the union members compact on a single line
export type BaseDir = | 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Images' | 'Dictionaries' | 'Log' | 'Cache' | 'Temp' | 'None';
export type DeleteAction = 'cloud' | 'local' | 'both';
export type SelectDirectoryMode = 'read' | 'write';
+1 -1
View File
@@ -112,7 +112,7 @@ export const flattenContributors = (
: formatLanguageMap(contributors?.name);
};
// prettier-ignore
// biome-ignore format: keep the language codes compact on a single line
const LASTNAME_AUTHOR_SORT_LANGS = [ 'ar', 'bo', 'de', 'en', 'es', 'fr', 'hi', 'it', 'nl', 'pl', 'pt', 'ru', 'th', 'tr', 'uk' ];
const formatAuthorName = (name: string, lastNameFirst: boolean) => {
+1 -1
View File
@@ -3,7 +3,7 @@
*/
// ISO 639-1 language codes (2-letter codes)
// prettier-ignore
// biome-ignore format: keep the ISO codes grouped by first letter
const ISO_639_1_CODES = new Set([
'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az',
'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs',
+64 -15
View File
@@ -1,25 +1,68 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": true },
"formatter": { "enabled": false },
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!**/node_modules/**",
"!**/packages/**",
"!**/.claude/**",
"!**/gen/**",
"!**/autogenerated/**",
"!**/schemas/**"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf",
"includes": [
"**",
"!**/.next/**",
"!**/.open-next/**",
"!**/.build/**",
"!**/.tauri/**",
"!**/.wrangler/**",
"!**/.vercel/**",
"!**/out/**",
"!**/build/**",
"!**/dist/**",
"!**/target/**",
"!**/fastlane/**",
"!apps/readest-app/public/*.js",
"!apps/readest-app/public/vendor/**",
"!apps/readest-app/src-tauri/plugins/tauri-plugin-turso/**",
"!apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade/**",
"!**/*.mjs",
"!**/*.cjs",
"!**/*.mts",
"!pnpm-lock.yaml"
]
},
"assist": { "enabled": false },
"css": { "linter": { "enabled": false } },
"css": {
"parser": { "tailwindDirectives": true },
"linter": { "enabled": false },
"formatter": { "enabled": true, "quoteStyle": "single" }
},
"linter": {
"enabled": true,
"includes": [
"**",
"!.next/**",
"!.open-next/**",
"!.wrangler/**",
"!.claude/**",
"!dist/**",
"!out/**",
"!build/**",
"!public/**",
"!src-tauri/**",
"!next-env.d.ts",
"!i18next-scanner.config.cjs"
"!apps/readest-app/.next/**",
"!apps/readest-app/.open-next/**",
"!apps/readest-app/.wrangler/**",
"!apps/readest-app/.claude/**",
"!apps/readest-app/dist/**",
"!apps/readest-app/out/**",
"!apps/readest-app/build/**",
"!apps/readest-app/public/**",
"!apps/readest-app/src-tauri/**",
"!apps/readest-app/next-env.d.ts",
"!apps/readest-app/i18next-scanner.config.cjs"
],
"rules": {
"recommended": true,
@@ -120,6 +163,12 @@
}
},
"javascript": {
"globals": ["React"]
"globals": ["React"],
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}
+4 -6
View File
@@ -12,8 +12,8 @@
"prepare": "husky",
"fmt:check": "pnpm --filter @readest/readest-app fmt:check",
"clippy:check": "pnpm --filter @readest/readest-app clippy:check",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,json,md,html,yml}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,css,json,md,html,yml}\""
"format": "biome format --write .",
"format:check": "biome format ."
},
"packageManager": "pnpm@11.1.1",
"devDependencies": {
@@ -21,13 +21,11 @@
"@sindresorhus/tsconfig": "^6.0.0",
"husky": "^9.1.6",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"typescript": "^5"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,css,json,md,html,yml}": [
"prettier --write"
"**/*.{js,jsx,ts,tsx,css,json}": [
"biome format --write --no-errors-on-unmatched"
]
}
}
-78
View File
@@ -52,12 +52,6 @@ importers:
lint-staged:
specifier: ^16.2.7
version: 16.4.0
prettier:
specifier: ^3.3.3
version: 3.8.3
prettier-plugin-tailwindcss:
specifier: ^0.6.8
version: 0.6.14(prettier@3.8.3)
typescript:
specifier: ^5
version: 5.9.3
@@ -7072,72 +7066,6 @@ packages:
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prettier-plugin-tailwindcss@0.6.14:
resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==}
engines: {node: '>=14.21.3'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-hermes': '*'
'@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-import-sort: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-style-order: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-hermes':
optional: true
'@prettier/plugin-oxc':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-import-sort:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-style-order:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
pretty-bytes@6.1.1:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0}
@@ -16113,12 +16041,6 @@ snapshots:
preact@10.29.1: {}
prettier-plugin-tailwindcss@0.6.14(prettier@3.8.3):
dependencies:
prettier: 3.8.3
prettier@3.8.3: {}
pretty-bytes@6.1.1: {}
pretty-format@27.5.1: