@oh-my-pi/pi-coding-agent 3.25.0 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +4 -4
- package/src/core/tools/complete.ts +2 -4
- package/src/core/tools/jtd-to-json-schema.ts +174 -196
- package/src/core/tools/read.ts +4 -4
- package/src/core/tools/task/executor.ts +146 -20
- package/src/core/tools/task/name-generator.ts +1544 -214
- package/src/core/tools/task/types.ts +19 -5
- package/src/core/tools/task/worker.ts +103 -13
- package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
- package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
- package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
- package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
- package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
- package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
- package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
- package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
- package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
- package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
- package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
- package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
- package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
- package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
- package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
- package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
- package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
- package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
- package/src/core/tools/web-fetch-handlers/github.ts +424 -0
- package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
- package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
- package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
- package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
- package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
- package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
- package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
- package/src/core/tools/web-fetch-handlers/index.ts +69 -0
- package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
- package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
- package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
- package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
- package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
- package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
- package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
- package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
- package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
- package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
- package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
- package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
- package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
- package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
- package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
- package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
- package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
- package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
- package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
- package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
- package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
- package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
- package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
- package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
- package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
- package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
- package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
- package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
- package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
- package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
- package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
- package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
- package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
- package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
- package/src/core/tools/web-fetch-handlers/types.ts +163 -0
- package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
- package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
- package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
- package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
- package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
- package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
- package/src/core/tools/web-fetch.ts +152 -1324
- package/src/utils/tools-manager.ts +110 -8
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface RepologyPackage {
|
|
5
|
+
repo: string;
|
|
6
|
+
subrepo?: string;
|
|
7
|
+
srcname?: string;
|
|
8
|
+
binname?: string;
|
|
9
|
+
visiblename?: string;
|
|
10
|
+
version: string;
|
|
11
|
+
origversion?: string;
|
|
12
|
+
status:
|
|
13
|
+
| "newest"
|
|
14
|
+
| "devel"
|
|
15
|
+
| "unique"
|
|
16
|
+
| "outdated"
|
|
17
|
+
| "legacy"
|
|
18
|
+
| "rolling"
|
|
19
|
+
| "noscheme"
|
|
20
|
+
| "incorrect"
|
|
21
|
+
| "untrusted"
|
|
22
|
+
| "ignored";
|
|
23
|
+
summary?: string;
|
|
24
|
+
categories?: string[];
|
|
25
|
+
licenses?: string[];
|
|
26
|
+
maintainers?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get emoji indicator for version status
|
|
31
|
+
*/
|
|
32
|
+
function statusIndicator(status: string): string {
|
|
33
|
+
switch (status) {
|
|
34
|
+
case "newest":
|
|
35
|
+
return "\u2705"; // green check
|
|
36
|
+
case "devel":
|
|
37
|
+
return "\uD83D\uDEA7"; // construction
|
|
38
|
+
case "unique":
|
|
39
|
+
return "\uD83D\uDD35"; // blue circle
|
|
40
|
+
case "outdated":
|
|
41
|
+
return "\uD83D\uDD34"; // red circle
|
|
42
|
+
case "legacy":
|
|
43
|
+
return "\u26A0\uFE0F"; // warning
|
|
44
|
+
case "rolling":
|
|
45
|
+
return "\uD83D\uDD04"; // arrows
|
|
46
|
+
default:
|
|
47
|
+
return "\u2796"; // minus
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Prettify repository name
|
|
53
|
+
*/
|
|
54
|
+
function prettifyRepo(repo: string): string {
|
|
55
|
+
const mapping: Record<string, string> = {
|
|
56
|
+
arch: "Arch Linux",
|
|
57
|
+
aur: "AUR",
|
|
58
|
+
debian_unstable: "Debian Unstable",
|
|
59
|
+
debian_stable: "Debian Stable",
|
|
60
|
+
ubuntu_24_04: "Ubuntu 24.04",
|
|
61
|
+
ubuntu_22_04: "Ubuntu 22.04",
|
|
62
|
+
fedora_rawhide: "Fedora Rawhide",
|
|
63
|
+
fedora_40: "Fedora 40",
|
|
64
|
+
gentoo: "Gentoo",
|
|
65
|
+
nix_unstable: "Nixpkgs Unstable",
|
|
66
|
+
nix_stable: "Nixpkgs Stable",
|
|
67
|
+
homebrew: "Homebrew",
|
|
68
|
+
macports: "MacPorts",
|
|
69
|
+
alpine_edge: "Alpine Edge",
|
|
70
|
+
freebsd: "FreeBSD",
|
|
71
|
+
openbsd: "OpenBSD",
|
|
72
|
+
void_x86_64: "Void Linux",
|
|
73
|
+
opensuse_tumbleweed: "openSUSE Tumbleweed",
|
|
74
|
+
msys2_mingw: "MSYS2",
|
|
75
|
+
chocolatey: "Chocolatey",
|
|
76
|
+
winget: "Winget",
|
|
77
|
+
scoop: "Scoop",
|
|
78
|
+
conda_main: "Conda",
|
|
79
|
+
pypi: "PyPI",
|
|
80
|
+
crates_io: "Crates.io",
|
|
81
|
+
npm: "npm",
|
|
82
|
+
rubygems: "RubyGems",
|
|
83
|
+
cpan: "CPAN",
|
|
84
|
+
hackage: "Hackage",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Check exact match first
|
|
88
|
+
if (mapping[repo]) return mapping[repo];
|
|
89
|
+
|
|
90
|
+
// Check partial matches
|
|
91
|
+
for (const [key, value] of Object.entries(mapping)) {
|
|
92
|
+
if (repo.startsWith(key)) return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback: titlecase with underscores replaced
|
|
96
|
+
return repo
|
|
97
|
+
.split("_")
|
|
98
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
99
|
+
.join(" ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Handle Repology URLs via API
|
|
104
|
+
*/
|
|
105
|
+
export const handleRepology: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = new URL(url);
|
|
108
|
+
if (parsed.hostname !== "repology.org" && parsed.hostname !== "www.repology.org") return null;
|
|
109
|
+
|
|
110
|
+
// Extract package name from /project/{name}/versions or /project/{name}/information
|
|
111
|
+
const match = parsed.pathname.match(/^\/project\/([^/]+)/);
|
|
112
|
+
if (!match) return null;
|
|
113
|
+
|
|
114
|
+
const packageName = decodeURIComponent(match[1]);
|
|
115
|
+
const fetchedAt = new Date().toISOString();
|
|
116
|
+
|
|
117
|
+
// Fetch from Repology API
|
|
118
|
+
const apiUrl = `https://repology.org/api/v1/project/${encodeURIComponent(packageName)}`;
|
|
119
|
+
const result = await loadPage(apiUrl, {
|
|
120
|
+
timeout,
|
|
121
|
+
headers: { Accept: "application/json" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!result.ok) return null;
|
|
125
|
+
|
|
126
|
+
let packages: RepologyPackage[];
|
|
127
|
+
try {
|
|
128
|
+
packages = JSON.parse(result.content);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Empty response means package not found
|
|
134
|
+
if (!Array.isArray(packages) || packages.length === 0) return null;
|
|
135
|
+
|
|
136
|
+
// Find newest version(s) and extract metadata
|
|
137
|
+
const newestVersions = new Set<string>();
|
|
138
|
+
let summary: string | undefined;
|
|
139
|
+
let licenses: string[] = [];
|
|
140
|
+
const categories = new Set<string>();
|
|
141
|
+
|
|
142
|
+
for (const pkg of packages) {
|
|
143
|
+
if (pkg.status === "newest" || pkg.status === "unique") {
|
|
144
|
+
newestVersions.add(pkg.version);
|
|
145
|
+
}
|
|
146
|
+
if (!summary && pkg.summary) summary = pkg.summary;
|
|
147
|
+
if (pkg.licenses?.length && !licenses.length) licenses = pkg.licenses;
|
|
148
|
+
if (pkg.categories) {
|
|
149
|
+
for (const cat of pkg.categories) categories.add(cat);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// If no newest found, find the highest version
|
|
154
|
+
if (newestVersions.size === 0) {
|
|
155
|
+
const versions = packages.map((p) => p.version);
|
|
156
|
+
if (versions.length > 0) newestVersions.add(versions[0]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Group packages by status for counting
|
|
160
|
+
const statusCounts: Record<string, number> = {};
|
|
161
|
+
for (const pkg of packages) {
|
|
162
|
+
statusCounts[pkg.status] = (statusCounts[pkg.status] || 0) + 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Build markdown
|
|
166
|
+
let md = `# ${packageName}\n\n`;
|
|
167
|
+
if (summary) md += `${summary}\n\n`;
|
|
168
|
+
|
|
169
|
+
md += `**Newest Version:** ${Array.from(newestVersions).join(", ") || "unknown"}\n`;
|
|
170
|
+
md += `**Repositories:** ${packages.length}\n`;
|
|
171
|
+
if (licenses.length) md += `**License:** ${licenses.join(", ")}\n`;
|
|
172
|
+
if (categories.size) md += `**Categories:** ${Array.from(categories).join(", ")}\n`;
|
|
173
|
+
md += "\n";
|
|
174
|
+
|
|
175
|
+
// Status summary
|
|
176
|
+
md += "## Version Status Summary\n\n";
|
|
177
|
+
const statusOrder = [
|
|
178
|
+
"newest",
|
|
179
|
+
"unique",
|
|
180
|
+
"devel",
|
|
181
|
+
"rolling",
|
|
182
|
+
"outdated",
|
|
183
|
+
"legacy",
|
|
184
|
+
"noscheme",
|
|
185
|
+
"incorrect",
|
|
186
|
+
"untrusted",
|
|
187
|
+
"ignored",
|
|
188
|
+
];
|
|
189
|
+
for (const status of statusOrder) {
|
|
190
|
+
if (statusCounts[status]) {
|
|
191
|
+
md += `- ${statusIndicator(status)} **${status}**: ${statusCounts[status]} repos\n`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
md += "\n";
|
|
195
|
+
|
|
196
|
+
// Sort packages: newest first, then by repo name
|
|
197
|
+
const sortedPackages = [...packages].sort((a, b) => {
|
|
198
|
+
const statusPriority: Record<string, number> = {
|
|
199
|
+
newest: 0,
|
|
200
|
+
unique: 1,
|
|
201
|
+
devel: 2,
|
|
202
|
+
rolling: 3,
|
|
203
|
+
outdated: 4,
|
|
204
|
+
legacy: 5,
|
|
205
|
+
noscheme: 6,
|
|
206
|
+
incorrect: 7,
|
|
207
|
+
untrusted: 8,
|
|
208
|
+
ignored: 9,
|
|
209
|
+
};
|
|
210
|
+
const aPriority = statusPriority[a.status] ?? 10;
|
|
211
|
+
const bPriority = statusPriority[b.status] ?? 10;
|
|
212
|
+
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
213
|
+
return a.repo.localeCompare(b.repo);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Show top repositories (up to 15)
|
|
217
|
+
md += "## Package Versions by Repository\n\n";
|
|
218
|
+
md += "| Repository | Version | Status |\n";
|
|
219
|
+
md += "|------------|---------|--------|\n";
|
|
220
|
+
|
|
221
|
+
const shownRepos = new Set<string>();
|
|
222
|
+
let count = 0;
|
|
223
|
+
for (const pkg of sortedPackages) {
|
|
224
|
+
// Skip duplicate repos (some have multiple entries)
|
|
225
|
+
const repoKey = pkg.subrepo ? `${pkg.repo}/${pkg.subrepo}` : pkg.repo;
|
|
226
|
+
if (shownRepos.has(repoKey)) continue;
|
|
227
|
+
shownRepos.add(repoKey);
|
|
228
|
+
|
|
229
|
+
const repoName = prettifyRepo(pkg.repo);
|
|
230
|
+
const version = pkg.origversion || pkg.version;
|
|
231
|
+
md += `| ${repoName} | \`${version}\` | ${statusIndicator(pkg.status)} ${pkg.status} |\n`;
|
|
232
|
+
|
|
233
|
+
count++;
|
|
234
|
+
if (count >= 15) break;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (packages.length > 15) {
|
|
238
|
+
md += `\n*...and ${packages.length - 15} more repositories*\n`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
md += `\n---\n\n[View on Repology](${url})\n`;
|
|
242
|
+
|
|
243
|
+
const output = finalizeOutput(md);
|
|
244
|
+
return {
|
|
245
|
+
url,
|
|
246
|
+
finalUrl: url,
|
|
247
|
+
contentType: "text/markdown",
|
|
248
|
+
method: "repology",
|
|
249
|
+
content: output.content,
|
|
250
|
+
fetchedAt,
|
|
251
|
+
truncated: output.truncated,
|
|
252
|
+
notes: ["Fetched via Repology API"],
|
|
253
|
+
};
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
return null;
|
|
257
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleBiorxiv } from "./biorxiv";
|
|
3
|
+
import { handleOpenLibrary } from "./openlibrary";
|
|
4
|
+
import { handleWikidata } from "./wikidata";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleWikidata", () => {
|
|
9
|
+
it("returns null for non-matching URLs", async () => {
|
|
10
|
+
const result = await handleWikidata("https://example.com", 20);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns null for non-wikidata URLs", async () => {
|
|
15
|
+
const result = await handleWikidata("https://wikipedia.org/wiki/Apple_Inc", 20);
|
|
16
|
+
expect(result).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fetches Q312 - Apple Inc", async () => {
|
|
20
|
+
const result = await handleWikidata("https://www.wikidata.org/wiki/Q312", 20);
|
|
21
|
+
expect(result).not.toBeNull();
|
|
22
|
+
expect(result?.method).toBe("wikidata");
|
|
23
|
+
expect(result?.content).toContain("Apple");
|
|
24
|
+
expect(result?.content).toContain("Q312");
|
|
25
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
26
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
27
|
+
expect(result?.truncated).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("fetches Q5 - human (entity)", async () => {
|
|
31
|
+
const result = await handleWikidata("https://www.wikidata.org/entity/Q5", 20);
|
|
32
|
+
expect(result).not.toBeNull();
|
|
33
|
+
expect(result?.method).toBe("wikidata");
|
|
34
|
+
expect(result?.content).toContain("human");
|
|
35
|
+
expect(result?.content).toContain("Q5");
|
|
36
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
37
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
38
|
+
expect(result?.truncated).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe.skipIf(SKIP)("handleOpenLibrary", () => {
|
|
43
|
+
it("returns null for non-matching URLs", async () => {
|
|
44
|
+
const result = await handleOpenLibrary("https://example.com", 20);
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns null for non-openlibrary URLs", async () => {
|
|
49
|
+
const result = await handleOpenLibrary("https://amazon.com/books/123", 20);
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("fetches by ISBN - Fantastic Mr Fox", async () => {
|
|
54
|
+
const result = await handleOpenLibrary("https://openlibrary.org/isbn/9780140328721", 20);
|
|
55
|
+
expect(result).not.toBeNull();
|
|
56
|
+
expect(result?.method).toBe("openlibrary");
|
|
57
|
+
expect(result?.content).toContain("Fantastic Mr");
|
|
58
|
+
expect(result?.content).toContain("Roald Dahl");
|
|
59
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
60
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
61
|
+
expect(result?.truncated).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("fetches work OL45804W - The Lord of the Rings", async () => {
|
|
65
|
+
const result = await handleOpenLibrary("https://openlibrary.org/works/OL45804W", 20);
|
|
66
|
+
expect(result).not.toBeNull();
|
|
67
|
+
expect(result?.method).toBe("openlibrary");
|
|
68
|
+
expect(result?.content).toContain("Lord of the Rings");
|
|
69
|
+
expect(result?.content).toContain("Tolkien");
|
|
70
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
71
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
72
|
+
expect(result?.truncated).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe.skipIf(SKIP)("handleBiorxiv", () => {
|
|
77
|
+
it("returns null for non-matching URLs", async () => {
|
|
78
|
+
const result = await handleBiorxiv("https://example.com", 20);
|
|
79
|
+
expect(result).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns null for non-biorxiv URLs", async () => {
|
|
83
|
+
const result = await handleBiorxiv("https://nature.com/articles/123", 20);
|
|
84
|
+
expect(result).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Using the AlphaFold Protein Structure Database paper - highly cited and stable
|
|
88
|
+
it("fetches bioRxiv preprint - AlphaFold database", async () => {
|
|
89
|
+
const result = await handleBiorxiv("https://www.biorxiv.org/content/10.1101/2021.10.04.463034", 20);
|
|
90
|
+
expect(result).not.toBeNull();
|
|
91
|
+
expect(result?.method).toBe("biorxiv");
|
|
92
|
+
expect(result?.content).toContain("AlphaFold");
|
|
93
|
+
expect(result?.content).toContain("Abstract");
|
|
94
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
95
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
96
|
+
expect(result?.truncated).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Testing with version suffix handling
|
|
100
|
+
it("fetches bioRxiv preprint with version suffix", async () => {
|
|
101
|
+
const result = await handleBiorxiv("https://www.biorxiv.org/content/10.1101/2021.10.04.463034v1", 20);
|
|
102
|
+
expect(result).not.toBeNull();
|
|
103
|
+
expect(result?.method).toBe("biorxiv");
|
|
104
|
+
expect(result?.content).toContain("AlphaFold");
|
|
105
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface RfcMetadata {
|
|
5
|
+
doc_id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
authors?: Array<{ name: string; affiliation?: string }>;
|
|
8
|
+
pub_status?: string;
|
|
9
|
+
current_status?: string;
|
|
10
|
+
stream?: string;
|
|
11
|
+
area?: string;
|
|
12
|
+
wg_acronym?: string;
|
|
13
|
+
pub_date?: string;
|
|
14
|
+
page_count?: number;
|
|
15
|
+
abstract?: string;
|
|
16
|
+
keywords?: string[];
|
|
17
|
+
obsoletes?: string[];
|
|
18
|
+
obsoleted_by?: string[];
|
|
19
|
+
updates?: string[];
|
|
20
|
+
updated_by?: string[];
|
|
21
|
+
see_also?: string[];
|
|
22
|
+
errata_url?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract RFC number from various URL patterns
|
|
27
|
+
*/
|
|
28
|
+
function extractRfcNumber(url: URL): string | null {
|
|
29
|
+
const { hostname, pathname } = url;
|
|
30
|
+
|
|
31
|
+
// https://www.rfc-editor.org/rfc/rfc{number}
|
|
32
|
+
// https://www.rfc-editor.org/rfc/rfc{number}.html
|
|
33
|
+
// https://www.rfc-editor.org/rfc/rfc{number}.txt
|
|
34
|
+
if (hostname === "www.rfc-editor.org" || hostname === "rfc-editor.org") {
|
|
35
|
+
const match = pathname.match(/\/rfc\/rfc(\d+)(?:\.(?:html|txt|pdf))?$/i);
|
|
36
|
+
if (match) return match[1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// https://datatracker.ietf.org/doc/rfc{number}/
|
|
40
|
+
// https://datatracker.ietf.org/doc/html/rfc{number}
|
|
41
|
+
if (hostname === "datatracker.ietf.org") {
|
|
42
|
+
const match = pathname.match(/\/doc\/(?:html\/)?rfc(\d+)\/?$/i);
|
|
43
|
+
if (match) return match[1];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// https://tools.ietf.org/html/rfc{number}
|
|
47
|
+
if (hostname === "tools.ietf.org") {
|
|
48
|
+
const match = pathname.match(/\/html\/rfc(\d+)$/i);
|
|
49
|
+
if (match) return match[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Clean up RFC plain text - remove page headers/footers and extra formatting
|
|
57
|
+
*/
|
|
58
|
+
function cleanRfcText(text: string): string {
|
|
59
|
+
const lines = text.split("\n");
|
|
60
|
+
const cleaned: string[] = [];
|
|
61
|
+
let skipNext = 0;
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < lines.length; i++) {
|
|
64
|
+
const line = lines[i];
|
|
65
|
+
|
|
66
|
+
// Skip lines we've marked to skip (form feeds and surrounding blank lines)
|
|
67
|
+
if (skipNext > 0) {
|
|
68
|
+
skipNext--;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Skip form feed characters and page headers (RFC NNNN ... Month Year pattern)
|
|
73
|
+
if (line.includes("\f")) {
|
|
74
|
+
// Skip the form feed line and typically 2-3 following header lines
|
|
75
|
+
skipNext = 3;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Skip page footer lines (typically just a page number or "[Page N]")
|
|
80
|
+
if (/^\s*\[Page \d+\]\s*$/.test(line)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
cleaned.push(line);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return cleaned.join("\n").replace(/\n{4,}/g, "\n\n\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Handle RFC Editor URLs - fetches IETF RFCs
|
|
92
|
+
*/
|
|
93
|
+
export const handleRfc: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = new URL(url);
|
|
96
|
+
const rfcNumber = extractRfcNumber(parsed);
|
|
97
|
+
|
|
98
|
+
if (!rfcNumber) return null;
|
|
99
|
+
|
|
100
|
+
const fetchedAt = new Date().toISOString();
|
|
101
|
+
const notes: string[] = [];
|
|
102
|
+
|
|
103
|
+
// Fetch metadata JSON and plain text in parallel
|
|
104
|
+
const metadataUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.json`;
|
|
105
|
+
const textUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.txt`;
|
|
106
|
+
|
|
107
|
+
const [metaResult, textResult] = await Promise.all([
|
|
108
|
+
loadPage(metadataUrl, { timeout: Math.min(timeout, 10) }),
|
|
109
|
+
loadPage(textUrl, { timeout }),
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
// We need at least the text content
|
|
113
|
+
if (!textResult.ok) return null;
|
|
114
|
+
|
|
115
|
+
let metadata: RfcMetadata | null = null;
|
|
116
|
+
if (metaResult.ok) {
|
|
117
|
+
try {
|
|
118
|
+
metadata = JSON.parse(metaResult.content);
|
|
119
|
+
notes.push("Metadata from RFC Editor JSON API");
|
|
120
|
+
} catch {
|
|
121
|
+
// JSON parse failed, continue without metadata
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build markdown output
|
|
126
|
+
let md = "";
|
|
127
|
+
|
|
128
|
+
if (metadata) {
|
|
129
|
+
md += `# RFC ${rfcNumber}: ${metadata.title}\n\n`;
|
|
130
|
+
|
|
131
|
+
// Authors
|
|
132
|
+
if (metadata.authors?.length) {
|
|
133
|
+
const authorList = metadata.authors
|
|
134
|
+
.map((a) => (a.affiliation ? `${a.name} (${a.affiliation})` : a.name))
|
|
135
|
+
.join(", ");
|
|
136
|
+
md += `**Authors:** ${authorList}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Publication info
|
|
140
|
+
if (metadata.pub_date) md += `**Published:** ${metadata.pub_date}\n`;
|
|
141
|
+
if (metadata.current_status) md += `**Status:** ${metadata.current_status}\n`;
|
|
142
|
+
if (metadata.stream) md += `**Stream:** ${metadata.stream}\n`;
|
|
143
|
+
if (metadata.area) md += `**Area:** ${metadata.area}\n`;
|
|
144
|
+
if (metadata.wg_acronym) md += `**Working Group:** ${metadata.wg_acronym}\n`;
|
|
145
|
+
if (metadata.page_count) md += `**Pages:** ${metadata.page_count}\n`;
|
|
146
|
+
|
|
147
|
+
// Related RFCs
|
|
148
|
+
if (metadata.obsoletes?.length) {
|
|
149
|
+
md += `**Obsoletes:** ${metadata.obsoletes.join(", ")}\n`;
|
|
150
|
+
}
|
|
151
|
+
if (metadata.obsoleted_by?.length) {
|
|
152
|
+
md += `**Obsoleted by:** ${metadata.obsoleted_by.join(", ")}\n`;
|
|
153
|
+
}
|
|
154
|
+
if (metadata.updates?.length) {
|
|
155
|
+
md += `**Updates:** ${metadata.updates.join(", ")}\n`;
|
|
156
|
+
}
|
|
157
|
+
if (metadata.updated_by?.length) {
|
|
158
|
+
md += `**Updated by:** ${metadata.updated_by.join(", ")}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Keywords
|
|
162
|
+
if (metadata.keywords?.length) {
|
|
163
|
+
md += `**Keywords:** ${metadata.keywords.join(", ")}\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Errata
|
|
167
|
+
if (metadata.errata_url) {
|
|
168
|
+
md += `**Errata:** ${metadata.errata_url}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
md += "\n";
|
|
172
|
+
|
|
173
|
+
// Abstract from metadata
|
|
174
|
+
if (metadata.abstract) {
|
|
175
|
+
md += `## Abstract\n\n${metadata.abstract}\n\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
md += "---\n\n";
|
|
179
|
+
} else {
|
|
180
|
+
// No metadata, use simple header
|
|
181
|
+
md += `# RFC ${rfcNumber}\n\n`;
|
|
182
|
+
notes.push("Metadata not available, showing plain text only");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Add full text content
|
|
186
|
+
md += "## Full Text\n\n";
|
|
187
|
+
md += "```\n";
|
|
188
|
+
md += cleanRfcText(textResult.content);
|
|
189
|
+
md += "\n```\n";
|
|
190
|
+
|
|
191
|
+
const output = finalizeOutput(md);
|
|
192
|
+
return {
|
|
193
|
+
url,
|
|
194
|
+
finalUrl: `https://www.rfc-editor.org/rfc/rfc${rfcNumber}`,
|
|
195
|
+
contentType: "text/markdown",
|
|
196
|
+
method: "rfc",
|
|
197
|
+
content: output.content,
|
|
198
|
+
fetchedAt,
|
|
199
|
+
truncated: output.truncated,
|
|
200
|
+
notes: notes.length ? notes : ["Fetched from RFC Editor"],
|
|
201
|
+
};
|
|
202
|
+
} catch {}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface RubyGemsDependency {
|
|
5
|
+
name: string;
|
|
6
|
+
requirements: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface RubyGemsResponse {
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
version_created_at?: string;
|
|
13
|
+
authors?: string;
|
|
14
|
+
info?: string;
|
|
15
|
+
licenses?: string[];
|
|
16
|
+
homepage_uri?: string;
|
|
17
|
+
source_code_uri?: string;
|
|
18
|
+
documentation_uri?: string;
|
|
19
|
+
project_uri?: string;
|
|
20
|
+
downloads: number;
|
|
21
|
+
version_downloads?: number;
|
|
22
|
+
gem_uri?: string;
|
|
23
|
+
dependencies?: {
|
|
24
|
+
development?: RubyGemsDependency[];
|
|
25
|
+
runtime?: RubyGemsDependency[];
|
|
26
|
+
};
|
|
27
|
+
metadata?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handle RubyGems URLs via API
|
|
32
|
+
*/
|
|
33
|
+
export const handleRubyGems: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = new URL(url);
|
|
36
|
+
if (parsed.hostname !== "rubygems.org" && parsed.hostname !== "www.rubygems.org") return null;
|
|
37
|
+
|
|
38
|
+
// Extract gem name from /gems/{name}
|
|
39
|
+
const match = parsed.pathname.match(/^\/gems\/([^/]+)/);
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
|
|
42
|
+
const gemName = decodeURIComponent(match[1]);
|
|
43
|
+
const fetchedAt = new Date().toISOString();
|
|
44
|
+
|
|
45
|
+
// Fetch from RubyGems API
|
|
46
|
+
const apiUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`;
|
|
47
|
+
const result = await loadPage(apiUrl, {
|
|
48
|
+
timeout,
|
|
49
|
+
headers: { Accept: "application/json" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!result.ok) return null;
|
|
53
|
+
|
|
54
|
+
let gem: RubyGemsResponse;
|
|
55
|
+
try {
|
|
56
|
+
gem = JSON.parse(result.content);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let md = `# ${gem.name}\n\n`;
|
|
62
|
+
if (gem.info) md += `${gem.info}\n\n`;
|
|
63
|
+
|
|
64
|
+
// Version and license
|
|
65
|
+
md += `**Version:** ${gem.version}`;
|
|
66
|
+
if (gem.licenses?.length) md += ` · **License:** ${gem.licenses.join(", ")}`;
|
|
67
|
+
md += "\n";
|
|
68
|
+
|
|
69
|
+
// Downloads
|
|
70
|
+
md += `**Total Downloads:** ${formatCount(gem.downloads)}`;
|
|
71
|
+
if (gem.version_downloads) md += ` · **Version Downloads:** ${formatCount(gem.version_downloads)}`;
|
|
72
|
+
md += "\n\n";
|
|
73
|
+
|
|
74
|
+
// Links
|
|
75
|
+
if (gem.homepage_uri) md += `**Homepage:** ${gem.homepage_uri}\n`;
|
|
76
|
+
if (gem.source_code_uri) md += `**Source Code:** ${gem.source_code_uri}\n`;
|
|
77
|
+
if (gem.documentation_uri) md += `**Documentation:** ${gem.documentation_uri}\n`;
|
|
78
|
+
if (gem.authors) md += `**Authors:** ${gem.authors}\n`;
|
|
79
|
+
|
|
80
|
+
// Runtime dependencies
|
|
81
|
+
const runtimeDeps = gem.dependencies?.runtime;
|
|
82
|
+
if (runtimeDeps && runtimeDeps.length > 0) {
|
|
83
|
+
md += `\n## Runtime Dependencies\n\n`;
|
|
84
|
+
for (const dep of runtimeDeps) {
|
|
85
|
+
md += `- ${dep.name} ${dep.requirements}\n`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Development dependencies
|
|
90
|
+
const devDeps = gem.dependencies?.development;
|
|
91
|
+
if (devDeps && devDeps.length > 0) {
|
|
92
|
+
md += `\n## Development Dependencies\n\n`;
|
|
93
|
+
for (const dep of devDeps) {
|
|
94
|
+
md += `- ${dep.name} ${dep.requirements}\n`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const output = finalizeOutput(md);
|
|
99
|
+
return {
|
|
100
|
+
url,
|
|
101
|
+
finalUrl: url,
|
|
102
|
+
contentType: "text/markdown",
|
|
103
|
+
method: "rubygems",
|
|
104
|
+
content: output.content,
|
|
105
|
+
fetchedAt,
|
|
106
|
+
truncated: output.truncated,
|
|
107
|
+
notes: ["Fetched via RubyGems API"],
|
|
108
|
+
};
|
|
109
|
+
} catch {}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
};
|