@larkiny/astro-github-loader 0.11.1 → 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}`);
@@ -35,17 +35,15 @@ function isExternalLink(link) {
35
35
  */
36
36
  function normalizePath(linkPath, currentFilePath, logger) {
37
37
  logger?.debug(`[normalizePath] BEFORE: linkPath="${linkPath}", currentFilePath="${currentFilePath}"`);
38
- // Handle relative paths
39
- if (linkPath.startsWith('./') || linkPath.includes('../')) {
38
+ // Handle relative paths (including simple relative paths without ./ prefix)
39
+ // A link is relative if it doesn't start with / or contain a protocol
40
+ const isAbsoluteOrExternal = linkPath.startsWith('/') || linkPath.includes('://') || linkPath.startsWith('#');
41
+ if (!isAbsoluteOrExternal) {
40
42
  const currentDir = path.dirname(currentFilePath);
41
43
  const resolved = path.posix.normalize(path.posix.join(currentDir, linkPath));
42
44
  logger?.debug(`[normalizePath] RELATIVE PATH RESOLVED: "${linkPath}" -> "${resolved}" (currentDir: "${currentDir}")`);
43
45
  return resolved;
44
46
  }
45
- // Remove leading './'
46
- if (linkPath.startsWith('./')) {
47
- return linkPath.slice(2);
48
- }
49
47
  logger?.debug(`[normalizePath] AFTER: "${linkPath}" (no changes)`);
50
48
  return linkPath;
51
49
  }
@@ -188,7 +186,11 @@ function transformLink(linkText, linkUrl, context) {
188
186
  }
189
187
  }
190
188
  // Check if this links to an imported file
191
- const targetPath = context.global.sourceToTargetMap.get(normalizedPath);
189
+ let targetPath = context.global.sourceToTargetMap.get(normalizedPath);
190
+ // If not found and path ends with /, try looking for index.md
191
+ if (!targetPath && normalizedPath.endsWith('/')) {
192
+ targetPath = context.global.sourceToTargetMap.get(normalizedPath + 'index.md');
193
+ }
192
194
  if (targetPath) {
193
195
  // This is an internal link to an imported file
194
196
  const siteUrl = generateSiteUrl(targetPath, context.global.stripPrefixes);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { globalLinkTransform } from './github.link-transform.js';
3
+ describe('globalLinkTransform', () => {
4
+ describe('relative link resolution', () => {
5
+ it('should resolve simple relative links without ./ prefix', () => {
6
+ const files = [
7
+ {
8
+ sourcePath: 'docs/front-end-guide/index.md',
9
+ targetPath: 'src/content/docs/reference/api/front-end-guide/overview.md',
10
+ content: '[Designing a language](02-designing-a-language.md)',
11
+ id: 'file1',
12
+ },
13
+ {
14
+ sourcePath: 'docs/front-end-guide/02-designing-a-language.md',
15
+ targetPath: 'src/content/docs/reference/api/front-end-guide/02-designing-a-language.md',
16
+ content: '# Designing a language',
17
+ id: 'file2',
18
+ },
19
+ ];
20
+ const result = globalLinkTransform(files, {
21
+ stripPrefixes: ['src/content/docs'],
22
+ linkMappings: [],
23
+ });
24
+ expect(result[0].content).toBe('[Designing a language](/reference/api/front-end-guide/02-designing-a-language/)');
25
+ });
26
+ it('should resolve relative links with ./ prefix', () => {
27
+ const files = [
28
+ {
29
+ sourcePath: 'docs/front-end-guide/index.md',
30
+ targetPath: 'src/content/docs/reference/api/front-end-guide/overview.md',
31
+ content: '[Intro](./00-introduction.md)',
32
+ id: 'file1',
33
+ },
34
+ {
35
+ sourcePath: 'docs/front-end-guide/00-introduction.md',
36
+ targetPath: 'src/content/docs/reference/api/front-end-guide/00-introduction.md',
37
+ content: '# Introduction',
38
+ id: 'file2',
39
+ },
40
+ ];
41
+ const result = globalLinkTransform(files, {
42
+ stripPrefixes: ['src/content/docs'],
43
+ linkMappings: [],
44
+ });
45
+ expect(result[0].content).toBe('[Intro](/reference/api/front-end-guide/00-introduction/)');
46
+ });
47
+ it('should resolve relative links with ../ prefix', () => {
48
+ const files = [
49
+ {
50
+ sourcePath: 'docs/front-end-guide/subfolder/page.md',
51
+ targetPath: 'src/content/docs/reference/api/front-end-guide/subfolder/page.md',
52
+ content: '[Back to overview](../index.md)',
53
+ id: 'file1',
54
+ },
55
+ {
56
+ sourcePath: 'docs/front-end-guide/index.md',
57
+ targetPath: 'src/content/docs/reference/api/front-end-guide/overview.md',
58
+ content: '# Overview',
59
+ id: 'file2',
60
+ },
61
+ ];
62
+ const result = globalLinkTransform(files, {
63
+ stripPrefixes: ['src/content/docs'],
64
+ linkMappings: [],
65
+ });
66
+ expect(result[0].content).toBe('[Back to overview](/reference/api/front-end-guide/overview/)');
67
+ });
68
+ it('should preserve absolute links', () => {
69
+ const files = [
70
+ {
71
+ sourcePath: 'docs/page.md',
72
+ targetPath: 'src/content/docs/page.md',
73
+ content: '[Absolute link](/some/absolute/path)',
74
+ id: 'file1',
75
+ },
76
+ ];
77
+ const result = globalLinkTransform(files, {
78
+ stripPrefixes: ['src/content/docs'],
79
+ linkMappings: [],
80
+ });
81
+ expect(result[0].content).toBe('[Absolute link](/some/absolute/path)');
82
+ });
83
+ it('should preserve external links', () => {
84
+ const files = [
85
+ {
86
+ sourcePath: 'docs/page.md',
87
+ targetPath: 'src/content/docs/page.md',
88
+ content: '[External](https://example.com)',
89
+ id: 'file1',
90
+ },
91
+ ];
92
+ const result = globalLinkTransform(files, {
93
+ stripPrefixes: ['src/content/docs'],
94
+ linkMappings: [],
95
+ });
96
+ expect(result[0].content).toBe('[External](https://example.com)');
97
+ });
98
+ it('should preserve anchor-only links', () => {
99
+ const files = [
100
+ {
101
+ sourcePath: 'docs/page.md',
102
+ targetPath: 'src/content/docs/page.md',
103
+ content: '[Jump to section](#my-section)',
104
+ id: 'file1',
105
+ },
106
+ ];
107
+ const result = globalLinkTransform(files, {
108
+ stripPrefixes: ['src/content/docs'],
109
+ linkMappings: [],
110
+ });
111
+ expect(result[0].content).toBe('[Jump to section](#my-section)');
112
+ });
113
+ it('should preserve anchors in relative links', () => {
114
+ const files = [
115
+ {
116
+ sourcePath: 'docs/front-end-guide/index.md',
117
+ targetPath: 'src/content/docs/reference/api/front-end-guide/overview.md',
118
+ content: '[Section](02-designing-a-language.md#primitive-types)',
119
+ id: 'file1',
120
+ },
121
+ {
122
+ sourcePath: 'docs/front-end-guide/02-designing-a-language.md',
123
+ targetPath: 'src/content/docs/reference/api/front-end-guide/02-designing-a-language.md',
124
+ content: '# Designing a language',
125
+ id: 'file2',
126
+ },
127
+ ];
128
+ const result = globalLinkTransform(files, {
129
+ stripPrefixes: ['src/content/docs'],
130
+ linkMappings: [],
131
+ });
132
+ expect(result[0].content).toBe('[Section](/reference/api/front-end-guide/02-designing-a-language/#primitive-types)');
133
+ });
134
+ it('should handle multiple links in the same file', () => {
135
+ const files = [
136
+ {
137
+ sourcePath: 'docs/front-end-guide/index.md',
138
+ targetPath: 'src/content/docs/reference/api/front-end-guide/overview.md',
139
+ content: `
140
+ [Introduction](00-introduction.md)
141
+ [Calling puya](01-calling-puya.md)
142
+ [Designing a language](02-designing-a-language.md)
143
+ `.trim(),
144
+ id: 'file1',
145
+ },
146
+ {
147
+ sourcePath: 'docs/front-end-guide/00-introduction.md',
148
+ targetPath: 'src/content/docs/reference/api/front-end-guide/00-introduction.md',
149
+ content: '# Introduction',
150
+ id: 'file2',
151
+ },
152
+ {
153
+ sourcePath: 'docs/front-end-guide/01-calling-puya.md',
154
+ targetPath: 'src/content/docs/reference/api/front-end-guide/01-calling-puya.md',
155
+ content: '# Calling Puya',
156
+ id: 'file3',
157
+ },
158
+ {
159
+ sourcePath: 'docs/front-end-guide/02-designing-a-language.md',
160
+ targetPath: 'src/content/docs/reference/api/front-end-guide/02-designing-a-language.md',
161
+ content: '# Designing a language',
162
+ id: 'file4',
163
+ },
164
+ ];
165
+ const result = globalLinkTransform(files, {
166
+ stripPrefixes: ['src/content/docs'],
167
+ linkMappings: [],
168
+ });
169
+ expect(result[0].content).toBe(`
170
+ [Introduction](/reference/api/front-end-guide/00-introduction/)
171
+ [Calling puya](/reference/api/front-end-guide/01-calling-puya/)
172
+ [Designing a language](/reference/api/front-end-guide/02-designing-a-language/)
173
+ `.trim());
174
+ });
175
+ it('should strip .md extension from unresolved relative links', () => {
176
+ const files = [
177
+ {
178
+ sourcePath: 'docs/page.md',
179
+ targetPath: 'src/content/docs/page.md',
180
+ content: '[Unresolved](some-file-not-in-map.md)',
181
+ id: 'file1',
182
+ },
183
+ ];
184
+ const result = globalLinkTransform(files, {
185
+ stripPrefixes: ['src/content/docs'],
186
+ linkMappings: [],
187
+ });
188
+ // Should normalize to docs/some-file-not-in-map (current file's dir + relative link)
189
+ // but not resolve to full path since file not in map, so just strips .md
190
+ expect(result[0].content).toBe('[Unresolved](docs/some-file-not-in-map)');
191
+ });
192
+ });
193
+ describe('index.md handling', () => {
194
+ it('should convert index.md to trailing slash', () => {
195
+ const files = [
196
+ {
197
+ sourcePath: 'docs/page.md',
198
+ targetPath: 'src/content/docs/page.md',
199
+ content: '[Link](subfolder/index.md)',
200
+ id: 'file1',
201
+ },
202
+ {
203
+ sourcePath: 'docs/subfolder/index.md',
204
+ targetPath: 'src/content/docs/subfolder/index.md',
205
+ content: '# Index',
206
+ id: 'file2',
207
+ },
208
+ ];
209
+ const result = globalLinkTransform(files, {
210
+ stripPrefixes: ['src/content/docs'],
211
+ linkMappings: [
212
+ {
213
+ pattern: /\/index$/,
214
+ replacement: '/',
215
+ global: true,
216
+ },
217
+ ],
218
+ });
219
+ expect(result[0].content).toBe('[Link](/subfolder/)');
220
+ });
221
+ });
222
+ });
@@ -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.1",
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",
@@ -0,0 +1 @@
1
+ # Content
@@ -0,0 +1 @@
1
+ # Content
@@ -0,0 +1 @@
1
+ # Content
@@ -0,0 +1 @@
1
+ # Content
@@ -0,0 +1 @@
1
+ # Content
@@ -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}`);
@@ -104,19 +104,17 @@ function isExternalLink(link: string): boolean {
104
104
  function normalizePath(linkPath: string, currentFilePath: string, logger?: Logger): string {
105
105
  logger?.debug(`[normalizePath] BEFORE: linkPath="${linkPath}", currentFilePath="${currentFilePath}"`);
106
106
 
107
- // Handle relative paths
108
- if (linkPath.startsWith('./') || linkPath.includes('../')) {
107
+ // Handle relative paths (including simple relative paths without ./ prefix)
108
+ // A link is relative if it doesn't start with / or contain a protocol
109
+ const isAbsoluteOrExternal = linkPath.startsWith('/') || linkPath.includes('://') || linkPath.startsWith('#');
110
+
111
+ if (!isAbsoluteOrExternal) {
109
112
  const currentDir = path.dirname(currentFilePath);
110
113
  const resolved = path.posix.normalize(path.posix.join(currentDir, linkPath));
111
114
  logger?.debug(`[normalizePath] RELATIVE PATH RESOLVED: "${linkPath}" -> "${resolved}" (currentDir: "${currentDir}")`);
112
115
  return resolved;
113
116
  }
114
117
 
115
- // Remove leading './'
116
- if (linkPath.startsWith('./')) {
117
- return linkPath.slice(2);
118
- }
119
-
120
118
  logger?.debug(`[normalizePath] AFTER: "${linkPath}" (no changes)`);
121
119
  return linkPath;
122
120
  }
@@ -286,7 +284,12 @@ function transformLink(linkText: string, linkUrl: string, context: LinkContext):
286
284
  }
287
285
 
288
286
  // Check if this links to an imported file
289
- const targetPath = context.global.sourceToTargetMap.get(normalizedPath);
287
+ let targetPath = context.global.sourceToTargetMap.get(normalizedPath);
288
+
289
+ // If not found and path ends with /, try looking for index.md
290
+ if (!targetPath && normalizedPath.endsWith('/')) {
291
+ targetPath = context.global.sourceToTargetMap.get(normalizedPath + 'index.md');
292
+ }
290
293
 
291
294
  if (targetPath) {
292
295
  // This is an internal link to an imported file
@@ -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
  /**