hui

@hui/eslint-config (0.4.0)

Published 2026-10-09 15:58:46 +08:00 by lihui

Installation

@hui:registry=https://git.huili.dev/api/packages/hui/npm/
npm install @hui/eslint-config@0.4.0
"@hui/eslint-config": "0.4.0"

About this package

echo 与 halcyon 共用的 eslint flat config 工厂

@hui/eslint-config

echo 与 halcyon 两个 monorepo 共用的 eslint flat config。

对外只有一个东西:工厂函数 defineEslintConfig(options),同步返回一份 flat config 数组。

pnpm add -D @hui/eslint-config eslint typescript

消费者的 devDependencies 里就这三项。eslint 和 typescript 是本包的 peerDependencies, 两条都得由消费者自己装:

  • eslint 不能由本包自带,否则消费者进程里会出现两个 eslint 实例(理由见下面一节)。
  • typescript 是 typescript-eslint 跑起来的前提——它靠 TS 自己的编译器去解析 .ts。 这条依赖以前没写出来,能跑通是 pnpm 的 auto-install-peers 在背后补的;换个包管理器 或者把那个开关关掉,就会炸在「找不到 typescript」上。所以现在明写。

插件(@eslint/js、@stylistic/eslint-plugin、eslint-plugin-react-hooks、globals、 typescript-eslint、eslint-plugin-astro)不在这个名单里:它们全在这个包自己的 dependencies 里,装本包时一起下来, 不需要也不应该再装一遍。

这个包不在公网 npm 上

它发布在自托管的 Gitea 包仓库 https://git.huili.dev/api/packages/hui/npm/, 公网 registry.npmjs.org 上没有这个包。

装它

消费者项目的 .npmrc 里要加一行 scope 映射,@hui 开头的包才知道去哪儿取:

@hui:registry=https://git.huili.dev/api/packages/hui/npm/

这一行不是秘密,应该提交进仓库——它只说明「去哪儿取」,不含任何凭证。少了它, 上面那句 pnpm add -D @hui/eslint-config 会去公网找,然后 404。

hui 组织是公开的,装包不需要令牌。只有发布才要令牌,放在 ~/.npmrc,绝不提交:

//git.huili.dev/api/packages/hui/npm/:_authToken=<token>

令牌在 Gitea 的 Settings → Applications 里生成,要带 write:package 权限。

发它

npm publish

不用带 --registry:目标已经由 package.json 里的 publishConfig.registry 钉死。

这个字段防的不是「少敲一段参数」。npm publish 不带参数时的默认目标是公网 registry.npmjs.org。只要公网上能往 @hui 下发包,在这个目录里手滑敲一次 裸的 npm publish 就不会失败,而会成功:包被发到公网上,任何人都能看到,而且那个版本号 在 npm 上再也不能重用。要防的是「忘了加参数会成功」,不是「忘了加参数会失败」。

用法

前端 app(对应 echo 的 apps/web、apps/admin)

// apps/web/eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'

export default defineEslintConfig({
  react: true,
  globals: ['browser', 'node'],
  // 这些是项目专有的产物目录,包的默认 ignores 里只有 `**/dist/`
  ignores: ['build/', '.react-router/', '.wrangler/', 'worker-configuration.d.ts'],
})

Astro app(对应 halcyon 的 apps/web)

// 仓库根 eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'

export default [
  ...defineEslintConfig({
    files: ['apps/web/**/*.{ts,astro}'],
    astro: true,
    globals: ['browser', 'node'],
  }),
  ...defineEslintConfig({
    files: ['apps/admin/**/*.{ts,tsx}'],
    react: true,
    globals: ['browser', 'node'],
  }),
]

astro: true 检查模板、frontmatter 和 <script> 中的 TypeScript。只选 **/*.astro 也会检查其中的客户端脚本;消费者不必额外安装解析器。Astro 的解析器和推荐规则只作用于 范围内的 Astro 文件及其虚拟脚本,不会套到普通 .ts / .tsx 或其他 app 上。

模板格式仍由人工维护;这里提供代码规则与 Astro 推荐检查,不是整份文件的排版工具。

不依赖任何运行环境的包(对应 echo 的 packages/monitoring)

// packages/monitoring/eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'

export default defineEslintConfig()

省略 globals 就是一套环境全局都不注入。这不是图省事:一个既不依赖浏览器也不依赖 node 的包, 用到任何环境全局都说明那段代码不该住在这里(与 tsconfig 里写 types: [] 是同一条判据)。

node 脚本,外加一条本地规则(对应 echo 的 scripts)

// scripts/eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'

export default defineEslintConfig({
  globals: ['node'],
  rules: {
    // interface / 类型字面量的成员分隔:跨行不加分隔符,单行内用分号且末尾不留
    '@stylistic/member-delimiter-style': ['error', {
      multiline: { delimiter: 'none' },
      singleline: { delimiter: 'semi', requireLast: false },
    }],
  },
})

选项

选项 类型 默认 含义
files string[] 无 限定检查范围,用于「一个根配置管多个 app」。原有 JS/TS/React 规则块保留 覆盖 语义;新增 Astro 层使用嵌套 files 数组与插件内部范围求交集,并自动包含客户端虚拟脚本。请带上扩展名,写 'apps/web/**/*.{ts,tsx}' 而不是 'apps/web/**'
ignores string[] [] 追加到默认 ignores 之上,不是替换
astro boolean false 带上 Astro 推荐检查及 TypeScript 解析支持
react boolean false 带上 eslint-plugin-react-hooks 的 flat recommended
globals ('browser' | 'node')[] [] 注入哪几套环境全局名。省略 = 一个都不注入
rules object {} 额外规则,合并在共享规则之后

默认 ignores 只有 ['**/dist/'] 一条。**/ 前缀不是保险而是必需:flat config 的忽略模式按 「相对配置文件的路径」匹配,光写 dist/ 只挡得住顶层那一个。漏掉的表现不是「多 lint 了几个文件」, 而是 eslint 去 lint 一份 tsc 生成的 .d.ts,然后报「Extra semicolon」——一个不可能修的错误 (那个文件不是手写的,改了下次构建就没了)。

