@chatcode/cco-market 1.45.1

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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
@@ -0,0 +1,1321 @@
1
+ /**
2
+ * Response shapes of the /dsh-market/* host routes plus the pure helpers the
3
+ * Market UI shares between its section and toast components.
4
+ */
5
+
6
+ import type { DiagnosticReportV1 } from '../diagnostics.ts'
7
+ import { findCatalogEntryForLocal } from '../catalog-local-match.ts'
8
+ export type { SharedHostPackageDependencyFinding } from '../diagnostics.ts'
9
+
10
+ /** Localized text keyed by language ('zh' / 'en'). */
11
+ export type LocalizedText = Record<string, string | undefined>
12
+
13
+ /** One registry entry from /dsh-market/registry. */
14
+ /**
15
+ * Resolve a market API path against the page the UI is served from.
16
+ *
17
+ * Every call used to be root-absolute (`/dsh-market/…`), which the browser
18
+ * resolves against the ORIGIN — so behind a reverse proxy that mounts ChatCode CLI
19
+ * under a prefix (`https://host/app/my-dsh/`), the panel rendered and then
20
+ * every request in it went to `https://host/dsh-market/…`, missed the prefix
21
+ * rule entirely, and 404'd (#345).
22
+ *
23
+ * Anchored on `document.baseURI`, which is the directory the host serves its
24
+ * UI from. Safe for root deployments because that directory is `/` there, and
25
+ * safe generally because the ChatCode CLI Web UI does not use path routing — measured
26
+ * against a real dsh: `location.pathname` is `/` on the market page, not
27
+ * `/settings/...`, so the directory really is the mount point rather than
28
+ * wherever the user happens to have navigated.
29
+ */
30
+ export function api(path: string): string {
31
+ const relative = path.replace(/^\/+/, '')
32
+ if (typeof document === 'undefined') return `/${relative}`
33
+ const url = new URL(relative, document.baseURI)
34
+ return `${url.pathname}${url.search}`
35
+ }
36
+
37
+ export interface RegistryPlugin {
38
+ name: string
39
+ owner: string
40
+ url: string
41
+ npm?: string | null
42
+ tarball?: string | null
43
+ /** One legacy category id or several category ids. */
44
+ category: string | string[]
45
+ description?: LocalizedText
46
+ stars?: number
47
+ /**
48
+ * npm downloads in the last 30 days, when the entry has a published
49
+ * package. Absent means "no npm package" — a coverage gap, not a zero.
50
+ */
51
+ downloads?: number | null
52
+ added?: string
53
+ install?: string
54
+ /**
55
+ * Catalog-side deprecation flags (#60): absent for every normal entry, so
56
+ * catalogs without the fields render exactly as before.
57
+ */
58
+ deprecated?: boolean
59
+ /** Catalog name of the suggested replacement plugin, when deprecated. */
60
+ replacement?: string
61
+ /** Author-curated screenshot URLs from the registry (#61); optional. */
62
+ screenshots?: string[]
63
+ }
64
+
65
+ /**
66
+ * Present a registry install command with the public ChatCode CLI launcher.
67
+ * The catalog still publishes legacy `dsh plugin ...` metadata and remains
68
+ * valid input; only the displayed command is normalized.
69
+ */
70
+ export function chatCodeInstallCommand(command: string): string {
71
+ return command.replace(/^(?:dsh|cco|chatcode-cli)(?=\s+plugin\b)/u, 'chatcode-cli')
72
+ }
73
+
74
+ /** Category ids for one entry, de-duplicated in declaration order. */
75
+ export function pluginCategories(plugin: Pick<RegistryPlugin, 'category'>): string[] {
76
+ const values: unknown[] = Array.isArray(plugin.category) ? plugin.category : [plugin.category]
77
+ const categories: string[] = []
78
+ const seen = new Set<string>()
79
+ for (const value of values) {
80
+ if (typeof value !== 'string' || value === '' || seen.has(value)) continue
81
+ seen.add(value)
82
+ categories.push(value)
83
+ }
84
+ return categories
85
+ }
86
+
87
+ /** The catalog payload under `registry` in /dsh-market/registry. */
88
+ export interface Registry {
89
+ /** Catalog generation marker; unchanged payload generations share caches. */
90
+ updated: string
91
+ count: number
92
+ categories: Record<string, LocalizedText>
93
+ plugins: RegistryPlugin[]
94
+ }
95
+
96
+ /** Discovery-time verdict derived from a package's public npm manifest. */
97
+ export interface HostCompatibility {
98
+ status: 'compatible' | 'incompatible' | 'unknown'
99
+ basis: 'manifest' | 'undeclared' | 'unavailable'
100
+ requirement: string | null
101
+ declarations: Array<{ kind: 'engine' | 'peer'; package?: string; range: string }>
102
+ }
103
+
104
+ /** npm package name -> discovery-time host verdict. */
105
+ export type HostCompatibilityMap = Record<string, HostCompatibility>
106
+
107
+ /** Profile dependency map: package name → install spec. */
108
+ export type InstalledMap = Record<string, string>
109
+
110
+ /**
111
+ * Add active profile Bundles as presence-only catalog entries.
112
+ *
113
+ * The returned map is for catalog matching only. Update and uninstall flows
114
+ * must keep using the dependency-only map because a Bundle supplied by the
115
+ * dsh installation is not owned by the profile package manager.
116
+ */
117
+ export function installedForCatalog(installed: InstalledMap, bundles: readonly string[]): InstalledMap {
118
+ return Object.fromEntries([
119
+ ...bundles.map(name => [name, '*'] as const),
120
+ ...Object.entries(installed),
121
+ ])
122
+ }
123
+
124
+ /** Strong repo identities discovered for local link:/file: dependencies (#141). */
125
+ export type InstalledRepoIdentities = Record<string, string[]>
126
+
127
+ /** Weak Git-origin hints used only to disambiguate multiple same-named entries. */
128
+ export type InstalledRepoHints = Record<string, string[]>
129
+
130
+ /** Response of the /dsh-market/gist export action. */
131
+ export interface GistExportResult {
132
+ ok: boolean
133
+ gistId: string
134
+ gistUrl: string
135
+ }
136
+
137
+ /** Per-package update status from /dsh-market/updates. */
138
+ export interface UpdateStatus {
139
+ updateAvailable?: boolean
140
+ version?: string
141
+ kind?: string
142
+ /** What is installed and what the source of truth offers — versions for npm
143
+ packages, commit shas for github installs; the notes dialog (#294) shows
144
+ the range between them in whichever form reads best. */
145
+ current?: string | null
146
+ latest?: string | null
147
+ /** Updating this local package switches it to its matched online release. */
148
+ restoreRequired?: boolean
149
+ /** Explicit source migration; never part of update-all. */
150
+ sourceMigration?: { kind: 'git-to-npm'; repo: string; target: string }
151
+ }
152
+
153
+ /** Poll payload from /dsh-market/status. */
154
+ export interface MarketStatus {
155
+ /** The market's own version — rendered in the heading so screenshots carry it. */
156
+ version?: string
157
+ /** Whether the profile package manager owns the market dependency. */
158
+ selfManaged?: boolean
159
+ /** Legacy single prefix for browser bundles predating githubRoutes. */
160
+ githubProxy?: string | null
161
+ /** Ordered per-service routes; null means the canonical GitHub URL. */
162
+ githubRoutes?: {
163
+ raw?: Array<string | null>
164
+ avatar?: Array<string | null>
165
+ }
166
+ /** Saved UI escape route, if any. */
167
+ githubProxyCustom?: string | null
168
+ /** True when DSHM_GITHUB_PROXY owns the effective route. */
169
+ githubProxyManaged?: boolean
170
+ active?: boolean
171
+ lastLine?: string
172
+ seconds?: number
173
+ installed?: InstalledMap
174
+ pnpm?: boolean
175
+ boot?: string
176
+ /** pnpm ndjson stage, when the structured reporter produced events. */
177
+ phase?: 'resolving' | 'downloading' | 'linking' | 'building' | null
178
+ done?: number
179
+ total?: number | null
180
+ currentPackage?: string | null
181
+ downloaded?: number | null
182
+ size?: number | null
183
+ /** True once the user asked to cancel and the host is killing the run. */
184
+ cancelling?: boolean
185
+ /**
186
+ * The route-level operation lock (#91): stays true through install
187
+ * post-processing after pnpm already exited (progress.active false).
188
+ * Restart must not be offered while it is held.
189
+ */
190
+ busy?: boolean
191
+ /**
192
+ * The process supervisor the host detected around itself (systemd, pm2),
193
+ * or null/absent when none. Present so the UI can explain WHY the restart
194
+ * button is missing instead of just omitting it (#229).
195
+ */
196
+ supervisor?: string | null
197
+ /**
198
+ * Debugger latch (#447): `'inspector'` when the host is under a debugger,
199
+ * or null/absent otherwise. Kept separate from `supervisor` and from
200
+ * `restart` so `allowRestart` settings are not conflated with debug state.
201
+ */
202
+ debugger?: string | null
203
+ }
204
+
205
+ /** Post-install activation state (P0-2), per installed package. */
206
+ export type ActivationState = 'live' | 'restart' | 'inert' | 'broken' | 'missing' | 'disabled'
207
+
208
+ export interface ActivationInfo {
209
+ state: ActivationState
210
+ reasons: string[]
211
+ bundle: boolean
212
+ hot: boolean
213
+ }
214
+
215
+ /** The /dsh-market/installed payload (fields the market UI consumes). */
216
+ export interface InstalledPayload {
217
+ profile?: string
218
+ installed: InstalledMap
219
+ /** Strong source identities for local link:/file: dependencies (#141). */
220
+ repoIdentities?: InstalledRepoIdentities
221
+ /** Weak local Git-origin hints; never used to reject a unique match. */
222
+ repoHints?: InstalledRepoHints
223
+ activation?: Record<string, ActivationInfo>
224
+ diagnostics?: DiagnosticReportV1
225
+ live?: string[]
226
+ /** Plugins the user switched off; persisted across restarts (#60). */
227
+ disabled?: string[]
228
+ /**
229
+ * Packages whose bundle rows the user patch layer (cordis.patch.yml)
230
+ * disables / force-enables (port of dsh-plugin-hub). Covers toggles made
231
+ * OUTSIDE the market — hand-edited patch files, the dsh CLI — which the
232
+ * market's own disable list never sees.
233
+ */
234
+ patchDisabled?: string[]
235
+ patchForced?: string[]
236
+ /** Custom plugin groups: group name → member package names. */
237
+ groups?: Record<string, string[]>
238
+ /** Display order of group names. */
239
+ groupOrder?: string[]
240
+ /** Catalog entry URLs bookmarked for later install (#414). */
241
+ favorites?: string[]
242
+ }
243
+
244
+ /**
245
+ * A group's derived switch state: all members enabled / all disabled /
246
+ * mixed / no members. Pure — the UI renders exactly this and the group
247
+ * switch itself is never persisted (#60).
248
+ */
249
+ export type GroupSwitchState = 'on' | 'off' | 'mixed' | 'empty'
250
+
251
+ export function groupSwitchState(members: string[] | undefined, disabled: ReadonlySet<string>): GroupSwitchState {
252
+ const list = members ?? []
253
+ if (list.length === 0) return 'empty'
254
+ let anyOn = false
255
+ let anyOff = false
256
+ for (const member of list) {
257
+ if (disabled.has(member)) anyOff = true
258
+ else anyOn = true
259
+ }
260
+ return anyOn && anyOff ? 'mixed' : anyOff ? 'off' : 'on'
261
+ }
262
+
263
+ /** Registered theme definition surfaced by the theme service snapshot. */
264
+ export interface ThemeDef {
265
+ id: string
266
+ colorScheme?: string
267
+ tokens?: Record<string, string | undefined>
268
+ }
269
+
270
+ /** Theme service snapshot; null when the composition has no theme service. */
271
+ export interface ThemeSnapshot {
272
+ preference: string
273
+ themes: ThemeDef[]
274
+ }
275
+
276
+ /** Bound locale translator for the dsh-market namespace. */
277
+ export type Translate = (key: string) => string
278
+
279
+ export function avatarColor(name: string): string {
280
+ let hash = 0
281
+ for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0
282
+ return 'hsl(' + (((hash % 360) + 360) % 360) + ' 55% 52%)'
283
+ }
284
+
285
+ export function readSession(key: string): any {
286
+ try { return JSON.parse(sessionStorage.getItem(key) || 'null') } catch { return null }
287
+ }
288
+
289
+ /** Heuristic: plugins that target a terminal surface rather than the web UI. */
290
+ export function looksTerminal(plugin: RegistryPlugin, lang: string): boolean {
291
+ const desc = (plugin.description && (plugin.description[lang] || plugin.description.en)) || ''
292
+ // A description can mention a CLI only to say it is NOT required. Treating
293
+ // that as positive evidence labels web plugins as terminal-only. Strip
294
+ // bounded negated clauses before applying the deliberately broad heuristic;
295
+ // the package name remains untouched and therefore stays strong evidence.
296
+ const positiveDesc = desc
297
+ .replace(/\b(?:no|without)\b[^.!?;:,。!?;\n]{0,80}\b(?:tui|cli|tty|terminal)\b/gi, '')
298
+ .replace(/(?:无需|无须|不需要|不用)[^。!?;\n]{0,48}(?:tui|cli|tty|terminal|终端|命令行)/gi, '')
299
+ return /\b(tui|cli|tty|terminal)\b|终端|命令行/i.test(plugin.name + ' ' + positiveDesc)
300
+ }
301
+
302
+ /** Sortable field for the Discover list. */
303
+ export type SortField = 'downloads' | 'stars' | 'added'
304
+ /** Sort direction: desc = newest/most first, asc = oldest/least first. */
305
+ export type SortDir = 'desc' | 'asc'
306
+ /** Combined sort key sent to visiblePlugins. */
307
+ export type SortKey = `${SortField}-${SortDir}`
308
+
309
+ /** Recency windows for the "published within" filter. */
310
+ export type TimeRange = 'all' | 'day' | 'week' | 'month' | 'quarter' | 'year'
311
+
312
+ /** Days per TimeRange (`all` has no cutoff and is handled by the caller). */
313
+ export const TIME_RANGE_DAYS: Record<Exclude<TimeRange, 'all'>, number> = {
314
+ day: 1,
315
+ week: 7,
316
+ month: 30,
317
+ quarter: 90,
318
+ year: 365,
319
+ }
320
+
321
+ /** True when `added` is a date within the last `days` days (inclusive). */
322
+ export function withinDays(added: string | undefined, days: number): boolean {
323
+ if (added === undefined || added === '') return false
324
+ const time = Date.parse(added)
325
+ if (Number.isNaN(time)) return false
326
+ const age = Date.now() - time
327
+ return age >= 0 && age <= days * 86_400_000
328
+ }
329
+
330
+ /** Filters and sort order driving the discover list. */
331
+ export interface ListQuery {
332
+ /** Active category id, or 'all'. */
333
+ category: string
334
+ /** Raw search input (trimmed and lowercased internally). */
335
+ query: string
336
+ /** UI language for description matching ('zh' / 'en'). */
337
+ lang: string
338
+ /** Category labels indexed by id; omitted by callers that do not need label search. */
339
+ categories?: Record<string, LocalizedText>
340
+ /** 'stars-desc' | 'stars-asc' | 'added-desc' | 'added-asc'; anything else keeps registry order. */
341
+ sort: string
342
+ /** Keep only plugins published within the last N days; undefined = any time. */
343
+ sinceDays?: number
344
+ /** Optional discovery metadata, keyed by the entry's npm package name. */
345
+ hostCompatibility?: HostCompatibilityMap
346
+ /** Hide only entries that are known to be incompatible; unknown stays visible. */
347
+ compatibleWithHost?: boolean
348
+ }
349
+
350
+ /**
351
+ * Whether a catalog entry IS the market itself. The catalog still carries
352
+ * it — nothing about the data changes, and the Installed tab still shows it
353
+ * — this is purely "a store has no reason to sell itself to someone already
354
+ * standing in it."
355
+ */
356
+ export function isMarketItself(plugin: Pick<RegistryPlugin, 'name' | 'npm'>): boolean {
357
+ return plugin.name === 'dsh-market' || plugin.npm === 'dshmarket' || plugin.npm === '@chatcode/cco-market'
358
+ }
359
+
360
+ /**
361
+ * A single pass that inserts a separator after the character on the left of
362
+ * every Han ↔ Latin/number boundary. Punctuation is normalized separately
363
+ * below. Keeping this generic lets `MCP管理`, `管理MCP`, and `OAuth2授权`
364
+ * behave like their space-separated forms without product-specific aliases.
365
+ */
366
+ const searchScriptBoundary = /(?:\p{Script=Han}(?=[\p{Script=Latin}\p{N}])|[\p{Script=Latin}\p{N}](?=\p{Script=Han}))/gu
367
+
368
+ /** Normalize package names, human text, and adjacent mixed-script terms alike. */
369
+ function searchText(value: string): string {
370
+ return value.normalize('NFKC').toLowerCase()
371
+ .replace(searchScriptBoundary, '$& ')
372
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
373
+ .trim()
374
+ .replace(/\s+/g, ' ')
375
+ }
376
+
377
+ /**
378
+ * Normalized catalog fields are immutable for the lifetime of one registry
379
+ * entry. Keep them with that entry so typing does not repeat unicode
380
+ * normalization across the whole catalog, while replaced catalogs remain
381
+ * collectible. The raw query is intentionally not cached: it is normalized
382
+ * once per call and would otherwise grow the cache on every keystroke.
383
+ */
384
+ const pluginSearchTextCache = new WeakMap<RegistryPlugin, Map<string, string>>()
385
+
386
+ function cachedPluginSearchText(plugin: RegistryPlugin, value: string): string {
387
+ let fields = pluginSearchTextCache.get(plugin)
388
+ if (fields === undefined) {
389
+ fields = new Map()
390
+ pluginSearchTextCache.set(plugin, fields)
391
+ }
392
+ const hit = fields.get(value)
393
+ if (hit !== undefined) return hit
394
+ const normalized = searchText(value)
395
+ fields.set(value, normalized)
396
+ return normalized
397
+ }
398
+
399
+ /**
400
+ * Relevance within one field. Exact and prefix matches beat phrase matches;
401
+ * for a multi-word query every word must occur in the same field.
402
+ */
403
+ function fieldRelevance(
404
+ plugin: RegistryPlugin,
405
+ value: string | undefined,
406
+ query: string,
407
+ tokens: string[],
408
+ weight: number,
409
+ ): number {
410
+ if (!value) return 0
411
+ const text = cachedPluginSearchText(plugin, value)
412
+ if (text === '' || !tokens.every(token => text.includes(token))) return 0
413
+ if (text === query) return weight + 300
414
+ if (text.startsWith(query)) return weight + 250
415
+ if (text.includes(query)) return weight + 200
416
+ return weight + 150
417
+ }
418
+
419
+ /**
420
+ * Search ranking is field-aware rather than a popularity-only filter:
421
+ * package identities outrank owners, descriptions, and categories. The
422
+ * selected popularity/date sort remains the tie-breaker between equally
423
+ * relevant entries.
424
+ */
425
+ function pluginRelevance(
426
+ plugin: RegistryPlugin,
427
+ query: string,
428
+ tokens: string[],
429
+ lang: string,
430
+ categories: Record<string, LocalizedText> | undefined,
431
+ ): number {
432
+ const descriptions = plugin.description ?? {}
433
+ const preferredLocale = descriptions[lang] ? lang : descriptions.en ? 'en' : null
434
+ const preferredDescription = descriptions[lang] || descriptions.en
435
+ const otherDescriptions = Object.entries(descriptions)
436
+ .filter(([locale, value]) => locale !== preferredLocale && typeof value === 'string')
437
+ .map(([, value]) => value)
438
+ const categoryIds = pluginCategories(plugin)
439
+ const categoryLabels = categoryIds.flatMap(category => Object.values(categories?.[category] ?? {}))
440
+
441
+ return Math.max(
442
+ fieldRelevance(plugin, plugin.name, query, tokens, 700),
443
+ fieldRelevance(plugin, plugin.npm ?? undefined, query, tokens, 700),
444
+ fieldRelevance(plugin, plugin.owner, query, tokens, 400),
445
+ fieldRelevance(plugin, preferredDescription, query, tokens, 280),
446
+ ...otherDescriptions.map(value => fieldRelevance(plugin, value, query, tokens, 240)),
447
+ ...categoryIds.map(value => fieldRelevance(plugin, value, query, tokens, 180)),
448
+ ...categoryLabels.map(value => fieldRelevance(plugin, value, query, tokens, 180)),
449
+ )
450
+ }
451
+
452
+ /** Compare two already-filtered entries using the user's selected sort. */
453
+ function comparePlugins(a: RegistryPlugin, b: RegistryPlugin, sort: string): number {
454
+ // A github:-only entry has no npm package and therefore no download count
455
+ // at all — that is a coverage gap, not a "0 downloads" verdict, and must
456
+ // not be read as less popular than a package that genuinely has zero.
457
+ // Such entries always sort after every entry WITH a real count, in either
458
+ // direction, and are ordered against each other by star count — the only
459
+ // signal available for them — rather than left in an arbitrary tie.
460
+ const hasDownloads = (p: RegistryPlugin): p is RegistryPlugin & { downloads: number } => typeof p.downloads === 'number'
461
+ if (sort === 'downloads-desc') {
462
+ if (hasDownloads(a) && hasDownloads(b)) return b.downloads - a.downloads
463
+ if (hasDownloads(a)) return -1
464
+ if (hasDownloads(b)) return 1
465
+ return (b.stars ?? -1) - (a.stars ?? -1)
466
+ }
467
+ if (sort === 'downloads-asc') {
468
+ if (hasDownloads(a) && hasDownloads(b)) return a.downloads - b.downloads
469
+ if (hasDownloads(a)) return -1
470
+ if (hasDownloads(b)) return 1
471
+ return (a.stars ?? -1) - (b.stars ?? -1)
472
+ }
473
+ if (sort === 'stars-desc') return (b.stars ?? -1) - (a.stars ?? -1)
474
+ if (sort === 'stars-asc') return (a.stars ?? -1) - (b.stars ?? -1)
475
+ if (sort === 'added-desc') return String(b.added).localeCompare(String(a.added))
476
+ if (sort === 'added-asc') return String(a.added).localeCompare(String(b.added))
477
+ return 0
478
+ }
479
+
480
+ /**
481
+ * The discover list: category filter, then the published-within window, then
482
+ * relevance-ranked search across package identity / owner / every localized
483
+ * description / category ids and labels. With no search, only the selected
484
+ * sort applies, preserving the existing discover-list behaviour.
485
+ * Pure — the section renders exactly this.
486
+ */
487
+ export function visiblePlugins(plugins: RegistryPlugin[], options: ListQuery): RegistryPlugin[] {
488
+ const query = searchText(options.query)
489
+ const tokens = query.split(' ').filter(Boolean)
490
+ const scored = plugins.flatMap((plugin, index) => {
491
+ if (isMarketItself(plugin)) return []
492
+ const categories = pluginCategories(plugin)
493
+ if (options.category !== 'all' && !categories.includes(options.category)) return []
494
+ if (options.sinceDays !== undefined && !withinDays(plugin.added, options.sinceDays)) return []
495
+ if (options.compatibleWithHost === true && plugin.npm != null
496
+ && options.hostCompatibility?.[plugin.npm]?.status === 'incompatible') return []
497
+ const relevance = query === '' ? 0 : pluginRelevance(plugin, query, tokens, options.lang, options.categories)
498
+ return relevance === 0 && query !== '' ? [] : [{ plugin, relevance, index }]
499
+ })
500
+
501
+ return scored.sort((a, b) =>
502
+ b.relevance - a.relevance
503
+ || comparePlugins(a.plugin, b.plugin, options.sort)
504
+ || a.index - b.index,
505
+ ).map(row => row.plugin)
506
+ }
507
+
508
+ /**
509
+ * Favorites tab listing: only bookmarked catalog entries, then the usual
510
+ * search/sort window. Pure — the section renders exactly this.
511
+ */
512
+ export function pluginsForFavorites(
513
+ plugins: RegistryPlugin[],
514
+ favoriteUrls: ReadonlySet<string>,
515
+ options: Omit<ListQuery, 'category'>,
516
+ ): RegistryPlugin[] {
517
+ const subset = plugins.filter(plugin => favoriteUrls.has(plugin.url))
518
+ return visiblePlugins(subset, { ...options, category: 'all' })
519
+ }
520
+
521
+ /** Bookmarked URLs with no matching catalog entry — delisted or URL changed. */
522
+ export function staleFavoriteUrls(urls: readonly string[], plugins: RegistryPlugin[]): string[] {
523
+ if (urls.length === 0) return []
524
+ const catalog = new Set(plugins.map(plugin => plugin.url))
525
+ return urls.filter(url => !catalog.has(url))
526
+ }
527
+
528
+ /** The themes tab listing: theme category only, most-starred first. */
529
+ export function themePlugins(plugins: RegistryPlugin[]): RegistryPlugin[] {
530
+ return plugins.filter(p => pluginCategories(p).includes('theme')).sort((a, b) => (b.stars || 0) - (a.stars || 0))
531
+ }
532
+
533
+ /**
534
+ * Category chip order: collapsed with an active non-'all' chip that would
535
+ * otherwise be clipped out of the two-row preview, the active one moves to
536
+ * the front so it stays visible.
537
+ *
538
+ * Reported as "点了某个分类,标签就跑到前面来了,好奇怪": the earlier version
539
+ * moved the active chip to the front unconditionally, so clicking a category
540
+ * that was ALREADY visible inside the two rows still reshuffled it — and
541
+ * every chip after it — for no reason, since nothing was at risk of being
542
+ * hidden. `visibleCount` is how many chips (the 'all' chip included) the
543
+ * two-row clip fits; a category already within that budget in its natural
544
+ * position is left exactly where it was.
545
+ *
546
+ * `visibleCount === null` (not yet measured, e.g. the very first collapsed
547
+ * render) keeps the old unconditional behaviour: with no measurement to
548
+ * check against, guaranteeing visibility is the safe default.
549
+ */
550
+ export function orderedCategories(
551
+ categories: string[],
552
+ active: string,
553
+ open: boolean,
554
+ visibleCount: number | null = null,
555
+ ): string[] {
556
+ if (open || active === 'all') return categories
557
+ if (visibleCount !== null) {
558
+ // One slot of the budget is always the 'all' chip itself.
559
+ const budget = Math.max(0, visibleCount - 1)
560
+ const naturalIndex = categories.indexOf(active)
561
+ if (naturalIndex !== -1 && naturalIndex < budget) return categories
562
+ }
563
+ return [active, ...categories.filter(id => id !== active)]
564
+ }
565
+
566
+ /**
567
+ * Page-number list for the discover pager. With few pages it is simply
568
+ * 1..total; with many it windows around the current page —
569
+ * `1 … n-1 n n+1 … total` (at most five numbered buttons) — so a
570
+ * 400-plugin catalog stays compact instead of a long row of numbered
571
+ * buttons. Always begins with 1 and ends with total, so the first/last
572
+ * pages stay one click away even without the pager's «/» shortcuts.
573
+ */
574
+ export function pageItems(current: number, total: number): Array<number | '…'> {
575
+ if (total <= 7) {
576
+ const all: number[] = []
577
+ for (let i = 1; i <= total; i++) all.push(i)
578
+ return all
579
+ }
580
+ const start = Math.max(2, current - 1)
581
+ const end = Math.min(total - 1, current + 1)
582
+ const items: Array<number | '…'> = [1]
583
+ if (start > 2) items.push('…')
584
+ for (let i = start; i <= end; i++) items.push(i)
585
+ if (end < total - 1) items.push('…')
586
+ items.push(total)
587
+ return items
588
+ }
589
+
590
+ /**
591
+ * Unified installed-state matching (#15): both sides collapse to lowercase
592
+ * identity sets — the registry entry contributes its bare name, npm name and
593
+ * owner/repo; the dependency contributes its key and the repo inside its
594
+ * spec — and any exact intersection counts. Exact equality, not substrings,
595
+ * so prefix-related repo names cannot cross-match.
596
+ */
597
+ /**
598
+ * Memo for entryIdentities, keyed on the catalog entry object itself.
599
+ *
600
+ * Catalog entries are parsed once and never mutated, so the identity set is
601
+ * a pure function of an object that outlives every call — a WeakMap holds
602
+ * it for exactly as long as the catalog is alive and not one render longer.
603
+ * Worth caching because this is the innermost step of the installed-state
604
+ * matching that runs for every card on screen (#262).
605
+ */
606
+ const entryIdCache = new WeakMap<RegistryPlugin, Set<string>>()
607
+
608
+ function entryIdentities(plugin: RegistryPlugin): Set<string> {
609
+ const cached = entryIdCache.get(plugin)
610
+ if (cached !== undefined) return cached
611
+ const ids = new Set<string>([plugin.name.toLowerCase()])
612
+ if (plugin.npm) ids.add(plugin.npm.toLowerCase())
613
+ // Subpath-aware: a /tree/ entry identifies as repo#path:/sub, never the
614
+ // bare repo — two subpackages of one monorepo must not cross-match.
615
+ const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(plugin.url)
616
+ if (m !== null) {
617
+ ids.add(m[2] !== undefined ? `${m[1]!.toLowerCase()}#path:/${m[2].toLowerCase()}` : m[1]!.toLowerCase())
618
+ }
619
+ entryIdCache.set(plugin, ids)
620
+ return ids
621
+ }
622
+
623
+ const REPO_ID_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#path:\/[A-Za-z0-9_./-]+)?$/
624
+
625
+ function addRepoIdentities(ids: Set<string>, values: readonly string[]): void {
626
+ for (const value of values) {
627
+ if (!REPO_ID_RE.test(value)) continue
628
+ const subpath = value.split('#path:/')[1]
629
+ if (subpath !== undefined && subpath.split('/').some(seg => seg === '' || seg === '.' || seg === '..')) continue
630
+ ids.add(value.toLowerCase())
631
+ }
632
+ }
633
+
634
+ /** Repo identities carried by a github shortcut, including `#sha&path:`. */
635
+ function githubSpecRepoIds(spec: string): Set<string> {
636
+ const ids = new Set<string>()
637
+ const match = /^github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?(?:#(.*))?$/i.exec(spec)
638
+ if (match === null) return ids
639
+ const repo = match[1]!.toLowerCase()
640
+ let subpath: string | null = null
641
+ for (const selector of (match[2] ?? '').split('&')) {
642
+ if (!selector.startsWith('path:/')) continue
643
+ const candidate = selector.slice('path:/'.length)
644
+ if (!REPO_ID_RE.test(`${repo}#path:/${candidate}`)
645
+ || candidate.split('/').some(seg => seg === '' || seg === '.' || seg === '..')
646
+ || subpath !== null) return new Set()
647
+ subpath = candidate.toLowerCase()
648
+ }
649
+ ids.add(repo)
650
+ if (subpath !== null) ids.add(`${repo}#path:/${subpath}`)
651
+ return ids
652
+ }
653
+
654
+ function depIdentities(name: string, spec: string, repoIdentities: readonly string[] = []): Set<string> {
655
+ const ids = new Set<string>([name.toLowerCase()])
656
+ // A scoped npm key usually mirrors owner/repo — expose that identity so an
657
+ // npm-installed plugin still matches an entry whose npm field is unset.
658
+ const scoped = /^@([^/]+)\/(.+)$/.exec(name)
659
+ if (scoped !== null) ids.add(`${scoped[1]!.toLowerCase()}/${scoped[2]!.toLowerCase()}`)
660
+ for (const id of githubSpecRepoIds(spec)) ids.add(id)
661
+ addRepoIdentities(ids, repoIdentities)
662
+ return ids
663
+ }
664
+
665
+ /**
666
+ * Repo identities stated by the dependency SPEC itself (github: installs) —
667
+ * hard evidence of where the package came from, unlike the name-derived
668
+ * mirror in depIdentities, which is only a matching aid.
669
+ */
670
+ function depRepoIds(spec: string, repoIdentities: readonly string[] = []): Set<string> {
671
+ const ids = githubSpecRepoIds(spec)
672
+ addRepoIdentities(ids, repoIdentities)
673
+ return ids
674
+ }
675
+
676
+ /** Repo identity of a registry entry's source url (repo or repo#path form). */
677
+ function entryRepoIds(plugin: RegistryPlugin): Set<string> {
678
+ const ids = new Set<string>()
679
+ const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(plugin.url)
680
+ if (m !== null) {
681
+ ids.add(m[2] !== undefined ? `${m[1]!.toLowerCase()}#path:/${m[2].toLowerCase()}` : m[1]!.toLowerCase())
682
+ }
683
+ return ids
684
+ }
685
+
686
+ /**
687
+ * The curated registry lists distinct plugins sharing one name — twelve
688
+ * name-groups at the time of #66 (both dsh-usage-stats, four dsh-memory…).
689
+ * A name coincidence must not survive contradicting repo evidence: when the
690
+ * dependency's spec pins a github repo AND the entry states one, the repos
691
+ * decide — the loose name/npm identities only apply when at least one side
692
+ * carries no repo evidence (npm installs, non-github entries).
693
+ */
694
+ function sameSourceConflict(plugin: RegistryPlugin, spec: string, repoIdentities: readonly string[] = []): boolean {
695
+ const entry = entryRepoIds(plugin)
696
+ const dep = depRepoIds(spec, repoIdentities)
697
+ if (entry.size === 0 || dep.size === 0) return false
698
+ for (const id of dep) if (entry.has(id)) return false
699
+ return true
700
+ }
701
+
702
+ function repoHintMatches(plugin: RegistryPlugin, hints: readonly string[]): boolean {
703
+ const entry = entryRepoIds(plugin)
704
+ const values = new Set<string>()
705
+ addRepoIdentities(values, hints)
706
+ for (const id of values) if (entry.has(id)) return true
707
+ return false
708
+ }
709
+
710
+ /**
711
+ * Memo for looseMatchCount, keyed on the catalog array then the dep name.
712
+ *
713
+ * This is THE hot path behind "the plugin list is very laggy" (#262). The
714
+ * count answers "how many catalog entries could this installed dependency
715
+ * be?", which depends only on the catalog and the name — not on the card
716
+ * being drawn. But it was called from matchInstalledName, which runs once
717
+ * per installed dependency, which runs once per rendered card: a full scan
718
+ * of ~1800 entries, repeated cards × installed times, on every single
719
+ * render. A profile from the reporter put it at 2.9 seconds, 28% of the
720
+ * whole trace, and a local benchmark measured 48ms per render at 24 cards
721
+ * and 224ms at 96 against a smaller 839-entry catalog.
722
+ *
723
+ * Keyed on the array identity so a refetched catalog gets a fresh map for
724
+ * free — a new parse is a new array, and the old one is collectable.
725
+ */
726
+ const looseMatchCountCache = new WeakMap<RegistryPlugin[], Map<string, number>>()
727
+
728
+ function looseMatchCount(plugins: RegistryPlugin[], name: string): number {
729
+ let byName = looseMatchCountCache.get(plugins)
730
+ if (byName === undefined) {
731
+ byName = new Map<string, number>()
732
+ looseMatchCountCache.set(plugins, byName)
733
+ }
734
+ const hit = byName.get(name)
735
+ if (hit !== undefined) return hit
736
+ // Built once for the whole scan. looseMatches() rebuilt this identity set
737
+ // for every entry it tested, so the allocation alone ran ~1800 times per
738
+ // call before this.
739
+ const dep = depIdentities(name, '')
740
+ let count = 0
741
+ for (const plugin of plugins) {
742
+ for (const id of entryIdentities(plugin)) {
743
+ if (dep.has(id)) { count += 1; break }
744
+ }
745
+ }
746
+ byName.set(name, count)
747
+ return count
748
+ }
749
+
750
+ function looseMatches(plugin: RegistryPlugin, name: string): boolean {
751
+ const dep = depIdentities(name, '')
752
+ for (const id of entryIdentities(plugin)) if (dep.has(id)) return true
753
+ return false
754
+ }
755
+
756
+ /** The installed dependency name a registry entry corresponds to, or null. */
757
+ export function matchInstalledName(
758
+ plugin: RegistryPlugin,
759
+ installed: InstalledMap,
760
+ repoIdentities: InstalledRepoIdentities = {},
761
+ plugins?: RegistryPlugin[],
762
+ repoHints: InstalledRepoHints = {},
763
+ ): string | null {
764
+ const ids = entryIdentities(plugin)
765
+ for (const [name, spec] of Object.entries(installed)) {
766
+ const specStr = String(spec)
767
+ const repos = repoIdentities[name] ?? []
768
+ // Discover badges and theme cards share this helper. Local link:/file:
769
+ // installs must use the same strict catalog row as restore and the
770
+ // Installed tab — a coincidental unique name must not mark someone
771
+ // else's fork as installed (#485).
772
+ if (/^(?:link|file):/i.test(specStr)) {
773
+ if (plugins === undefined) continue
774
+ const entry = findCatalogEntryForLocal(plugins, name, repos, repoHints[name] ?? [])
775
+ if (entry !== null && entry.url === plugin.url) return name
776
+ continue
777
+ }
778
+ if (depRepoIds(specStr, repos).size === 0 && plugins !== undefined && looseMatchCount(plugins, name) > 1
779
+ && !repoHintMatches(plugin, repoHints[name] ?? [])) continue
780
+ if (sameSourceConflict(plugin, specStr, repos)) continue
781
+ for (const id of depIdentities(name, specStr, repos)) {
782
+ if (ids.has(id)) return name
783
+ }
784
+ }
785
+ return null
786
+ }
787
+
788
+ /** The registry entry an installed dependency corresponds to, or undefined. */
789
+ export function entryForDep(
790
+ plugins: RegistryPlugin[],
791
+ name: string,
792
+ spec: string,
793
+ repoIdentities: readonly string[] = [],
794
+ repoHints: readonly string[] = [],
795
+ ): RegistryPlugin | undefined {
796
+ if (depRepoIds(String(spec), repoIdentities).size === 0 && looseMatchCount(plugins, name) > 1) {
797
+ const hinted = plugins.find(plugin => repoHintMatches(plugin, repoHints) && looseMatches(plugin, name))
798
+ if (hinted === undefined) return undefined
799
+ }
800
+ const ids = depIdentities(name, String(spec), repoIdentities)
801
+ return plugins.find((plugin) => {
802
+ if (sameSourceConflict(plugin, String(spec), repoIdentities)) return false
803
+ for (const id of entryIdentities(plugin)) if (ids.has(id)) return true
804
+ return false
805
+ })
806
+ }
807
+
808
+ export function isInstalled(
809
+ plugin: RegistryPlugin,
810
+ installed: InstalledMap,
811
+ repoIdentities: InstalledRepoIdentities = {},
812
+ plugins?: RegistryPlugin[],
813
+ repoHints: InstalledRepoHints = {},
814
+ ): boolean {
815
+ return matchInstalledName(plugin, installed, repoIdentities, plugins, repoHints) !== null
816
+ }
817
+
818
+ /**
819
+ * The header brand mark now lives in MarketSection.tsx as an inline SVG
820
+ * (official-style monochrome glyph, fill="currentColor") so it follows the
821
+ * active theme; the colored assets/logo.svg tile is no longer inlined here.
822
+ */
823
+
824
+ /** Four representative colors for a theme card's preview strip. */
825
+ export function themeSwatch(def: ThemeDef): string[] {
826
+ const tk = def.tokens || {}
827
+ const pick = (names: string[]) => { for (const n of names) { if (tk[n]) return tk[n]! } return null }
828
+ const dark = def.colorScheme === 'dark'
829
+ return [
830
+ pick(['--dsw-alias-bg-base', '--dsw-alias-bg-layer-1']) || (dark ? '#0f1115' : '#ffffff'),
831
+ pick(['--dsw-alias-bg-layer-2', '--dsw-alias-bg-overlay']) || (dark ? '#1a1d23' : '#f3f4f6'),
832
+ pick(['--dsw-alias-brand-primary']) || '#4f6ef7',
833
+ pick(['--dsw-alias-label-primary']) || (dark ? '#e5e7eb' : '#1f2328'),
834
+ ]
835
+ }
836
+
837
+ // ------------------------------------------------------------- screenshots
838
+
839
+ /**
840
+ * Ordered routes for GitHub URLs this page loads. Set from the status poll,
841
+ * which resolves them from the download region on the server.
842
+ *
843
+ * Module state rather than a prop: avatars and README fetches are built in
844
+ * separate files, and threading the lists through every card would put them
845
+ * in signatures that have no other reason to know about networking.
846
+ *
847
+ * Applied at the LAST moment, never stored. Extracted image URLs stay
848
+ * canonical, so changing region re-renders against the new route instead of
849
+ * leaving a page full of links to a proxy the user just switched away from.
850
+ */
851
+ export type ClientGithubService = 'raw' | 'avatar'
852
+ export interface ClientGithubRouteCandidate { proxy: string | null; url: string }
853
+ export type ClientGithubRoutes = Partial<Record<ClientGithubService, Array<string | null>>>
854
+
855
+ let githubRoutes: Record<ClientGithubService, Array<string | null>> = {
856
+ raw: [null],
857
+ avatar: [null],
858
+ }
859
+ const preferredGithubRoutes = new Map<ClientGithubService, string | null>()
860
+
861
+ function cleanGithubRoutes(value: unknown, fallback: string | null): Array<string | null> {
862
+ if (!Array.isArray(value)) return [fallback]
863
+ const out: Array<string | null> = []
864
+ for (const item of value) {
865
+ if (item !== null && typeof item !== 'string') continue
866
+ const route = typeof item === 'string' ? item.trim().replace(/\/+$/u, '') : null
867
+ if (route === '' || out.includes(route)) continue
868
+ out.push(route)
869
+ }
870
+ return out.length === 0 ? [fallback] : out
871
+ }
872
+
873
+ /** Install server-resolved per-service candidates, with old-host fallback. */
874
+ export function setGithubRoutes(routes: ClientGithubRoutes | undefined, fallback: string | null = null): void {
875
+ let changed = false
876
+ for (const service of ['raw', 'avatar'] as const) {
877
+ const next = cleanGithubRoutes(routes?.[service], fallback)
878
+ if (next.length === githubRoutes[service].length
879
+ && next.every((route, index) => route === githubRoutes[service][index])) continue
880
+ githubRoutes[service] = next
881
+ preferredGithubRoutes.delete(service)
882
+ changed = true
883
+ }
884
+ // Failed/empty README results are cached too. A route change is the user's
885
+ // explicit request to try a different path, so an old failure must not win.
886
+ if (changed) readmeShotsCache.clear()
887
+ }
888
+
889
+ /** Apply either a current status payload or its legacy single-prefix shape. */
890
+ export function applyGithubRouting(status: Pick<MarketStatus, 'githubRoutes' | 'githubProxy'>): void {
891
+ setGithubRoutes(status.githubRoutes, typeof status.githubProxy === 'string' ? status.githubProxy : null)
892
+ }
893
+
894
+ /** Point every browser-side GitHub request at one legacy prefix. */
895
+ export function setGithubProxy(proxy: string | null): void {
896
+ setGithubRoutes({ raw: [proxy], avatar: [proxy] }, proxy)
897
+ }
898
+
899
+ /** First avatar prefix, retained for old consumers and tests. */
900
+ export function githubProxyInUse(): string | null {
901
+ return githubRoutes.avatar[0] ?? null
902
+ }
903
+
904
+ /** Candidates for one request, with that service's last winner first. */
905
+ export function githubRouteCandidates(service: ClientGithubService, url: string): ClientGithubRouteCandidate[] {
906
+ const routes = [...githubRoutes[service]]
907
+ if (preferredGithubRoutes.has(service)) {
908
+ const preferred = preferredGithubRoutes.get(service)!
909
+ const index = routes.findIndex(route => route === preferred)
910
+ if (index > 0) routes.unshift(...routes.splice(index, 1))
911
+ }
912
+ return routes.map(proxy => ({ proxy, url: proxy === null ? url : `${proxy}/${url}` }))
913
+ }
914
+
915
+ /** Cache a route only after the consumer has validated its expected payload. */
916
+ export function rememberGithubRoute(service: ClientGithubService, proxy: string | null): void {
917
+ preferredGithubRoutes.set(service, proxy)
918
+ }
919
+
920
+ /** Test/page-lifetime reset for both configured routes and learned winners. */
921
+ export function resetGithubRouting(): void {
922
+ githubRoutes = { raw: [null], avatar: [null] }
923
+ preferredGithubRoutes.clear()
924
+ }
925
+
926
+ /** `url` through the first candidate for its host, retained for compatibility. */
927
+ export function githubUrl(url: string): string {
928
+ const service: ClientGithubService = url.includes('avatars.githubusercontent.com') ? 'avatar' : 'raw'
929
+ return githubRouteCandidates(service, url)[0]!.url
930
+ }
931
+
932
+ /**
933
+ * Image hosts screenshots may load from (#61) — GitHub's own hosting only.
934
+ * Any other host is dropped BEFORE an <img> is created: a screenshot URL is
935
+ * a request carrying the user's IP, so registry data and README content are
936
+ * both treated as untrusted here, matching the upstream build gate.
937
+ */
938
+ const SCREENSHOT_HOSTS = new Set([
939
+ 'raw.githubusercontent.com',
940
+ 'user-images.githubusercontent.com',
941
+ 'camo.githubusercontent.com',
942
+ 'github.com',
943
+ ])
944
+
945
+ const MAX_SCREENSHOTS = 6
946
+
947
+ /** A README image together with the evidence used to rank it as a preview. */
948
+ export interface ScreenshotCandidate {
949
+ src: string
950
+ semanticScore: number
951
+ order: number
952
+ curated: boolean
953
+ }
954
+
955
+ /** Dimensions observed from a low-resolution, no-upscale image probe. */
956
+ export interface ScreenshotMeasurement {
957
+ src: string
958
+ width: number
959
+ height: number
960
+ }
961
+
962
+ /** Return one safe screenshot URL without applying the public list limit. */
963
+ function safeScreenshot(value: unknown): string | null {
964
+ if (typeof value !== 'string') return null
965
+ let parsed: URL
966
+ try { parsed = new URL(value) } catch { return null }
967
+ if (parsed.protocol !== 'https:' || !SCREENSHOT_HOSTS.has(parsed.hostname)) return null
968
+ if (/\.svg$/iu.test(parsed.pathname)) return null
969
+ return value
970
+ }
971
+
972
+ /** Keep only https URLs on allowlisted image hosts; SVG dropped (logos/badges). */
973
+ export function safeScreenshots(urls: unknown): string[] {
974
+ if (!Array.isArray(urls)) return []
975
+ const safe: string[] = []
976
+ for (const value of urls) {
977
+ const src = safeScreenshot(value)
978
+ if (src === null) continue
979
+ if (!safe.includes(src)) safe.push(src)
980
+ if (safe.length >= MAX_SCREENSHOTS) break
981
+ }
982
+ return safe
983
+ }
984
+
985
+ const PREVIEW_WORDS = /(?:preview|screen[ -]?shots?|shots?|demo|showcase|gallery|theme|skin|appearance|效果|预览|截图|演示|展示|界面|主题|皮肤)/iu
986
+ const FULL_PREVIEW_WORDS = /(?:full|overview|home|main|conversation|chat|workspace|dashboard|完整|主页|首页|全景|主界面)/iu
987
+ const PARTIAL_PREVIEW_WORDS = /(?:settings?|panel|dialog|modal|picker|menu|controls?|fragment|crop|detail|配置|设置|面板|弹窗|局部|细节)/iu
988
+ const NON_PREVIEW_WORDS = /(?:badge|shield|logo|icon|avatar|sponsor|donat|fund|qr(?:code)?|wechat|qq(?:group)?|npm|build|coverage|license|status|button|favicon|徽章|图标|头像|赞助|捐赠|二维码|微信|交流群)/iu
989
+
990
+ interface ReadmeImageParts {
991
+ src: string
992
+ alt: string
993
+ title: string
994
+ width: number | null
995
+ height: number | null
996
+ }
997
+
998
+ /** A quoted or unquoted HTML attribute; README HTML is data, never rendered. */
999
+ function htmlAttribute(html: string, name: string): string {
1000
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'iu').exec(html)
1001
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? ''
1002
+ }
1003
+
1004
+ function numericDimension(raw: string): number | null {
1005
+ if (!/^\d+(?:\.\d+)?$/u.test(raw.trim())) return null
1006
+ const value = Number(raw)
1007
+ return Number.isFinite(value) && value > 0 ? value : null
1008
+ }
1009
+
1010
+ /** Resolve one README image path to the canonical GitHub-hosted URL. */
1011
+ function resolveReadmeImage(raw: string, owner: string, repo: string, base: string): string | null {
1012
+ const src = raw.trim().replace(/^<|>$/g, '')
1013
+ if (src === '' || src.startsWith('data:')) return null
1014
+ let absolute: string
1015
+ if (/^https?:\/\//iu.test(src)) {
1016
+ absolute = src
1017
+ } else if (src.startsWith('/')) {
1018
+ absolute = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD${src}`
1019
+ } else {
1020
+ try { absolute = new URL(src, base).href } catch { return null }
1021
+ }
1022
+ return safeScreenshot(absolute)
1023
+ }
1024
+
1025
+ /** Score evidence available without downloading the image itself. */
1026
+ function readmeSemanticScore(
1027
+ image: ReadmeImageParts,
1028
+ heading: string,
1029
+ nearby: string,
1030
+ order: number,
1031
+ offset: number,
1032
+ ): number {
1033
+ const label = `${image.alt} ${image.title}`
1034
+ const path = (() => {
1035
+ try {
1036
+ const parsed = new URL(image.src)
1037
+ // Do not count owner/repository names as image evidence: practically
1038
+ // every entry here contains "theme" or "skin" in its repo name.
1039
+ if (parsed.hostname === 'raw.githubusercontent.com') {
1040
+ return '/' + parsed.pathname.split('/').slice(4).join('/')
1041
+ }
1042
+ return parsed.pathname
1043
+ } catch { return image.src }
1044
+ })()
1045
+ let score = 20 + Math.max(0, 8 - order)
1046
+ if (PREVIEW_WORDS.test(label)) score += 55
1047
+ if (PREVIEW_WORDS.test(path)) score += 40
1048
+ if (PREVIEW_WORDS.test(heading)) score += 32
1049
+ if (PREVIEW_WORDS.test(nearby)) score += 12
1050
+ if (FULL_PREVIEW_WORDS.test(`${label} ${path}`)) score += 35
1051
+ if (PARTIAL_PREVIEW_WORDS.test(`${label} ${path}`)) score -= 30
1052
+ if (NON_PREVIEW_WORDS.test(label)) score -= 140
1053
+ if (NON_PREVIEW_WORDS.test(path)) score -= 120
1054
+ if (NON_PREVIEW_WORDS.test(heading)) score -= 55
1055
+ if (NON_PREVIEW_WORDS.test(nearby)) score -= 18
1056
+ // A title-block image with no screenshot evidence is usually branding.
1057
+ if (offset < 500 && !PREVIEW_WORDS.test(`${label} ${path} ${heading}`)) score -= 20
1058
+ if (image.width !== null && image.height !== null) {
1059
+ score += previewDimensionScore(image.width, image.height) ?? -500
1060
+ } else if ((image.width ?? image.height ?? Number.POSITIVE_INFINITY) < 240) {
1061
+ score -= 100
1062
+ }
1063
+ return score
1064
+ }
1065
+
1066
+ /**
1067
+ * Ranked README image candidates for use when the catalog has no curated
1068
+ * screenshots. Ranking uses the image label/path, nearest heading, nearby
1069
+ * prose, declared dimensions and document position. This prevents a title
1070
+ * logo or a row of tiny badges from consuming the six-candidate limit before
1071
+ * a later Screenshots section is reached.
1072
+ */
1073
+ export function extractReadmeImageCandidates(
1074
+ markdown: string,
1075
+ owner: string,
1076
+ repo: string,
1077
+ subpath: string | null,
1078
+ ): ScreenshotCandidate[] {
1079
+ const base = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/${subpath === null ? '' : subpath + '/'}`
1080
+ const headings = [...markdown.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gmu)]
1081
+ .map(match => ({ offset: match.index, text: match[1] ?? '' }))
1082
+ const found = new Map<string, ScreenshotCandidate>()
1083
+ let headingIndex = -1
1084
+ let order = 0
1085
+ // Markdown and HTML image forms stay in one pass, preserving position.
1086
+ const imagePattern = /!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?\s*\)|<img\b([^>]*?)\/?\s*>/gimu
1087
+ for (const match of markdown.matchAll(imagePattern)) {
1088
+ while (headingIndex + 1 < headings.length && headings[headingIndex + 1]!.offset < match.index) headingIndex += 1
1089
+ const html = match[7] ?? ''
1090
+ const rawSrc = match[2] ?? match[3] ?? htmlAttribute(html, 'src')
1091
+ const src = resolveReadmeImage(rawSrc, owner, repo, base)
1092
+ if (src === null) continue
1093
+ const image: ReadmeImageParts = {
1094
+ src,
1095
+ alt: match[1] ?? htmlAttribute(html, 'alt'),
1096
+ title: match[4] ?? match[5] ?? match[6] ?? htmlAttribute(html, 'title'),
1097
+ width: numericDimension(htmlAttribute(html, 'width')),
1098
+ height: numericDimension(htmlAttribute(html, 'height')),
1099
+ }
1100
+ const heading = headings[headingIndex]?.text ?? ''
1101
+ const nearby = markdown.slice(Math.max(0, match.index - 100), Math.min(markdown.length, match.index + match[0].length + 100))
1102
+ const candidate: ScreenshotCandidate = {
1103
+ src,
1104
+ semanticScore: readmeSemanticScore(image, heading, nearby, order, match.index),
1105
+ order,
1106
+ curated: false,
1107
+ }
1108
+ const previous = found.get(src)
1109
+ if (previous === undefined || candidate.semanticScore > previous.semanticScore) found.set(src, candidate)
1110
+ order += 1
1111
+ }
1112
+ return [...found.values()]
1113
+ .filter(candidate => candidate.semanticScore >= 20)
1114
+ .sort((a, b) => b.semanticScore - a.semanticScore || a.order - b.order)
1115
+ .slice(0, MAX_SCREENSHOTS)
1116
+ }
1117
+
1118
+ /** Ranked README image URLs; retained as the simple public extraction API. */
1119
+ export function extractReadmeImages(markdown: string, owner: string, repo: string, subpath: string | null): string[] {
1120
+ return extractReadmeImageCandidates(markdown, owner, repo, subpath).map(candidate => candidate.src)
1121
+ }
1122
+
1123
+ /**
1124
+ * Score dimensions from a 240px-high, no-upscale probe.
1125
+ *
1126
+ * A theme preview should resemble a complete desktop surface: landscape,
1127
+ * neither a narrow crop nor a panoramic strip, and large enough to inspect.
1128
+ * Small square logos and portrait fragments intentionally return null.
1129
+ */
1130
+ export function previewDimensionScore(width: number, height: number): number | null {
1131
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
1132
+ const ratio = width / height
1133
+ const area = width * height
1134
+ if (width < 280 || height < 150 || area < 48_000 || ratio < 1.05 || ratio > 3.2) return null
1135
+ let score = Math.min(28, Math.round(area / 4_000))
1136
+ if (ratio >= 1.35 && ratio <= 2.05) score += 48
1137
+ else if (ratio >= 1.18 && ratio <= 2.4) score += 28
1138
+ else score += 8
1139
+ if (width >= 320 && height >= 180) score += 14
1140
+ return score
1141
+ }
1142
+
1143
+ /** Combine README semantics with measured geometry and return the best set. */
1144
+ export function rankThemeScreenshots(
1145
+ candidates: ScreenshotCandidate[],
1146
+ measurements: ScreenshotMeasurement[],
1147
+ ): string[] {
1148
+ const bySrc = new Map(measurements.map(item => [item.src, item]))
1149
+ return candidates.flatMap(candidate => {
1150
+ const measured = bySrc.get(candidate.src)
1151
+ if (measured === undefined) return []
1152
+ const dimensionScore = previewDimensionScore(measured.width, measured.height)
1153
+ return dimensionScore === null ? [] : [{ candidate, score: candidate.semanticScore + dimensionScore }]
1154
+ }).sort((a, b) => b.score - a.score || a.candidate.order - b.candidate.order)
1155
+ .slice(0, MAX_SCREENSHOTS)
1156
+ .map(item => item.candidate.src)
1157
+ }
1158
+
1159
+ const readmeShotsCache = new Map<string, Promise<ScreenshotCandidate[]>>()
1160
+
1161
+ /** Clear README-derived media at the boundary of an accepted catalog generation. */
1162
+ export function resetScreenshotsCache(): void {
1163
+ readmeShotsCache.clear()
1164
+ }
1165
+
1166
+ /**
1167
+ * Screenshot candidates for a plugin: the registry's curated list when
1168
+ * present, otherwise lazily extracted and semantically ranked from README.
1169
+ */
1170
+ export function pluginScreenshotCandidates(plugin: RegistryPlugin): Promise<ScreenshotCandidate[]> {
1171
+ const curated = safeScreenshots(plugin.screenshots)
1172
+ if (curated.length > 0) {
1173
+ return Promise.resolve(curated.map((src, order) => ({ src, order, semanticScore: 1_000 - order, curated: true })))
1174
+ }
1175
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(plugin.url)
1176
+ if (m === null) return Promise.resolve([])
1177
+ const [, owner, repo, subpath = null] = m
1178
+ const cacheKey = plugin.url
1179
+ const cached = readmeShotsCache.get(cacheKey)
1180
+ if (cached !== undefined) return cached
1181
+ const fetchReadme = async (path: string | null): Promise<string | null> => {
1182
+ const url = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/${path === null ? '' : path + '/'}README.md`
1183
+ for (const candidate of githubRouteCandidates('raw', url)) {
1184
+ const controller = new AbortController()
1185
+ const timer = setTimeout(() => { controller.abort() }, 6000)
1186
+ try {
1187
+ const res = await fetch(candidate.url, { signal: controller.signal })
1188
+ if (!res.ok) continue
1189
+ const body = await res.text()
1190
+ const contentType = res.headers.get('content-type')?.toLowerCase() ?? ''
1191
+ // A public proxy's branded error document is not a README even when
1192
+ // it carries HTTP 200. Validate before remembering the route.
1193
+ if (contentType.includes('text/html') || /^\s*(?:<!doctype\s+html|<html[\s>])/iu.test(body)) continue
1194
+ rememberGithubRoute('raw', candidate.proxy)
1195
+ return body
1196
+ } catch {
1197
+ // Transport failure is exactly what the next candidate is for.
1198
+ } finally {
1199
+ clearTimeout(timer)
1200
+ }
1201
+ }
1202
+ return null
1203
+ }
1204
+ const task = (async () => {
1205
+ // Monorepo subpath entries prefer their own README, falling back to the
1206
+ // repo root; shots in the subpath README resolve against its directory.
1207
+ const sub = subpath === null ? null : await fetchReadme(subpath)
1208
+ if (sub !== null) return extractReadmeImageCandidates(sub, owner!, repo!, subpath)
1209
+ const root = await fetchReadme(null)
1210
+ return root === null ? [] : extractReadmeImageCandidates(root, owner!, repo!, null)
1211
+ })().catch(() => [] as ScreenshotCandidate[])
1212
+ readmeShotsCache.set(cacheKey, task)
1213
+ return task
1214
+ }
1215
+
1216
+ /** Screenshot URLs for dialogs; theme covers use the richer candidate API. */
1217
+ export async function pluginScreenshots(plugin: RegistryPlugin): Promise<string[]> {
1218
+ return (await pluginScreenshotCandidates(plugin)).map(candidate => candidate.src)
1219
+ }
1220
+
1221
+ /**
1222
+ * The human-readable part of a failed command's output.
1223
+ *
1224
+ * pnpm's ndjson reporter writes one JSON object per progress tick, and a
1225
+ * large `github:` download emits thousands of them. When a failure matches
1226
+ * none of the known signatures there is no diagnosis to show, so the UI
1227
+ * falls back to the tail of stdout/stderr — which for exactly that case is
1228
+ * 600 characters of `{"name":"pnpm:fetching-progress","downloaded":…}`.
1229
+ * The user is handed machine noise at the one moment they need a sentence
1230
+ * (#148, and the same shape behind #161).
1231
+ *
1232
+ * Progress objects are dropped; anything else — including JSON carrying a
1233
+ * real message — is kept, because an unrecognized failure is precisely when
1234
+ * throwing information away is most expensive.
1235
+ */
1236
+ export function humanOutput(raw: string): string {
1237
+ const lines = raw.split(/\r?\n/)
1238
+ const kept: string[] = []
1239
+ for (const line of lines) {
1240
+ const trimmed = line.trim()
1241
+ if (trimmed === '') continue
1242
+ if (!trimmed.startsWith('{')) { kept.push(line); continue }
1243
+ try {
1244
+ const parsed = JSON.parse(trimmed) as { name?: unknown; err?: unknown; message?: unknown }
1245
+ const name = typeof parsed.name === 'string' ? parsed.name : ''
1246
+ // Keep anything that carries a diagnosis, drop pure progress chatter.
1247
+ if (parsed.err !== undefined || typeof parsed.message === 'string') { kept.push(line); continue }
1248
+ if (name.startsWith('pnpm:')) continue
1249
+ kept.push(line)
1250
+ } catch {
1251
+ kept.push(line)
1252
+ }
1253
+ }
1254
+ return kept.join('\n').trim()
1255
+ }
1256
+
1257
+ /**
1258
+ * The plugin's own name, for display.
1259
+ *
1260
+ * The catalog's `name` is an IDENTITY, and for the 104 entries that live in
1261
+ * a repository holding several plugins it is a compound one:
1262
+ * `dsh-web#packages/dsh-web-all`. Shown verbatim it puts a repository
1263
+ * path in front of a user who did not ask about repositories — and worse, it
1264
+ * disagrees with the market's own installed list, which reads names out of
1265
+ * the profile manifest and calls the same plugin `dsh-web-all`. The same
1266
+ * thing had two names either side of the Install button.
1267
+ *
1268
+ * A card answers two questions: who made it, and what is it called. The
1269
+ * author is drawn beside their avatar as one unit, so the title is free to
1270
+ * be just the plugin. Duplicate titles across authors are fine — the byline
1271
+ * is what separates them — which is why this does not try to keep the
1272
+ * repository as a qualifier.
1273
+ *
1274
+ * The repository name IS the plugin name in the ordinary case, because a
1275
+ * repository holding one plugin is named after it. Only the compound form
1276
+ * needs unpicking, and its last segment is the plugin's own directory.
1277
+ *
1278
+ * Not a substitute for the identity: every key, lookup and install still
1279
+ * uses `name` unchanged.
1280
+ */
1281
+ export function pluginName(name: string): string {
1282
+ const hash = name.indexOf('#')
1283
+ if (hash === -1) return name
1284
+ const sub = name.slice(hash + 1)
1285
+ const leaf = sub.slice(sub.lastIndexOf('/') + 1)
1286
+ // A sub-path that is empty or trailing-slashed tells us nothing; the
1287
+ // repository half is a better answer than an empty title.
1288
+ return leaf === '' ? name.slice(0, hash) : leaf
1289
+ }
1290
+
1291
+ /**
1292
+ * Compact display for a count that can run into the tens of thousands
1293
+ * (npm downloads, star counts): "11.9k" instead of "11862". Reported —
1294
+ * the raw number made the card byline visibly cramped once downloads was
1295
+ * added alongside stars.
1296
+ *
1297
+ * Below 1000 the exact number is shown; a small count is exactly the case
1298
+ * where the precision matters and abbreviating it buys nothing.
1299
+ */
1300
+ export function formatCount(n: number): string {
1301
+ if (!Number.isFinite(n) || n < 1000) return String(n)
1302
+ const k = Math.round(n / 100) / 10
1303
+ return `${Number.isInteger(k) ? k.toFixed(0) : k.toFixed(1)}k`
1304
+ }
1305
+
1306
+ export { findCatalogEntryForLocal, resolveCatalogRestore } from '../catalog-local-match.ts'
1307
+ export type { CatalogRestoreReason } from '../catalog-local-match.ts'
1308
+
1309
+ /** Catalog row for an installed dependency — strict for local link:/file: specs. */
1310
+ export function catalogEntryForInstalled(
1311
+ plugins: RegistryPlugin[],
1312
+ name: string,
1313
+ spec: string,
1314
+ repoIdentities: readonly string[] = [],
1315
+ repoHints: readonly string[] = [],
1316
+ ): RegistryPlugin | undefined {
1317
+ if (/^(?:link|file):/i.test(spec)) {
1318
+ return findCatalogEntryForLocal(plugins, name, repoIdentities, repoHints) ?? undefined
1319
+ }
1320
+ return entryForDep(plugins, name, spec, repoIdentities, repoHints)
1321
+ }