@larkiny/astro-github-loader 0.11.3 → 0.13.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.
- package/README.md +35 -55
- package/dist/github.assets.d.ts +70 -0
- package/dist/github.assets.js +253 -0
- package/dist/github.auth.js +13 -9
- package/dist/github.cleanup.d.ts +3 -2
- package/dist/github.cleanup.js +30 -23
- package/dist/github.constants.d.ts +0 -16
- package/dist/github.constants.js +0 -16
- package/dist/github.content.d.ts +5 -131
- package/dist/github.content.js +152 -794
- package/dist/github.dryrun.d.ts +9 -5
- package/dist/github.dryrun.js +49 -25
- package/dist/github.link-transform.d.ts +2 -2
- package/dist/github.link-transform.js +68 -57
- package/dist/github.loader.js +30 -46
- package/dist/github.logger.d.ts +2 -2
- package/dist/github.logger.js +33 -24
- package/dist/github.paths.d.ts +76 -0
- package/dist/github.paths.js +190 -0
- package/dist/github.storage.d.ts +16 -0
- package/dist/github.storage.js +115 -0
- package/dist/github.types.d.ts +40 -4
- package/dist/index.d.ts +8 -6
- package/dist/index.js +3 -6
- package/dist/test-helpers.d.ts +130 -0
- package/dist/test-helpers.js +194 -0
- package/package.json +3 -1
- package/src/github.assets.spec.ts +717 -0
- package/src/github.assets.ts +365 -0
- package/src/github.auth.spec.ts +245 -0
- package/src/github.auth.ts +24 -10
- package/src/github.cleanup.spec.ts +380 -0
- package/src/github.cleanup.ts +91 -47
- package/src/github.constants.ts +0 -17
- package/src/github.content.spec.ts +305 -454
- package/src/github.content.ts +259 -957
- package/src/github.dryrun.spec.ts +598 -0
- package/src/github.dryrun.ts +108 -54
- package/src/github.link-transform.spec.ts +1345 -0
- package/src/github.link-transform.ts +177 -95
- package/src/github.loader.spec.ts +75 -50
- package/src/github.loader.ts +101 -76
- package/src/github.logger.spec.ts +795 -0
- package/src/github.logger.ts +77 -35
- package/src/github.paths.spec.ts +523 -0
- package/src/github.paths.ts +259 -0
- package/src/github.storage.spec.ts +377 -0
- package/src/github.storage.ts +135 -0
- package/src/github.types.ts +54 -9
- package/src/index.ts +43 -6
- package/src/test-helpers.ts +215 -0
package/src/github.content.ts
CHANGED
|
@@ -1,542 +1,236 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
-
import path
|
|
4
|
-
import picomatch from "picomatch";
|
|
5
|
-
import { globalLinkTransform, generateAutoLinkMappings, type ImportedFile } from "./github.link-transform.js";
|
|
6
|
-
import type { Logger } from "./github.logger.js";
|
|
7
|
-
|
|
3
|
+
import path from "node:path";
|
|
8
4
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./github.
|
|
13
|
-
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
* @returns Final filename after applying path mapping logic
|
|
53
|
-
* @internal
|
|
54
|
-
*/
|
|
55
|
-
export function applyRename(filePath: string, matchedPattern?: MatchedPattern | null, options?: ImportOptions): string {
|
|
56
|
-
if (options?.includes && matchedPattern && matchedPattern.index < options.includes.length) {
|
|
57
|
-
const includePattern = options.includes[matchedPattern.index];
|
|
58
|
-
|
|
59
|
-
if (includePattern.pathMappings) {
|
|
60
|
-
// First check for exact file match (current behavior - backwards compatible)
|
|
61
|
-
if (includePattern.pathMappings[filePath]) {
|
|
62
|
-
const mappingValue = includePattern.pathMappings[filePath];
|
|
63
|
-
return typeof mappingValue === 'string' ? mappingValue : mappingValue.target;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Then check for folder-to-folder mappings
|
|
67
|
-
for (const [sourceFolder, mappingValue] of Object.entries(includePattern.pathMappings)) {
|
|
68
|
-
// Check if this is a folder mapping (ends with /) and file is within it
|
|
69
|
-
if (sourceFolder.endsWith('/') && filePath.startsWith(sourceFolder)) {
|
|
70
|
-
// Replace the source folder path with target folder path
|
|
71
|
-
const targetFolder = typeof mappingValue === 'string' ? mappingValue : mappingValue.target;
|
|
72
|
-
const relativePath = filePath.slice(sourceFolder.length);
|
|
73
|
-
return path.posix.join(targetFolder, relativePath);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Return original filename if no path mapping found
|
|
80
|
-
return basename(filePath);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Generates a local file path based on the matched pattern and file path
|
|
85
|
-
* @param filePath - The original file path from the repository
|
|
86
|
-
* @param matchedPattern - The pattern that matched this file (or null if no includes specified)
|
|
87
|
-
* @param options - Import options containing includes patterns for path mapping lookups
|
|
88
|
-
* @return {string} The local file path where this content should be stored
|
|
89
|
-
* @internal
|
|
90
|
-
*/
|
|
91
|
-
export function generatePath(filePath: string, matchedPattern?: MatchedPattern | null, options?: ImportOptions): string {
|
|
92
|
-
if (matchedPattern) {
|
|
93
|
-
// Extract the directory part from the pattern (before any glob wildcards)
|
|
94
|
-
const pattern = matchedPattern.pattern;
|
|
95
|
-
const beforeGlob = pattern.split(/[*?{]/)[0];
|
|
96
|
-
|
|
97
|
-
// Remove the pattern prefix from the file path to get the relative path
|
|
98
|
-
let relativePath = filePath;
|
|
99
|
-
if (beforeGlob && filePath.startsWith(beforeGlob)) {
|
|
100
|
-
relativePath = filePath.substring(beforeGlob.length);
|
|
101
|
-
// Remove leading slash if present
|
|
102
|
-
if (relativePath.startsWith('/')) {
|
|
103
|
-
relativePath = relativePath.substring(1);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// If no relative path remains, use just the filename
|
|
108
|
-
if (!relativePath) {
|
|
109
|
-
relativePath = basename(filePath);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Apply path mapping logic
|
|
113
|
-
const finalFilename = applyRename(filePath, matchedPattern, options);
|
|
114
|
-
// Always apply path mapping if applyRename returned something different from the original basename
|
|
115
|
-
// OR if there are pathMappings configured (since empty string mappings might return same basename)
|
|
116
|
-
const hasPathMappings = options?.includes?.[matchedPattern.index]?.pathMappings &&
|
|
117
|
-
Object.keys(options.includes[matchedPattern.index].pathMappings!).length > 0;
|
|
118
|
-
if (finalFilename !== basename(filePath) || hasPathMappings) {
|
|
119
|
-
// Check if applyRename returned a full path (contains path separators) or just a filename
|
|
120
|
-
if (finalFilename.includes('/') || finalFilename.includes('\\')) {
|
|
121
|
-
// applyRename returned a full relative path - need to extract relative part
|
|
122
|
-
// Remove the pattern prefix to get the relative path within the pattern context
|
|
123
|
-
const beforeGlob = pattern.split(/[*?{]/)[0];
|
|
124
|
-
if (beforeGlob && finalFilename.startsWith(beforeGlob)) {
|
|
125
|
-
relativePath = finalFilename.substring(beforeGlob.length);
|
|
126
|
-
// Remove leading slash if present
|
|
127
|
-
if (relativePath.startsWith('/')) {
|
|
128
|
-
relativePath = relativePath.substring(1);
|
|
129
|
-
}
|
|
130
|
-
} else {
|
|
131
|
-
relativePath = finalFilename;
|
|
132
|
-
}
|
|
133
|
-
} else {
|
|
134
|
-
// applyRename returned just a filename
|
|
135
|
-
// If the filename is different due to pathMapping, use it directly
|
|
136
|
-
// This handles cases where pathMappings flatten directory structures
|
|
137
|
-
relativePath = finalFilename;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return join(matchedPattern.basePath, relativePath);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Should not happen since we always use includes
|
|
145
|
-
throw new Error("No matched pattern provided - includes are required");
|
|
146
|
-
}
|
|
5
|
+
globalLinkTransform,
|
|
6
|
+
generateAutoLinkMappings,
|
|
7
|
+
type ImportedFile,
|
|
8
|
+
} from "./github.link-transform.js";
|
|
9
|
+
import { Octokit } from "octokit";
|
|
10
|
+
import { INVALID_STRING_ERROR } from "./github.constants.js";
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
CollectionEntryOptions,
|
|
14
|
+
ExtendedLoaderContext,
|
|
15
|
+
ImportOptions,
|
|
16
|
+
TransformFunction,
|
|
17
|
+
} from "./github.types.js";
|
|
18
|
+
|
|
19
|
+
// Decomposed modules
|
|
20
|
+
import {
|
|
21
|
+
type ImportStats,
|
|
22
|
+
generateId,
|
|
23
|
+
generatePath,
|
|
24
|
+
shouldIncludeFile,
|
|
25
|
+
getHeaders,
|
|
26
|
+
} from "./github.paths.js";
|
|
27
|
+
import { resolveAssetConfig, processAssets } from "./github.assets.js";
|
|
28
|
+
import { storeProcessedFile } from "./github.storage.js";
|
|
29
|
+
|
|
30
|
+
// Re-export items that used to live in this module so existing internal
|
|
31
|
+
// consumers can migrate gradually (cleanup.ts, spec files, etc.).
|
|
32
|
+
export {
|
|
33
|
+
type ImportStats,
|
|
34
|
+
generateId,
|
|
35
|
+
generatePath,
|
|
36
|
+
shouldIncludeFile,
|
|
37
|
+
applyRename,
|
|
38
|
+
getHeaders,
|
|
39
|
+
syncHeaders,
|
|
40
|
+
} from "./github.paths.js";
|
|
41
|
+
export { syncFile } from "./github.storage.js";
|
|
42
|
+
export {
|
|
43
|
+
resolveAssetConfig,
|
|
44
|
+
detectAssets,
|
|
45
|
+
downloadAsset,
|
|
46
|
+
transformAssetReferences,
|
|
47
|
+
} from "./github.assets.js";
|
|
147
48
|
|
|
148
49
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* @param {string} path - The path of the file to synchronize, including its directory and filename.
|
|
152
|
-
* @param {string} content - The content to write into the file.
|
|
153
|
-
* @return {Promise<void>} - A promise that resolves when the file has been successfully written.
|
|
50
|
+
* Validates that a basePath is relative and does not escape the project root.
|
|
154
51
|
* @internal
|
|
155
52
|
*/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
53
|
+
function validateBasePath(basePath: string, projectRoot: string): void {
|
|
54
|
+
if (path.isAbsolute(basePath)) {
|
|
55
|
+
throw new Error(`basePath must be relative, got absolute path: ${basePath}`);
|
|
56
|
+
}
|
|
57
|
+
const resolved = path.resolve(projectRoot, basePath);
|
|
58
|
+
const normalized = path.normalize(resolved);
|
|
59
|
+
if (!normalized.startsWith(path.normalize(projectRoot))) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`basePath "${basePath}" resolves outside project root`,
|
|
62
|
+
);
|
|
162
63
|
}
|
|
163
|
-
|
|
164
|
-
// Write the file to the filesystem and store
|
|
165
|
-
await fs.writeFile(path, content, "utf-8");
|
|
166
64
|
}
|
|
167
65
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
* @internal
|
|
171
|
-
*/
|
|
172
|
-
const DEFAULT_ASSET_PATTERNS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp'];
|
|
66
|
+
const GITHUB_IDENTIFIER_RE = /^[a-zA-Z0-9._-]+$/;
|
|
67
|
+
const GITHUB_REF_RE = /^[a-zA-Z0-9._\-/]+$/;
|
|
173
68
|
|
|
174
69
|
/**
|
|
175
|
-
*
|
|
176
|
-
* @param filePath - The file path to check (relative to the repository root)
|
|
177
|
-
* @param options - Import options containing includes patterns
|
|
178
|
-
* @returns Object with include status and matched pattern, or null if not included
|
|
70
|
+
* Validates a GitHub owner or repo identifier.
|
|
179
71
|
* @internal
|
|
180
72
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// If no include patterns specified, include all files
|
|
185
|
-
if (!includes || includes.length === 0) {
|
|
186
|
-
return { included: true, matchedPattern: null };
|
|
73
|
+
function validateGitHubIdentifier(value: string, name: string): void {
|
|
74
|
+
if (!value || value.length > 100) {
|
|
75
|
+
throw new Error(`Invalid ${name}: must be 1-100 characters`);
|
|
187
76
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const matcher = picomatch(includePattern.pattern);
|
|
193
|
-
|
|
194
|
-
if (matcher(filePath)) {
|
|
195
|
-
return {
|
|
196
|
-
included: true,
|
|
197
|
-
matchedPattern: {
|
|
198
|
-
pattern: includePattern.pattern,
|
|
199
|
-
basePath: includePattern.basePath,
|
|
200
|
-
index: i
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
}
|
|
77
|
+
if (!GITHUB_IDENTIFIER_RE.test(value)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Invalid ${name}: "${value}" contains disallowed characters`,
|
|
80
|
+
);
|
|
204
81
|
}
|
|
205
|
-
|
|
206
|
-
// No patterns matched
|
|
207
|
-
return { included: false, matchedPattern: null };
|
|
208
82
|
}
|
|
209
83
|
|
|
210
84
|
/**
|
|
211
|
-
*
|
|
212
|
-
* @param content - The markdown content to parse
|
|
213
|
-
* @param assetPatterns - File extensions to treat as assets
|
|
214
|
-
* @returns Array of detected asset paths
|
|
85
|
+
* Validates a GitHub ref (branch/tag name). More permissive than identifiers — allows `/`.
|
|
215
86
|
* @internal
|
|
216
87
|
*/
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
// Match markdown images: 
|
|
222
|
-
const imageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
223
|
-
let match;
|
|
224
|
-
|
|
225
|
-
while ((match = imageRegex.exec(content)) !== null) {
|
|
226
|
-
const assetPath = match[1];
|
|
227
|
-
// Only include relative paths and assets matching our patterns
|
|
228
|
-
if (assetPath.startsWith('./') || assetPath.startsWith('../') || !assetPath.includes('://')) {
|
|
229
|
-
const ext = extname(assetPath).toLowerCase();
|
|
230
|
-
if (patterns.includes(ext)) {
|
|
231
|
-
assets.push(assetPath);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Match HTML img tags: <img src="path">
|
|
237
|
-
const htmlImgRegex = /<img[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
238
|
-
while ((match = htmlImgRegex.exec(content)) !== null) {
|
|
239
|
-
const assetPath = match[1];
|
|
240
|
-
if (assetPath.startsWith('./') || assetPath.startsWith('../') || !assetPath.includes('://')) {
|
|
241
|
-
const ext = extname(assetPath).toLowerCase();
|
|
242
|
-
if (patterns.includes(ext)) {
|
|
243
|
-
assets.push(assetPath);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
88
|
+
function validateGitHubRef(value: string): void {
|
|
89
|
+
if (!value || value.length > 256) {
|
|
90
|
+
throw new Error(`Invalid ref: must be 1-256 characters`);
|
|
246
91
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Downloads an asset from GitHub and saves it locally
|
|
253
|
-
* @param octokit - GitHub API client
|
|
254
|
-
* @param owner - Repository owner
|
|
255
|
-
* @param repo - Repository name
|
|
256
|
-
* @param ref - Git reference
|
|
257
|
-
* @param assetPath - Path to the asset in the repository
|
|
258
|
-
* @param localPath - Local path where the asset should be saved
|
|
259
|
-
* @param signal - Abort signal for cancellation
|
|
260
|
-
* @returns Promise that resolves when the asset is downloaded
|
|
261
|
-
* @internal
|
|
262
|
-
*/
|
|
263
|
-
export async function downloadAsset(
|
|
264
|
-
octokit: any,
|
|
265
|
-
owner: string,
|
|
266
|
-
repo: string,
|
|
267
|
-
ref: string,
|
|
268
|
-
assetPath: string,
|
|
269
|
-
localPath: string,
|
|
270
|
-
signal?: AbortSignal
|
|
271
|
-
): Promise<void> {
|
|
272
|
-
try {
|
|
273
|
-
const { data } = await octokit.rest.repos.getContent({
|
|
274
|
-
owner,
|
|
275
|
-
repo,
|
|
276
|
-
path: assetPath,
|
|
277
|
-
ref,
|
|
278
|
-
request: { signal },
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
if (Array.isArray(data) || data.type !== 'file' || !data.download_url) {
|
|
282
|
-
throw new Error(`Asset ${assetPath} is not a valid file (type: ${data.type}, downloadUrl: ${data.download_url})`);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const response = await fetch(data.download_url, { signal });
|
|
286
|
-
if (!response.ok) {
|
|
287
|
-
throw new Error(`Failed to download asset: ${response.status} ${response.statusText}`);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const buffer = await response.arrayBuffer();
|
|
291
|
-
const dir = dirname(localPath);
|
|
292
|
-
|
|
293
|
-
if (!existsSync(dir)) {
|
|
294
|
-
await fs.mkdir(dir, { recursive: true });
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
await fs.writeFile(localPath, new Uint8Array(buffer));
|
|
298
|
-
} catch (error: any) {
|
|
299
|
-
if (error.status === 404) {
|
|
300
|
-
throw new Error(`Asset not found: ${assetPath}`);
|
|
301
|
-
}
|
|
302
|
-
throw error;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Transforms asset references in markdown content to use local paths
|
|
308
|
-
* @param content - The markdown content to transform
|
|
309
|
-
* @param assetMap - Map of original asset paths to new local paths
|
|
310
|
-
* @returns Transformed content with updated asset references
|
|
311
|
-
* @internal
|
|
312
|
-
*/
|
|
313
|
-
export function transformAssetReferences(content: string, assetMap: Map<string, string>): string {
|
|
314
|
-
let transformedContent = content;
|
|
315
|
-
|
|
316
|
-
for (const [originalPath, newPath] of assetMap) {
|
|
317
|
-
// Transform markdown images
|
|
318
|
-
const imageRegex = new RegExp(`(!)\\[([^\\]]*)\\]\\(\\s*${escapeRegExp(originalPath)}\\s*\\)`, 'g');
|
|
319
|
-
transformedContent = transformedContent.replace(imageRegex, `$1[$2](${newPath})`);
|
|
320
|
-
|
|
321
|
-
// Transform HTML img tags
|
|
322
|
-
const htmlRegex = new RegExp(`(<img[^>]+src\\s*=\\s*["'])${escapeRegExp(originalPath)}(["'][^>]*>)`, 'gi');
|
|
323
|
-
transformedContent = transformedContent.replace(htmlRegex, `$1${newPath}$2`);
|
|
92
|
+
if (!GITHUB_REF_RE.test(value)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Invalid ref: "${value}" contains disallowed characters`,
|
|
95
|
+
);
|
|
324
96
|
}
|
|
325
|
-
|
|
326
|
-
return transformedContent;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Escapes special regex characters in a string
|
|
331
|
-
* @internal
|
|
332
|
-
*/
|
|
333
|
-
function escapeRegExp(string: string): string {
|
|
334
|
-
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
335
97
|
}
|
|
336
98
|
|
|
337
99
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* @param options - Configuration options including asset settings
|
|
341
|
-
* @param octokit - GitHub API client
|
|
342
|
-
* @param signal - Abort signal for cancellation
|
|
343
|
-
* @returns Promise that resolves to transformed content
|
|
100
|
+
* Collects file data by downloading content and applying transforms.
|
|
101
|
+
* Extracted from the nested closure inside toCollectionEntry for clarity.
|
|
344
102
|
* @internal
|
|
345
103
|
*/
|
|
346
|
-
async function
|
|
347
|
-
|
|
104
|
+
async function collectFileData(
|
|
105
|
+
{ url, editUrl: _editUrl }: { url: string | null; editUrl: string },
|
|
348
106
|
filePath: string,
|
|
349
107
|
options: ImportOptions,
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
signal?: AbortSignal
|
|
353
|
-
): Promise<
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
108
|
+
context: ExtendedLoaderContext,
|
|
109
|
+
octokit: Octokit,
|
|
110
|
+
signal?: AbortSignal,
|
|
111
|
+
): Promise<ImportedFile | null> {
|
|
112
|
+
const logger = context.logger;
|
|
113
|
+
|
|
114
|
+
if (url === null || typeof url !== "string") {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const urlObj = new URL(url);
|
|
119
|
+
|
|
120
|
+
// Determine if file needs renaming and generate appropriate ID
|
|
121
|
+
const includeCheck = shouldIncludeFile(filePath, options);
|
|
122
|
+
const matchedPattern = includeCheck.included
|
|
123
|
+
? includeCheck.matchedPattern
|
|
124
|
+
: null;
|
|
125
|
+
|
|
126
|
+
// Check if this file has a path mapping
|
|
127
|
+
const hasPathMapping =
|
|
128
|
+
matchedPattern &&
|
|
129
|
+
options?.includes &&
|
|
130
|
+
matchedPattern.index < options.includes.length &&
|
|
131
|
+
options.includes[matchedPattern.index].pathMappings &&
|
|
132
|
+
options.includes[matchedPattern.index].pathMappings![filePath];
|
|
133
|
+
|
|
134
|
+
// Generate ID based on appropriate path
|
|
135
|
+
const id = hasPathMapping
|
|
136
|
+
? generateId(generatePath(filePath, matchedPattern, options)) // Use path-mapped path for ID
|
|
137
|
+
: generateId(filePath); // Use original path for ID
|
|
138
|
+
|
|
139
|
+
const finalPath = generatePath(filePath, matchedPattern, options);
|
|
140
|
+
let contents: string;
|
|
141
|
+
|
|
142
|
+
logger.logFileProcessing("Fetching", filePath, `from ${urlObj.toString()}`);
|
|
143
|
+
|
|
144
|
+
// Download file content
|
|
145
|
+
const init = {
|
|
146
|
+
signal,
|
|
147
|
+
headers: getHeaders({ init: {}, meta: context.meta, id }),
|
|
148
|
+
};
|
|
149
|
+
let res: Response | null = null;
|
|
379
150
|
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
logger.logAssetProcessing("Processing", assetPath);
|
|
151
|
+
// Fetch with retries (simplified version of syncEntry logic)
|
|
152
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
383
153
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
logger.debug(` 🔗 Resolved path: ${resolvedAssetPath}`);
|
|
387
|
-
|
|
388
|
-
// Generate unique filename to avoid conflicts
|
|
389
|
-
const originalFilename = basename(assetPath);
|
|
390
|
-
const ext = extname(originalFilename);
|
|
391
|
-
const nameWithoutExt = basename(originalFilename, ext);
|
|
392
|
-
const uniqueFilename = `${nameWithoutExt}-${Date.now()}${ext}`;
|
|
393
|
-
const localPath = join(assetsPath, uniqueFilename);
|
|
394
|
-
logger.debug(` 💾 Local path: ${localPath}`);
|
|
395
|
-
|
|
396
|
-
// Check if asset already exists (simple cache check)
|
|
397
|
-
if (existsSync(localPath)) {
|
|
398
|
-
logger.logAssetProcessing("Cached", assetPath);
|
|
399
|
-
assetsCached++;
|
|
400
|
-
} else {
|
|
401
|
-
// Download the asset
|
|
402
|
-
logger.logAssetProcessing("Downloading", assetPath, `from ${owner}/${repo}@${ref}:${resolvedAssetPath}`);
|
|
403
|
-
await downloadAsset(octokit, owner, repo, ref, resolvedAssetPath, localPath, signal);
|
|
404
|
-
logger.logAssetProcessing("Downloaded", assetPath);
|
|
405
|
-
assetsDownloaded++;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Generate URL for the transformed reference
|
|
409
|
-
const assetUrl = `${assetsBaseUrl}/${uniqueFilename}`.replace(/\/+/g, '/');
|
|
410
|
-
logger.debug(` 🔄 Transform: ${assetPath} -> ${assetUrl}`);
|
|
411
|
-
|
|
412
|
-
// Map the transformation
|
|
413
|
-
assetMap.set(assetPath, assetUrl);
|
|
154
|
+
res = await fetch(urlObj, init);
|
|
155
|
+
if (res.ok) break;
|
|
414
156
|
} catch (error) {
|
|
415
|
-
|
|
157
|
+
if (attempt === 2) throw error;
|
|
158
|
+
await new Promise((resolve) =>
|
|
159
|
+
setTimeout(resolve, 1000 * (attempt + 1)),
|
|
160
|
+
);
|
|
416
161
|
}
|
|
417
|
-
}));
|
|
418
|
-
|
|
419
|
-
logger.verbose(` 🗺️ Processed ${assetMap.size} assets: ${assetsDownloaded} downloaded, ${assetsCached} cached`);
|
|
420
|
-
|
|
421
|
-
// Transform the content with new asset references
|
|
422
|
-
const transformedContent = transformAssetReferences(content, assetMap);
|
|
423
|
-
return { content: transformedContent, assetsDownloaded, assetsCached };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Resolves an asset path relative to a base path
|
|
428
|
-
* @internal
|
|
429
|
-
*/
|
|
430
|
-
function resolveAssetPath(basePath: string, assetPath: string): string {
|
|
431
|
-
if (assetPath.startsWith('./')) {
|
|
432
|
-
return join(dirname(basePath), assetPath.slice(2));
|
|
433
|
-
} else if (assetPath.startsWith('../')) {
|
|
434
|
-
return join(dirname(basePath), assetPath);
|
|
435
|
-
}
|
|
436
|
-
return assetPath;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Synchronizes an entry by fetching its contents, validating its metadata, and storing or rendering it as needed.
|
|
441
|
-
*
|
|
442
|
-
* @param {LoaderContext} context - The loader context containing the required utilities, metadata, and configuration.
|
|
443
|
-
* @param {Object} urls - Object containing URL data.
|
|
444
|
-
* @param {string | URL | null} urls.url - The URL of the entry to fetch. Throws an error if null or invalid.
|
|
445
|
-
* @param {string} urls.editUrl - The URL for editing the entry.
|
|
446
|
-
* @param {RootOptions} options - Configuration settings for processing the entry such as file paths and custom options.
|
|
447
|
-
* @param {any} octokit - GitHub API client for downloading assets.
|
|
448
|
-
* @param {RequestInit} [init] - Optional parameter for customizing the fetch request.
|
|
449
|
-
* @return {Promise<void>} Resolves when the entry has been successfully processed and stored. Throws errors if invalid URL, missing configuration, or other issues occur.
|
|
450
|
-
* @internal
|
|
451
|
-
*/
|
|
452
|
-
export async function syncEntry(
|
|
453
|
-
context: LoaderContext,
|
|
454
|
-
{ url, editUrl }: { url: string | URL | null; editUrl: string },
|
|
455
|
-
filePath: string,
|
|
456
|
-
options: ImportOptions,
|
|
457
|
-
octokit: any,
|
|
458
|
-
init: RequestInit = {},
|
|
459
|
-
) {
|
|
460
|
-
// Exit on null or if the URL is invalid
|
|
461
|
-
if (url === null || (typeof url !== "string" && !(url instanceof URL))) {
|
|
462
|
-
throw new TypeError(INVALID_URL_ERROR);
|
|
463
162
|
}
|
|
464
|
-
// Validate URL
|
|
465
|
-
if (typeof url === "string") url = new URL(url);
|
|
466
163
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
function configForFile(file: string) {
|
|
471
|
-
const ext = file.split(".").at(-1);
|
|
472
|
-
if (!ext) {
|
|
473
|
-
logger.warn(`No extension found for ${file}`);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
return entryTypes?.get(`.${ext}`);
|
|
164
|
+
if (!res) {
|
|
165
|
+
throw new Error(`No response received for ${urlObj.toString()}`);
|
|
477
166
|
}
|
|
478
|
-
// Custom ID, TODO: Allow custom id generators
|
|
479
|
-
let id = generateId(filePath);
|
|
480
|
-
|
|
481
|
-
init.headers = getHeaders({
|
|
482
|
-
init: init.headers,
|
|
483
|
-
meta,
|
|
484
|
-
id,
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
let res = await fetch(url, init);
|
|
488
167
|
|
|
489
168
|
if (res.status === 304) {
|
|
490
|
-
//
|
|
169
|
+
// File not modified, read existing content from disk if it exists
|
|
491
170
|
const includeResult = shouldIncludeFile(filePath, options);
|
|
492
|
-
const relativePath = generatePath(
|
|
171
|
+
const relativePath = generatePath(
|
|
172
|
+
filePath,
|
|
173
|
+
includeResult.included ? includeResult.matchedPattern : null,
|
|
174
|
+
options,
|
|
175
|
+
);
|
|
493
176
|
const fileUrl = pathToFileURL(relativePath);
|
|
494
|
-
|
|
177
|
+
|
|
495
178
|
if (existsSync(fileURLToPath(fileUrl))) {
|
|
496
|
-
logger.
|
|
497
|
-
|
|
179
|
+
logger.logFileProcessing("Using cached", filePath, "304 not modified");
|
|
180
|
+
const { promises: fs } = await import("node:fs");
|
|
181
|
+
contents = await fs.readFile(fileURLToPath(fileUrl), "utf-8");
|
|
498
182
|
} else {
|
|
499
|
-
|
|
500
|
-
|
|
183
|
+
// File is missing locally, re-fetch without cache headers
|
|
184
|
+
logger.logFileProcessing(
|
|
185
|
+
"Re-fetching",
|
|
186
|
+
filePath,
|
|
187
|
+
"missing locally despite 304",
|
|
188
|
+
);
|
|
501
189
|
const freshInit = { ...init };
|
|
502
190
|
freshInit.headers = new Headers(init.headers);
|
|
503
|
-
freshInit.headers.delete(
|
|
504
|
-
freshInit.headers.delete(
|
|
505
|
-
|
|
506
|
-
res = await fetch(
|
|
507
|
-
if (!res.ok)
|
|
191
|
+
freshInit.headers.delete("If-None-Match");
|
|
192
|
+
freshInit.headers.delete("If-Modified-Since");
|
|
193
|
+
|
|
194
|
+
res = await fetch(urlObj, freshInit);
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Failed to fetch file content from ${urlObj.toString()}: ${res.status} ${res.statusText || "Unknown error"}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
contents = await res.text();
|
|
508
201
|
}
|
|
202
|
+
} else if (!res.ok) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Failed to fetch file content from ${urlObj.toString()}: ${res.status} ${res.statusText || "Unknown error"}`,
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
contents = await res.text();
|
|
509
208
|
}
|
|
510
|
-
if (!res.ok) throw new Error(res.statusText);
|
|
511
|
-
let contents = await res.text();
|
|
512
|
-
const entryType = configForFile(filePath || "tmp.md");
|
|
513
|
-
if (!entryType) throw new Error("No entry type found");
|
|
514
209
|
|
|
515
|
-
// Process assets FIRST if configuration is provided -
|
|
516
|
-
|
|
517
|
-
if (
|
|
210
|
+
// Process assets FIRST if configuration is provided (or co-located defaults apply)
|
|
211
|
+
const resolvedAssetConfig = resolveAssetConfig(options, filePath);
|
|
212
|
+
if (resolvedAssetConfig) {
|
|
518
213
|
try {
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
};
|
|
529
|
-
const assetResult = await processAssets(contents, filePath, options, octokit, dummyLogger as Logger, init.signal || undefined);
|
|
214
|
+
const optionsWithAssets = { ...options, ...resolvedAssetConfig };
|
|
215
|
+
const assetResult = await processAssets(
|
|
216
|
+
contents,
|
|
217
|
+
filePath,
|
|
218
|
+
optionsWithAssets,
|
|
219
|
+
octokit,
|
|
220
|
+
logger,
|
|
221
|
+
signal,
|
|
222
|
+
);
|
|
530
223
|
contents = assetResult.content;
|
|
531
|
-
} catch (error
|
|
532
|
-
logger.warn(
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.warn(
|
|
226
|
+
`Asset processing failed for ${id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
227
|
+
);
|
|
533
228
|
}
|
|
534
229
|
}
|
|
535
230
|
|
|
536
|
-
// Apply content transforms
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
const transformsToApply: any[] = [];
|
|
231
|
+
// Apply content transforms
|
|
232
|
+
const includeResult = shouldIncludeFile(filePath, options);
|
|
233
|
+
const transformsToApply: TransformFunction[] = [];
|
|
540
234
|
|
|
541
235
|
// Add global transforms first
|
|
542
236
|
if (options.transforms && options.transforms.length > 0) {
|
|
@@ -544,8 +238,13 @@ export async function syncEntry(
|
|
|
544
238
|
}
|
|
545
239
|
|
|
546
240
|
// Add pattern-specific transforms
|
|
547
|
-
if (
|
|
548
|
-
|
|
241
|
+
if (
|
|
242
|
+
includeResult.included &&
|
|
243
|
+
includeResult.matchedPattern &&
|
|
244
|
+
options.includes
|
|
245
|
+
) {
|
|
246
|
+
const matchedInclude =
|
|
247
|
+
options.includes[includeResult.matchedPattern.index];
|
|
549
248
|
if (matchedInclude.transforms && matchedInclude.transforms.length > 0) {
|
|
550
249
|
transformsToApply.push(...matchedInclude.transforms);
|
|
551
250
|
}
|
|
@@ -556,90 +255,43 @@ export async function syncEntry(
|
|
|
556
255
|
id,
|
|
557
256
|
path: filePath,
|
|
558
257
|
options,
|
|
559
|
-
matchedPattern:
|
|
258
|
+
matchedPattern:
|
|
259
|
+
includeResult.included && includeResult.matchedPattern
|
|
260
|
+
? includeResult.matchedPattern
|
|
261
|
+
: undefined,
|
|
560
262
|
};
|
|
561
263
|
|
|
562
264
|
for (const transform of transformsToApply) {
|
|
563
265
|
try {
|
|
564
266
|
contents = transform(contents, transformContext);
|
|
565
267
|
} catch (error) {
|
|
566
|
-
logger
|
|
268
|
+
context.logger?.warn(`Transform failed for ${id}: ${error}`);
|
|
567
269
|
}
|
|
568
270
|
}
|
|
569
271
|
}
|
|
570
272
|
|
|
571
|
-
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
existingEntry &&
|
|
585
|
-
existingEntry.digest === digest &&
|
|
586
|
-
existingEntry.filePath
|
|
587
|
-
) {
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
// Write file to path
|
|
591
|
-
if (!existsSync(fileURLToPath(fileUrl))) {
|
|
592
|
-
(logger as any).verbose(`Writing ${id} to ${fileUrl}`);
|
|
593
|
-
await syncFile(fileURLToPath(fileUrl), contents);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const parsedData = await parseData({
|
|
597
|
-
id,
|
|
598
|
-
data,
|
|
599
|
-
filePath: fileUrl.toString(),
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
if (entryType.getRenderFunction) {
|
|
603
|
-
(logger as any).verbose(`Rendering ${id}`);
|
|
604
|
-
const render = await entryType.getRenderFunction(config);
|
|
605
|
-
let rendered: RenderedContent | undefined = undefined;
|
|
606
|
-
try {
|
|
607
|
-
rendered = await render?.({
|
|
608
|
-
id,
|
|
609
|
-
data,
|
|
610
|
-
body,
|
|
611
|
-
filePath: fileUrl.toString(),
|
|
612
|
-
digest,
|
|
613
|
-
});
|
|
614
|
-
} catch (error: any) {
|
|
615
|
-
logger.error(`Error rendering ${id}: ${error.message}`);
|
|
616
|
-
}
|
|
617
|
-
store.set({
|
|
618
|
-
id,
|
|
619
|
-
data: parsedData,
|
|
620
|
-
body,
|
|
621
|
-
filePath: relativePath,
|
|
622
|
-
digest,
|
|
623
|
-
rendered,
|
|
624
|
-
});
|
|
625
|
-
} else if ("contentModuleTypes" in entryType) {
|
|
626
|
-
store.set({
|
|
627
|
-
id,
|
|
628
|
-
data: parsedData,
|
|
629
|
-
body,
|
|
630
|
-
filePath: relativePath,
|
|
631
|
-
digest,
|
|
632
|
-
deferredRender: true,
|
|
633
|
-
});
|
|
634
|
-
} else {
|
|
635
|
-
store.set({ id, data: parsedData, body, filePath: relativePath, digest });
|
|
636
|
-
}
|
|
273
|
+
// Build link context for this file
|
|
274
|
+
const linkContext =
|
|
275
|
+
includeResult.included && includeResult.matchedPattern
|
|
276
|
+
? {
|
|
277
|
+
sourcePath: filePath,
|
|
278
|
+
targetPath: finalPath,
|
|
279
|
+
basePath: includeResult.matchedPattern.basePath,
|
|
280
|
+
pathMappings:
|
|
281
|
+
options.includes?.[includeResult.matchedPattern.index]
|
|
282
|
+
?.pathMappings,
|
|
283
|
+
matchedPattern: includeResult.matchedPattern,
|
|
284
|
+
}
|
|
285
|
+
: undefined;
|
|
637
286
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
287
|
+
// Use the finalPath we already computed
|
|
288
|
+
return {
|
|
289
|
+
sourcePath: filePath,
|
|
290
|
+
targetPath: finalPath,
|
|
291
|
+
content: contents,
|
|
641
292
|
id,
|
|
642
|
-
|
|
293
|
+
linkContext,
|
|
294
|
+
};
|
|
643
295
|
}
|
|
644
296
|
|
|
645
297
|
/**
|
|
@@ -660,8 +312,23 @@ export async function toCollectionEntry({
|
|
|
660
312
|
if (typeof repo !== "string" || typeof owner !== "string")
|
|
661
313
|
throw new TypeError(INVALID_STRING_ERROR);
|
|
662
314
|
|
|
663
|
-
//
|
|
664
|
-
|
|
315
|
+
// Validate identifiers to prevent injection into API calls / URLs
|
|
316
|
+
validateGitHubIdentifier(owner, "owner");
|
|
317
|
+
validateGitHubIdentifier(repo, "repo");
|
|
318
|
+
if (ref !== "main") validateGitHubRef(ref);
|
|
319
|
+
|
|
320
|
+
// Validate include pattern basePaths don't escape the project
|
|
321
|
+
const projectRoot = process.cwd();
|
|
322
|
+
if (options.includes) {
|
|
323
|
+
for (const inc of options.includes) {
|
|
324
|
+
validateBasePath(inc.basePath, projectRoot);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (options.assetsPath) {
|
|
328
|
+
validateBasePath(options.assetsPath, projectRoot);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const logger = context.logger;
|
|
665
332
|
|
|
666
333
|
/**
|
|
667
334
|
* OPTIMIZATION: Use Git Trees API for efficient file discovery
|
|
@@ -693,7 +360,7 @@ export async function toCollectionEntry({
|
|
|
693
360
|
repo,
|
|
694
361
|
sha: ref,
|
|
695
362
|
per_page: 1,
|
|
696
|
-
request: { signal }
|
|
363
|
+
request: { signal },
|
|
697
364
|
});
|
|
698
365
|
|
|
699
366
|
if (commits.length === 0) {
|
|
@@ -711,19 +378,23 @@ export async function toCollectionEntry({
|
|
|
711
378
|
repo,
|
|
712
379
|
tree_sha: treeSha,
|
|
713
380
|
recursive: "true",
|
|
714
|
-
request: { signal }
|
|
381
|
+
request: { signal },
|
|
715
382
|
});
|
|
716
383
|
|
|
717
384
|
logger.debug(`Retrieved ${treeData.tree.length} items from repository tree`);
|
|
718
385
|
|
|
719
386
|
// Filter tree to only include files (not dirs/submodules) that match our patterns
|
|
720
|
-
const fileEntries = treeData.tree.filter(
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
387
|
+
const fileEntries = treeData.tree.filter(
|
|
388
|
+
(item: { type?: string; path?: string }) => {
|
|
389
|
+
if (item.type !== "blob") return false; // Only process files (blobs)
|
|
390
|
+
const includeCheck = shouldIncludeFile(item.path!, options);
|
|
391
|
+
return includeCheck.included;
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
logger.info(
|
|
396
|
+
`Found ${fileEntries.length} files matching include patterns (filtered from ${treeData.tree.length} total items)`,
|
|
397
|
+
);
|
|
727
398
|
|
|
728
399
|
// Collect all files first (with content transforms applied)
|
|
729
400
|
const allFiles: ImportedFile[] = [];
|
|
@@ -731,12 +402,17 @@ export async function toCollectionEntry({
|
|
|
731
402
|
for (const treeItem of fileEntries) {
|
|
732
403
|
const filePath = treeItem.path;
|
|
733
404
|
// Construct the download URL (raw.githubusercontent.com format)
|
|
734
|
-
const
|
|
735
|
-
const
|
|
405
|
+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
406
|
+
const downloadUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${commitSha}/${encodedPath}`;
|
|
407
|
+
const editUrl = treeItem.url || ""; // Git blob URL (use empty string as fallback)
|
|
736
408
|
|
|
737
409
|
const fileData = await collectFileData(
|
|
738
410
|
{ url: downloadUrl, editUrl },
|
|
739
|
-
filePath
|
|
411
|
+
filePath,
|
|
412
|
+
options,
|
|
413
|
+
context,
|
|
414
|
+
octokit,
|
|
415
|
+
signal,
|
|
740
416
|
);
|
|
741
417
|
|
|
742
418
|
if (fileData) {
|
|
@@ -760,16 +436,21 @@ export async function toCollectionEntry({
|
|
|
760
436
|
|
|
761
437
|
// Generate automatic link mappings from pathMappings
|
|
762
438
|
const autoGeneratedMappings = options.includes
|
|
763
|
-
? generateAutoLinkMappings(
|
|
439
|
+
? generateAutoLinkMappings(
|
|
440
|
+
options.includes,
|
|
441
|
+
options.linkTransform.stripPrefixes,
|
|
442
|
+
)
|
|
764
443
|
: [];
|
|
765
444
|
|
|
766
445
|
// Combine auto-generated mappings with user-defined mappings
|
|
767
446
|
const allLinkMappings = [
|
|
768
447
|
...autoGeneratedMappings,
|
|
769
|
-
...(options.linkTransform.linkMappings || [])
|
|
448
|
+
...(options.linkTransform.linkMappings || []),
|
|
770
449
|
];
|
|
771
450
|
|
|
772
|
-
logger.debug(
|
|
451
|
+
logger.debug(
|
|
452
|
+
`Generated ${autoGeneratedMappings.length} automatic link mappings from pathMappings`,
|
|
453
|
+
);
|
|
773
454
|
|
|
774
455
|
processedFiles = globalLinkTransform(allFiles, {
|
|
775
456
|
stripPrefixes: options.linkTransform.stripPrefixes,
|
|
@@ -792,383 +473,4 @@ export async function toCollectionEntry({
|
|
|
792
473
|
}
|
|
793
474
|
|
|
794
475
|
return stats;
|
|
795
|
-
|
|
796
|
-
// Helper function to collect file data with content transforms applied
|
|
797
|
-
async function collectFileData(
|
|
798
|
-
{ url, editUrl }: { url: string | null; editUrl: string },
|
|
799
|
-
filePath: string
|
|
800
|
-
): Promise<ImportedFile | null> {
|
|
801
|
-
if (url === null || typeof url !== "string") {
|
|
802
|
-
return null;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const urlObj = new URL(url);
|
|
806
|
-
|
|
807
|
-
// Determine if file needs renaming and generate appropriate ID
|
|
808
|
-
const includeCheck = shouldIncludeFile(filePath, options);
|
|
809
|
-
const matchedPattern = includeCheck.included ? includeCheck.matchedPattern : null;
|
|
810
|
-
|
|
811
|
-
// Check if this file has a path mapping
|
|
812
|
-
const hasPathMapping = matchedPattern &&
|
|
813
|
-
options?.includes &&
|
|
814
|
-
matchedPattern.index < options.includes.length &&
|
|
815
|
-
options.includes[matchedPattern.index].pathMappings &&
|
|
816
|
-
options.includes[matchedPattern.index].pathMappings![filePath];
|
|
817
|
-
|
|
818
|
-
// Generate ID based on appropriate path
|
|
819
|
-
const id = hasPathMapping ?
|
|
820
|
-
generateId(generatePath(filePath, matchedPattern, options)) : // Use path-mapped path for ID
|
|
821
|
-
generateId(filePath); // Use original path for ID
|
|
822
|
-
|
|
823
|
-
const finalPath = generatePath(filePath, matchedPattern, options);
|
|
824
|
-
let contents: string;
|
|
825
|
-
|
|
826
|
-
logger.logFileProcessing("Fetching", filePath, `from ${urlObj.toString()}`);
|
|
827
|
-
|
|
828
|
-
// Download file content
|
|
829
|
-
const init = { signal, headers: getHeaders({ init: {}, meta: context.meta, id }) };
|
|
830
|
-
let res: Response | null = null;
|
|
831
|
-
|
|
832
|
-
// Fetch with retries (simplified version of syncEntry logic)
|
|
833
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
834
|
-
try {
|
|
835
|
-
res = await fetch(urlObj, init);
|
|
836
|
-
if (res.ok) break;
|
|
837
|
-
} catch (error) {
|
|
838
|
-
if (attempt === 2) throw error;
|
|
839
|
-
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
if (!res) {
|
|
844
|
-
throw new Error(`No response received for ${urlObj.toString()}`);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
if (res.status === 304) {
|
|
848
|
-
// File not modified, read existing content from disk if it exists
|
|
849
|
-
const includeResult = shouldIncludeFile(filePath, options);
|
|
850
|
-
const relativePath = generatePath(filePath, includeResult.included ? includeResult.matchedPattern : null, options);
|
|
851
|
-
const fileUrl = pathToFileURL(relativePath);
|
|
852
|
-
|
|
853
|
-
if (existsSync(fileURLToPath(fileUrl))) {
|
|
854
|
-
logger.logFileProcessing("Using cached", filePath, "304 not modified");
|
|
855
|
-
const { promises: fs } = await import('node:fs');
|
|
856
|
-
contents = await fs.readFile(fileURLToPath(fileUrl), 'utf-8');
|
|
857
|
-
} else {
|
|
858
|
-
// File is missing locally, re-fetch without cache headers
|
|
859
|
-
logger.logFileProcessing("Re-fetching", filePath, "missing locally despite 304");
|
|
860
|
-
const freshInit = { ...init };
|
|
861
|
-
freshInit.headers = new Headers(init.headers);
|
|
862
|
-
freshInit.headers.delete('If-None-Match');
|
|
863
|
-
freshInit.headers.delete('If-Modified-Since');
|
|
864
|
-
|
|
865
|
-
res = await fetch(urlObj, freshInit);
|
|
866
|
-
if (!res.ok) {
|
|
867
|
-
throw new Error(`Failed to fetch file content from ${urlObj.toString()}: ${res.status} ${res.statusText || 'Unknown error'}`);
|
|
868
|
-
}
|
|
869
|
-
contents = await res.text();
|
|
870
|
-
}
|
|
871
|
-
} else if (!res.ok) {
|
|
872
|
-
throw new Error(`Failed to fetch file content from ${urlObj.toString()}: ${res.status} ${res.statusText || 'Unknown error'}`);
|
|
873
|
-
} else {
|
|
874
|
-
contents = await res.text();
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// Process assets FIRST if configuration is provided
|
|
878
|
-
let fileAssetsDownloaded = 0;
|
|
879
|
-
let fileAssetsCached = 0;
|
|
880
|
-
if (options.assetsPath && options.assetsBaseUrl) {
|
|
881
|
-
try {
|
|
882
|
-
const assetResult = await processAssets(contents, filePath, options, octokit, logger, signal);
|
|
883
|
-
contents = assetResult.content;
|
|
884
|
-
fileAssetsDownloaded = assetResult.assetsDownloaded;
|
|
885
|
-
fileAssetsCached = assetResult.assetsCached;
|
|
886
|
-
} catch (error) {
|
|
887
|
-
logger.warn(`Asset processing failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
// Apply content transforms
|
|
892
|
-
const includeResult = shouldIncludeFile(filePath, options);
|
|
893
|
-
const transformsToApply: any[] = [];
|
|
894
|
-
|
|
895
|
-
// Add global transforms first
|
|
896
|
-
if (options.transforms && options.transforms.length > 0) {
|
|
897
|
-
transformsToApply.push(...options.transforms);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Add pattern-specific transforms
|
|
901
|
-
if (includeResult.included && includeResult.matchedPattern && options.includes) {
|
|
902
|
-
const matchedInclude = options.includes[includeResult.matchedPattern.index];
|
|
903
|
-
if (matchedInclude.transforms && matchedInclude.transforms.length > 0) {
|
|
904
|
-
transformsToApply.push(...matchedInclude.transforms);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
if (transformsToApply.length > 0) {
|
|
909
|
-
const transformContext = {
|
|
910
|
-
id,
|
|
911
|
-
path: filePath,
|
|
912
|
-
options,
|
|
913
|
-
matchedPattern: includeResult.included ? includeResult.matchedPattern : undefined,
|
|
914
|
-
};
|
|
915
|
-
|
|
916
|
-
for (const transform of transformsToApply) {
|
|
917
|
-
try {
|
|
918
|
-
contents = transform(contents, transformContext);
|
|
919
|
-
} catch (error) {
|
|
920
|
-
context.logger?.warn(`Transform failed for ${id}: ${error}`);
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// Build link context for this file
|
|
926
|
-
const linkContext = includeResult.included && includeResult.matchedPattern ? {
|
|
927
|
-
sourcePath: filePath,
|
|
928
|
-
targetPath: finalPath,
|
|
929
|
-
basePath: includeResult.matchedPattern.basePath,
|
|
930
|
-
pathMappings: options.includes?.[includeResult.matchedPattern.index]?.pathMappings,
|
|
931
|
-
matchedPattern: includeResult.matchedPattern,
|
|
932
|
-
} : undefined;
|
|
933
|
-
|
|
934
|
-
// Use the finalPath we already computed
|
|
935
|
-
return {
|
|
936
|
-
sourcePath: filePath,
|
|
937
|
-
targetPath: finalPath,
|
|
938
|
-
content: contents,
|
|
939
|
-
id,
|
|
940
|
-
linkContext,
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// Helper function to store a processed file
|
|
945
|
-
async function storeProcessedFile(
|
|
946
|
-
file: ImportedFile,
|
|
947
|
-
context: any,
|
|
948
|
-
clear: boolean
|
|
949
|
-
): Promise<any> {
|
|
950
|
-
const { store, generateDigest, entryTypes, logger, parseData, config } = context;
|
|
951
|
-
|
|
952
|
-
function configForFile(filePath: string) {
|
|
953
|
-
const ext = filePath.split(".").at(-1);
|
|
954
|
-
if (!ext) {
|
|
955
|
-
logger.warn(`No extension found for ${filePath}`);
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
return entryTypes?.get(`.${ext}`);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
const entryType = configForFile(file.sourcePath || "tmp.md");
|
|
962
|
-
if (!entryType) throw new Error("No entry type found");
|
|
963
|
-
|
|
964
|
-
const fileUrl = pathToFileURL(file.targetPath);
|
|
965
|
-
const { body, data } = await entryType.getEntryInfo({
|
|
966
|
-
contents: file.content,
|
|
967
|
-
fileUrl: fileUrl,
|
|
968
|
-
});
|
|
969
|
-
|
|
970
|
-
// Generate digest for storage (repository-level caching handles change detection)
|
|
971
|
-
const digest = generateDigest(file.content);
|
|
972
|
-
const existingEntry = store.get(file.id);
|
|
973
|
-
|
|
974
|
-
if (existingEntry) {
|
|
975
|
-
logger.debug(`🔄 File ${file.id} - updating`);
|
|
976
|
-
} else {
|
|
977
|
-
logger.debug(`📄 File ${file.id} - adding`);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
// Write file to disk
|
|
981
|
-
if (!existsSync(fileURLToPath(fileUrl))) {
|
|
982
|
-
logger.verbose(`Writing ${file.id} to ${fileUrl}`);
|
|
983
|
-
await syncFile(fileURLToPath(fileUrl), file.content);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const parsedData = await parseData({
|
|
987
|
-
id: file.id,
|
|
988
|
-
data,
|
|
989
|
-
filePath: fileUrl.toString(),
|
|
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
|
-
|
|
1000
|
-
// Store in content store
|
|
1001
|
-
if (entryType.getRenderFunction) {
|
|
1002
|
-
logger.verbose(`Rendering ${file.id}`);
|
|
1003
|
-
const render = await entryType.getRenderFunction(config);
|
|
1004
|
-
let rendered = undefined;
|
|
1005
|
-
try {
|
|
1006
|
-
rendered = await render?.({
|
|
1007
|
-
id: file.id,
|
|
1008
|
-
data,
|
|
1009
|
-
body,
|
|
1010
|
-
filePath: fileUrl.toString(),
|
|
1011
|
-
digest,
|
|
1012
|
-
});
|
|
1013
|
-
} catch (error: any) {
|
|
1014
|
-
logger.error(`Error rendering ${file.id}: ${error.message}`);
|
|
1015
|
-
}
|
|
1016
|
-
logger.debug(`🔍 Storing collection entry: ${file.id} (${file.sourcePath} -> ${file.targetPath})`);
|
|
1017
|
-
store.set({
|
|
1018
|
-
id: file.id,
|
|
1019
|
-
data: parsedData,
|
|
1020
|
-
body,
|
|
1021
|
-
filePath: file.targetPath,
|
|
1022
|
-
digest,
|
|
1023
|
-
rendered,
|
|
1024
|
-
});
|
|
1025
|
-
} else if ("contentModuleTypes" in entryType) {
|
|
1026
|
-
store.set({
|
|
1027
|
-
id: file.id,
|
|
1028
|
-
data: parsedData,
|
|
1029
|
-
body,
|
|
1030
|
-
filePath: file.targetPath,
|
|
1031
|
-
digest,
|
|
1032
|
-
deferredRender: true,
|
|
1033
|
-
});
|
|
1034
|
-
} else {
|
|
1035
|
-
store.set({
|
|
1036
|
-
id: file.id,
|
|
1037
|
-
data: parsedData,
|
|
1038
|
-
body,
|
|
1039
|
-
filePath: file.targetPath,
|
|
1040
|
-
digest
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
return { id: file.id, filePath: file.targetPath };
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
async function processDirectoryRecursively(path: string): Promise<any> {
|
|
1048
|
-
// Fetch the content
|
|
1049
|
-
const { data, status } = await octokit.rest.repos.getContent({
|
|
1050
|
-
owner,
|
|
1051
|
-
repo,
|
|
1052
|
-
path,
|
|
1053
|
-
ref,
|
|
1054
|
-
request: { signal },
|
|
1055
|
-
});
|
|
1056
|
-
if (status !== 200) throw new Error(INVALID_SERVICE_RESPONSE);
|
|
1057
|
-
|
|
1058
|
-
// Matches for regular files
|
|
1059
|
-
if (!Array.isArray(data)) {
|
|
1060
|
-
const filePath = data.path;
|
|
1061
|
-
switch (data.type) {
|
|
1062
|
-
// Return
|
|
1063
|
-
case "file":
|
|
1064
|
-
return await syncEntry(
|
|
1065
|
-
context,
|
|
1066
|
-
{ url: data.download_url, editUrl: data.url },
|
|
1067
|
-
filePath,
|
|
1068
|
-
options,
|
|
1069
|
-
octokit,
|
|
1070
|
-
{ signal },
|
|
1071
|
-
);
|
|
1072
|
-
default:
|
|
1073
|
-
throw new Error("Invalid type");
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// Directory listing with filtering - process sequentially
|
|
1078
|
-
const filteredEntries = data
|
|
1079
|
-
.filter(({ type, path }) => {
|
|
1080
|
-
// Always include directories for recursion
|
|
1081
|
-
if (type === "dir") return true;
|
|
1082
|
-
// Apply filtering logic to files
|
|
1083
|
-
if (type === "file") {
|
|
1084
|
-
return shouldIncludeFile(path, options).included;
|
|
1085
|
-
}
|
|
1086
|
-
return false;
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
const results = [];
|
|
1090
|
-
for (const { type, path, download_url, url } of filteredEntries) {
|
|
1091
|
-
switch (type) {
|
|
1092
|
-
// Recurse
|
|
1093
|
-
case "dir":
|
|
1094
|
-
results.push(await processDirectoryRecursively(path));
|
|
1095
|
-
break;
|
|
1096
|
-
// Return
|
|
1097
|
-
case "file":
|
|
1098
|
-
results.push(await syncEntry(
|
|
1099
|
-
context,
|
|
1100
|
-
{ url: download_url, editUrl: url },
|
|
1101
|
-
path,
|
|
1102
|
-
options,
|
|
1103
|
-
octokit,
|
|
1104
|
-
{ signal },
|
|
1105
|
-
));
|
|
1106
|
-
break;
|
|
1107
|
-
default:
|
|
1108
|
-
throw new Error("Invalid type");
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
return results;
|
|
1112
|
-
} // End of processDirectoryRecursively function
|
|
1113
476
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
/**
|
|
1118
|
-
* Get the headers needed to make a conditional request.
|
|
1119
|
-
* Uses the etag and last-modified values from the meta store.
|
|
1120
|
-
* @internal
|
|
1121
|
-
*/
|
|
1122
|
-
export function getHeaders({
|
|
1123
|
-
init,
|
|
1124
|
-
meta,
|
|
1125
|
-
id,
|
|
1126
|
-
}: {
|
|
1127
|
-
/** Initial headers to include */
|
|
1128
|
-
init?: RequestInit["headers"];
|
|
1129
|
-
/** Meta store to get etag and last-modified values from */
|
|
1130
|
-
meta: LoaderContext["meta"];
|
|
1131
|
-
id: string;
|
|
1132
|
-
}): Headers {
|
|
1133
|
-
const tag = `${id}-etag`;
|
|
1134
|
-
const lastModifiedTag = `${id}-last-modified`;
|
|
1135
|
-
const etag = meta.get(tag);
|
|
1136
|
-
const lastModified = meta.get(lastModifiedTag);
|
|
1137
|
-
const headers = new Headers(init);
|
|
1138
|
-
|
|
1139
|
-
if (etag) {
|
|
1140
|
-
headers.set("If-None-Match", etag);
|
|
1141
|
-
} else if (lastModified) {
|
|
1142
|
-
headers.set("If-Modified-Since", lastModified);
|
|
1143
|
-
}
|
|
1144
|
-
return headers;
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* Store the etag or last-modified headers from a response in the meta store.
|
|
1149
|
-
* @internal
|
|
1150
|
-
*/
|
|
1151
|
-
export function syncHeaders({
|
|
1152
|
-
headers,
|
|
1153
|
-
meta,
|
|
1154
|
-
id,
|
|
1155
|
-
}: {
|
|
1156
|
-
/** Headers from the response */
|
|
1157
|
-
headers: Headers;
|
|
1158
|
-
/** Meta store to store etag and last-modified values in */
|
|
1159
|
-
meta: LoaderContext["meta"];
|
|
1160
|
-
/** id string */
|
|
1161
|
-
id: string;
|
|
1162
|
-
}) {
|
|
1163
|
-
const etag = headers.get("etag");
|
|
1164
|
-
const lastModified = headers.get("last-modified");
|
|
1165
|
-
const tag = `${id}-etag`;
|
|
1166
|
-
const lastModifiedTag = `${id}-last-modified`;
|
|
1167
|
-
meta.delete(tag);
|
|
1168
|
-
meta.delete(lastModifiedTag);
|
|
1169
|
-
if (etag) {
|
|
1170
|
-
meta.set(tag, etag);
|
|
1171
|
-
} else if (lastModified) {
|
|
1172
|
-
meta.set(lastModifiedTag, lastModified);
|
|
1173
|
-
}
|
|
1174
|
-
}
|