@gesslar/sassy 0.19.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/Session.js ADDED
@@ -0,0 +1,360 @@
1
+ import chokidar from "chokidar"
2
+
3
+ import Command from "./Command.js"
4
+ import Sass from "./Sass.js"
5
+ import File from "./File.js"
6
+ import Term from "./Term.js"
7
+ import Theme from "./Theme.js"
8
+ import Util from "./Util.js"
9
+
10
+ export default class Session {
11
+ #theme = null
12
+ #command = null
13
+ #options = null
14
+ #watcher = null
15
+ #history = []
16
+ #stats = Object.seal({builds: 0, failures: 0})
17
+ #building = false
18
+
19
+ get theme() {
20
+ return this.#theme
21
+ }
22
+
23
+ /**
24
+ * Creates a new Session instance for managing theme compilation lifecycle.
25
+ * Sessions provide persistent state across rebuilds, error tracking, and
26
+ * individual theme management within the build system.
27
+ *
28
+ * @param {Command} command - The parent build command instance
29
+ * @param {Theme} theme - The theme instance to manage
30
+ * @param {object} options - Build configuration options
31
+ * @param {boolean} [options.watch] - Whether to enable file watching
32
+ * @param {boolean} [options.nerd] - Whether to show verbose output
33
+ * @param {boolean} [options.dryRun] - Whether to skip file writes
34
+ */
35
+ constructor(command, theme, options) {
36
+ this.#command = command
37
+ this.#theme = theme
38
+ this.#options = options
39
+ }
40
+
41
+ async run() {
42
+ if(this.#options.watch) {
43
+ this.#command.emitter.on("closeSession", async() =>
44
+ await this.#handleCloseSession())
45
+ this.#command.emitter.on("rebuild", async() =>
46
+ await this.#handleRebuild())
47
+ this.#command.emitter.on("resetWatcher", async() =>
48
+ await this.#resetWatcher())
49
+
50
+ this.#command.emitter.on("recordBuildStart", arg =>
51
+ this.#recordBuildStart(arg))
52
+ this.#command.emitter.on("recordBuildFail", arg =>
53
+ this.#recordBuildFail(arg))
54
+
55
+ await this.#resetWatcher()
56
+ }
57
+
58
+ this.#building = true
59
+ await this.#command.asyncEmit("building")
60
+ this.#command.asyncEmit("recordBuildStart", this.#theme)
61
+ await this.#buildPipeline()
62
+ }
63
+
64
+ /**
65
+ * Runs the build pipeline for a theme: load, build, and write.
66
+ *
67
+ * @param {boolean} [forceWrite] - Forces a write of the theme, used by rebuild option (defaults to false)
68
+ * @returns {Promise<void>} Nuttin', honey.
69
+ */
70
+ async #buildPipeline(forceWrite=false) {
71
+ if(!this.#building)
72
+ return
73
+
74
+ this.#theme.reset()
75
+
76
+ const buildStart = Date.now()
77
+ let loadCost, buildCost, writeCost
78
+
79
+ try {
80
+ /**
81
+ * ****************************************************************
82
+ * Have the theme load itself.
83
+ * ****************************************************************
84
+ */
85
+
86
+ loadCost = (await Util.time(() => this.#theme.load())).cost
87
+ const bytes = await File.fileSize(this.#theme.sourceFile)
88
+ Term.status([
89
+ ["success", Util.rightAlignText(`${loadCost.toLocaleString()}ms`, 10), ["[","]"]],
90
+ `${this.#theme.name} loaded`,
91
+ ["info", `${bytes} bytes`, ["[","]"]]
92
+ ], this.#options)
93
+ /**
94
+ * ****************************************************************
95
+ * Have the theme build itself.
96
+ * ****************************************************************
97
+ */
98
+
99
+ buildCost = (await Util.time(() => this.#theme.build())).cost
100
+
101
+ const compileResult =
102
+ await Promise.allSettled(this.#theme.dependencies.map(async dep => {
103
+
104
+ return await (async fileObject => {
105
+ const fileName = File.relativeOrAbsolutePath(this.#command.cwd, fileObject)
106
+ const fileSize = await File.fileSize(fileObject)
107
+ return [fileName, fileSize]
108
+ })(dep)
109
+
110
+ }))
111
+
112
+ const rejected = compileResult.filter(result => result.status === "rejected")
113
+ if(rejected.length > 0) {
114
+ rejected.forEach(reject => Term.error(reject.reason))
115
+ throw new Error("Compilation failed")
116
+ }
117
+
118
+ const dependencies = compileResult.slice(1).map(dep => dep.value)
119
+ const totalBytes = compileResult.reduce((acc,curr) => acc + curr.value[1], 0)
120
+
121
+ Term.status([
122
+ ["success", Util.rightAlignText(`${buildCost.toLocaleString()}ms`, 10), ["[","]"]],
123
+ `${this.#theme.name} compiled`,
124
+ ["success", `${compileResult[0].value[1].toLocaleString()} bytes`, ["[","]"]],
125
+ ["info", `${totalBytes.toLocaleString()} total bytes`, ["(",")"]],
126
+ ], this.#options)
127
+
128
+ if(this.#options.nerd) {
129
+ dependencies.forEach(f => {
130
+ const [fileName,fileSize] = f
131
+
132
+ Term.status([
133
+ `${" ".repeat(13)}`,
134
+ ["muted", fileName],
135
+ ["muted", `${fileSize.toLocaleString()} bytes`, ["{","}"]]
136
+ ], this.#options)
137
+
138
+ })
139
+ }
140
+
141
+ /**
142
+ * ****************************************************************
143
+ * Lastly. Tom Riddle that shit into the IO! I would say just "O",
144
+ * but that wouldn't be very inclusive language. *I see you!*
145
+ * ****************************************************************
146
+ */
147
+
148
+ const writeResult = await Util.time(() => this.#theme.write(forceWrite))
149
+ writeCost = writeResult.cost
150
+ const result = writeResult.result
151
+ const {
152
+ status: writeStatus,
153
+ file: outputFile,
154
+ bytes: writeBytes
155
+ } = result
156
+ const outputFilename = File.relativeOrAbsolutePath(this.#command.cwd, outputFile)
157
+ const status = [
158
+ ["success", Util.rightAlignText(`${writeCost.toLocaleString()}ms`, 10), ["[","]"]],
159
+ ]
160
+
161
+ if(writeStatus === "written") {
162
+ status.push(
163
+ `${outputFilename} written`,
164
+ ["success", `${writeBytes.toLocaleString()} bytes`, ["[","]"]]
165
+ )
166
+ } else {
167
+ status.push(
168
+ `${outputFilename}`,
169
+ ["warn", writeStatus.toLocaleUpperCase(), ["[","]"]]
170
+ )
171
+ }
172
+
173
+ Term.status(status, this.#options)
174
+
175
+ // Track successful build
176
+ this.#command.asyncEmit("recordBuildSucceed", this.#theme)
177
+ this.#history.push({
178
+ timestamp: buildStart,
179
+ loadTime: loadCost,
180
+ buildTime: buildCost,
181
+ writeTime: writeCost,
182
+ success: true
183
+ })
184
+
185
+ } catch(error) {
186
+ // Track failed build
187
+ await this.#command.asyncEmit("recordBuildFail", this.#theme)
188
+ this.#history.push({
189
+ timestamp: buildStart,
190
+ loadTime: loadCost || 0,
191
+ buildTime: buildCost || 0,
192
+ writeTime: writeCost || 0,
193
+ success: false,
194
+ error: error.message
195
+ })
196
+
197
+ if(error instanceof Sass)
198
+ error.report(this.#options.nerd)
199
+ } finally {
200
+ this.#building = false
201
+ this.#command.asyncEmit("finishedBuilding")
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Handles a file change event and triggers a rebuild for the theme.
207
+ *
208
+ * @param {string} changed - Path to the changed file
209
+ * @returns {Promise<void>}
210
+ */
211
+ async #handleFileChange(changed) {
212
+ try {
213
+ if(this.#building)
214
+ return
215
+
216
+ this.#building = true
217
+ this.#command.asyncEmit("building")
218
+
219
+ const changedFile = this.#theme.dependencies.find(dep => dep.path === changed)
220
+
221
+ if(!changedFile)
222
+ return
223
+
224
+ const fileName = File.relativeOrAbsolutePath(this.#command.cwd, changedFile)
225
+
226
+ const message = [
227
+ ["info", "REBUILDING", ["[","]"]],
228
+ this.#theme.name,
229
+ ]
230
+
231
+ if(this.#options.nerd)
232
+ message.push(["muted", fileName])
233
+
234
+ Term.status(message)
235
+
236
+ await this.#resetWatcher()
237
+ await this.#buildPipeline()
238
+ } finally {
239
+ this.#building = false
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Displays a formatted summary of the session's build statistics and performance.
245
+ * Shows total builds, success/failure counts, success rate percentage, and timing
246
+ * information from the most recent build. Used during session cleanup to provide
247
+ * final statistics to the user.
248
+ *
249
+ * @returns {void}
250
+ */
251
+ showSummary() {
252
+ const {builds, failures} = this.#stats
253
+ const successes = builds-failures
254
+ const successRate = builds > 0 ? ((successes / builds) * 100).toFixed(1) : "0.0"
255
+
256
+ Term.info()
257
+
258
+ Term.status([
259
+ [builds > 0 ? "success" : "error", "SESSION SUMMARY"],
260
+ [builds > 0 ? "info" : "error", this.#theme.name, ["[", "]"]]
261
+ ], this.#options)
262
+
263
+ Term.status([
264
+ [builds > 0 ? "success" : "error", "Builds", ["[", "]"]],
265
+ builds.toLocaleString(),
266
+ [successes > 0 ? "success" : "error", "Successes", ["(", ")"]],
267
+ successes.toLocaleString(),
268
+ [failures > 0 ? "error" : "error", "Failures", ["(", ")"]],
269
+ failures.toLocaleString(),
270
+ [builds > 0 ? "info" : "error", `${successRate}%`, ["(", ")"]]
271
+ ], this.#options)
272
+
273
+ if(this.#history.length > 0) {
274
+ const lastBuild = this.#history[this.#history.length - 1]
275
+ const totalTime = lastBuild.loadTime + lastBuild.buildTime + lastBuild.writeTime
276
+
277
+ Term.status([
278
+ [builds > 0 ? "info" : "muted", "Last Build", ["[", "]"]],
279
+ [builds > 0 ? "success" : "muted", `${totalTime.toLocaleString()}ms total`, ["(", ")"]]
280
+ ], this.#options)
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Handles a rebuild event, resetting and rebuilding all watched themes.
286
+ *
287
+ * @returns {Promise<void>}
288
+ */
289
+ async #handleRebuild() {
290
+ if(this.#building)
291
+ return
292
+
293
+ try {
294
+ this.#command.asyncEmit("recordBuildStart", this.#theme)
295
+ this.#building = true
296
+ await this.#resetWatcher()
297
+ this.#command.asyncEmit("building")
298
+ await this.#buildPipeline(true)
299
+ } catch(error) {
300
+ await this.#command.asyncEmit("recordBuildFail", this.#theme)
301
+ throw Sass.new("Handling rebuild request.", error)
302
+ } finally {
303
+ this.#building = false
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Resets the file watcher for a theme, setting up new dependencies.
309
+ *
310
+ * @returns {Promise<void>}
311
+ */
312
+ async #resetWatcher() {
313
+ if(this.#watcher)
314
+ await this.#watcher.close()
315
+
316
+ const dependencies = this.#theme.dependencies.map(d => d.path)
317
+ this.#watcher = chokidar.watch(dependencies, {
318
+ // Prevent watching own output files
319
+ ignored: [this.#theme.outputFileName],
320
+ // Add some stability options
321
+ awaitWriteFinish: {
322
+ stabilityThreshold: 100,
323
+ pollInterval: 50
324
+ }
325
+ })
326
+
327
+ this.#watcher.on("change", this.#handleFileChange.bind(this))
328
+ }
329
+
330
+ /**
331
+ * Handles quitting the watch mode and cleans up watchers.
332
+ *
333
+ * @returns {Promise<void>}
334
+ */
335
+ async #handleCloseSession() {
336
+ this.showSummary()
337
+
338
+ if(this.#watcher) {
339
+ try {
340
+ await this.#watcher.close()
341
+ } catch(_) {
342
+ void _
343
+ }
344
+ }
345
+ }
346
+
347
+ #recordBuildStart(theme) {
348
+ if(theme !== this.#theme)
349
+ return
350
+
351
+ this.#stats.builds++
352
+ }
353
+
354
+ #recordBuildFail(theme) {
355
+ if(theme !== this.#theme)
356
+ return
357
+
358
+ this.#stats.failures++
359
+ }
360
+ }
package/src/Term.js ADDED
@@ -0,0 +1,175 @@
1
+ import c from "@gesslar/colours"
2
+ // import colorSupport from "color-support"
3
+ import console from "node:console"
4
+ import process from "node:process"
5
+
6
+ import Sass from "./Sass.js"
7
+
8
+ export default class Term {
9
+ /**
10
+ * Log an informational message.
11
+ *
12
+ * @param {...any} arg - Values to log.
13
+ */
14
+ static log(...arg) {
15
+ console.log(...arg)
16
+ }
17
+
18
+ /**
19
+ * Log an informational message.
20
+ *
21
+ * @param {...any} arg - Values to log.
22
+ */
23
+ static info(...arg) {
24
+ console.info(...arg)
25
+ }
26
+
27
+ /**
28
+ * Log a warning message.
29
+ *
30
+ * @param {any} msg - Warning text / object.
31
+ */
32
+ static warn(...msg) {
33
+ console.warn(...msg)
34
+ }
35
+
36
+ /**
37
+ * Log an error message (plus optional details).
38
+ *
39
+ * @param {...any} arg - Values to log.
40
+ */
41
+ static error(...arg) {
42
+ console.error(...arg)
43
+ }
44
+
45
+ /**
46
+ * Log a debug message (no-op unless console.debug provided/visible by env).
47
+ *
48
+ * @param {...any} arg - Values to log.
49
+ */
50
+ static debug(...arg) {
51
+ console.debug(...arg)
52
+ }
53
+
54
+ /**
55
+ * Emit a status line to the terminal.
56
+ *
57
+ * Accepts either a plain string or an array of message segments (see
58
+ * `terminalMessage()` for formatting options). If `silent` is true, output
59
+ * is suppressed.
60
+ *
61
+ * This is a convenient shortcut for logging status updates, with optional
62
+ * formatting and easy suppression.
63
+ *
64
+ * @param {string | Array<string | [string, string]>} args - Message or segments.
65
+ * @param {object} [options] - Behaviour flags.
66
+ * @param {boolean} options.silent - When true, suppress output.
67
+ * @returns {void}
68
+ */
69
+ static status(args, {silent=false} = {}) {
70
+ if(silent)
71
+ return
72
+
73
+ return Term.info(Term.terminalMessage(args))
74
+ }
75
+
76
+ /**
77
+ * Constructs a formatted status line.
78
+ *
79
+ * Input forms:
80
+ * - string: printed as-is
81
+ * - array: each element is either:
82
+ * - a plain string (emitted unchanged), or
83
+ * - a tuple: [level, text] where `level` maps to an ansiColors alias
84
+ * (e.g. success, info, warn, error, modified).
85
+ * - a tuple: [level, text, [openBracket,closeBracket]] where `level` maps to an ansiColors alias
86
+ * (e.g. success, info, warn, error, modified). These are rendered as
87
+ * colourised bracketed segments: [TEXT].
88
+ *
89
+ * The function performs a shallow validation: tuple elements must both be
90
+ * strings; otherwise a TypeError is thrown. Nested arrays beyond depth 1 are
91
+ * not supported.
92
+ *
93
+ * Recursion: array input is normalised into a single string then re-dispatched
94
+ * through `status` to leverage the string branch (keeps logic DRY).
95
+ *
96
+ * @param {string | Array<string, string> | Array<string, string, string>} argList - Message spec.
97
+ * @returns {void}
98
+ */
99
+ static terminalMessage(argList) {
100
+ if(typeof argList === "string")
101
+ return argList
102
+
103
+ if(Array.isArray(argList)) {
104
+ const message = argList
105
+ .map(args => {
106
+ // Bracketed
107
+ if(Array.isArray(args))
108
+
109
+ if(args.length === 3 && Array.isArray(args[2]))
110
+ return Term.terminalBracket(args)
111
+
112
+ else
113
+ return Term.terminalBracket([...args])
114
+
115
+ // Plain string, no decoration
116
+ if(typeof args === "string")
117
+ return args
118
+ })
119
+ .join(" ")
120
+
121
+ return Term.terminalMessage(message)
122
+ }
123
+
124
+ throw Sass.new("Invalid arguments passed to terminalMessage")
125
+ }
126
+
127
+ /**
128
+ * Construct a single coloured bracketed segment from a tuple specifying
129
+ * the style level and the text. The first element ("level") maps to an
130
+ * `ansiColors` alias (e.g. success, info, warn, error, modified) and is
131
+ * used both for the inner text colour and to locate its matching
132
+ * "-bracket" alias for the surrounding square brackets. The second
133
+ * element is the raw text to display.
134
+ *
135
+ * Input validation: every element of `parts` must be a string; otherwise
136
+ * an `Sass` error is thrown. (Additional elements beyond the first two are
137
+ * ignored – the method destructures only the first pair.)
138
+ *
139
+ * Example:
140
+ * terminalBracket(["success", "COMPILED"]) → "[COMPILED]" with coloured
141
+ * brackets + inner text (assuming colour support is available in the
142
+ * terminal).
143
+ *
144
+ * This method does not append trailing spaces; callers are responsible for
145
+ * joining multiple segments with appropriate separators.
146
+ *
147
+ * @param {string[]} parts - Tuple: [level, text]. Additional entries ignored.
148
+ * @returns {string} Colourised bracketed segment (e.g. "[TEXT]").
149
+ * @throws {Sass} If any element of `parts` is not a string.
150
+ */
151
+ static terminalBracket([level, text, brackets=["",""]]) {
152
+ if(!(typeof level === "string" && typeof text === "string"))
153
+ throw Sass.new("Each element must be a string.")
154
+
155
+ return "" +
156
+ c`{${level}-bracket}${brackets[0]}{/}`
157
+ + c`{${level}}${text}{/}`
158
+ + c`{${level}-bracket}${brackets[1]}{/}`
159
+ }
160
+
161
+ static async resetTerminal() {
162
+ await Term.directWrite("\x1b[?25h")
163
+ process.stdin.setRawMode(false)
164
+ }
165
+
166
+ static async clearLines(num) {
167
+ await Term.directWrite(`${"\r\x1b[2K\x1b[1A".repeat(num)}`)
168
+ }
169
+
170
+ static directWrite(output) {
171
+ return new Promise(resolve => {
172
+ process.stdout.write(output, () => resolve())
173
+ })
174
+ }
175
+ }