@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,523 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ generateId,
4
+ applyRename,
5
+ generatePath,
6
+ shouldIncludeFile,
7
+ getHeaders,
8
+ syncHeaders,
9
+ } from "./github.paths.js";
10
+ import type { ImportOptions, MatchedPattern } from "./github.types.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // generateId
14
+ // ---------------------------------------------------------------------------
15
+ describe("generateId", () => {
16
+ it("removes file extension", () => {
17
+ expect(generateId("docs/guide.md")).toBe("docs/guide");
18
+ });
19
+
20
+ it("handles nested paths", () => {
21
+ expect(generateId("src/lib/utils.ts")).toBe("src/lib/utils");
22
+ });
23
+
24
+ it("returns the path as-is when there is no extension", () => {
25
+ expect(generateId("README")).toBe("README");
26
+ });
27
+
28
+ it("preserves dot-prefixed directory names", () => {
29
+ expect(generateId("docs/.hidden/file.md")).toBe("docs/.hidden/file");
30
+ });
31
+
32
+ it("removes only the last extension for files with multiple dots", () => {
33
+ expect(generateId("file.spec.ts")).toBe("file.spec");
34
+ });
35
+
36
+ it("handles a leading dot file (hidden file)", () => {
37
+ // lastDotIndex === 0, so the condition > 0 is false → unchanged
38
+ expect(generateId(".gitignore")).toBe(".gitignore");
39
+ });
40
+
41
+ it("handles an empty string", () => {
42
+ expect(generateId("")).toBe("");
43
+ });
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // applyRename
48
+ // ---------------------------------------------------------------------------
49
+ describe("applyRename", () => {
50
+ it("returns basename when no options are provided", () => {
51
+ expect(applyRename("docs/guide.md")).toBe("guide.md");
52
+ });
53
+
54
+ it("returns basename when no matchedPattern is provided", () => {
55
+ const options: ImportOptions = {
56
+ owner: "o",
57
+ repo: "r",
58
+ includes: [{ pattern: "docs/**", basePath: "guide" }],
59
+ };
60
+ expect(applyRename("docs/guide.md", null, options)).toBe("guide.md");
61
+ });
62
+
63
+ it("returns basename when options have no includes", () => {
64
+ const options: ImportOptions = { owner: "o", repo: "r" };
65
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
66
+ expect(applyRename("docs/guide.md", mp, options)).toBe("guide.md");
67
+ });
68
+
69
+ it("applies exact file mapping (string value)", () => {
70
+ const options: ImportOptions = {
71
+ owner: "o",
72
+ repo: "r",
73
+ includes: [
74
+ {
75
+ pattern: "docs/**",
76
+ basePath: "guide",
77
+ pathMappings: { "docs/README.md": "overview.md" },
78
+ },
79
+ ],
80
+ };
81
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
82
+ expect(applyRename("docs/README.md", mp, options)).toBe("overview.md");
83
+ });
84
+
85
+ it("applies exact file mapping (object value)", () => {
86
+ const options: ImportOptions = {
87
+ owner: "o",
88
+ repo: "r",
89
+ includes: [
90
+ {
91
+ pattern: "docs/**",
92
+ basePath: "guide",
93
+ pathMappings: { "docs/README.md": { target: "overview.md" } },
94
+ },
95
+ ],
96
+ };
97
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
98
+ expect(applyRename("docs/README.md", mp, options)).toBe("overview.md");
99
+ });
100
+
101
+ it("applies folder mapping (string value)", () => {
102
+ const options: ImportOptions = {
103
+ owner: "o",
104
+ repo: "r",
105
+ includes: [
106
+ {
107
+ pattern: "docs/**",
108
+ basePath: "guide",
109
+ pathMappings: { "docs/features/": "" },
110
+ },
111
+ ],
112
+ };
113
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
114
+ expect(applyRename("docs/features/auth.md", mp, options)).toBe("auth.md");
115
+ });
116
+
117
+ it("applies folder mapping (object value)", () => {
118
+ const options: ImportOptions = {
119
+ owner: "o",
120
+ repo: "r",
121
+ includes: [
122
+ {
123
+ pattern: "docs/**",
124
+ basePath: "guide",
125
+ pathMappings: {
126
+ "docs/features/": { target: "feat/" },
127
+ },
128
+ },
129
+ ],
130
+ };
131
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
132
+ expect(applyRename("docs/features/auth.md", mp, options)).toBe("feat/auth.md");
133
+ });
134
+
135
+ it("returns basename when no mapping matches", () => {
136
+ const options: ImportOptions = {
137
+ owner: "o",
138
+ repo: "r",
139
+ includes: [
140
+ {
141
+ pattern: "docs/**",
142
+ basePath: "guide",
143
+ pathMappings: { "docs/README.md": "overview.md" },
144
+ },
145
+ ],
146
+ };
147
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
148
+ expect(applyRename("docs/other.md", mp, options)).toBe("other.md");
149
+ });
150
+
151
+ it("returns basename when pattern index is out of bounds", () => {
152
+ const options: ImportOptions = {
153
+ owner: "o",
154
+ repo: "r",
155
+ includes: [{ pattern: "docs/**", basePath: "guide" }],
156
+ };
157
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 5 };
158
+ expect(applyRename("docs/guide.md", mp, options)).toBe("guide.md");
159
+ });
160
+
161
+ it("returns basename when pathMappings is not defined on the include", () => {
162
+ const options: ImportOptions = {
163
+ owner: "o",
164
+ repo: "r",
165
+ includes: [{ pattern: "docs/**", basePath: "guide" }],
166
+ };
167
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
168
+ expect(applyRename("docs/guide.md", mp, options)).toBe("guide.md");
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // generatePath
174
+ // ---------------------------------------------------------------------------
175
+ describe("generatePath", () => {
176
+ it("joins basePath with relative path after stripping pattern prefix", () => {
177
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
178
+ const options: ImportOptions = {
179
+ owner: "o",
180
+ repo: "r",
181
+ includes: [{ pattern: "docs/**", basePath: "guide" }],
182
+ };
183
+ // filePath "docs/intro.md" → beforeGlob "docs/" → relative "intro.md"
184
+ const result = generatePath("docs/intro.md", mp, options);
185
+ expect(result).toBe("guide/intro.md");
186
+ });
187
+
188
+ it("uses filename when path equals the pattern prefix exactly", () => {
189
+ const mp: MatchedPattern = { pattern: "README.md", basePath: "root", index: 0 };
190
+ const options: ImportOptions = {
191
+ owner: "o",
192
+ repo: "r",
193
+ includes: [{ pattern: "README.md", basePath: "root" }],
194
+ };
195
+ // beforeGlob = "README.md" → relative becomes "" → falls back to basename
196
+ const result = generatePath("README.md", mp, options);
197
+ expect(result).toBe("root/README.md");
198
+ });
199
+
200
+ it("applies path mapping that produces a filename", () => {
201
+ const options: ImportOptions = {
202
+ owner: "o",
203
+ repo: "r",
204
+ includes: [
205
+ {
206
+ pattern: "docs/**",
207
+ basePath: "guide",
208
+ pathMappings: { "docs/README.md": "overview.md" },
209
+ },
210
+ ],
211
+ };
212
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
213
+ const result = generatePath("docs/README.md", mp, options);
214
+ expect(result).toBe("guide/overview.md");
215
+ });
216
+
217
+ it("applies path mapping that produces a full relative path", () => {
218
+ const options: ImportOptions = {
219
+ owner: "o",
220
+ repo: "r",
221
+ includes: [
222
+ {
223
+ pattern: "docs/**",
224
+ basePath: "guide",
225
+ pathMappings: { "docs/features/": "topics/" },
226
+ },
227
+ ],
228
+ };
229
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
230
+ const result = generatePath("docs/features/auth.md", mp, options);
231
+ expect(result).toBe("guide/topics/auth.md");
232
+ });
233
+
234
+ it("handles a full relative path that starts with pattern prefix", () => {
235
+ const options: ImportOptions = {
236
+ owner: "o",
237
+ repo: "r",
238
+ includes: [
239
+ {
240
+ pattern: "docs/**",
241
+ basePath: "guide",
242
+ pathMappings: { "docs/README.md": "docs/renamed.md" },
243
+ },
244
+ ],
245
+ };
246
+ const mp: MatchedPattern = { pattern: "docs/**", basePath: "guide", index: 0 };
247
+ const result = generatePath("docs/README.md", mp, options);
248
+ // applyRename returns "docs/renamed.md", which starts with beforeGlob "docs/"
249
+ // so relative becomes "renamed.md"
250
+ expect(result).toBe("guide/renamed.md");
251
+ });
252
+
253
+ it("throws when no matchedPattern is provided", () => {
254
+ expect(() => generatePath("docs/file.md")).toThrow(
255
+ "No matched pattern provided - includes are required",
256
+ );
257
+ });
258
+
259
+ it("throws when matchedPattern is null", () => {
260
+ expect(() => generatePath("docs/file.md", null)).toThrow(
261
+ "No matched pattern provided - includes are required",
262
+ );
263
+ });
264
+
265
+ it("preserves nested directory structure within pattern", () => {
266
+ const mp: MatchedPattern = { pattern: "src/**", basePath: "lib", index: 0 };
267
+ const options: ImportOptions = {
268
+ owner: "o",
269
+ repo: "r",
270
+ includes: [{ pattern: "src/**", basePath: "lib" }],
271
+ };
272
+ const result = generatePath("src/utils/helpers/format.ts", mp, options);
273
+ expect(result).toBe("lib/utils/helpers/format.ts");
274
+ });
275
+ });
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // shouldIncludeFile
279
+ // ---------------------------------------------------------------------------
280
+ describe("shouldIncludeFile", () => {
281
+ it("includes all files when includes is undefined", () => {
282
+ const options: ImportOptions = { owner: "o", repo: "r" };
283
+ const result = shouldIncludeFile("any/file.md", options);
284
+ expect(result).toEqual({ included: true, matchedPattern: null });
285
+ });
286
+
287
+ it("includes all files when includes is an empty array", () => {
288
+ const options: ImportOptions = { owner: "o", repo: "r", includes: [] };
289
+ const result = shouldIncludeFile("any/file.md", options);
290
+ expect(result).toEqual({ included: true, matchedPattern: null });
291
+ });
292
+
293
+ it("returns the matched pattern when a pattern matches", () => {
294
+ const options: ImportOptions = {
295
+ owner: "o",
296
+ repo: "r",
297
+ includes: [{ pattern: "docs/**/*.md", basePath: "guide" }],
298
+ };
299
+ const result = shouldIncludeFile("docs/intro.md", options);
300
+ expect(result).toEqual({
301
+ included: true,
302
+ matchedPattern: { pattern: "docs/**/*.md", basePath: "guide", index: 0 },
303
+ });
304
+ });
305
+
306
+ it("returns not included when no pattern matches", () => {
307
+ const options: ImportOptions = {
308
+ owner: "o",
309
+ repo: "r",
310
+ includes: [{ pattern: "docs/**/*.md", basePath: "guide" }],
311
+ };
312
+ const result = shouldIncludeFile("src/index.ts", options);
313
+ expect(result).toEqual({ included: false, matchedPattern: null });
314
+ });
315
+
316
+ it("returns the correct index when matching the second pattern", () => {
317
+ const options: ImportOptions = {
318
+ owner: "o",
319
+ repo: "r",
320
+ includes: [
321
+ { pattern: "docs/**/*.md", basePath: "guide" },
322
+ { pattern: "src/**/*.ts", basePath: "api" },
323
+ ],
324
+ };
325
+ const result = shouldIncludeFile("src/utils.ts", options);
326
+ expect(result).toEqual({
327
+ included: true,
328
+ matchedPattern: { pattern: "src/**/*.ts", basePath: "api", index: 1 },
329
+ });
330
+ });
331
+
332
+ it("uses the first matching pattern when multiple could match", () => {
333
+ const options: ImportOptions = {
334
+ owner: "o",
335
+ repo: "r",
336
+ includes: [
337
+ { pattern: "docs/**", basePath: "all-docs" },
338
+ { pattern: "docs/**/*.md", basePath: "md-docs" },
339
+ ],
340
+ };
341
+ const result = shouldIncludeFile("docs/guide.md", options);
342
+ expect(result).toEqual({
343
+ included: true,
344
+ matchedPattern: { pattern: "docs/**", basePath: "all-docs", index: 0 },
345
+ });
346
+ });
347
+
348
+ it("matches glob patterns with ** and braces", () => {
349
+ const options: ImportOptions = {
350
+ owner: "o",
351
+ repo: "r",
352
+ includes: [
353
+ { pattern: "src/**/*.{ts,tsx}", basePath: "components" },
354
+ ],
355
+ };
356
+
357
+ expect(shouldIncludeFile("src/Button.tsx", options)).toEqual({
358
+ included: true,
359
+ matchedPattern: {
360
+ pattern: "src/**/*.{ts,tsx}",
361
+ basePath: "components",
362
+ index: 0,
363
+ },
364
+ });
365
+
366
+ expect(shouldIncludeFile("src/utils/helpers.ts", options)).toEqual({
367
+ included: true,
368
+ matchedPattern: {
369
+ pattern: "src/**/*.{ts,tsx}",
370
+ basePath: "components",
371
+ index: 0,
372
+ },
373
+ });
374
+
375
+ expect(shouldIncludeFile("src/styles.css", options)).toEqual({
376
+ included: false,
377
+ matchedPattern: null,
378
+ });
379
+ });
380
+ });
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // getHeaders
384
+ // ---------------------------------------------------------------------------
385
+ describe("getHeaders", () => {
386
+ let meta: Map<string, string>;
387
+
388
+ beforeEach(() => {
389
+ meta = new Map<string, string>();
390
+ });
391
+
392
+ it("returns plain Headers when no cached values exist", () => {
393
+ const headers = getHeaders({ meta, id: "test" });
394
+ expect(headers.has("If-None-Match")).toBe(false);
395
+ expect(headers.has("If-Modified-Since")).toBe(false);
396
+ });
397
+
398
+ it("sets If-None-Match when etag is cached", () => {
399
+ meta.set("test-etag", '"abc123"');
400
+ const headers = getHeaders({ meta, id: "test" });
401
+ expect(headers.get("If-None-Match")).toBe('"abc123"');
402
+ });
403
+
404
+ it("sets If-Modified-Since when lastModified is cached and no etag", () => {
405
+ meta.set("test-last-modified", "Thu, 01 Jan 2025 00:00:00 GMT");
406
+ const headers = getHeaders({ meta, id: "test" });
407
+ expect(headers.get("If-Modified-Since")).toBe(
408
+ "Thu, 01 Jan 2025 00:00:00 GMT",
409
+ );
410
+ expect(headers.has("If-None-Match")).toBe(false);
411
+ });
412
+
413
+ it("etag takes precedence over lastModified", () => {
414
+ meta.set("test-etag", '"abc123"');
415
+ meta.set("test-last-modified", "Thu, 01 Jan 2025 00:00:00 GMT");
416
+ const headers = getHeaders({ meta, id: "test" });
417
+ expect(headers.get("If-None-Match")).toBe('"abc123"');
418
+ expect(headers.has("If-Modified-Since")).toBe(false);
419
+ });
420
+
421
+ it("preserves initial headers", () => {
422
+ const init = { Authorization: "token ghp_xxx", Accept: "application/json" };
423
+ const headers = getHeaders({ init, meta, id: "test" });
424
+ expect(headers.get("Authorization")).toBe("token ghp_xxx");
425
+ expect(headers.get("Accept")).toBe("application/json");
426
+ });
427
+
428
+ it("preserves initial headers alongside conditional headers", () => {
429
+ meta.set("test-etag", '"abc123"');
430
+ const init = { Authorization: "token ghp_xxx" };
431
+ const headers = getHeaders({ init, meta, id: "test" });
432
+ expect(headers.get("Authorization")).toBe("token ghp_xxx");
433
+ expect(headers.get("If-None-Match")).toBe('"abc123"');
434
+ });
435
+ });
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // syncHeaders
439
+ // ---------------------------------------------------------------------------
440
+ describe("syncHeaders", () => {
441
+ let meta: Map<string, string>;
442
+
443
+ beforeEach(() => {
444
+ meta = new Map<string, string>();
445
+ });
446
+
447
+ it("stores etag from response headers", () => {
448
+ const headers = new Headers({ etag: '"def456"' });
449
+ syncHeaders({ headers, meta, id: "test" });
450
+ expect(meta.get("test-etag")).toBe('"def456"');
451
+ expect(meta.has("test-last-modified")).toBe(false);
452
+ });
453
+
454
+ it("stores lastModified when no etag is present", () => {
455
+ const headers = new Headers({
456
+ "last-modified": "Fri, 02 Jan 2025 12:00:00 GMT",
457
+ });
458
+ syncHeaders({ headers, meta, id: "test" });
459
+ expect(meta.get("test-last-modified")).toBe(
460
+ "Fri, 02 Jan 2025 12:00:00 GMT",
461
+ );
462
+ expect(meta.has("test-etag")).toBe(false);
463
+ });
464
+
465
+ it("etag takes precedence over lastModified", () => {
466
+ const headers = new Headers({
467
+ etag: '"xyz789"',
468
+ "last-modified": "Fri, 02 Jan 2025 12:00:00 GMT",
469
+ });
470
+ syncHeaders({ headers, meta, id: "test" });
471
+ expect(meta.get("test-etag")).toBe('"xyz789"');
472
+ expect(meta.has("test-last-modified")).toBe(false);
473
+ });
474
+
475
+ it("clears previous etag before setting new lastModified", () => {
476
+ meta.set("test-etag", '"old-etag"');
477
+ meta.set("test-last-modified", "old-date");
478
+
479
+ const headers = new Headers({
480
+ "last-modified": "Sat, 03 Jan 2025 00:00:00 GMT",
481
+ });
482
+ syncHeaders({ headers, meta, id: "test" });
483
+
484
+ expect(meta.has("test-etag")).toBe(false);
485
+ expect(meta.get("test-last-modified")).toBe(
486
+ "Sat, 03 Jan 2025 00:00:00 GMT",
487
+ );
488
+ });
489
+
490
+ it("clears previous lastModified before setting new etag", () => {
491
+ meta.set("test-etag", '"old-etag"');
492
+ meta.set("test-last-modified", "old-date");
493
+
494
+ const headers = new Headers({ etag: '"new-etag"' });
495
+ syncHeaders({ headers, meta, id: "test" });
496
+
497
+ expect(meta.get("test-etag")).toBe('"new-etag"');
498
+ expect(meta.has("test-last-modified")).toBe(false);
499
+ });
500
+
501
+ it("clears all previous caching headers when response has none", () => {
502
+ meta.set("test-etag", '"stale-etag"');
503
+ meta.set("test-last-modified", "stale-date");
504
+
505
+ const headers = new Headers();
506
+ syncHeaders({ headers, meta, id: "test" });
507
+
508
+ expect(meta.has("test-etag")).toBe(false);
509
+ expect(meta.has("test-last-modified")).toBe(false);
510
+ });
511
+
512
+ it("does not affect meta entries for other ids", () => {
513
+ meta.set("other-etag", '"other"');
514
+ meta.set("other-last-modified", "other-date");
515
+
516
+ const headers = new Headers({ etag: '"new"' });
517
+ syncHeaders({ headers, meta, id: "test" });
518
+
519
+ expect(meta.get("other-etag")).toBe('"other"');
520
+ expect(meta.get("other-last-modified")).toBe("other-date");
521
+ expect(meta.get("test-etag")).toBe('"new"');
522
+ });
523
+ });