@gesslar/toolkit 3.9.0 → 3.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/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
  }