@yunzai-ng/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/http/client.d.ts.map +1 -1
  2. package/dist/http/client.js +39 -3
  3. package/dist/http/client.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/platform/paths.d.ts +10 -0
  9. package/dist/platform/paths.d.ts.map +1 -1
  10. package/dist/platform/paths.js +50 -7
  11. package/dist/platform/paths.js.map +1 -1
  12. package/dist/plugin/context.d.ts +3 -0
  13. package/dist/plugin/context.d.ts.map +1 -1
  14. package/dist/plugin/context.js +18 -2
  15. package/dist/plugin/context.js.map +1 -1
  16. package/dist/plugin/market.d.ts +119 -8
  17. package/dist/plugin/market.d.ts.map +1 -1
  18. package/dist/plugin/market.js +183 -22
  19. package/dist/plugin/market.js.map +1 -1
  20. package/dist/plugin/pm.d.ts +82 -0
  21. package/dist/plugin/pm.d.ts.map +1 -0
  22. package/dist/plugin/pm.js +129 -0
  23. package/dist/plugin/pm.js.map +1 -0
  24. package/dist/server/api.d.ts.map +1 -1
  25. package/dist/server/api.js +54 -5
  26. package/dist/server/api.js.map +1 -1
  27. package/dist/server/browse.d.ts.map +1 -1
  28. package/dist/server/browse.js +10 -2
  29. package/dist/server/browse.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/http/client.test.ts +75 -1
  32. package/src/http/client.ts +40 -3
  33. package/src/index.ts +1 -0
  34. package/src/platform/paths.test.ts +166 -0
  35. package/src/platform/paths.ts +52 -7
  36. package/src/plugin/context.test.ts +29 -1
  37. package/src/plugin/context.ts +18 -2
  38. package/src/plugin/market.test.ts +375 -2
  39. package/src/plugin/market.ts +278 -25
  40. package/src/plugin/pm.test.ts +73 -0
  41. package/src/plugin/pm.ts +163 -0
  42. package/src/server/api.test.ts +72 -2
  43. package/src/server/api.ts +65 -5
  44. package/src/server/browse.test.ts +18 -10
  45. package/src/server/browse.ts +11 -2
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 模块职责:在一个插件目录里执行包管理器 —— 装依赖与跑 npm script
3
+ * 依赖方向:只依赖 node 内置模块;不认识市场、不认识插件宿主
4
+ * 生命周期:无状态
5
+ * 注意事项:**脚本名是要拼进命令行的,故必须先过白名单。** Windows 上 node 拒绝不经 shell
6
+ * 执行 `.cmd`(CVE-2024-27980 之后的行为),而 `pnpm` / `npm` 在 Windows 上正是
7
+ * `.cmd`,因此这里只能带 `shell` 执行;一旦带了 shell,参数就是被解释的字符串,
8
+ * 索引里一个 `build && curl …` 就是任意命令执行。目录路径不进命令行(走 `cwd`),
9
+ * 于是唯一的可变部分就是脚本名 —— 它由 {@link assertScriptName} 挡住。
10
+ *
11
+ * **装依赖要不要带 devDependencies 由调用方决定,本模块不猜。** 从源码装的插件
12
+ * 要跑 `build`,而编译器(typescript)在 devDependencies 里 —— 那时 `--prod`
13
+ * 装出来的目录跑 `build` 必然失败,报的还是「找不到 tsc」这种离原因很远的错。
14
+ */
15
+ import { execFile } from "node:child_process"
16
+
17
+ /**
18
+ * 包管理器候选,按优先级
19
+ *
20
+ * pnpm 在前:本项目各插件的 lock 文件都是 pnpm 的,装出来的布局与插件作者测过的一致。
21
+ * npm 兜底,随 node 一起装。不认 yarn —— berry 缺省不建 `node_modules`(PnP 模式),
22
+ * 那时 `import()` 插件入口会失败,而失败原因与包管理器的关系很难看出来。
23
+ */
24
+ export const PACKAGE_MANAGERS: readonly string[] = ["pnpm", "npm"]
25
+
26
+ /**
27
+ * 装依赖的超时毫秒
28
+ *
29
+ * 比任何网络请求都长:国内网络下一次冷装依赖十分钟并不罕见,而超时的后果是留下一个
30
+ * 装了一半的 `node_modules` —— 那比等下去更难收拾。
31
+ */
32
+ export const INSTALL_TIMEOUT_MS = 15 * 60 * 1000
33
+
34
+ /**
35
+ * 跑一个 script 的超时毫秒
36
+ *
37
+ * 与装依赖同一量级,且理由相同:`build` 只要几秒,但装后脚本可能在下载一个
38
+ * 上百兆的运行时(renderer-puppeteer 的 `install:browser` 拉的是 Chromium)。
39
+ */
40
+ export const SCRIPT_TIMEOUT_MS = 15 * 60 * 1000
41
+
42
+ /**
43
+ * 合法的 npm script 名
44
+ *
45
+ * 字母或数字开头,其余可含字母、数字、冒号、点、下划线与连字符 —— 冒号是为了
46
+ * `install:browser` 这类分段名。**刻意不含空格、引号与任何 shell 元字符**:
47
+ * 理由见文件头,这一条正则是那道边界本身。
48
+ */
49
+ const SCRIPT_NAME_RE = /^[a-z\d][a-z\d:._-]*$/i
50
+
51
+ /**
52
+ * 一个 script 名是否合法
53
+ *
54
+ * 与 {@link assertScriptName} 同一判据,两种形态:解析索引时要的是「不合法就丢弃这一条」,
55
+ * 而真要执行前要的是「不合法就抛」。共用一条正则,免得日后只改一处。
56
+ * @param name 待判定的名称
57
+ * @returns 是否合法
58
+ */
59
+ export function isScriptName(name: string): boolean {
60
+ return SCRIPT_NAME_RE.test(name)
61
+ }
62
+
63
+ /**
64
+ * 校验一个 script 名
65
+ *
66
+ * 执行前的最后一道闸:即便索引解析时已筛过,`runPm` 也可能被别处直接调用。
67
+ * @param name 待校验的名称
68
+ * @returns 名称本身
69
+ * @throws 名称为空或含白名单之外的字符时
70
+ */
71
+ export function assertScriptName(name: string): string {
72
+ if (!isScriptName(name)) throw new Error(`script 名不合法:${name}`)
73
+ return name
74
+ }
75
+
76
+ /** 一次包管理器动作 */
77
+ export type PmTask =
78
+ | {
79
+ /** 装依赖 */
80
+ readonly kind: "install"
81
+ /** 是否连 devDependencies 一起装 */
82
+ readonly dev: boolean
83
+ }
84
+ | {
85
+ /** 跑一个 script */
86
+ readonly kind: "run"
87
+ /** script 名,须已过 {@link assertScriptName} */
88
+ readonly script: string
89
+ }
90
+
91
+ /**
92
+ * 执行一次包管理器动作的函数形态
93
+ *
94
+ * 抽成类型是为了让测试替换它。这条路的正确性几乎全在**下发了什么** —— 要不要带
95
+ * devDependencies、跑的是哪个 script、失败之后还继不继续 —— 而真实的包管理器
96
+ * 只能告诉你「最后目录里有 node_modules」,上述任一条错掉同样可能得到一个看着
97
+ * 对的目录,直到某个插件在加载时报出一条与原因无关的错。故留这道缝。
98
+ * @param task 动作
99
+ * @param cwd 插件目录
100
+ * @param timeout 超时毫秒
101
+ * @returns 实际用的是哪个包管理器
102
+ */
103
+ export type PmRunner = (task: PmTask, cwd: string, timeout: number) => Promise<string>
104
+
105
+ /**
106
+ * 把一次动作译成某个包管理器的实参
107
+ *
108
+ * `--prod` / `--omit=dev` 两家的写法不同,故按包管理器分别给。
109
+ * @param pm 包管理器
110
+ * @param task 动作
111
+ * @returns 实参
112
+ */
113
+ function argsOf(pm: string, task: PmTask): string[] {
114
+ if (task.kind === "run") return ["run", task.script]
115
+ if (task.dev) return ["install"]
116
+ return pm === "pnpm" ? ["install", "--prod"] : ["install", "--omit=dev"]
117
+ }
118
+
119
+ /**
120
+ * 依次尝试各包管理器,`MarketDeps.pm` 的缺省实现
121
+ *
122
+ * 不加 `--ignore-scripts`:插件可能依赖原生模块(sqlite、sharp),install 脚本正是它们
123
+ * 编译或下载预编译产物的地方,禁掉会装出一份 `import` 即报错的 `node_modules`,而报错
124
+ * 指向缺少 `.node` 文件,离「我禁了脚本」很远。既然已明说要装,就该装成能用的。
125
+ *
126
+ * 两家都失败时把各自的原因都带上:只报最后一个(npm 的)会让「机器上压根没有 pnpm」
127
+ * 与「pnpm 装到一半失败」看起来一样。
128
+ * @param task 动作
129
+ * @param cwd 插件目录
130
+ * @param timeout 超时毫秒
131
+ * @returns 实际用的是哪个包管理器
132
+ * @throws 全部候选都不可用或都失败时
133
+ */
134
+ export const runPm: PmRunner = async (task, cwd, timeout) => {
135
+ if (task.kind === "run") assertScriptName(task.script)
136
+ const errors: string[] = []
137
+ for (const pm of PACKAGE_MANAGERS) {
138
+ try {
139
+ await new Promise<void>((resolve, reject) => {
140
+ execFile(
141
+ pm,
142
+ argsOf(pm, task),
143
+ {
144
+ cwd,
145
+ timeout,
146
+ env: { ...process.env },
147
+ windowsHide: true,
148
+ // 理由见文件头:Windows 上 pnpm/npm 是 .cmd,不经 shell 起不来
149
+ shell: process.platform === "win32"
150
+ },
151
+ (err, _stdout, stderr) => {
152
+ if (err) reject(new Error(stderr.trim() || err.message))
153
+ else resolve()
154
+ }
155
+ )
156
+ })
157
+ return pm
158
+ } catch (err) {
159
+ errors.push(`${pm}:${err instanceof Error ? err.message : String(err)}`)
160
+ }
161
+ }
162
+ throw new Error(`包管理器均不可用或执行失败 —— ${errors.join(";")}`)
163
+ }
@@ -123,6 +123,8 @@ interface Spies {
123
123
  install: ReturnType<typeof vi.fn>
124
124
  /** 更新 */
125
125
  update: ReturnType<typeof vi.fn>
126
+ /** 单独重跑装依赖与装后步骤 */
127
+ setup: ReturnType<typeof vi.fn>
126
128
  /** 卸载 */
127
129
  remove: ReturnType<typeof vi.fn>
128
130
  }
