@gesslar/toolkit 3.9.0 → 3.12.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/src/lib/FS.js CHANGED
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * @file FS.js
3
3
  *
4
- * File system utilities for path manipulation, file discovery, and path resolution.
5
- * Provides glob-based file search, URI conversion, and intelligent path merging.
4
+ * File system utilities for path manipulation, file discovery, and path
5
+ * resolution.
6
+ *
7
+ * Provides glob-based file search, URI conversion, and intelligent path
8
+ * merging.
6
9
  */
7
10
 
8
- import {globby} from "globby"
9
11
  import path from "node:path"
10
12
  import url from "node:url"
11
13
 
12
14
  import Collection from "../browser/lib/Collection.js"
13
- import Sass from "./Sass.js"
15
+ import Data from "../browser/lib/Data.js"
14
16
  import Valid from "./Valid.js"
15
17
 
16
18
  /** @typedef {import("./FileObject.js").default} FileObject */
@@ -58,7 +60,7 @@ export default class FS {
58
60
  * @returns {string} The fixed path
59
61
  */
60
62
  static fixSlashes(pathName) {
61
- return pathName.replace(/\\/g, "/")
63
+ return path.normalize(pathName.replace(/\\/g, "/"))
62
64
  }
63
65
 
64
66
  /**
@@ -68,12 +70,10 @@ export default class FS {
68
70
  * @param {string} pathName - The path to convert
69
71
  * @returns {string} The URI
70
72
  */
71
- static pathToUri(pathName) {
73
+ static pathToUrl(pathName) {
72
74
  try {
73
75
  return url.pathToFileURL(pathName).href
74
- } catch(e) {
75
- void e // stfu linter
76
-
76
+ } catch {
77
77
  return pathName
78
78
  }
79
79
  }
@@ -85,61 +85,19 @@ export default class FS {
85
85
  * @param {string} pathName - The URI to convert
86
86
  * @returns {string} The path
87
87
  */
88
- static uriToPath(pathName) {
88
+ static urlToPath(pathName) {
89
89
  try {
90
- return url.fileURLToPath(pathName)
91
- } catch(_) {
90
+ return url.fileURLToPath(new URL(pathName).pathName)
91
+ } catch {
92
92
  return pathName
93
93
  }
94
94
  }
95
95
 
96
- /**
97
- * Retrieve all files matching a specific glob pattern.
98
- *
99
- * @static
100
- * @param {string|Array<string>} glob - The glob pattern(s) to search.
101
- * @returns {Promise<Array<FileObject>>} A promise that resolves to an array of file objects
102
- * @throws {Sass} If the input is not a string or array of strings.
103
- * @throws {Sass} If the glob pattern array is empty or for other search failures.
104
- */
105
- static async getFiles(glob) {
106
- const isString = typeof glob === "string"
107
- const isArray = Array.isArray(glob)
108
- const isStringArray = isArray && glob.every(item => typeof item === "string")
109
-
110
- Valid.assert(
111
- (isString && glob.length > 0) ||
112
- (isStringArray && glob.length > 0),
113
- "glob must be a non-empty string or array of strings.",
114
- 1
115
- )
116
-
117
- const globbyArray = (
118
- isString
119
- ? glob.split("|").map(g => g.trim()).filter(Boolean)
120
- : glob
121
- ).map(g => FS.fixSlashes(g))
122
-
123
- if(isArray && !globbyArray.length)
124
- throw Sass.new(
125
- `Invalid glob pattern: Array cannot be empty. Got ${JSON.stringify(glob)}`,
126
- )
127
-
128
- // Use Globby to fetch matching files
129
- const {default: FileObject} = await import("./FileObject.js")
130
-
131
- const filesArray = await globby(globbyArray)
132
- const files = filesArray.map(file => new FileObject(file))
133
-
134
- // Flatten the result and remove duplicates
135
- return files
136
- }
137
-
138
96
  /**
139
97
  * Computes the relative path from one file or directory to another.
140
98
  *
141
- * If the target is outside the source (i.e., the relative path starts with ".."),
142
- * returns the absolute path to the target instead.
99
+ * If the target is outside the source (i.e., the relative path starts with
100
+ * ".."), returns the absolute path to the target instead.
143
101
  *
144
102
  * @static
145
103
  * @param {FileObject|DirectoryObject} from - The source file or directory object
@@ -159,7 +117,8 @@ export default class FS {
159
117
  }
160
118
 
161
119
  /**
162
- * Merge two paths by finding overlapping segments and combining them efficiently
120
+ * Merge two paths by finding overlapping segments and combining them
121
+ * efficiently
163
122
  *
164
123
  * @static
165
124
  * @param {string} path1 - The first path
@@ -169,8 +128,8 @@ export default class FS {
169
128
  */
170
129
  static mergeOverlappingPaths(path1, path2, sep=path.sep) {
171
130
  const isAbsolutePath1 = path.isAbsolute(path1)
172
- const from = path1.split(sep).filter(Boolean)
173
- const to = path2.split(sep).filter(Boolean)
131
+ const from = path.normalize(path1).split(sep).filter(Boolean)
132
+ const to = path.normalize(path2).split(sep).filter(Boolean)
174
133
 
175
134
  // If they're the same, just return path1
176
135
  if(to.length === from.length && from.every((f, i) => to[i] === f))
@@ -203,9 +162,16 @@ export default class FS {
203
162
  * @returns {string} The resolved path
204
163
  */
205
164
  static resolvePath(fromPath, toPath) {
165
+ Valid.type(fromPath, "String")
166
+ Valid.type(toPath, "String")
167
+
206
168
  // Normalize inputs
207
- const from = fromPath?.trim() ?? ""
208
- const to = toPath?.trim() ?? ""
169
+ const from = this.fixSlashes(fromPath?.trim() ?? "")
170
+ const to = this.fixSlashes(toPath?.trim() ?? "")
171
+
172
+ // Are they the same? What's the resolve?
173
+ if(from === to)
174
+ return from
209
175
 
210
176
  // Handle empty cases
211
177
  if(!from && !to)
@@ -217,20 +183,205 @@ export default class FS {
217
183
  if(!to)
218
184
  return from
219
185
 
220
- const normalizedTo = /^\.\//.test(to)
221
- ? path.normalize(to)
222
- : to
223
-
224
186
  // Strategy 1: If 'to' is absolute, it's standalone
225
- if(path.isAbsolute(normalizedTo))
226
- return normalizedTo
187
+ if(path.isAbsolute(to))
188
+ return path.resolve(to)
227
189
 
228
190
  // Strategy 2: If 'to' contains relative navigation
229
- if(to.startsWith("../"))
230
- return path.resolve(from, normalizedTo)
191
+ if(to.startsWith(this.fixSlashes("../")))
192
+ return path.resolve(from, to)
231
193
 
232
194
  // Strategy 3: Try overlap-based merging, which will default to a basic
233
195
  // join if no overlap
234
- return FS.mergeOverlappingPaths(from, normalizedTo)
196
+ return FS.mergeOverlappingPaths(from, to)
197
+ }
198
+
199
+ /**
200
+ * Check if a candidate path is contained within a container path.
201
+ *
202
+ * @static
203
+ * @param {string} container - The container path to check against
204
+ * @param {string} candidate - The candidate path that might be contained
205
+ * @returns {boolean} True if candidate is within container, false otherwise
206
+ * @throws {Sass} If container is not a non-empty string
207
+ * @throws {Sass} If candidate is not a non-empty string
208
+ * @example
209
+ * FS.pathContains("/home/user", "/home/user/docs") // true
210
+ * FS.pathContains("/home/user", "/home/other") // false
211
+ */
212
+ static pathContains(container, candidate) {
213
+ Valid.type(container, "String", {allowEmpty: false})
214
+ Valid.type(candidate, "String", {allowEmpty: false})
215
+
216
+ const realPath = Data.append(container, "/") // bookend this mofo
217
+
218
+ return candidate.startsWith(realPath)
219
+ }
220
+
221
+ /**
222
+ * Convert an absolute path to a relative path by finding overlapping segments.
223
+ * Returns the relative portion of the 'to' path after the last occurrence
224
+ * of the final segment from the 'from' path.
225
+ *
226
+ * @static
227
+ * @param {string} from - The base path to calculate relative from
228
+ * @param {string} to - The target path to make relative
229
+ * @param {string} [sep=path.sep] - The path separator to use (defaults to system separator)
230
+ * @returns {string|null} The relative path, empty string if paths are identical, or null if no overlap found
231
+ * @example
232
+ * FS.toRelativePath("/projects/toolkit", "/projects/toolkit/src") // "src"
233
+ * FS.toRelativePath("/home/user", "/home/user") // ""
234
+ * FS.toRelativePath("/projects/app", "/other/path") // null
235
+ */
236
+ static toRelativePath(from, to, sep=path.sep) {
237
+ // If they're the same, just return ""
238
+ if(from === to)
239
+ return ""
240
+
241
+ const fromTrail = from.split(sep)
242
+ const toTrail = to.split(sep)
243
+ const overlapIndex = toTrail.findIndex(curr => curr === fromTrail.at(-1))
244
+
245
+ // If overlap is found, slice and join
246
+ if(overlapIndex !== -1) {
247
+ const relative = toTrail.slice(overlapIndex+1)
248
+
249
+ return relative.join(sep)
250
+ }
251
+
252
+ // If no overlap, we got nothing, soz.
253
+ return null
254
+ }
255
+
256
+ /**
257
+ * Find the common root path between two paths by identifying overlapping segments.
258
+ * Returns the portion of 'from' that matches up to the overlap point in 'to'.
259
+ *
260
+ * @static
261
+ * @param {string} from - The first path to compare
262
+ * @param {string} to - The second path to find common root with
263
+ * @param {string} [sep=path.sep] - The path separator to use (defaults to system separator)
264
+ * @returns {string|null} The common root path, the original path if identical, or null if no overlap found
265
+ * @throws {Sass} If from is not a non-empty string
266
+ * @throws {Sass} If to is not a non-empty string
267
+ * @example
268
+ * FS.getCommonRootPath("/projects/toolkit/src", "/projects/toolkit/tests") // "/projects/toolkit"
269
+ * FS.getCommonRootPath("/home/user", "/home/user") // "/home/user"
270
+ * FS.getCommonRootPath("/projects/app", "/other/path") // null
271
+ */
272
+ static getCommonRootPath(from, to, sep=path.sep) {
273
+ Valid.type(from, "String", {allowEmpty: false})
274
+ Valid.type(to, "String", {allowEmpty: false})
275
+
276
+ // If they're the same, just return one or t'other, tis no mattah
277
+ if(from === to)
278
+ return from
279
+
280
+ const fromTrail = from.split(sep)
281
+ const toTrail = to.split(sep)
282
+ const overlapIndex = toTrail.findLastIndex(
283
+ curr => curr === fromTrail.at(-1)
284
+ )
285
+
286
+ // If overlap is found, slice and join
287
+ if(overlapIndex !== -1) {
288
+ const relative = fromTrail.slice(0, overlapIndex+1)
289
+
290
+ return relative.join(sep)
291
+ }
292
+
293
+ // If no overlap, we got nothing, soz.
294
+ return null
295
+ }
296
+
297
+ /**
298
+ * @typedef {object} PathParts
299
+ * @property {string} base - The file name with extension
300
+ * @property {string} dir - The directory path
301
+ * @property {string} ext - The file extension (including dot)
302
+ */
303
+
304
+ /**
305
+ * Deconstruct a file or directory name into parts.
306
+ *
307
+ * @static
308
+ * @param {string} pathName - The file/directory name to deconstruct
309
+ * @returns {PathParts} The filename parts
310
+ * @throws {Sass} If not a string of more than 1 character
311
+ */
312
+ static pathParts(pathName) {
313
+ Valid.type(pathName, "String", {allowEmpty: false})
314
+
315
+ return path.parse(pathName)
316
+ }
317
+
318
+ /**
319
+ * Convert a virtual capped path to its real filesystem path.
320
+ * For capped objects, resolves the virtual path relative to the cap's real path.
321
+ * For uncapped objects, returns the path unchanged.
322
+ *
323
+ * @static
324
+ * @param {FileObject|DirectoryObject} fileOrDirectoryObject - The file or directory object to convert
325
+ * @returns {string} The real filesystem path
326
+ * @throws {Sass} If parameter is not a FileObject or DirectoryObject
327
+ * @example
328
+ * const temp = new TempDirectoryObject("myapp")
329
+ * const file = temp.getFile("/config.json")
330
+ * FS.virtualToRealPath(file) // "/tmp/myapp-ABC123/config.json"
331
+ *
332
+ * @example
333
+ * const regular = new FileObject("/home/user/file.txt")
334
+ * FS.virtualToRealPath(regular) // "/home/user/file.txt"
335
+ */
336
+ static virtualToRealPath(fileOrDirectoryObject) {
337
+ Valid.type(fileOrDirectoryObject, "FileObject|DirectoryObject")
338
+
339
+ let target, cap
340
+
341
+ if(fileOrDirectoryObject.isFile) {
342
+ if(!fileOrDirectoryObject.parent.isCapped) {
343
+ return fileOrDirectoryObject.path
344
+ } else {
345
+ target = fileOrDirectoryObject.path
346
+ cap = fileOrDirectoryObject.parent.cap.real.path
347
+ }
348
+ } else {
349
+ if(!fileOrDirectoryObject.isCapped) {
350
+ return fileOrDirectoryObject.path
351
+ } else {
352
+ target = fileOrDirectoryObject.path
353
+ cap = fileOrDirectoryObject.cap.real.path
354
+ }
355
+ }
356
+
357
+ return this.resolvePath(cap, target)
358
+ }
359
+
360
+ /**
361
+ * Convert an absolute path to a relative format by removing the root component.
362
+ * By default, keeps a leading separator (making it "absolute-like relative").
363
+ * Use forceActuallyRelative to get a truly relative path without leading separator.
364
+ *
365
+ * @static
366
+ * @param {string} pathToCheck - The path to convert (returned unchanged if already relative)
367
+ * @param {boolean} [forceActuallyRelative=false] - If true, removes leading separator for truly relative path
368
+ * @returns {string} The relative path (with or without leading separator based on forceActuallyRelative)
369
+ * @example
370
+ * FS.absoluteToRelative("/home/user/docs") // "/home/user/docs" (with leading /)
371
+ * FS.absoluteToRelative("/home/user/docs", true) // "home/user/docs" (truly relative)
372
+ * FS.absoluteToRelative("relative/path") // "relative/path" (unchanged)
373
+ */
374
+ static absoluteToRelative(pathToCheck, forceActuallyRelative=false) {
375
+ if(!path.isAbsolute(pathToCheck))
376
+ return pathToCheck
377
+
378
+ const {root} = this.pathParts(pathToCheck)
379
+ const sep = path.sep
380
+ const chopped = Data.chopLeft(pathToCheck, root)
381
+ const absolute = forceActuallyRelative
382
+ ? chopped
383
+ : Data.prepend(chopped, sep)
384
+
385
+ return absolute
235
386
  }
236
387
  }
@@ -70,65 +70,61 @@ export default class FileObject extends FS {
70
70
  isFile: true,
71
71
  isDirectory: false,
72
72
  parent: null,
73
+ parentPath: null,
73
74
  })
74
75
 
75
76
  /**
76
77
  * Constructs a FileObject instance.
77
78
  *
78
- * @param {string | FileObject} fileName - The file path or FileObject
79
+ * @param {string} fileName - The file path
79
80
  * @param {DirectoryObject|string|null} [parent] - The parent directory (object or string)
80
81
  */
81
82
  constructor(fileName, parent=null) {
82
83
  super()
83
84
 
84
- // If passed a FileObject, extract its path
85
- if(Data.isType(fileName, "FileObject"))
86
- fileName = fileName.path
87
-
88
- if(!fileName || typeof fileName !== "string" || fileName.length === 0)
89
- throw Sass.new("fileName must be a non-empty string")
85
+ Valid.type(fileName, "String", {allowEmpty: false})
86
+ Valid.type(parent, "Null|String|DirectoryObject", {allowEmpty: false})
90
87
 
91
88
  const fixedFile = FS.fixSlashes(fileName)
92
- const {dir,base,ext} = this.#deconstructFilenameToParts(fixedFile)
89
+ const {dir, base, ext} = FS.pathParts(fixedFile)
93
90
 
94
91
  const parentObject = (() => {
95
- switch(Data.typeOf(parent)) {
96
- case "String":
97
- return new DirectoryObject(parent)
98
- case "DirectoryObject":
99
- case "CappedDirectoryObject":
100
- case "TempDirectoryObject":
101
- return parent
102
- default:
103
- return new DirectoryObject(dir)
104
- }
105
- })()
92
+ if(Data.isType(parent, "String"))
93
+ return new DirectoryObject(parent)
106
94
 
107
- // Use real path if parent is capped, otherwise use path
108
- const parentPath = parentObject.realPath || parentObject.path
109
- const final = FS.resolvePath(parentPath ?? ".", fixedFile)
95
+ if(Data.isType(parent, "DirectoryObject"))
96
+ return parent
110
97
 
111
- const resolved = final
112
- const url = new URL(FS.pathToUri(resolved))
98
+ return new DirectoryObject(dir)
99
+ })()
113
100
 
114
- // Compute the actual parent directory from the resolved path
115
- const actualParentPath = path.dirname(resolved)
101
+ // If the parent is passed, we need to treat the fileName as relative,
102
+ // regardless of what you-know-who says.
103
+ const resolvedFilename = parent
104
+ ? FS.absoluteToRelative(fixedFile, true)
105
+ : fixedFile
116
106
 
117
- // If the file is directly in the provided parent directory, reuse that object
118
- // Otherwise, create a DirectoryObject for the actual parent directory
119
- // Use real path for comparison if parent is capped
120
- const parentRealPath = parentObject.realPath || parentObject.path
121
- const actualParent = parentObject && actualParentPath === parentRealPath
122
- ? parentObject
123
- : new DirectoryObject(actualParentPath)
107
+ // Use real path if parent is capped, otherwise use path
108
+ const parentPath = parentObject.real?.path || parentObject.path
109
+ const resolved = FS.resolvePath(parentPath ?? ".", resolvedFilename)
110
+ const {dir: actualParent} = FS.pathParts(resolved)
111
+ const url = new URL(FS.pathToUrl(resolved))
124
112
 
125
- this.#meta.supplied = fixedFile
113
+ this.#meta.supplied = fileName
126
114
  this.#meta.path = resolved
127
115
  this.#meta.url = url
128
116
  this.#meta.name = base
129
117
  this.#meta.extension = ext
130
118
  this.#meta.module = path.basename(this.supplied, this.extension)
131
- this.#meta.parent = actualParent
119
+ this.#meta.parentPath = actualParent
120
+ // Preserve capped parent or use actualParent path match
121
+ const useCappedParent =
122
+ parentObject.isCapped ||
123
+ FS.fixSlashes(actualParent) === FS.fixSlashes(parentObject.path)
124
+
125
+ this.#meta.parent = useCappedParent
126
+ ? parentObject
127
+ : new DirectoryObject(actualParent)
132
128
 
133
129
  Object.freeze(this.#meta)
134
130
  }
@@ -157,6 +153,7 @@ export default class FileObject extends FS {
157
153
  extension: this.extension,
158
154
  isFile: this.isFile,
159
155
  isDirectory: this.isDirectory,
156
+ parentPath: this.parentPath,
160
157
  parent: this.parent ? this.parent.path : null
161
158
  }
162
159
  }
@@ -190,7 +187,9 @@ export default class FileObject extends FS {
190
187
 
191
188
  /**
192
189
  * Returns the file path. If the parent is a capped directory, returns the
193
- * virtual path relative to the cap. Otherwise returns the real filesystem path.
190
+ * virtual path relative to the cap. Otherwise returns the real filesystem
191
+ * path.
192
+ *
194
193
  * Use `.real.path` to always get the actual filesystem path.
195
194
  *
196
195
  * @returns {string} The file path (virtual if parent is capped, real otherwise)
@@ -200,13 +199,14 @@ export default class FileObject extends FS {
200
199
  const parent = this.#meta.parent
201
200
 
202
201
  // If parent is capped, return virtual path
203
- if(parent?.capped) {
204
- const cap = parent.cap
202
+ if(parent?.isCapped) {
203
+ const cap = parent.cap.real.path
205
204
  const capResolved = path.resolve(cap)
206
- const relative = path.relative(capResolved, realPath)
205
+ const relativeRealPath = FS.absoluteToRelative(realPath)
206
+ const absolute = FS.resolvePath(capResolved, relativeRealPath)
207
207
 
208
208
  // Return with leading slash to indicate it's cap-relative
209
- return "/" + relative.split(path.sep).join("/")
209
+ return FS.absoluteToRelative(absolute)
210
210
  }
211
211
 
212
212
  // Otherwise return real path
@@ -220,12 +220,9 @@ export default class FileObject extends FS {
220
220
  * @returns {URL} The file URL (virtual if parent is capped, real otherwise)
221
221
  */
222
222
  get url() {
223
- const parent = this.#meta.parent
224
-
225
223
  // If parent is capped, return virtual URL
226
- if(parent?.capped) {
227
- return new URL(FS.pathToUri(this.path))
228
- }
224
+ if(this.parent?.isCapped)
225
+ return new URL(FS.pathToUrl(this.path))
229
226
 
230
227
  return this.#meta.url
231
228
  }
@@ -293,6 +290,10 @@ export default class FileObject extends FS {
293
290
  return this.#meta.parent
294
291
  }
295
292
 
293
+ get parentPath() {
294
+ return this.#meta.parentPath
295
+ }
296
+
296
297
  /**
297
298
  * Returns a plain FileObject representing the actual filesystem location.
298
299
  * This provides an "escape hatch" when working with capped directories,
@@ -310,7 +311,7 @@ export default class FileObject extends FS {
310
311
  * file.real.parent.parent // Can traverse outside the cap
311
312
  */
312
313
  get real() {
313
- return new FileObject(this.#meta.path)
314
+ return new FileObject(this.path)
314
315
  }
315
316
 
316
317
  /**
@@ -323,7 +324,7 @@ export default class FileObject extends FS {
323
324
  await fs.access(this.#meta.path, fs.constants.R_OK)
324
325
 
325
326
  return true
326
- } catch(_) {
327
+ } catch {
327
328
  return false
328
329
  }
329
330
  }
@@ -338,7 +339,7 @@ export default class FileObject extends FS {
338
339
  await fs.access(this.#meta.path, fs.constants.W_OK)
339
340
 
340
341
  return true
341
- } catch(_) {
342
+ } catch {
342
343
  return false
343
344
  }
344
345
  }
@@ -353,7 +354,7 @@ export default class FileObject extends FS {
353
354
  await fs.access(this.#meta.path, fs.constants.F_OK)
354
355
 
355
356
  return true
356
- } catch(_) {
357
+ } catch {
357
358
  return false
358
359
  }
359
360
  }
@@ -368,7 +369,7 @@ export default class FileObject extends FS {
368
369
  const stat = await fs.stat(this.#meta.path)
369
370
 
370
371
  return stat.size
371
- } catch(_) {
372
+ } catch {
372
373
  return null
373
374
  }
374
375
  }
@@ -384,31 +385,11 @@ export default class FileObject extends FS {
384
385
  const stat = await fs.stat(this.#meta.path)
385
386
 
386
387
  return stat.mtime
387
- } catch(_) {
388
+ } catch {
388
389
  return null
389
390
  }
390
391
  }
391
392
 
392
- /**
393
- * @typedef {object} FileParts
394
- * @property {string} base - The file name with extension
395
- * @property {string} dir - The directory path
396
- * @property {string} ext - The file extension (including dot)
397
- */
398
-
399
- /**
400
- * Deconstruct a filename into parts
401
- *
402
- * @param {string} fileName - The filename to deconstruct
403
- * @returns {FileParts} The filename parts
404
- */
405
- #deconstructFilenameToParts(fileName) {
406
- Valid.assert(typeof fileName === "string" && fileName.length > 0,
407
- "file must be a non-zero length string", 1)
408
-
409
- return path.parse(fileName)
410
- }
411
-
412
393
  /**
413
394
  * Reads the content of a file asynchronously.
414
395
  *
@@ -464,14 +445,30 @@ export default class FileObject extends FS {
464
445
  * await file.write(JSON.stringify({key: 'value'}))
465
446
  */
466
447
  async write(content, encoding="utf8") {
467
- if(!this.#meta.url)
468
- throw Sass.new("No URL in file")
448
+ const realPath = FS.virtualToRealPath(this)
449
+ if(!realPath)
450
+ throw Sass.new("No actual disk location detected.")
469
451
 
470
- if(await this.parent.exists)
471
- await fs.writeFile(this.#meta.url, content, encoding)
452
+ // On Windows, normalize the parent directory path to handle 8.3 short names
453
+ let pathToWrite = realPath
454
+ if(process.platform === "win32") {
455
+ try {
456
+ const parentPath = path.dirname(realPath)
457
+ const normalizedParent = await fs.realpath(parentPath)
458
+ pathToWrite = path.join(normalizedParent, path.basename(realPath))
459
+ } catch {
460
+ // If normalization fails, use original path
461
+ }
462
+ }
463
+
464
+ try {
465
+ await fs.writeFile(pathToWrite, content, encoding)
466
+ } catch(error) {
467
+ if(error.code === "ENOENT")
468
+ throw Sass.new(`Invalid directory: ${path.dirname(pathToWrite)}`)
472
469
 
473
- else
474
- throw Sass.new(`Invalid directory, ${this.parent.url.href}`)
470
+ throw Sass.from(error, "Failed to write file")
471
+ }
475
472
  }
476
473
 
477
474
  /**
@@ -491,7 +488,7 @@ export default class FileObject extends FS {
491
488
  * await file.writeBinary(buffer)
492
489
  */
493
490
  async writeBinary(data) {
494
- if(!this.#meta.url)
491
+ if(!this.url)
495
492
  throw Sass.new("No URL in file")
496
493
 
497
494
  const exists = await this.parent.exists
@@ -504,7 +501,7 @@ export default class FileObject extends FS {
504
501
 
505
502
  // According to the internet, if it's already binary, I don't need
506
503
  // an encoding. 🤷
507
- return await fs.writeFile(this.#meta.url, bufferData)
504
+ return await fs.writeFile(this.url, bufferData)
508
505
  }
509
506
 
510
507
  /**
@@ -555,7 +552,7 @@ export default class FileObject extends FS {
555
552
  * @returns {Promise<object>} The file contents as a module.
556
553
  */
557
554
  async import() {
558
- const url = this.#meta.url
555
+ const url = this.url
559
556
 
560
557
  if(!url)
561
558
  throw Sass.new("No URL in file map")
@@ -577,7 +574,7 @@ export default class FileObject extends FS {
577
574
  * await file.delete()
578
575
  */
579
576
  async delete() {
580
- const url = this.#meta.url
577
+ const url = this.url
581
578
 
582
579
  if(!url)
583
580
  throw Sass.new("This object does not represent a valid resource.")