行宽与方法间空行

  • @stylistic/max-len:代码和注释每行最多 100 字符,不自动忽略 URL、字符串、模板字符串 或正则。超长代码手动拆行;确实不能拆分的内容逐处使用局部禁用注释,说明原因。
  • hui/lines-between-methods:相邻类成员只要有一个是方法,就至少隔一行空行。 构造方法、get / set、普通方法、箭头函数或普通函数形式的字段都算方法, TypeScript 的类型断言不改变这一判断。两个普通数据字段可以连续排列。 空行可由 eslint --fix 自动补齐,不减少已有空行,并保留行尾注释和方法说明注释的位置。
export class Store {
  active = false
  generation = 0

  getSnapshot = () => this.active

  subscribe = (listener) => listener()
}

// eslint-disable-next-line @stylistic/max-len -- 外部服务要求使用完整 URL,无法拆分。
export const url = 'https://example.com/externally-issued/immutable-link/019b16d4-6a4b-7e01-82f3-61793317d69b'

消费者可通过已有的 rules 选项覆盖这两条规则,不需要额外安装插件。

为什么现在能抽这个包了

echo 的 eslint.shared.js 里留着两条「当时试过、决定不抽」的理由。它们都成立,这个包是照着它们设计的。

一、插件版本会在根与各 app 之间漂开

当时的方案是把装配抽到仓库根的一个文件里。根 node_modules 里解析不到任何 eslint 插件, 所以得先把插件加进根 package.json——于是插件在根和各 app 各有一份、两组版本范围, 而 eslint . 是从 app 目录跑的。两边漂开的表现是某条规则悄悄换了行为,比它消除的重复更糟。

这个包换的是位置,不是手法:插件不再「根和 app 各一份」,而是全部收进包自己的 dependencies。 消费者那边只剩 eslint、typescript 和这个包三项,插件没有第二处版本范围,也就没有可漂的东西。 升级插件变成升级这一个包的一件事,而且升完有快照 diff 兜着(见下面的测试一节)。

eslint 本身是 peerDependency 而不是 dependency,这条不能反过来:由包自带的话, 消费者进程里会出现两个 eslint 实例——插件注册在其中一个上、规则从另一个上查找,症状是 「规则名不认识」或者更糟的「规则静静地不生效」。

二、装配顺序写错是静默的

原文的判据是「漂了有没有人喊」:装配写错时插件没注册、规则名不认识,eslint . 直接起不来, 属于会喊的那一类,所以判定不用收。这条判据没错,但它衡量的前提是「重复留在原地」。

把顺序钉进工厂之后,问题的形状变了:消费者根本拿不到拼错的机会。不是从「会喊」改判成 「不会喊」,而是把那一格从棋盘上拿掉了。

而且「起不来」只覆盖了拼错的一半。另一半是顺序错但语法全对,那种是真的静默:

  • ts.configs.recommended 必须在 js.configs.recommended 之后。它内部有一段 eslint-recommended,专门把「TS 编译器自己就管得了」的核心规则关掉(no-undef 之类)。 放反了这些规则会被重新打开,然后在每个 .ts 文件上误报一堆——而这错法只表现为 「误报变多」,很容易被当成规则本身太严。
  • 本包的规则块必须是最后一个。共享规则里有几条是故意推翻上面推荐集的(关掉 no-undef、 把 @typescript-eslint/no-unused-vars 换成带下划线豁免的版本)。往前挪一格,这几条就被 推荐集盖回去,同样没有任何报错,只表现为「明明配了下划线豁免却还是报未使用变量」。

顺序固定为:全局 ignores → js.configs.recommended → ts.configs.recommended → (react 为真时)eslint-plugin-react-hooks →(astro 为真时)Astro 配置 → 本包的规则块。

测试

