@nanmicoder/dsh-skills-hub 0.0.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/AGENTS.md +9 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README_EN.md +120 -0
- package/README_ZH.md +5 -0
- package/assets/readme/hero-en.png +0 -0
- package/assets/readme/hero-zh.png +0 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +10 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/CONTRACT.md +125 -0
- package/docs/RELEASE.md +24 -0
- package/lib/client.js +25115 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +51 -0
- package/lib/index.js +176 -0
- package/lib/market/cache.js +113 -0
- package/lib/market/clawhub-provider.js +598 -0
- package/lib/market/install-service.js +394 -0
- package/lib/market/market-service.js +312 -0
- package/lib/market/provider-fetch.js +216 -0
- package/lib/market/skillhub-provider.js +277 -0
- package/lib/market/types.js +82 -0
- package/lib/skills/installed.js +426 -0
- package/lib/skills/management.js +36 -0
- package/lib/skills/read-text.js +23 -0
- package/lib/skills/root.js +112 -0
- package/lib/types/client/api.d.ts +106 -0
- package/lib/types/client/components/FilterBar.d.ts +20 -0
- package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
- package/lib/types/client/components/InstalledSkills.d.ts +3 -0
- package/lib/types/client/components/MarkdownView.d.ts +4 -0
- package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
- package/lib/types/client/components/MarketHome.d.ts +12 -0
- package/lib/types/client/components/SecurityBadge.d.ts +16 -0
- package/lib/types/client/components/SkillAvatar.d.ts +14 -0
- package/lib/types/client/components/SkillCard.d.ts +17 -0
- package/lib/types/client/components/SkillDetailView.d.ts +21 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
- package/lib/types/client/icons.d.ts +71 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locale-context.d.ts +31 -0
- package/lib/types/client/locales.d.ts +117 -0
- package/lib/types/client/page/MarketPage.d.ts +21 -0
- package/lib/types/client/skill-markdown.d.ts +2 -0
- package/lib/types/client/state.d.ts +119 -0
- package/lib/types/config.d.ts +51 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/market/cache.d.ts +49 -0
- package/lib/types/market/clawhub-provider.d.ts +28 -0
- package/lib/types/market/install-service.d.ts +75 -0
- package/lib/types/market/market-service.d.ts +74 -0
- package/lib/types/market/provider-fetch.d.ts +61 -0
- package/lib/types/market/skillhub-provider.d.ts +13 -0
- package/lib/types/market/types.d.ts +163 -0
- package/lib/types/skills/installed.d.ts +117 -0
- package/lib/types/skills/management.d.ts +7 -0
- package/lib/types/skills/read-text.d.ts +5 -0
- package/lib/types/skills/root.d.ts +48 -0
- package/lib/types/web-routes.d.ts +74 -0
- package/lib/web-routes.js +427 -0
- package/package.json +160 -0
- package/release-notes/v0.0.1.md +11 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillHub provider (https://api.skillhub.cn)
|
|
3
|
+
*
|
|
4
|
+
* Endpoints (verified against the live API):
|
|
5
|
+
* - GET /api/skills?page=&pageSize=&keyword= → {code, data:{skills[], total}, message}
|
|
6
|
+
* NOTE: pagination param MUST be `pageSize` (a `limit` param is silently ignored)
|
|
7
|
+
* NOTE: search param MUST be `keyword` (a `q` param is silently ignored)
|
|
8
|
+
* - GET /api/v1/skills/{slug} → {skill, owner, latestVersion, securityReports}
|
|
9
|
+
* - GET /api/v1/skills/{slug}/files → {count, files:[{path, sha256, size}]}
|
|
10
|
+
* - GET /api/v1/skills/{slug}/file?path= → 302 redirect to Tencent COS (follow)
|
|
11
|
+
*/
|
|
12
|
+
import { getProviderBase, providerFetch, providerFetchJson, readResponseTextWithLimit, MarketHttpError, } from "./provider-fetch.js";
|
|
13
|
+
import { detectMarketLanguage, MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError, skillId, } from "./types.js";
|
|
14
|
+
// The frontmatter parser is shared with the ClawHub provider: both sources hand
|
|
15
|
+
// us a raw SKILL.md and the detail contract promises a frontmatter-free body.
|
|
16
|
+
import { parseFrontmatter } from "./clawhub-provider.js";
|
|
17
|
+
import { markSourceHealth } from "./cache.js";
|
|
18
|
+
/**
|
|
19
|
+
* Vendor statuses that mean "no findings". Anything else (suspicious,
|
|
20
|
+
* malicious, …) flags the skill: an unrecognised vendor status must never be
|
|
21
|
+
* reported as safe.
|
|
22
|
+
*/
|
|
23
|
+
const BENIGN_STATUSES = new Set(['benign', 'safe', 'clean']);
|
|
24
|
+
// ─── Untrusted-payload helpers ───────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Upstream JSON is untrusted input: a field declared `string` in the ported
|
|
27
|
+
* types can arrive as an object, an array or a number. These helpers keep every
|
|
28
|
+
* `NormalizedSkill` field at its declared runtime type.
|
|
29
|
+
*/
|
|
30
|
+
function asString(value) {
|
|
31
|
+
return typeof value === 'string' ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
function asNumber(value) {
|
|
34
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
/** A usable slug is a non-empty string — anything else is not a skill entry. */
|
|
37
|
+
function hasUsableSlug(item) {
|
|
38
|
+
if (typeof item !== 'object' || item === null)
|
|
39
|
+
return false;
|
|
40
|
+
const slug = item.slug;
|
|
41
|
+
return typeof slug === 'string' && slug.trim() !== '';
|
|
42
|
+
}
|
|
43
|
+
/** File entries are only useful with a string path; sizes default to 0. */
|
|
44
|
+
function usableFileEntries(files) {
|
|
45
|
+
if (!Array.isArray(files))
|
|
46
|
+
return [];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
if (typeof file !== 'object' || file === null)
|
|
50
|
+
continue;
|
|
51
|
+
const record = file;
|
|
52
|
+
if (typeof record.path !== 'string' || record.path === '')
|
|
53
|
+
continue;
|
|
54
|
+
out.push({
|
|
55
|
+
path: record.path,
|
|
56
|
+
size: asNumber(record.size) ?? 0,
|
|
57
|
+
sha256: asString(record.sha256),
|
|
58
|
+
contentType: asString(record.contentType),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function mapSecurity(reports, verified) {
|
|
64
|
+
const normalized = [];
|
|
65
|
+
for (const [vendor, report] of Object.entries(reports ?? {})) {
|
|
66
|
+
const status = asString(report?.status);
|
|
67
|
+
if (status === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
normalized.push({
|
|
70
|
+
vendor,
|
|
71
|
+
status,
|
|
72
|
+
statusText: asString(report?.statusText) || status,
|
|
73
|
+
reportUrl: asString(report?.reportUrl),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (normalized.length === 0)
|
|
77
|
+
return { status: 'unknown', reports: normalized };
|
|
78
|
+
const anyFlagged = normalized.some((report) => !BENIGN_STATUSES.has(report.status.toLowerCase()));
|
|
79
|
+
if (anyFlagged)
|
|
80
|
+
return { status: 'flagged', reports: normalized };
|
|
81
|
+
return { status: verified ? 'verified' : 'benign', reports: normalized };
|
|
82
|
+
}
|
|
83
|
+
function parseUpstream(item) {
|
|
84
|
+
const upstreamUrl = asString(item.upstream_url);
|
|
85
|
+
if (item.source !== 'clawhub' || !upstreamUrl)
|
|
86
|
+
return undefined;
|
|
87
|
+
// upstream_url looks like https://clawhub.ai/{owner}/{slug} — the trailing segment is the slug.
|
|
88
|
+
try {
|
|
89
|
+
const segments = new URL(upstreamUrl).pathname.split('/').filter(Boolean);
|
|
90
|
+
const slug = segments[segments.length - 1];
|
|
91
|
+
if (slug)
|
|
92
|
+
return { source: 'clawhub', slug };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Malformed upstream URL — treat as a native entry.
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
function normalizeListItem(item) {
|
|
100
|
+
const tags = [];
|
|
101
|
+
for (const sub of item.subCategories ?? []) {
|
|
102
|
+
const name = asString(sub?.name);
|
|
103
|
+
if (name)
|
|
104
|
+
tags.push(name);
|
|
105
|
+
}
|
|
106
|
+
const verified = item.verified === undefined ? undefined : Boolean(item.verified);
|
|
107
|
+
return {
|
|
108
|
+
id: skillId('skillhub', item.slug),
|
|
109
|
+
source: 'skillhub',
|
|
110
|
+
slug: item.slug,
|
|
111
|
+
name: asString(item.name) || asString(item.displayName) || item.slug,
|
|
112
|
+
// Prefer the Chinese copy: this market's primary audience reads zh-CN.
|
|
113
|
+
summary: asString(item.description_zh)
|
|
114
|
+
|| asString(item.description)
|
|
115
|
+
|| asString(item.summary_zh)
|
|
116
|
+
|| asString(item.summary)
|
|
117
|
+
|| '',
|
|
118
|
+
author: { handle: asString(item.ownerName) || '' },
|
|
119
|
+
stats: {
|
|
120
|
+
downloads: asNumber(item.downloads) ?? 0,
|
|
121
|
+
installs: asNumber(item.installs),
|
|
122
|
+
stars: asNumber(item.stars),
|
|
123
|
+
},
|
|
124
|
+
tags,
|
|
125
|
+
category: asString(item.category),
|
|
126
|
+
version: asString(item.version),
|
|
127
|
+
updatedAt: asNumber(item.updated_at) ?? asNumber(item.updatedAt),
|
|
128
|
+
iconUrl: asString(item.iconUrl) || undefined,
|
|
129
|
+
// List responses carry no security reports — `verified` is the only signal;
|
|
130
|
+
// the detail endpoint refines this to benign/flagged via securityReports.
|
|
131
|
+
securityStatus: verified ? 'verified' : 'unknown',
|
|
132
|
+
requiresApiKey: item.labels?.requires_api_key === 'true',
|
|
133
|
+
verified,
|
|
134
|
+
upstream: parseUpstream(item),
|
|
135
|
+
installState: 'installable',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Fetch one page of the `{code, data, message}` envelope.
|
|
140
|
+
*
|
|
141
|
+
* SkillHub paginates by page number, so the "cursor" is just the next page
|
|
142
|
+
* number as a string; `total` (not the page length) decides exhaustion.
|
|
143
|
+
*/
|
|
144
|
+
async function fetchPage(params) {
|
|
145
|
+
const base = getProviderBase('skillhub');
|
|
146
|
+
const url = new URL('/api/skills', base);
|
|
147
|
+
url.searchParams.set('page', String(params.page));
|
|
148
|
+
url.searchParams.set('pageSize', String(params.pageSize));
|
|
149
|
+
if (params.keyword)
|
|
150
|
+
url.searchParams.set('keyword', params.keyword);
|
|
151
|
+
const envelope = await providerFetchJson('skillhub', url.toString());
|
|
152
|
+
if (envelope.code !== 0 || !Array.isArray(envelope.data?.skills)) {
|
|
153
|
+
throw new MarketUpstreamError('skillhub', MARKET_ERROR_CODES.upstreamBadResponse, `skillhub responded code=${envelope.code}: ${envelope.message || 'bad payload'}`);
|
|
154
|
+
}
|
|
155
|
+
const rawItems = envelope.data.skills;
|
|
156
|
+
const usable = rawItems.filter(hasUsableSlug);
|
|
157
|
+
if (usable.length === 0 && rawItems.length > 0) {
|
|
158
|
+
// Every entry was malformed: report the payload instead of silently serving
|
|
159
|
+
// an empty page while `/status` still claims the source is healthy.
|
|
160
|
+
const error = new MarketUpstreamError('skillhub', MARKET_ERROR_CODES.upstreamBadResponse, 'skillhub list contained no usable items');
|
|
161
|
+
markSourceHealth('skillhub', 'degraded', error.message);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
if (usable.length !== rawItems.length) {
|
|
165
|
+
markSourceHealth('skillhub', 'degraded', 'skillhub list contained malformed items');
|
|
166
|
+
}
|
|
167
|
+
const items = usable.map(normalizeListItem);
|
|
168
|
+
const total = envelope.data.total ?? 0;
|
|
169
|
+
const hasMore = params.page * params.pageSize < total;
|
|
170
|
+
return {
|
|
171
|
+
items,
|
|
172
|
+
nextCursor: hasMore ? String(params.page + 1) : undefined,
|
|
173
|
+
total,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function cursorToPage(cursor) {
|
|
177
|
+
return cursor ? Math.max(1, Number.parseInt(cursor, 10) || 1) : 1;
|
|
178
|
+
}
|
|
179
|
+
export const skillhubProvider = {
|
|
180
|
+
source: 'skillhub',
|
|
181
|
+
async list({ cursor, limit }) {
|
|
182
|
+
return fetchPage({ page: cursorToPage(cursor), pageSize: limit });
|
|
183
|
+
},
|
|
184
|
+
async search({ q, cursor, limit }) {
|
|
185
|
+
return fetchPage({ keyword: q, page: cursorToPage(cursor), pageSize: limit });
|
|
186
|
+
},
|
|
187
|
+
async detail(slug) {
|
|
188
|
+
const base = getProviderBase('skillhub');
|
|
189
|
+
const data = await providerFetchJson('skillhub', new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base).toString());
|
|
190
|
+
if (typeof data.skill?.slug !== 'string' || data.skill.slug.trim() === '') {
|
|
191
|
+
throw new MarketUpstreamError('skillhub', MARKET_ERROR_CODES.upstreamBadResponse, 'skillhub detail missing skill');
|
|
192
|
+
}
|
|
193
|
+
const item = normalizeListItem(data.skill);
|
|
194
|
+
if (data.skill.stats) {
|
|
195
|
+
item.stats = {
|
|
196
|
+
downloads: asNumber(data.skill.stats.downloads) ?? item.stats.downloads,
|
|
197
|
+
installs: asNumber(data.skill.stats.installs) ?? item.stats.installs,
|
|
198
|
+
stars: asNumber(data.skill.stats.stars) ?? item.stats.stars,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const security = mapSecurity(data.securityReports, data.skill.verified === undefined ? undefined : Boolean(data.skill.verified));
|
|
202
|
+
const version = asString(data.latestVersion?.version) || item.version;
|
|
203
|
+
let files = [];
|
|
204
|
+
try {
|
|
205
|
+
files = await skillhubProvider.listFiles(slug);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// File list is best-effort at detail time; install re-fetches it.
|
|
209
|
+
}
|
|
210
|
+
// SkillHub detail has no full SKILL.md body — fetch it for the overview tab.
|
|
211
|
+
let description = '';
|
|
212
|
+
let descriptionFrontmatter;
|
|
213
|
+
const skillMd = files.find((file) => file.path === 'SKILL.md');
|
|
214
|
+
if (skillMd) {
|
|
215
|
+
try {
|
|
216
|
+
const fetched = await skillhubProvider.fetchFile(slug, 'SKILL.md');
|
|
217
|
+
description = fetched.content;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
description = item.summary;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
description = item.summary;
|
|
225
|
+
}
|
|
226
|
+
// This endpoint serves SKILL.md verbatim, frontmatter included. Strip it and
|
|
227
|
+
// expose the parsed map like the ClawHub provider does: the overview tab
|
|
228
|
+
// would otherwise render the raw `---` block as body text, and the metadata
|
|
229
|
+
// panel would never appear for SkillHub skills.
|
|
230
|
+
if (description.startsWith('---')) {
|
|
231
|
+
const parsed = parseFrontmatter(description);
|
|
232
|
+
description = parsed.content;
|
|
233
|
+
descriptionFrontmatter = parsed.frontmatter;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
...item,
|
|
237
|
+
version,
|
|
238
|
+
author: {
|
|
239
|
+
handle: asString(data.owner?.handle) || item.author.handle,
|
|
240
|
+
displayName: asString(data.owner?.displayName),
|
|
241
|
+
avatarUrl: asString(data.owner?.image) || undefined,
|
|
242
|
+
},
|
|
243
|
+
securityStatus: security.status,
|
|
244
|
+
securityReports: security.reports.length ? security.reports : undefined,
|
|
245
|
+
description,
|
|
246
|
+
...(descriptionFrontmatter ? { descriptionFrontmatter } : {}),
|
|
247
|
+
files: files.map((file) => ({
|
|
248
|
+
path: file.path,
|
|
249
|
+
size: file.size,
|
|
250
|
+
sha256: file.sha256,
|
|
251
|
+
contentType: file.contentType,
|
|
252
|
+
language: detectMarketLanguage(file.path),
|
|
253
|
+
tooBig: false,
|
|
254
|
+
})),
|
|
255
|
+
totalSize: files.reduce((sum, file) => sum + file.size, 0),
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
async listFiles(slug) {
|
|
259
|
+
const base = getProviderBase('skillhub');
|
|
260
|
+
const data = await providerFetchJson('skillhub', new URL(`/api/v1/skills/${encodeURIComponent(slug)}/files`, base).toString());
|
|
261
|
+
if (!Array.isArray(data.files)) {
|
|
262
|
+
throw new MarketUpstreamError('skillhub', MARKET_ERROR_CODES.upstreamBadResponse, 'skillhub files missing list');
|
|
263
|
+
}
|
|
264
|
+
return usableFileEntries(data.files);
|
|
265
|
+
},
|
|
266
|
+
async fetchFile(slug, filePath) {
|
|
267
|
+
const base = getProviderBase('skillhub');
|
|
268
|
+
const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}/file`, base);
|
|
269
|
+
url.searchParams.set('path', filePath);
|
|
270
|
+
// 302 → Tencent COS; providerFetch follows redirects.
|
|
271
|
+
const res = await providerFetch('skillhub', url.toString());
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
throw new MarketHttpError('skillhub', res.status, res.status === 404 ? MARKET_ERROR_CODES.upstreamBadResponse : MARKET_ERROR_CODES.upstreamError, `skillhub file fetch failed (${res.status})`);
|
|
274
|
+
}
|
|
275
|
+
return await readResponseTextWithLimit('skillhub', res, MARKET_LIMITS.maxFileSize, `file ${filePath}`);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Market — shared types for the market aggregation layer.
|
|
3
|
+
*
|
|
4
|
+
* Upstream sources:
|
|
5
|
+
* - ClawHub (https://clawhub.ai) — cursor pagination, no security audits
|
|
6
|
+
* - SkillHub (https://api.skillhub.cn) — page/pageSize pagination, security reports
|
|
7
|
+
*/
|
|
8
|
+
export const MARKET_SOURCES = ['clawhub', 'skillhub'];
|
|
9
|
+
// ─── Error codes ─────────────────────────────────────────────────────────────
|
|
10
|
+
export const MARKET_ERROR_CODES = {
|
|
11
|
+
upstreamError: 'MARKET_UPSTREAM_ERROR',
|
|
12
|
+
upstreamTimeout: 'MARKET_UPSTREAM_TIMEOUT',
|
|
13
|
+
upstreamBadResponse: 'MARKET_UPSTREAM_BAD_RESPONSE',
|
|
14
|
+
installInProgress: 'MARKET_INSTALL_IN_PROGRESS',
|
|
15
|
+
alreadyInstalled: 'MARKET_ALREADY_INSTALLED',
|
|
16
|
+
notInstallable: 'MARKET_NOT_INSTALLABLE',
|
|
17
|
+
checksumMismatch: 'MARKET_CHECKSUM_MISMATCH',
|
|
18
|
+
diskError: 'MARKET_DISK_ERROR',
|
|
19
|
+
notInstalled: 'MARKET_NOT_INSTALLED',
|
|
20
|
+
notManaged: 'MARKET_NOT_MANAGED',
|
|
21
|
+
};
|
|
22
|
+
export class MarketUpstreamError extends Error {
|
|
23
|
+
source;
|
|
24
|
+
code;
|
|
25
|
+
constructor(source, code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.source = source;
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = 'MarketUpstreamError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ─── Limits ──────────────────────────────────────────────────────────────────
|
|
33
|
+
export const MARKET_LIMITS = {
|
|
34
|
+
/** Max bytes for a single skill file (install + preview) */
|
|
35
|
+
maxFileSize: 5 * 1024 * 1024,
|
|
36
|
+
/** Max total bytes for an installable skill */
|
|
37
|
+
maxTotalSize: 20 * 1024 * 1024,
|
|
38
|
+
/** Max file count for an installable skill */
|
|
39
|
+
maxFileCount: 200,
|
|
40
|
+
/** File preview content is truncated beyond this many bytes */
|
|
41
|
+
previewTruncateBytes: 300 * 1024,
|
|
42
|
+
/** ClawHub search has no pagination — cap merged search results */
|
|
43
|
+
searchResultCap: 50,
|
|
44
|
+
};
|
|
45
|
+
export function skillId(source, slug) {
|
|
46
|
+
return `${source}:${slug}`;
|
|
47
|
+
}
|
|
48
|
+
export function parseSkillId(id) {
|
|
49
|
+
const idx = id.indexOf(':');
|
|
50
|
+
if (idx <= 0)
|
|
51
|
+
return null;
|
|
52
|
+
const source = id.slice(0, idx);
|
|
53
|
+
const slug = id.slice(idx + 1);
|
|
54
|
+
if (!MARKET_SOURCES.includes(source) || !slug)
|
|
55
|
+
return null;
|
|
56
|
+
return { source: source, slug };
|
|
57
|
+
}
|
|
58
|
+
/** Directory-name whitelist: lowercase alnum, dash, underscore, dot (no leading dot). */
|
|
59
|
+
export function sanitizeDirName(slug) {
|
|
60
|
+
// Upstream payloads are untrusted: a non-string slug is simply not a usable
|
|
61
|
+
// directory name, and must degrade to "not installable" instead of throwing
|
|
62
|
+
// from `slug.toLowerCase()` deep inside a list request.
|
|
63
|
+
if (typeof slug !== 'string')
|
|
64
|
+
return null;
|
|
65
|
+
const name = slug.toLowerCase();
|
|
66
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
67
|
+
return null;
|
|
68
|
+
if (name.includes('..'))
|
|
69
|
+
return null;
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
const LANG_MAP = {
|
|
73
|
+
md: 'markdown', ts: 'typescript', tsx: 'typescript',
|
|
74
|
+
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
75
|
+
json: 'json', yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
76
|
+
py: 'python', toml: 'toml', css: 'css', html: 'html',
|
|
77
|
+
txt: 'text', xml: 'xml', sql: 'sql', rs: 'rust', go: 'go', rb: 'ruby',
|
|
78
|
+
};
|
|
79
|
+
export function detectMarketLanguage(filename) {
|
|
80
|
+
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
81
|
+
return LANG_MAP[ext] || 'text';
|
|
82
|
+
}
|