@gesslar/toolkit 0.2.9 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/toolkit",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
4
  "description": "Get in, bitches, we're going toolkitting.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -25,7 +25,8 @@
25
25
  "submit": "npm publish --access public",
26
26
  "update": "npx npm-check-updates -u && npm install",
27
27
  "test": "node --test tests/unit/*.test.js",
28
- "test:unit": "node --test tests/unit/*.test.js"
28
+ "test:unit": "node --test tests/unit/*.test.js",
29
+ "pr": "gt submit --publish --restack --ai"
29
30
  },
30
31
  "repository": {
31
32
  "type": "git",
@@ -19,7 +19,7 @@ export default class ActionRunner {
19
19
  constructor({action, build, logger}) {
20
20
  this.#action = action
21
21
  this.#build = build
22
- this.#logger = logger ?? Glog.newDebug()
22
+ this.#logger = logger ?? {newDebug: () => () => {}}
23
23
  }
24
24
 
25
25
  /**
@@ -0,0 +1,246 @@
1
+ import Data from "./Data.js"
2
+ import Sass from "./Sass.js"
3
+ import ActionBuilder from "./ActionBuilder.js"
4
+ import ActionRunner from "./ActionRunner.js"
5
+
6
+ /**
7
+ * Generic base class for managing actions with lifecycle hooks.
8
+ * Provides common functionality for action setup, execution, and cleanup.
9
+ * Designed to be extended by specific implementations.
10
+ */
11
+ export default class BaseActionManager {
12
+ #action = null
13
+ #hookManager = null
14
+ #contract = null
15
+ #log = null
16
+ #debug = null
17
+ #file = null
18
+ #variables = null
19
+ #runner = null
20
+ #id = null
21
+
22
+ /**
23
+ * @param {object} config - Configuration object
24
+ * @param {object} config.actionDefinition - Action definition with action, file, and contract
25
+ * @param {object} config.logger - Logger instance
26
+ * @param {object} [config.variables] - Variables to pass to action
27
+ */
28
+ constructor({actionDefinition, logger, variables}) {
29
+ this.#id = Symbol(performance.now())
30
+ this.#log = logger
31
+ this.#debug = this.#log.newDebug()
32
+ this.#variables = variables || {}
33
+
34
+ this.#initialize(actionDefinition)
35
+ }
36
+
37
+ get id() {
38
+ return this.#id
39
+ }
40
+
41
+ get action() {
42
+ return this.#action
43
+ }
44
+
45
+ get hookManager() {
46
+ return this.#hookManager
47
+ }
48
+
49
+ set hookManager(hookManager) {
50
+ if(this.hookManager)
51
+ throw new Error("Hook manager already set")
52
+
53
+ this.#hookManager = hookManager
54
+ this.#attachHooksToAction(hookManager)
55
+ }
56
+
57
+ get contract() {
58
+ return this.#contract
59
+ }
60
+
61
+ get meta() {
62
+ return this.#action?.meta
63
+ }
64
+
65
+ get log() {
66
+ return this.#log
67
+ }
68
+
69
+ get variables() {
70
+ return this.#variables
71
+ }
72
+
73
+ get runner() {
74
+ return this.#runner
75
+ }
76
+
77
+ get file() {
78
+ return this.#file
79
+ }
80
+
81
+ /**
82
+ * Initialize the action manager with the provided definition.
83
+ * Override in subclasses to add specific validation or setup.
84
+ *
85
+ * @param {object} actionDefinition - Action definition
86
+ * @protected
87
+ */
88
+ #initialize(actionDefinition) {
89
+ const debug = this.#debug
90
+
91
+ debug("Setting up action", 2)
92
+
93
+ const {action, file, contract} = actionDefinition
94
+
95
+ if(!action)
96
+ throw new Error("Action is required")
97
+
98
+ if(!contract)
99
+ throw new Error("Contract is required")
100
+
101
+ this.#action = action
102
+ this.#contract = contract
103
+ this.#file = file
104
+
105
+ debug("Action initialization complete", 2)
106
+ }
107
+
108
+ /**
109
+ * Attach hooks to the action instance.
110
+ * Override in subclasses to customize hook attachment.
111
+ *
112
+ * @param {object} hookManager - Hook manager instance
113
+ * @protected
114
+ */
115
+ #attachHooksToAction(hookManager) {
116
+ // Basic hook attachment - can be overridden by subclasses
117
+ this.action.hook = hookManager.on?.bind(hookManager)
118
+ this.action.hooks = hookManager.hooks
119
+ }
120
+
121
+ /**
122
+ * Setup the action by creating and configuring the runner.
123
+ * Override setupActionInstance() in subclasses for custom setup logic.
124
+ *
125
+ * @returns {Promise<void>}
126
+ */
127
+ async setupAction() {
128
+ this.#debug("Setting up action for %s on %s", 2, this.action.meta?.kind, this.id)
129
+
130
+ await this.#setupHooks()
131
+ await this.#setupActionInstance()
132
+ }
133
+
134
+ /**
135
+ * Setup the action instance and create the runner.
136
+ * Override in subclasses to customize action setup.
137
+ *
138
+ * @protected
139
+ */
140
+ async #setupActionInstance() {
141
+ const actionInstance = new this.action()
142
+ const setup = actionInstance?.setup
143
+
144
+ // Setup is required for actions.
145
+ if(Data.typeOf(setup) === "Function") {
146
+ const builder = new ActionBuilder(actionInstance)
147
+ const configuredBuilder = setup(builder)
148
+ const buildResult = configuredBuilder.build()
149
+ const runner = new ActionRunner({
150
+ action: buildResult.action,
151
+ build: buildResult.build,
152
+ logger: this.#log
153
+ })
154
+
155
+ this.#runner = runner
156
+ } else {
157
+ throw Sass.new("Action setup must be a function.")
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Run the action with the provided input.
163
+ *
164
+ * @param {unknown} result - Input to pass to the action
165
+ * @returns {Promise<unknown>} Action result
166
+ */
167
+ async runAction(result) {
168
+ if(!this.#runner)
169
+ throw new Error("Action not set up. Call setupAction() first.")
170
+
171
+ return await this.#runner.run(result)
172
+ }
173
+
174
+ /**
175
+ * Cleanup the action and hooks.
176
+ *
177
+ * @returns {Promise<void>}
178
+ */
179
+ async cleanupAction() {
180
+ this.#debug("Cleaning up action for %s on %s", 2, this.action.meta?.kind, this.id)
181
+
182
+ await this.#cleanupHooks()
183
+ await this.#cleanupActionInstance()
184
+ }
185
+
186
+ /**
187
+ * Setup hooks if hook manager is present.
188
+ * Override in subclasses to customize hook setup.
189
+ *
190
+ * @protected
191
+ */
192
+ async #setupHooks() {
193
+ const setup = this.#hookManager?.setup
194
+
195
+ const type = Data.typeOf(setup)
196
+
197
+ // No hooks attached.
198
+ if(type === "Null" || type === "Undefined")
199
+ return
200
+
201
+ if(type !== "Function")
202
+ throw Sass.new("Hook setup must be a function.")
203
+
204
+ await setup.call(
205
+ this.hookManager.hooks, {
206
+ action: this.action,
207
+ variables: this.#variables,
208
+ log: this.#log
209
+ }
210
+ )
211
+ }
212
+
213
+ /**
214
+ * Cleanup hooks if hook manager is present.
215
+ * Override in subclasses to customize hook cleanup.
216
+ *
217
+ * @protected
218
+ */
219
+ async #cleanupHooks() {
220
+ const cleanup = this.hookManager?.cleanup
221
+
222
+ if(!cleanup)
223
+ return
224
+
225
+ await cleanup.call(this.hookManager.hooks)
226
+ }
227
+
228
+ /**
229
+ * Cleanup the action instance.
230
+ * Override in subclasses to add custom cleanup logic.
231
+ *
232
+ * @protected
233
+ */
234
+ async #cleanupActionInstance() {
235
+ const cleanup = this.action?.cleanup
236
+
237
+ if(!cleanup)
238
+ return
239
+
240
+ await cleanup.call(this.action)
241
+ }
242
+
243
+ toString() {
244
+ return `${this.#file?.module || "UNDEFINED"} (${this.meta?.action || "UNDEFINED"})`
245
+ }
246
+ }
@@ -0,0 +1,209 @@
1
+ import {setTimeout as timeoutPromise} from "timers/promises"
2
+ import Collection from "./Collection.js"
3
+ import Data from "./Data.js"
4
+ import Sass from "./Sass.js"
5
+ import Valid from "./Valid.js"
6
+
7
+ /**
8
+ * Generic base class for managing hooks with configurable event types.
9
+ * Provides common functionality for hook registration, execution, and lifecycle management.
10
+ * Designed to be extended by specific implementations.
11
+ */
12
+ export default class BaseHookManager {
13
+ #hooksFile = null
14
+ #log = null
15
+ #hooks = null
16
+ #action = null
17
+ #timeout = 1000 // Default 1 second timeout
18
+ #allowedEvents = []
19
+
20
+ /**
21
+ * @param {object} config - Configuration object
22
+ * @param {string|object} config.action - Action identifier or instance
23
+ * @param {object} config.hooksFile - File object containing hooks
24
+ * @param {object} config.logger - Logger instance
25
+ * @param {number} [config.timeOut] - Hook execution timeout in milliseconds
26
+ * @param {string[]} [config.allowedEvents] - Array of allowed event types for validation
27
+ */
28
+ constructor({action, hooksFile, logger, timeOut = 1000, allowedEvents = []}) {
29
+ this.#action = action
30
+ this.#hooksFile = hooksFile
31
+ this.#log = logger
32
+ this.#timeout = timeOut
33
+ this.#allowedEvents = allowedEvents
34
+ }
35
+
36
+ get action() {
37
+ return this.#action
38
+ }
39
+
40
+ get hooksFile() {
41
+ return this.#hooksFile
42
+ }
43
+
44
+ get hooks() {
45
+ return this.#hooks
46
+ }
47
+
48
+ get log() {
49
+ return this.#log
50
+ }
51
+
52
+ get timeout() {
53
+ return this.#timeout
54
+ }
55
+
56
+ get allowedEvents() {
57
+ return this.#allowedEvents
58
+ }
59
+
60
+ get setup() {
61
+ return this.hooks?.setup || null
62
+ }
63
+
64
+ get cleanup() {
65
+ return this.hooks?.cleanup || null
66
+ }
67
+
68
+ /**
69
+ * Static factory method to create and initialize a hook manager.
70
+ * Override loadHooks() in subclasses to customize hook loading logic.
71
+ *
72
+ * @param {object} config - Same as constructor config
73
+ * @returns {Promise<BaseHookManager|null>} Initialized hook manager or null if no hooks found
74
+ */
75
+ static async new(config) {
76
+ const instance = new this(config)
77
+ const debug = instance.log.newDebug()
78
+
79
+ debug("Creating new HookManager instance with args: `%o`", 2, config)
80
+
81
+ const hooksFile = instance.hooksFile
82
+
83
+ debug("Loading hooks from `%s`", 2, hooksFile.uri)
84
+
85
+ debug("Checking hooks file exists: %j", 2, hooksFile)
86
+
87
+ try {
88
+ const hooksFileContent = await import(hooksFile.uri)
89
+
90
+ debug("Hooks file loaded successfully", 2)
91
+
92
+ if(!hooksFileContent)
93
+ throw new Error(`Hooks file is empty: ${hooksFile.uri}`)
94
+
95
+ const hooks = await instance.loadHooks(hooksFileContent)
96
+
97
+ if(Data.isEmpty(hooks))
98
+ return null
99
+
100
+ debug("Hooks found for action: `%s`", 2, instance.action)
101
+
102
+ if(!hooks)
103
+ return null
104
+
105
+ // Attach common properties to hooks
106
+ hooks.log = instance.log
107
+ hooks.timeout = instance.timeout
108
+ instance.#hooks = hooks
109
+
110
+ debug("Hooks loaded successfully for `%s`", 2, instance.action)
111
+
112
+ return instance
113
+ } catch(error) {
114
+ debug("Failed to load hooks: %s", 1, error.message)
115
+
116
+ return null
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Load hooks from the imported hooks file content.
122
+ * Override in subclasses to customize hook loading logic.
123
+ *
124
+ * @param {object} hooksFileContent - Imported hooks file content
125
+ * @returns {Promise<object|null>} Loaded hooks object or null if no hooks found
126
+ * @protected
127
+ */
128
+ async loadHooks(hooksFileContent) {
129
+ const hooks = hooksFileContent.default || hooksFileContent.Hooks
130
+
131
+ if(!hooks)
132
+ throw new Error(`\`${this.hooksFile.uri}\` contains no hooks.`)
133
+
134
+ // Default implementation: look for hooks by action name
135
+ const hooksObj = hooks[this.action]
136
+
137
+ return hooksObj || null
138
+ }
139
+
140
+ /**
141
+ * Trigger a hook by event name.
142
+ *
143
+ * @param {string} event - The type of hook to trigger
144
+ * @param {object} args - The hook arguments as an object
145
+ * @returns {Promise<unknown>} The result of the hook
146
+ */
147
+ async on(event, args) {
148
+ const debug = this.log.newDebug()
149
+
150
+ debug("Triggering hook for event `%s`", 4, event)
151
+
152
+ if(!event)
153
+ throw new Error("Event type is required for hook invocation")
154
+
155
+ // Validate event type if allowed events are configured
156
+ if(this.#allowedEvents.length > 0 && !this.#allowedEvents.includes(event))
157
+ throw new Error(`Invalid event type: ${event}. Allowed events: ${this.#allowedEvents.join(", ")}`)
158
+
159
+ const hook = this.hooks?.[event]
160
+
161
+ if(hook) {
162
+ Valid.type(hook, "function", `Hook "${event}" is not a function`)
163
+
164
+ const hookExecution = hook.call(this.hooks, args)
165
+ const hookTimeout = this.timeout
166
+
167
+ const expireAsync = () =>
168
+ timeoutPromise(
169
+ hookTimeout,
170
+ new Error(`Hook execution exceeded timeout of ${hookTimeout}ms`)
171
+ )
172
+
173
+ const result = await Promise.race([hookExecution, expireAsync()])
174
+
175
+ if(result?.status === "error")
176
+ throw Sass.new(result.error)
177
+
178
+ debug("Hook executed successfully for event: `%s`", 4, event)
179
+
180
+ return result
181
+ } else {
182
+ debug("No hook found for event: `%s`", 4, event)
183
+
184
+ return null
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Check if a hook exists for the given event.
190
+ *
191
+ * @param {string} event - Event name to check
192
+ * @returns {boolean} True if hook exists
193
+ */
194
+ hasHook(event) {
195
+ return !!(this.hooks?.[event])
196
+ }
197
+
198
+ /**
199
+ * Get all available hook events.
200
+ *
201
+ * @returns {string[]} Array of available hook event names
202
+ */
203
+ getAvailableEvents() {
204
+ return this.hooks ? Object.keys(this.hooks).filter(key =>
205
+ typeof this.hooks[key] === "function" &&
206
+ !["setup", "cleanup", "log", "timeout"].includes(key)
207
+ ) : []
208
+ }
209
+ }
@@ -48,6 +48,8 @@ export default class DirectoryObject extends FS {
48
48
  extension: null,
49
49
  isFile: false,
50
50
  isDirectory: true,
51
+ trail: null,
52
+ sep: null,
51
53
  })
52
54
 
53
55
  /**
@@ -63,6 +65,8 @@ export default class DirectoryObject extends FS {
63
65
  const fileUri = FS.pathToUri(absolutePath)
64
66
  const filePath = FS.uriToPath(fileUri)
65
67
  const baseName = path.basename(absolutePath) || "."
68
+ const trail = filePath.split(path.sep)
69
+ const sep = path.sep
66
70
 
67
71
  this.#meta.supplied = fixedDir
68
72
  this.#meta.path = filePath
@@ -70,6 +74,8 @@ export default class DirectoryObject extends FS {
70
74
  this.#meta.name = baseName
71
75
  this.#meta.extension = ""
72
76
  this.#meta.module = baseName
77
+ this.#meta.trail = trail
78
+ this.#meta.sep = sep
73
79
 
74
80
  Object.freeze(this.#meta)
75
81
  }
@@ -173,6 +179,27 @@ export default class DirectoryObject extends FS {
173
179
  return this.#meta.extension
174
180
  }
175
181
 
182
+ /**
183
+ * Returns the platform-specific path separator.
184
+ *
185
+ * @returns {string} The path separator ('/' on Unix, '\\' on Windows)
186
+ */
187
+ get sep() {
188
+ return this.#meta.sep
189
+ }
190
+
191
+ /**
192
+ * Returns the directory path split into segments.
193
+ *
194
+ * @returns {string[]} Array of path segments
195
+ * @example
196
+ * const dir = new DirectoryObject('/path/to/directory')
197
+ * console.log(dir.trail) // ['', 'path', 'to', 'directory']
198
+ */
199
+ get trail() {
200
+ return this.#meta.trail
201
+ }
202
+
176
203
  /**
177
204
  * Returns false. Because this is a directory.
178
205
  *
@@ -217,33 +244,29 @@ export default class DirectoryObject extends FS {
217
244
  {withFileTypes: true}
218
245
  )
219
246
 
220
- const results = await Promise.all(
221
- found.map(async dirent => {
222
- const fullPath = path.join(this.path, dirent.name)
223
- const stat = await fs.stat(fullPath)
224
-
225
- return {dirent, stat, fullPath}
226
- }),
227
- )
228
-
229
- const files = results
230
- .filter(({stat}) => stat.isFile())
231
- .map(({fullPath}) => new FileObject(fullPath))
247
+ const files = found
248
+ .filter(dirent => dirent.isFile())
249
+ .map(dirent => new FileObject(path.join(this.path, dirent.name)))
232
250
 
233
- const directories = results
234
- .filter(({stat}) => stat.isDirectory())
235
- .map(({fullPath}) => new DirectoryObject(fullPath))
251
+ const directories = found
252
+ .filter(dirent => dirent.isDirectory())
253
+ .map(dirent => new DirectoryObject(path.join(this.path, dirent.name)))
236
254
 
237
255
  return {files, directories}
238
256
  }
239
257
 
240
258
  /**
241
- * Ensures a directory exists, creating it if necessary
259
+ * Ensures a directory exists, creating it if necessary.
260
+ * Gracefully handles the case where the directory already exists.
242
261
  *
243
262
  * @async
244
- * @param {object} [options] - Any options to pass to mkdir
263
+ * @param {object} [options] - Options to pass to fs.mkdir (e.g., {recursive: true, mode: 0o755})
245
264
  * @returns {Promise<void>}
246
- * @throws {Sass} If directory creation fails
265
+ * @throws {Sass} If directory creation fails for reasons other than already existing
266
+ * @example
267
+ * // Create directory recursively
268
+ * const dir = new DirectoryObject('./build/output')
269
+ * await dir.assureExists({recursive: true})
247
270
  */
248
271
  async assureExists(options = {}) {
249
272
  if(await this.exists)
@@ -252,7 +275,60 @@ export default class DirectoryObject extends FS {
252
275
  try {
253
276
  await fs.mkdir(this.path, options)
254
277
  } catch(e) {
278
+ if(e.code === "EEXIST") {
279
+ // Directory already exists, ignore
280
+ return
281
+ }
282
+
255
283
  throw Sass.new(`Unable to create directory '${this.path}': ${e.message}`)
256
284
  }
257
285
  }
286
+
287
+ /**
288
+ * Private generator that walks up the directory tree.
289
+ *
290
+ * @private
291
+ * @generator
292
+ * @yields {DirectoryObject} Parent directory objects from current to root
293
+ */
294
+ *#walkUp() {
295
+ if(!Array.isArray(this.trail))
296
+ return
297
+
298
+ const curr = structuredClone(this.trail)
299
+
300
+ while(curr.length > 0) {
301
+ const joined = curr.join(this.sep)
302
+
303
+ // Stop if we've reached an empty path (which would resolve to CWD)
304
+ if(joined === "" || joined === this.sep) {
305
+ // Yield the root and stop
306
+ yield new DirectoryObject(this.sep)
307
+ break
308
+ }
309
+
310
+ yield new DirectoryObject(joined)
311
+ curr.pop()
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Generator that walks up the directory tree, yielding each parent directory.
317
+ * Starts from the current directory and yields each parent until reaching the root.
318
+ *
319
+ * @returns {object} Generator yielding parent DirectoryObject instances
320
+ * @example
321
+ * const dir = new DirectoryObject('/path/to/deep/directory')
322
+ * for(const parent of dir.walkUp) {
323
+ * console.log(parent.path)
324
+ * // /path/to/deep/directory
325
+ * // /path/to/deep
326
+ * // /path/to
327
+ * // /path
328
+ * // /
329
+ * }
330
+ */
331
+ get walkUp() {
332
+ return this.#walkUp()
333
+ }
258
334
  }
@@ -10,6 +10,7 @@ import path from "node:path"
10
10
  import util from "node:util"
11
11
  import YAML from "yaml"
12
12
 
13
+ import Data from "./Data.js"
13
14
  import DirectoryObject from "./DirectoryObject.js"
14
15
  import FS from "./FS.js"
15
16
  import Sass from "./Sass.js"
@@ -87,13 +88,18 @@ export default class FileObject extends FS {
87
88
 
88
89
  const {dir,base,ext} = this.#deconstructFilenameToParts(fixedFile)
89
90
 
90
- if(!directory) {
91
- directory = new DirectoryObject(dir)
92
- } else if(typeof directory === "string") {
93
- directory = new DirectoryObject(directory)
94
- }
91
+ const directoryObject = (() => {
92
+ switch(Data.typeOf(directory)) {
93
+ case "String":
94
+ return new DirectoryObject(directory)
95
+ case "DirectoryObject":
96
+ return directory
97
+ default:
98
+ return new DirectoryObject(dir)
99
+ }
100
+ })()
95
101
 
96
- const final = FS.resolvePath(directory?.path ?? ".", fixedFile)
102
+ const final = FS.resolvePath(directoryObject.path ?? ".", fixedFile)
97
103
 
98
104
  const resolved = final
99
105
  const fileUri = FS.pathToUri(resolved)
@@ -104,10 +110,7 @@ export default class FileObject extends FS {
104
110
  this.#meta.name = base
105
111
  this.#meta.extension = ext
106
112
  this.#meta.module = path.basename(this.supplied, this.extension)
107
-
108
- const {dir: newDir} = this.#deconstructFilenameToParts(this.path)
109
-
110
- this.#meta.directory = new DirectoryObject(newDir)
113
+ this.#meta.directory = directoryObject
111
114
 
112
115
  Object.freeze(this.#meta)
113
116
  }
@@ -363,29 +366,46 @@ export default class FileObject extends FS {
363
366
  }
364
367
 
365
368
  /**
366
- * Writes content to a file synchronously.
369
+ * Writes content to a file asynchronously.
370
+ * Validates that the parent directory exists before writing.
367
371
  *
368
372
  * @param {string} content - The content to write
369
- * @param {string} encoding - The encoding in which to write.
373
+ * @param {string} [encoding] - The encoding in which to write (default: "utf8")
370
374
  * @returns {Promise<void>}
375
+ * @throws {Sass} If the file path is invalid or the parent directory doesn't exist
376
+ * @example
377
+ * const file = new FileObject('./output/data.json')
378
+ * await file.write(JSON.stringify({key: 'value'}))
371
379
  */
372
380
  async write(content, encoding="utf8") {
373
381
  if(!this.path)
374
382
  throw Sass.new("No absolute path in file")
375
383
 
376
- await fs.writeFile(this.path, content, encoding)
384
+ if(await this.directory.exists)
385
+ await fs.writeFile(this.path, content, encoding)
386
+
387
+ else
388
+ throw Sass.new(`Invalid directory, ${this.directory.uri}`)
377
389
  }
378
390
 
379
391
  /**
380
- * Loads an object from JSON or YAML provided a fileMap
392
+ * Loads an object from JSON or YAML file.
393
+ * Attempts to parse content as JSON5 first, then falls back to YAML if specified.
381
394
  *
382
- * @param {string} [type] - The expected type of data to parse.
383
- * @param {string} [encoding] - The encoding to read the file as.
384
- * @returns {Promise<unknown>} The parsed data object.
395
+ * @param {string} [type] - The expected type of data to parse ("json", "json5", "yaml", or "any")
396
+ * @param {string} [encoding] - The encoding to read the file as (default: "utf8")
397
+ * @returns {Promise<unknown>} The parsed data object
398
+ * @throws {Sass} If the content cannot be parsed or type is unsupported
399
+ * @example
400
+ * const configFile = new FileObject('./config.json5')
401
+ * const config = await configFile.loadData('json5')
402
+ *
403
+ * // Auto-detect format
404
+ * const data = await configFile.loadData('any')
385
405
  */
386
406
  async loadData(type="any", encoding="utf8") {
387
407
  const content = await this.read(encoding)
388
- const normalizedType = type.toLocaleLowerCase()
408
+ const normalizedType = type.toLowerCase()
389
409
  const toTry = {
390
410
  json5: [JSON5],
391
411
  json: [JSON5],
@@ -394,7 +414,7 @@ export default class FileObject extends FS {
394
414
  }[normalizedType]
395
415
 
396
416
  if(!toTry) {
397
- throw Sass.new(`Unsupported data type '${type}'. Supported types: json, json5, yaml, any`)
417
+ throw Sass.new(`Unsupported data type '${type}'. Supported types: json, json5, yaml.`)
398
418
  }
399
419
 
400
420
  for(const format of toTry) {
package/src/lib/Sass.js CHANGED
@@ -102,10 +102,8 @@ export default class Sass extends Error {
102
102
  * @returns {string|undefined} Formatted stack trace or undefined
103
103
  */
104
104
  #fullBodyMassage(stack) {
105
- // Remove the first line, it's already been reported
106
-
107
105
  stack = stack ?? ""
108
-
106
+ // Remove the first line, it's already been reported
109
107
  const {rest} = stack.match(/^.*?\n(?<rest>[\s\S]+)$/m)?.groups ?? {}
110
108
  const lines = []
111
109
 
@@ -114,7 +112,7 @@ export default class Sass extends Error {
114
112
  ...rest
115
113
  .split("\n")
116
114
  .map(line => {
117
- const at = line.match(/^\s{4}at\s(?<at>.*)$/)?.groups?.at ?? {}
115
+ const at = line.match(/^\s{4}at\s(?<at>.*)$/)?.groups?.at ?? ""
118
116
 
119
117
  return at
120
118
  ? `* ${at}`
@@ -26,13 +26,11 @@ export default class Tantrum extends AggregateError {
26
26
  constructor(message, errors = []) {
27
27
  // Auto-wrap plain errors in Sass, keep existing Sass instances
28
28
  const wrappedErrors = errors.map(error => {
29
- if(error instanceof Sass) {
29
+ if(error instanceof Sass)
30
30
  return error
31
- }
32
31
 
33
- if(!(error instanceof Error)) {
32
+ if(!(error instanceof Error))
34
33
  throw new TypeError(`All items in errors array must be Error instances, got: ${typeof error}`)
35
- }
36
34
 
37
35
  return Sass.new(error.message, error)
38
36
  })
@@ -51,8 +49,9 @@ export default class Tantrum extends AggregateError {
51
49
  this.message
52
50
  )
53
51
 
52
+ Term.error()
53
+
54
54
  this.errors.forEach(error => {
55
- Term.error("\n")
56
55
  error.report(nerdMode)
57
56
  })
58
57
  }
@@ -40,6 +40,12 @@ export default class DirectoryObject extends FS {
40
40
  /** The directory extension (usually empty) */
41
41
  readonly extension: string
42
42
 
43
+ /** The platform-specific path separator (e.g., '/' on Unix, '\\' on Windows) */
44
+ readonly sep: string
45
+
46
+ /** Array of directory path segments split by separator */
47
+ readonly trail: string[]
48
+
43
49
  /** Always false for directories */
44
50
  readonly isFile: false
45
51
 
@@ -49,6 +55,25 @@ export default class DirectoryObject extends FS {
49
55
  /** Whether the directory exists (async) */
50
56
  readonly exists: Promise<boolean>
51
57
 
58
+ /**
59
+ * Generator that walks up the directory tree, yielding parent directories.
60
+ * Starts from the current directory and yields each parent until reaching the root.
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * const dir = new DirectoryObject('/path/to/deep/directory')
65
+ * for (const parent of dir.walkUp) {
66
+ * console.log(parent.path)
67
+ * // /path/to/deep/directory
68
+ * // /path/to/deep
69
+ * // /path/to
70
+ * // /path
71
+ * // /
72
+ * }
73
+ * ```
74
+ */
75
+ readonly walkUp: Generator<DirectoryObject, void, unknown>
76
+
52
77
  /** Returns a string representation of the DirectoryObject */
53
78
  toString(): string
54
79
 
@@ -64,9 +89,47 @@ export default class DirectoryObject extends FS {
64
89
  isDirectory: boolean
65
90
  }
66
91
 
67
- /** List the contents of this directory */
92
+ /**
93
+ * Lists the contents of this directory.
94
+ * Returns FileObject instances for files and DirectoryObject instances for subdirectories.
95
+ *
96
+ * @returns Promise resolving to object with files and directories arrays
97
+ * @throws {Error} If directory cannot be read
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const dir = new DirectoryObject('./src')
102
+ * const {files, directories} = await dir.read()
103
+ *
104
+ * console.log(`Found ${files.length} files`)
105
+ * files.forEach(file => console.log(file.name))
106
+ *
107
+ * console.log(`Found ${directories.length} subdirectories`)
108
+ * directories.forEach(subdir => console.log(subdir.name))
109
+ * ```
110
+ */
68
111
  read(): Promise<DirectoryListing>
69
112
 
70
- /** Ensure this directory exists, creating it if necessary */
113
+ /**
114
+ * Ensures this directory exists, creating it if necessary.
115
+ * Gracefully handles the case where the directory already exists (EEXIST error).
116
+ * Pass options to control directory creation behavior (e.g., recursive, mode).
117
+ *
118
+ * @param options - Options to pass to fs.mkdir (e.g., {recursive: true, mode: 0o755})
119
+ * @returns Promise that resolves when directory exists or has been created
120
+ * @throws {Sass} If directory creation fails for reasons other than already existing
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * const dir = new DirectoryObject('./build/output')
125
+ *
126
+ * // Create directory recursively
127
+ * await dir.assureExists({recursive: true})
128
+ *
129
+ * // Now safe to write files
130
+ * const file = new FileObject('result.json', dir)
131
+ * await file.write(JSON.stringify(data))
132
+ * ```
133
+ */
71
134
  assureExists(options?: any): Promise<void>
72
135
  }
@@ -321,10 +321,46 @@ export default class FileObject extends FS {
321
321
  /** Read the content of a file */
322
322
  read(encoding?: string): Promise<string>
323
323
 
324
- /** Write content to a file */
324
+ /**
325
+ * Write content to a file asynchronously.
326
+ * Validates that the parent directory exists before writing.
327
+ *
328
+ * @param content - The content to write
329
+ * @param encoding - The encoding in which to write (default: "utf8")
330
+ * @throws {Sass} If the file path is invalid or the parent directory doesn't exist
331
+ *
332
+ * @example
333
+ * ```typescript
334
+ * const file = new FileObject('./output/data.json')
335
+ * await file.write(JSON.stringify({key: 'value'}))
336
+ *
337
+ * // With custom encoding
338
+ * await file.write('content', 'utf16le')
339
+ * ```
340
+ */
325
341
  write(content: string, encoding?: string): Promise<void>
326
342
 
327
- /** Load an object from JSON5 or YAML file with type specification */
343
+ /**
344
+ * Load and parse data from JSON5 or YAML file.
345
+ * Attempts to parse content as JSON5 first, then falls back to YAML if type is "any".
346
+ *
347
+ * @param type - The expected data format: "json", "json5", "yaml", or "any" (default: "any")
348
+ * @param encoding - The file encoding (default: "utf8")
349
+ * @returns The parsed data object
350
+ * @throws {Sass} If the content cannot be parsed or type is unsupported
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * // Load JSON5 config
355
+ * const config = await configFile.loadData('json5')
356
+ *
357
+ * // Auto-detect format (tries JSON5, then YAML)
358
+ * const data = await dataFile.loadData('any')
359
+ *
360
+ * // Load YAML explicitly
361
+ * const yaml = await yamlFile.loadData('yaml')
362
+ * ```
363
+ */
328
364
  loadData(type?: 'json' | 'json5' | 'yaml' | 'any', encoding?: string): Promise<unknown>
329
365
 
330
366
  /**