三层,各自防的是不同的东西:

  1. 规则表快照。对每种选项组合算出完整配置,只留「被打开」的规则和 languageOptions.globals 的键名,存成快照。它的价值不在于验证你想到的那几条,而在于你没想到的那些: typescript-eslint 升个 minor 悄悄多塞一条规则、globals 换个大版本悄悄改了 browser 名单, 快照 diff 会当场把它摆到眼前。
  2. 冒烟测试。快照只证明「规则表长这样」,不证明「规则真的作用在文件上」——files 的 glob 写错、ignores 多挡一层,规则表完全正常但一个文件都没被检查。所以每种组合配一个故意违规的 小文件,断言预期的规则 id 真的报出来。
  3. 打包安装验证(pnpm test:pack)。前两层跑在本包自己的 node_modules 里,验不到 「别人装了这个包能不能用」。这一层 pnpm pack 打出 tarball,在临时目录里搭一个只装 两条 peer(eslint、typescript)和这个 tarball 的最小项目,真跑一次 npx eslint。 dependencies 少写一个插件、把 eslint 放错位置,只有在这里才会露头。

包自己的 eslint.config.js 也用自己的工厂函数——pnpm lint 是最直接的一层自测。

这一版刻意不做的事

  • import 排序(eslint-plugin-import 系列)。echo 的 apps/api 原先有一套,删掉的直接原因是 它的 peerDependencies 只到 eslint ^9,一个子项目能把整个仓库钉在旧版本上。要做也得换 eslint-plugin-import-x,那是另一件事。
  • jsx-a11y。没有任何一个现有子项目在用,加进来等于凭空给所有消费者立一套没人要求过的规矩。
  • 类型检查层(ts.configs.recommendedTypeChecked)。它要求 parserOptions.projectService 和 tsconfigRootDir,那是消费者的 tsconfig 布局,工厂替它猜只会猜错。echo 里只有 apps/api 用,而 apps/api 至今是空壳。等它长出代码、需求清楚了再加一个选项。

版本口径

  • 0.4.0 把包内插件命名空间从 lihui 改为 hui,规则名随之变为 hui/lines-between-methods。 消费者若在自己的配置里按旧名 lihui/lines-between-methods 覆盖或禁用该规则,需要改成新名。

  • 0.3.0 新增默认 100 字符行宽与方法间空行约束。已有 ^0.2.0 消费者不会自动升级到 0.3.0;升级依赖时再处理项目现有代码的检查结果。本版不改变依赖或 peer 范围。

  • eslint-plugin-astro 固定为 1.7.0,保持 halcyon 已使用的插件版本,并兼容本包现有 Node.js 下限与 ESLint 10。0.2.0 不同时升级 Astro 插件大版本,也不新增 JSX 无障碍规则。

  • engines.node 是 >=22.13.0,不是 >=24:halcyon 允许 22.13,而 echo 的 .npmrc 开了 engine-strict=true,写死 24 会让 halcyon 直接装不上。

  • globals 钉在 ^16.5.0 而不是 17.x:echo 现在用的就是 16.5。迁移那一步要求「行为不变」, 换大版本会让 browser / node 的全局名单悄悄变。等迁移落地了再单独升,那时快照 diff 会 把变化摆出来。

  • peerDependencies.typescript 是 >=5.9.0 <6.1.0。下界这么定是因为两个消费者都得覆盖到: echo 用 ^5.9.3、halcyon 用 6.0.3。上界 <6.1.0 是抄自 typescript-eslint 当前的 peer(>=4.8.4 <6.1.0)——确实是重复的一份,但不抄的代价更大。

    不写上界,等于告诉消费者「typescript 7 也行」。而 typescript 的最新版已经是 7.x:照着 一份没有上界的声明装,装到的就是 7,然后 eslint 一个文件都解析不了。而这不是一个装不上的 错误——pnpm 默认不开 strict-peer-dependencies,违反 peer 只换来一行 unmet peer typescript@">=4.8.4 <6.1.0": found 7.0.2 的警告,安装照样成功。少写上界, 交付给消费者的是一个装得上、跑不了的状态,唯一的信号是那行没人当回事的警告。

    所以这是一处刻意接受的重复:typescript-eslint 哪天放宽上界,这里得跟着放宽——两处 是绑在一起的,改一处就得看另一处。test/peer-range.test.js 盯着这件事:它在运行时读出 typescript-eslint 实际声明的 peer 范围,和这里的上界比对,漂了就当场失败。

Dependencies

Dependencies

ID Version
@eslint/js ^10.0.1
@stylistic/eslint-plugin ^5.10.0
eslint-plugin-astro 1.7.0
eslint-plugin-react-hooks ^7.1.1
globals ^16.5.0
typescript-eslint ^8.66.0

Development Dependencies

ID Version
eslint ^10.8.0
typescript ^5.9.3
vitest ^3.2.4

Peer Dependencies

ID Version
eslint ^10
typescript >=5.9.0 <6.1.0
Details
npm
2026-10-09 15:58:46 +08:00
15
MIT
latest
13 KiB
Assets (1)
Versions (2) View all
0.4.0 2026-10-09
0.3.0 2026-10-09