github-search-cli 0.7.0 → 0.8.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/README.md +66 -104
- package/bin/ghfind.js +14 -0
- package/completions/{search-cli.bash → ghfind.bash} +4 -4
- package/completions/ghfind.fish +19 -0
- package/completions/{search-cli.zsh → ghfind.zsh} +27 -27
- package/package.json +17 -9
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +239 -252
- package/src/compare.ts +46 -46
- package/src/config.ts +65 -65
- package/src/deepdive.ts +118 -124
- package/src/errors.ts +59 -62
- package/src/explore.ts +29 -30
- package/src/export.ts +63 -64
- package/src/help.ts +22 -30
- package/src/history.ts +91 -91
- package/src/init.ts +55 -55
- package/src/notifications.ts +65 -69
- package/src/open-url.ts +0 -1
- package/src/output.ts +67 -74
- package/src/pipe.ts +54 -60
- package/src/saved-searches.ts +58 -58
- package/src/search.ts +555 -0
- package/src/session.ts +36 -37
- package/src/share.ts +54 -49
- package/src/status.ts +61 -61
- package/src/themes.ts +52 -52
- package/src/tips.ts +27 -27
- package/src/trending.ts +556 -563
- package/src/tui.ts +1665 -1520
- package/src/types.ts +139 -147
- package/src/watch.ts +62 -65
- package/bin/search-cli.js +0 -17
- package/completions/search-cli.fish +0 -19
- package/src/cache.ts +0 -64
- package/src/normalizer.ts +0 -66
- package/src/provider.ts +0 -165
- package/src/query.ts +0 -249
- package/src/ranking.ts +0 -57
package/src/config.ts
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config system: load/save config from XDG paths, merge with env vars.
|
|
3
|
-
*
|
|
4
|
-
* Lookup order:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2. $XDG_CONFIG_HOME/
|
|
7
|
-
* 3. ~/.config/
|
|
8
|
-
*/
|
|
9
|
-
import type { Config } from "./types
|
|
10
|
-
import { homedir } from "os";
|
|
11
|
-
import { join } from "path";
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
-
|
|
14
|
-
/** Directory for persistent state (history, bookmarks, session). */
|
|
15
|
-
export function stateDir(): string {
|
|
16
|
-
const xdg = process.env.XDG_STATE_HOME;
|
|
17
|
-
const base = xdg ? join(xdg, "
|
|
18
|
-
return base;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Directory for cache files. */
|
|
22
|
-
export function cacheDir(): string {
|
|
23
|
-
const xdg = process.env.XDG_CACHE_HOME;
|
|
24
|
-
const base = xdg ? join(xdg, "
|
|
25
|
-
return base;
|
|
26
|
-
}
|
|
27
|
-
const DEFAULTS: Config = {
|
|
28
|
-
defaultSort: "best-match",
|
|
29
|
-
defaultLimit: 50,
|
|
30
|
-
theme: "tokyo-night",
|
|
31
|
-
cacheTtlSeconds: 300,
|
|
32
|
-
defaultTab: "search",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export function configPath(): string {
|
|
36
|
-
const envPath = process.env.
|
|
37
|
-
if (envPath) return envPath;
|
|
38
|
-
|
|
39
|
-
const xdg = process.env.XDG_CONFIG_HOME;
|
|
40
|
-
const base = xdg ? join(xdg, "
|
|
41
|
-
return join(base, "config.json");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function loadConfig(): Config {
|
|
45
|
-
const path = configPath();
|
|
46
|
-
try {
|
|
47
|
-
const raw = readFileSync(path, "utf-8");
|
|
48
|
-
const parsed = JSON.parse(raw);
|
|
49
|
-
return mergeWithEnv({ ...DEFAULTS, ...parsed });
|
|
50
|
-
} catch {
|
|
51
|
-
return mergeWithEnv({ ...DEFAULTS });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function saveConfig(config: Config): void {
|
|
56
|
-
const path = configPath();
|
|
57
|
-
const dir = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")));
|
|
58
|
-
mkdirSync(dir, { recursive: true });
|
|
59
|
-
writeFileSync(path, JSON.stringify(config, null, 2));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function mergeWithEnv(config: Config): Config {
|
|
63
|
-
if (process.env.GITHUB_TOKEN) config.githubToken = process.env.GITHUB_TOKEN;
|
|
64
|
-
return config;
|
|
65
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Config system: load/save config from XDG paths, merge with env vars.
|
|
3
|
+
*
|
|
4
|
+
* Lookup order:
|
|
5
|
+
* 1. GHFIND_CONFIG env var
|
|
6
|
+
* 2. $XDG_CONFIG_HOME/ghfind/config.json
|
|
7
|
+
* 3. ~/.config/ghfind/config.json
|
|
8
|
+
*/
|
|
9
|
+
import type { Config } from "./types";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
|
|
14
|
+
/** Directory for persistent state (history, bookmarks, session). */
|
|
15
|
+
export function stateDir(): string {
|
|
16
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
17
|
+
const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".local", "share", "ghfind");
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Directory for cache files. */
|
|
22
|
+
export function cacheDir(): string {
|
|
23
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
24
|
+
const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".cache", "ghfind");
|
|
25
|
+
return base;
|
|
26
|
+
}
|
|
27
|
+
const DEFAULTS: Config = {
|
|
28
|
+
defaultSort: "best-match",
|
|
29
|
+
defaultLimit: 50,
|
|
30
|
+
theme: "tokyo-night",
|
|
31
|
+
cacheTtlSeconds: 300,
|
|
32
|
+
defaultTab: "search",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function configPath(): string {
|
|
36
|
+
const envPath = process.env.GHFIND_CONFIG;
|
|
37
|
+
if (envPath) return envPath;
|
|
38
|
+
|
|
39
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
40
|
+
const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".config", "ghfind");
|
|
41
|
+
return join(base, "config.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function loadConfig(): Config {
|
|
45
|
+
const path = configPath();
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(path, "utf-8");
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
return mergeWithEnv({ ...DEFAULTS, ...parsed });
|
|
50
|
+
} catch {
|
|
51
|
+
return mergeWithEnv({ ...DEFAULTS });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function saveConfig(config: Config): void {
|
|
56
|
+
const path = configPath();
|
|
57
|
+
const dir = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")));
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
writeFileSync(path, JSON.stringify(config, null, 2));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mergeWithEnv(config: Config): Config {
|
|
63
|
+
if (process.env.GITHUB_TOKEN) config.githubToken = process.env.GITHUB_TOKEN;
|
|
64
|
+
return config;
|
|
65
|
+
}
|
package/src/deepdive.ts
CHANGED
|
@@ -1,124 +1,118 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
interface
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
fetch(`https://
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"──
|
|
113
|
-
data.
|
|
114
|
-
"",
|
|
115
|
-
"──
|
|
116
|
-
data.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
data.contributors,
|
|
120
|
-
"",
|
|
121
|
-
"── README ───────────────────────────────────────",
|
|
122
|
-
data.readme,
|
|
123
|
-
].join("\n");
|
|
124
|
-
}
|
|
1
|
+
/** Repo deep-dive — fetch and format rich repo details. */
|
|
2
|
+
import type { Repo } from "./types";
|
|
3
|
+
import { NetworkError } from "./errors";
|
|
4
|
+
|
|
5
|
+
const USER_AGENT = "ghfind/1.0";
|
|
6
|
+
|
|
7
|
+
/** Language breakdown: { [lang]: bytes } */
|
|
8
|
+
type LanguageMap = Record<string, number>;
|
|
9
|
+
|
|
10
|
+
/** Contributor summary */
|
|
11
|
+
interface Contributor {
|
|
12
|
+
login: string;
|
|
13
|
+
contributions: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** All deep-dive data for one repo. */
|
|
17
|
+
export interface DeepDiveData {
|
|
18
|
+
summary: string;
|
|
19
|
+
languages: string;
|
|
20
|
+
contributors: string;
|
|
21
|
+
readme: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fetch all deep-dive data for a repo. Returns formatted sections. */
|
|
25
|
+
export async function fetchDeepDive(repo: Repo, token?: string): Promise<DeepDiveData> {
|
|
26
|
+
const auth = token ? { Authorization: `Bearer ${token}` } : {};
|
|
27
|
+
const headers = { "User-Agent": USER_AGENT, ...auth };
|
|
28
|
+
|
|
29
|
+
const [languagesRes, contributorsRes] = await Promise.all([
|
|
30
|
+
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`, { headers }),
|
|
31
|
+
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/contributors?per_page=5`, { headers }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// README with branch fallback: main → master → README.rst
|
|
35
|
+
let readmeText = "";
|
|
36
|
+
for (const path of [`${repo.owner}/${repo.name}/main/README.md`, `${repo.owner}/${repo.name}/master/README.md`, `${repo.owner}/${repo.name}/main/README.rst`]) {
|
|
37
|
+
const r = await fetch(`https://raw.githubusercontent.com/${path}`, { headers });
|
|
38
|
+
if (r.ok) { readmeText = await r.text(); break; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
summary: formatSummary(repo),
|
|
43
|
+
languages: formatLanguages(languagesRes.ok ? await languagesRes.json().catch(() => ({})) : {}),
|
|
44
|
+
contributors: formatContributors(contributorsRes.ok ? await contributorsRes.json().catch(() => []) : []),
|
|
45
|
+
readme: formatReadme(readmeText),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatSummary(repo: Repo): string {
|
|
50
|
+
const lines = [
|
|
51
|
+
` ${repo.fullName}`,
|
|
52
|
+
` ★ ${repo.stars.toLocaleString()} ◆ ${repo.forks.toLocaleString()} ${repo.language ?? "—"}`,
|
|
53
|
+
repo.description ? ` ${repo.description}` : "",
|
|
54
|
+
repo.topics.length ? ` topics: ${repo.topics.slice(0, 8).join(", ")}` : "",
|
|
55
|
+
` ${repo.url}`,
|
|
56
|
+
];
|
|
57
|
+
return lines.filter(Boolean).join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatLanguages(langs: LanguageMap): string {
|
|
61
|
+
const entries = Object.entries(langs);
|
|
62
|
+
if (entries.length === 0) return " (no language data)";
|
|
63
|
+
const total = entries.reduce((s, [, v]) => s + v, 0);
|
|
64
|
+
const barW = 20;
|
|
65
|
+
return entries
|
|
66
|
+
.sort(([, a], [, b]) => b - a)
|
|
67
|
+
.slice(0, 8)
|
|
68
|
+
.map(([name, bytes]) => {
|
|
69
|
+
const pct = ((bytes / total) * 100).toFixed(1);
|
|
70
|
+
const filled = Math.round((bytes / total) * barW);
|
|
71
|
+
const bar = "█".repeat(filled) + "░".repeat(barW - filled);
|
|
72
|
+
return ` ${name.padEnd(16)} ${bar} ${pct}%`;
|
|
73
|
+
})
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatContributors(contributors: Contributor[]): string {
|
|
78
|
+
if (contributors.length === 0) return " (no contributor data)";
|
|
79
|
+
return contributors
|
|
80
|
+
.slice(0, 5)
|
|
81
|
+
.map((c) => ` ▲ ${c.login.padEnd(20)} ${c.contributions} commits`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatReadme(readme: string): string {
|
|
86
|
+
if (!readme) return " (no README)";
|
|
87
|
+
// Strip markdown formatting for terminal display
|
|
88
|
+
const stripped = readme
|
|
89
|
+
.replace(/```[\s\S]*?```/g, "[code block]")
|
|
90
|
+
.replace(/#{1,6}\s/g, "")
|
|
91
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
92
|
+
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
|
|
93
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((l) => l.trim())
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
|
|
98
|
+
const lines = stripped.slice(0, 30);
|
|
99
|
+
if (stripped.length > 30) lines.push("... (more lines, open in browser to read full)");
|
|
100
|
+
return lines.map((l) => ` ${l}`).join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build the full deep-dive display text from data. */
|
|
104
|
+
export function buildDeepDiveText(data: DeepDiveData): string {
|
|
105
|
+
return [
|
|
106
|
+
"── Summary ──────────────────────────────────────",
|
|
107
|
+
data.summary,
|
|
108
|
+
"",
|
|
109
|
+
"── Languages ────────────────────────────────────",
|
|
110
|
+
data.languages,
|
|
111
|
+
"",
|
|
112
|
+
"── Top Contributors ─────────────────────────────",
|
|
113
|
+
data.contributors,
|
|
114
|
+
"",
|
|
115
|
+
"── README ───────────────────────────────────────",
|
|
116
|
+
data.readme,
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,62 +1,59 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
this.name = "ParseError";
|
|
61
|
-
}
|
|
62
|
-
}
|
|
1
|
+
/** Structured error types with user-facing messages. */
|
|
2
|
+
|
|
3
|
+
export class SearchCliError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
message: string,
|
|
6
|
+
public readonly userMessage: string,
|
|
7
|
+
public readonly recoverable: boolean = true,
|
|
8
|
+
) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "SearchCliError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class NetworkError extends SearchCliError {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(
|
|
17
|
+
"Network request failed",
|
|
18
|
+
"⚠ Network error — check your connection. Retrying in 5s... [r]etry now",
|
|
19
|
+
);
|
|
20
|
+
this.name = "NetworkError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RateLimitError extends SearchCliError {
|
|
25
|
+
constructor(public readonly hasToken: boolean, resetSeconds?: number) {
|
|
26
|
+
const msg = hasToken
|
|
27
|
+
? `⚠ API rate limit exceeded (5,000/hr). Resets in ${resetSeconds ?? "?"}s. [r]etry [c]hange token`
|
|
28
|
+
: "⚠ Rate limited (60/hr). Set GITHUB_TOKEN for 5,000/hr. [r]etry";
|
|
29
|
+
super(msg, msg);
|
|
30
|
+
this.name = "RateLimitError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class BadQueryError extends SearchCliError {
|
|
35
|
+
constructor(public readonly detail: string) {
|
|
36
|
+
super(`Invalid query: ${detail}`, `⚠ Invalid query: ${detail}`);
|
|
37
|
+
this.name = "BadQueryError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NoResultsError extends SearchCliError {
|
|
42
|
+
constructor(public readonly query: string) {
|
|
43
|
+
super(
|
|
44
|
+
`No results for "${query}"`,
|
|
45
|
+
`🔍 No results for "${query}". Tips: check spelling, try fewer qualifiers, or [b]rowse trending`,
|
|
46
|
+
);
|
|
47
|
+
this.name = "NoResultsError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ParseError extends SearchCliError {
|
|
52
|
+
constructor(source: string, detail: string) {
|
|
53
|
+
super(
|
|
54
|
+
`Parse error from ${source}: ${detail}`,
|
|
55
|
+
`⚠ Could not parse ${source}. GitHub may have changed the layout. [r]etry`,
|
|
56
|
+
);
|
|
57
|
+
this.name = "ParseError";
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/explore.ts
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Topic explorer — browse popular GitHub topics.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Topic explorer — browse popular GitHub topics.
|
|
3
|
+
*
|
|
4
|
+
* Selecting a topic triggers a search.
|
|
5
|
+
*/
|
|
6
|
+
export interface TopicItem {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
repoCount: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Fetch popular topics from GitHub search API.
|
|
13
|
+
* Requires the `mercy-preview` media type for the /search/topics endpoint.
|
|
14
|
+
*/
|
|
15
|
+
export async function fetchTopics(): Promise<TopicItem[]> {
|
|
16
|
+
const res = await fetch("https://api.github.com/search/topics?q=popular&per_page=30", {
|
|
17
|
+
headers: {
|
|
18
|
+
Accept: "application/vnd.github.mercy-preview+json",
|
|
19
|
+
"User-Agent": "search-cli",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) throw new Error(`Topics API: HTTP ${res.status}`);
|
|
23
|
+
const data = await res.json() as { items?: Array<{ name: string; description?: string; score?: number }> };
|
|
24
|
+
return (data.items ?? []).map((item) => ({
|
|
25
|
+
name: item.name,
|
|
26
|
+
description: item.description || "",
|
|
27
|
+
repoCount: item.score || 0,
|
|
28
|
+
}));
|
|
29
|
+
}
|