@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.
@@ -10,11 +10,10 @@
10
10
 
11
11
  import path from "node:path"
12
12
 
13
- import {Data, Valid} from "../browser/index.js"
14
13
  import DirectoryObject from "./DirectoryObject.js"
15
14
  import FileObject from "./FileObject.js"
16
15
  import FS from "./FS.js"
17
- import Sass from "./Sass.js"
16
+ import Valid from "./Valid.js"
18
17
 
19
18
  /**
20
19
  * CappedDirectoryObject extends DirectoryObject with constraints that ensure
@@ -26,7 +25,10 @@ import Sass from "./Sass.js"
26
25
  * @augments DirectoryObject
27
26
  */
28
27
  export default class CappedDirectoryObject extends DirectoryObject {
28
+ #real
29
29
  #cap
30
+ #cappedParentPath
31
+ #cappedParent
30
32
 
31
33
  /**
32
34
  * Constructs a CappedDirectoryObject instance.
@@ -35,9 +37,8 @@ export default class CappedDirectoryObject extends DirectoryObject {
35
37
  * (virtual root). With a parent, the path is resolved relative to the parent's
36
38
  * cap using virtual path semantics (absolute paths treated as cap-relative).
37
39
  *
38
- * @param {string} [dirPath="."] - Directory path (becomes cap if no parent, else relative to parent's cap, defaults to current directory)
40
+ * @param {string} [directory="."] - Directory path (becomes cap if no parent, else relative to parent's cap, defaults to current directory)
39
41
  * @param {CappedDirectoryObject?} [parent] - Optional parent capped directory
40
- * @param {boolean} [temporary=false] - Whether this is a temporary directory
41
42
  * @throws {Sass} If parent is provided but not a CappedDirectoryObject
42
43
  * @throws {Sass} If the resulting path would escape the cap
43
44
  * @example
@@ -60,60 +61,35 @@ export default class CappedDirectoryObject extends DirectoryObject {
60
61
  * const config = new CappedDirectoryObject("/etc/config", cache)
61
62
  * // path: /home/user/.cache/etc/config, cap: /home/user/.cache
62
63
  */
63
- constructor(dirPath=".", parent=null, temporary=false) {
64
- Valid.type(dirPath, "String")
64
+ constructor(directory, source=null) {
65
+ Valid.type(source, "Null|CappedDirectoryObject")
65
66
 
66
- // Validate parent using inheritance-aware type checking
67
- if(parent !== null && !Data.isType(parent, "CappedDirectoryObject")) {
68
- throw Sass.new(`Parent must be null or a CappedDirectoryObject instance, got ${Data.typeOf(parent)}`)
69
- }
70
-
71
- let cap
72
- let resolvedPath
67
+ directory ||= "."
73
68
 
74
- if(!parent) {
75
- // No parent: dirPath becomes both the directory and the cap
76
- cap = path.resolve(dirPath)
77
- resolvedPath = cap
78
- } else {
79
- // With parent: inherit cap and resolve dirPath relative to it
80
- cap = parent.#cap
69
+ const baseLocalPath = source?.path ?? "/"
70
+ const baseRealPath = source?.real.path ?? directory
81
71
 
82
- // Use real path for filesystem operations
83
- const parentPath = parent.realPath || parent.path
84
- const capResolved = path.resolve(cap)
72
+ if(source && directory.startsWith("/"))
73
+ directory = directory.slice(1)
85
74
 
86
- let targetPath
75
+ // Find out what directory means to the basePath
76
+ const realResolved = FS.resolvePath(baseRealPath, directory)
77
+ const localResolved = source
78
+ ? FS.resolvePath(baseLocalPath, directory)
79
+ : path.parse(path.resolve("")).root
87
80
 
88
- // If absolute, treat as virtual path relative to cap (strip leading /)
89
- if(path.isAbsolute(dirPath)) {
90
- const relative = dirPath.replace(/^[/\\]+/, "")
91
- targetPath = relative ? path.join(capResolved, relative) : capResolved
92
- } else {
93
- // Relative path - resolve from parent directory
94
- targetPath = FS.resolvePath(parentPath, dirPath)
95
- }
81
+ super(localResolved)
96
82
 
97
- // Resolve to absolute path (handles .. and .)
98
- const resolved = path.resolve(targetPath)
83
+ this.#real = new DirectoryObject(realResolved)
84
+ this.#cap = source?.cap ?? this
99
85
 
100
- // Clamp to cap boundary - cannot escape above cap
101
- if(!resolved.startsWith(capResolved)) {
102
- // Path tried to escape - clamp to cap root
103
- resolvedPath = capResolved
104
- } else {
105
- resolvedPath = resolved
106
- }
86
+ if(source) {
87
+ this.#cappedParent = source
88
+ this.#cappedParentPath = source.path
89
+ } else {
90
+ this.#cappedParent = null
91
+ this.#cappedParentPath = null
107
92
  }
108
-
109
- // Call parent constructor with the path
110
- super(resolvedPath, temporary)
111
-
112
- // Store the cap AFTER calling super()
113
- this.#cap = cap
114
-
115
- // Validate that this path is within the cap
116
- this.#validateCapPath()
117
93
  }
118
94
 
119
95
  /**
@@ -133,95 +109,36 @@ export default class CappedDirectoryObject extends DirectoryObject {
133
109
  }
134
110
 
135
111
  /**
136
- * Validates that the directory path is within the cap directory tree.
112
+ * Indicates whether this directory is capped (constrained to a specific tree).
113
+ * Always returns true for CappedDirectoryObject instances.
137
114
  *
138
- * @private
139
- * @throws {Sass} If the path is not within the cap directory
140
- */
141
- #validateCapPath() {
142
- const cap = this.#cap
143
- const resolved = path.resolve(this.#realPath)
144
- const capResolved = path.resolve(cap)
145
-
146
- // Check if the resolved path starts with the cap directory
147
- if(!resolved.startsWith(capResolved)) {
148
- throw Sass.new(
149
- `Path '${this.#realPath}' is not within the cap directory '${cap}'`
150
- )
151
- }
152
- }
153
-
154
- /**
155
- * Re-caps this directory to itself, making it the new root of the capped tree.
156
- * This is a protected method intended for use by subclasses like TempDirectoryObject.
157
- *
158
- * @protected
159
- */
160
- _recapToSelf() {
161
- this.#cap = this.#realPath
162
- }
163
-
164
- /**
165
- * Returns the cap path for this directory.
166
- *
167
- * @returns {string} The cap directory path
168
- */
169
- get cap() {
170
- return this.#cap
171
- }
172
-
173
- /**
174
- * Returns whether this directory is capped.
115
+ * @returns {boolean} True for all CappedDirectoryObject instances
116
+ * @example
117
+ * const capped = new TempDirectoryObject("myapp")
118
+ * console.log(capped.isCapped) // true
175
119
  *
176
- * @returns {boolean} Always true for CappedDirectoryObject instances
120
+ * const regular = new DirectoryObject("/tmp")
121
+ * console.log(regular.isCapped) // false
177
122
  */
178
- get capped() {
123
+ get isCapped() {
179
124
  return true
180
125
  }
181
126
 
182
127
  /**
183
- * Returns the real filesystem path (for internal and subclass use).
184
- *
185
- * @protected
186
- * @returns {string} The actual filesystem path
187
- */
188
- get realPath() {
189
- return super.path
190
- }
191
-
192
- /**
193
- * Private alias for realPath (for use in private methods).
194
- *
195
- * @private
196
- * @returns {string} The actual filesystem path
197
- */
198
- get #realPath() {
199
- return this.realPath
200
- }
201
-
202
- /**
203
- * Returns the virtual path relative to the cap.
204
- * This is the default path representation in the capped environment.
205
- * Use `.real.path` to access the actual filesystem path.
128
+ * Returns the cap (root) of the capped directory tree.
129
+ * For root CappedDirectoryObject instances, returns itself.
130
+ * For children, returns the inherited cap from the parent chain.
206
131
  *
207
- * @returns {string} Path relative to cap, or "/" if at cap root
132
+ * @returns {CappedDirectoryObject} The cap directory object (root of the capped tree)
208
133
  * @example
209
134
  * const temp = new TempDirectoryObject("myapp")
210
- * const subdir = temp.getDirectory("data/cache")
211
- * console.log(subdir.path) // "/data/cache" (virtual, relative to cap)
212
- * console.log(subdir.real.path) // "/tmp/myapp-ABC123/data/cache" (actual filesystem)
135
+ * console.log(temp.cap === temp) // true (root is its own cap)
136
+ *
137
+ * const subdir = temp.getDirectory("data")
138
+ * console.log(subdir.cap === temp) // true (child inherits parent's cap)
213
139
  */
214
- get path() {
215
- const capResolved = path.resolve(this.#cap)
216
- const relative = path.relative(capResolved, this.#realPath)
217
-
218
- // If at cap root or empty, return "/"
219
- if(!relative || relative === ".") {
220
- return "/"
221
- }
222
-
223
- // Return with leading slash to indicate it's cap-relative
224
- return "/" + relative.split(path.sep).join("/")
140
+ get cap() {
141
+ return this.#cap
225
142
  }
226
143
 
227
144
  /**
@@ -243,7 +160,7 @@ export default class CappedDirectoryObject extends DirectoryObject {
243
160
  * subdir.real.parent // Can traverse outside the cap
244
161
  */
245
162
  get real() {
246
- return new DirectoryObject(this.#realPath)
163
+ return this.#real
247
164
  }
248
165
 
249
166
  /**
@@ -261,324 +178,23 @@ export default class CappedDirectoryObject extends DirectoryObject {
261
178
  * console.log(capped.parent) // null (at cap root)
262
179
  */
263
180
  get parent() {
264
- const capResolved = path.resolve(this.#cap)
265
-
266
- // If we're at the cap, return null (cap is the "root")
267
- if(this.#realPath === capResolved) {
268
- return null
269
- }
270
-
271
- // Compute parent's real path
272
- const parentPath = path.dirname(this.#realPath)
273
- const isRoot = parentPath === this.#realPath
274
-
275
- if(isRoot) {
276
- return null
277
- }
278
-
279
- // Compute relative path from current to parent (just "..")
280
- // Then use getDirectory to create the parent, which preserves the class type
281
- return this.getDirectory("..")
282
- }
283
-
284
- /**
285
- * Returns the URL with virtual path (cap-relative).
286
- *
287
- * @returns {URL} Virtual URL
288
- */
289
- get url() {
290
- return new URL(FS.pathToUri(this.path))
291
- }
292
-
293
- /**
294
- * Returns JSON representation with virtual paths and .real escape hatch.
295
- *
296
- * @returns {object} JSON representation
297
- */
298
- toJSON() {
299
- const capResolved = path.resolve(this.#cap)
300
- let parentPath
301
-
302
- if(this.#realPath === capResolved) {
303
- // At cap root, no parent
304
- parentPath = null
305
- } else {
306
- // Compute parent's virtual path
307
- const parentReal = path.dirname(this.#realPath)
308
- const relative = path.relative(capResolved, parentReal)
309
-
310
- // If parent is cap root or empty, return "/"
311
- if(!relative || relative === ".") {
312
- parentPath = "/"
313
- } else {
314
- // Return parent's virtual path with leading slash
315
- parentPath = "/" + relative.split(path.sep).join("/")
316
- }
317
- }
318
-
319
- return {
320
- supplied: this.supplied,
321
- path: this.path,
322
- url: this.url.toString(),
323
- name: this.name,
324
- module: this.module,
325
- extension: this.extension,
326
- isFile: this.isFile,
327
- isDirectory: this.isDirectory,
328
- parent: parentPath,
329
- root: this.root.path,
330
- real: this.real.toJSON()
331
- }
332
- }
333
-
334
- /**
335
- * Generator that walks up the directory tree, stopping at the cap.
336
- * Yields parent directories from current up to (and including) the cap root.
337
- *
338
- * @returns {Generator<DirectoryObject>} Generator yielding parent DirectoryObject instances
339
- * @example
340
- * const capped = new TempDirectoryObject("myapp")
341
- * const deep = capped.getDirectory("data").getDirectory("files")
342
- * for(const parent of deep.walkUp) {
343
- * console.log(parent.path)
344
- * // .../myapp-ABC123/data/files
345
- * // .../myapp-ABC123/data
346
- * // .../myapp-ABC123 (stops at cap)
347
- * }
348
- */
349
- *#walkUpCapped() {
350
- const capResolved = path.resolve(this.#cap)
351
-
352
- // Build trail from real path
353
- const trail = this.#realPath.split(path.sep).filter(Boolean)
354
- const curr = [...trail]
355
-
356
- while(curr.length > 0) {
357
- const joined = path.sep + curr.join(path.sep)
358
-
359
- // Don't yield anything beyond the cap
360
- if(!joined.startsWith(capResolved)) {
361
- break
362
- }
363
-
364
- // Yield plain DirectoryObject with real path
365
- yield new DirectoryObject(joined, this.temporary)
366
-
367
- // Stop after yielding the cap
368
- if(joined === capResolved) {
369
- break
370
- }
371
-
372
- curr.pop()
373
- }
374
- }
375
-
376
- /**
377
- * Returns a generator that walks up to the cap.
378
- *
379
- * @returns {Generator<DirectoryObject>} Generator yielding parent directories
380
- */
381
- get walkUp() {
382
- return this.#walkUpCapped()
383
- }
384
-
385
- /**
386
- * Creates a new CappedDirectoryObject by extending this directory's path.
387
- *
388
- * All paths are coerced to remain within the cap directory tree:
389
- * - Absolute paths (e.g., "/foo") are treated as relative to the cap
390
- * - Parent traversal ("..") is allowed but clamped at the cap boundary
391
- * - The cap acts as the virtual root directory
392
- *
393
- * @param {string} newPath - The path to resolve (can be absolute or contain ..)
394
- * @returns {CappedDirectoryObject} A new CappedDirectoryObject with the coerced path
395
- * @example
396
- * const capped = new TempDirectoryObject("myapp")
397
- * const subDir = capped.getDirectory("data")
398
- * console.log(subDir.path) // "/tmp/myapp-ABC123/data"
399
- *
400
- * @example
401
- * // Absolute paths are relative to cap
402
- * const abs = capped.getDirectory("/foo/bar")
403
- * console.log(abs.path) // "/tmp/myapp-ABC123/foo/bar"
404
- *
405
- * @example
406
- * // Excessive .. traversal clamps to cap
407
- * const up = capped.getDirectory("../../../etc/passwd")
408
- * console.log(up.path) // "/tmp/myapp-ABC123" (clamped to cap)
409
- */
410
- getDirectory(newPath) {
411
- Valid.type(newPath, "String")
412
-
413
- // Fast path: if it's a simple name (no separators, not absolute, no ..)
414
- // use the subclass constructor directly to preserve type
415
- const isSimpleName = !path.isAbsolute(newPath) &&
416
- !newPath.includes("/") &&
417
- !newPath.includes("\\") &&
418
- !newPath.includes("..")
419
-
420
- if(isSimpleName) {
421
- // Both CappedDirectoryObject and subclasses use same signature now
422
- return new this.constructor(newPath, this, this.temporary)
423
- }
424
-
425
- // Complex path - handle coercion
426
- const capResolved = path.resolve(this.#cap)
427
- let targetPath
428
-
429
- // If absolute, treat as relative to cap (virtual root)
430
- if(path.isAbsolute(newPath)) {
431
- // Strip leading slashes to make relative
432
- const relative = newPath.replace(/^[/\\]+/, "")
433
-
434
- // Join with cap (unless empty, which means cap root)
435
- targetPath = relative ? path.join(capResolved, relative) : capResolved
436
- } else {
437
- // Relative path - resolve from current directory
438
- targetPath = FS.resolvePath(this.#realPath, newPath)
439
- }
440
-
441
- // Resolve to absolute path (handles .. and .)
442
- const resolved = path.resolve(targetPath)
443
-
444
- // Coerce: if path escaped cap, clamp to cap boundary
445
- const coerced = resolved.startsWith(capResolved)
446
- ? resolved
447
- : capResolved
448
-
449
- // Compute path relative to cap for reconstruction
450
- const relativeToCap = path.relative(capResolved, coerced)
451
-
452
- // If we're at the cap root, return cap root directory
453
- if(!relativeToCap || relativeToCap === ".") {
454
- return this.#createCappedAtRoot()
455
- }
456
-
457
- // Build directory by traversing segments from cap
458
- return this.#buildDirectoryFromRelativePath(relativeToCap)
459
- }
460
-
461
- /**
462
- * Creates a CappedDirectoryObject at the cap root.
463
- * Can be overridden by subclasses that have different root semantics.
464
- *
465
- * @private
466
- * @returns {CappedDirectoryObject} Directory object at cap root
467
- */
468
- #createCappedAtRoot() {
469
- // Create a base CappedDirectoryObject at the cap path
470
- // This works for direct usage of CappedDirectoryObject
471
- // Subclasses may need to override if they have special semantics
472
- return new CappedDirectoryObject(this.#cap, null, this.temporary)
473
- }
474
-
475
- /**
476
- * Builds a directory by traversing path segments from cap.
477
- *
478
- * @private
479
- * @param {string} relativePath - Path relative to cap
480
- * @returns {CappedDirectoryObject} The directory at the final path
481
- */
482
- #buildDirectoryFromRelativePath(relativePath) {
483
- const segments = relativePath.split(path.sep).filter(Boolean)
484
-
485
- // Start at cap root
486
- let current = this.#createCappedAtRoot()
487
-
488
- // Traverse each segment, using constructor to preserve class type
489
- for(const segment of segments) {
490
- // Use simple name constructor to preserve subclass type
491
- // Works for both CappedDirectoryObject and TempDirectoryObject
492
- current = new this.constructor(segment, current, this.temporary)
493
- }
494
-
495
- return current
181
+ return this.#cappedParent
496
182
  }
497
183
 
498
184
  /**
499
- * Creates a new FileObject by extending this directory's path.
500
- *
501
- * All paths are coerced to remain within the cap directory tree:
502
- * - Absolute paths (e.g., "/config.json") are treated as relative to the cap
503
- * - Parent traversal ("..") is allowed but clamped at the cap boundary
504
- * - The cap acts as the virtual root directory
505
- *
506
- * @param {string} filename - The filename to resolve (can be absolute or contain ..)
507
- * @returns {FileObject} A new FileObject with the coerced path
508
- * @example
509
- * const capped = new TempDirectoryObject("myapp")
510
- * const file = capped.getFile("config.json")
511
- * console.log(file.path) // "/tmp/myapp-ABC123/config.json"
185
+ * Returns the path of the parent directory.
186
+ * Returns null if this directory is at the cap root (no parent).
512
187
  *
188
+ * @returns {string|null} The parent directory path, or null if at cap root
513
189
  * @example
514
- * // Absolute paths are relative to cap
515
- * const abs = capped.getFile("/data/config.json")
516
- * console.log(abs.path) // "/tmp/myapp-ABC123/data/config.json"
190
+ * const temp = new TempDirectoryObject("myapp")
191
+ * console.log(temp.parentPath) // null (at cap root)
517
192
  *
518
- * @example
519
- * // Excessive .. traversal clamps to cap
520
- * const up = capped.getFile("../../../etc/passwd")
521
- * console.log(up.path) // "/tmp/myapp-ABC123/passwd" (clamped to cap)
193
+ * const subdir = temp.getDirectory("data")
194
+ * console.log(subdir.parentPath) // "/data" or similar (parent's virtual path)
522
195
  */
523
- getFile(filename) {
524
- Valid.type(filename, "String")
525
-
526
- // Fast path: if it's a simple filename (no separators, not absolute, no ..)
527
- // use this as the parent directly
528
- const isSimpleName = !path.isAbsolute(filename) &&
529
- !filename.includes("/") &&
530
- !filename.includes("\\") &&
531
- !filename.includes("..")
532
-
533
- if(isSimpleName) {
534
- // Simple filename - create directly with this as parent
535
- return new FileObject(filename, this)
536
- }
537
-
538
- // Complex path - handle coercion
539
- const capResolved = path.resolve(this.#cap)
540
- let targetPath
541
-
542
- // If absolute, treat as relative to cap (virtual root)
543
- if(path.isAbsolute(filename)) {
544
- // Strip leading slashes to make relative
545
- const relative = filename.replace(/^[/\\]+/, "")
546
-
547
- // Join with cap
548
- targetPath = path.join(capResolved, relative)
549
- } else {
550
- // Relative path - resolve from current directory
551
- targetPath = FS.resolvePath(this.#realPath, filename)
552
- }
553
-
554
- // Resolve to absolute path (handles .. and .)
555
- const resolved = path.resolve(targetPath)
556
-
557
- // Coerce: if path escaped cap, clamp to cap boundary
558
- const coerced = resolved.startsWith(capResolved)
559
- ? resolved
560
- : capResolved
561
-
562
- // Extract directory and filename parts
563
- let fileDir = path.dirname(coerced)
564
- let fileBasename = path.basename(coerced)
565
-
566
- // Special case: if coerced is exactly the cap (file tried to escape),
567
- // the file should be placed at the cap root with just the filename
568
- if(coerced === capResolved) {
569
- // Extract just the filename from the original path
570
- fileBasename = path.basename(resolved)
571
- fileDir = capResolved
572
- }
573
-
574
- // Get or create the parent directory
575
- const relativeToCap = path.relative(capResolved, fileDir)
576
- const parentDir = !relativeToCap || relativeToCap === "."
577
- ? this.#createCappedAtRoot()
578
- : this.#buildDirectoryFromRelativePath(relativeToCap)
579
-
580
- // Create FileObject with parent directory
581
- return new FileObject(fileBasename, parentDir)
196
+ get parentPath() {
197
+ return this.#cappedParentPath
582
198
  }
583
199
 
584
200
  /**
@@ -596,20 +212,14 @@ export default class CappedDirectoryObject extends DirectoryObject {
596
212
  * @param {string} [pat=""] - Optional glob pattern
597
213
  * @returns {Promise<{files: Array<FileObject>, directories: Array}>} Directory contents
598
214
  */
599
- async read(pat="") {
600
- const {files, directories} = await this.real.read(pat)
601
-
602
- // Convert plain DirectoryObjects to CappedDirectoryObjects with same cap
603
- const cappedDirectories = directories.map(dir => {
604
- const name = dir.name
605
-
606
- return new this.constructor(name, this)
607
- })
215
+ async read(...arg) {
216
+ const {files, directories} = await this.real.read(...arg)
608
217
 
609
- // Recreate FileObjects with capped parent so they return virtual paths
610
- const cappedFiles = files.map(file => new FileObject(file.name, this))
218
+ // we need to re-cast
219
+ const recastDirs = directories.map(e => this.getDirectory(e.name))
220
+ const recastFiles = files.map(f => new FileObject(f.name, this))
611
221
 
612
- return {files: cappedFiles, directories: cappedDirectories}
222
+ return {files: recastFiles, directories: recastDirs}
613
223
  }
614
224
 
615
225
  /**
@@ -640,36 +250,4 @@ export default class CappedDirectoryObject extends DirectoryObject {
640
250
  async delete() {
641
251
  return await this.real.delete()
642
252
  }
643
-
644
- /**
645
- * Override remove to preserve temporary flag check.
646
- *
647
- * @returns {Promise<void>}
648
- */
649
- async remove() {
650
- if(!this.temporary)
651
- throw Sass.new("This is not a temporary directory.")
652
-
653
- const {files, directories} = await this.read()
654
-
655
- // Remove subdirectories recursively
656
- for(const dir of directories)
657
- await dir.remove()
658
-
659
- // Remove files
660
- for(const file of files)
661
- await file.delete()
662
-
663
- // Delete the now-empty directory
664
- await this.delete()
665
- }
666
-
667
- /**
668
- * Returns a string representation of the CappedDirectoryObject.
669
- *
670
- * @returns {string} string representation of the CappedDirectoryObject
671
- */
672
- toString() {
673
- return `[CappedDirectoryObject: ${this.path} (real: ${this.#realPath})]`
674
- }
675
253
  }