@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,174 @@
|
|
|
1
|
+
import type { SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface MDNSection {
|
|
5
|
+
type: string;
|
|
6
|
+
value: {
|
|
7
|
+
id?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
isH3?: boolean;
|
|
11
|
+
code?: string;
|
|
12
|
+
language?: string;
|
|
13
|
+
items?: Array<{ term: string; description: string }>;
|
|
14
|
+
rows?: string[][];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MDNDoc {
|
|
19
|
+
doc: {
|
|
20
|
+
title: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
mdn_url: string;
|
|
23
|
+
body: MDNSection[];
|
|
24
|
+
browserCompat?: unknown;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert MDN body sections to markdown
|
|
30
|
+
*/
|
|
31
|
+
function convertMDNBody(sections: MDNSection[]): string {
|
|
32
|
+
const parts: string[] = [];
|
|
33
|
+
|
|
34
|
+
for (const section of sections) {
|
|
35
|
+
const { type, value } = section;
|
|
36
|
+
|
|
37
|
+
switch (type) {
|
|
38
|
+
case "prose":
|
|
39
|
+
if (value.content) {
|
|
40
|
+
const markdown = htmlToBasicMarkdown(value.content);
|
|
41
|
+
if (value.title) {
|
|
42
|
+
const level = value.isH3 ? "###" : "##";
|
|
43
|
+
parts.push(`${level} ${value.title}\n\n${markdown}`);
|
|
44
|
+
} else {
|
|
45
|
+
parts.push(markdown);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
|
|
50
|
+
case "browser_compatibility":
|
|
51
|
+
if (value.title) {
|
|
52
|
+
parts.push(`## ${value.title}\n\n(See browser compatibility data at MDN)`);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case "specifications":
|
|
57
|
+
if (value.title) {
|
|
58
|
+
parts.push(`## ${value.title}\n\n(See specifications at MDN)`);
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
|
|
62
|
+
case "code_example":
|
|
63
|
+
if (value.title) {
|
|
64
|
+
parts.push(`### ${value.title}`);
|
|
65
|
+
}
|
|
66
|
+
if (value.code) {
|
|
67
|
+
const lang = value.language || "";
|
|
68
|
+
parts.push(`\`\`\`${lang}\n${value.code}\n\`\`\``);
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
|
|
72
|
+
case "definition_list":
|
|
73
|
+
if (value.items) {
|
|
74
|
+
for (const item of value.items) {
|
|
75
|
+
parts.push(`**${item.term}**`);
|
|
76
|
+
const desc = htmlToBasicMarkdown(item.description);
|
|
77
|
+
parts.push(desc);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case "table":
|
|
83
|
+
if (value.rows && value.rows.length > 0) {
|
|
84
|
+
// Simple markdown table
|
|
85
|
+
const header = value.rows[0].map((cell) => htmlToBasicMarkdown(cell)).join(" | ");
|
|
86
|
+
const separator = value.rows[0].map(() => "---").join(" | ");
|
|
87
|
+
const bodyRows = value.rows
|
|
88
|
+
.slice(1)
|
|
89
|
+
.map((row) => row.map((cell) => htmlToBasicMarkdown(cell)).join(" | "));
|
|
90
|
+
|
|
91
|
+
parts.push(`| ${header} |`);
|
|
92
|
+
parts.push(`| ${separator} |`);
|
|
93
|
+
for (const row of bodyRows) {
|
|
94
|
+
parts.push(`| ${row} |`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
default:
|
|
100
|
+
// Skip unknown types
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return parts.join("\n\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const handleMDN: SpecialHandler = async (url: string, timeout: number) => {
|
|
109
|
+
const urlObj = new URL(url);
|
|
110
|
+
|
|
111
|
+
// Only handle developer.mozilla.org
|
|
112
|
+
if (!urlObj.hostname.includes("developer.mozilla.org")) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Only handle docs paths
|
|
117
|
+
if (!urlObj.pathname.includes("/docs/")) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const notes: string[] = [];
|
|
122
|
+
|
|
123
|
+
// Construct JSON API URL
|
|
124
|
+
const jsonUrl = url.replace(/\/?$/, "/index.json");
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const result = await loadPage(jsonUrl, { timeout, headers: { Accept: "application/json" } });
|
|
128
|
+
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
notes.push(`Failed to fetch MDN JSON API (status ${result.status || "unknown"})`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const data: MDNDoc = JSON.parse(result.content);
|
|
135
|
+
const { doc } = data;
|
|
136
|
+
|
|
137
|
+
if (!doc || !doc.title) {
|
|
138
|
+
notes.push("Invalid MDN JSON structure");
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build markdown content
|
|
143
|
+
const parts: string[] = [];
|
|
144
|
+
|
|
145
|
+
parts.push(`# ${doc.title}`);
|
|
146
|
+
|
|
147
|
+
if (doc.summary) {
|
|
148
|
+
const summary = htmlToBasicMarkdown(doc.summary);
|
|
149
|
+
parts.push(summary);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (doc.body && doc.body.length > 0) {
|
|
153
|
+
const bodyMarkdown = convertMDNBody(doc.body);
|
|
154
|
+
parts.push(bodyMarkdown);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const rawContent = parts.join("\n\n");
|
|
158
|
+
const { content, truncated } = finalizeOutput(rawContent);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
url,
|
|
162
|
+
finalUrl: doc.mdn_url || result.finalUrl,
|
|
163
|
+
contentType: "text/markdown",
|
|
164
|
+
method: "mdn",
|
|
165
|
+
content,
|
|
166
|
+
fetchedAt: new Date().toISOString(),
|
|
167
|
+
truncated,
|
|
168
|
+
notes,
|
|
169
|
+
};
|
|
170
|
+
} catch (err) {
|
|
171
|
+
notes.push(`MDN handler error: ${err instanceof Error ? err.message : String(err)}`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleHuggingFace } from "./huggingface";
|
|
3
|
+
import { handleSpotify } from "./spotify";
|
|
4
|
+
import { handleVimeo } from "./vimeo";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleVimeo", () => {
|
|
9
|
+
it("returns null for non-Vimeo URLs", async () => {
|
|
10
|
+
const result = await handleVimeo("https://example.com", 10);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns null for invalid Vimeo URLs", async () => {
|
|
15
|
+
const result = await handleVimeo("https://vimeo.com/invalid", 10);
|
|
16
|
+
expect(result).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fetches video metadata via oEmbed", async () => {
|
|
20
|
+
const result = await handleVimeo("https://vimeo.com/1084537", 20);
|
|
21
|
+
expect(result).not.toBeNull();
|
|
22
|
+
expect(result?.method).toBe("vimeo");
|
|
23
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
24
|
+
expect(result?.content).toContain("Video ID");
|
|
25
|
+
expect(result?.notes).toContain("Fetched via Vimeo oEmbed API");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("handles player.vimeo.com URLs", async () => {
|
|
29
|
+
const result = await handleVimeo("https://player.vimeo.com/video/1084537", 20);
|
|
30
|
+
expect(result).not.toBeNull();
|
|
31
|
+
expect(result?.method).toBe("vimeo");
|
|
32
|
+
expect(result?.content).toContain("Video ID");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("handles vimeo.com/user/video format", async () => {
|
|
36
|
+
const result = await handleVimeo("https://vimeo.com/staffpicks/1084537", 20);
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result?.method).toBe("vimeo");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe.skipIf(SKIP)("handleSpotify", () => {
|
|
43
|
+
it("returns null for non-Spotify URLs", async () => {
|
|
44
|
+
const result = await handleSpotify("https://example.com", 10);
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns null for invalid Spotify URLs", async () => {
|
|
49
|
+
const result = await handleSpotify("https://open.spotify.com/invalid/xyz", 10);
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("identifies track URLs", async () => {
|
|
54
|
+
const result = await handleSpotify("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", 20);
|
|
55
|
+
expect(result).not.toBeNull();
|
|
56
|
+
expect(result?.method).toBe("spotify");
|
|
57
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
58
|
+
expect(result?.content).toContain("Type");
|
|
59
|
+
expect(result?.content).toContain("track");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("identifies album URLs", async () => {
|
|
63
|
+
const result = await handleSpotify("https://open.spotify.com/album/2ODvWsOgouMbaA5xf0RkJe", 20);
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result?.method).toBe("spotify");
|
|
66
|
+
expect(result?.content).toContain("album");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("identifies playlist URLs", async () => {
|
|
70
|
+
const result = await handleSpotify("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", 20);
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result?.method).toBe("spotify");
|
|
73
|
+
expect(result?.content).toContain("playlist");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("identifies podcast episode URLs", async () => {
|
|
77
|
+
const result = await handleSpotify("https://open.spotify.com/episode/0Q86acNRm6V9GYx55SXKwf", 20);
|
|
78
|
+
expect(result).not.toBeNull();
|
|
79
|
+
expect(result?.method).toBe("spotify");
|
|
80
|
+
expect(result?.content).toContain("podcast-episode");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("identifies podcast show URLs", async () => {
|
|
84
|
+
const result = await handleSpotify("https://open.spotify.com/show/2MAi0BvDc6GTFvKFPXnkCL", 20);
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result?.method).toBe("spotify");
|
|
87
|
+
expect(result?.content).toContain("podcast-show");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe.skipIf(SKIP)("handleHuggingFace", () => {
|
|
92
|
+
it("returns null for non-HF URLs", async () => {
|
|
93
|
+
const result = await handleHuggingFace("https://example.com", 10);
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null for invalid HF URLs", async () => {
|
|
98
|
+
const result = await handleHuggingFace("https://huggingface.co", 10);
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("fetches model info", async () => {
|
|
103
|
+
const result = await handleHuggingFace("https://huggingface.co/bert-base-uncased", 20);
|
|
104
|
+
expect(result).not.toBeNull();
|
|
105
|
+
expect(result?.method).toBe("huggingface");
|
|
106
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
107
|
+
expect(result?.content).toContain("bert-base-uncased");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("fetches dataset info", async () => {
|
|
111
|
+
const result = await handleHuggingFace("https://huggingface.co/datasets/squad", 20);
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result?.method).toBe("huggingface");
|
|
114
|
+
expect(result?.content).toContain("squad");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("fetches space info", async () => {
|
|
118
|
+
const result = await handleHuggingFace("https://huggingface.co/spaces/gradio/hello_world", 20);
|
|
119
|
+
expect(result).not.toBeNull();
|
|
120
|
+
expect(result?.method).toBe("huggingface");
|
|
121
|
+
expect(result?.content).toContain("gradio/hello_world");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("fetches model without org prefix", async () => {
|
|
125
|
+
// Some models like bert-base-uncased don't have an org prefix
|
|
126
|
+
const result = await handleHuggingFace("https://huggingface.co/bert-base-uncased", 20);
|
|
127
|
+
expect(result).not.toBeNull();
|
|
128
|
+
expect(result?.method).toBe("huggingface");
|
|
129
|
+
expect(result?.content).toContain("bert-base-uncased");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("handles org/model format", async () => {
|
|
133
|
+
const result = await handleHuggingFace("https://huggingface.co/google/bert_uncased_L-2_H-128_A-2", 20);
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(result?.method).toBe("huggingface");
|
|
136
|
+
expect(result?.content).toContain("google/bert_uncased_L-2_H-128_A-2");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface ModuleResponse {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
abstract?: string;
|
|
8
|
+
author: string;
|
|
9
|
+
distribution: string;
|
|
10
|
+
release: string;
|
|
11
|
+
path: string;
|
|
12
|
+
pod?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReleaseResponse {
|
|
16
|
+
name: string;
|
|
17
|
+
version: string;
|
|
18
|
+
abstract?: string;
|
|
19
|
+
author: string;
|
|
20
|
+
distribution: string;
|
|
21
|
+
license?: string[];
|
|
22
|
+
stat?: { mtime: number };
|
|
23
|
+
download_url?: string;
|
|
24
|
+
dependency?: Array<{
|
|
25
|
+
module: string;
|
|
26
|
+
version: string;
|
|
27
|
+
phase: string;
|
|
28
|
+
relationship: string;
|
|
29
|
+
}>;
|
|
30
|
+
metadata?: {
|
|
31
|
+
resources?: {
|
|
32
|
+
repository?: { url?: string; web?: string };
|
|
33
|
+
homepage?: string;
|
|
34
|
+
bugtracker?: { web?: string };
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle MetaCPAN URLs via fastapi.metacpan.org
|
|
41
|
+
*/
|
|
42
|
+
export const handleMetaCPAN: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(url);
|
|
45
|
+
if (parsed.hostname !== "metacpan.org" && parsed.hostname !== "www.metacpan.org") return null;
|
|
46
|
+
|
|
47
|
+
const fetchedAt = new Date().toISOString();
|
|
48
|
+
|
|
49
|
+
// Match /pod/Module::Name pattern
|
|
50
|
+
const podMatch = parsed.pathname.match(/^\/pod\/(.+?)(?:\/|$)/);
|
|
51
|
+
if (podMatch) {
|
|
52
|
+
const moduleName = decodeURIComponent(podMatch[1]);
|
|
53
|
+
return await fetchModule(url, moduleName, timeout, fetchedAt);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Match /release/AUTHOR/Distribution pattern
|
|
57
|
+
const releaseMatch = parsed.pathname.match(/^\/release\/([^/]+)\/([^/]+)/);
|
|
58
|
+
if (releaseMatch) {
|
|
59
|
+
const distribution = decodeURIComponent(releaseMatch[2]);
|
|
60
|
+
return await fetchRelease(url, distribution, timeout, fetchedAt);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Match /release/Distribution pattern (without author)
|
|
64
|
+
const simpleReleaseMatch = parsed.pathname.match(/^\/release\/([^/]+)$/);
|
|
65
|
+
if (simpleReleaseMatch) {
|
|
66
|
+
const distribution = decodeURIComponent(simpleReleaseMatch[1]);
|
|
67
|
+
return await fetchRelease(url, distribution, timeout, fetchedAt);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
async function fetchModule(
|
|
77
|
+
url: string,
|
|
78
|
+
moduleName: string,
|
|
79
|
+
timeout: number,
|
|
80
|
+
fetchedAt: string,
|
|
81
|
+
): Promise<RenderResult | null> {
|
|
82
|
+
const apiUrl = `https://fastapi.metacpan.org/v1/module/${moduleName}`;
|
|
83
|
+
const result = await loadPage(apiUrl, { timeout });
|
|
84
|
+
|
|
85
|
+
if (!result.ok) return null;
|
|
86
|
+
|
|
87
|
+
let module: ModuleResponse;
|
|
88
|
+
try {
|
|
89
|
+
module = JSON.parse(result.content);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fetch additional release info for dependencies and metadata
|
|
95
|
+
const releaseUrl = `https://fastapi.metacpan.org/v1/release/${module.distribution}`;
|
|
96
|
+
const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5) });
|
|
97
|
+
|
|
98
|
+
let release: ReleaseResponse | null = null;
|
|
99
|
+
if (releaseResult.ok) {
|
|
100
|
+
try {
|
|
101
|
+
release = JSON.parse(releaseResult.content);
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const md = formatModuleMarkdown(module, release);
|
|
106
|
+
const output = finalizeOutput(md);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
url,
|
|
110
|
+
finalUrl: url,
|
|
111
|
+
contentType: "text/markdown",
|
|
112
|
+
method: "metacpan",
|
|
113
|
+
content: output.content,
|
|
114
|
+
fetchedAt,
|
|
115
|
+
truncated: output.truncated,
|
|
116
|
+
notes: ["Fetched via MetaCPAN API"],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchRelease(
|
|
121
|
+
url: string,
|
|
122
|
+
distribution: string,
|
|
123
|
+
timeout: number,
|
|
124
|
+
fetchedAt: string,
|
|
125
|
+
): Promise<RenderResult | null> {
|
|
126
|
+
const apiUrl = `https://fastapi.metacpan.org/v1/release/${distribution}`;
|
|
127
|
+
const result = await loadPage(apiUrl, { timeout });
|
|
128
|
+
|
|
129
|
+
if (!result.ok) return null;
|
|
130
|
+
|
|
131
|
+
let release: ReleaseResponse;
|
|
132
|
+
try {
|
|
133
|
+
release = JSON.parse(result.content);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const md = formatReleaseMarkdown(release);
|
|
139
|
+
const output = finalizeOutput(md);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
url,
|
|
143
|
+
finalUrl: url,
|
|
144
|
+
contentType: "text/markdown",
|
|
145
|
+
method: "metacpan",
|
|
146
|
+
content: output.content,
|
|
147
|
+
fetchedAt,
|
|
148
|
+
truncated: output.truncated,
|
|
149
|
+
notes: ["Fetched via MetaCPAN API"],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatModuleMarkdown(module: ModuleResponse, release: ReleaseResponse | null): string {
|
|
154
|
+
let md = `# ${module.name}\n\n`;
|
|
155
|
+
if (module.abstract) md += `${module.abstract}\n\n`;
|
|
156
|
+
|
|
157
|
+
md += `**Version:** ${module.version}`;
|
|
158
|
+
md += ` · **Distribution:** ${module.distribution}`;
|
|
159
|
+
md += ` · **Author:** [${module.author}](https://metacpan.org/author/${module.author})\n`;
|
|
160
|
+
|
|
161
|
+
if (release) {
|
|
162
|
+
if (release.license?.length) {
|
|
163
|
+
md += `**License:** ${release.license.join(", ")}\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const resources = release.metadata?.resources;
|
|
167
|
+
if (resources?.repository?.web || resources?.repository?.url) {
|
|
168
|
+
const repoUrl = resources.repository.web || resources.repository.url;
|
|
169
|
+
md += `**Repository:** ${repoUrl}\n`;
|
|
170
|
+
}
|
|
171
|
+
if (resources?.homepage) {
|
|
172
|
+
md += `**Homepage:** ${resources.homepage}\n`;
|
|
173
|
+
}
|
|
174
|
+
if (resources?.bugtracker?.web) {
|
|
175
|
+
md += `**Issues:** ${resources.bugtracker.web}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Show runtime dependencies
|
|
179
|
+
const runtimeDeps = release.dependency?.filter(
|
|
180
|
+
(d) => d.phase === "runtime" && d.relationship === "requires" && d.module !== "perl",
|
|
181
|
+
);
|
|
182
|
+
if (runtimeDeps?.length) {
|
|
183
|
+
md += `\n## Dependencies\n\n`;
|
|
184
|
+
for (const dep of runtimeDeps.slice(0, 20)) {
|
|
185
|
+
md += `- **${dep.module}**`;
|
|
186
|
+
if (dep.version && dep.version !== "0") md += ` >= ${dep.version}`;
|
|
187
|
+
md += "\n";
|
|
188
|
+
}
|
|
189
|
+
if (runtimeDeps.length > 20) {
|
|
190
|
+
md += `\n*...and ${runtimeDeps.length - 20} more*\n`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
md += `\n## Installation\n\n\`\`\`bash\ncpanm ${module.name}\n\`\`\`\n`;
|
|
196
|
+
|
|
197
|
+
return md;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatReleaseMarkdown(release: ReleaseResponse): string {
|
|
201
|
+
let md = `# ${release.distribution}\n\n`;
|
|
202
|
+
if (release.abstract) md += `${release.abstract}\n\n`;
|
|
203
|
+
|
|
204
|
+
md += `**Version:** ${release.version}`;
|
|
205
|
+
md += ` · **Author:** [${release.author}](https://metacpan.org/author/${release.author})\n`;
|
|
206
|
+
|
|
207
|
+
if (release.license?.length) {
|
|
208
|
+
md += `**License:** ${release.license.join(", ")}\n`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (release.stat?.mtime) {
|
|
212
|
+
const date = new Date(release.stat.mtime * 1000).toISOString().split("T")[0];
|
|
213
|
+
md += `**Released:** ${date}\n`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const resources = release.metadata?.resources;
|
|
217
|
+
if (resources?.repository?.web || resources?.repository?.url) {
|
|
218
|
+
const repoUrl = resources.repository.web || resources.repository.url;
|
|
219
|
+
md += `**Repository:** ${repoUrl}\n`;
|
|
220
|
+
}
|
|
221
|
+
if (resources?.homepage) {
|
|
222
|
+
md += `**Homepage:** ${resources.homepage}\n`;
|
|
223
|
+
}
|
|
224
|
+
if (resources?.bugtracker?.web) {
|
|
225
|
+
md += `**Issues:** ${resources.bugtracker.web}\n`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Show runtime dependencies
|
|
229
|
+
const runtimeDeps = release.dependency?.filter(
|
|
230
|
+
(d) => d.phase === "runtime" && d.relationship === "requires" && d.module !== "perl",
|
|
231
|
+
);
|
|
232
|
+
if (runtimeDeps?.length) {
|
|
233
|
+
md += `\n## Dependencies\n\n`;
|
|
234
|
+
for (const dep of runtimeDeps.slice(0, 20)) {
|
|
235
|
+
md += `- **${dep.module}**`;
|
|
236
|
+
if (dep.version && dep.version !== "0") md += ` >= ${dep.version}`;
|
|
237
|
+
md += "\n";
|
|
238
|
+
}
|
|
239
|
+
if (runtimeDeps.length > 20) {
|
|
240
|
+
md += `\n*...and ${runtimeDeps.length - 20} more*\n`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
md += `\n## Installation\n\n\`\`\`bash\ncpanm ${release.distribution}\n\`\`\`\n`;
|
|
245
|
+
|
|
246
|
+
return md;
|
|
247
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Handle npm URLs via registry API
|
|
6
|
+
*/
|
|
7
|
+
export const handleNpm: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = new URL(url);
|
|
10
|
+
if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return null;
|
|
11
|
+
|
|
12
|
+
// Extract package name from /package/[scope/]name
|
|
13
|
+
const match = parsed.pathname.match(/^\/package\/(.+?)(?:\/|$)/);
|
|
14
|
+
if (!match) return null;
|
|
15
|
+
|
|
16
|
+
let packageName = decodeURIComponent(match[1]);
|
|
17
|
+
// Handle scoped packages: /package/@scope/name
|
|
18
|
+
if (packageName.startsWith("@")) {
|
|
19
|
+
const scopeMatch = parsed.pathname.match(/^\/package\/(@[^/]+\/[^/]+)/);
|
|
20
|
+
if (scopeMatch) packageName = decodeURIComponent(scopeMatch[1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fetchedAt = new Date().toISOString();
|
|
24
|
+
|
|
25
|
+
// Fetch from npm registry - use /latest endpoint for smaller response
|
|
26
|
+
const latestUrl = `https://registry.npmjs.org/${packageName}/latest`;
|
|
27
|
+
const downloadsUrl = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`;
|
|
28
|
+
|
|
29
|
+
// Fetch package info and download stats in parallel
|
|
30
|
+
const [result, downloadsResult] = await Promise.all([
|
|
31
|
+
loadPage(latestUrl, { timeout }),
|
|
32
|
+
loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
if (!result.ok) return null;
|
|
36
|
+
|
|
37
|
+
// Parse download stats
|
|
38
|
+
let weeklyDownloads: number | null = null;
|
|
39
|
+
if (downloadsResult.ok) {
|
|
40
|
+
try {
|
|
41
|
+
const dlData = JSON.parse(downloadsResult.content) as { downloads?: number };
|
|
42
|
+
weeklyDownloads = dlData.downloads ?? null;
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let pkg: {
|
|
47
|
+
name: string;
|
|
48
|
+
version: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
license?: string;
|
|
51
|
+
homepage?: string;
|
|
52
|
+
repository?: { url: string } | string;
|
|
53
|
+
keywords?: string[];
|
|
54
|
+
maintainers?: Array<{ name: string }>;
|
|
55
|
+
dependencies?: Record<string, string>;
|
|
56
|
+
readme?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
pkg = JSON.parse(result.content);
|
|
61
|
+
} catch {
|
|
62
|
+
return null; // JSON parse failed (truncated response)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let md = `# ${pkg.name}\n\n`;
|
|
66
|
+
if (pkg.description) md += `${pkg.description}\n\n`;
|
|
67
|
+
|
|
68
|
+
md += `**Latest:** ${pkg.version || "unknown"}`;
|
|
69
|
+
if (pkg.license) md += ` · **License:** ${typeof pkg.license === "string" ? pkg.license : pkg.license}`;
|
|
70
|
+
md += "\n";
|
|
71
|
+
if (weeklyDownloads !== null) {
|
|
72
|
+
md += `**Weekly Downloads:** ${formatCount(weeklyDownloads)}\n`;
|
|
73
|
+
}
|
|
74
|
+
md += "\n";
|
|
75
|
+
|
|
76
|
+
if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`;
|
|
77
|
+
const repoUrl = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
|
|
78
|
+
if (repoUrl) md += `**Repository:** ${repoUrl.replace(/^git\+/, "").replace(/\.git$/, "")}\n`;
|
|
79
|
+
if (pkg.keywords?.length) md += `**Keywords:** ${pkg.keywords.join(", ")}\n`;
|
|
80
|
+
if (pkg.maintainers?.length) md += `**Maintainers:** ${pkg.maintainers.map((m) => m.name).join(", ")}\n`;
|
|
81
|
+
|
|
82
|
+
if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
|
|
83
|
+
md += `\n## Dependencies\n\n`;
|
|
84
|
+
for (const [dep, version] of Object.entries(pkg.dependencies)) {
|
|
85
|
+
md += `- ${dep}: ${version}\n`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (pkg.readme) {
|
|
90
|
+
md += `\n---\n\n## README\n\n${pkg.readme}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const output = finalizeOutput(md);
|
|
94
|
+
return {
|
|
95
|
+
url,
|
|
96
|
+
finalUrl: url,
|
|
97
|
+
contentType: "text/markdown",
|
|
98
|
+
method: "npm",
|
|
99
|
+
content: output.content,
|
|
100
|
+
fetchedAt,
|
|
101
|
+
truncated: output.truncated,
|
|
102
|
+
notes: ["Fetched via npm registry"],
|
|
103
|
+
};
|
|
104
|
+
} catch {}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
};
|