@larkiny/astro-github-loader 0.11.3 → 0.13.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.
Files changed (51) hide show
  1. package/README.md +35 -55
  2. package/dist/github.assets.d.ts +70 -0
  3. package/dist/github.assets.js +253 -0
  4. package/dist/github.auth.js +13 -9
  5. package/dist/github.cleanup.d.ts +3 -2
  6. package/dist/github.cleanup.js +30 -23
  7. package/dist/github.constants.d.ts +0 -16
  8. package/dist/github.constants.js +0 -16
  9. package/dist/github.content.d.ts +5 -131
  10. package/dist/github.content.js +152 -794
  11. package/dist/github.dryrun.d.ts +9 -5
  12. package/dist/github.dryrun.js +49 -25
  13. package/dist/github.link-transform.d.ts +2 -2
  14. package/dist/github.link-transform.js +68 -57
  15. package/dist/github.loader.js +30 -46
  16. package/dist/github.logger.d.ts +2 -2
  17. package/dist/github.logger.js +33 -24
  18. package/dist/github.paths.d.ts +76 -0
  19. package/dist/github.paths.js +190 -0
  20. package/dist/github.storage.d.ts +16 -0
  21. package/dist/github.storage.js +115 -0
  22. package/dist/github.types.d.ts +40 -4
  23. package/dist/index.d.ts +8 -6
  24. package/dist/index.js +3 -6
  25. package/dist/test-helpers.d.ts +130 -0
  26. package/dist/test-helpers.js +194 -0
  27. package/package.json +3 -1
  28. package/src/github.assets.spec.ts +717 -0
  29. package/src/github.assets.ts +365 -0
  30. package/src/github.auth.spec.ts +245 -0
  31. package/src/github.auth.ts +24 -10
  32. package/src/github.cleanup.spec.ts +380 -0
  33. package/src/github.cleanup.ts +91 -47
  34. package/src/github.constants.ts +0 -17
  35. package/src/github.content.spec.ts +305 -454
  36. package/src/github.content.ts +259 -957
  37. package/src/github.dryrun.spec.ts +598 -0
  38. package/src/github.dryrun.ts +108 -54
  39. package/src/github.link-transform.spec.ts +1345 -0
  40. package/src/github.link-transform.ts +177 -95
  41. package/src/github.loader.spec.ts +75 -50
  42. package/src/github.loader.ts +101 -76
  43. package/src/github.logger.spec.ts +795 -0
  44. package/src/github.logger.ts +77 -35
  45. package/src/github.paths.spec.ts +523 -0
  46. package/src/github.paths.ts +259 -0
  47. package/src/github.storage.spec.ts +377 -0
  48. package/src/github.storage.ts +135 -0
  49. package/src/github.types.ts +54 -9
  50. package/src/index.ts +43 -6
  51. package/src/test-helpers.ts +215 -0
