@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 +46 -11
- package/dist/github.content.d.ts +1 -1
- package/dist/github.content.js +10 -3
- package/dist/github.link-transform.js +9 -7
- package/dist/github.link-transform.spec.d.ts +1 -0
- package/dist/github.link-transform.spec.js +222 -0
- package/dist/github.loader.js +16 -6
- package/dist/github.types.d.ts +7 -0
- package/package.json +1 -1
- package/src/content/docs/algokit/cli/accounts.md +1 -0
- package/src/content/docs/algokit/cli/generate.md +1 -0
- package/src/content/docs/algokit/cli/overview.md +1 -0
- package/src/content/docs/algokit/cli/tasks.md +1 -0
- package/src/content/docs/reference/algokit-cli/index.md +1 -0
- package/src/github.content.ts +11 -2
- package/src/github.link-transform.ts +11 -8
- package/src/github.loader.ts +17 -7
- package/src/github.types.ts +7 -0
package/README.md
CHANGED
|
@@ -584,17 +584,52 @@ const REMOTE_CONTENT_WITH_ASSETS: ImportOptions[] = [
|
|
|
584
584
|
|
|
585
585
|
## File Management Strategy
|
|
586
586
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
package/dist/github.content.d.ts
CHANGED
|
@@ -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.
|
package/dist/github.content.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/github.loader.js
CHANGED
|
@@ -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
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
package/dist/github.types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
package/src/github.content.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/github.loader.ts
CHANGED
|
@@ -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
|
-
//
|
|
95
|
-
|
|
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`
|
package/src/github.types.ts
CHANGED
|
@@ -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
|
/**
|