github-search-cli 9.7.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.7.1",
3
+ "version": "9.8.0",
4
4
  "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -84,5 +84,10 @@
84
84
  "license": "MIT",
85
85
  "publishConfig": {
86
86
  "access": "public"
87
- }
87
+ },
88
+ "main": "index.js",
89
+ "directories": {
90
+ "test": "tests"
91
+ },
92
+ "author": ""
88
93
  }
@@ -0,0 +1,55 @@
1
+ # What's New in ghfind 9.8.0
2
+
3
+ The README viewer learned to render markdown properly — and to show the
4
+ pictures inside it. Same menu item, same keys, but the overlay now shows a
5
+ repository the way its authors wrote it, images included.
6
+
7
+ ### Added
8
+
9
+ **Full markdown rendering in the README viewer**
10
+
11
+ - The viewer used to approximate markdown with box-drawing text: heading
12
+ markers became `█` prefixes, tables were hand-drawn, and everything else
13
+ was inline-decorated plain text. It now drives OpenTUI's markdown
14
+ renderable with a syntax style derived from your theme, so headings,
15
+ emphasis, links, lists, blockquotes, boxed tables, and fenced code blocks
16
+ render as markdown.
17
+ - Headings pick up the accent colour (H1 underlined), inline code the theme's
18
+ green, links the accent with dimmed URLs — all of it follows `t` theme
19
+ switching, and everything re-reads the palette when a README is opened.
20
+ - The document keeps its own block layout: paragraphs, lists, and code stay
21
+ scrollable in the same overlay (`↑`/`↓`, `j`/`k`, `PgUp`/`PgDn`).
22
+
23
+ **Images now render in the terminal**
24
+
25
+ - README images — markdown `![]()` and standalone `<img>` tags — are fetched
26
+ and drawn inline as truecolor half blocks. No image protocol support
27
+ required: it works on any truecolor terminal, inside the normal cell
28
+ buffer, so it scrolls and clips like every other line.
29
+ - Paths resolve the way GitHub resolves them: `docs/shot.png` and
30
+ `/assets/banner.webp` both resolve against the branch the README came
31
+ from, and `github.com/owner/repo/blob/<ref>/<path>` links are rewritten to
32
+ raw content first.
33
+ - Images are cached per URL (so toggling or re-opening a README doesn't
34
+ re-download), capped at 6 MB and 20 megapixels, and skipped entirely when
35
+ they can't be shown usefully: SVG is vector text, badges are tiny
36
+ machine-generated images, and neither survives rasterising. Skipped and
37
+ failed images leave an `🖼 alt text` caption, so a README never silently
38
+ loses a section.
39
+ - Press `i` in the viewer to toggle images off and on; the footer shows the
40
+ current state. On a runtime without `Bun.Image` (plain Node), images
41
+ degrade to those captions instead of erroring.
42
+
43
+ ### Changed
44
+
45
+ - **The update panel's release notes render as markdown too.** The in-app
46
+ update modal used the same text approximation as the README viewer; it now
47
+ shares the same markdown view, so release notes show real headings, lists,
48
+ inline code, and tables. Inline images stay off there — the panel is
49
+ narrow and notes are prose.
50
+ - With the viewer and the update panel both migrated, the old plain-text
51
+ markdown renderer is gone; the markdown view is the only renderer left.
52
+ - Test coverage grew with the feature: a PNG decoder (colour types, bit
53
+ depths, `tRNS`, every scanline filter), the image-to-cells rasteriser,
54
+ README image URL resolution and fetch behaviour, and the markdown token
55
+ helpers — 414 tests total, no new dependencies.
package/src/help.ts CHANGED
@@ -74,7 +74,7 @@ export function buildHelpSections(): HelpSection[] {
74
74
  { keys: "Limit", action: "Cycle limit: 10 → 25 → 50 → 100" },
75
75
  { keys: "Refresh", action: "Re-run current search" },
76
76
  { keys: "Graph", action: "Toggle commit activity chart" },
77
- { keys: "Readme", action: "Full README viewer" },
77
+ { keys: "Readme", action: "Full markdown README viewer" },
78
78
  { keys: "Deep-dive", action: "Languages + contributors + excerpt" },
79
79
  { keys: "Bookmark", action: "Save / unsave selected repo" },
80
80
  { keys: "Compare", action: "Add to / remove from comparison" },
@@ -114,6 +114,7 @@ export function buildHelpSections(): HelpSection[] {
114
114
  { keys: "d", action: "Delete entry (history, saved)" },
115
115
  { keys: "↑ ↓ / j k", action: "Navigate overlay list" },
116
116
  { keys: "← → / h l", action: "Navigate tabs / panels" },
117
+ { keys: "i", action: "Toggle inline images (README viewer)" },
117
118
  {
118
119
  keys: "PgUp/PgDn",
119
120
  action: "Scroll update notes; Home/End jump to ends",
@@ -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
+ }
@@ -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
+ }