github-search-cli 0.6.1 → 0.7.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 +183 -188
- package/demo.svg +66 -0
- package/package.json +2 -1
- package/src/cli.ts +13 -3
- package/src/config.ts +1 -2
- package/src/errors.ts +2 -2
- package/src/explore.ts +17 -32
- package/src/export.ts +9 -1
- package/src/help.ts +120 -46
- package/src/init.ts +9 -1
- package/src/normalizer.ts +17 -17
- package/src/open-url.ts +17 -0
- package/src/output.ts +2 -1
- package/src/pipe.ts +2 -7
- package/src/provider.ts +32 -9
- package/src/query.ts +5 -7
- package/src/ranking.ts +5 -3
- package/src/session.ts +4 -2
- package/src/trending.ts +41 -65
- package/src/tui.ts +75 -24
- package/src/watch.ts +13 -1
package/src/help.ts
CHANGED
|
@@ -1,46 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Help screen
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Help screen — structured sections rendered by OpenTUI components.
|
|
3
|
+
*
|
|
4
|
+
* Each section has a title (with category color) and formatted rows.
|
|
5
|
+
* The TUI builds these into Box + TextRenderable components inside a ScrollBox.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface HelpRow {
|
|
9
|
+
keys: string;
|
|
10
|
+
action: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HelpSection {
|
|
14
|
+
title: string;
|
|
15
|
+
/** Theme color key or hex — used for Box title color */
|
|
16
|
+
titleColor: string;
|
|
17
|
+
rows: HelpRow[];
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildHelpSections(): HelpSection[] {
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
title: " Navigation ",
|
|
25
|
+
titleColor: "blue",
|
|
26
|
+
rows: [
|
|
27
|
+
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
28
|
+
{ keys: "Enter / o", action: "Open repo in browser" },
|
|
29
|
+
{ keys: "/", action: "Focus search input" },
|
|
30
|
+
{ keys: "t", action: "Toggle search / trending" },
|
|
31
|
+
{ keys: "? / Ctrl+H", action: "Toggle this help screen" },
|
|
32
|
+
{ keys: "q", action: "Quit" },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
title: " Search Mode ",
|
|
37
|
+
titleColor: "green",
|
|
38
|
+
rows: [
|
|
39
|
+
{ keys: "s", action: "Cycle sort: best-match → stars → …" },
|
|
40
|
+
{ keys: "l", action: "Cycle limit: 10 → 25 → 50 → 100" },
|
|
41
|
+
{ keys: "r", action: "Refresh / re-run last search" },
|
|
42
|
+
{ keys: "g", action: "Toggle activity graph fullscreen" },
|
|
43
|
+
{ keys: "Tab", action: "Auto-complete qualifier" },
|
|
44
|
+
{ keys: "b", action: "Bookmark / unbookmark selected repo" },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: " Trending Mode ",
|
|
49
|
+
titleColor: "yellow",
|
|
50
|
+
rows: [
|
|
51
|
+
{ keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
|
|
52
|
+
{ keys: "← → / h l", action: "Navigate tabs" },
|
|
53
|
+
{ keys: "r", action: "Refresh" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
title: " Power Tools ",
|
|
58
|
+
titleColor: "purple",
|
|
59
|
+
rows: [
|
|
60
|
+
{ keys: "d", action: "Deep-dive: README + commit activity" },
|
|
61
|
+
{ keys: "c", action: "Add/remove repo from comparison" },
|
|
62
|
+
{ keys: "C (Shift+c)", action: "Show comparison view" },
|
|
63
|
+
{ keys: "E (Shift+e)", action: "Topic explorer" },
|
|
64
|
+
{ keys: "B (Shift+b)", action: "Bookmarks panel" },
|
|
65
|
+
{ keys: "PageDown / Ctrl+F", action: "Next page of results" },
|
|
66
|
+
{ keys: "PageUp / Ctrl+B", action: "Scroll to top of results" },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: " Overlays ",
|
|
71
|
+
titleColor: "teal",
|
|
72
|
+
rows: [
|
|
73
|
+
{ keys: "Ctrl+R", action: "Search history" },
|
|
74
|
+
{ keys: "Ctrl+O", action: "Saved searches" },
|
|
75
|
+
{ keys: "Ctrl+E", action: "Export results" },
|
|
76
|
+
{ keys: "Ctrl+N", action: "Notifications" },
|
|
77
|
+
{ keys: "Ctrl+S", action: "Save current search" },
|
|
78
|
+
{ keys: "Ctrl+P", action: "Share selected repo" },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
title: " Search Syntax ",
|
|
83
|
+
titleColor: "teal",
|
|
84
|
+
rows: [
|
|
85
|
+
{ keys: "keywords", action: "Free-text search" },
|
|
86
|
+
{ keys: "key:value", action: "Qualifier filter" },
|
|
87
|
+
{ keys: "-key:value", action: "Negated qualifier" },
|
|
88
|
+
{ keys: '"phrase"', action: "Exact phrase match" },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
title: " Qualifiers ",
|
|
93
|
+
titleColor: "muted",
|
|
94
|
+
rows: [
|
|
95
|
+
{ keys: "language:", action: "Filter by language" },
|
|
96
|
+
{ keys: "stars:>N", action: "Minimum stars" },
|
|
97
|
+
{ keys: "fork:true", action: "Include forks" },
|
|
98
|
+
{ keys: "user:", action: "User's repos" },
|
|
99
|
+
{ keys: "org:", action: "Organization repos" },
|
|
100
|
+
{ keys: "repo:", action: "Specific repo" },
|
|
101
|
+
{ keys: "topic:", action: "Filter by topic" },
|
|
102
|
+
{ keys: "license:", action: "License type" },
|
|
103
|
+
{ keys: "pushed:>YYYY-MM-DD", action: "Last push date" },
|
|
104
|
+
{ keys: "archived:false", action: "Exclude archived" },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
title: " Rate Limits ",
|
|
109
|
+
titleColor: "orange",
|
|
110
|
+
rows: [
|
|
111
|
+
{ keys: "No token", action: "10 req / min" },
|
|
112
|
+
{ keys: "GITHUB_TOKEN", action: "30 req / min (or your plan limit)" },
|
|
113
|
+
],
|
|
114
|
+
note: "Set GITHUB_TOKEN in your environment or add to config.",
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Column at which the action description starts (right-aligns keys). */
|
|
120
|
+
export const HELP_KEYS_COLUMN = 24;
|
package/src/init.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createInterface } from "readline";
|
|
7
7
|
import { saveConfig } from "./config.ts";
|
|
8
|
+
import { listThemes } from "./themes.ts";
|
|
8
9
|
import type { Config, SortStrategy } from "./types.ts";
|
|
9
10
|
|
|
10
11
|
function ask(query: string): Promise<string> {
|
|
@@ -24,15 +25,22 @@ export async function runInitWizard(): Promise<void> {
|
|
|
24
25
|
console.log("");
|
|
25
26
|
|
|
26
27
|
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
28
|
+
if (token && !token.startsWith("ghp_") && !token.startsWith("github_pat_") && !token.startsWith("gho_")) {
|
|
29
|
+
console.log(" ⚠ Token doesn't look like a GitHub token (should start with ghp_, gho_, or github_pat_)");
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
|
|
28
33
|
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
29
34
|
|
|
35
|
+
const themes = listThemes();
|
|
36
|
+
const themeStr = await ask(` Theme [${themes.join("/")}] (tokyo-night): `);
|
|
37
|
+
|
|
30
38
|
const config: Config = {
|
|
31
39
|
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
32
40
|
? sortStr
|
|
33
41
|
: "best-match") as SortStrategy,
|
|
34
42
|
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
35
|
-
theme: "tokyo-night",
|
|
43
|
+
theme: themes.includes(themeStr) ? themeStr : "tokyo-night",
|
|
36
44
|
cacheTtlSeconds: 300,
|
|
37
45
|
defaultTab: "search",
|
|
38
46
|
};
|
package/src/normalizer.ts
CHANGED
|
@@ -37,26 +37,26 @@ export interface GitHubSearchEnvelope {
|
|
|
37
37
|
|
|
38
38
|
/** Convert a single GitHub API item into a {@link Repo}. */
|
|
39
39
|
export function normalizeRepo(item: GitHubApiItem): Repo {
|
|
40
|
-
const owner = item.owner?.login ?? item.full_name
|
|
40
|
+
const owner = item.owner?.login ?? item.full_name?.split("/")[0] ?? "";
|
|
41
41
|
return {
|
|
42
|
-
id: item.id,
|
|
43
|
-
fullName: item.full_name,
|
|
44
|
-
name: item.name,
|
|
42
|
+
id: item.id ?? 0,
|
|
43
|
+
fullName: item.full_name ?? "",
|
|
44
|
+
name: item.name ?? "",
|
|
45
45
|
owner,
|
|
46
|
-
description: item.description,
|
|
47
|
-
url: item.html_url,
|
|
48
|
-
stars: item.stargazers_count,
|
|
49
|
-
forks: item.forks_count,
|
|
50
|
-
watchers: item.watchers_count,
|
|
51
|
-
language: item.language,
|
|
46
|
+
description: item.description ?? null,
|
|
47
|
+
url: item.html_url ?? "",
|
|
48
|
+
stars: item.stargazers_count ?? 0,
|
|
49
|
+
forks: item.forks_count ?? 0,
|
|
50
|
+
watchers: item.watchers_count ?? 0,
|
|
51
|
+
language: item.language ?? null,
|
|
52
52
|
topics: item.topics ?? [],
|
|
53
|
-
archived: item.archived,
|
|
54
|
-
isFork: item.fork,
|
|
55
|
-
private: item.private,
|
|
56
|
-
createdAt: item.created_at,
|
|
57
|
-
updatedAt: item.updated_at,
|
|
58
|
-
pushedAt: item.pushed_at,
|
|
59
|
-
score: item.score,
|
|
53
|
+
archived: item.archived ?? false,
|
|
54
|
+
isFork: item.fork ?? false,
|
|
55
|
+
private: item.private ?? false,
|
|
56
|
+
createdAt: item.created_at ?? "",
|
|
57
|
+
updatedAt: item.updated_at ?? "",
|
|
58
|
+
pushedAt: item.pushed_at ?? "",
|
|
59
|
+
score: item.score ?? 0,
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
package/src/open-url.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the user's default browser — platform-aware.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: three platforms, no abstraction. Add more browsers/flags
|
|
5
|
+
* only if the open/xdg-open pattern fails somewhere.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function openUrl(url: string): void {
|
|
9
|
+
try {
|
|
10
|
+
const p = process.platform;
|
|
11
|
+
if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
12
|
+
else if (p === "darwin") Bun.spawn(["open", url]);
|
|
13
|
+
else Bun.spawn(["xdg-open", url]);
|
|
14
|
+
} catch {
|
|
15
|
+
// non-critical — user can copy the URL manually
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Repo, SearchResponse } from "./types.ts";
|
|
7
7
|
|
|
8
|
-
/** Format a number compactly (e.g. 1234 -> "1.2k"). */
|
|
8
|
+
/** Format a number compactly (e.g. 1234 -> "1.2k", 999999 -> "1M"). */
|
|
9
9
|
export function formatCount(n: number): string {
|
|
10
10
|
if (n < 1000) return String(n);
|
|
11
|
+
if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
|
|
11
12
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
12
13
|
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
13
14
|
}
|
package/src/pipe.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Pipe-friendly format modifiers — output repo data as lines for shell piping.
|
|
3
3
|
*/
|
|
4
4
|
import type { Repo } from "./types.ts";
|
|
5
|
+
import { openUrl } from "./open-url.ts";
|
|
5
6
|
|
|
6
7
|
export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
7
8
|
|
|
@@ -56,10 +57,4 @@ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
const p = process.platform;
|
|
61
|
-
const args = p === "win32" ? ["cmd", "/c", "start", "", url]
|
|
62
|
-
: p === "darwin" ? ["open", url]
|
|
63
|
-
: ["xdg-open", url];
|
|
64
|
-
Bun.spawn(args);
|
|
65
|
-
}
|
|
60
|
+
|
package/src/provider.ts
CHANGED
|
@@ -89,15 +89,22 @@ export class GitHubSearchProvider implements SearchProvider {
|
|
|
89
89
|
},
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
const remainingStr = res.headers.get("x-ratelimit-remaining");
|
|
93
|
+
rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : undefined;
|
|
94
|
+
if (res.status === 403) {
|
|
95
|
+
if (remainingStr === "0" || remainingStr === null) {
|
|
96
|
+
// Rate limited
|
|
97
|
+
if (this.tokens.length > 0 && attempt < this.tokens.length) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
this.logger.warn("[github] rate limit exceeded");
|
|
101
|
+
rateLimited = true;
|
|
102
|
+
throw new RateLimitError(!!token);
|
|
97
103
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
// Non-rate-limit 403 (e.g. private repo, permissions)
|
|
105
|
+
const body = await res.text().catch(() => "");
|
|
106
|
+
this.logger.error(`[github] non-rate-limit 403: ${body.slice(0, 200)}`);
|
|
107
|
+
throw new NetworkError();
|
|
101
108
|
}
|
|
102
109
|
if (!res.ok) {
|
|
103
110
|
const body = await res.text().catch(() => "");
|
|
@@ -105,7 +112,23 @@ export class GitHubSearchProvider implements SearchProvider {
|
|
|
105
112
|
throw new NetworkError();
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
|
|
115
|
+
// Validate response is valid JSON before trusting the cast
|
|
116
|
+
const raw = await res.text();
|
|
117
|
+
let parsed: any;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(raw);
|
|
120
|
+
} catch {
|
|
121
|
+
const snippet = raw.slice(0, 200);
|
|
122
|
+
this.logger.error(`[github] invalid JSON response: ${snippet}`);
|
|
123
|
+
throw new ParseError("GitHub API", snippet.includes("<!DOCTYPE")
|
|
124
|
+
? "GitHub returned an HTML page (maintenance or CAPTCHA?)"
|
|
125
|
+
: "Invalid JSON response");
|
|
126
|
+
}
|
|
127
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) {
|
|
128
|
+
this.logger.error(`[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`);
|
|
129
|
+
throw new ParseError("GitHub API", "Unexpected response shape — missing items array");
|
|
130
|
+
}
|
|
131
|
+
const env = parsed as GitHubSearchEnvelope;
|
|
109
132
|
totalCount = env.total_count;
|
|
110
133
|
const repos = normalizeEnvelope(env);
|
|
111
134
|
all.push(...repos);
|
package/src/query.ts
CHANGED
|
@@ -186,12 +186,9 @@ export function applyFlagFilters(
|
|
|
186
186
|
export function validateQuery(query: ParsedQuery): void {
|
|
187
187
|
const has = (key: string) => query.qualifiers.some((q) => q.key === key && !q.negated);
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (has("user") && has("org")) {
|
|
193
|
-
throw new Error("Cannot combine `user:` and `org:` filters (a repo belongs to one or the other).");
|
|
194
|
-
}
|
|
189
|
+
// ponytail: removed fork+archived and user+org validation — both are valid
|
|
190
|
+
// GitHub search queries (a fork can be archived; user: acts as author/committer
|
|
191
|
+
// not owner, so user+org is a valid scoping query).
|
|
195
192
|
if (has("visibility") && has("private")) {
|
|
196
193
|
throw new Error("Cannot combine `visibility:` and `private:` filters.");
|
|
197
194
|
}
|
|
@@ -244,7 +241,8 @@ export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): stri
|
|
|
244
241
|
const q = buildGitHubQuery(query);
|
|
245
242
|
const { sort, order } = githubSortParam(options.sort);
|
|
246
243
|
const perPage = Math.min(Math.max(options.limit, 1), 100); // GitHub max page size
|
|
247
|
-
|
|
244
|
+
// ponytail: per_page is set by appendPage in provider, not here
|
|
245
|
+
const params = new URLSearchParams({ q: q || " " });
|
|
248
246
|
if (sort) params.set("sort", sort);
|
|
249
247
|
if (order) params.set("order", order);
|
|
250
248
|
return `https://api.github.com/search/repositories?${params.toString()}`;
|
package/src/ranking.ts
CHANGED
|
@@ -27,9 +27,11 @@ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
|
|
|
27
27
|
break;
|
|
28
28
|
case "best-match":
|
|
29
29
|
default:
|
|
30
|
-
// GitHub already returns best-match order;
|
|
31
|
-
//
|
|
32
|
-
|
|
30
|
+
// GitHub already returns best-match order; preserve it unchanged.
|
|
31
|
+
// Re-sorting by score would be redundant for first-page results and
|
|
32
|
+
// misleading for paginated (page 2 repos would intermix with page 1).
|
|
33
|
+
// ponytail: if deterministic output is needed, sort by id:
|
|
34
|
+
// copy.sort((a, b) => a.id - b.id);
|
|
33
35
|
break;
|
|
34
36
|
}
|
|
35
37
|
return copy;
|
package/src/session.ts
CHANGED
|
@@ -12,8 +12,10 @@ export function saveSession(state: SessionState): void {
|
|
|
12
12
|
mkdirSync(stateDir(), { recursive: true });
|
|
13
13
|
try {
|
|
14
14
|
writeFileSync(SESSION_FILE, JSON.stringify(state));
|
|
15
|
-
} catch {
|
|
16
|
-
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (process.env.DEBUG) {
|
|
17
|
+
console.error(`[search-cli] Failed to save session: ${err instanceof Error ? err.message : String(err)}`);
|
|
18
|
+
}
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
package/src/trending.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
t,
|
|
20
20
|
} from "@opentui/core";
|
|
21
21
|
import type { Repo } from "./types.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { openUrl } from "./open-url.ts";
|
|
23
|
+
import { NetworkError } from "./errors.ts";
|
|
24
|
+
import type { GitHubSearchEnvelope } from "./normalizer.ts";
|
|
23
25
|
|
|
24
26
|
// ─── Tokyo Night palette ──────────────────────────────────────────────
|
|
25
27
|
const C = {
|
|
@@ -80,12 +82,12 @@ function rankColor(rank: number): string {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
// ─── Tab definitions ──────────────────────────────────────────────────
|
|
83
|
-
export const TAB_NAMES = ["Today", "This Week", "This Month"
|
|
85
|
+
export const TAB_NAMES = ["Today", "This Week", "This Month"] as const;
|
|
84
86
|
export type TabName = (typeof TAB_NAMES)[number];
|
|
85
87
|
|
|
86
|
-
/** Map TabName to GitHub
|
|
87
|
-
*
|
|
88
|
-
*
|
|
88
|
+
/** Map TabName to GitHub API date range.
|
|
89
|
+
* We use the search API with `created:>DATE&sort=stars` so only
|
|
90
|
+
* daily/weekly/monthly ranges are meaningful.
|
|
89
91
|
*/
|
|
90
92
|
export function tabSince(tab: TabName): "daily" | "weekly" | "monthly" {
|
|
91
93
|
switch (tab) {
|
|
@@ -109,64 +111,53 @@ export interface TrendingRepo {
|
|
|
109
111
|
// ─── Mock data ────────────────────────────────────────────────────────
|
|
110
112
|
|
|
111
113
|
/**
|
|
112
|
-
* Fetch
|
|
113
|
-
*
|
|
114
|
-
*
|
|
114
|
+
* Fetch trending repos from the GitHub search API instead of HTML scraping.
|
|
115
|
+
* Uses `created:>DATE&sort=stars` queries since the API doesn't expose a
|
|
116
|
+
* dedicated trending endpoint.
|
|
117
|
+
*
|
|
118
|
+
* Note: `starsToday` is always 0 because the API doesn't provide period growth.
|
|
119
|
+
* The growth indicator is hidden when 0.
|
|
115
120
|
*/
|
|
116
121
|
export async function fetchTrendingRepos(since: "daily" | "weekly" | "monthly"): Promise<TrendingRepo[]> {
|
|
117
|
-
const
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
const msPerDay = 86_400_000;
|
|
124
|
+
const days = since === "daily" ? 1 : since === "weekly" ? 7 : 30;
|
|
125
|
+
const date = new Date(now - days * msPerDay).toISOString().slice(0, 10);
|
|
126
|
+
|
|
127
|
+
const url = `https://api.github.com/search/repositories?q=created:>${date}&sort=stars&order=desc&per_page=25`;
|
|
118
128
|
let res: Response;
|
|
119
129
|
try {
|
|
120
|
-
res = await fetch(url, {
|
|
130
|
+
res = await fetch(url, {
|
|
131
|
+
headers: {
|
|
132
|
+
Accept: "application/vnd.github+json",
|
|
133
|
+
"User-Agent": "search-cli",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
121
136
|
} catch {
|
|
122
137
|
throw new NetworkError();
|
|
123
138
|
}
|
|
124
|
-
if (!res.ok) throw new
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const h2 = block.match(/<h2[^>]*>[\s\S]*?<a[^>]*href="\/([^"/]+)\/([^"/]+?)"/);
|
|
137
|
-
if (!h2) continue;
|
|
138
|
-
const owner = h2[1];
|
|
139
|
-
const name = h2[2];
|
|
140
|
-
|
|
141
|
-
// Description from <p>
|
|
142
|
-
const p = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
|
|
143
|
-
const description = p ? p[1].replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim() : "";
|
|
144
|
-
|
|
145
|
-
// Language
|
|
146
|
-
const lang = block.match(/programmingLanguage"[^>]*>([^<]+)</);
|
|
147
|
-
const language = lang ? lang[1].trim() : "";
|
|
148
|
-
|
|
149
|
-
// Total stars (text after star SVG and before </a>)
|
|
150
|
-
const stargazerBlock = block.match(/stargazers[^>]*>[\s\S]*?<\/svg>\s*([\d,]+)\s*<\/a>/);
|
|
151
|
-
const stars = stargazerBlock ? parseInt(stargazerBlock[1].replace(/,/g, "")) : 0;
|
|
152
|
-
|
|
153
|
-
// Stars gained this period (e.g. "8,795 stars this week")
|
|
154
|
-
const periodS = block.match(new RegExp(`([\\d,]+)\\s+stars\\s+this\\s+${since === "daily" ? "day" : since === "weekly" ? "week" : "month"}`));
|
|
155
|
-
const starsToday = periodS ? parseInt(periodS[1].replace(/,/g, "")) : 0;
|
|
156
|
-
|
|
157
|
-
repos.push({ rank, owner, name, stars, starsToday, language, description });
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return repos;
|
|
139
|
+
if (!res.ok) throw new NetworkError();
|
|
140
|
+
const data = (await res.json()) as GitHubSearchEnvelope;
|
|
141
|
+
|
|
142
|
+
return data.items.map((item, i) => ({
|
|
143
|
+
rank: i + 1,
|
|
144
|
+
owner: item.owner?.login ?? "?",
|
|
145
|
+
name: item.name,
|
|
146
|
+
stars: item.stargazers_count,
|
|
147
|
+
starsToday: 0, // API doesn't provide period growth
|
|
148
|
+
language: item.language ?? "",
|
|
149
|
+
description: item.description ?? "",
|
|
150
|
+
}));
|
|
161
151
|
}
|
|
162
152
|
|
|
163
153
|
|
|
164
154
|
|
|
165
155
|
// ─── Number formatting ────────────────────────────────────────────────
|
|
166
156
|
|
|
167
|
-
/** 1234 → "1.2k", 1234567 → "1.2M" */
|
|
157
|
+
/** 1234 → "1.2k", 1234567 → "1.2M", 999999 → "1M" */
|
|
168
158
|
export function fmtStars(n: number): string {
|
|
169
159
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
160
|
+
if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
|
|
170
161
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
171
162
|
return String(n);
|
|
172
163
|
}
|
|
@@ -237,8 +228,6 @@ export function getTrendingQuery(tab: TabName): string {
|
|
|
237
228
|
Today: 1,
|
|
238
229
|
"This Week": 7,
|
|
239
230
|
"This Month": 30,
|
|
240
|
-
"This Year": 365,
|
|
241
|
-
"All Time": 0,
|
|
242
231
|
};
|
|
243
232
|
const days = ranges[tab];
|
|
244
233
|
if (days === 0) return "sort:stars";
|
|
@@ -517,8 +506,8 @@ export async function launchTrending(): Promise<void> {
|
|
|
517
506
|
return;
|
|
518
507
|
}
|
|
519
508
|
|
|
520
|
-
// Number keys 1-
|
|
521
|
-
if (/^[1-
|
|
509
|
+
// Number keys 1-3 switch tabs
|
|
510
|
+
if (/^[1-3]$/.test(name)) {
|
|
522
511
|
switchTab(parseInt(name, 10) - 1);
|
|
523
512
|
return;
|
|
524
513
|
}
|
|
@@ -569,19 +558,6 @@ export async function launchTrending(): Promise<void> {
|
|
|
569
558
|
loadTab(selectedTab);
|
|
570
559
|
}
|
|
571
560
|
|
|
572
|
-
// ── Utilities ─────────────────────────────────────────────────────────
|
|
573
|
-
|
|
574
|
-
function openUrl(url: string): void {
|
|
575
|
-
try {
|
|
576
|
-
const p = process.platform;
|
|
577
|
-
if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
578
|
-
else if (p === "darwin") Bun.spawn(["open", url]);
|
|
579
|
-
else Bun.spawn(["xdg-open", url]);
|
|
580
|
-
} catch {
|
|
581
|
-
// non-critical
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
561
|
// ── Auto-run ──────────────────────────────────────────────────────────
|
|
586
562
|
if (import.meta.main) {
|
|
587
563
|
launchTrending();
|