skilld 1.1.2 → 1.2.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/dist/_chunks/agent.mjs +8 -0
- package/dist/_chunks/agent.mjs.map +1 -1
- package/dist/_chunks/cache.mjs +3 -3
- package/dist/_chunks/cache.mjs.map +1 -1
- package/dist/_chunks/detect.mjs +6 -6
- package/dist/_chunks/detect.mjs.map +1 -1
- package/dist/_chunks/index2.d.mts +11 -2
- package/dist/_chunks/index2.d.mts.map +1 -1
- package/dist/_chunks/install.mjs +2 -2
- package/dist/_chunks/install.mjs.map +1 -1
- package/dist/_chunks/skills.mjs +5 -11
- package/dist/_chunks/skills.mjs.map +1 -1
- package/dist/_chunks/sources.mjs +789 -730
- package/dist/_chunks/sources.mjs.map +1 -1
- package/dist/_chunks/sync.mjs +2 -2
- package/dist/_chunks/sync.mjs.map +1 -1
- package/dist/_chunks/yaml.mjs +10 -1
- package/dist/_chunks/yaml.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/sources/index.d.mts +2 -2
- package/dist/sources/index.mjs +2 -2
- package/dist/types.d.mts +1 -1
- package/package.json +6 -6
package/dist/_chunks/sources.mjs
CHANGED
|
@@ -34,560 +34,292 @@ function buildFrontmatter(fields) {
|
|
|
34
34
|
lines.push("---");
|
|
35
35
|
return lines.join("\n");
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
/** Check if body contains a code block */
|
|
38
|
+
function hasCodeBlock(text) {
|
|
39
|
+
return /```[\s\S]*?```/.test(text) || /`[^`]+`/.test(text);
|
|
40
|
+
}
|
|
41
|
+
/** Noise patterns in comments — filter these out */
|
|
42
|
+
const COMMENT_NOISE_RE = /^(?:\+1|👍|same here|any update|bump|following|is there any progress|when will this|me too|i have the same|same issue|thanks|thank you)[\s!?.]*$/i;
|
|
39
43
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* Categorized by labels, noise filtered out, non-technical issues detected
|
|
44
|
+
* Smart body truncation — preserves code blocks and error messages.
|
|
45
|
+
* Instead of slicing at a char limit, finds a safe break point.
|
|
43
46
|
*/
|
|
44
|
-
|
|
47
|
+
function truncateBody(body, limit) {
|
|
48
|
+
if (body.length <= limit) return body;
|
|
49
|
+
const codeBlockRe = /```[\s\S]*?```/g;
|
|
50
|
+
let lastSafeEnd = limit;
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = codeBlockRe.exec(body)) !== null) {
|
|
53
|
+
const blockStart = match.index;
|
|
54
|
+
const blockEnd = blockStart + match[0].length;
|
|
55
|
+
if (blockStart < limit && blockEnd > limit) {
|
|
56
|
+
if (blockEnd <= limit + 500) lastSafeEnd = blockEnd;
|
|
57
|
+
else lastSafeEnd = blockStart;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const slice = body.slice(0, lastSafeEnd);
|
|
62
|
+
const lastParagraph = slice.lastIndexOf("\n\n");
|
|
63
|
+
if (lastParagraph > lastSafeEnd * .6) return `${slice.slice(0, lastParagraph)}\n\n...`;
|
|
64
|
+
return `${slice}...`;
|
|
65
|
+
}
|
|
66
|
+
let _ghToken;
|
|
45
67
|
/**
|
|
46
|
-
*
|
|
68
|
+
* Get GitHub auth token from gh CLI (cached).
|
|
69
|
+
* Returns null if gh CLI is not available or not authenticated.
|
|
47
70
|
*/
|
|
48
|
-
function
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
function getGitHubToken() {
|
|
72
|
+
if (_ghToken !== void 0) return _ghToken;
|
|
73
|
+
try {
|
|
74
|
+
const { stdout } = spawnSync("gh", ["auth", "token"], {
|
|
75
|
+
encoding: "utf-8",
|
|
76
|
+
timeout: 5e3,
|
|
77
|
+
stdio: [
|
|
78
|
+
"ignore",
|
|
79
|
+
"pipe",
|
|
80
|
+
"ignore"
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
_ghToken = stdout?.trim() || null;
|
|
84
|
+
} catch {
|
|
85
|
+
_ghToken = null;
|
|
86
|
+
}
|
|
87
|
+
return _ghToken;
|
|
88
|
+
}
|
|
89
|
+
/** Repos where ungh.cc failed but gh api succeeded (likely private) */
|
|
90
|
+
const _needsAuth = /* @__PURE__ */ new Set();
|
|
91
|
+
/** Mark a repo as needing authenticated access */
|
|
92
|
+
function markRepoPrivate(owner, repo) {
|
|
93
|
+
_needsAuth.add(`${owner}/${repo}`);
|
|
94
|
+
}
|
|
95
|
+
/** Check if a repo is known to need authenticated access */
|
|
96
|
+
function isKnownPrivateRepo(owner, repo) {
|
|
97
|
+
return _needsAuth.has(`${owner}/${repo}`);
|
|
98
|
+
}
|
|
99
|
+
const GH_API = "https://api.github.com";
|
|
100
|
+
const ghApiFetch = ofetch.create({
|
|
101
|
+
retry: 2,
|
|
102
|
+
retryDelay: 500,
|
|
103
|
+
timeout: 15e3,
|
|
104
|
+
headers: { "User-Agent": "skilld/1.0" }
|
|
105
|
+
});
|
|
106
|
+
const LINK_NEXT_RE = /<([^>]+)>;\s*rel="next"/;
|
|
107
|
+
/** Parse GitHub Link header for next page URL */
|
|
108
|
+
function parseLinkNext(header) {
|
|
109
|
+
if (!header) return null;
|
|
110
|
+
return header.match(LINK_NEXT_RE)?.[1] ?? null;
|
|
52
111
|
}
|
|
53
|
-
/** Labels that indicate noise — filter these out entirely */
|
|
54
|
-
const NOISE_LABELS = new Set([
|
|
55
|
-
"duplicate",
|
|
56
|
-
"stale",
|
|
57
|
-
"invalid",
|
|
58
|
-
"wontfix",
|
|
59
|
-
"won't fix",
|
|
60
|
-
"spam",
|
|
61
|
-
"off-topic",
|
|
62
|
-
"needs triage",
|
|
63
|
-
"triage"
|
|
64
|
-
]);
|
|
65
|
-
/** Labels that indicate feature requests — deprioritize */
|
|
66
|
-
const FEATURE_LABELS = new Set([
|
|
67
|
-
"enhancement",
|
|
68
|
-
"feature",
|
|
69
|
-
"feature request",
|
|
70
|
-
"feature-request",
|
|
71
|
-
"proposal",
|
|
72
|
-
"rfc",
|
|
73
|
-
"idea",
|
|
74
|
-
"suggestion"
|
|
75
|
-
]);
|
|
76
|
-
const BUG_LABELS = new Set([
|
|
77
|
-
"bug",
|
|
78
|
-
"defect",
|
|
79
|
-
"regression",
|
|
80
|
-
"error",
|
|
81
|
-
"crash",
|
|
82
|
-
"fix",
|
|
83
|
-
"confirmed",
|
|
84
|
-
"verified"
|
|
85
|
-
]);
|
|
86
|
-
const QUESTION_LABELS = new Set([
|
|
87
|
-
"question",
|
|
88
|
-
"help wanted",
|
|
89
|
-
"support",
|
|
90
|
-
"usage",
|
|
91
|
-
"how-to",
|
|
92
|
-
"help",
|
|
93
|
-
"assistance"
|
|
94
|
-
]);
|
|
95
|
-
const DOCS_LABELS = new Set([
|
|
96
|
-
"documentation",
|
|
97
|
-
"docs",
|
|
98
|
-
"doc",
|
|
99
|
-
"typo"
|
|
100
|
-
]);
|
|
101
112
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
113
|
+
* Authenticated fetch against api.github.com. Returns null if no token or request fails.
|
|
114
|
+
* Endpoint should be relative, e.g. `repos/owner/repo/releases`.
|
|
104
115
|
*/
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
return
|
|
116
|
+
async function ghApi(endpoint) {
|
|
117
|
+
const token = getGitHubToken();
|
|
118
|
+
if (!token) return null;
|
|
119
|
+
return ghApiFetch(`${GH_API}/${endpoint}`, { headers: { Authorization: `token ${token}` } }).catch(() => null);
|
|
108
120
|
}
|
|
109
121
|
/**
|
|
110
|
-
*
|
|
122
|
+
* Paginated GitHub API fetch. Follows Link headers, returns concatenated arrays.
|
|
123
|
+
* Endpoint should return a JSON array, e.g. `repos/owner/repo/releases`.
|
|
111
124
|
*/
|
|
112
|
-
function
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
async function ghApiPaginated(endpoint) {
|
|
126
|
+
const token = getGitHubToken();
|
|
127
|
+
if (!token) return [];
|
|
128
|
+
const headers = { Authorization: `token ${token}` };
|
|
129
|
+
const results = [];
|
|
130
|
+
let url = `${GH_API}/${endpoint}`;
|
|
131
|
+
while (url) {
|
|
132
|
+
const res = await ghApiFetch.raw(url, { headers }).catch(() => null);
|
|
133
|
+
if (!res?.ok || !Array.isArray(res._data)) break;
|
|
134
|
+
results.push(...res._data);
|
|
135
|
+
url = parseLinkNext(res.headers.get("link"));
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
119
138
|
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/sources/utils.ts
|
|
120
141
|
/**
|
|
121
|
-
*
|
|
142
|
+
* Shared utilities for doc resolution
|
|
122
143
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
144
|
+
const $fetch = ofetch.create({
|
|
145
|
+
retry: 3,
|
|
146
|
+
retryDelay: 500,
|
|
147
|
+
timeout: 15e3,
|
|
148
|
+
headers: { "User-Agent": "skilld/1.0" }
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* Fetch text content from URL
|
|
152
|
+
*/
|
|
153
|
+
async function fetchText(url) {
|
|
154
|
+
return $fetch(url, { responseType: "text" }).catch(() => null);
|
|
127
155
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
156
|
+
const RAW_GH_RE = /raw\.githubusercontent\.com\/([^/]+)\/([^/]+)/;
|
|
157
|
+
/** Extract owner/repo from a GitHub raw content URL */
|
|
158
|
+
function extractGitHubRepo(url) {
|
|
159
|
+
const match = url.match(RAW_GH_RE);
|
|
160
|
+
return match ? {
|
|
161
|
+
owner: match[1],
|
|
162
|
+
repo: match[2]
|
|
163
|
+
} : null;
|
|
131
164
|
}
|
|
132
165
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
166
|
+
* Fetch text from a GitHub raw URL with auth fallback for private repos.
|
|
167
|
+
* Tries unauthenticated first (fast path), falls back to authenticated
|
|
168
|
+
* request when the repo is known to be private or unauthenticated fails.
|
|
169
|
+
*
|
|
170
|
+
* Only sends auth tokens to raw.githubusercontent.com — returns null for
|
|
171
|
+
* non-GitHub URLs that fail unauthenticated to prevent token leakage.
|
|
136
172
|
*/
|
|
137
|
-
function
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
async function fetchGitHubRaw(url) {
|
|
174
|
+
const gh = extractGitHubRepo(url);
|
|
175
|
+
if (!(gh ? isKnownPrivateRepo(gh.owner, gh.repo) : false)) {
|
|
176
|
+
const content = await fetchText(url);
|
|
177
|
+
if (content) return content;
|
|
178
|
+
}
|
|
179
|
+
if (!gh) return null;
|
|
180
|
+
const token = getGitHubToken();
|
|
181
|
+
if (!token) return null;
|
|
182
|
+
const content = await $fetch(url, {
|
|
183
|
+
responseType: "text",
|
|
184
|
+
headers: { Authorization: `token ${token}` }
|
|
185
|
+
}).catch(() => null);
|
|
186
|
+
if (content) markRepoPrivate(gh.owner, gh.repo);
|
|
187
|
+
return content;
|
|
142
188
|
}
|
|
143
189
|
/**
|
|
144
|
-
*
|
|
145
|
-
* Steep decay so recent issues dominate over old high-reaction ones.
|
|
146
|
-
* At 0.6: 1yr=0.63x, 2yr=0.45x, 4yr=0.29x, 6yr=0.22x
|
|
190
|
+
* Verify URL exists and is not HTML (likely 404 page)
|
|
147
191
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
192
|
+
async function verifyUrl(url) {
|
|
193
|
+
const res = await $fetch.raw(url, { method: "HEAD" }).catch(() => null);
|
|
194
|
+
if (!res) return false;
|
|
195
|
+
return !(res.headers.get("content-type") || "").includes("text/html");
|
|
150
196
|
}
|
|
151
197
|
/**
|
|
152
|
-
*
|
|
153
|
-
* Bugs and questions get priority; feature requests are hard-capped.
|
|
198
|
+
* Check if URL points to a social media or package registry site (not real docs)
|
|
154
199
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
for (let i = 0; i < take; i++) {
|
|
173
|
-
selected.push(group[i]);
|
|
174
|
-
used.add(group[i].number);
|
|
175
|
-
remaining--;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (remaining > 0) {
|
|
179
|
-
const unused = issues.filter((i) => !used.has(i.number) && i.type !== "feature").sort((a, b) => b.score - a.score);
|
|
180
|
-
for (const issue of unused) {
|
|
181
|
-
if (remaining <= 0) break;
|
|
182
|
-
selected.push(issue);
|
|
183
|
-
remaining--;
|
|
184
|
-
}
|
|
200
|
+
const USELESS_HOSTS = new Set([
|
|
201
|
+
"twitter.com",
|
|
202
|
+
"x.com",
|
|
203
|
+
"facebook.com",
|
|
204
|
+
"linkedin.com",
|
|
205
|
+
"youtube.com",
|
|
206
|
+
"instagram.com",
|
|
207
|
+
"npmjs.com",
|
|
208
|
+
"www.npmjs.com",
|
|
209
|
+
"yarnpkg.com"
|
|
210
|
+
]);
|
|
211
|
+
function isUselessDocsUrl(url) {
|
|
212
|
+
try {
|
|
213
|
+
const { hostname } = new URL(url);
|
|
214
|
+
return USELESS_HOSTS.has(hostname);
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
185
217
|
}
|
|
186
|
-
return selected.sort((a, b) => b.score - a.score);
|
|
187
218
|
}
|
|
188
219
|
/**
|
|
189
|
-
*
|
|
220
|
+
* Check if URL is a GitHub repo URL (not a docs site)
|
|
190
221
|
*/
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
222
|
+
function isGitHubRepoUrl(url) {
|
|
223
|
+
try {
|
|
224
|
+
const parsed = new URL(url);
|
|
225
|
+
return parsed.hostname === "github.com" || parsed.hostname === "www.github.com";
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
195
229
|
}
|
|
196
230
|
/**
|
|
197
|
-
*
|
|
198
|
-
* Instead of slicing at a char limit, finds a safe break point.
|
|
231
|
+
* Parse owner/repo from GitHub URL
|
|
199
232
|
*/
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const blockEnd = blockStart + match[0].length;
|
|
208
|
-
if (blockStart < limit && blockEnd > limit) {
|
|
209
|
-
if (blockEnd <= limit + 500) lastSafeEnd = blockEnd;
|
|
210
|
-
else lastSafeEnd = blockStart;
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
const slice = body.slice(0, lastSafeEnd);
|
|
215
|
-
const lastParagraph = slice.lastIndexOf("\n\n");
|
|
216
|
-
if (lastParagraph > lastSafeEnd * .6) return `${slice.slice(0, lastParagraph)}\n\n...`;
|
|
217
|
-
return `${slice}...`;
|
|
233
|
+
function parseGitHubUrl(url) {
|
|
234
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:[/#]|$)/);
|
|
235
|
+
if (!match) return null;
|
|
236
|
+
return {
|
|
237
|
+
owner: match[1],
|
|
238
|
+
repo: match[2]
|
|
239
|
+
};
|
|
218
240
|
}
|
|
219
241
|
/**
|
|
220
|
-
*
|
|
242
|
+
* Normalize git repo URL to https
|
|
221
243
|
*/
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
let datePart = "";
|
|
225
|
-
if (fromDate) datePart = state === "closed" ? `+closed:>=${fromDate}` : `+created:>=${fromDate}`;
|
|
226
|
-
else if (state === "closed") if (releasedAt) {
|
|
227
|
-
const date = new Date(releasedAt);
|
|
228
|
-
date.setMonth(date.getMonth() + 6);
|
|
229
|
-
datePart = `+closed:<=${isoDate(date.toISOString())}`;
|
|
230
|
-
} else datePart = `+closed:>${oneYearAgo()}`;
|
|
231
|
-
else if (releasedAt) {
|
|
232
|
-
const date = new Date(releasedAt);
|
|
233
|
-
date.setMonth(date.getMonth() + 6);
|
|
234
|
-
datePart = `+created:<=${isoDate(date.toISOString())}`;
|
|
235
|
-
}
|
|
236
|
-
const { stdout: result } = spawnSync("gh", [
|
|
237
|
-
"api",
|
|
238
|
-
`search/issues?q=${`repo:${owner}/${repo}+is:issue+is:${state}${datePart}`}&sort=reactions&order=desc&per_page=${fetchCount}`,
|
|
239
|
-
"-q",
|
|
240
|
-
".items[] | {number, title, state, labels: [.labels[]?.name], body, createdAt: .created_at, url: .html_url, reactions: .reactions[\"+1\"], comments: .comments, user: .user.login, userType: .user.type, authorAssociation: .author_association}"
|
|
241
|
-
], {
|
|
242
|
-
encoding: "utf-8",
|
|
243
|
-
maxBuffer: 10 * 1024 * 1024
|
|
244
|
-
});
|
|
245
|
-
if (!result) return [];
|
|
246
|
-
return result.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((issue) => !BOT_USERS.has(issue.user) && issue.userType !== "Bot").filter((issue) => !isNoiseIssue(issue)).filter((issue) => !isNonTechnical(issue)).map(({ user: _, userType: __, authorAssociation, ...issue }) => {
|
|
247
|
-
const isMaintainer = [
|
|
248
|
-
"OWNER",
|
|
249
|
-
"MEMBER",
|
|
250
|
-
"COLLABORATOR"
|
|
251
|
-
].includes(authorAssociation);
|
|
252
|
-
const isRoadmap = /\broadmap\b/i.test(issue.title) || issue.labels.some((l) => /roadmap/i.test(l));
|
|
253
|
-
return {
|
|
254
|
-
...issue,
|
|
255
|
-
type: classifyIssue(issue.labels),
|
|
256
|
-
topComments: [],
|
|
257
|
-
score: freshnessScore(issue.reactions, issue.createdAt) * (isMaintainer && isRoadmap ? 5 : 1)
|
|
258
|
-
};
|
|
259
|
-
}).sort((a, b) => b.score - a.score).slice(0, count);
|
|
260
|
-
}
|
|
261
|
-
function oneYearAgo() {
|
|
262
|
-
const d = /* @__PURE__ */ new Date();
|
|
263
|
-
d.setFullYear(d.getFullYear() - 1);
|
|
264
|
-
return isoDate(d.toISOString());
|
|
244
|
+
function normalizeRepoUrl(url) {
|
|
245
|
+
return url.replace(/^git\+/, "").replace(/#.*$/, "").replace(/\.git$/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/");
|
|
265
246
|
}
|
|
266
|
-
/** Noise patterns in comments — filter these out */
|
|
267
|
-
const COMMENT_NOISE_RE$1 = /^(?:\+1|👍|same here|any update|bump|following|is there any progress|when will this|me too|i have the same|same issue)[\s!?.]*$/i;
|
|
268
247
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* Prioritizes: comments with code blocks, from maintainers, with high reactions.
|
|
272
|
-
* Filters out "+1", "any updates?", "same here" noise.
|
|
248
|
+
* Parse package spec with optional dist-tag or version: "vue@beta" → { name: "vue", tag: "beta" }
|
|
249
|
+
* Handles scoped packages: "@vue/reactivity@beta" → { name: "@vue/reactivity", tag: "beta" }
|
|
273
250
|
*/
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
`query=${query}`,
|
|
284
|
-
"-f",
|
|
285
|
-
`owner=${owner}`,
|
|
286
|
-
"-f",
|
|
287
|
-
`repo=${repo}`
|
|
288
|
-
], {
|
|
289
|
-
encoding: "utf-8",
|
|
290
|
-
maxBuffer: 10 * 1024 * 1024
|
|
291
|
-
});
|
|
292
|
-
if (!result) return;
|
|
293
|
-
const repo_ = JSON.parse(result)?.data?.repository;
|
|
294
|
-
if (!repo_) return;
|
|
295
|
-
for (let i = 0; i < worth.length; i++) {
|
|
296
|
-
const nodes = repo_[`i${i}`]?.comments?.nodes;
|
|
297
|
-
if (!Array.isArray(nodes)) continue;
|
|
298
|
-
const issue = worth[i];
|
|
299
|
-
const comments = nodes.filter((c) => c.author && !BOT_USERS.has(c.author.login)).filter((c) => !COMMENT_NOISE_RE$1.test((c.body || "").trim())).map((c) => {
|
|
300
|
-
const isMaintainer = [
|
|
301
|
-
"OWNER",
|
|
302
|
-
"MEMBER",
|
|
303
|
-
"COLLABORATOR"
|
|
304
|
-
].includes(c.authorAssociation);
|
|
305
|
-
const body = c.body || "";
|
|
306
|
-
const reactions = c.reactions?.totalCount || 0;
|
|
307
|
-
const _score = (isMaintainer ? 3 : 1) * (hasCodeBlock$1(body) ? 2 : 1) * (1 + reactions);
|
|
308
|
-
return {
|
|
309
|
-
body,
|
|
310
|
-
author: c.author.login,
|
|
311
|
-
reactions,
|
|
312
|
-
isMaintainer,
|
|
313
|
-
_score
|
|
314
|
-
};
|
|
315
|
-
}).sort((a, b) => b._score - a._score);
|
|
316
|
-
issue.topComments = comments.slice(0, 3).map(({ _score: _, ...c }) => c);
|
|
317
|
-
if (issue.state === "closed") issue.resolvedIn = detectResolvedVersion(comments);
|
|
251
|
+
function parsePackageSpec(spec) {
|
|
252
|
+
if (spec.startsWith("@")) {
|
|
253
|
+
const slashIdx = spec.indexOf("/");
|
|
254
|
+
if (slashIdx !== -1) {
|
|
255
|
+
const atIdx = spec.indexOf("@", slashIdx + 1);
|
|
256
|
+
if (atIdx !== -1) return {
|
|
257
|
+
name: spec.slice(0, atIdx),
|
|
258
|
+
tag: spec.slice(atIdx + 1)
|
|
259
|
+
};
|
|
318
260
|
}
|
|
319
|
-
|
|
261
|
+
return { name: spec };
|
|
262
|
+
}
|
|
263
|
+
const atIdx = spec.indexOf("@");
|
|
264
|
+
if (atIdx !== -1) return {
|
|
265
|
+
name: spec.slice(0, atIdx),
|
|
266
|
+
tag: spec.slice(atIdx + 1)
|
|
267
|
+
};
|
|
268
|
+
return { name: spec };
|
|
320
269
|
}
|
|
321
270
|
/**
|
|
322
|
-
*
|
|
323
|
-
* Looks for version patterns in maintainer/collaborator comments.
|
|
271
|
+
* Extract branch hint from URL fragment (e.g. "git+https://...#main" → "main")
|
|
324
272
|
*/
|
|
325
|
-
function
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const vMatch = c.body.match(/\bv?(\d+\.\d+\.\d+)\b/);
|
|
332
|
-
if (vMatch) return vMatch[1];
|
|
333
|
-
}
|
|
334
|
-
}
|
|
273
|
+
function extractBranchHint(url) {
|
|
274
|
+
const hash = url.indexOf("#");
|
|
275
|
+
if (hash === -1) return void 0;
|
|
276
|
+
const fragment = url.slice(hash + 1);
|
|
277
|
+
if (!fragment || fragment === "readme") return void 0;
|
|
278
|
+
return fragment;
|
|
335
279
|
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/sources/releases.ts
|
|
336
282
|
/**
|
|
337
|
-
*
|
|
338
|
-
* Returns a balanced mix: bugs > questions > docs > other > features.
|
|
339
|
-
* Filters noise, non-technical content, and enriches with quality comments.
|
|
283
|
+
* GitHub release notes fetching via GitHub API (preferred) with ungh.cc fallback
|
|
340
284
|
*/
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
} catch {
|
|
352
|
-
return [];
|
|
353
|
-
}
|
|
285
|
+
function parseSemver(version) {
|
|
286
|
+
const clean = version.replace(/^v/, "");
|
|
287
|
+
const match = clean.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
288
|
+
if (!match) return null;
|
|
289
|
+
return {
|
|
290
|
+
major: +match[1],
|
|
291
|
+
minor: match[2] ? +match[2] : 0,
|
|
292
|
+
patch: match[3] ? +match[3] : 0,
|
|
293
|
+
raw: clean
|
|
294
|
+
};
|
|
354
295
|
}
|
|
355
296
|
/**
|
|
356
|
-
*
|
|
297
|
+
* Extract version from a release tag, handling monorepo formats:
|
|
298
|
+
* - `pkg@1.2.3` → `1.2.3`
|
|
299
|
+
* - `pkg-v1.2.3` → `1.2.3`
|
|
300
|
+
* - `v1.2.3` → `1.2.3`
|
|
301
|
+
* - `1.2.3` → `1.2.3`
|
|
357
302
|
*/
|
|
358
|
-
function
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
state: issue.state,
|
|
365
|
-
created: isoDate(issue.createdAt),
|
|
366
|
-
url: issue.url,
|
|
367
|
-
reactions: issue.reactions,
|
|
368
|
-
comments: issue.comments
|
|
369
|
-
};
|
|
370
|
-
if (issue.resolvedIn) fmFields.resolvedIn = issue.resolvedIn;
|
|
371
|
-
if (issue.labels.length > 0) fmFields.labels = `[${issue.labels.join(", ")}]`;
|
|
372
|
-
const lines = [
|
|
373
|
-
buildFrontmatter(fmFields),
|
|
374
|
-
"",
|
|
375
|
-
`# ${issue.title}`
|
|
376
|
-
];
|
|
377
|
-
if (issue.body) {
|
|
378
|
-
const body = truncateBody$1(issue.body, limit);
|
|
379
|
-
lines.push("", body);
|
|
380
|
-
}
|
|
381
|
-
if (issue.topComments.length > 0) {
|
|
382
|
-
lines.push("", "---", "", "## Top Comments");
|
|
383
|
-
for (const c of issue.topComments) {
|
|
384
|
-
const reactions = c.reactions > 0 ? ` (+${c.reactions})` : "";
|
|
385
|
-
const maintainer = c.isMaintainer ? " [maintainer]" : "";
|
|
386
|
-
const commentBody = truncateBody$1(c.body, 600);
|
|
387
|
-
lines.push("", `**@${c.author}**${maintainer}${reactions}:`, "", commentBody);
|
|
388
|
-
}
|
|
303
|
+
function extractVersion(tag, packageName) {
|
|
304
|
+
if (packageName) {
|
|
305
|
+
const atMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}@(.+)$`));
|
|
306
|
+
if (atMatch) return atMatch[1];
|
|
307
|
+
const dashMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}-v?(.+)$`));
|
|
308
|
+
if (dashMatch) return dashMatch[1];
|
|
389
309
|
}
|
|
390
|
-
return
|
|
310
|
+
return tag.replace(/^v/, "");
|
|
311
|
+
}
|
|
312
|
+
function escapeRegex(str) {
|
|
313
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
391
314
|
}
|
|
392
315
|
/**
|
|
393
|
-
*
|
|
394
|
-
* Groups by type so the LLM can quickly find bugs vs questions.
|
|
316
|
+
* Check if a release tag belongs to a specific package
|
|
395
317
|
*/
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
|
|
399
|
-
const typeLabels = {
|
|
400
|
-
bug: "Bugs & Regressions",
|
|
401
|
-
question: "Questions & Usage Help",
|
|
402
|
-
docs: "Documentation",
|
|
403
|
-
feature: "Feature Requests",
|
|
404
|
-
other: "Other"
|
|
405
|
-
};
|
|
406
|
-
const typeOrder = [
|
|
407
|
-
"bug",
|
|
408
|
-
"question",
|
|
409
|
-
"docs",
|
|
410
|
-
"other",
|
|
411
|
-
"feature"
|
|
412
|
-
];
|
|
413
|
-
const sections = [
|
|
414
|
-
[
|
|
415
|
-
"---",
|
|
416
|
-
`total: ${issues.length}`,
|
|
417
|
-
`open: ${issues.filter((i) => i.state === "open").length}`,
|
|
418
|
-
`closed: ${issues.filter((i) => i.state !== "open").length}`,
|
|
419
|
-
"---"
|
|
420
|
-
].join("\n"),
|
|
421
|
-
"",
|
|
422
|
-
"# Issues Index",
|
|
423
|
-
""
|
|
424
|
-
];
|
|
425
|
-
for (const type of typeOrder) {
|
|
426
|
-
const group = byType.get(type);
|
|
427
|
-
if (!group?.length) continue;
|
|
428
|
-
sections.push(`## ${typeLabels[type]} (${group.length})`, "");
|
|
429
|
-
for (const issue of group) {
|
|
430
|
-
const reactions = issue.reactions > 0 ? ` (+${issue.reactions})` : "";
|
|
431
|
-
const state = issue.state === "open" ? "" : " [closed]";
|
|
432
|
-
const resolved = issue.resolvedIn ? ` [fixed in ${issue.resolvedIn}]` : "";
|
|
433
|
-
const date = isoDate(issue.createdAt);
|
|
434
|
-
sections.push(`- [#${issue.number}](./issue-${issue.number}.md): ${issue.title}${reactions}${state}${resolved} (${date})`);
|
|
435
|
-
}
|
|
436
|
-
sections.push("");
|
|
437
|
-
}
|
|
438
|
-
return sections.join("\n");
|
|
318
|
+
function tagMatchesPackage(tag, packageName) {
|
|
319
|
+
return tag.startsWith(`${packageName}@`) || tag.startsWith(`${packageName}-v`) || tag.startsWith(`${packageName}-`);
|
|
439
320
|
}
|
|
440
|
-
//#endregion
|
|
441
|
-
//#region src/sources/utils.ts
|
|
442
321
|
/**
|
|
443
|
-
*
|
|
444
|
-
*/
|
|
445
|
-
const $fetch = ofetch.create({
|
|
446
|
-
retry: 3,
|
|
447
|
-
retryDelay: 500,
|
|
448
|
-
timeout: 15e3,
|
|
449
|
-
headers: { "User-Agent": "skilld/1.0" }
|
|
450
|
-
});
|
|
451
|
-
/**
|
|
452
|
-
* Fetch text content from URL
|
|
453
|
-
*/
|
|
454
|
-
async function fetchText(url) {
|
|
455
|
-
return $fetch(url, { responseType: "text" }).catch(() => null);
|
|
456
|
-
}
|
|
457
|
-
/**
|
|
458
|
-
* Verify URL exists and is not HTML (likely 404 page)
|
|
459
|
-
*/
|
|
460
|
-
async function verifyUrl(url) {
|
|
461
|
-
const res = await $fetch.raw(url, { method: "HEAD" }).catch(() => null);
|
|
462
|
-
if (!res) return false;
|
|
463
|
-
return !(res.headers.get("content-type") || "").includes("text/html");
|
|
464
|
-
}
|
|
465
|
-
/**
|
|
466
|
-
* Check if URL points to a social media or package registry site (not real docs)
|
|
467
|
-
*/
|
|
468
|
-
const USELESS_HOSTS = new Set([
|
|
469
|
-
"twitter.com",
|
|
470
|
-
"x.com",
|
|
471
|
-
"facebook.com",
|
|
472
|
-
"linkedin.com",
|
|
473
|
-
"youtube.com",
|
|
474
|
-
"instagram.com",
|
|
475
|
-
"npmjs.com",
|
|
476
|
-
"www.npmjs.com",
|
|
477
|
-
"yarnpkg.com"
|
|
478
|
-
]);
|
|
479
|
-
function isUselessDocsUrl(url) {
|
|
480
|
-
try {
|
|
481
|
-
const { hostname } = new URL(url);
|
|
482
|
-
return USELESS_HOSTS.has(hostname);
|
|
483
|
-
} catch {
|
|
484
|
-
return false;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* Check if URL is a GitHub repo URL (not a docs site)
|
|
489
|
-
*/
|
|
490
|
-
function isGitHubRepoUrl(url) {
|
|
491
|
-
try {
|
|
492
|
-
const parsed = new URL(url);
|
|
493
|
-
return parsed.hostname === "github.com" || parsed.hostname === "www.github.com";
|
|
494
|
-
} catch {
|
|
495
|
-
return false;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Parse owner/repo from GitHub URL
|
|
500
|
-
*/
|
|
501
|
-
function parseGitHubUrl(url) {
|
|
502
|
-
const match = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:[/#]|$)/);
|
|
503
|
-
if (!match) return null;
|
|
504
|
-
return {
|
|
505
|
-
owner: match[1],
|
|
506
|
-
repo: match[2]
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
/**
|
|
510
|
-
* Normalize git repo URL to https
|
|
511
|
-
*/
|
|
512
|
-
function normalizeRepoUrl(url) {
|
|
513
|
-
return url.replace(/^git\+/, "").replace(/#.*$/, "").replace(/\.git$/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/");
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* Parse package spec with optional dist-tag or version: "vue@beta" → { name: "vue", tag: "beta" }
|
|
517
|
-
* Handles scoped packages: "@vue/reactivity@beta" → { name: "@vue/reactivity", tag: "beta" }
|
|
518
|
-
*/
|
|
519
|
-
function parsePackageSpec(spec) {
|
|
520
|
-
if (spec.startsWith("@")) {
|
|
521
|
-
const slashIdx = spec.indexOf("/");
|
|
522
|
-
if (slashIdx !== -1) {
|
|
523
|
-
const atIdx = spec.indexOf("@", slashIdx + 1);
|
|
524
|
-
if (atIdx !== -1) return {
|
|
525
|
-
name: spec.slice(0, atIdx),
|
|
526
|
-
tag: spec.slice(atIdx + 1)
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
return { name: spec };
|
|
530
|
-
}
|
|
531
|
-
const atIdx = spec.indexOf("@");
|
|
532
|
-
if (atIdx !== -1) return {
|
|
533
|
-
name: spec.slice(0, atIdx),
|
|
534
|
-
tag: spec.slice(atIdx + 1)
|
|
535
|
-
};
|
|
536
|
-
return { name: spec };
|
|
537
|
-
}
|
|
538
|
-
/**
|
|
539
|
-
* Extract branch hint from URL fragment (e.g. "git+https://...#main" → "main")
|
|
540
|
-
*/
|
|
541
|
-
function extractBranchHint(url) {
|
|
542
|
-
const hash = url.indexOf("#");
|
|
543
|
-
if (hash === -1) return void 0;
|
|
544
|
-
const fragment = url.slice(hash + 1);
|
|
545
|
-
if (!fragment || fragment === "readme") return void 0;
|
|
546
|
-
return fragment;
|
|
547
|
-
}
|
|
548
|
-
//#endregion
|
|
549
|
-
//#region src/sources/releases.ts
|
|
550
|
-
/**
|
|
551
|
-
* GitHub release notes fetching via gh CLI (preferred) with ungh.cc fallback
|
|
552
|
-
*/
|
|
553
|
-
function parseSemver(version) {
|
|
554
|
-
const clean = version.replace(/^v/, "");
|
|
555
|
-
const match = clean.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
556
|
-
if (!match) return null;
|
|
557
|
-
return {
|
|
558
|
-
major: +match[1],
|
|
559
|
-
minor: match[2] ? +match[2] : 0,
|
|
560
|
-
patch: match[3] ? +match[3] : 0,
|
|
561
|
-
raw: clean
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Extract version from a release tag, handling monorepo formats:
|
|
566
|
-
* - `pkg@1.2.3` → `1.2.3`
|
|
567
|
-
* - `pkg-v1.2.3` → `1.2.3`
|
|
568
|
-
* - `v1.2.3` → `1.2.3`
|
|
569
|
-
* - `1.2.3` → `1.2.3`
|
|
570
|
-
*/
|
|
571
|
-
function extractVersion(tag, packageName) {
|
|
572
|
-
if (packageName) {
|
|
573
|
-
const atMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}@(.+)$`));
|
|
574
|
-
if (atMatch) return atMatch[1];
|
|
575
|
-
const dashMatch = tag.match(new RegExp(`^${escapeRegex(packageName)}-v?(.+)$`));
|
|
576
|
-
if (dashMatch) return dashMatch[1];
|
|
577
|
-
}
|
|
578
|
-
return tag.replace(/^v/, "");
|
|
579
|
-
}
|
|
580
|
-
function escapeRegex(str) {
|
|
581
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Check if a release tag belongs to a specific package
|
|
585
|
-
*/
|
|
586
|
-
function tagMatchesPackage(tag, packageName) {
|
|
587
|
-
return tag.startsWith(`${packageName}@`) || tag.startsWith(`${packageName}-v`) || tag.startsWith(`${packageName}-`);
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Check if a version string contains a prerelease suffix (e.g. 6.0.0-beta, 1.2.3-rc.1)
|
|
322
|
+
* Check if a version string contains a prerelease suffix (e.g. 6.0.0-beta, 1.2.3-rc.1)
|
|
591
323
|
*/
|
|
592
324
|
function isPrerelease(version) {
|
|
593
325
|
return /^\d+\.\d+\.\d+-.+/.test(version.replace(/^v/, ""));
|
|
@@ -597,47 +329,25 @@ function compareSemver(a, b) {
|
|
|
597
329
|
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
598
330
|
return a.patch - b.patch;
|
|
599
331
|
}
|
|
600
|
-
/**
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
], {
|
|
612
|
-
encoding: "utf-8",
|
|
613
|
-
timeout: 3e4,
|
|
614
|
-
stdio: [
|
|
615
|
-
"ignore",
|
|
616
|
-
"pipe",
|
|
617
|
-
"ignore"
|
|
618
|
-
]
|
|
619
|
-
});
|
|
620
|
-
if (!ndjson) return [];
|
|
621
|
-
return ndjson.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
622
|
-
} catch {
|
|
623
|
-
return [];
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
/**
|
|
627
|
-
* Fetch all releases from a GitHub repo via ungh.cc (fallback)
|
|
628
|
-
*/
|
|
629
|
-
async function fetchReleasesViaUngh(owner, repo) {
|
|
630
|
-
return (await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, { signal: AbortSignal.timeout(15e3) }).catch(() => null))?.releases ?? [];
|
|
332
|
+
/** Map GitHub API release to our GitHubRelease shape */
|
|
333
|
+
function mapApiRelease(r) {
|
|
334
|
+
return {
|
|
335
|
+
id: r.id,
|
|
336
|
+
tag: r.tag_name,
|
|
337
|
+
name: r.name,
|
|
338
|
+
prerelease: r.prerelease,
|
|
339
|
+
createdAt: r.created_at,
|
|
340
|
+
publishedAt: r.published_at,
|
|
341
|
+
markdown: r.body
|
|
342
|
+
};
|
|
631
343
|
}
|
|
632
344
|
/**
|
|
633
|
-
* Fetch all releases —
|
|
345
|
+
* Fetch all releases — GitHub API first (authenticated, async), ungh.cc fallback
|
|
634
346
|
*/
|
|
635
347
|
async function fetchAllReleases(owner, repo) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
return fetchReleasesViaUngh(owner, repo);
|
|
348
|
+
const apiReleases = await ghApiPaginated(`repos/${owner}/${repo}/releases`);
|
|
349
|
+
if (apiReleases.length > 0) return apiReleases.map(mapApiRelease);
|
|
350
|
+
return (await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, { signal: AbortSignal.timeout(15e3) }).catch(() => null))?.releases ?? [];
|
|
641
351
|
}
|
|
642
352
|
/**
|
|
643
353
|
* Select last 20 stable releases for a package, sorted newest first.
|
|
@@ -757,10 +467,7 @@ async function fetchChangelog(owner, repo, ref, packageName) {
|
|
|
757
467
|
}
|
|
758
468
|
paths.push("CHANGELOG.md", "changelog.md", "CHANGES.md");
|
|
759
469
|
for (const path of paths) {
|
|
760
|
-
const content = await
|
|
761
|
-
responseType: "text",
|
|
762
|
-
signal: AbortSignal.timeout(1e4)
|
|
763
|
-
}).catch(() => null);
|
|
470
|
+
const content = await fetchGitHubRaw(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`);
|
|
764
471
|
if (content) return content;
|
|
765
472
|
}
|
|
766
473
|
return null;
|
|
@@ -887,116 +594,490 @@ async function fetchBlogReleases(packageName, installedVersion) {
|
|
|
887
594
|
content: formatBlogRelease(r)
|
|
888
595
|
}));
|
|
889
596
|
}
|
|
890
|
-
//#endregion
|
|
891
|
-
//#region src/sources/crawl.ts
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/sources/crawl.ts
|
|
599
|
+
/**
|
|
600
|
+
* Website crawl doc source — fetches docs by crawling a URL pattern
|
|
601
|
+
*/
|
|
602
|
+
/**
|
|
603
|
+
* Crawl a URL pattern and return docs as cached doc format.
|
|
604
|
+
* Uses HTTP crawler (no browser needed) with sitemap discovery + glob filtering.
|
|
605
|
+
*
|
|
606
|
+
* @param url - URL with optional glob pattern (e.g. 'https://example.com/docs/**')
|
|
607
|
+
* @param onProgress - Optional progress callback
|
|
608
|
+
* @param maxPages - Max pages to crawl (default 200)
|
|
609
|
+
*/
|
|
610
|
+
async function fetchCrawledDocs(url, onProgress, maxPages = 200) {
|
|
611
|
+
const outputDir = join(tmpdir(), "skilld-crawl", Date.now().toString());
|
|
612
|
+
onProgress?.(`Crawling ${url}`);
|
|
613
|
+
const userLang = getUserLang();
|
|
614
|
+
const foreignUrls = /* @__PURE__ */ new Set();
|
|
615
|
+
const doCrawl = () => crawlAndGenerate({
|
|
616
|
+
urls: [url],
|
|
617
|
+
outputDir,
|
|
618
|
+
driver: "http",
|
|
619
|
+
generateLlmsTxt: false,
|
|
620
|
+
generateIndividualMd: true,
|
|
621
|
+
maxRequestsPerCrawl: maxPages,
|
|
622
|
+
onPage: (page) => {
|
|
623
|
+
const lang = extractHtmlLang(page.html);
|
|
624
|
+
if (lang && !lang.startsWith("en") && !lang.startsWith(userLang)) foreignUrls.add(page.url);
|
|
625
|
+
}
|
|
626
|
+
}, (progress) => {
|
|
627
|
+
if (progress.crawling.status === "processing" && progress.crawling.total > 0) onProgress?.(`Crawling ${progress.crawling.processed}/${progress.crawling.total} pages`);
|
|
628
|
+
});
|
|
629
|
+
let results = await doCrawl().catch((err) => {
|
|
630
|
+
onProgress?.(`Crawl failed: ${err?.message || err}`);
|
|
631
|
+
return [];
|
|
632
|
+
});
|
|
633
|
+
if (results.length === 0) {
|
|
634
|
+
onProgress?.("Retrying crawl");
|
|
635
|
+
results = await doCrawl().catch(() => []);
|
|
636
|
+
}
|
|
637
|
+
rmSync(outputDir, {
|
|
638
|
+
recursive: true,
|
|
639
|
+
force: true
|
|
640
|
+
});
|
|
641
|
+
const docs = [];
|
|
642
|
+
let localeFiltered = 0;
|
|
643
|
+
for (const result of results) {
|
|
644
|
+
if (!result.success || !result.content) continue;
|
|
645
|
+
if (foreignUrls.has(result.url)) {
|
|
646
|
+
localeFiltered++;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const segments = (new URL(result.url).pathname.replace(/\/$/, "") || "/index").split("/").filter(Boolean);
|
|
650
|
+
if (isForeignPathPrefix(segments[0], userLang)) {
|
|
651
|
+
localeFiltered++;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const path = `docs/${segments.join("/")}.md`;
|
|
655
|
+
docs.push({
|
|
656
|
+
path,
|
|
657
|
+
content: result.content
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (localeFiltered > 0) onProgress?.(`Filtered ${localeFiltered} foreign locale pages`);
|
|
661
|
+
onProgress?.(`Crawled ${docs.length} pages`);
|
|
662
|
+
return docs;
|
|
663
|
+
}
|
|
664
|
+
const HTML_LANG_RE = /<html[^>]*\slang=["']([^"']+)["']/i;
|
|
665
|
+
/** Extract lang attribute from <html> tag */
|
|
666
|
+
function extractHtmlLang(html) {
|
|
667
|
+
return HTML_LANG_RE.exec(html)?.[1]?.toLowerCase();
|
|
668
|
+
}
|
|
669
|
+
/** Common ISO 639-1 locale codes for i18n'd doc sites */
|
|
670
|
+
const LOCALE_CODES = new Set([
|
|
671
|
+
"ar",
|
|
672
|
+
"de",
|
|
673
|
+
"es",
|
|
674
|
+
"fr",
|
|
675
|
+
"id",
|
|
676
|
+
"it",
|
|
677
|
+
"ja",
|
|
678
|
+
"ko",
|
|
679
|
+
"nl",
|
|
680
|
+
"pl",
|
|
681
|
+
"pt",
|
|
682
|
+
"pt-br",
|
|
683
|
+
"ru",
|
|
684
|
+
"th",
|
|
685
|
+
"tr",
|
|
686
|
+
"uk",
|
|
687
|
+
"vi",
|
|
688
|
+
"zh",
|
|
689
|
+
"zh-cn",
|
|
690
|
+
"zh-tw"
|
|
691
|
+
]);
|
|
692
|
+
/** Check if a URL path segment is a known locale prefix foreign to both English and user's locale */
|
|
693
|
+
function isForeignPathPrefix(segment, userLang) {
|
|
694
|
+
if (!segment) return false;
|
|
695
|
+
const lower = segment.toLowerCase();
|
|
696
|
+
if (lower === "en" || lower.startsWith(userLang)) return false;
|
|
697
|
+
return LOCALE_CODES.has(lower);
|
|
698
|
+
}
|
|
699
|
+
/** Detect user's 2-letter language code from env (e.g. 'ja' from LANG=ja_JP.UTF-8) */
|
|
700
|
+
function getUserLang() {
|
|
701
|
+
const code = (process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "").split(/[_.:-]/)[0]?.toLowerCase() || "";
|
|
702
|
+
return code.length >= 2 ? code.slice(0, 2) : "en";
|
|
703
|
+
}
|
|
704
|
+
/** Append glob pattern to a docs URL for crawling */
|
|
705
|
+
function toCrawlPattern(docsUrl) {
|
|
706
|
+
return `${docsUrl.replace(/\/+$/, "")}/**`;
|
|
707
|
+
}
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/sources/issues.ts
|
|
710
|
+
/**
|
|
711
|
+
* GitHub issues fetching via gh CLI Search API
|
|
712
|
+
* Freshness-weighted scoring, type quotas, comment quality filtering
|
|
713
|
+
* Categorized by labels, noise filtered out, non-technical issues detected
|
|
714
|
+
*/
|
|
715
|
+
let _ghAvailable;
|
|
716
|
+
/**
|
|
717
|
+
* Check if gh CLI is installed and authenticated (cached)
|
|
718
|
+
*/
|
|
719
|
+
function isGhAvailable() {
|
|
720
|
+
if (_ghAvailable !== void 0) return _ghAvailable;
|
|
721
|
+
const { status } = spawnSync("gh", ["auth", "status"], { stdio: "ignore" });
|
|
722
|
+
return _ghAvailable = status === 0;
|
|
723
|
+
}
|
|
724
|
+
/** Labels that indicate noise — filter these out entirely */
|
|
725
|
+
const NOISE_LABELS = new Set([
|
|
726
|
+
"duplicate",
|
|
727
|
+
"stale",
|
|
728
|
+
"invalid",
|
|
729
|
+
"wontfix",
|
|
730
|
+
"won't fix",
|
|
731
|
+
"spam",
|
|
732
|
+
"off-topic",
|
|
733
|
+
"needs triage",
|
|
734
|
+
"triage"
|
|
735
|
+
]);
|
|
736
|
+
/** Labels that indicate feature requests — deprioritize */
|
|
737
|
+
const FEATURE_LABELS = new Set([
|
|
738
|
+
"enhancement",
|
|
739
|
+
"feature",
|
|
740
|
+
"feature request",
|
|
741
|
+
"feature-request",
|
|
742
|
+
"proposal",
|
|
743
|
+
"rfc",
|
|
744
|
+
"idea",
|
|
745
|
+
"suggestion"
|
|
746
|
+
]);
|
|
747
|
+
const BUG_LABELS = new Set([
|
|
748
|
+
"bug",
|
|
749
|
+
"defect",
|
|
750
|
+
"regression",
|
|
751
|
+
"error",
|
|
752
|
+
"crash",
|
|
753
|
+
"fix",
|
|
754
|
+
"confirmed",
|
|
755
|
+
"verified"
|
|
756
|
+
]);
|
|
757
|
+
const QUESTION_LABELS = new Set([
|
|
758
|
+
"question",
|
|
759
|
+
"help wanted",
|
|
760
|
+
"support",
|
|
761
|
+
"usage",
|
|
762
|
+
"how-to",
|
|
763
|
+
"help",
|
|
764
|
+
"assistance"
|
|
765
|
+
]);
|
|
766
|
+
const DOCS_LABELS = new Set([
|
|
767
|
+
"documentation",
|
|
768
|
+
"docs",
|
|
769
|
+
"doc",
|
|
770
|
+
"typo"
|
|
771
|
+
]);
|
|
772
|
+
/**
|
|
773
|
+
* Check if a label contains any keyword from a set.
|
|
774
|
+
* Handles emoji-prefixed labels like ":sparkles: feature request" or ":lady_beetle: bug".
|
|
775
|
+
*/
|
|
776
|
+
function labelMatchesAny(label, keywords) {
|
|
777
|
+
for (const keyword of keywords) if (label === keyword || label.includes(keyword)) return true;
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Classify an issue by its labels into a type useful for skill generation
|
|
782
|
+
*/
|
|
783
|
+
function classifyIssue(labels) {
|
|
784
|
+
const lower = labels.map((l) => l.toLowerCase());
|
|
785
|
+
if (lower.some((l) => labelMatchesAny(l, BUG_LABELS))) return "bug";
|
|
786
|
+
if (lower.some((l) => labelMatchesAny(l, QUESTION_LABELS))) return "question";
|
|
787
|
+
if (lower.some((l) => labelMatchesAny(l, DOCS_LABELS))) return "docs";
|
|
788
|
+
if (lower.some((l) => labelMatchesAny(l, FEATURE_LABELS))) return "feature";
|
|
789
|
+
return "other";
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Check if an issue should be filtered out entirely
|
|
793
|
+
*/
|
|
794
|
+
function isNoiseIssue(issue) {
|
|
795
|
+
if (issue.labels.map((l) => l.toLowerCase()).some((l) => labelMatchesAny(l, NOISE_LABELS))) return true;
|
|
796
|
+
if (issue.title.startsWith("☂️") || issue.title.startsWith("[META]") || issue.title.startsWith("[Tracking]")) return true;
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Detect non-technical issues: fan mail, showcases, sentiment.
|
|
801
|
+
* Short body + no code + high reactions = likely non-technical.
|
|
802
|
+
* Note: roadmap/tracking issues are NOT filtered — they get score-boosted instead.
|
|
803
|
+
*/
|
|
804
|
+
function isNonTechnical(issue) {
|
|
805
|
+
const body = (issue.body || "").trim();
|
|
806
|
+
if (body.length < 200 && !hasCodeBlock(body) && issue.reactions > 50) return true;
|
|
807
|
+
if (/\b(?:love|thank|awesome|great work)\b/i.test(issue.title) && !hasCodeBlock(body)) return true;
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Freshness-weighted score: reactions * decay(age_in_years)
|
|
812
|
+
* Steep decay so recent issues dominate over old high-reaction ones.
|
|
813
|
+
* At 0.6: 1yr=0.63x, 2yr=0.45x, 4yr=0.29x, 6yr=0.22x
|
|
814
|
+
*/
|
|
815
|
+
function freshnessScore(reactions, createdAt) {
|
|
816
|
+
return reactions * (1 / (1 + (Date.now() - new Date(createdAt).getTime()) / (365.25 * 24 * 60 * 60 * 1e3) * .6));
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Type quotas — guarantee a mix of issue types.
|
|
820
|
+
* Bugs and questions get priority; feature requests are hard-capped.
|
|
821
|
+
*/
|
|
822
|
+
function applyTypeQuotas(issues, limit) {
|
|
823
|
+
const byType = /* @__PURE__ */ new Map();
|
|
824
|
+
for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
|
|
825
|
+
for (const group of byType.values()) group.sort((a, b) => b.score - a.score);
|
|
826
|
+
const quotas = [
|
|
827
|
+
["bug", Math.ceil(limit * .4)],
|
|
828
|
+
["question", Math.ceil(limit * .3)],
|
|
829
|
+
["docs", Math.ceil(limit * .15)],
|
|
830
|
+
["feature", Math.ceil(limit * .1)],
|
|
831
|
+
["other", Math.ceil(limit * .05)]
|
|
832
|
+
];
|
|
833
|
+
const selected = [];
|
|
834
|
+
const used = /* @__PURE__ */ new Set();
|
|
835
|
+
let remaining = limit;
|
|
836
|
+
for (const [type, quota] of quotas) {
|
|
837
|
+
const group = byType.get(type) || [];
|
|
838
|
+
const take = Math.min(quota, group.length, remaining);
|
|
839
|
+
for (let i = 0; i < take; i++) {
|
|
840
|
+
selected.push(group[i]);
|
|
841
|
+
used.add(group[i].number);
|
|
842
|
+
remaining--;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (remaining > 0) {
|
|
846
|
+
const unused = issues.filter((i) => !used.has(i.number) && i.type !== "feature").sort((a, b) => b.score - a.score);
|
|
847
|
+
for (const issue of unused) {
|
|
848
|
+
if (remaining <= 0) break;
|
|
849
|
+
selected.push(issue);
|
|
850
|
+
remaining--;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return selected.sort((a, b) => b.score - a.score);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Body truncation limit based on reactions — high-reaction issues deserve more space
|
|
857
|
+
*/
|
|
858
|
+
function bodyLimit(reactions) {
|
|
859
|
+
if (reactions >= 10) return 2e3;
|
|
860
|
+
if (reactions >= 5) return 1500;
|
|
861
|
+
return 800;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Fetch issues for a state using GitHub Search API sorted by reactions
|
|
865
|
+
*/
|
|
866
|
+
function fetchIssuesByState(owner, repo, state, count, releasedAt, fromDate) {
|
|
867
|
+
const fetchCount = Math.min(count * 3, 100);
|
|
868
|
+
let datePart = "";
|
|
869
|
+
if (fromDate) datePart = state === "closed" ? `+closed:>=${fromDate}` : `+created:>=${fromDate}`;
|
|
870
|
+
else if (state === "closed") if (releasedAt) {
|
|
871
|
+
const date = new Date(releasedAt);
|
|
872
|
+
date.setMonth(date.getMonth() + 6);
|
|
873
|
+
datePart = `+closed:<=${isoDate(date.toISOString())}`;
|
|
874
|
+
} else datePart = `+closed:>${oneYearAgo()}`;
|
|
875
|
+
else if (releasedAt) {
|
|
876
|
+
const date = new Date(releasedAt);
|
|
877
|
+
date.setMonth(date.getMonth() + 6);
|
|
878
|
+
datePart = `+created:<=${isoDate(date.toISOString())}`;
|
|
879
|
+
}
|
|
880
|
+
const { stdout: result } = spawnSync("gh", [
|
|
881
|
+
"api",
|
|
882
|
+
`search/issues?q=${`repo:${owner}/${repo}+is:issue+is:${state}${datePart}`}&sort=reactions&order=desc&per_page=${fetchCount}`,
|
|
883
|
+
"-q",
|
|
884
|
+
".items[] | {number, title, state, labels: [.labels[]?.name], body, createdAt: .created_at, url: .html_url, reactions: .reactions[\"+1\"], comments: .comments, user: .user.login, userType: .user.type, authorAssociation: .author_association}"
|
|
885
|
+
], {
|
|
886
|
+
encoding: "utf-8",
|
|
887
|
+
maxBuffer: 10 * 1024 * 1024
|
|
888
|
+
});
|
|
889
|
+
if (!result) return [];
|
|
890
|
+
return result.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((issue) => !BOT_USERS.has(issue.user) && issue.userType !== "Bot").filter((issue) => !isNoiseIssue(issue)).filter((issue) => !isNonTechnical(issue)).map(({ user: _, userType: __, authorAssociation, ...issue }) => {
|
|
891
|
+
const isMaintainer = [
|
|
892
|
+
"OWNER",
|
|
893
|
+
"MEMBER",
|
|
894
|
+
"COLLABORATOR"
|
|
895
|
+
].includes(authorAssociation);
|
|
896
|
+
const isRoadmap = /\broadmap\b/i.test(issue.title) || issue.labels.some((l) => /roadmap/i.test(l));
|
|
897
|
+
return {
|
|
898
|
+
...issue,
|
|
899
|
+
type: classifyIssue(issue.labels),
|
|
900
|
+
topComments: [],
|
|
901
|
+
score: freshnessScore(issue.reactions, issue.createdAt) * (isMaintainer && isRoadmap ? 5 : 1)
|
|
902
|
+
};
|
|
903
|
+
}).sort((a, b) => b.score - a.score).slice(0, count);
|
|
904
|
+
}
|
|
905
|
+
function oneYearAgo() {
|
|
906
|
+
const d = /* @__PURE__ */ new Date();
|
|
907
|
+
d.setFullYear(d.getFullYear() - 1);
|
|
908
|
+
return isoDate(d.toISOString());
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Batch-fetch top comments for issues via GraphQL.
|
|
912
|
+
* Enriches the top N highest-score issues with their best comments.
|
|
913
|
+
* Prioritizes: comments with code blocks, from maintainers, with high reactions.
|
|
914
|
+
* Filters out "+1", "any updates?", "same here" noise.
|
|
915
|
+
*/
|
|
916
|
+
function enrichWithComments(owner, repo, issues, topN = 15) {
|
|
917
|
+
const worth = issues.filter((i) => i.comments > 0 && (i.type === "bug" || i.type === "question" || i.reactions >= 3)).sort((a, b) => b.score - a.score).slice(0, topN);
|
|
918
|
+
if (worth.length === 0) return;
|
|
919
|
+
const query = `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { ${worth.map((issue, i) => `i${i}: issue(number: ${issue.number}) { comments(first: 10) { nodes { body author { login } authorAssociation reactions { totalCount } } } }`).join(" ")} } }`;
|
|
920
|
+
try {
|
|
921
|
+
const { stdout: result } = spawnSync("gh", [
|
|
922
|
+
"api",
|
|
923
|
+
"graphql",
|
|
924
|
+
"-f",
|
|
925
|
+
`query=${query}`,
|
|
926
|
+
"-f",
|
|
927
|
+
`owner=${owner}`,
|
|
928
|
+
"-f",
|
|
929
|
+
`repo=${repo}`
|
|
930
|
+
], {
|
|
931
|
+
encoding: "utf-8",
|
|
932
|
+
maxBuffer: 10 * 1024 * 1024
|
|
933
|
+
});
|
|
934
|
+
if (!result) return;
|
|
935
|
+
const repo_ = JSON.parse(result)?.data?.repository;
|
|
936
|
+
if (!repo_) return;
|
|
937
|
+
for (let i = 0; i < worth.length; i++) {
|
|
938
|
+
const nodes = repo_[`i${i}`]?.comments?.nodes;
|
|
939
|
+
if (!Array.isArray(nodes)) continue;
|
|
940
|
+
const issue = worth[i];
|
|
941
|
+
const comments = nodes.filter((c) => c.author && !BOT_USERS.has(c.author.login)).filter((c) => !COMMENT_NOISE_RE.test((c.body || "").trim())).map((c) => {
|
|
942
|
+
const isMaintainer = [
|
|
943
|
+
"OWNER",
|
|
944
|
+
"MEMBER",
|
|
945
|
+
"COLLABORATOR"
|
|
946
|
+
].includes(c.authorAssociation);
|
|
947
|
+
const body = c.body || "";
|
|
948
|
+
const reactions = c.reactions?.totalCount || 0;
|
|
949
|
+
const _score = (isMaintainer ? 3 : 1) * (hasCodeBlock(body) ? 2 : 1) * (1 + reactions);
|
|
950
|
+
return {
|
|
951
|
+
body,
|
|
952
|
+
author: c.author.login,
|
|
953
|
+
reactions,
|
|
954
|
+
isMaintainer,
|
|
955
|
+
_score
|
|
956
|
+
};
|
|
957
|
+
}).sort((a, b) => b._score - a._score);
|
|
958
|
+
issue.topComments = comments.slice(0, 3).map(({ _score: _, ...c }) => c);
|
|
959
|
+
if (issue.state === "closed") issue.resolvedIn = detectResolvedVersion(comments);
|
|
960
|
+
}
|
|
961
|
+
} catch {}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Try to detect which version fixed a closed issue from maintainer comments.
|
|
965
|
+
* Looks for version patterns in maintainer/collaborator comments.
|
|
966
|
+
*/
|
|
967
|
+
function detectResolvedVersion(comments) {
|
|
968
|
+
const maintainerComments = comments.filter((c) => c.isMaintainer);
|
|
969
|
+
for (const c of maintainerComments.reverse()) {
|
|
970
|
+
const match = c.body.match(/(?:fixed|landed|released|available|shipped|resolved|included)\s+in\s+v?(\d+\.\d+(?:\.\d+)?)/i);
|
|
971
|
+
if (match) return match[1];
|
|
972
|
+
if (c.body.length < 100) {
|
|
973
|
+
const vMatch = c.body.match(/\bv?(\d+\.\d+\.\d+)\b/);
|
|
974
|
+
if (vMatch) return vMatch[1];
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
892
978
|
/**
|
|
893
|
-
*
|
|
979
|
+
* Fetch issues from a GitHub repo with freshness-weighted scoring and type quotas.
|
|
980
|
+
* Returns a balanced mix: bugs > questions > docs > other > features.
|
|
981
|
+
* Filters noise, non-technical content, and enriches with quality comments.
|
|
894
982
|
*/
|
|
983
|
+
async function fetchGitHubIssues(owner, repo, limit = 30, releasedAt, fromDate) {
|
|
984
|
+
if (!isGhAvailable()) return [];
|
|
985
|
+
const openCount = Math.ceil(limit * .75);
|
|
986
|
+
const closedCount = limit - openCount;
|
|
987
|
+
try {
|
|
988
|
+
const open = fetchIssuesByState(owner, repo, "open", Math.min(openCount * 2, 100), releasedAt, fromDate);
|
|
989
|
+
const closed = fetchIssuesByState(owner, repo, "closed", Math.min(closedCount * 2, 50), releasedAt, fromDate);
|
|
990
|
+
const selected = applyTypeQuotas([...open, ...closed], limit);
|
|
991
|
+
enrichWithComments(owner, repo, selected);
|
|
992
|
+
return selected;
|
|
993
|
+
} catch {
|
|
994
|
+
return [];
|
|
995
|
+
}
|
|
996
|
+
}
|
|
895
997
|
/**
|
|
896
|
-
*
|
|
897
|
-
* Uses HTTP crawler (no browser needed) with sitemap discovery + glob filtering.
|
|
898
|
-
*
|
|
899
|
-
* @param url - URL with optional glob pattern (e.g. 'https://example.com/docs/**')
|
|
900
|
-
* @param onProgress - Optional progress callback
|
|
901
|
-
* @param maxPages - Max pages to crawl (default 200)
|
|
998
|
+
* Format a single issue as markdown with YAML frontmatter
|
|
902
999
|
*/
|
|
903
|
-
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
});
|
|
926
|
-
if (results.length === 0) {
|
|
927
|
-
onProgress?.("Retrying crawl");
|
|
928
|
-
results = await doCrawl().catch(() => []);
|
|
1000
|
+
function formatIssueAsMarkdown(issue) {
|
|
1001
|
+
const limit = bodyLimit(issue.reactions);
|
|
1002
|
+
const fmFields = {
|
|
1003
|
+
number: issue.number,
|
|
1004
|
+
title: issue.title,
|
|
1005
|
+
type: issue.type,
|
|
1006
|
+
state: issue.state,
|
|
1007
|
+
created: isoDate(issue.createdAt),
|
|
1008
|
+
url: issue.url,
|
|
1009
|
+
reactions: issue.reactions,
|
|
1010
|
+
comments: issue.comments
|
|
1011
|
+
};
|
|
1012
|
+
if (issue.resolvedIn) fmFields.resolvedIn = issue.resolvedIn;
|
|
1013
|
+
if (issue.labels.length > 0) fmFields.labels = `[${issue.labels.join(", ")}]`;
|
|
1014
|
+
const lines = [
|
|
1015
|
+
buildFrontmatter(fmFields),
|
|
1016
|
+
"",
|
|
1017
|
+
`# ${issue.title}`
|
|
1018
|
+
];
|
|
1019
|
+
if (issue.body) {
|
|
1020
|
+
const body = truncateBody(issue.body, limit);
|
|
1021
|
+
lines.push("", body);
|
|
929
1022
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if (!result.success || !result.content) continue;
|
|
938
|
-
if (foreignUrls.has(result.url)) {
|
|
939
|
-
localeFiltered++;
|
|
940
|
-
continue;
|
|
941
|
-
}
|
|
942
|
-
const segments = (new URL(result.url).pathname.replace(/\/$/, "") || "/index").split("/").filter(Boolean);
|
|
943
|
-
if (isForeignPathPrefix(segments[0], userLang)) {
|
|
944
|
-
localeFiltered++;
|
|
945
|
-
continue;
|
|
1023
|
+
if (issue.topComments.length > 0) {
|
|
1024
|
+
lines.push("", "---", "", "## Top Comments");
|
|
1025
|
+
for (const c of issue.topComments) {
|
|
1026
|
+
const reactions = c.reactions > 0 ? ` (+${c.reactions})` : "";
|
|
1027
|
+
const maintainer = c.isMaintainer ? " [maintainer]" : "";
|
|
1028
|
+
const commentBody = truncateBody(c.body, 600);
|
|
1029
|
+
lines.push("", `**@${c.author}**${maintainer}${reactions}:`, "", commentBody);
|
|
946
1030
|
}
|
|
947
|
-
const path = `docs/${segments.join("/")}.md`;
|
|
948
|
-
docs.push({
|
|
949
|
-
path,
|
|
950
|
-
content: result.content
|
|
951
|
-
});
|
|
952
1031
|
}
|
|
953
|
-
|
|
954
|
-
onProgress?.(`Crawled ${docs.length} pages`);
|
|
955
|
-
return docs;
|
|
956
|
-
}
|
|
957
|
-
const HTML_LANG_RE = /<html[^>]*\slang=["']([^"']+)["']/i;
|
|
958
|
-
/** Extract lang attribute from <html> tag */
|
|
959
|
-
function extractHtmlLang(html) {
|
|
960
|
-
return HTML_LANG_RE.exec(html)?.[1]?.toLowerCase();
|
|
961
|
-
}
|
|
962
|
-
/** Common ISO 639-1 locale codes for i18n'd doc sites */
|
|
963
|
-
const LOCALE_CODES = new Set([
|
|
964
|
-
"ar",
|
|
965
|
-
"de",
|
|
966
|
-
"es",
|
|
967
|
-
"fr",
|
|
968
|
-
"id",
|
|
969
|
-
"it",
|
|
970
|
-
"ja",
|
|
971
|
-
"ko",
|
|
972
|
-
"nl",
|
|
973
|
-
"pl",
|
|
974
|
-
"pt",
|
|
975
|
-
"pt-br",
|
|
976
|
-
"ru",
|
|
977
|
-
"th",
|
|
978
|
-
"tr",
|
|
979
|
-
"uk",
|
|
980
|
-
"vi",
|
|
981
|
-
"zh",
|
|
982
|
-
"zh-cn",
|
|
983
|
-
"zh-tw"
|
|
984
|
-
]);
|
|
985
|
-
/** Check if a URL path segment is a known locale prefix foreign to both English and user's locale */
|
|
986
|
-
function isForeignPathPrefix(segment, userLang) {
|
|
987
|
-
if (!segment) return false;
|
|
988
|
-
const lower = segment.toLowerCase();
|
|
989
|
-
if (lower === "en" || lower.startsWith(userLang)) return false;
|
|
990
|
-
return LOCALE_CODES.has(lower);
|
|
991
|
-
}
|
|
992
|
-
/** Detect user's 2-letter language code from env (e.g. 'ja' from LANG=ja_JP.UTF-8) */
|
|
993
|
-
function getUserLang() {
|
|
994
|
-
const code = (process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "").split(/[_.:-]/)[0]?.toLowerCase() || "";
|
|
995
|
-
return code.length >= 2 ? code.slice(0, 2) : "en";
|
|
1032
|
+
return lines.join("\n");
|
|
996
1033
|
}
|
|
997
|
-
/**
|
|
998
|
-
|
|
999
|
-
|
|
1034
|
+
/**
|
|
1035
|
+
* Generate a summary index of all issues for quick LLM scanning.
|
|
1036
|
+
* Groups by type so the LLM can quickly find bugs vs questions.
|
|
1037
|
+
*/
|
|
1038
|
+
function generateIssueIndex(issues) {
|
|
1039
|
+
const byType = /* @__PURE__ */ new Map();
|
|
1040
|
+
for (const issue of issues) mapInsert(byType, issue.type, () => []).push(issue);
|
|
1041
|
+
const typeLabels = {
|
|
1042
|
+
bug: "Bugs & Regressions",
|
|
1043
|
+
question: "Questions & Usage Help",
|
|
1044
|
+
docs: "Documentation",
|
|
1045
|
+
feature: "Feature Requests",
|
|
1046
|
+
other: "Other"
|
|
1047
|
+
};
|
|
1048
|
+
const typeOrder = [
|
|
1049
|
+
"bug",
|
|
1050
|
+
"question",
|
|
1051
|
+
"docs",
|
|
1052
|
+
"other",
|
|
1053
|
+
"feature"
|
|
1054
|
+
];
|
|
1055
|
+
const sections = [
|
|
1056
|
+
[
|
|
1057
|
+
"---",
|
|
1058
|
+
`total: ${issues.length}`,
|
|
1059
|
+
`open: ${issues.filter((i) => i.state === "open").length}`,
|
|
1060
|
+
`closed: ${issues.filter((i) => i.state !== "open").length}`,
|
|
1061
|
+
"---"
|
|
1062
|
+
].join("\n"),
|
|
1063
|
+
"",
|
|
1064
|
+
"# Issues Index",
|
|
1065
|
+
""
|
|
1066
|
+
];
|
|
1067
|
+
for (const type of typeOrder) {
|
|
1068
|
+
const group = byType.get(type);
|
|
1069
|
+
if (!group?.length) continue;
|
|
1070
|
+
sections.push(`## ${typeLabels[type]} (${group.length})`, "");
|
|
1071
|
+
for (const issue of group) {
|
|
1072
|
+
const reactions = issue.reactions > 0 ? ` (+${issue.reactions})` : "";
|
|
1073
|
+
const state = issue.state === "open" ? "" : " [closed]";
|
|
1074
|
+
const resolved = issue.resolvedIn ? ` [fixed in ${issue.resolvedIn}]` : "";
|
|
1075
|
+
const date = isoDate(issue.createdAt);
|
|
1076
|
+
sections.push(`- [#${issue.number}](./issue-${issue.number}.md): ${issue.title}${reactions}${state}${resolved} (${date})`);
|
|
1077
|
+
}
|
|
1078
|
+
sections.push("");
|
|
1079
|
+
}
|
|
1080
|
+
return sections.join("\n");
|
|
1000
1081
|
}
|
|
1001
1082
|
//#endregion
|
|
1002
1083
|
//#region src/sources/discussions.ts
|
|
@@ -1017,35 +1098,6 @@ const LOW_VALUE_CATEGORIES = new Set([
|
|
|
1017
1098
|
"ideas",
|
|
1018
1099
|
"polls"
|
|
1019
1100
|
]);
|
|
1020
|
-
/** Noise patterns in comments — filter these out */
|
|
1021
|
-
const COMMENT_NOISE_RE = /^(?:\+1|👍|same here|any update|bump|following|is there any progress|when will this|me too|i have the same|same issue|thanks|thank you)[\s!?.]*$/i;
|
|
1022
|
-
/** Check if body contains a code block */
|
|
1023
|
-
function hasCodeBlock(text) {
|
|
1024
|
-
return /```[\s\S]*?```/.test(text) || /`[^`]+`/.test(text);
|
|
1025
|
-
}
|
|
1026
|
-
/**
|
|
1027
|
-
* Smart body truncation — preserves code blocks and error messages.
|
|
1028
|
-
* Instead of slicing at a char limit, finds a safe break point.
|
|
1029
|
-
*/
|
|
1030
|
-
function truncateBody(body, limit) {
|
|
1031
|
-
if (body.length <= limit) return body;
|
|
1032
|
-
const codeBlockRe = /```[\s\S]*?```/g;
|
|
1033
|
-
let lastSafeEnd = limit;
|
|
1034
|
-
let match;
|
|
1035
|
-
while ((match = codeBlockRe.exec(body)) !== null) {
|
|
1036
|
-
const blockStart = match.index;
|
|
1037
|
-
const blockEnd = blockStart + match[0].length;
|
|
1038
|
-
if (blockStart < limit && blockEnd > limit) {
|
|
1039
|
-
if (blockEnd <= limit + 500) lastSafeEnd = blockEnd;
|
|
1040
|
-
else lastSafeEnd = blockStart;
|
|
1041
|
-
break;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
const slice = body.slice(0, lastSafeEnd);
|
|
1045
|
-
const lastParagraph = slice.lastIndexOf("\n\n");
|
|
1046
|
-
if (lastParagraph > lastSafeEnd * .6) return `${slice.slice(0, lastParagraph)}\n\n...`;
|
|
1047
|
-
return `${slice}...`;
|
|
1048
|
-
}
|
|
1049
1101
|
/** Off-topic or spam title patterns — instant reject */
|
|
1050
1102
|
const TITLE_NOISE_RE = /looking .*(?:developer|engineer|freelanc)|hiring|job post|guide me to (?:complete|finish|build)|help me (?:complete|finish|build)|seeking .* tutorial|recommend.* course/i;
|
|
1051
1103
|
/** Minimum score for a discussion to be included */
|
|
@@ -1506,7 +1558,8 @@ async function downloadGitHubSkills(owner, repo, ref, skillPath, onProgress) {
|
|
|
1506
1558
|
onProgress?.(`Downloading ${owner}/${repo}/${skillPath}@${ref}`);
|
|
1507
1559
|
const { dir } = await downloadTemplate(`github:${owner}/${repo}/${skillPath}#${ref}`, {
|
|
1508
1560
|
dir: tempDir,
|
|
1509
|
-
force: true
|
|
1561
|
+
force: true,
|
|
1562
|
+
auth: getGitHubToken() || void 0
|
|
1510
1563
|
});
|
|
1511
1564
|
const skill = readLocalSkill(dir, skillPath);
|
|
1512
1565
|
return skill ? [skill] : [];
|
|
@@ -1515,7 +1568,8 @@ async function downloadGitHubSkills(owner, repo, ref, skillPath, onProgress) {
|
|
|
1515
1568
|
try {
|
|
1516
1569
|
const { dir } = await downloadTemplate(`github:${owner}/${repo}/skills#${ref}`, {
|
|
1517
1570
|
dir: tempDir,
|
|
1518
|
-
force: true
|
|
1571
|
+
force: true,
|
|
1572
|
+
auth: getGitHubToken() || void 0
|
|
1519
1573
|
});
|
|
1520
1574
|
const skills = [];
|
|
1521
1575
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1528,7 +1582,7 @@ async function downloadGitHubSkills(owner, repo, ref, skillPath, onProgress) {
|
|
|
1528
1582
|
return skills;
|
|
1529
1583
|
}
|
|
1530
1584
|
} catch {}
|
|
1531
|
-
const content = await
|
|
1585
|
+
const content = await fetchGitHubRaw(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/SKILL.md`);
|
|
1532
1586
|
if (content) {
|
|
1533
1587
|
const fm = parseSkillFrontmatterName(content);
|
|
1534
1588
|
onProgress?.("Found 1 skill");
|
|
@@ -1697,10 +1751,20 @@ const MIN_GIT_DOCS = 5;
|
|
|
1697
1751
|
/** True when git-docs exist but are too few to be useful (< MIN_GIT_DOCS) */
|
|
1698
1752
|
const isShallowGitDocs = (n) => n > 0 && n < 5;
|
|
1699
1753
|
/**
|
|
1700
|
-
* List files at a git ref
|
|
1754
|
+
* List files at a git ref. Tries ungh.cc first (fast, no rate limits),
|
|
1755
|
+
* falls back to GitHub API for private repos.
|
|
1701
1756
|
*/
|
|
1702
1757
|
async function listFilesAtRef(owner, repo, ref) {
|
|
1703
|
-
|
|
1758
|
+
if (!isKnownPrivateRepo(owner, repo)) {
|
|
1759
|
+
const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/files/${ref}`).catch(() => null);
|
|
1760
|
+
if (data?.files?.length) return data.files.map((f) => f.path);
|
|
1761
|
+
}
|
|
1762
|
+
const tree = await ghApi(`repos/${owner}/${repo}/git/trees/${ref}?recursive=1`);
|
|
1763
|
+
if (tree?.tree?.length) {
|
|
1764
|
+
markRepoPrivate(owner, repo);
|
|
1765
|
+
return tree.tree.map((f) => f.path);
|
|
1766
|
+
}
|
|
1767
|
+
return [];
|
|
1704
1768
|
}
|
|
1705
1769
|
/**
|
|
1706
1770
|
* Find git tag for a version by checking if ungh can list files at that ref.
|
|
@@ -1738,13 +1802,29 @@ async function findGitTag(owner, repo, version, packageName, branchHint) {
|
|
|
1738
1802
|
return null;
|
|
1739
1803
|
}
|
|
1740
1804
|
/**
|
|
1741
|
-
*
|
|
1742
|
-
|
|
1805
|
+
* Fetch releases from ungh.cc first, fall back to GitHub API for private repos.
|
|
1806
|
+
*/
|
|
1807
|
+
async function fetchUnghReleases(owner, repo) {
|
|
1808
|
+
if (!isKnownPrivateRepo(owner, repo)) {
|
|
1809
|
+
const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`).catch(() => null);
|
|
1810
|
+
if (data?.releases?.length) return data.releases;
|
|
1811
|
+
}
|
|
1812
|
+
const raw = await ghApiPaginated(`repos/${owner}/${repo}/releases`);
|
|
1813
|
+
if (raw.length > 0) {
|
|
1814
|
+
markRepoPrivate(owner, repo);
|
|
1815
|
+
return raw.map((r) => ({
|
|
1816
|
+
tag: r.tag_name,
|
|
1817
|
+
publishedAt: r.published_at
|
|
1818
|
+
}));
|
|
1819
|
+
}
|
|
1820
|
+
return [];
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Find the latest release tag matching `{packageName}@*`.
|
|
1743
1824
|
*/
|
|
1744
1825
|
async function findLatestReleaseTag(owner, repo, packageName) {
|
|
1745
|
-
const data = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`).catch(() => null);
|
|
1746
1826
|
const prefix = `${packageName}@`;
|
|
1747
|
-
return
|
|
1827
|
+
return (await fetchUnghReleases(owner, repo)).find((r) => r.tag.startsWith(prefix))?.tag ?? null;
|
|
1748
1828
|
}
|
|
1749
1829
|
/**
|
|
1750
1830
|
* Filter file paths by prefix and md/mdx extension
|
|
@@ -1994,7 +2074,7 @@ async function verifyNpmRepo(owner, repo, packageName) {
|
|
|
1994
2074
|
`packages/${packageName.replace(/^@/, "").replace("/", "-")}/package.json`
|
|
1995
2075
|
];
|
|
1996
2076
|
for (const path of paths) {
|
|
1997
|
-
const text = await
|
|
2077
|
+
const text = await fetchGitHubRaw(`${base}/${path}`);
|
|
1998
2078
|
if (!text) continue;
|
|
1999
2079
|
try {
|
|
2000
2080
|
if (JSON.parse(text).name === packageName) return true;
|
|
@@ -2051,38 +2131,35 @@ async function searchGitHubRepo(packageName) {
|
|
|
2051
2131
|
async function fetchGitHubRepoMeta(owner, repo, packageName) {
|
|
2052
2132
|
const override = packageName ? getDocOverride(packageName) : void 0;
|
|
2053
2133
|
if (override?.homepage) return { homepage: override.homepage };
|
|
2054
|
-
|
|
2055
|
-
const { stdout: json } = spawnSync("gh", [
|
|
2056
|
-
"api",
|
|
2057
|
-
`repos/${owner}/${repo}`,
|
|
2058
|
-
"-q",
|
|
2059
|
-
"{homepage}"
|
|
2060
|
-
], {
|
|
2061
|
-
encoding: "utf-8",
|
|
2062
|
-
timeout: 1e4
|
|
2063
|
-
});
|
|
2064
|
-
if (!json) throw new Error("no output");
|
|
2065
|
-
const data = JSON.parse(json);
|
|
2066
|
-
return data?.homepage ? { homepage: data.homepage } : null;
|
|
2067
|
-
} catch {}
|
|
2068
|
-
const data = await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
|
|
2134
|
+
const data = await ghApi(`repos/${owner}/${repo}`) ?? await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
|
|
2069
2135
|
return data?.homepage ? { homepage: data.homepage } : null;
|
|
2070
2136
|
}
|
|
2071
2137
|
/**
|
|
2072
2138
|
* Resolve README URL for a GitHub repo, returns ungh:// pseudo-URL or raw URL
|
|
2073
2139
|
*/
|
|
2074
2140
|
async function fetchReadme(owner, repo, subdir, ref) {
|
|
2075
|
-
const
|
|
2076
|
-
if ((
|
|
2141
|
+
const branch = ref || "main";
|
|
2142
|
+
if (!isKnownPrivateRepo(owner, repo)) {
|
|
2143
|
+
const unghUrl = subdir ? `https://ungh.cc/repos/${owner}/${repo}/files/${branch}/${subdir}/README.md` : `https://ungh.cc/repos/${owner}/${repo}/readme${ref ? `?ref=${ref}` : ""}`;
|
|
2144
|
+
if ((await $fetch.raw(unghUrl).catch(() => null))?.ok) return `ungh://${owner}/${repo}${subdir ? `/${subdir}` : ""}${ref ? `@${ref}` : ""}`;
|
|
2145
|
+
}
|
|
2077
2146
|
const basePath = subdir ? `${subdir}/` : "";
|
|
2078
2147
|
const branches = ref ? [ref] : ["main", "master"];
|
|
2148
|
+
const token = isKnownPrivateRepo(owner, repo) ? getGitHubToken() : null;
|
|
2149
|
+
const authHeaders = token ? { Authorization: `token ${token}` } : {};
|
|
2079
2150
|
for (const b of branches) for (const filename of [
|
|
2080
2151
|
"README.md",
|
|
2081
2152
|
"Readme.md",
|
|
2082
2153
|
"readme.md"
|
|
2083
2154
|
]) {
|
|
2084
2155
|
const readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${b}/${basePath}${filename}`;
|
|
2085
|
-
if ((await $fetch.raw(readmeUrl).catch(() => null))?.ok) return readmeUrl;
|
|
2156
|
+
if ((await $fetch.raw(readmeUrl, { headers: authHeaders }).catch(() => null))?.ok) return readmeUrl;
|
|
2157
|
+
}
|
|
2158
|
+
const refParam = ref ? `?ref=${ref}` : "";
|
|
2159
|
+
const apiData = await ghApi(subdir ? `repos/${owner}/${repo}/contents/${subdir}/README.md${refParam}` : `repos/${owner}/${repo}/readme${refParam}`);
|
|
2160
|
+
if (apiData?.download_url) {
|
|
2161
|
+
markRepoPrivate(owner, repo);
|
|
2162
|
+
return apiData.download_url;
|
|
2086
2163
|
}
|
|
2087
2164
|
return null;
|
|
2088
2165
|
}
|
|
@@ -2116,6 +2193,7 @@ async function fetchReadmeContent(url) {
|
|
|
2116
2193
|
return text;
|
|
2117
2194
|
}
|
|
2118
2195
|
}
|
|
2196
|
+
if (url.includes("raw.githubusercontent.com")) return fetchGitHubRaw(url);
|
|
2119
2197
|
return fetchText(url);
|
|
2120
2198
|
}
|
|
2121
2199
|
/**
|
|
@@ -2125,34 +2203,14 @@ async function fetchReadmeContent(url) {
|
|
|
2125
2203
|
async function resolveGitHubRepo(owner, repo, onProgress) {
|
|
2126
2204
|
onProgress?.("Fetching repo metadata");
|
|
2127
2205
|
const repoUrl = `https://github.com/${owner}/${repo}`;
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
const { stdout: json } = spawnSync("gh", [
|
|
2132
|
-
"api",
|
|
2133
|
-
`repos/${owner}/${repo}`,
|
|
2134
|
-
"--jq",
|
|
2135
|
-
"{homepage: .homepage, description: .description}"
|
|
2136
|
-
], {
|
|
2137
|
-
encoding: "utf-8",
|
|
2138
|
-
timeout: 1e4
|
|
2139
|
-
});
|
|
2140
|
-
if (json) {
|
|
2141
|
-
const data = JSON.parse(json);
|
|
2142
|
-
homepage = data.homepage || void 0;
|
|
2143
|
-
description = data.description || void 0;
|
|
2144
|
-
}
|
|
2145
|
-
} catch {}
|
|
2146
|
-
if (!homepage && !description) {
|
|
2147
|
-
const data = await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
|
|
2148
|
-
homepage = data?.homepage || void 0;
|
|
2149
|
-
description = data?.description || void 0;
|
|
2150
|
-
}
|
|
2206
|
+
const meta = await ghApi(`repos/${owner}/${repo}`) ?? await $fetch(`https://api.github.com/repos/${owner}/${repo}`).catch(() => null);
|
|
2207
|
+
const homepage = meta?.homepage || void 0;
|
|
2208
|
+
const description = meta?.description || void 0;
|
|
2151
2209
|
onProgress?.("Fetching latest release");
|
|
2152
|
-
const
|
|
2210
|
+
const releases = await fetchUnghReleases(owner, repo);
|
|
2153
2211
|
let version = "main";
|
|
2154
2212
|
let releasedAt;
|
|
2155
|
-
const latestRelease =
|
|
2213
|
+
const latestRelease = releases[0];
|
|
2156
2214
|
if (latestRelease) {
|
|
2157
2215
|
version = latestRelease.tag.replace(/^v/, "");
|
|
2158
2216
|
releasedAt = latestRelease.publishedAt;
|
|
@@ -2458,7 +2516,7 @@ function parseVersionSpecifier(name, version, cwd) {
|
|
|
2458
2516
|
};
|
|
2459
2517
|
if (/^[\^~>=<\d]/.test(version)) return {
|
|
2460
2518
|
name,
|
|
2461
|
-
version: version.replace(/^[\^~>=<]
|
|
2519
|
+
version: version.replace(/^[\^~>=<]+/, "")
|
|
2462
2520
|
};
|
|
2463
2521
|
if (version.startsWith("catalog:") || version.startsWith("workspace:")) return {
|
|
2464
2522
|
name,
|
|
@@ -2581,9 +2639,10 @@ async function fetchPkgDist(name, version) {
|
|
|
2581
2639
|
} });
|
|
2582
2640
|
writable.on("finish", () => {
|
|
2583
2641
|
fileStream.end();
|
|
2584
|
-
res();
|
|
2585
2642
|
});
|
|
2643
|
+
fileStream.on("close", () => res());
|
|
2586
2644
|
writable.on("error", reject);
|
|
2645
|
+
fileStream.on("error", reject);
|
|
2587
2646
|
function pump() {
|
|
2588
2647
|
reader.read().then(({ done, value }) => {
|
|
2589
2648
|
if (done) {
|
|
@@ -2628,6 +2687,6 @@ function getInstalledSkillVersion(skillDir) {
|
|
|
2628
2687
|
return readFileSync(skillPath, "utf-8").match(/^version:\s*"?([^"\n]+)"?/m)?.[1] || null;
|
|
2629
2688
|
}
|
|
2630
2689
|
//#endregion
|
|
2631
|
-
export {
|
|
2690
|
+
export { isGitHubRepoUrl as $, parseGitSkillInput as A, isGhAvailable as B, downloadLlmsDocs as C, normalizeLlmsLinks as D, fetchLlmsUrl as E, formatDiscussionAsMarkdown as F, fetchReleaseNotes as G, toCrawlPattern as H, generateDiscussionIndex as I, parseSemver as J, generateReleaseIndex as K, fetchGitHubIssues as L, resolveEntryFiles as M, generateDocsIndex as N, parseMarkdownLinks as O, fetchGitHubDiscussions as P, fetchText as Q, formatIssueAsMarkdown as R, validateGitDocsWithLlms as S, fetchLlmsTxt as T, fetchBlogReleases as U, fetchCrawledDocs as V, compareSemver as W, extractBranchHint as X, $fetch as Y, fetchGitHubRaw as Z, fetchReadme as _, getInstalledSkillVersion as a, isShallowGitDocs as b, readLocalPackageInfo as c, resolvePackageDocs as d, normalizeRepoUrl as et, resolvePackageDocsWithAttempts as f, fetchGitHubRepoMeta as g, fetchGitDocs as h, fetchPkgDist as i, parseSkillFrontmatterName as j, fetchGitSkills as k, resolveInstalledVersion as l, MIN_GIT_DOCS as m, fetchNpmPackage as n, parsePackageSpec as nt, parseVersionSpecifier as o, searchNpmPackages as p, isPrerelease as q, fetchNpmRegistryMeta as r, verifyUrl as rt, readLocalDependencies as s, fetchLatestVersion as t, parseGitHubUrl as tt, resolveLocalPackageDocs as u, fetchReadmeContent as v, extractSections as w, resolveGitHubRepo as x, filterFrameworkDocs as y, generateIssueIndex as z };
|
|
2632
2691
|
|
|
2633
2692
|
//# sourceMappingURL=sources.mjs.map
|