framer-code-link 0.2.0 → 0.2.1
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/dist/index.mjs +0 -0
- package/package.json +7 -4
- package/src/controller.test.ts +0 -891
- package/src/controller.ts +0 -1419
- package/src/helpers/connection.ts +0 -187
- package/src/helpers/files.test.ts +0 -117
- package/src/helpers/files.ts +0 -463
- package/src/helpers/installer.ts +0 -530
- package/src/helpers/sync-validator.ts +0 -87
- package/src/helpers/user-actions.ts +0 -158
- package/src/helpers/watcher.test.ts +0 -74
- package/src/helpers/watcher.ts +0 -110
- package/src/index.ts +0 -111
- package/src/types.ts +0 -112
- package/src/utils/file-metadata-cache.ts +0 -121
- package/src/utils/hash-tracker.ts +0 -79
- package/src/utils/imports.ts +0 -62
- package/src/utils/logging.ts +0 -235
- package/src/utils/paths.ts +0 -76
- package/src/utils/project.ts +0 -120
- package/src/utils/state-persistence.ts +0 -138
- package/tsconfig.json +0 -14
- package/vitest.config.ts +0 -8
package/src/helpers/files.ts
DELETED
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File operations helper
|
|
3
|
-
*
|
|
4
|
-
* Single place that understands disk + conflicts. Provides:
|
|
5
|
-
* - listFiles: returns current filesystem state
|
|
6
|
-
* - detectConflicts: compares remote vs local and returns conflicts + safe writes
|
|
7
|
-
* - writeRemoteFiles: applies writes/deletes from remote
|
|
8
|
-
* - deleteLocalFile: removes a file from disk
|
|
9
|
-
*
|
|
10
|
-
* Controller decides WHEN to call these, but never computes conflicts itself.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import fs from "fs/promises"
|
|
14
|
-
import path from "path"
|
|
15
|
-
import type {
|
|
16
|
-
FileInfo,
|
|
17
|
-
ConflictResolution,
|
|
18
|
-
Conflict,
|
|
19
|
-
ConflictVersionData,
|
|
20
|
-
} from "../types.js"
|
|
21
|
-
import type { HashTracker } from "../utils/hash-tracker.js"
|
|
22
|
-
import { normalizePath, sanitizeFilePath } from "@code-link/shared"
|
|
23
|
-
import { warn, debug } from "../utils/logging.js"
|
|
24
|
-
import {
|
|
25
|
-
hashFileContent,
|
|
26
|
-
type PersistedFileState,
|
|
27
|
-
} from "../utils/state-persistence.js"
|
|
28
|
-
|
|
29
|
-
const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"]
|
|
30
|
-
const DEFAULT_EXTENSION = ".tsx"
|
|
31
|
-
const DEFAULT_REMOTE_DRIFT_MS = 2000
|
|
32
|
-
|
|
33
|
-
/** Normalize file name for case-insensitive comparison (macOS/Windows compat) */
|
|
34
|
-
function normalizeForComparison(fileName: string): string {
|
|
35
|
-
return fileName.toLowerCase()
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Lists all supported files in the files directory
|
|
40
|
-
*/
|
|
41
|
-
export async function listFiles(filesDir: string): Promise<FileInfo[]> {
|
|
42
|
-
const files: FileInfo[] = []
|
|
43
|
-
|
|
44
|
-
async function walk(currentDir: string): Promise<void> {
|
|
45
|
-
const entries = await fs.readdir(currentDir, { withFileTypes: true })
|
|
46
|
-
|
|
47
|
-
for (const entry of entries) {
|
|
48
|
-
const entryPath = path.join(currentDir, entry.name)
|
|
49
|
-
|
|
50
|
-
if (entry.isDirectory()) {
|
|
51
|
-
await walk(entryPath)
|
|
52
|
-
continue
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (!isSupportedExtension(entry.name)) continue
|
|
56
|
-
|
|
57
|
-
const relativePath = path.relative(filesDir, entryPath)
|
|
58
|
-
const normalizedPath = normalizePath(relativePath)
|
|
59
|
-
// Don't capitalize when listing existing files - preserve their actual names
|
|
60
|
-
const sanitizedPath = sanitizeFilePath(normalizedPath, false).path
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const [content, stats] = await Promise.all([
|
|
64
|
-
fs.readFile(entryPath, "utf-8"),
|
|
65
|
-
fs.stat(entryPath),
|
|
66
|
-
])
|
|
67
|
-
|
|
68
|
-
files.push({
|
|
69
|
-
name: sanitizedPath,
|
|
70
|
-
content,
|
|
71
|
-
modifiedAt: stats.mtimeMs,
|
|
72
|
-
})
|
|
73
|
-
} catch (err) {
|
|
74
|
-
warn(`Failed to read ${entryPath}:`, err)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
await walk(filesDir)
|
|
81
|
-
} catch (err) {
|
|
82
|
-
warn("Failed to list files:", err)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return files
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Detects conflicts between remote files and local filesystem
|
|
90
|
-
* Returns conflicts that need user resolution and safe writes that can be applied
|
|
91
|
-
*/
|
|
92
|
-
export interface ConflictDetectionOptions {
|
|
93
|
-
preferRemote?: boolean
|
|
94
|
-
detectConflicts?: boolean
|
|
95
|
-
persistedState?: Map<string, PersistedFileState>
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function detectConflicts(
|
|
99
|
-
remoteFiles: FileInfo[],
|
|
100
|
-
filesDir: string,
|
|
101
|
-
options: ConflictDetectionOptions = {}
|
|
102
|
-
): Promise<ConflictResolution> {
|
|
103
|
-
const conflicts: Conflict[] = []
|
|
104
|
-
const writes: FileInfo[] = []
|
|
105
|
-
const localOnly: FileInfo[] = []
|
|
106
|
-
const unchanged: FileInfo[] = []
|
|
107
|
-
const detect = options.detectConflicts ?? true
|
|
108
|
-
const preferRemote = options.preferRemote ?? false
|
|
109
|
-
const persistedState = options.persistedState
|
|
110
|
-
|
|
111
|
-
const getPersistedState = (fileName: string) =>
|
|
112
|
-
persistedState?.get(normalizeForComparison(fileName)) ??
|
|
113
|
-
persistedState?.get(fileName)
|
|
114
|
-
|
|
115
|
-
debug(`Detecting conflicts for ${remoteFiles.length} remote files`)
|
|
116
|
-
|
|
117
|
-
// Build a snapshot of all local files (keyed by lowercase for case-insensitive matching)
|
|
118
|
-
const localFiles = await listFiles(filesDir)
|
|
119
|
-
const localFileMap = new Map(
|
|
120
|
-
localFiles.map((f) => [normalizeForComparison(f.name), f])
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
// Build a set of remote file names for quick lookup (lowercase keys)
|
|
124
|
-
const remoteFileMap = new Map(
|
|
125
|
-
remoteFiles.map((f) => {
|
|
126
|
-
const normalized = resolveRemoteReference(filesDir, f.name)
|
|
127
|
-
return [normalizeForComparison(normalized.relativePath), f]
|
|
128
|
-
})
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
// Track which files we've processed (lowercase for case-insensitive matching)
|
|
132
|
-
const processedFiles = new Set<string>()
|
|
133
|
-
|
|
134
|
-
// Process remote files (remote-only or both sides)
|
|
135
|
-
for (const remote of remoteFiles) {
|
|
136
|
-
const normalized = resolveRemoteReference(filesDir, remote.name)
|
|
137
|
-
const normalizedKey = normalizeForComparison(normalized.relativePath)
|
|
138
|
-
const local = localFileMap.get(normalizedKey)
|
|
139
|
-
processedFiles.add(normalizedKey)
|
|
140
|
-
|
|
141
|
-
const persisted = getPersistedState(normalized.relativePath)
|
|
142
|
-
const localHash = local ? hashFileContent(local.content) : null
|
|
143
|
-
const localMatchesPersisted =
|
|
144
|
-
!!persisted && !!local && localHash === persisted.contentHash
|
|
145
|
-
|
|
146
|
-
if (!local) {
|
|
147
|
-
// File exists in remote but not locally
|
|
148
|
-
if (persisted) {
|
|
149
|
-
// File was previously synced but now missing locally → deleted locally while offline
|
|
150
|
-
// This is a conflict: local=null (deleted), remote=content
|
|
151
|
-
debug(
|
|
152
|
-
`Conflict: ${normalized.relativePath} deleted locally while offline`
|
|
153
|
-
)
|
|
154
|
-
conflicts.push({
|
|
155
|
-
fileName: normalized.relativePath,
|
|
156
|
-
localContent: null,
|
|
157
|
-
remoteContent: remote.content,
|
|
158
|
-
remoteModifiedAt: remote.modifiedAt,
|
|
159
|
-
lastSyncedAt: persisted?.timestamp,
|
|
160
|
-
})
|
|
161
|
-
} else {
|
|
162
|
-
// New file from remote (never synced before): download
|
|
163
|
-
writes.push({
|
|
164
|
-
name: normalized.relativePath,
|
|
165
|
-
content: remote.content,
|
|
166
|
-
modifiedAt: remote.modifiedAt,
|
|
167
|
-
})
|
|
168
|
-
}
|
|
169
|
-
continue
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (local.content === remote.content) {
|
|
173
|
-
// Content matches - no disk write needed but track for metadata
|
|
174
|
-
unchanged.push({
|
|
175
|
-
name: normalized.relativePath,
|
|
176
|
-
content: remote.content,
|
|
177
|
-
modifiedAt: remote.modifiedAt,
|
|
178
|
-
})
|
|
179
|
-
continue
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (!detect || preferRemote) {
|
|
183
|
-
writes.push({
|
|
184
|
-
name: normalized.relativePath,
|
|
185
|
-
content: remote.content,
|
|
186
|
-
modifiedAt: remote.modifiedAt,
|
|
187
|
-
})
|
|
188
|
-
continue
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Check if local file is "clean" (matches last persisted state)
|
|
192
|
-
// If so, we can safely overwrite it with remote changes
|
|
193
|
-
// Both sides have the file with different content -> conflict
|
|
194
|
-
const localClean = persisted ? localMatchesPersisted : undefined
|
|
195
|
-
conflicts.push({
|
|
196
|
-
fileName: normalized.relativePath,
|
|
197
|
-
localContent: local.content,
|
|
198
|
-
remoteContent: remote.content,
|
|
199
|
-
localModifiedAt: local.modifiedAt,
|
|
200
|
-
remoteModifiedAt: remote.modifiedAt,
|
|
201
|
-
lastSyncedAt: persisted?.timestamp,
|
|
202
|
-
localClean,
|
|
203
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Process local-only files (not present in remote)
|
|
207
|
-
for (const local of localFiles) {
|
|
208
|
-
const localKey = normalizeForComparison(local.name)
|
|
209
|
-
if (!processedFiles.has(localKey)) {
|
|
210
|
-
const persisted = getPersistedState(local.name)
|
|
211
|
-
if (persisted) {
|
|
212
|
-
// File was previously synced but now missing from remote → deleted in Framer
|
|
213
|
-
const localHash = hashFileContent(local.content)
|
|
214
|
-
const localClean = localHash === persisted.contentHash
|
|
215
|
-
debug(
|
|
216
|
-
`Conflict: ${local.name} deleted in Framer (localClean=${localClean})`
|
|
217
|
-
)
|
|
218
|
-
conflicts.push({
|
|
219
|
-
fileName: local.name,
|
|
220
|
-
localContent: local.content,
|
|
221
|
-
remoteContent: null,
|
|
222
|
-
localModifiedAt: local.modifiedAt,
|
|
223
|
-
lastSyncedAt: persisted?.timestamp,
|
|
224
|
-
localClean,
|
|
225
|
-
})
|
|
226
|
-
} else {
|
|
227
|
-
// New local file (never synced before): upload later
|
|
228
|
-
localOnly.push({
|
|
229
|
-
name: local.name,
|
|
230
|
-
content: local.content,
|
|
231
|
-
modifiedAt: local.modifiedAt,
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Check for files in persisted state that are missing from BOTH sides
|
|
238
|
-
// These were deleted on both sides while offline - auto-clean them (no conflict)
|
|
239
|
-
if (persistedState) {
|
|
240
|
-
for (const [fileName] of persistedState) {
|
|
241
|
-
const normalizedKey = normalizeForComparison(fileName)
|
|
242
|
-
const inLocal = localFileMap.has(normalizedKey)
|
|
243
|
-
const inRemote = remoteFileMap.has(normalizedKey)
|
|
244
|
-
if (!inLocal && !inRemote) {
|
|
245
|
-
debug(`[AUTO-RESOLVE] ${fileName}: deleted on both sides, no conflict`)
|
|
246
|
-
// No action needed - the file is gone from both sides
|
|
247
|
-
// The persisted state will be cleaned up when we persist
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return { conflicts, writes, localOnly, unchanged }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export interface AutoResolveResult {
|
|
256
|
-
autoResolvedLocal: Conflict[]
|
|
257
|
-
autoResolvedRemote: Conflict[]
|
|
258
|
-
remainingConflicts: Conflict[]
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function autoResolveConflicts(
|
|
262
|
-
conflicts: Conflict[],
|
|
263
|
-
versions: ConflictVersionData[],
|
|
264
|
-
options: { remoteDriftMs?: number } = {}
|
|
265
|
-
): AutoResolveResult {
|
|
266
|
-
const versionMap = new Map(
|
|
267
|
-
versions.map((version) => [version.fileName, version.latestRemoteVersionMs])
|
|
268
|
-
)
|
|
269
|
-
const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS
|
|
270
|
-
|
|
271
|
-
const autoResolvedLocal: Conflict[] = []
|
|
272
|
-
const autoResolvedRemote: Conflict[] = []
|
|
273
|
-
const remainingConflicts: Conflict[] = []
|
|
274
|
-
|
|
275
|
-
for (const conflict of conflicts) {
|
|
276
|
-
const latestRemoteVersionMs = versionMap.get(conflict.fileName)
|
|
277
|
-
const lastSyncedAt = conflict.lastSyncedAt
|
|
278
|
-
const localClean = conflict.localClean === true
|
|
279
|
-
|
|
280
|
-
debug(`Auto-resolve checking ${conflict.fileName}`)
|
|
281
|
-
|
|
282
|
-
// Remote deletion: file deleted in Framer
|
|
283
|
-
if (conflict.remoteContent === null) {
|
|
284
|
-
if (localClean) {
|
|
285
|
-
debug(` Remote deleted, local clean -> REMOTE (delete locally)`)
|
|
286
|
-
autoResolvedRemote.push(conflict)
|
|
287
|
-
} else {
|
|
288
|
-
debug(` Remote deleted, local modified -> conflict`)
|
|
289
|
-
remainingConflicts.push(conflict)
|
|
290
|
-
}
|
|
291
|
-
continue
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (!latestRemoteVersionMs) {
|
|
295
|
-
debug(` No remote version data, keeping conflict`)
|
|
296
|
-
remainingConflicts.push(conflict)
|
|
297
|
-
continue
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (!lastSyncedAt) {
|
|
301
|
-
debug(` No last sync timestamp, keeping conflict`)
|
|
302
|
-
remainingConflicts.push(conflict)
|
|
303
|
-
continue
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`)
|
|
307
|
-
debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`)
|
|
308
|
-
|
|
309
|
-
const remoteUnchanged =
|
|
310
|
-
latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
|
|
311
|
-
// localClean already declared above for remote deletion handling
|
|
312
|
-
|
|
313
|
-
if (remoteUnchanged && !localClean) {
|
|
314
|
-
debug(` Remote unchanged, local changed -> LOCAL`)
|
|
315
|
-
autoResolvedLocal.push(conflict)
|
|
316
|
-
} else if (localClean && !remoteUnchanged) {
|
|
317
|
-
debug(` Local unchanged, remote changed -> REMOTE`)
|
|
318
|
-
autoResolvedRemote.push(conflict)
|
|
319
|
-
} else if (remoteUnchanged && localClean) {
|
|
320
|
-
debug(` Both unchanged, skipping`)
|
|
321
|
-
} else {
|
|
322
|
-
debug(` Both changed, real conflict`)
|
|
323
|
-
remainingConflicts.push(conflict)
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
autoResolvedLocal,
|
|
329
|
-
autoResolvedRemote,
|
|
330
|
-
remainingConflicts,
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Writes remote files to disk and updates hash tracker to prevent echoes
|
|
336
|
-
* CRITICAL: Update hashTracker BEFORE writing to disk
|
|
337
|
-
*/
|
|
338
|
-
export async function writeRemoteFiles(
|
|
339
|
-
files: FileInfo[],
|
|
340
|
-
filesDir: string,
|
|
341
|
-
hashTracker: HashTracker,
|
|
342
|
-
installer?: { process: (fileName: string, content: string) => void }
|
|
343
|
-
): Promise<void> {
|
|
344
|
-
debug(`Writing ${files.length} remote files`)
|
|
345
|
-
|
|
346
|
-
for (const file of files) {
|
|
347
|
-
try {
|
|
348
|
-
const normalized = resolveRemoteReference(filesDir, file.name)
|
|
349
|
-
const fullPath = normalized.absolutePath
|
|
350
|
-
|
|
351
|
-
// Ensure directory exists
|
|
352
|
-
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
353
|
-
|
|
354
|
-
// CRITICAL ORDER: Update hash tracker FIRST (in memory)
|
|
355
|
-
hashTracker.remember(normalized.relativePath, file.content)
|
|
356
|
-
|
|
357
|
-
// THEN write to disk
|
|
358
|
-
await fs.writeFile(fullPath, file.content, "utf-8")
|
|
359
|
-
|
|
360
|
-
debug(`Wrote file: ${normalized.relativePath}`)
|
|
361
|
-
|
|
362
|
-
// Trigger type installer if available
|
|
363
|
-
installer?.process(normalized.relativePath, file.content)
|
|
364
|
-
} catch (err) {
|
|
365
|
-
warn(`Failed to write file ${file.name}:`, err)
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Deletes a local file from disk
|
|
372
|
-
*/
|
|
373
|
-
export async function deleteLocalFile(
|
|
374
|
-
fileName: string,
|
|
375
|
-
filesDir: string,
|
|
376
|
-
hashTracker: HashTracker
|
|
377
|
-
): Promise<void> {
|
|
378
|
-
const normalized = resolveRemoteReference(filesDir, fileName)
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
// CRITICAL ORDER: Mark delete FIRST (in memory) to prevent echo
|
|
382
|
-
hashTracker.markDelete(normalized.relativePath)
|
|
383
|
-
|
|
384
|
-
// THEN delete from disk
|
|
385
|
-
await fs.unlink(normalized.absolutePath)
|
|
386
|
-
|
|
387
|
-
// Clear the hash immediately
|
|
388
|
-
hashTracker.forget(normalized.relativePath)
|
|
389
|
-
|
|
390
|
-
debug(`Deleted file: ${normalized.relativePath}`)
|
|
391
|
-
} catch (err) {
|
|
392
|
-
const nodeError = err as NodeJS.ErrnoException
|
|
393
|
-
|
|
394
|
-
if (nodeError?.code === "ENOENT") {
|
|
395
|
-
// Treat missing files as already deleted to keep hash tracker in sync
|
|
396
|
-
hashTracker.forget(normalized.relativePath)
|
|
397
|
-
debug(`File already deleted: ${normalized.relativePath}`)
|
|
398
|
-
return
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Clear pending delete marker immediately on failure
|
|
402
|
-
hashTracker.clearDelete(normalized.relativePath)
|
|
403
|
-
warn(`Failed to delete file ${fileName}:`, err)
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Reads a single file from disk (safe, returns null on error)
|
|
409
|
-
*/
|
|
410
|
-
export async function readFileSafe(
|
|
411
|
-
fileName: string,
|
|
412
|
-
filesDir: string
|
|
413
|
-
): Promise<string | null> {
|
|
414
|
-
const normalized = resolveRemoteReference(filesDir, fileName)
|
|
415
|
-
|
|
416
|
-
try {
|
|
417
|
-
return await fs.readFile(normalized.absolutePath, "utf-8")
|
|
418
|
-
} catch {
|
|
419
|
-
return null
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function readLocalSnapshot(absolutePath: string) {
|
|
424
|
-
try {
|
|
425
|
-
const [content, stats] = await Promise.all([
|
|
426
|
-
fs.readFile(absolutePath, "utf-8"),
|
|
427
|
-
fs.stat(absolutePath),
|
|
428
|
-
])
|
|
429
|
-
return { content, modifiedAt: stats.mtimeMs }
|
|
430
|
-
} catch {
|
|
431
|
-
return null
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function resolveRemoteReference(filesDir: string, rawName: string) {
|
|
436
|
-
const normalized = sanitizeRelativePath(rawName)
|
|
437
|
-
const absolutePath = path.join(filesDir, normalized.relativePath)
|
|
438
|
-
return { ...normalized, absolutePath }
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function sanitizeRelativePath(relativePath: string) {
|
|
442
|
-
const trimmed = normalizePath(relativePath.trim())
|
|
443
|
-
const hasExtension = SUPPORTED_EXTENSIONS.some((ext) =>
|
|
444
|
-
trimmed.toLowerCase().endsWith(ext)
|
|
445
|
-
)
|
|
446
|
-
const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`
|
|
447
|
-
// Don't capitalize when processing remote files - preserve exact casing from Framer
|
|
448
|
-
const sanitized = sanitizeFilePath(candidate, false)
|
|
449
|
-
const normalized = normalizePath(sanitized.path)
|
|
450
|
-
|
|
451
|
-
return {
|
|
452
|
-
relativePath: normalized,
|
|
453
|
-
extension:
|
|
454
|
-
sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION,
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function isSupportedExtension(fileName: string) {
|
|
459
|
-
const lower = fileName.toLowerCase()
|
|
460
|
-
return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Removed ensureSanitizedFileOnDisk as we want to trust user file names
|