github-search-cli 9.7.1 → 9.8.1

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.
@@ -0,0 +1,277 @@
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
+ /**
37
+ * How many image downloads may be in flight at once. A README can reference
38
+ * dozens of images; firing them all at the same time makes them compete for
39
+ * bandwidth so the first one lands later, not sooner.
40
+ */
41
+ export const MAX_CONCURRENT_IMAGE_FETCHES = 4;
42
+
43
+ const cache = new Map<string, Promise<Uint8Array | null>>();
44
+
45
+ let activeFetches = 0;
46
+ const waiting: Array<() => void> = [];
47
+
48
+ /** Hand the next queued caller its turn, if the cap allows it. */
49
+ function releaseSlot(): void {
50
+ activeFetches--;
51
+ waiting.shift()?.();
52
+ }
53
+
54
+ /** Run `job` as soon as fewer than the cap are already running. */
55
+ async function withSlot<T>(job: () => Promise<T>): Promise<T> {
56
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
57
+ await new Promise<void>((resolve) => waiting.push(resolve));
58
+ }
59
+ activeFetches++;
60
+ try {
61
+ return await job();
62
+ } finally {
63
+ releaseSlot();
64
+ }
65
+ }
66
+
67
+ /** Drop memoised image downloads (used by tests). */
68
+ export function clearImageByteCache(): void {
69
+ cache.clear();
70
+ }
71
+
72
+ /** Decode a `data:image/...;base64,...` URI into raw bytes. */
73
+ function decodeDataUri(uri: string): Uint8Array | null {
74
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
75
+ if (!match) return null;
76
+ const mime = (match[1] ?? "").toLowerCase();
77
+ if (mime.includes("svg")) return null;
78
+ const payload = match[3];
79
+ try {
80
+ if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
81
+ return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Rewrite GitHub web URLs (`.../blob/<ref>/<path>`, `.../raw/<ref>/<path>`)
89
+ * to their raw equivalents so they can be fetched as bytes.
90
+ */
91
+ function toRawGitHubUrl(url: URL): URL {
92
+ if (url.hostname.toLowerCase() !== "github.com") return url;
93
+ const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
94
+ if (!match) return url;
95
+ const [, owner, repo, rest] = match;
96
+ return new URL(
97
+ `https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`,
98
+ );
99
+ }
100
+
101
+ /**
102
+ * GitHub resolves `/foo.png` against the *repository* root, not the host
103
+ * root, so rebuild it on the base's `owner/repo/branch` prefix instead of
104
+ * letting `new URL()` swap the whole path out.
105
+ */
106
+ function resolveRootRelative(path: string, base: string): URL | null {
107
+ let parsed: URL;
108
+ try {
109
+ parsed = new URL(base);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
114
+ return null;
115
+ }
116
+ const segments = parsed.pathname.split("/").filter(Boolean);
117
+ if (segments.length < 3) return null;
118
+ return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
119
+ }
120
+
121
+ /**
122
+ * Resolve an image reference from a README into a fetchable URL, or `null`
123
+ * when it isn't something we can render (SVG, badges, mailto, …).
124
+ *
125
+ * `base` is the URL of the README itself, so relative paths resolve against
126
+ * the branch the README came from.
127
+ */
128
+ export function resolveReadmeImageUrl(
129
+ raw: string,
130
+ base: string,
131
+ ): string | null {
132
+ const cleaned = raw.trim();
133
+ if (!cleaned) return null;
134
+
135
+ if (/^data:/i.test(cleaned)) {
136
+ return isRenderableImageUrl(cleaned) ? cleaned : null;
137
+ }
138
+
139
+ let url: URL | null = null;
140
+ try {
141
+ url = cleaned.startsWith("/")
142
+ ? resolveRootRelative(cleaned, base)
143
+ : new URL(cleaned, base);
144
+ } catch {
145
+ return null;
146
+ }
147
+ if (!url) return null;
148
+ url = toRawGitHubUrl(url);
149
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
150
+
151
+ url.hash = "";
152
+ const resolved = url.toString();
153
+ return isRenderableImageUrl(resolved) ? resolved : null;
154
+ }
155
+
156
+ /**
157
+ * Whether a URL is likely to be a picture we can rasterise. Skipped:
158
+ * SVG (vector text), badge services, and GitHub-hosted HTML pages.
159
+ */
160
+ export function isRenderableImageUrl(url: string): boolean {
161
+ if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
162
+ let parsed: URL;
163
+ try {
164
+ parsed = new URL(url);
165
+ } catch {
166
+ return false;
167
+ }
168
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
169
+ if (BADGE_HOST.test(parsed.hostname)) return false;
170
+
171
+ const path = parsed.pathname.toLowerCase();
172
+ if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
173
+ // GitHub's drag-and-drop uploads live under /user-attachments/ with opaque
174
+ // UUID paths and no file extension — the fetch decides what they are.
175
+ if (parsed.hostname.toLowerCase().endsWith("github.com")) {
176
+ if (/^\/user-attachments\//.test(path)) return true;
177
+ if (!RAW_HOSTS.has(parsed.hostname)) {
178
+ return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
179
+ }
180
+ }
181
+ // Unknown extension on a host we don't know: the fetch decides.
182
+ if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
183
+ return true;
184
+ }
185
+
186
+ /** Hosts that accept the caller's GitHub token. */
187
+ function isGitHubHost(hostname: string): boolean {
188
+ return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
189
+ }
190
+
191
+ /**
192
+ * Download image bytes, memoised per URL. Returns `null` for anything that
193
+ * fails, is too large, or turns out not to be an image.
194
+ */
195
+ export async function fetchImageBytes(
196
+ url: string,
197
+ opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number } = {},
198
+ ): Promise<Uint8Array | null> {
199
+ const cached = cache.get(url);
200
+ if (cached) return cached;
201
+ const pending = withSlot(() => loadImageBytes(url, opts));
202
+ cache.set(url, pending);
203
+ // Map iteration order is insertion order, so the first key is the oldest.
204
+ while (cache.size > CACHE_LIMIT) {
205
+ const oldest = cache.keys().next().value;
206
+ if (oldest === undefined) break;
207
+ cache.delete(oldest);
208
+ }
209
+ return pending;
210
+ }
211
+
212
+ async function loadImageBytes(
213
+ url: string,
214
+ opts: { token?: string; fetchImpl?: typeof fetch; timeoutMs?: number },
215
+ ): Promise<Uint8Array | null> {
216
+ if (/^data:/i.test(url)) return decodeDataUri(url);
217
+
218
+ const fetchImpl = opts.fetchImpl ?? fetch;
219
+ const timeoutMs = opts.timeoutMs ?? 10_000;
220
+ const controller = new AbortController();
221
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
222
+ try {
223
+ const headers: Record<string, string> = {
224
+ "User-Agent": "ghfind/1.0",
225
+ Accept: "image/*,*/*;q=0.5",
226
+ };
227
+ let host = "";
228
+ try {
229
+ host = new URL(url).hostname;
230
+ } catch {
231
+ return null;
232
+ }
233
+ if (opts.token && isGitHubHost(host)) {
234
+ headers.Authorization = `Bearer ${opts.token}`;
235
+ }
236
+
237
+ const res = await fetchImpl(url, { headers, signal: controller.signal });
238
+ if (!res.ok) return null;
239
+
240
+ const contentType = res.headers.get("content-type") ?? "";
241
+ if (contentType && !/^image\//i.test(contentType)) {
242
+ // GitHub's attachment endpoints (user-uploads proxied via camo) serve
243
+ // real images as binary/octet-stream; the rasteriser rejects anything
244
+ // that isn't actually an image, so accept it there and let decoding
245
+ // decide.
246
+ const githubHost = isGitHubHost(host);
247
+ if (!githubHost || !/octet-stream/i.test(contentType)) return null;
248
+ }
249
+ if (/svg/i.test(contentType)) return null;
250
+
251
+ const declared = Number(res.headers.get("content-length") ?? "0");
252
+ if (declared > MAX_IMAGE_BYTES) return null;
253
+
254
+ const buffer = new Uint8Array(await res.arrayBuffer());
255
+ if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
256
+ return null;
257
+ }
258
+ return buffer;
259
+ } catch {
260
+ return null;
261
+ } finally {
262
+ clearTimeout(timer);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Absolute URL of the README file itself, used as the base for relative
268
+ * image paths.
269
+ */
270
+ export function readmeBaseUrl(
271
+ owner: string,
272
+ repo: string,
273
+ branch: string,
274
+ path = "README.md",
275
+ ): string {
276
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
277
+ }
@@ -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
+ }
@@ -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
+ }