@@ -262,6 +264,15 @@ describe("面板 API", () => {
262
264
  version: "2.0.0",
263
265
  needsDependencies: false
264
266
  })),
267
+ setup: vi.fn(async (name: string) => ({
268
+ name,
269
+ dir: join(dir, "plugins", name),
270
+ version: "1.0.0",
271
+ needsDependencies: false,
272
+ installedDeps: true,
273
+ packageManager: "pnpm",
274
+ ranScripts: ["build"]
275
+ })),
265
276
  remove: vi.fn(async (name: string) => name === "demo")
266
277
  },
267
278
  accounts: {
@@ -721,12 +732,71 @@ describe("面板 API", () => {
721
732
  it("安装后默认立即加载,并把本次加载的插件名带回来", async () => {
722
733
  const res = await call("POST", "market/install", { name: "fresh" })
723
734
  expect(res.statusCode).toBe(200)
724
- expect(spies.market.install).toHaveBeenCalledWith("fresh")
735
+ expect(spies.market.install).toHaveBeenCalledWith("fresh", { dependencies: true })
725
736
  expect(spies.plugins.loadAll).toHaveBeenCalled()
726
737
  expect(res.json().loaded).toEqual(["fresh"])
727
738
  expect(res.json().version).toBe("1.0.0")
728
739
  })
729
740
 
741
+ /*
742
+ * 缺省装依赖,这一条只能由用例固定
743
+ *
744
+ * 缺省若翻回 false,表现是「装完却跑不起来」重新成为常态,而那条「请自行执行
745
+ * pnpm install」的提示对着的是一个多数人不会去开的终端 —— 从接口的返回值上
746
+ * 看不出缺省变过,故此处钉住。
747
+ */
748
+ it("装依赖缺省为真,显式传 false 才不装", async () => {
749
+ await call("POST", "market/install", { name: "fresh", dependencies: false })
750
+ expect(spies.market.install).toHaveBeenLastCalledWith("fresh", { dependencies: false })
751
+
752
+ await call("POST", "market/demo/update", { dependencies: false })
753
+ expect(spies.market.update).toHaveBeenLastCalledWith("demo", { dependencies: false })
754
+ })
755
+
756
+ /*
757
+ * 装后步骤失败时不许加载
758
+ *
759
+ * `build` 挂了就没有 `dist/`,此时加载只会再报一条「找不到模块」—— 两条错误里
760
+ * 后一条更显眼而更没用,使用者会去查模块解析,而真正的原因在上一条里。
761
+ */
762
+ it("装后步骤失败时不加载,原因原样带回", async () => {
763
+ spies.market.install.mockResolvedValueOnce({
764
+ name: "fresh",
765
+ dir: join(dir, "plugins", "fresh"),
766
+ via: "git",
767
+ version: "1.0.0",
768
+ needsDependencies: false,
769
+ installedDeps: true,
770
+ packageManager: "pnpm",
771
+ ranScripts: [],
772
+ setupError: "build:tsc 退出码 2"
773
+ })
774
+ const res = await call("POST", "market/install", { name: "fresh" })
775
+ expect(res.statusCode).toBe(200)
776
+ expect(spies.plugins.loadAll).not.toHaveBeenCalled()
777
+ expect(res.json().loaded).toEqual([])
778
+ expect(String(res.json().setupError)).toContain("tsc 退出码 2")
779
+ })
780
+
781
+ it("单独重跑装依赖与编译:先卸载再跑,跑完才加载", async () => {
782
+ const res = await call("POST", "market/demo/setup", {})
783
+ expect(res.statusCode).toBe(200)
784
+ expect(spies.market.setup).toHaveBeenCalledWith("demo")
785
+ // 顺序即正确性:build 会覆盖 dist,旧模块还在内存里就会响应刚被覆盖掉的代码
786
+ expect(spies.plugins.unload.mock.invocationCallOrder[0]).toBeLessThan(
787
+ spies.market.setup.mock.invocationCallOrder[0] ?? 0
788
+ )
789
+ expect(res.json().ranScripts).toEqual(["build"])
790
+ expect(res.json().loaded).toEqual(["fresh"])
791
+ })
792
+
793
+ it("重跑装依赖失败时回 400", async () => {
794
+ spies.market.setup.mockRejectedValueOnce(new Error("插件目录 无名 不存在"))
795
+ const res = await call("POST", "market/无名/setup", {})
796
+ expect(res.statusCode).toBe(400)
797
+ expect(String(res.json().error)).toContain("不存在")
798
+ })
799
+
730
800
  it("load 为 false 时只落盘不加载", async () => {
731
801
  const res = await call("POST", "market/install", { name: "fresh", load: false })
732
802
  expect(res.statusCode).toBe(200)
@@ -747,7 +817,7 @@ describe("面板 API", () => {
747
817
  const res = await call("POST", "market/demo/update", {})
748
818
  expect(res.statusCode).toBe(200)
749
819
  expect(spies.plugins.unload).toHaveBeenCalledWith("demo")
750
- expect(spies.market.update).toHaveBeenCalledWith("demo")
820
+ expect(spies.market.update).toHaveBeenCalledWith("demo", { dependencies: true })
751
821
  expect(res.json().unloaded).toBe(true)
752
822
  expect(res.json().version).toBe("2.0.0")
753
823
  })
package/src/server/api.ts CHANGED
@@ -554,20 +554,32 @@ export function createApiRoutes(deps: ApiDeps): ApiSurface {
554
554
  * @param name 插件名
555
555
  * @param load 安装后是否立即加载
556
556
  * @param replace 目标已存在时是否覆盖
557
+ * @param dependencies 是否顺带装依赖并跑索引声明的装后步骤
557
558
  * @returns 安装结果,附本次加载成功的插件名
558
559
  * @throws 安装失败时以 400 结束请求
559
560
  */
560
561
  const installFromMarket = async (
561
562
  name: string,
562
563
  load: boolean,
563
- replace: boolean
564
+ replace: boolean,
565
+ dependencies: boolean
564
566
  ): Promise<Record<string, unknown>> => {
565
567
  const instance = market()
566
568
  let unloaded = false
567
569
  try {
568
570
  if (replace && deps.plugins.get(name) !== undefined) unloaded = await deps.plugins.unload(name)
569
- const result = replace ? await instance.update(name) : await instance.install(name)
570
- const loaded = load ? [...(await deps.plugins.loadAll()).loaded] : []
571
+ const result = replace
572
+ ? await instance.update(name, { dependencies })
573
+ : await instance.install(name, { dependencies })
574
+ /*
575
+ * 缺依赖或装后步骤失败时不加载
576
+ *
577
+ * 两者都注定让加载失败,而报出来的错离原因很远:缺依赖报的是「找不到某个包」,
578
+ * 缺产物(`build` 挂了)报的是「找不到 dist/index.js」。两条错误里后一条更
579
+ * 显眼而更没用 —— 使用者会去查那个文件为什么不在,而真正该看的是上一条。
580
+ */
581
+ const skip = result.setupError !== undefined || result.dependencyError !== undefined || result.needsDependencies
582
+ const loaded = load && !skip ? [...(await deps.plugins.loadAll()).loaded] : []
571
583
  return { ...result, unloaded, loaded }
572
584
  } catch (err) {
573
585
  throw fail(400, err instanceof Error ? err.message : String(err))
@@ -580,18 +592,66 @@ export function createApiRoutes(deps: ApiDeps): ApiSurface {
580
592
 
581
593
  add("POST", "market/refresh", () => market().list(true))
582
594
 
595
+ /*
596
+ * 装依赖缺省为真
597
+ *
598
+ * 与面板商店那侧一致。缺省关掉会让「装完却跑不起来」成为常态,而那条提示(「请到
599
+ * 目录内自行执行 pnpm install」)对着的是一个多数人不会去开的终端。请求方仍可显式
600
+ * 传 false —— 离线部署、或依赖已随镜像预置好时用得上。
601
+ */
583
602
  add("POST", "market/install", async req => {
584
603
  requireWritable()
585
604
  const body = objectOf(req.body)
586
605
  const name = requireString(body, "name")
587
- return installFromMarket(name, optionalBoolean(body, "load") ?? true, false)
606
+ return installFromMarket(
607
+ name,
608
+ optionalBoolean(body, "load") ?? true,
609
+ false,
610
+ optionalBoolean(body, "dependencies") ?? true
611
+ )
588
612
  })
589
613
 
590
614
  add("POST", "market/:name/update", async req => {
591
615
  requireWritable()
592
616
  const name = req.params.name ?? ""
593
617
  const body = req.body === undefined ? {} : objectOf(req.body)
594
- return installFromMarket(name, optionalBoolean(body, "load") ?? true, true)
618
+ return installFromMarket(
619
+ name,
620
+ optionalBoolean(body, "load") ?? true,
621
+ true,
622
+ optionalBoolean(body, "dependencies") ?? true
623
+ )
624
+ })
625
+
626
+ /*
627
+ * 单独重跑装依赖与装后步骤,不重新取源
628
+ *
629
+ * 三种情形要用到:手工放进插件目录的插件(没有安装动作可挂)、装的时候这一步失败过、
630
+ * 以及使用者自己 `git pull` 过而产物已旧。与安装那条路共用 `PluginMarket.setup()`,
631
+ * 故「装什么、跑什么」只有一处定义。
632
+ */
633
+ add("POST", "market/:name/setup", async req => {
634
+ requireWritable()
635
+ const name = req.params.name ?? ""
636
+ const body = req.body === undefined ? {} : objectOf(req.body)
637
+ const load = optionalBoolean(body, "load") ?? true
638
+ /*
639
+ * 先卸载,再跑 —— 次序有意义
640
+ *
641
+ * `build` 会覆盖 `dist/`,而旧模块此刻还在内存里、它注册的命令仍在响应。跑完再卸载
642
+ * 意味着中间有一段时间里「磁盘上是新代码、正在响应的是旧代码」,那种不一致比一次
643
+ * 失败的重载难查得多。
644
+ */
645
+ const unloaded = deps.plugins.get(name) === undefined ? false : await deps.plugins.unload(name)
646
+ try {
647
+ const result = await market().setup(name)
648
+ // 判据与 installFromMarket 一致:缺依赖或缺产物时加载注定失败,见那里的注释
649
+ const skip = result.setupError !== undefined || result.needsDependencies
650
+ const loaded = load && !skip ? [...(await deps.plugins.loadAll()).loaded] : []
651
+ return { ...result, unloaded, loaded }
652
+ } catch (err) {
653
+ throw fail(400, err instanceof Error ? err.message : String(err))
654
+ }
595
655
  })
596
656
 
597
657
  add("DELETE", "market/:name", async req => {
@@ -123,16 +123,24 @@ describe("目录浏览", () => {
123
123
  }
124
124
  })
125
125
 
126
- it("超过 1000 项时截断并标明", async () => {
127
- await Promise.all(
128
- Array.from({ length: 1005 }, (_, i) => writeFile(join(dir, `f${String(i).padStart(4, "0")}.txt`), "1"))
129
- )
130
- const out = await listing(dir)
131
- expect(out.entries.length).toBe(1000)
132
- expect(out.truncated).toBe(true)
133
- // 先排序再截断,故截到的必是排序后的前 1000 项
134
- expect(out.entries[0]?.name).toBe("f0000.txt")
135
- })
126
+ // 单独给 30 秒而非用 5 秒的缺省值:这一条要建 1005 个真实文件才能触到上限,
127
+ // 而 MAX_ENTRIES 是模块常量、无从在测试里调小。本机上光是写文件就要 0.8~1.3 秒
128
+ // 且波动六成,CI runner 的磁盘更慢 —— ubuntu 上曾耗时 9.5 秒而超时。
129
+ // 试过把写入分批(64、16 个一组)反而更慢,故仍一次性并发。
130
+ it(
131
+ "超过 1000 项时截断并标明",
132
+ async () => {
133
+ await Promise.all(
134
+ Array.from({ length: 1005 }, (_, i) => writeFile(join(dir, `f${String(i).padStart(4, "0")}.txt`), "1"))
135
+ )
136
+ const out = await listing(dir)
137
+ expect(out.entries.length).toBe(1000)
138
+ expect(out.truncated).toBe(true)
139
+ // 先排序再截断,故截到的必是排序后的前 1000 项
140
+ expect(out.entries[0]?.name).toBe("f0000.txt")
141
+ },
142
+ 30_000
143
+ )
136
144
 
137
145
  it("未超上限时 truncated 为假", async () => {
138
146
  await writeFile(join(dir, "one.txt"), "1")
@@ -156,7 +156,16 @@ async function isDirEntry(dir: string, entry: Dirent, windows: boolean): Promise
156
156
  }
157
157
 
158
158
  /**
159
- * 排序:目录在前,同类按名称(`localeCompare` + `numeric`,故 `f2` 在 `f10` 之前)
159
+ * 排序用的比较器
160
+ *
161
+ * 提到模块级而非在比较函数里写 `localeCompare(name, "zh-Hans-CN", { numeric: true })`:
162
+ * 带 options 的 `localeCompare` 每次调用都新建一个 collator,而排序会调用它上万次
163
+ * (1000 项约一万次比较)。实测同一批 1005 个名字,逐次新建 21~28 ms,复用则不足 1 ms。
164
+ */
165
+ const COLLATOR = new Intl.Collator("zh-Hans-CN", { numeric: true })
166
+
167
+ /**
168
+ * 排序:目录在前,同类按名称(`numeric` 故 `f2` 在 `f10` 之前)
160
169
  *
161
170
  * **中文名排在英文名之前不是缺陷** —— CLDR 的中文规则把汉字整块提到拉丁字母之前。
162
171
  * 要改成「英文在前」得在此处显式加一层「是否以 ASCII 起头」的比较键,而不是换 locale。
@@ -166,7 +175,7 @@ async function isDirEntry(dir: string, entry: Dirent, windows: boolean): Promise
166
175
  */
167
176
  function compareEntries(a: BrowseEntry, b: BrowseEntry): number {
168
177
  if (a.dir !== b.dir) return a.dir ? -1 : 1
169
- return a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true })
178
+ return COLLATOR.compare(a.name, b.name)
170
179
  }
171
180
 
172
181
  /**