@larkiny/astro-github-loader 0.11.2 → 0.11.3

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/README.md CHANGED
@@ -584,17 +584,52 @@ const REMOTE_CONTENT_WITH_ASSETS: ImportOptions[] = [
584
584
 
585
585
  ## File Management Strategy
586
586
 
587
- > **⚠️ Important: Do not use `clear: true`**
588
- >
589
- > The `clear: true` option should not be used with the current implementation due to how Astro content collection syncing works. Mass file deletions can cause Astro to invalidate entire content collections, leading to 404 errors and build instability.
590
- >
591
- > **Instead**: If you need to handle file deletions, renames, or path restructuring from the source repository:
592
- >
593
- > 1. Manually delete the local import folders (e.g., `src/content/docs/imported`)
594
- > 2. Re-run the import process
595
- > 3. Fresh content will be imported with the new structure
596
- >
597
- > This approach ensures your site remains stable while handling structural changes.
587
+ The `clear` option enables selective replacement of content collection entries during import. When enabled, existing entries are atomically replaced (deleted then re-added) one at a time, preserving content collection stability.
588
+
589
+ ### Using the Clear Option
590
+
591
+ ```typescript
592
+ // Per-config clear (recommended)
593
+ const REMOTE_CONTENT: ImportOptions[] = [
594
+ {
595
+ name: "Docs that need clearing",
596
+ owner: "your-org",
597
+ repo: "docs-repo",
598
+ clear: true, // Enable clearing for this config only
599
+ includes: [
600
+ { pattern: "docs/**/*.md", basePath: "src/content/docs/imported" }
601
+ ],
602
+ },
603
+ {
604
+ name: "Docs that don't need clearing",
605
+ owner: "your-org",
606
+ repo: "other-docs",
607
+ clear: false, // Explicitly disable (or omit for default behavior)
608
+ includes: [
609
+ { pattern: "guides/**/*.md", basePath: "src/content/docs/guides" }
610
+ ],
611
+ },
612
+ ];
613
+
614
+ // Or use global clear with per-config override
615
+ await githubLoader({
616
+ octokit,
617
+ configs: REMOTE_CONTENT,
618
+ clear: true, // Global default - can be overridden per-config
619
+ }).load(context);
620
+ ```
621
+
622
+ ### When to Use Clear
623
+
624
+ - **Use `clear: true`** when you need to ensure stale entries are removed (e.g., files renamed or deleted in the source repo)
625
+ - **Use `clear: false`** (default) for incremental updates where you want to preserve existing entries
626
+
627
+ ### How It Works
628
+
629
+ Unlike a bulk clear operation, the loader uses a selective delete-before-set approach:
630
+ 1. For each file being imported, if an entry already exists, it's deleted immediately before the new entry is added
631
+ 2. This atomic replacement ensures the content collection is never empty
632
+ 3. Astro's content collection system handles individual deletions gracefully
598
633
 
599
634
  ## Change Detection & Dry-Run Mode
600
635
 
@@ -111,7 +111,7 @@ export declare function syncEntry(context: LoaderContext, { url, editUrl }: {
111
111
  * Handles both files and directories, recursively processing directories if needed.
112
112
  * @internal
113
113
  */
114
- export declare function toCollectionEntry({ context, octokit, options, signal, force, }: CollectionEntryOptions): Promise<ImportStats>;
114
+ export declare function toCollectionEntry({ context, octokit, options, signal, force, clear, }: CollectionEntryOptions): Promise<ImportStats>;
115
115
  /**
116
116
  * Get the headers needed to make a conditional request.
117
117
  * Uses the etag and last-modified values from the meta store.
@@ -549,7 +549,7 @@ export async function syncEntry(context, { url, editUrl }, filePath, options, oc
549
549
  * Handles both files and directories, recursively processing directories if needed.
550
550
  * @internal
551
551
  */
552
- export async function toCollectionEntry({ context, octokit, options, signal, force = false, }) {
552
+ export async function toCollectionEntry({ context, octokit, options, signal, force = false, clear = false, }) {
553
553
  const { owner, repo, ref = "main" } = options || {};
554
554
  if (typeof repo !== "string" || typeof owner !== "string")
555
555
  throw new TypeError(INVALID_STRING_ERROR);
@@ -654,7 +654,7 @@ export async function toCollectionEntry({ context, octokit, options, signal, for
654
654
  stats.processed = processedFiles.length;
655
655
  for (const file of processedFiles) {
656
656
  logger.logFileProcessing("Storing", file.sourcePath);
657
- const result = await storeProcessedFile(file, context, options);
657
+ const result = await storeProcessedFile(file, context, clear);
658
658
  if (result) {
659
659
  stats.updated++;
660
660
  }
@@ -796,7 +796,7 @@ export async function toCollectionEntry({ context, octokit, options, signal, for
796
796
  };
797
797
  }
798
798
  // Helper function to store a processed file
799
- async function storeProcessedFile(file, context, options) {
799
+ async function storeProcessedFile(file, context, clear) {
800
800
  const { store, generateDigest, entryTypes, logger, parseData, config } = context;
801
801
  function configForFile(filePath) {
802
802
  const ext = filePath.split(".").at(-1);
@@ -833,6 +833,13 @@ export async function toCollectionEntry({ context, octokit, options, signal, for
833
833
  data,
834
834
  filePath: fileUrl.toString(),
835
835
  });
836
+ // When clear mode is enabled, delete the existing entry before setting the new one.
837
+ // This provides atomic replacement without breaking Astro's content collection,
838
+ // as opposed to calling store.clear() which empties everything at once.
839
+ if (clear && existingEntry) {
840
+ logger.debug(`🗑️ Clearing existing entry before replacement: ${file.id}`);
841
+ store.delete(file.id);
842
+ }
836
843
  // Store in content store
837
844
  if (entryType.getRenderFunction) {
838
845
  logger.verbose(`Rendering ${file.id}`);
@@ -44,7 +44,6 @@ export function githubLoader({ octokit, configs, fetchOptions = {}, clear = fals
44
44
  return {
45
45
  name: "github-loader",
46
46
  load: async (context) => {
47
- const { store } = context;
48
47
  // Create global logger with specified level or default
49
48
  const globalLogger = createLogger(logLevel || 'default');
50
49
  if (dryRun) {
@@ -62,11 +61,9 @@ export function githubLoader({ octokit, configs, fetchOptions = {}, clear = fals
62
61
  }
63
62
  }
64
63
  globalLogger.debug(`Loading data from ${configs.length} sources`);
65
- // Always use standard processing - no file deletions to avoid Astro issues
66
- globalLogger.info(clear ? "Processing with content store clear" : "Processing without content store clear");
67
- if (clear) {
68
- store.clear();
69
- }
64
+ // Log clear mode status - actual clearing happens per-entry in toCollectionEntry
65
+ // to avoid breaking Astro's content collection by emptying the store all at once
66
+ globalLogger.info(clear ? "Processing with selective entry replacement" : "Processing without entry replacement");
70
67
  // Process each config sequentially to avoid overwhelming GitHub API/CDN
71
68
  for (let i = 0; i < configs.length; i++) {
72
69
  const config = configs[i];
@@ -131,6 +128,18 @@ export function githubLoader({ octokit, configs, fetchOptions = {}, clear = fals
131
128
  else {
132
129
  configLogger.info(`🔄 Force mode enabled for ${configName} - proceeding with full import`);
133
130
  }
131
+ // Determine effective clear setting: per-config takes precedence over global
132
+ const effectiveClear = config.clear ?? clear;
133
+ // Perform selective cleanup before importing if clear is enabled
134
+ if (effectiveClear) {
135
+ configLogger.info(`🧹 Clearing obsolete files for ${configName}...`);
136
+ try {
137
+ await performSelectiveCleanup(config, { ...context, logger: configLogger }, octokit);
138
+ }
139
+ catch (error) {
140
+ configLogger.warn(`Cleanup failed for ${configName}, continuing with import: ${error}`);
141
+ }
142
+ }
134
143
  // Perform the import with spinner
135
144
  const stats = await globalLogger.withSpinner(`🔄 Importing ${configName}...`, () => toCollectionEntry({
136
145
  context: { ...context, logger: configLogger },
@@ -138,6 +147,7 @@ export function githubLoader({ octokit, configs, fetchOptions = {}, clear = fals
138
147
  options: config,
139
148
  fetchOptions,
140
149
  force,
150
+ clear: effectiveClear,
141
151
  }), `✅ ${configName} imported successfully`, `❌ ${configName} import failed`);
142
152
  summary.duration = Date.now() - startTime;
143
153
  summary.filesProcessed = stats?.processed || 0;
@@ -199,6 +199,13 @@ export type CollectionEntryOptions = {
199
199
  * @default false
200
200
  */
201
201
  force?: boolean;
202
+ /**
203
+ * When true, deletes existing store entries before setting new ones.
204
+ * This enables atomic replacement of entries without breaking the content collection.
205
+ * Passed from GithubLoaderOptions.clear
206
+ * @internal
207
+ */
208
+ clear?: boolean;
202
209
  };
203
210
  /**
204
211
  * Interface representing rendered content, including HTML and associated metadata.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@larkiny/astro-github-loader",
3
3
  "type": "module",
4
- "version": "0.11.2",
4
+ "version": "0.11.3",
5
5
  "description": "Load content from GitHub repositories into Astro content collections with asset management and content transformations",
6
6
  "keywords": [
7
7
  "astro",
@@ -654,6 +654,7 @@ export async function toCollectionEntry({
654
654
  options,
655
655
  signal,
656
656
  force = false,
657
+ clear = false,
657
658
  }: CollectionEntryOptions): Promise<ImportStats> {
658
659
  const { owner, repo, ref = "main" } = options || {};
659
660
  if (typeof repo !== "string" || typeof owner !== "string")
@@ -782,7 +783,7 @@ export async function toCollectionEntry({
782
783
  stats.processed = processedFiles.length;
783
784
  for (const file of processedFiles) {
784
785
  logger.logFileProcessing("Storing", file.sourcePath);
785
- const result = await storeProcessedFile(file, context, options);
786
+ const result = await storeProcessedFile(file, context, clear);
786
787
  if (result) {
787
788
  stats.updated++;
788
789
  } else {
@@ -944,7 +945,7 @@ export async function toCollectionEntry({
944
945
  async function storeProcessedFile(
945
946
  file: ImportedFile,
946
947
  context: any,
947
- options: ImportOptions
948
+ clear: boolean
948
949
  ): Promise<any> {
949
950
  const { store, generateDigest, entryTypes, logger, parseData, config } = context;
950
951
 
@@ -988,6 +989,14 @@ export async function toCollectionEntry({
988
989
  filePath: fileUrl.toString(),
989
990
  });
990
991
 
992
+ // When clear mode is enabled, delete the existing entry before setting the new one.
993
+ // This provides atomic replacement without breaking Astro's content collection,
994
+ // as opposed to calling store.clear() which empties everything at once.
995
+ if (clear && existingEntry) {
996
+ logger.debug(`🗑️ Clearing existing entry before replacement: ${file.id}`);
997
+ store.delete(file.id);
998
+ }
999
+
991
1000
  // Store in content store
992
1001
  if (entryType.getRenderFunction) {
993
1002
  logger.verbose(`Rendering ${file.id}`);
@@ -67,7 +67,6 @@ export function githubLoader({
67
67
  return {
68
68
  name: "github-loader",
69
69
  load: async (context) => {
70
- const { store } = context;
71
70
 
72
71
  // Create global logger with specified level or default
73
72
  const globalLogger = createLogger(logLevel || 'default');
@@ -91,12 +90,9 @@ export function githubLoader({
91
90
 
92
91
  globalLogger.debug(`Loading data from ${configs.length} sources`);
93
92
 
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
- }
93
+ // Log clear mode status - actual clearing happens per-entry in toCollectionEntry
94
+ // to avoid breaking Astro's content collection by emptying the store all at once
95
+ globalLogger.info(clear ? "Processing with selective entry replacement" : "Processing without entry replacement");
100
96
 
101
97
  // Process each config sequentially to avoid overwhelming GitHub API/CDN
102
98
  for (let i = 0; i < configs.length; i++) {
@@ -171,6 +167,19 @@ export function githubLoader({
171
167
  configLogger.info(`🔄 Force mode enabled for ${configName} - proceeding with full import`);
172
168
  }
173
169
 
170
+ // Determine effective clear setting: per-config takes precedence over global
171
+ const effectiveClear = config.clear ?? clear;
172
+
173
+ // Perform selective cleanup before importing if clear is enabled
174
+ if (effectiveClear) {
175
+ configLogger.info(`🧹 Clearing obsolete files for ${configName}...`);
176
+ try {
177
+ await performSelectiveCleanup(config, { ...context, logger: configLogger as any }, octokit);
178
+ } catch (error) {
179
+ configLogger.warn(`Cleanup failed for ${configName}, continuing with import: ${error}`);
180
+ }
181
+ }
182
+
174
183
  // Perform the import with spinner
175
184
  const stats = await globalLogger.withSpinner(
176
185
  `🔄 Importing ${configName}...`,
@@ -180,6 +189,7 @@ export function githubLoader({
180
189
  options: config,
181
190
  fetchOptions,
182
191
  force,
192
+ clear: effectiveClear,
183
193
  }),
184
194
  `✅ ${configName} imported successfully`,
185
195
  `❌ ${configName} import failed`
@@ -216,6 +216,13 @@ export type CollectionEntryOptions = {
216
216
  * @default false
217
217
  */
218
218
  force?: boolean;
219
+ /**
220
+ * When true, deletes existing store entries before setting new ones.
221
+ * This enables atomic replacement of entries without breaking the content collection.
222
+ * Passed from GithubLoaderOptions.clear
223
+ * @internal
224
+ */
225
+ clear?: boolean;
219
226
  };
220
227
 
221
228
  /**