@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 +3 -2
- package/src/lib/ActionRunner.js +1 -1
- package/src/lib/BaseActionManager.js +246 -0
- package/src/lib/BaseHookManager.js +209 -0
- package/src/lib/DirectoryObject.js +94 -18
- package/src/lib/FileObject.js +39 -19
- package/src/lib/Sass.js +2 -4
- package/src/lib/Tantrum.js +4 -5
- package/src/types/DirectoryObject.d.ts +65 -2
- package/src/types/FileObject.d.ts +38 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gesslar/toolkit",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/lib/ActionRunner.js
CHANGED
|
@@ -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
|
|
221
|
-
|
|
222
|
-
|
|
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 =
|
|
234
|
-
.filter(
|
|
235
|
-
.map(
|
|
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] -
|
|
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
|
}
|
package/src/lib/FileObject.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
directory
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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}`
|
package/src/lib/Tantrum.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
/**
|