@gesslar/toolkit 0.3.0 → 0.5.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/Data.js CHANGED
@@ -160,7 +160,7 @@ export default class Data {
160
160
  // Special cases that need extra validation
161
161
  switch(valueType) {
162
162
  case "Number":
163
- return valueType === "Number" && !isNaN(value) // Excludes NaN
163
+ return type === "Number" && !isNaN(value) // Excludes NaN
164
164
  default:
165
165
  return valueType === type
166
166
  }
@@ -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/Glog.js CHANGED
@@ -1,7 +1,8 @@
1
- import Data from "./Data.js"
2
- import Util from "./Util.js"
3
1
  import c from "@gesslar/colours"
2
+
3
+ import Data from "./Data.js"
4
4
  import Term from "./Term.js"
5
+ import Util from "./Util.js"
5
6
  // ErrorStackParser will be dynamically imported when needed
6
7
 
7
8
  /**
@@ -0,0 +1,194 @@
1
+ import {setTimeout as timeout} from "timers/promises"
2
+
3
+ import FileObject from "./FileObject.js"
4
+ import Sass from "./Sass.js"
5
+ import Util from "./Util.js"
6
+ import Valid from "./Valid.js"
7
+
8
+ /**
9
+ * Generic base class for managing hooks with configurable event types.
10
+ * Provides common functionality for hook registration, execution, and lifecycle management.
11
+ * Designed to be extended by specific implementations.
12
+ */
13
+ export default class Hooks {
14
+ #hooksFile = null
15
+ #hooks = null
16
+ #actionKind = null
17
+ #timeout = 1000 // Default 1 second timeout
18
+ #debug = null
19
+
20
+ /**
21
+ * Creates a new BaseHookManager instance.
22
+ *
23
+ * @param {object} config - Configuration object
24
+ * @param {string} config.actionKind - Action identifier
25
+ * @param {FileObject} config.hooksFile - File object containing hooks with uri property
26
+ * @param {number} [config.hookTimeout] - Hook execution timeout in milliseconds
27
+ * @param {unknown} [config.hooks] - The hooks object
28
+ * @param {import('../types.js').DebugFunction} debug - Debug function from Glog.
29
+ */
30
+ constructor({actionKind, hooksFile, hooks, hookTimeout = 1000}, debug) {
31
+ this.#actionKind = actionKind
32
+ this.#hooksFile = hooksFile
33
+ this.#hooks = hooks
34
+ this.#timeout = hookTimeout
35
+ this.#debug = debug
36
+ }
37
+
38
+ /**
39
+ * Gets the action identifier.
40
+ *
41
+ * @returns {string} Action identifier or instance
42
+ */
43
+ get actionKind() {
44
+ return this.#actionKind
45
+ }
46
+
47
+ /**
48
+ * Gets the hooks file object.
49
+ *
50
+ * @returns {FileObject} File object containing hooks
51
+ */
52
+ get hooksFile() {
53
+ return this.#hooksFile
54
+ }
55
+
56
+ /**
57
+ * Gets the loaded hooks object.
58
+ *
59
+ * @returns {object|null} Hooks object or null if not loaded
60
+ */
61
+ get hooks() {
62
+ return this.#hooks
63
+ }
64
+
65
+ /**
66
+ * Gets the hook execution timeout in milliseconds.
67
+ *
68
+ * @returns {number} Timeout in milliseconds
69
+ */
70
+ get timeout() {
71
+ return this.#timeout
72
+ }
73
+
74
+ /**
75
+ * Gets the setup hook function if available.
76
+ *
77
+ * @returns {(args: object) => unknown|null} Setup hook function or null
78
+ */
79
+ get setup() {
80
+ return this.hooks?.setup || null
81
+ }
82
+
83
+ /**
84
+ * Gets the cleanup hook function if available.
85
+ *
86
+ * @returns {(args: object) => unknown|null} Cleanup hook function or null
87
+ */
88
+ get cleanup() {
89
+ return this.hooks?.cleanup || null
90
+ }
91
+
92
+ /**
93
+ * Static factory method to create and initialize a hook manager.
94
+ * Loads hooks from the specified file and returns an initialized instance.
95
+ * Override loadHooks() in subclasses to customize hook loading logic.
96
+ *
97
+ * @param {object} config - Same configuration object as constructor
98
+ * @param {string|object} config.actionKind - Action identifier or instance
99
+ * @param {FileObject} config.hooksFile - File object containing hooks with uri property
100
+ * @param {number} [config.timeOut] - Hook execution timeout in milliseconds
101
+ * @param {import('../types.js').DebugFunction} debug - The debug function.
102
+ * @returns {Promise<Hooks|null>} Initialized hook manager or null if no hooks found
103
+ */
104
+ static async new(config, debug) {
105
+ debug("Creating new HookManager instance with args: %o", 2, config)
106
+
107
+ const instance = new this(config, debug)
108
+ const hooksFile = instance.hooksFile
109
+
110
+ debug("Loading hooks from %o", 2, hooksFile.uri)
111
+
112
+ debug("Checking hooks file exists: %o", 2, hooksFile.uri)
113
+ if(!await hooksFile.exists)
114
+ throw Sass.new(`No such hooks file, ${hooksFile.uri}`)
115
+
116
+ try {
117
+ const hooksImport = await hooksFile.import()
118
+
119
+ if(!hooksImport)
120
+ return null
121
+
122
+ debug("Hooks file imported successfully as a module", 2)
123
+
124
+ const actionKind = instance.actionKind
125
+ if(!hooksImport[actionKind])
126
+ return null
127
+
128
+ const hooks = new hooksImport[actionKind]({debug})
129
+
130
+ debug(hooks.constructor.name, 4)
131
+
132
+ // Attach common properties to hooks
133
+ instance.#hooks = hooks
134
+
135
+ debug("Hooks %o loaded successfully for %o", 2, hooksFile.uri, instance.actionKind)
136
+
137
+ return instance
138
+ } catch(error) {
139
+ debug("Failed to load hooks %o: %o", 1, hooksFile.uri, error.message)
140
+
141
+ return null
142
+ }
143
+ }
144
+
145
+ async callHook(kind, activityName, context) {
146
+ try {
147
+ const debug = this.#debug
148
+ const hooks = this.#hooks
149
+
150
+ if(!hooks)
151
+ return
152
+
153
+ const hookName = `${kind}$${activityName}`
154
+
155
+ debug("Looking for hook: %o", 4, hookName)
156
+
157
+ const hook = hooks[hookName]
158
+ if(!hook)
159
+ return
160
+
161
+ debug("Triggering hook: %o", 4, hookName)
162
+ Valid.type(hook, "Function", `Hook "${hookName}" is not a function`)
163
+
164
+ const hookFunction = async() => {
165
+ debug("Hook function starting execution: %o", 4, hookName)
166
+
167
+ const duration = (await Util.time(() => hook.call(this.#hooks, context))).cost
168
+
169
+ debug("Hook function completed successfully: %o, after %oms", 4, hookName, duration)
170
+ }
171
+
172
+ const hookTimeout = this.timeout
173
+ const expireAsync = (async() => {
174
+ await timeout(hookTimeout)
175
+ throw Sass.new(`Hook ${hookName} execution exceeded timeout of ${hookTimeout}ms`)
176
+ })()
177
+
178
+ try {
179
+ debug("Starting Promise race for hook: %o", 4, hookName)
180
+ await Util.race([
181
+ hookFunction(),
182
+ expireAsync
183
+ ])
184
+ } catch(error) {
185
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
186
+ }
187
+
188
+ debug("We made it throoough the wildernessss", 4)
189
+
190
+ } catch(error) {
191
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
192
+ }
193
+ }
194
+ }