@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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- 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
|
+
}
|