github-search-cli 9.7.0 → 9.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/CHANGELOG.md +63 -0
- package/README.md +17 -16
- package/bin/ghfind.js +5 -5
- package/dist/cli.js +1068 -342
- package/package.json +9 -3
- package/release-notes/9.7.1.md +62 -0
- package/release-notes/9.8.0.md +55 -0
- package/scripts/build.ts +1 -1
- package/scripts/postinstall.js +8 -8
- package/src/cli.ts +21 -13
- package/src/compare.ts +5 -5
- package/src/config.ts +3 -3
- package/src/deepdive.ts +0 -1
- package/src/doctor.ts +144 -8
- package/src/error-report.ts +14 -8
- package/src/export.ts +1 -1
- package/src/help.ts +2 -1
- package/src/history.ts +5 -5
- package/src/image-loader.ts +248 -0
- package/src/image-render.ts +209 -0
- package/src/init.ts +2 -2
- package/src/login.ts +4 -4
- package/src/markdown-images.ts +123 -0
- package/src/markdown-view.ts +300 -0
- package/src/mcp-server.ts +4 -3
- package/src/open-url.ts +16 -5
- package/src/output.ts +1 -1
- package/src/package-browser.ts +1 -1
- package/src/png.ts +260 -0
- package/src/releases.ts +1 -14
- package/src/search-state.ts +1 -4
- package/src/search.ts +3 -4
- package/src/share.ts +2 -2
- package/src/storage.ts +4 -4
- package/src/themes.ts +1 -0
- package/src/trending-parser.ts +3 -4
- package/src/trending.ts +7 -7
- package/src/tui.ts +115 -83
- package/src/update-check.ts +96 -34
- package/src/version.ts +3 -3
- package/src/watch.ts +1 -1
- package/src/markdown-render.ts +0 -211
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* README image loading — URL resolution, filtering and a small fetch cache.
|
|
3
|
+
*
|
|
4
|
+
* READMEs reference images in a half-dozen shapes: relative paths, absolute
|
|
5
|
+
* paths from a branch root, `github.com/.../blob/...` pages, media hosts and
|
|
6
|
+
* inline `data:` URIs. Only some of them are worth downloading, and all of
|
|
7
|
+
* the downloads are cached because a README is re-opened often.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Reject anything bigger than this before buffering it. */
|
|
11
|
+
export const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hosts that serve badges rather than pictures. They're tiny SVGs with text
|
|
15
|
+
* baked in — unreadable once rasterised, so they're skipped.
|
|
16
|
+
*/
|
|
17
|
+
const BADGE_HOST =
|
|
18
|
+
/(^|\.)(shields\.io|badgen\.net|badge\.fury\.io|codecov\.io|coveralls\.io|travis-ci\.(org|com)|snyk\.io|sonarcloud\.io|david-dm\.(org|io))$/i;
|
|
19
|
+
|
|
20
|
+
/** Hosts whose repo-relative image paths we are allowed to fetch. */
|
|
21
|
+
const RAW_HOSTS = new Set([
|
|
22
|
+
"raw.githubusercontent.com",
|
|
23
|
+
"camo.githubusercontent.com",
|
|
24
|
+
"user-images.githubusercontent.com",
|
|
25
|
+
"private-user-images.githubusercontent.com",
|
|
26
|
+
"avatars.githubusercontent.com",
|
|
27
|
+
"objects.githubusercontent.com",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Formats the rasteriser cannot show — SVG is text, so it's skipped. */
|
|
31
|
+
const RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
|
|
32
|
+
|
|
33
|
+
/** Cap on memoised downloads — a long session can open many READMEs. */
|
|
34
|
+
const CACHE_LIMIT = 24;
|
|
35
|
+
|
|
36
|
+
const cache = new Map<string, Promise<Uint8Array | null>>();
|
|
37
|
+
|
|
38
|
+
/** Drop memoised image downloads (used by tests). */
|
|
39
|
+
export function clearImageByteCache(): void {
|
|
40
|
+
cache.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Decode a `data:image/...;base64,...` URI into raw bytes. */
|
|
44
|
+
function decodeDataUri(uri: string): Uint8Array | null {
|
|
45
|
+
const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
const mime = (match[1] ?? "").toLowerCase();
|
|
48
|
+
if (mime.includes("svg")) return null;
|
|
49
|
+
const payload = match[3];
|
|
50
|
+
try {
|
|
51
|
+
if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
|
|
52
|
+
return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rewrite GitHub web URLs (`.../blob/<ref>/<path>`, `.../raw/<ref>/<path>`)
|
|
60
|
+
* to their raw equivalents so they can be fetched as bytes.
|
|
61
|
+
*/
|
|
62
|
+
function toRawGitHubUrl(url: URL): URL {
|
|
63
|
+
if (url.hostname.toLowerCase() !== "github.com") return url;
|
|
64
|
+
const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
|
|
65
|
+
if (!match) return url;
|
|
66
|
+
const [, owner, repo, rest] = match;
|
|
67
|
+
return new URL(
|
|
68
|
+
`https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* GitHub resolves `/foo.png` against the *repository* root, not the host
|
|
74
|
+
* root, so rebuild it on the base's `owner/repo/branch` prefix instead of
|
|
75
|
+
* letting `new URL()` swap the whole path out.
|
|
76
|
+
*/
|
|
77
|
+
function resolveRootRelative(path: string, base: string): URL | null {
|
|
78
|
+
let parsed: URL;
|
|
79
|
+
try {
|
|
80
|
+
parsed = new URL(base);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
88
|
+
if (segments.length < 3) return null;
|
|
89
|
+
return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve an image reference from a README into a fetchable URL, or `null`
|
|
94
|
+
* when it isn't something we can render (SVG, badges, mailto, …).
|
|
95
|
+
*
|
|
96
|
+
* `base` is the URL of the README itself, so relative paths resolve against
|
|
97
|
+
* the branch the README came from.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveReadmeImageUrl(
|
|
100
|
+
raw: string,
|
|
101
|
+
base: string,
|
|
102
|
+
): string | null {
|
|
103
|
+
const cleaned = raw.trim();
|
|
104
|
+
if (!cleaned) return null;
|
|
105
|
+
|
|
106
|
+
if (/^data:/i.test(cleaned)) {
|
|
107
|
+
return isRenderableImageUrl(cleaned) ? cleaned : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let url: URL | null = null;
|
|
111
|
+
try {
|
|
112
|
+
url = cleaned.startsWith("/")
|
|
113
|
+
? resolveRootRelative(cleaned, base)
|
|
114
|
+
: new URL(cleaned, base);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (!url) return null;
|
|
119
|
+
url = toRawGitHubUrl(url);
|
|
120
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
121
|
+
|
|
122
|
+
url.hash = "";
|
|
123
|
+
const resolved = url.toString();
|
|
124
|
+
return isRenderableImageUrl(resolved) ? resolved : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether a URL is likely to be a picture we can rasterise. Skipped:
|
|
129
|
+
* SVG (vector text), badge services, and GitHub-hosted HTML pages.
|
|
130
|
+
*/
|
|
131
|
+
export function isRenderableImageUrl(url: string): boolean {
|
|
132
|
+
if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
|
|
133
|
+
let parsed: URL;
|
|
134
|
+
try {
|
|
135
|
+
parsed = new URL(url);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
140
|
+
if (BADGE_HOST.test(parsed.hostname)) return false;
|
|
141
|
+
|
|
142
|
+
const path = parsed.pathname.toLowerCase();
|
|
143
|
+
if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
|
|
144
|
+
// GitHub's drag-and-drop uploads live under /user-attachments/ with opaque
|
|
145
|
+
// UUID paths and no file extension — the fetch decides what they are.
|
|
146
|
+
if (parsed.hostname.toLowerCase().endsWith("github.com")) {
|
|
147
|
+
if (/^\/user-attachments\//.test(path)) return true;
|
|
148
|
+
if (!RAW_HOSTS.has(parsed.hostname)) {
|
|
149
|
+
return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Unknown extension on a host we don't know: the fetch decides.
|
|
153
|
+
if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Hosts that accept the caller's GitHub token. */
|
|
158
|
+
function isGitHubHost(hostname: string): boolean {
|
|
159
|
+
return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Download image bytes, memoised per URL. Returns `null` for anything that
|
|
164
|
+
* fails, is too large, or turns out not to be an image.
|
|
165
|
+
*/
|
|
166
|
+
export async function fetchImageBytes(
|
|
167
|
+
url: string,
|
|
168
|
+
opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number } = {},
|
|
169
|
+
): Promise<Uint8Array | null> {
|
|
170
|
+
const cached = cache.get(url);
|
|
171
|
+
if (cached) return cached;
|
|
172
|
+
const pending = loadImageBytes(url, opts);
|
|
173
|
+
cache.set(url, pending);
|
|
174
|
+
// Map iteration order is insertion order, so the first key is the oldest.
|
|
175
|
+
while (cache.size > CACHE_LIMIT) {
|
|
176
|
+
const oldest = cache.keys().next().value;
|
|
177
|
+
if (oldest === undefined) break;
|
|
178
|
+
cache.delete(oldest);
|
|
179
|
+
}
|
|
180
|
+
return pending;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function loadImageBytes(
|
|
184
|
+
url: string,
|
|
185
|
+
opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number },
|
|
186
|
+
): Promise<Uint8Array | null> {
|
|
187
|
+
if (/^data:/i.test(url)) return decodeDataUri(url);
|
|
188
|
+
|
|
189
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
190
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
193
|
+
try {
|
|
194
|
+
const headers: Record<string, string> = {
|
|
195
|
+
"User-Agent": "ghfind/1.0",
|
|
196
|
+
Accept: "image/*,*/*;q=0.5",
|
|
197
|
+
};
|
|
198
|
+
let host = "";
|
|
199
|
+
try {
|
|
200
|
+
host = new URL(url).hostname;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (opts.token && isGitHubHost(host)) {
|
|
205
|
+
headers.Authorization = `Bearer ${opts.token}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const res = await fetchImpl(url, { headers, signal: controller.signal });
|
|
209
|
+
if (!res.ok) return null;
|
|
210
|
+
|
|
211
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
212
|
+
if (contentType && !/^image\//i.test(contentType)) {
|
|
213
|
+
// GitHub's attachment endpoints (user-uploads proxied via camo) serve
|
|
214
|
+
// real images as binary/octet-stream; the rasteriser rejects anything
|
|
215
|
+
// that isn't actually an image, so accept it there and let decoding
|
|
216
|
+
// decide.
|
|
217
|
+
const githubHost = isGitHubHost(host);
|
|
218
|
+
if (!githubHost || !/octet-stream/i.test(contentType)) return null;
|
|
219
|
+
}
|
|
220
|
+
if (/svg/i.test(contentType)) return null;
|
|
221
|
+
|
|
222
|
+
const declared = Number(res.headers.get("content-length") ?? "0");
|
|
223
|
+
if (declared > MAX_IMAGE_BYTES) return null;
|
|
224
|
+
|
|
225
|
+
const buffer = new Uint8Array(await res.arrayBuffer());
|
|
226
|
+
if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
return buffer;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
} finally {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Absolute URL of the README file itself, used as the base for relative
|
|
239
|
+
* image paths.
|
|
240
|
+
*/
|
|
241
|
+
export function readmeBaseUrl(
|
|
242
|
+
owner: string,
|
|
243
|
+
repo: string,
|
|
244
|
+
branch: string,
|
|
245
|
+
path = "README.md",
|
|
246
|
+
): string {
|
|
247
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
|
|
248
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal image rendering — turns image bytes into half-block cells.
|
|
3
|
+
*
|
|
4
|
+
* A terminal cell is roughly twice as tall as it is wide, so one cell is
|
|
5
|
+
* drawn with `▀`: the foreground colour paints the top half and the
|
|
6
|
+
* background colour the bottom half. That gives two image pixels of
|
|
7
|
+
* vertical resolution per character row with no image protocol support
|
|
8
|
+
* needed — it works on any truecolor terminal, inside OpenTUI's own
|
|
9
|
+
* cell buffer.
|
|
10
|
+
*/
|
|
11
|
+
import { decodePng, type DecodedImage } from "./png";
|
|
12
|
+
|
|
13
|
+
export type Rgb = readonly [number, number, number];
|
|
14
|
+
|
|
15
|
+
export interface HalfBlockCell {
|
|
16
|
+
/** Colour of the top half of the cell. */
|
|
17
|
+
top: Rgb;
|
|
18
|
+
/** Colour of the bottom half of the cell. */
|
|
19
|
+
bottom: Rgb;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HalfBlockImage {
|
|
23
|
+
cols: number;
|
|
24
|
+
rows: number;
|
|
25
|
+
/** Row-major, `cells[row][col]`. */
|
|
26
|
+
cells: HalfBlockCell[][];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A run of cells sharing the same top/bottom colours. */
|
|
30
|
+
export interface ImageRun {
|
|
31
|
+
text: string;
|
|
32
|
+
top: Rgb;
|
|
33
|
+
bottom: Rgb;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FitOptions {
|
|
37
|
+
/** Hard cap on the rendered width, in terminal cells. */
|
|
38
|
+
maxCols: number;
|
|
39
|
+
/** Hard cap on the rendered height, in terminal cells. */
|
|
40
|
+
maxRows: number;
|
|
41
|
+
/**
|
|
42
|
+
* Width ÷ height of one terminal cell. Real cells are about half as wide
|
|
43
|
+
* as tall, so the default keeps image proportions honest.
|
|
44
|
+
*/
|
|
45
|
+
cellAspect?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RasterizeOptions extends FitOptions {
|
|
49
|
+
/** Painted behind transparent pixels. Defaults to black. */
|
|
50
|
+
background?: Rgb;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Default cell width ÷ height — standard terminal cells are ~1:2. */
|
|
54
|
+
export const DEFAULT_CELL_ASPECT = 0.5;
|
|
55
|
+
|
|
56
|
+
/** The character used to split a cell into two vertically stacked pixels. */
|
|
57
|
+
export const HALF_BLOCK = "\u2580";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Largest cell grid that shows the whole image inside the given bounds
|
|
61
|
+
* without distorting it.
|
|
62
|
+
*/
|
|
63
|
+
export function fitImageToCells(
|
|
64
|
+
imgWidth: number,
|
|
65
|
+
imgHeight: number,
|
|
66
|
+
opts: FitOptions,
|
|
67
|
+
): { cols: number; rows: number } {
|
|
68
|
+
const cellAspect = opts.cellAspect ?? DEFAULT_CELL_ASPECT;
|
|
69
|
+
const minCols = Math.max(1, Math.min(opts.maxCols, 8));
|
|
70
|
+
if (imgWidth <= 0 || imgHeight <= 0) {
|
|
71
|
+
return { cols: minCols, rows: 1 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// rows/cols = cellAspect * (imgHeight / imgWidth)
|
|
75
|
+
const rowsPerCol = cellAspect * (imgHeight / imgWidth);
|
|
76
|
+
let cols = Math.max(minCols, Math.floor(opts.maxCols));
|
|
77
|
+
let rows = Math.max(1, Math.round(cols * rowsPerCol));
|
|
78
|
+
|
|
79
|
+
if (rows > opts.maxRows) {
|
|
80
|
+
rows = Math.max(1, opts.maxRows);
|
|
81
|
+
cols = Math.max(
|
|
82
|
+
minCols,
|
|
83
|
+
Math.min(cols, Math.floor(rows / Math.max(rowsPerCol, 1e-6))),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { cols, rows };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Sample RGBA at a pixel, compositing over `background` when translucent. */
|
|
90
|
+
function pixelAt(
|
|
91
|
+
img: DecodedImage,
|
|
92
|
+
x: number,
|
|
93
|
+
y: number,
|
|
94
|
+
background: Rgb,
|
|
95
|
+
): Rgb {
|
|
96
|
+
const o = (y * img.width + x) * 4;
|
|
97
|
+
const a = img.data[o + 3] / 255;
|
|
98
|
+
if (a >= 1) return [img.data[o], img.data[o + 1], img.data[o + 2]];
|
|
99
|
+
const mix = (channel: number, bg: number) =>
|
|
100
|
+
Math.round(channel * a + bg * (1 - a));
|
|
101
|
+
return [
|
|
102
|
+
mix(img.data[o], background[0]),
|
|
103
|
+
mix(img.data[o + 1], background[1]),
|
|
104
|
+
mix(img.data[o + 2], background[2]),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Convert decoded pixels into half-block cells, row-major. */
|
|
109
|
+
export function toHalfBlocks(
|
|
110
|
+
img: DecodedImage,
|
|
111
|
+
opts: { background?: Rgb } = {},
|
|
112
|
+
): HalfBlockImage {
|
|
113
|
+
const background = opts.background ?? [0, 0, 0];
|
|
114
|
+
const cells: HalfBlockCell[][] = [];
|
|
115
|
+
for (let row = 0; row * 2 < img.height; row++) {
|
|
116
|
+
const cellsRow: HalfBlockCell[] = [];
|
|
117
|
+
const topY = row * 2;
|
|
118
|
+
const bottomY = topY + 1;
|
|
119
|
+
for (let x = 0; x < img.width; x++) {
|
|
120
|
+
const top = pixelAt(img, x, topY, background);
|
|
121
|
+
const bottom =
|
|
122
|
+
bottomY < img.height ? pixelAt(img, x, bottomY, background) : top;
|
|
123
|
+
cellsRow.push({ top, bottom });
|
|
124
|
+
}
|
|
125
|
+
cells.push(cellsRow);
|
|
126
|
+
}
|
|
127
|
+
return { cols: img.width, rows: cells.length, cells };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Collapse each cell row into runs of identical colours. */
|
|
131
|
+
export function toImageRuns(image: HalfBlockImage): ImageRun[][] {
|
|
132
|
+
return image.cells.map((cells) => {
|
|
133
|
+
const runs: ImageRun[] = [];
|
|
134
|
+
for (const cell of cells) {
|
|
135
|
+
const last = runs[runs.length - 1];
|
|
136
|
+
if (
|
|
137
|
+
last &&
|
|
138
|
+
last.top[0] === cell.top[0] &&
|
|
139
|
+
last.top[1] === cell.top[1] &&
|
|
140
|
+
last.top[2] === cell.top[2] &&
|
|
141
|
+
last.bottom[0] === cell.bottom[0] &&
|
|
142
|
+
last.bottom[1] === cell.bottom[1] &&
|
|
143
|
+
last.bottom[2] === cell.bottom[2]
|
|
144
|
+
) {
|
|
145
|
+
last.text += HALF_BLOCK;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
runs.push({ text: HALF_BLOCK, top: cell.top, bottom: cell.bottom });
|
|
149
|
+
}
|
|
150
|
+
return runs;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Source image pixel budget. A README can link to anything, and a highly
|
|
156
|
+
* compressible 20k×20k PNG is a few kilobytes on the wire, so refuse absurd
|
|
157
|
+
* canvases before the decoder allocates for them.
|
|
158
|
+
*/
|
|
159
|
+
const MAX_SOURCE_PIXELS = 20_000_000;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Decode any supported image format, scale it to the requested cell grid
|
|
163
|
+
* and convert it to half blocks.
|
|
164
|
+
*
|
|
165
|
+
* Anything `Bun.Image` can read (PNG, JPEG, WebP, GIF first frame, BMP,
|
|
166
|
+
* and HEIC/AVIF where the OS has a codec) is normalised to PNG here, so the
|
|
167
|
+
* decoder only ever sees one format. Returns `null` when the runtime has no
|
|
168
|
+
* image codec, the image is unreadable, or it is too large to bother with.
|
|
169
|
+
*/
|
|
170
|
+
export async function rasterizeImage(
|
|
171
|
+
bytes: Uint8Array,
|
|
172
|
+
opts: RasterizeOptions,
|
|
173
|
+
): Promise<HalfBlockImage | null> {
|
|
174
|
+
const ImageCtor = (
|
|
175
|
+
globalThis as {
|
|
176
|
+
Bun?: {
|
|
177
|
+
Image?: new (
|
|
178
|
+
input: Uint8Array,
|
|
179
|
+
constructorOptions?: { maxPixels?: number },
|
|
180
|
+
) => {
|
|
181
|
+
metadata(): Promise<{ width: number; height: number }>;
|
|
182
|
+
resize(
|
|
183
|
+
width: number,
|
|
184
|
+
height?: number,
|
|
185
|
+
options?: { fit?: "fill" | "inside" },
|
|
186
|
+
): {
|
|
187
|
+
png(options?: { compressionLevel?: number }): {
|
|
188
|
+
bytes(): Promise<Uint8Array>;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
).Bun?.Image;
|
|
195
|
+
if (!ImageCtor) return null;
|
|
196
|
+
|
|
197
|
+
const source = new ImageCtor(bytes, { maxPixels: MAX_SOURCE_PIXELS });
|
|
198
|
+
try {
|
|
199
|
+
const meta = await source.metadata();
|
|
200
|
+
const { cols, rows } = fitImageToCells(meta.width, meta.height, opts);
|
|
201
|
+
const png = await source
|
|
202
|
+
.resize(cols, rows * 2, { fit: "fill" })
|
|
203
|
+
.png({ compressionLevel: 1 })
|
|
204
|
+
.bytes();
|
|
205
|
+
return toHalfBlocks(decodePng(png), { background: opts.background });
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
5
|
*/
|
|
6
|
-
import { createInterface } from "readline";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
7
|
import { saveConfig } from "./config";
|
|
8
8
|
import { isLikelyGithubToken } from "./login";
|
|
9
9
|
import { listThemes } from "./themes";
|
|
@@ -44,7 +44,7 @@ export async function runInitWizard(): Promise<void> {
|
|
|
44
44
|
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
45
45
|
? sortStr
|
|
46
46
|
: "best-match") as SortStrategy,
|
|
47
|
-
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
47
|
+
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr, 10) || 50)),
|
|
48
48
|
theme: themes.includes(themeStr) ? themeStr : themes[0],
|
|
49
49
|
cacheTtlSeconds: 300,
|
|
50
50
|
defaultTab: "search",
|
package/src/login.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* used by `ghfind init` (~/.config/ghfind/config.json). `GITHUB_TOKEN` in the
|
|
7
7
|
* environment always takes precedence at runtime (see src/config.ts).
|
|
8
8
|
*/
|
|
9
|
-
import { createInterface } from "readline";
|
|
10
|
-
import { homedir } from "os";
|
|
11
|
-
import { join } from "path";
|
|
12
|
-
import { existsSync, readFileSync } from "fs";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { loadConfig, saveConfig, configPath } from "./config";
|
|
14
14
|
import type { Config } from "./types";
|
|
15
15
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown token helpers — pure functions over marked-shaped tokens.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of any OpenTUI import so the token handling can be unit tested
|
|
5
|
+
* without a renderer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Minimal shape of a marked inline token. */
|
|
9
|
+
export interface InlineTokenLike {
|
|
10
|
+
type?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
href?: string;
|
|
13
|
+
raw?: string;
|
|
14
|
+
tokens?: InlineTokenLike[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Minimal shape of a marked block token. */
|
|
18
|
+
export interface BlockTokenLike extends InlineTokenLike {
|
|
19
|
+
depth?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ImageRef {
|
|
23
|
+
href: string;
|
|
24
|
+
alt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
28
|
+
const ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
|
|
29
|
+
const HTML_TAG = /<\/?[a-z][^>]*>/gi;
|
|
30
|
+
|
|
31
|
+
/** Images embedded as raw `<img>` tags, when there's no other content. */
|
|
32
|
+
function imagesFromHtml(raw: string | undefined): ImageRef[] {
|
|
33
|
+
if (!raw || !/<img\b/i.test(raw)) return [];
|
|
34
|
+
// Anything other than an <img> tag with real text between the tags means
|
|
35
|
+
// this isn't a pure image block (e.g. a centered figure with a caption).
|
|
36
|
+
const withoutTags = raw
|
|
37
|
+
.replace(IMG_TAG, "")
|
|
38
|
+
.replace(/<\/?(?:p|div|center|br|a|picture|source)\b[^>]*>/gi, "")
|
|
39
|
+
.trim();
|
|
40
|
+
if (withoutTags) return [];
|
|
41
|
+
|
|
42
|
+
const refs: ImageRef[] = [];
|
|
43
|
+
for (const match of raw.matchAll(IMG_TAG)) {
|
|
44
|
+
const tag = match[0];
|
|
45
|
+
const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
|
|
46
|
+
refs.push({ href: match[1], alt });
|
|
47
|
+
}
|
|
48
|
+
return refs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Flatten inline tokens (bold, links, code, …) down to their text. */
|
|
52
|
+
export function inlinePlainText(tokens: InlineTokenLike[] | undefined): string {
|
|
53
|
+
if (!tokens) return "";
|
|
54
|
+
let out = "";
|
|
55
|
+
for (const token of tokens) {
|
|
56
|
+
if (token.type === "image") continue;
|
|
57
|
+
if (token.type === "html") {
|
|
58
|
+
out += (token.raw ?? "").replace(HTML_TAG, "");
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (token.type === "br") {
|
|
62
|
+
out += "\n";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (token.text && (!token.tokens || token.tokens.length === 0)) {
|
|
66
|
+
out += token.text;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (token.tokens?.length) {
|
|
70
|
+
out += inlinePlainText(token.tokens);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (token.raw) out += token.raw.replace(HTML_TAG, "");
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Image references for a block token, or `null` when the block isn't a pure
|
|
80
|
+
* image block (markdown images and/or standalone `<img>` tags only).
|
|
81
|
+
*/
|
|
82
|
+
export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
|
|
83
|
+
const images: ImageRef[] = [];
|
|
84
|
+
|
|
85
|
+
const collect = (inline: InlineTokenLike): boolean => {
|
|
86
|
+
if (inline.type === "image") {
|
|
87
|
+
if (inline.href)
|
|
88
|
+
images.push({ href: inline.href, alt: inline.text ?? "" });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (inline.type === "html") {
|
|
92
|
+
const refs = imagesFromHtml(inline.raw);
|
|
93
|
+
if (refs.length > 0) {
|
|
94
|
+
images.push(...refs);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// Inline markup such as <br> doesn't disqualify the block.
|
|
98
|
+
return !(inline.raw ?? "").replace(HTML_TAG, "").trim();
|
|
99
|
+
}
|
|
100
|
+
// Whitespace-only text between images is fine; anything else means the
|
|
101
|
+
// block is a paragraph that happens to contain an image.
|
|
102
|
+
if (inline.type === "text" || inline.type === "escape") {
|
|
103
|
+
return !(inline.text ?? "").trim();
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (token.type === "image" && token.href) {
|
|
109
|
+
return [{ href: token.href, alt: token.text ?? "" }];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (token.type === "html") {
|
|
113
|
+
const refs = imagesFromHtml(token.raw);
|
|
114
|
+
return refs.length > 0 ? refs : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const inline = token.tokens ?? [];
|
|
118
|
+
if (inline.length === 0) return null;
|
|
119
|
+
for (const child of inline) {
|
|
120
|
+
if (!collect(child)) return null;
|
|
121
|
+
}
|
|
122
|
+
return images.length > 0 ? images : null;
|
|
123
|
+
}
|