@larkiny/astro-github-loader 0.9.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.
@@ -0,0 +1,452 @@
1
+ import { slug } from 'github-slugger';
2
+ import path from 'node:path';
3
+ import type { LinkMapping, LinkTransformContext, MatchedPattern, IncludePattern, PathMappingValue, EnhancedPathMapping } from './github.types.js';
4
+ import type { Logger } from './github.logger.js';
5
+
6
+ /**
7
+ * Represents an imported file with its content and metadata
8
+ */
9
+ export interface ImportedFile {
10
+ /** Original source path in the repository */
11
+ sourcePath: string;
12
+ /** Target path where the file will be written */
13
+ targetPath: string;
14
+ /** File content */
15
+ content: string;
16
+ /** File ID for cross-referencing */
17
+ id: string;
18
+ /** Context information for link transformations */
19
+ linkContext?: LinkTransformContext;
20
+ }
21
+
22
+ /**
23
+ * Context for global link transformation
24
+ */
25
+ interface GlobalLinkContext {
26
+ /** Map from source paths to target paths for all imported files */
27
+ sourceToTargetMap: Map<string, string>;
28
+ /** Map from source paths to file IDs */
29
+ sourceToIdMap: Map<string, string>;
30
+ /** Base paths to strip from final URLs (e.g., "src/content/docs") */
31
+ stripPrefixes: string[];
32
+ /** Custom handlers for special link types */
33
+ customHandlers?: LinkHandler[];
34
+ /** Path mappings for common transformations */
35
+ linkMappings?: LinkMapping[];
36
+ /** Logger for debug output */
37
+ logger?: Logger;
38
+ }
39
+
40
+ /**
41
+ * Custom handler for specific link patterns
42
+ */
43
+ export interface LinkHandler {
44
+ /** Test if this handler should process the link */
45
+ test: (link: string, context: LinkContext) => boolean;
46
+ /** Transform the link */
47
+ transform: (link: string, context: LinkContext) => string;
48
+ }
49
+
50
+ /**
51
+ * Context for individual link transformation
52
+ */
53
+ interface LinkContext {
54
+ /** The file containing the link */
55
+ currentFile: ImportedFile;
56
+ /** The original link text */
57
+ originalLink: string;
58
+ /** Any anchor/fragment in the link */
59
+ anchor: string;
60
+ /** Global context */
61
+ global: GlobalLinkContext;
62
+ }
63
+
64
+ /**
65
+ * Extract anchor fragment from a link
66
+ */
67
+ function extractAnchor(link: string): { path: string; anchor: string } {
68
+ const anchorMatch = link.match(/#.*$/);
69
+ const anchor = anchorMatch ? anchorMatch[0] : '';
70
+ const path = link.replace(/#.*$/, '');
71
+ return { path, anchor };
72
+ }
73
+
74
+ /**
75
+ * Check if a link is external (should not be transformed)
76
+ * External links are left completely unchanged by all transformations
77
+ */
78
+ function isExternalLink(link: string): boolean {
79
+ return (
80
+ // Common protocols
81
+ /^https?:\/\//.test(link) ||
82
+ /^mailto:/.test(link) ||
83
+ /^tel:/.test(link) ||
84
+ /^ftp:/.test(link) ||
85
+ /^ftps:\/\//.test(link) ||
86
+
87
+ // Any protocol with ://
88
+ link.includes('://') ||
89
+
90
+ // Anchor-only links (same page)
91
+ link.startsWith('#') ||
92
+
93
+ // Data URLs
94
+ /^data:/.test(link) ||
95
+
96
+ // File protocol
97
+ /^file:\/\//.test(link)
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Normalize path separators and resolve relative paths
103
+ */
104
+ function normalizePath(linkPath: string, currentFilePath: string, logger?: Logger): string {
105
+ logger?.debug(`[normalizePath] BEFORE: linkPath="${linkPath}", currentFilePath="${currentFilePath}"`);
106
+
107
+ // Handle relative paths
108
+ if (linkPath.startsWith('./') || linkPath.includes('../')) {
109
+ const currentDir = path.dirname(currentFilePath);
110
+ const resolved = path.posix.normalize(path.posix.join(currentDir, linkPath));
111
+ logger?.debug(`[normalizePath] RELATIVE PATH RESOLVED: "${linkPath}" -> "${resolved}" (currentDir: "${currentDir}")`);
112
+ return resolved;
113
+ }
114
+
115
+ // Remove leading './'
116
+ if (linkPath.startsWith('./')) {
117
+ return linkPath.slice(2);
118
+ }
119
+
120
+ logger?.debug(`[normalizePath] AFTER: "${linkPath}" (no changes)`);
121
+ return linkPath;
122
+ }
123
+
124
+ /**
125
+ * Apply link mappings to transform a URL
126
+ */
127
+ function applyLinkMappings(
128
+ linkUrl: string,
129
+ linkMappings: LinkMapping[],
130
+ context: LinkContext
131
+ ): string {
132
+ const { path: linkPath, anchor } = extractAnchor(linkUrl);
133
+ let transformedPath = linkPath;
134
+
135
+ for (const mapping of linkMappings) {
136
+ // Check if contextFilter allows this mapping to be applied
137
+ if (mapping.contextFilter && context.currentFile.linkContext) {
138
+ if (!mapping.contextFilter(context.currentFile.linkContext)) {
139
+ continue; // Skip this mapping
140
+ }
141
+ }
142
+
143
+ // Handle relative links automatically if enabled
144
+ if (mapping.relativeLinks && context.currentFile.linkContext) {
145
+ // Check if this is a relative link (doesn't start with /, http, etc.)
146
+ if (!linkPath.startsWith('/') && !isExternalLink(linkPath)) {
147
+ // Check if the link points to a known directory structure
148
+ const knownPaths = ['modules/', 'classes/', 'interfaces/', 'enums/'];
149
+ const isKnownPath = knownPaths.some(p => linkPath.startsWith(p));
150
+
151
+ if (isKnownPath) {
152
+ // Strip .md extension from the link path
153
+ const cleanLinkPath = linkPath.replace(/\.md$/, '');
154
+
155
+ // Convert relative path to absolute path using the target base
156
+ const targetBase = generateSiteUrl(context.currentFile.linkContext.basePath, context.global.stripPrefixes);
157
+
158
+ // Construct final URL with proper Starlight formatting
159
+ let finalUrl = targetBase.replace(/\/$/, '') + '/' + cleanLinkPath;
160
+
161
+ // Add trailing slash if it doesn't end with one and isn't empty
162
+ if (finalUrl && !finalUrl.endsWith('/')) {
163
+ finalUrl += '/';
164
+ }
165
+
166
+ transformedPath = finalUrl;
167
+ return transformedPath + anchor;
168
+ }
169
+ }
170
+ }
171
+
172
+ let matched = false;
173
+ let replacement = '';
174
+
175
+ if (typeof mapping.pattern === 'string') {
176
+ // String pattern - exact match or contains
177
+ if (transformedPath.includes(mapping.pattern)) {
178
+ matched = true;
179
+ if (typeof mapping.replacement === 'string') {
180
+ replacement = transformedPath.replace(mapping.pattern, mapping.replacement);
181
+ } else {
182
+ replacement = mapping.replacement(transformedPath, anchor, context);
183
+ }
184
+ }
185
+ } else {
186
+ // RegExp pattern
187
+ const match = transformedPath.match(mapping.pattern);
188
+ if (match) {
189
+ matched = true;
190
+ if (typeof mapping.replacement === 'string') {
191
+ replacement = transformedPath.replace(mapping.pattern, mapping.replacement);
192
+ } else {
193
+ replacement = mapping.replacement(transformedPath, anchor, context);
194
+ }
195
+ }
196
+ }
197
+
198
+ if (matched) {
199
+ // Apply the transformation and continue with next mapping
200
+ transformedPath = replacement;
201
+ // Note: We continue applying other mappings to allow chaining
202
+ }
203
+ }
204
+
205
+ return transformedPath + anchor;
206
+ }
207
+
208
+ /**
209
+ * Convert a target path to a site-compatible URL
210
+ */
211
+ function generateSiteUrl(targetPath: string, stripPrefixes: string[]): string {
212
+ let url = targetPath;
213
+
214
+ // Strip configured prefixes
215
+ for (const prefix of stripPrefixes) {
216
+ if (url.startsWith(prefix)) {
217
+ url = url.slice(prefix.length);
218
+ break;
219
+ }
220
+ }
221
+
222
+ // Remove leading slash if present
223
+ url = url.replace(/^\//, '');
224
+
225
+ // Remove file extension
226
+ url = url.replace(/\.(md|mdx)$/i, '');
227
+
228
+ // Handle index files - they should resolve to parent directory
229
+ if (url.endsWith('/index')) {
230
+ url = url.replace('/index', '');
231
+ } else if (url === 'index') {
232
+ url = '';
233
+ }
234
+
235
+ // Split path into segments and slugify each
236
+ const segments = url.split('/').map(segment => segment ? slug(segment) : '');
237
+
238
+ // Reconstruct URL
239
+ url = segments.filter(s => s).join('/');
240
+
241
+ // Ensure leading slash
242
+ if (url && !url.startsWith('/')) {
243
+ url = '/' + url;
244
+ }
245
+
246
+ // Add trailing slash for non-empty paths
247
+ if (url && !url.endsWith('/')) {
248
+ url = url + '/';
249
+ }
250
+
251
+ return url || '/';
252
+ }
253
+
254
+ /**
255
+ * Transform a single markdown link
256
+ *
257
+ * Processing order:
258
+ * 1. Skip external links (no transformation)
259
+ * 2. Normalize path relative to current file
260
+ * 3. Apply global path mappings to normalized path
261
+ * 4. Check if link targets imported file in sourceToTargetMap
262
+ * 5. Apply non-global path mappings if unresolved
263
+ * 6. Check custom handlers
264
+ */
265
+ function transformLink(linkText: string, linkUrl: string, context: LinkContext): string {
266
+ // Skip external links FIRST - no transformations should ever be applied to them
267
+ if (isExternalLink(linkUrl)) {
268
+ return `[${linkText}](${linkUrl})`;
269
+ }
270
+
271
+ const { path: linkPath, anchor } = extractAnchor(linkUrl);
272
+
273
+ // Normalize the link path relative to current file FIRST
274
+ const normalizedPath = normalizePath(linkPath, context.currentFile.sourcePath, context.global.logger);
275
+
276
+ // Apply global path mappings to the normalized path
277
+ let processedNormalizedPath = normalizedPath;
278
+ if (context.global.linkMappings) {
279
+ const globalMappings = context.global.linkMappings.filter(m => m.global);
280
+ if (globalMappings.length > 0) {
281
+ processedNormalizedPath = applyLinkMappings(normalizedPath + anchor, globalMappings, context);
282
+ // Extract path again after global mappings
283
+ const { path: newPath } = extractAnchor(processedNormalizedPath);
284
+ processedNormalizedPath = newPath;
285
+ }
286
+ }
287
+
288
+ // Check if this links to an imported file
289
+ const targetPath = context.global.sourceToTargetMap.get(normalizedPath);
290
+
291
+ if (targetPath) {
292
+ // This is an internal link to an imported file
293
+ const siteUrl = generateSiteUrl(targetPath, context.global.stripPrefixes);
294
+ return `[${linkText}](${siteUrl}${anchor})`;
295
+ }
296
+
297
+ // Apply non-global path mappings to unresolved links
298
+ if (context.global.linkMappings) {
299
+ const nonGlobalMappings = context.global.linkMappings.filter(m => !m.global);
300
+ if (nonGlobalMappings.length > 0) {
301
+ const mappedUrl = applyLinkMappings(processedNormalizedPath + anchor, nonGlobalMappings, context);
302
+ if (mappedUrl !== (processedNormalizedPath + anchor)) {
303
+ return `[${linkText}](${mappedUrl})`;
304
+ }
305
+ }
306
+ }
307
+
308
+ // Check custom handlers
309
+ if (context.global.customHandlers) {
310
+ for (const handler of context.global.customHandlers) {
311
+ const currentUrl = processedNormalizedPath + anchor;
312
+ if (handler.test(currentUrl, context)) {
313
+ const transformedUrl = handler.transform(currentUrl, context);
314
+ return `[${linkText}](${transformedUrl})`;
315
+ }
316
+ }
317
+ }
318
+
319
+ // No transformation needed - return processed URL
320
+ return `[${linkText}](${processedNormalizedPath + anchor})`;
321
+ }
322
+
323
+ /**
324
+ * Global link transformation function
325
+ * Processes all imported files and resolves internal links
326
+ */
327
+ export function globalLinkTransform(
328
+ importedFiles: ImportedFile[],
329
+ options: {
330
+ stripPrefixes: string[];
331
+ customHandlers?: LinkHandler[];
332
+ linkMappings?: LinkMapping[];
333
+ logger?: Logger;
334
+ }
335
+ ): ImportedFile[] {
336
+ // Build global context
337
+ const sourceToTargetMap = new Map<string, string>();
338
+ const sourceToIdMap = new Map<string, string>();
339
+
340
+ for (const file of importedFiles) {
341
+ sourceToTargetMap.set(file.sourcePath, file.targetPath);
342
+ sourceToIdMap.set(file.sourcePath, file.id);
343
+ }
344
+
345
+ const globalContext: GlobalLinkContext = {
346
+ sourceToTargetMap,
347
+ sourceToIdMap,
348
+ stripPrefixes: options.stripPrefixes,
349
+ customHandlers: options.customHandlers,
350
+ linkMappings: options.linkMappings,
351
+ logger: options.logger,
352
+ };
353
+
354
+ // Transform links in all files
355
+ const markdownLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
356
+
357
+ return importedFiles.map(file => ({
358
+ ...file,
359
+ content: file.content.replace(markdownLinkRegex, (match, linkText, linkUrl) => {
360
+ const linkContext: LinkContext = {
361
+ currentFile: file,
362
+ originalLink: linkUrl,
363
+ anchor: extractAnchor(linkUrl).anchor,
364
+ global: globalContext,
365
+ };
366
+
367
+ return transformLink(linkText, linkUrl, linkContext);
368
+ }),
369
+ }));
370
+ }
371
+
372
+
373
+ /**
374
+ * Infer cross-section path from basePath
375
+ * @param basePath - The base path from include pattern (e.g., 'src/content/docs/reference/api')
376
+ * @returns Inferred cross-section path (e.g., '/reference/api')
377
+ */
378
+ function inferCrossSectionPath(basePath: string): string {
379
+ return basePath
380
+ .replace(/^src\/content\/docs/, '')
381
+ .replace(/\/$/, '') || '/';
382
+ }
383
+
384
+ /**
385
+ * Generate link mappings automatically from pathMappings in include patterns
386
+ * @param includes - Array of include patterns with pathMappings
387
+ * @param stripPrefixes - Prefixes to strip when generating URLs
388
+ * @returns Array of generated link mappings
389
+ */
390
+ export function generateAutoLinkMappings(
391
+ includes: IncludePattern[],
392
+ stripPrefixes: string[] = []
393
+ ): LinkMapping[] {
394
+ const linkMappings: LinkMapping[] = [];
395
+
396
+ for (const includePattern of includes) {
397
+ if (!includePattern.pathMappings) continue;
398
+
399
+ const inferredCrossSection = inferCrossSectionPath(includePattern.basePath);
400
+
401
+ for (const [sourcePath, mappingValue] of Object.entries(includePattern.pathMappings)) {
402
+ // Handle both string and enhanced object formats
403
+ const targetPath = typeof mappingValue === 'string' ? mappingValue : mappingValue.target;
404
+ const crossSectionPath = typeof mappingValue === 'object' && mappingValue.crossSectionPath
405
+ ? mappingValue.crossSectionPath
406
+ : inferredCrossSection;
407
+
408
+ if (sourcePath.endsWith('/')) {
409
+ // Folder mapping - use regex with capture group
410
+ const sourcePattern = sourcePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
411
+
412
+ linkMappings.push({
413
+ pattern: new RegExp(`^${sourcePattern}(.+)$`),
414
+ replacement: (transformedPath: string, anchor: string, context: any) => {
415
+ const relativePath = transformedPath.replace(new RegExp(`^${sourcePattern}`), '');
416
+ let finalPath: string;
417
+ if (crossSectionPath && crossSectionPath !== '/') {
418
+ finalPath = targetPath === ''
419
+ ? `${crossSectionPath}/${relativePath}`
420
+ : `${crossSectionPath}/${targetPath}${relativePath}`;
421
+ } else {
422
+ finalPath = targetPath === '' ? relativePath : `${targetPath}${relativePath}`;
423
+ }
424
+ return generateSiteUrl(finalPath, stripPrefixes);
425
+ },
426
+ global: true,
427
+ });
428
+ } else {
429
+ // File mapping - exact string match
430
+ const sourcePattern = sourcePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
431
+
432
+ linkMappings.push({
433
+ pattern: new RegExp(`^${sourcePattern}$`),
434
+ replacement: (transformedPath: string, anchor: string, context: any) => {
435
+ const finalPath = crossSectionPath && crossSectionPath !== '/'
436
+ ? `${crossSectionPath}/${targetPath}`
437
+ : targetPath;
438
+ return generateSiteUrl(finalPath, stripPrefixes);
439
+ },
440
+ global: true,
441
+ });
442
+ }
443
+ }
444
+ }
445
+
446
+ return linkMappings;
447
+ }
448
+
449
+ /**
450
+ * Export types for use in configuration
451
+ */
452
+ export type { LinkContext, GlobalLinkContext };
@@ -0,0 +1,106 @@
1
+ import { beforeEach, describe, it, expect } from "vitest";
2
+ import { githubLoader } from "./github.loader.js";
3
+ import { globalLinkTransform, type ImportedFile } from "./github.link-transform.js";
4
+ import { createLogger, type ImportSummary } from "./github.logger.js";
5
+ import { Octokit } from "octokit";
6
+
7
+ const FIXTURES = [
8
+ {
9
+ owner: "awesome-algorand",
10
+ repo: "algokit-cli",
11
+ ref: "docs/starlight-preview",
12
+ path: ".devportal/starlight",
13
+ },
14
+ ];
15
+ describe("githubLoader", () => {
16
+ let octokit: Octokit;
17
+ beforeEach(() => {
18
+ octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
19
+ });
20
+
21
+ it("should work", async () => {
22
+ const result = githubLoader({ octokit, configs: FIXTURES });
23
+
24
+ console.log(result);
25
+ });
26
+
27
+ describe("context-aware link transformations", () => {
28
+ it("should handle relative links from API files with contextFilter", () => {
29
+ const testFiles: ImportedFile[] = [
30
+ {
31
+ id: "api-readme",
32
+ sourcePath: "docs/code/README.md",
33
+ targetPath: "src/content/docs/reference/algokit-utils-ts/api/README.md",
34
+ content: 'Check out the [modules](modules/) for more info.',
35
+ linkContext: {
36
+ sourcePath: "docs/code/README.md",
37
+ targetPath: "src/content/docs/reference/algokit-utils-ts/api/README.md",
38
+ basePath: "src/content/docs/reference/algokit-utils-ts/api",
39
+ pathMappings: { "docs/code/": "" }
40
+ }
41
+ },
42
+ {
43
+ id: "modules-index",
44
+ sourcePath: "docs/code/modules/index.md",
45
+ targetPath: "src/content/docs/reference/algokit-utils-ts/api/modules/index.md",
46
+ content: 'This is the modules index.',
47
+ linkContext: {
48
+ sourcePath: "docs/code/modules/index.md",
49
+ targetPath: "src/content/docs/reference/algokit-utils-ts/api/modules/index.md",
50
+ basePath: "src/content/docs/reference/algokit-utils-ts/api",
51
+ pathMappings: { "docs/code/": "" }
52
+ }
53
+ }
54
+ ];
55
+
56
+ const result = globalLinkTransform(testFiles, {
57
+ stripPrefixes: ['src/content/docs'],
58
+ linkMappings: [
59
+ {
60
+ contextFilter: (context) => context.sourcePath.startsWith('docs/code/'),
61
+ relativeLinks: true,
62
+ pattern: /.*/,
63
+ replacement: '',
64
+ global: false
65
+ }
66
+ ]
67
+ });
68
+
69
+ // The relative link `modules/` should be transformed to `/reference/algokit-utils-ts/api/modules/`
70
+ expect(result[0].content).toContain('[modules](/reference/algokit-utils-ts/api/modules/)');
71
+ });
72
+ });
73
+
74
+ describe("logging system", () => {
75
+ it("should create logger with different levels", () => {
76
+ const silentLogger = createLogger('silent');
77
+ const defaultLogger = createLogger('default');
78
+ const verboseLogger = createLogger('verbose');
79
+ const debugLogger = createLogger('debug');
80
+
81
+ expect(silentLogger.getLevel()).toBe('silent');
82
+ expect(defaultLogger.getLevel()).toBe('default');
83
+ expect(verboseLogger.getLevel()).toBe('verbose');
84
+ expect(debugLogger.getLevel()).toBe('debug');
85
+ });
86
+
87
+ it("should format import summary correctly", () => {
88
+ const logger = createLogger('default');
89
+ const summary: ImportSummary = {
90
+ configName: 'Test Config',
91
+ repository: 'test/repo',
92
+ ref: 'main',
93
+ filesProcessed: 10,
94
+ filesUpdated: 5,
95
+ filesUnchanged: 5,
96
+ assetsDownloaded: 3,
97
+ assetsCached: 2,
98
+ duration: 1500,
99
+ status: 'success'
100
+ };
101
+
102
+ // This test mainly verifies the types work correctly
103
+ expect(() => logger.logImportSummary(summary)).not.toThrow();
104
+ });
105
+ });
106
+ });