@@ -0,0 +1,717 @@
1
+ import { beforeEach, describe, it, expect, vi } from "vitest";
2
+ import { Octokit } from "octokit";
3
+ import {
4
+ resolveAssetConfig,
5
+ detectAssets,
6
+ downloadAsset,
7
+ transformAssetReferences,
8
+ resolveAssetPath,
9
+ processAssets,
10
+ } from "./github.assets.js";
11
+ import type { ImportOptions } from "./github.types.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Mock node:fs so downloadAsset / processAssets never touch the real filesystem
15
+ // ---------------------------------------------------------------------------
16
+ vi.mock("node:fs", async () => {
17
+ const actual = await vi.importActual("node:fs");
18
+ return {
19
+ ...actual,
20
+ existsSync: vi.fn(() => false),
21
+ promises: {
22
+ mkdir: vi.fn(),
23
+ writeFile: vi.fn(),
24
+ },
25
+ };
26
+ });
27
+
28
+ import { existsSync, promises as fs } from "node:fs";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Minimal mock that satisfies the Logger interface used by processAssets */
35
+ function createMockLogger() {
36
+ return {
37
+ verbose: vi.fn(),
38
+ debug: vi.fn(),
39
+ warn: vi.fn(),
40
+ info: vi.fn(),
41
+ error: vi.fn(),
42
+ logAssetProcessing: vi.fn(),
43
+ logFileProcessing: vi.fn(),
44
+ logImportSummary: vi.fn(),
45
+ withSpinner: vi.fn(),
46
+ getLevel: () => "default" as const,
47
+ };
48
+ }
49
+
50
+ /** Saves the original global fetch so we can restore it in afterEach */
51
+ const originalFetch = globalThis.fetch;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Tests
55
+ // ---------------------------------------------------------------------------
56
+
57
+ beforeEach(() => {
58
+ vi.restoreAllMocks();
59
+ // Re-apply the fs mock defaults after restoreAllMocks clears them
60
+ vi.mocked(existsSync).mockReturnValue(false);
61
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
62
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
63
+ globalThis.fetch = originalFetch;
64
+ });
65
+
66
+ // ========================== resolveAssetConfig =============================
67
+
68
+ describe("resolveAssetConfig", () => {
69
+ it("returns explicit config when both assetsPath and assetsBaseUrl are set", () => {
70
+ const options: ImportOptions = {
71
+ owner: "test",
72
+ repo: "repo",
73
+ assetsPath: "/custom/assets",
74
+ assetsBaseUrl: "/static/img",
75
+ includes: [{ pattern: "docs/**/*.md", basePath: "out" }],
76
+ };
77
+
78
+ expect(resolveAssetConfig(options, "docs/guide.md")).toEqual({
79
+ assetsPath: "/custom/assets",
80
+ assetsBaseUrl: "/static/img",
81
+ });
82
+ });
83
+
84
+ it("returns null when only assetsPath is set (misconfiguration)", () => {
85
+ const options: ImportOptions = {
86
+ owner: "o",
87
+ repo: "r",
88
+ assetsPath: "/assets",
89
+ includes: [{ pattern: "docs/**/*.md", basePath: "out" }],
90
+ };
91
+ expect(resolveAssetConfig(options, "docs/guide.md")).toBeNull();
92
+ });
93
+
94
+ it("returns null when only assetsBaseUrl is set (misconfiguration)", () => {
95
+ const options: ImportOptions = {
96
+ owner: "o",
97
+ repo: "r",
98
+ assetsBaseUrl: "/img",
99
+ includes: [{ pattern: "docs/**/*.md", basePath: "out" }],
100
+ };
101
+ expect(resolveAssetConfig(options, "docs/guide.md")).toBeNull();
102
+ });
103
+
104
+ it("derives co-located defaults from matched include basePath", () => {
105
+ const options: ImportOptions = {
106
+ owner: "o",
107
+ repo: "r",
108
+ includes: [
109
+ { pattern: "docs/**/*.md", basePath: "src/content/docs/mylib" },
110
+ ],
111
+ };
112
+
113
+ const result = resolveAssetConfig(options, "docs/intro.md");
114
+
115
+ expect(result).toEqual({
116
+ assetsPath: "src/content/docs/mylib/assets",
117
+ assetsBaseUrl: "./assets",
118
+ });
119
+ });
120
+
121
+ it("returns null when file does not match any include pattern", () => {
122
+ const options: ImportOptions = {
123
+ owner: "o",
124
+ repo: "r",
125
+ includes: [{ pattern: "docs/**/*.md", basePath: "out" }],
126
+ };
127
+
128
+ expect(resolveAssetConfig(options, "src/main.ts")).toBeNull();
129
+ });
130
+
131
+ it("returns null when no includes are defined and no explicit config", () => {
132
+ const options: ImportOptions = { owner: "o", repo: "r" };
133
+ expect(resolveAssetConfig(options, "anything.md")).toBeNull();
134
+ });
135
+ });
136
+
137
+ // ============================== detectAssets ===============================
138
+
139
+ describe("detectAssets", () => {
140
+ it("finds markdown image references", () => {
141
+ const content = "![screenshot](./screenshot.png)";
142
+ expect(detectAssets(content)).toEqual(["./screenshot.png"]);
143
+ });
144
+
145
+ it("finds HTML img tags", () => {
146
+ const content = '<img src="photo.jpg" alt="photo">';
147
+ expect(detectAssets(content)).toEqual(["photo.jpg"]);
148
+ });
149
+
150
+ it("finds HTML img tags with single quotes", () => {
151
+ const content = "<img src='icon.svg' />";
152
+ expect(detectAssets(content)).toEqual(["icon.svg"]);
153
+ });
154
+
155
+ it("ignores absolute URLs in markdown images", () => {
156
+ const content = "![logo](https://example.com/logo.png)";
157
+ expect(detectAssets(content)).toEqual([]);
158
+ });
159
+
160
+ it("ignores absolute URLs in HTML img tags", () => {
161
+ const content = '<img src="https://cdn.example.com/hero.jpg">';
162
+ expect(detectAssets(content)).toEqual([]);
163
+ });
164
+
165
+ it("ignores non-matching extensions", () => {
166
+ const content = "![doc](./readme.pdf)\n![data](./file.csv)";
167
+ expect(detectAssets(content)).toEqual([]);
168
+ });
169
+
170
+ it("supports custom asset patterns", () => {
171
+ const content = "![doc](./readme.pdf)";
172
+ expect(detectAssets(content, [".pdf"])).toEqual(["./readme.pdf"]);
173
+ });
174
+
175
+ it("deduplicates repeated references", () => {
176
+ const content =
177
+ "![a](./img.png)\n![b](./img.png)\n![c](./img.png)";
178
+ expect(detectAssets(content)).toEqual(["./img.png"]);
179
+ });
180
+
181
+ it("handles ../ relative paths", () => {
182
+ const content = "![up](../assets/diagram.svg)";
183
+ expect(detectAssets(content)).toEqual(["../assets/diagram.svg"]);
184
+ });
185
+
186
+ it("returns empty array when no assets are present", () => {
187
+ const content = "# Just a heading\n\nSome text with no images.";
188
+ expect(detectAssets(content)).toEqual([]);
189
+ });
190
+
191
+ it("finds multiple different assets", () => {
192
+ const content = [
193
+ "![a](./alpha.png)",
194
+ "![b](./beta.jpg)",
195
+ '<img src="gamma.svg">',
196
+ ].join("\n");
197
+
198
+ const result = detectAssets(content);
199
+ expect(result).toContain("./alpha.png");
200
+ expect(result).toContain("./beta.jpg");
201
+ expect(result).toContain("gamma.svg");
202
+ expect(result).toHaveLength(3);
203
+ });
204
+
205
+ it("detects bare filenames without ./ prefix", () => {
206
+ const content = "![icon](icon.webp)";
207
+ expect(detectAssets(content)).toEqual(["icon.webp"]);
208
+ });
209
+
210
+ it("handles mixed markdown and HTML references", () => {
211
+ const content =
212
+ '![md](./one.png)\n<img src="two.gif">\n![md2](./three.jpeg)';
213
+ const result = detectAssets(content);
214
+ expect(result).toHaveLength(3);
215
+ });
216
+ });
217
+
218
+ // ============================ downloadAsset ================================
219
+
220
+ describe("downloadAsset", () => {
221
+ let octokit: Octokit;
222
+
223
+ beforeEach(() => {
224
+ octokit = new Octokit({ auth: "mock-token" });
225
+ });
226
+
227
+ it("downloads a file and writes it to disk", async () => {
228
+ const binaryPayload = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
229
+
230
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
231
+ data: {
232
+ type: "file",
233
+ name: "image.png",
234
+ path: "docs/image.png",
235
+ download_url: "https://raw.githubusercontent.com/o/r/main/docs/image.png",
236
+ },
237
+ status: 200,
238
+ url: "",
239
+ headers: {},
240
+ } as any);
241
+
242
+ globalThis.fetch = vi.fn().mockResolvedValue({
243
+ ok: true,
244
+ status: 200,
245
+ arrayBuffer: async () => binaryPayload.buffer,
246
+ });
247
+
248
+ await downloadAsset(
249
+ octokit,
250
+ "o",
251
+ "r",
252
+ "main",
253
+ "docs/image.png",
254
+ "/out/assets/image.png",
255
+ );
256
+
257
+ expect(fs.mkdir).toHaveBeenCalledWith("/out/assets", { recursive: true });
258
+ expect(fs.writeFile).toHaveBeenCalledWith(
259
+ "/out/assets/image.png",
260
+ expect.any(Uint8Array),
261
+ );
262
+ });
263
+
264
+ it("skips mkdir when directory already exists", async () => {
265
+ vi.mocked(existsSync).mockReturnValue(true);
266
+
267
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
268
+ data: {
269
+ type: "file",
270
+ name: "img.png",
271
+ path: "img.png",
272
+ download_url: "https://raw.githubusercontent.com/o/r/main/img.png",
273
+ },
274
+ status: 200,
275
+ url: "",
276
+ headers: {},
277
+ } as any);
278
+
279
+ globalThis.fetch = vi.fn().mockResolvedValue({
280
+ ok: true,
281
+ status: 200,
282
+ arrayBuffer: async () => new ArrayBuffer(4),
283
+ });
284
+
285
+ await downloadAsset(octokit, "o", "r", "main", "img.png", "/out/img.png");
286
+
287
+ expect(fs.mkdir).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it("throws when getContent returns an array (directory)", async () => {
291
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
292
+ data: [{ type: "file", name: "a.png" }],
293
+ status: 200,
294
+ url: "",
295
+ headers: {},
296
+ } as any);
297
+
298
+ await expect(
299
+ downloadAsset(octokit, "o", "r", "main", "dir/", "/out/dir/"),
300
+ ).rejects.toThrow("is a directory");
301
+ });
302
+
303
+ it("throws when data.type is not file", async () => {
304
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
305
+ data: { type: "symlink", name: "link", path: "link", download_url: null },
306
+ status: 200,
307
+ url: "",
308
+ headers: {},
309
+ } as any);
310
+
311
+ await expect(
312
+ downloadAsset(octokit, "o", "r", "main", "link", "/out/link"),
313
+ ).rejects.toThrow("is not a valid file");
314
+ });
315
+
316
+ it('throws "Asset not found" on 404 status', async () => {
317
+ const notFoundError = Object.assign(new Error("Not Found"), {
318
+ status: 404,
319
+ });
320
+ vi.spyOn(octokit.rest.repos, "getContent").mockRejectedValue(
321
+ notFoundError,
322
+ );
323
+
324
+ await expect(
325
+ downloadAsset(octokit, "o", "r", "main", "missing.png", "/out/x.png"),
326
+ ).rejects.toThrow("Asset not found: missing.png");
327
+ });
328
+
329
+ it("throws download error on non-ok fetch response", async () => {
330
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
331
+ data: {
332
+ type: "file",
333
+ name: "img.png",
334
+ path: "img.png",
335
+ download_url: "https://raw.githubusercontent.com/o/r/main/img.png",
336
+ },
337
+ status: 200,
338
+ url: "",
339
+ headers: {},
340
+ } as any);
341
+
342
+ globalThis.fetch = vi.fn().mockResolvedValue({
343
+ ok: false,
344
+ status: 500,
345
+ statusText: "Internal Server Error",
346
+ });
347
+
348
+ await expect(
349
+ downloadAsset(octokit, "o", "r", "main", "img.png", "/out/img.png"),
350
+ ).rejects.toThrow("Failed to download asset: 500 Internal Server Error");
351
+ });
352
+
353
+ it("re-throws unexpected errors as-is", async () => {
354
+ vi.spyOn(octokit.rest.repos, "getContent").mockRejectedValue(
355
+ new Error("Network failure"),
356
+ );
357
+
358
+ await expect(
359
+ downloadAsset(octokit, "o", "r", "main", "x.png", "/out/x.png"),
360
+ ).rejects.toThrow("Network failure");
361
+ });
362
+ });
363
+
364
+ // ======================== transformAssetReferences =========================
365
+
366
+ describe("transformAssetReferences", () => {
367
+ it("transforms a markdown image reference", () => {
368
+ const content = "![alt](./img.png)";
369
+ const map = new Map([["./img.png", "/assets/img-123.png"]]);
370
+ expect(transformAssetReferences(content, map)).toBe(
371
+ "![alt](/assets/img-123.png)",
372
+ );
373
+ });
374
+
375
+ it("transforms an HTML img tag", () => {
376
+ const content = '<img src="./photo.jpg" alt="photo">';
377
+ const map = new Map([["./photo.jpg", "/assets/photo-456.jpg"]]);
378
+ expect(transformAssetReferences(content, map)).toBe(
379
+ '<img src="/assets/photo-456.jpg" alt="photo">',
380
+ );
381
+ });
382
+
383
+ it("handles multiple replacements", () => {
384
+ const content = "![a](./a.png)\n![b](./b.svg)";
385
+ const map = new Map([
386
+ ["./a.png", "/out/a.png"],
387
+ ["./b.svg", "/out/b.svg"],
388
+ ]);
389
+ const result = transformAssetReferences(content, map);
390
+ expect(result).toContain("![a](/out/a.png)");
391
+ expect(result).toContain("![b](/out/b.svg)");
392
+ });
393
+
394
+ it("leaves non-matching references unchanged", () => {
395
+ const content = "![x](./other.png)\n![y](./untouched.gif)";
396
+ const map = new Map([["./other.png", "/new/other.png"]]);
397
+ const result = transformAssetReferences(content, map);
398
+ expect(result).toContain("![x](/new/other.png)");
399
+ expect(result).toContain("![y](./untouched.gif)");
400
+ });
401
+
402
+ it("handles special regex characters in paths", () => {
403
+ const content = "![chart](./data[1].chart(v2).png)";
404
+ const map = new Map([
405
+ ["./data[1].chart(v2).png", "/assets/chart-789.png"],
406
+ ]);
407
+ expect(transformAssetReferences(content, map)).toBe(
408
+ "![chart](/assets/chart-789.png)",
409
+ );
410
+ });
411
+
412
+ it("returns unchanged content when map is empty", () => {
413
+ const content = "![a](./a.png)";
414
+ expect(transformAssetReferences(content, new Map())).toBe(content);
415
+ });
416
+
417
+ it("transforms multiple occurrences of the same asset", () => {
418
+ const content = "![first](./icon.svg)\nSome text\n![second](./icon.svg)";
419
+ const map = new Map([["./icon.svg", "/out/icon.svg"]]);
420
+ const result = transformAssetReferences(content, map);
421
+ expect(result).toBe(
422
+ "![first](/out/icon.svg)\nSome text\n![second](/out/icon.svg)",
423
+ );
424
+ });
425
+ });
426
+
427
+ // ============================= resolveAssetPath ============================
428
+
429
+ describe("resolveAssetPath", () => {
430
+ it("resolves ./relative paths against dirname of base", () => {
431
+ expect(resolveAssetPath("docs/guide.md", "./image.png")).toBe(
432
+ "docs/image.png",
433
+ );
434
+ });
435
+
436
+ it("resolves ../parent paths against dirname of base", () => {
437
+ expect(resolveAssetPath("docs/sub/page.md", "../shared/logo.svg")).toBe(
438
+ "docs/shared/logo.svg",
439
+ );
440
+ });
441
+
442
+ it("returns absolute-looking paths unchanged", () => {
443
+ expect(resolveAssetPath("docs/guide.md", "assets/icon.png")).toBe(
444
+ "assets/icon.png",
445
+ );
446
+ });
447
+
448
+ it("returns bare filenames unchanged", () => {
449
+ expect(resolveAssetPath("docs/guide.md", "photo.jpg")).toBe("photo.jpg");
450
+ });
451
+
452
+ it("resolves ./ from a nested directory", () => {
453
+ expect(
454
+ resolveAssetPath("a/b/c/file.md", "./diagram.svg"),
455
+ ).toBe("a/b/c/diagram.svg");
456
+ });
457
+ });
458
+
459
+ // ============================== processAssets ==============================
460
+
461
+ describe("processAssets", () => {
462
+ let octokit: Octokit;
463
+ let mockLogger: ReturnType<typeof createMockLogger>;
464
+
465
+ beforeEach(() => {
466
+ octokit = new Octokit({ auth: "mock-token" });
467
+ mockLogger = createMockLogger();
468
+ // Stabilize Date.now for deterministic unique filenames
469
+ vi.spyOn(Date, "now").mockReturnValue(1000000);
470
+ });
471
+
472
+ it("skips processing when assetsPath is missing", async () => {
473
+ const options: ImportOptions = {
474
+ owner: "o",
475
+ repo: "r",
476
+ assetsBaseUrl: "/img",
477
+ // assetsPath intentionally omitted
478
+ };
479
+
480
+ const result = await processAssets(
481
+ "![a](./a.png)",
482
+ "docs/file.md",
483
+ options,
484
+ octokit,
485
+ mockLogger as any,
486
+ );
487
+
488
+ expect(result.content).toBe("![a](./a.png)");
489
+ expect(result.assetsDownloaded).toBe(0);
490
+ expect(result.assetsCached).toBe(0);
491
+ });
492
+
493
+ it("skips processing when assetsBaseUrl is missing", async () => {
494
+ const options: ImportOptions = {
495
+ owner: "o",
496
+ repo: "r",
497
+ assetsPath: "/tmp/assets",
498
+ // assetsBaseUrl intentionally omitted
499
+ };
500
+
501
+ const result = await processAssets(
502
+ "![a](./a.png)",
503
+ "docs/file.md",
504
+ options,
505
+ octokit,
506
+ mockLogger as any,
507
+ );
508
+
509
+ expect(result.content).toBe("![a](./a.png)");
510
+ expect(result.assetsDownloaded).toBe(0);
511
+ });
512
+
513
+ it("returns unchanged content when no assets are detected", async () => {
514
+ const options: ImportOptions = {
515
+ owner: "o",
516
+ repo: "r",
517
+ assetsPath: "/tmp/assets",
518
+ assetsBaseUrl: "./assets",
519
+ };
520
+
521
+ const content = "# Just text\n\nNo images here.";
522
+ const result = await processAssets(
523
+ content,
524
+ "docs/file.md",
525
+ options,
526
+ octokit,
527
+ mockLogger as any,
528
+ );
529
+
530
+ expect(result.content).toBe(content);
531
+ expect(result.assetsDownloaded).toBe(0);
532
+ expect(result.assetsCached).toBe(0);
533
+ });
534
+
535
+ it("downloads assets and transforms references end-to-end", async () => {
536
+ const options: ImportOptions = {
537
+ owner: "myorg",
538
+ repo: "myrepo",
539
+ ref: "main",
540
+ assetsPath: "/out/assets",
541
+ assetsBaseUrl: "./assets",
542
+ };
543
+
544
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
545
+ data: {
546
+ type: "file",
547
+ name: "diagram.png",
548
+ path: "docs/diagram.png",
549
+ download_url:
550
+ "https://raw.githubusercontent.com/myorg/myrepo/main/docs/diagram.png",
551
+ },
552
+ status: 200,
553
+ url: "",
554
+ headers: {},
555
+ } as any);
556
+
557
+ globalThis.fetch = vi.fn().mockResolvedValue({
558
+ ok: true,
559
+ status: 200,
560
+ arrayBuffer: async () => new ArrayBuffer(8),
561
+ });
562
+
563
+ const content = "# Guide\n\n![diagram](./diagram.png)\n";
564
+ const result = await processAssets(
565
+ content,
566
+ "docs/guide.md",
567
+ options,
568
+ octokit,
569
+ mockLogger as any,
570
+ );
571
+
572
+ expect(result.assetsDownloaded).toBe(1);
573
+ expect(result.assetsCached).toBe(0);
574
+ // The transformed path should use the assetsBaseUrl + unique filename
575
+ expect(result.content).toContain("./assets/diagram-1000000.png");
576
+ expect(result.content).not.toContain("./diagram.png");
577
+ });
578
+
579
+ it("handles download errors gracefully (logs warning, continues)", async () => {
580
+ const options: ImportOptions = {
581
+ owner: "o",
582
+ repo: "r",
583
+ ref: "main",
584
+ assetsPath: "/out/assets",
585
+ assetsBaseUrl: "./assets",
586
+ };
587
+
588
+ vi.spyOn(octokit.rest.repos, "getContent").mockRejectedValue(
589
+ new Error("API rate limit"),
590
+ );
591
+
592
+ const content = "![img](./fail.png)";
593
+ const result = await processAssets(
594
+ content,
595
+ "docs/page.md",
596
+ options,
597
+ octokit,
598
+ mockLogger as any,
599
+ );
600
+
601
+ // Content stays unchanged because the download failed
602
+ expect(result.content).toBe(content);
603
+ expect(result.assetsDownloaded).toBe(0);
604
+ expect(mockLogger.warn).toHaveBeenCalledWith(
605
+ expect.stringContaining("Failed to process asset ./fail.png"),
606
+ );
607
+ });
608
+
609
+ it("reports cached count when asset already exists on disk", async () => {
610
+ vi.mocked(existsSync).mockReturnValue(true);
611
+
612
+ const options: ImportOptions = {
613
+ owner: "o",
614
+ repo: "r",
615
+ ref: "main",
616
+ assetsPath: "/out/assets",
617
+ assetsBaseUrl: "./assets",
618
+ };
619
+
620
+ const content = "![cached](./cached.png)";
621
+ const result = await processAssets(
622
+ content,
623
+ "docs/page.md",
624
+ options,
625
+ octokit,
626
+ mockLogger as any,
627
+ );
628
+
629
+ expect(result.assetsCached).toBe(1);
630
+ expect(result.assetsDownloaded).toBe(0);
631
+ // Content should still be transformed with the new reference
632
+ expect(result.content).toContain("./assets/cached-1000000.png");
633
+ });
634
+
635
+ it("processes multiple assets in a single file", async () => {
636
+ const options: ImportOptions = {
637
+ owner: "o",
638
+ repo: "r",
639
+ ref: "main",
640
+ assetsPath: "/out/assets",
641
+ assetsBaseUrl: "./assets",
642
+ };
643
+
644
+ vi.spyOn(octokit.rest.repos, "getContent").mockResolvedValue({
645
+ data: {
646
+ type: "file",
647
+ name: "img.png",
648
+ path: "docs/img.png",
649
+ download_url: "https://raw.githubusercontent.com/o/r/main/docs/img.png",
650
+ },
651
+ status: 200,
652
+ url: "",
653
+ headers: {},
654
+ } as any);
655
+
656
+ globalThis.fetch = vi.fn().mockResolvedValue({
657
+ ok: true,
658
+ status: 200,
659
+ arrayBuffer: async () => new ArrayBuffer(4),
660
+ });
661
+
662
+ const content = "![a](./alpha.png)\n![b](./beta.jpg)";
663
+ const result = await processAssets(
664
+ content,
665
+ "docs/page.md",
666
+ options,
667
+ octokit,
668
+ mockLogger as any,
669
+ );
670
+
671
+ expect(result.assetsDownloaded).toBe(2);
672
+ expect(result.content).toContain("./assets/alpha-1000000.png");
673
+ expect(result.content).toContain("./assets/beta-1000000.jpg");
674
+ });
675
+
676
+ it("uses default ref 'main' when ref is not specified", async () => {
677
+ const options: ImportOptions = {
678
+ owner: "o",
679
+ repo: "r",
680
+ // ref intentionally omitted
681
+ assetsPath: "/out/assets",
682
+ assetsBaseUrl: "./assets",
683
+ };
684
+
685
+ const getContentSpy = vi
686
+ .spyOn(octokit.rest.repos, "getContent")
687
+ .mockResolvedValue({
688
+ data: {
689
+ type: "file",
690
+ name: "x.png",
691
+ path: "docs/x.png",
692
+ download_url: "https://raw.githubusercontent.com/o/r/main/docs/x.png",
693
+ },
694
+ status: 200,
695
+ url: "",
696
+ headers: {},
697
+ } as any);
698
+
699
+ globalThis.fetch = vi.fn().mockResolvedValue({
700
+ ok: true,
701
+ status: 200,
702
+ arrayBuffer: async () => new ArrayBuffer(2),
703
+ });
704
+
705
+ await processAssets(
706
+ "![x](./x.png)",
707
+ "docs/file.md",
708
+ options,
709
+ octokit,
710
+ mockLogger as any,
711
+ );
712
+
713
+ expect(getContentSpy).toHaveBeenCalledWith(
714
+ expect.objectContaining({ ref: "main" }),
715
+ );
716
+ });
717
+ });