@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,189 @@
1
+ import { toCollectionEntry } from "./github.content.js";
2
+ import { performSelectiveCleanup } from "./github.cleanup.js";
3
+ import { performDryRun, displayDryRunResults, updateImportState } from "./github.dryrun.js";
4
+ import { createLogger, type Logger, type ImportSummary } from "./github.logger.js";
5
+
6
+ import type {
7
+ Loader,
8
+ GithubLoaderOptions,
9
+ ImportOptions,
10
+ SyncStats,
11
+ } from "./github.types.js";
12
+
13
+ /**
14
+ * Performs selective cleanup for configurations with basePath
15
+ * @param configs - Array of configuration objects
16
+ * @param context - Loader context
17
+ * @param octokit - GitHub API client
18
+ * @internal
19
+ */
20
+ async function performSelectiveCleanups(
21
+ configs: ImportOptions[],
22
+ context: any,
23
+ octokit: any
24
+ ): Promise<SyncStats[]> {
25
+ const results: SyncStats[] = [];
26
+
27
+ // Process each config sequentially to avoid overwhelming Astro's file watcher
28
+ for (const config of configs) {
29
+ if (config.enabled === false) {
30
+ context.logger.debug(`Skipping disabled config: ${config.name || `${config.owner}/${config.repo}`}`);
31
+ continue;
32
+ }
33
+
34
+ try {
35
+ const stats = await performSelectiveCleanup(config, context, octokit);
36
+ results.push(stats);
37
+ } catch (error: any) {
38
+ context.logger.error(`Selective cleanup failed for ${config.name || `${config.owner}/${config.repo}`}: ${error}`);
39
+ // Continue with other configs even if one fails
40
+ }
41
+ }
42
+
43
+ return results;
44
+ }
45
+
46
+ /**
47
+ * Loads data from GitHub repositories based on the provided configurations and options.
48
+ *
49
+ * Features:
50
+ * - Sequential processing with spinner feedback for long-running operations
51
+ * - Dry run mode for change detection without actual imports
52
+ * - Configurable logging levels per configuration
53
+ * - Import state tracking for incremental updates
54
+ * - Content store management with optional clearing
55
+ *
56
+ * @return A loader object responsible for managing the data loading process.
57
+ */
58
+ export function githubLoader({
59
+ octokit,
60
+ configs,
61
+ fetchOptions = {},
62
+ clear = false,
63
+ dryRun = false,
64
+ logLevel,
65
+ force = false,
66
+ }: GithubLoaderOptions): Loader {
67
+ return {
68
+ name: "github-loader",
69
+ load: async (context) => {
70
+ const { store } = context;
71
+
72
+ // Create global logger with specified level or default
73
+ const globalLogger = createLogger(logLevel || 'default');
74
+
75
+ if (dryRun) {
76
+ globalLogger.info("šŸ” Dry run mode enabled - checking for changes only");
77
+
78
+ try {
79
+ const results = await performDryRun(configs, context, octokit);
80
+ displayDryRunResults(results, context.logger);
81
+
82
+ globalLogger.info("\n🚫 Dry run complete - no imports performed");
83
+ globalLogger.info("šŸ’” Set dryRun: false to perform actual imports");
84
+
85
+ return; // Exit without importing
86
+ } catch (error: any) {
87
+ globalLogger.error(`Dry run failed: ${error.message}`);
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ globalLogger.debug(`Loading data from ${configs.length} sources`);
93
+
94
+ // Always use standard processing - no file deletions to avoid Astro issues
95
+ globalLogger.info(clear ? "Processing with content store clear" : "Processing without content store clear");
96
+
97
+ if (clear) {
98
+ store.clear();
99
+ }
100
+
101
+ // Process each config sequentially to avoid overwhelming GitHub API/CDN
102
+ for (let i = 0; i < configs.length; i++) {
103
+ const config = configs[i];
104
+
105
+ if (config.enabled === false) {
106
+ globalLogger.debug(`Skipping disabled config: ${config.name || `${config.owner}/${config.repo}`}`);
107
+ continue;
108
+ }
109
+
110
+ // Add small delay between configs to be gentler on GitHub's CDN
111
+ if (i > 0) {
112
+ await new Promise(resolve => setTimeout(resolve, 1000));
113
+ }
114
+
115
+ // Determine the effective log level for this config
116
+ const effectiveLogLevel = logLevel || config.logLevel || 'default';
117
+ const configLogger = createLogger(effectiveLogLevel);
118
+
119
+ const configName = config.name || `${config.owner}/${config.repo}`;
120
+ const repository = `${config.owner}/${config.repo}`;
121
+
122
+ let summary: ImportSummary = {
123
+ configName,
124
+ repository,
125
+ ref: config.ref,
126
+ filesProcessed: 0,
127
+ filesUpdated: 0,
128
+ filesUnchanged: 0,
129
+ duration: 0,
130
+ status: 'error',
131
+ };
132
+
133
+ const startTime = Date.now();
134
+
135
+ try {
136
+ // Perform the import with spinner
137
+ const stats = await globalLogger.withSpinner(
138
+ `šŸ”„ Importing ${configName}...`,
139
+ () => toCollectionEntry({
140
+ context: { ...context, logger: configLogger as any },
141
+ octokit,
142
+ options: config,
143
+ fetchOptions,
144
+ force,
145
+ }),
146
+ `āœ… ${configName} imported successfully`,
147
+ `āŒ ${configName} import failed`
148
+ );
149
+
150
+ summary.duration = Date.now() - startTime;
151
+ summary.filesProcessed = stats?.processed || 0;
152
+ summary.filesUpdated = stats?.updated || 0;
153
+ summary.filesUnchanged = stats?.unchanged || 0;
154
+ summary.assetsDownloaded = stats?.assetsDownloaded || 0;
155
+ summary.assetsCached = stats?.assetsCached || 0;
156
+ summary.status = 'success';
157
+
158
+ // Log structured summary
159
+ configLogger.logImportSummary(summary);
160
+
161
+ // Update state tracking for future dry runs
162
+ try {
163
+ // Get the latest commit info to track state
164
+ const { data } = await octokit.rest.repos.listCommits({
165
+ owner: config.owner,
166
+ repo: config.repo,
167
+ sha: config.ref || 'main',
168
+ per_page: 1
169
+ });
170
+
171
+ if (data.length > 0) {
172
+ await updateImportState(process.cwd(), config, data[0].sha);
173
+ }
174
+ } catch (error) {
175
+ // Don't fail the import if state tracking fails
176
+ configLogger.debug(`Failed to update import state for ${configName}: ${error}`);
177
+ }
178
+ } catch (error: any) {
179
+ summary.duration = Date.now() - startTime;
180
+ summary.status = 'error';
181
+ summary.error = error.message;
182
+
183
+ configLogger.logImportSummary(summary);
184
+ // Continue with other configs even if one fails
185
+ }
186
+ }
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Multi-level logging system for astro-github-loader
3
+ */
4
+
5
+ export type LogLevel = 'silent' | 'default' | 'verbose' | 'debug';
6
+
7
+ export interface LoggerOptions {
8
+ level: LogLevel;
9
+ prefix?: string;
10
+ }
11
+
12
+ export interface ImportSummary {
13
+ configName: string;
14
+ repository: string;
15
+ ref?: string;
16
+ filesProcessed: number;
17
+ filesUpdated: number;
18
+ filesUnchanged: number;
19
+ assetsDownloaded?: number;
20
+ assetsCached?: number;
21
+ duration: number;
22
+ status: 'success' | 'error' | 'cancelled';
23
+ error?: string;
24
+ }
25
+
26
+ export interface SyncSummary {
27
+ added: number;
28
+ updated: number;
29
+ deleted: number;
30
+ unchanged: number;
31
+ duration: number;
32
+ }
33
+
34
+ export interface CleanupSummary {
35
+ deleted: number;
36
+ duration: number;
37
+ }
38
+
39
+ /**
40
+ * Centralized logger with configurable verbosity levels and spinner support for long-running operations
41
+ */
42
+ export class Logger {
43
+ private level: LogLevel;
44
+ private prefix: string;
45
+ private spinnerInterval?: NodeJS.Timeout;
46
+ private spinnerChars = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā '];
47
+ private spinnerIndex = 0;
48
+ private spinnerStartTime?: number;
49
+
50
+ constructor(options: LoggerOptions) {
51
+ this.level = options.level;
52
+ this.prefix = options.prefix || '';
53
+ }
54
+
55
+ /**
56
+ * Set the logging level
57
+ */
58
+ setLevel(level: LogLevel): void {
59
+ this.level = level;
60
+ }
61
+
62
+ /**
63
+ * Get the current logging level
64
+ */
65
+ getLevel(): LogLevel {
66
+ return this.level;
67
+ }
68
+
69
+ /**
70
+ * Check if a specific level should be logged
71
+ */
72
+ private shouldLog(level: LogLevel): boolean {
73
+ const levels: Record<LogLevel, number> = {
74
+ silent: 0,
75
+ default: 1,
76
+ verbose: 2,
77
+ debug: 3,
78
+ };
79
+
80
+ return levels[this.level] >= levels[level];
81
+ }
82
+
83
+ /**
84
+ * Format message with prefix
85
+ */
86
+ private formatMessage(message: string): string {
87
+ return this.prefix ? `${this.prefix} ${message}` : message;
88
+ }
89
+
90
+ /**
91
+ * Silent level - no output
92
+ */
93
+ silent(): void {
94
+ // Intentionally empty
95
+ }
96
+
97
+ /**
98
+ * Default level - summary information only
99
+ */
100
+ info(message: string): void {
101
+ if (this.shouldLog('default')) {
102
+ console.log(this.formatMessage(message));
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Verbose level - detailed operation information
108
+ */
109
+ verbose(message: string): void {
110
+ if (this.shouldLog('verbose')) {
111
+ console.log(this.formatMessage(message));
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Debug level - all information including diagnostics
117
+ */
118
+ debug(message: string): void {
119
+ if (this.shouldLog('debug')) {
120
+ console.log(this.formatMessage(message));
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Error - always shown unless silent
126
+ */
127
+ error(message: string): void {
128
+ if (this.shouldLog('default')) {
129
+ console.error(this.formatMessage(message));
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Warning - shown at default level and above
135
+ */
136
+ warn(message: string): void {
137
+ if (this.shouldLog('default')) {
138
+ console.warn(this.formatMessage(message));
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Log structured import summary (default level)
144
+ */
145
+ logImportSummary(summary: ImportSummary): void {
146
+ if (!this.shouldLog('default')) return;
147
+
148
+ const statusIcon = summary.status === 'success' ? 'āœ…' : summary.status === 'error' ? 'āŒ' : '🚫';
149
+
150
+ this.info('');
151
+ this.info(`šŸ“Š Import Summary: ${summary.configName}`);
152
+ this.info(`ā”œā”€ Repository: ${summary.repository}${summary.ref ? `@${summary.ref}` : ''}`);
153
+ this.info(`ā”œā”€ Files: ${summary.filesProcessed} processed, ${summary.filesUpdated} updated, ${summary.filesUnchanged} unchanged`);
154
+
155
+ if (summary.assetsDownloaded !== undefined || summary.assetsCached !== undefined) {
156
+ const downloaded = summary.assetsDownloaded || 0;
157
+ const cached = summary.assetsCached || 0;
158
+ this.info(`ā”œā”€ Assets: ${downloaded} downloaded, ${cached} cached`);
159
+ }
160
+
161
+ this.info(`ā”œā”€ Duration: ${(summary.duration / 1000).toFixed(1)}s`);
162
+ this.info(`└─ Status: ${statusIcon} ${summary.status === 'success' ? 'Success' : summary.status === 'error' ? `Error: ${summary.error}` : 'Cancelled'}`);
163
+ this.info('');
164
+ }
165
+
166
+ /**
167
+ * Log sync operation summary (default level)
168
+ */
169
+ logSyncSummary(configName: string, summary: SyncSummary): void {
170
+ if (!this.shouldLog('default')) return;
171
+
172
+ if (summary.added > 0 || summary.updated > 0 || summary.deleted > 0) {
173
+ this.info(`Sync completed for ${configName}: ${summary.added} added, ${summary.updated} updated, ${summary.deleted} deleted (${summary.duration}ms)`);
174
+ } else {
175
+ this.info(`No changes needed for ${configName} (${summary.duration}ms)`);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Log cleanup operation summary (default level)
181
+ */
182
+ logCleanupSummary(configName: string, summary: CleanupSummary): void {
183
+ if (!this.shouldLog('default')) return;
184
+
185
+ if (summary.deleted > 0) {
186
+ this.info(`Cleanup completed for ${configName}: ${summary.deleted} obsolete files deleted (${summary.duration}ms)`);
187
+ } else {
188
+ this.debug(`No cleanup needed for ${configName} (${summary.duration}ms)`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Log file-level processing (verbose level)
194
+ */
195
+ logFileProcessing(action: string, filePath: string, details?: string): void {
196
+ const message = details ? `${action}: ${filePath} - ${details}` : `${action}: ${filePath}`;
197
+ this.verbose(message);
198
+ }
199
+
200
+ /**
201
+ * Log asset processing (verbose level)
202
+ */
203
+ logAssetProcessing(action: string, assetPath: string, details?: string): void {
204
+ const message = details ? `Asset ${action}: ${assetPath} - ${details}` : `Asset ${action}: ${assetPath}`;
205
+ this.verbose(message);
206
+ }
207
+
208
+ /**
209
+ * Create a child logger with additional prefix
210
+ */
211
+ child(prefix: string): Logger {
212
+ return new Logger({
213
+ level: this.level,
214
+ prefix: this.prefix ? `${this.prefix}${prefix}` : prefix,
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Time a function execution and log the result
220
+ */
221
+ async time<T>(label: string, fn: () => Promise<T>): Promise<T> {
222
+ const startTime = Date.now();
223
+ this.debug(`ā±ļø Starting: ${label}`);
224
+
225
+ try {
226
+ const result = await fn();
227
+ const duration = Date.now() - startTime;
228
+ this.verbose(`āœ… Completed: ${label} (${duration}ms)`);
229
+ return result;
230
+ } catch (error) {
231
+ const duration = Date.now() - startTime;
232
+ this.error(`āŒ Failed: ${label} (${duration}ms): ${error}`);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Format duration in human-readable format
239
+ */
240
+ private formatDuration(seconds: number): string {
241
+ if (seconds < 60) {
242
+ return `${seconds}s`;
243
+ } else if (seconds < 3600) {
244
+ const mins = Math.floor(seconds / 60);
245
+ const secs = seconds % 60;
246
+ return `${mins}m ${secs}s`;
247
+ } else {
248
+ const hours = Math.floor(seconds / 3600);
249
+ const mins = Math.floor((seconds % 3600) / 60);
250
+ return `${hours}h ${mins}m`;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Start a spinner with duration timer for long-running operations
256
+ */
257
+ startSpinner(message: string = 'Processing...'): void {
258
+ if (this.level === 'silent') return;
259
+
260
+ this.spinnerStartTime = Date.now();
261
+ this.spinnerIndex = 0;
262
+
263
+ const updateSpinner = () => {
264
+ const elapsed = Math.floor((Date.now() - this.spinnerStartTime!) / 1000);
265
+ const spinner = this.spinnerChars[this.spinnerIndex];
266
+ const duration = this.formatDuration(elapsed);
267
+ const formattedMessage = this.formatMessage(`${message} ${spinner} (${duration})`);
268
+ process.stdout.write(`\r${formattedMessage}`);
269
+ this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length;
270
+ };
271
+
272
+ // Initial display
273
+ updateSpinner();
274
+
275
+ // Update every 100ms
276
+ this.spinnerInterval = setInterval(updateSpinner, 100);
277
+ }
278
+
279
+ /**
280
+ * Stop the spinner and optionally show a final message
281
+ */
282
+ stopSpinner(finalMessage?: string): void {
283
+ if (this.spinnerInterval) {
284
+ clearInterval(this.spinnerInterval);
285
+ this.spinnerInterval = undefined;
286
+ }
287
+
288
+ if (finalMessage && this.spinnerStartTime) {
289
+ const totalTime = Math.floor((Date.now() - this.spinnerStartTime) / 1000);
290
+ const duration = this.formatDuration(totalTime);
291
+ const formattedMessage = this.formatMessage(`${finalMessage} (${duration})`);
292
+ process.stdout.write(`\r${formattedMessage}\n`);
293
+ } else if (finalMessage) {
294
+ const formattedMessage = this.formatMessage(finalMessage);
295
+ process.stdout.write(`\r${formattedMessage}\n`);
296
+ } else {
297
+ process.stdout.write('\r\x1b[K'); // Clear the line
298
+ }
299
+
300
+ this.spinnerStartTime = undefined;
301
+ }
302
+
303
+ /**
304
+ * Execute a function with spinner feedback
305
+ */
306
+ async withSpinner<T>(message: string, fn: () => Promise<T>, successMessage?: string, errorMessage?: string): Promise<T> {
307
+ this.startSpinner(message);
308
+ try {
309
+ const result = await fn();
310
+ this.stopSpinner(successMessage || `āœ… ${message.replace(/^[šŸ”„ā³]?\s*/, '')} completed`);
311
+ return result;
312
+ } catch (error) {
313
+ this.stopSpinner(errorMessage || `āŒ ${message.replace(/^[šŸ”„ā³]?\s*/, '')} failed`);
314
+ throw error;
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Create a logger instance with the specified level
321
+ */
322
+ export function createLogger(level: LogLevel = 'default', prefix?: string): Logger {
323
+ return new Logger({ level, prefix });
324
+ }