@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/Command.js ADDED
@@ -0,0 +1,212 @@
1
+ import Sass from "./Sass.js"
2
+ import FileObject from "./FileObject.js"
3
+
4
+ /**
5
+ * Base class for command-line interface commands.
6
+ * Provides common functionality for CLI option handling and file resolution.
7
+ */
8
+ export default class Command {
9
+ #cliCommand = null
10
+ #cliOptions = null
11
+ #optionNames = []
12
+ #command
13
+ #cwd
14
+ #packageJson
15
+
16
+ #cache
17
+
18
+ /**
19
+ * Creates a new Command instance.
20
+ *
21
+ * @param {object} config - Configuration object
22
+ * @param {object} config.cwd - Current working directory object
23
+ * @param {object} config.packageJson - Package.json data
24
+ */
25
+ constructor({cwd,packageJson}) {
26
+ this.#cwd = cwd
27
+ this.#packageJson = packageJson
28
+ }
29
+
30
+ get cache() {
31
+ return this.#cache
32
+ }
33
+
34
+ set cache(cache) {
35
+ if(!this.#cache)
36
+ this.#cache = cache
37
+ }
38
+
39
+ /**
40
+ * Gets the current working directory object.
41
+ *
42
+ * @returns {object} The current working directory
43
+ */
44
+ get cwd() {
45
+ return this.#cwd
46
+ }
47
+
48
+ /**
49
+ * Gets the package.json data.
50
+ *
51
+ * @returns {object} The package.json object
52
+ */
53
+ get packageJson() {
54
+ return this.#packageJson
55
+ }
56
+
57
+ /**
58
+ * Gets the CLI command string.
59
+ *
60
+ * @returns {string|null} The CLI command string
61
+ */
62
+ get cliCommand() {
63
+ return this.#cliCommand
64
+ }
65
+
66
+ /**
67
+ * Sets the CLI command string.
68
+ *
69
+ * @param {string} data - The CLI command string
70
+ */
71
+ set cliCommand(data) {
72
+ this.#cliCommand = data
73
+ }
74
+
75
+ /**
76
+ * Gets the CLI options object.
77
+ *
78
+ * @returns {object|null} The CLI options configuration
79
+ */
80
+ get cliOptions() {
81
+ return this.#cliOptions
82
+ }
83
+
84
+ /**
85
+ * Sets the CLI options object.
86
+ *
87
+ * @param {object} data - The CLI options configuration
88
+ */
89
+ set cliOptions(data) {
90
+ this.#cliOptions = data
91
+ }
92
+
93
+ /**
94
+ * Gets the array of CLI option names.
95
+ *
96
+ * @returns {string[]} Array of option names
97
+ */
98
+ get cliOptionNames() {
99
+ return this.#optionNames
100
+ }
101
+
102
+ /**
103
+ * Builds the CLI command interface using the commander.js program instance.
104
+ * Initializes the command with its options and action handler.
105
+ *
106
+ * @param {object} program - The commander.js program instance
107
+ * @returns {Promise<this>} Returns this instance for method chaining
108
+ */
109
+ async buildCli(program) {
110
+ if(!this.cliCommand)
111
+ throw Sass.new("This command has no CLI command string.")
112
+
113
+ if(!this.cliOptions)
114
+ throw Sass.new("This command has no CLI options.")
115
+
116
+ this.#command = program.command(this.cliCommand)
117
+ this.#command.action(async(...arg) => {
118
+ try {
119
+ await this.execute(...arg)
120
+ } catch(error) {
121
+ throw Sass.new(`Trying to execute ${this.constructor.name} with ${JSON.stringify(...arg)}`, error)
122
+ }
123
+ })
124
+
125
+ this.addCliOptions(this.cliOptions, true)
126
+
127
+ return this
128
+ }
129
+
130
+ /**
131
+ * Adds a single CLI option to the command.
132
+ *
133
+ * @param {string} name - The option name
134
+ * @param {string[]} options - Array containing option flag and description
135
+ * @param {boolean} preserve - Whether to preserve this option name in the list
136
+ * @returns {this} Returns this instance for method chaining
137
+ */
138
+ addCliOption(name, options, preserve) {
139
+ if(!this.#command)
140
+ throw new Error("Unitialised Command")
141
+
142
+ this.#command.option(...options)
143
+
144
+ if(preserve === true)
145
+ this.#optionNames.push(name)
146
+
147
+ return this
148
+ }
149
+
150
+ /**
151
+ * Adds multiple CLI options to the command.
152
+ *
153
+ * @param {object} options - Object mapping option names to [flag, description] arrays
154
+ * @param {boolean} preserve - Whether to preserve option names in the list
155
+ * @returns {this} Returns this instance for method chaining
156
+ */
157
+ addCliOptions(options, preserve) {
158
+ for(const [name, opts] of Object.entries(options))
159
+ this.addCliOption(name, opts, preserve)
160
+
161
+ return this
162
+ }
163
+
164
+ /**
165
+ * Resolves a theme file name to a FileObject and validates its existence.
166
+ *
167
+ * @param {string} fileName - The theme file name or path
168
+ * @param {object} cwd - The current working directory object
169
+ * @returns {Promise<FileObject>} The resolved and validated FileObject
170
+ * @throws {Sass} If the file does not exist
171
+ */
172
+ async resolveThemeFileName(fileName, cwd) {
173
+ const fileObject = new FileObject(fileName, cwd)
174
+
175
+ if(!await fileObject.exists)
176
+ throw Sass.new(`No such file 🤷: ${fileObject.path}`)
177
+
178
+ return fileObject
179
+ }
180
+
181
+ /**
182
+ * Emits an event asynchronously and waits for all listeners to complete.
183
+ * Unlike the standard EventEmitter.emit() which is synchronous, this method
184
+ * properly handles async event listeners by waiting for all of them to
185
+ * resolve or reject using Promise.allSettled().
186
+ *
187
+ * @param {string} event - The event name to emit
188
+ * @param {any[]} [arg] - Arguments to pass to event listeners
189
+ * @returns {Promise<void>} Resolves when all listeners have completed
190
+ */
191
+ async asyncEmit(event, arg) {
192
+ try {
193
+ arg = arg || new Array()
194
+ const listeners = this.emitter.listeners(event)
195
+
196
+ const settled = await Promise.allSettled(listeners.map(listener => listener(arg)))
197
+ const rejected = settled.filter(reject => reject.status === "rejected")
198
+
199
+ if(rejected.length > 0) {
200
+ if(rejected[0].reason instanceof Error)
201
+ throw rejected[0].reason
202
+ else
203
+ throw Sass.new(rejected[0].reason)
204
+ }
205
+ } catch(error) {
206
+ throw Sass.new(
207
+ `Processing '${event}' event with ${arg&&arg.length?`'${arg}'`:"no arguments"}.`,
208
+ error
209
+ )
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * @file Compiler.js
3
+ *
4
+ * Defines the Compiler class, the main engine for processing theme configuration files.
5
+ * Handles all phases of theme compilation:
6
+ * 1. Import resolution (merging modular theme files)
7
+ * 2. Variable decomposition and flattening
8
+ * 3. Token evaluation and colour function application
9
+ * 4. Recursive resolution of references
10
+ * 5. Output assembly for VS Code themes
11
+ * Supports extension points for custom phases and output formats.
12
+ */
13
+
14
+ import Sass from "./Sass.js"
15
+ import Data from "./Data.js"
16
+ import Evaluator from "./Evaluator.js"
17
+ import File from "./File.js"
18
+ import FileObject from "./FileObject.js"
19
+ import Term from "./Term.js"
20
+ import Theme from "./Theme.js"
21
+ import Util from "./Util.js"
22
+
23
+ /**
24
+ * Main compiler class for processing theme source files.
25
+ * Handles the complete compilation pipeline from source to VS Code theme output.
26
+ */
27
+ export default class Compiler {
28
+ /**
29
+ * Compiles a theme source file into a VS Code colour theme.
30
+ * Processes configuration, variables, imports, and theme definitions.
31
+ *
32
+ * @param {object} theme - The file object containing source data and metadata
33
+ * @returns {Promise<void>} Resolves when compilation is complete
34
+ */
35
+ async compile(theme) {
36
+ try {
37
+ const source = theme.source
38
+ const {config: sourceConfig} = source ?? {}
39
+ const {vars: sourceVars} = source
40
+ const {theme: sourceTheme} = source
41
+
42
+ const evaluator = new Evaluator()
43
+ const evaluate = (...arg) => evaluator.evaluate(...arg)
44
+
45
+ const config = this.#decomposeObject(sourceConfig)
46
+ evaluate(config)
47
+ const recompConfig = this.#composeObject(config)
48
+
49
+ const header = {
50
+ $schema: recompConfig.$schema,
51
+ name: recompConfig.name,
52
+ type: recompConfig.type
53
+ }
54
+
55
+ // Let's get all of the imports!
56
+ const imports = recompConfig.import ?? []
57
+ const {imported,importedFiles} = await this.#import(imports, theme)
58
+
59
+ theme.dependencies = importedFiles
60
+
61
+ const merged = Data.mergeObject({},
62
+ imported,
63
+ {
64
+ vars: sourceVars ?? {},
65
+ colors: sourceTheme?.colors ?? {},
66
+ tokenColors: sourceTheme?.tokenColors ?? [],
67
+ semanticTokenColors: sourceTheme?.semanticTokenColors ?? {},
68
+ }
69
+ )
70
+
71
+ // Shred them up! Kinda. And evaluate the variables in place
72
+ const vars = this.#decomposeObject(merged.vars)
73
+ evaluate(vars)
74
+ const workColors = this.#decomposeObject(merged.colors)
75
+ evaluate(workColors)
76
+ const workTokenColors = this.#decomposeObject(merged.tokenColors)
77
+ evaluate(workTokenColors)
78
+ const workSemanticTokenColors = this.#decomposeObject(merged.semanticTokenColors)
79
+ evaluate(workSemanticTokenColors)
80
+
81
+ theme.lookup = evaluator.lookup
82
+
83
+ // Now let's do some reducing... into a form that works for VS Code
84
+ const reducer = (acc,curr) => {
85
+ acc[curr.flatPath] = curr.value
86
+ return acc
87
+ }
88
+
89
+ // Assemble into one object with the proper keys
90
+ const colors = workColors.reduce(reducer, {})
91
+ const tokenColors = this.#composeArray(workTokenColors)
92
+ const semanticTokenColors = workSemanticTokenColors.reduce(reducer, {})
93
+
94
+ // Mix and maaatch all jumbly wumbly...
95
+ const output = Data.mergeObject(
96
+ {},
97
+ header,
98
+ sourceConfig.custom ?? {},
99
+ {
100
+ colors,
101
+ semanticTokenColors,
102
+ tokenColors
103
+ }
104
+ )
105
+
106
+ // Voilà!
107
+ theme.output = output
108
+ theme.pool = evaluator.pool
109
+ } catch(error) {
110
+ throw Sass.new(`Compiling ${theme.name}`, error)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Imports external theme files and merges their content.
116
+ * Processes import specifications and loads referenced files.
117
+ *
118
+ * @param {Array<string>} imports - The import filenames.
119
+ * @param {Theme} theme - The theme object being compiled.
120
+ * @returns {Promise<object>} Object containing imported data and file references
121
+ */
122
+ async #import(imports, theme) {
123
+ const importedFiles = []
124
+ const imported = {
125
+ vars: {},
126
+ colors: {},
127
+ tokenColors: []
128
+ }
129
+
130
+ imports = typeof imports === "string"
131
+ ? [imports]
132
+ : imports
133
+
134
+
135
+ if(!Data.isArrayUniform(imports, "string"))
136
+ throw new Sass(
137
+ `All import entries must be strings. Got ${JSON.stringify(imports)}`
138
+ )
139
+
140
+ const loaded = []
141
+
142
+ for(const importing of imports) {
143
+ try {
144
+ const file = new FileObject(importing, theme.sourceFile.directory)
145
+
146
+ importedFiles.push(file)
147
+
148
+ // Get the cached version or a new version. Who knows? I don't know.
149
+ const {result, cost} = await Util.time(async() => {
150
+ return await theme.cache.loadCachedData(file)
151
+ })
152
+
153
+ if(theme.options.nerd) {
154
+ Term.status([
155
+ ["muted", Util.rightAlignText(`${cost.toLocaleString()}ms`, 10), ["[","]"]],
156
+ "",
157
+ ["muted", `${File.relativeOrAbsolutePath(theme.cwd,file)}`],
158
+ ["muted", `${theme.name}`,["(",")"]],
159
+ ], theme.options)
160
+ }
161
+
162
+ if(result) {
163
+ loaded.push(result)
164
+ }
165
+ } catch(error) {
166
+ throw Sass.new(`Attempting to import ${importing}`, error)
167
+ }
168
+ }
169
+
170
+ loaded.forEach(data => {
171
+ const {vars={}} = data ?? {}
172
+ const {colors={},tokenColors=[]} = data.theme ?? {}
173
+
174
+ imported.vars = Data.mergeObject(imported.vars, vars)
175
+ imported.colors = Data.mergeObject(imported.colors, colors)
176
+ imported.tokenColors = Data.mergeArray(imported.tokenColors, tokenColors)
177
+ })
178
+
179
+ return {imported,importedFiles}
180
+ }
181
+
182
+ /**
183
+ * Decomposes a nested object into flat entries with path information.
184
+ * Recursively processes objects and arrays to create a flat structure for
185
+ * evaluation.
186
+ *
187
+ * @param {object} work - The object to decompose
188
+ * @param {string[]} path - Current path array for nested properties
189
+ * @returns {Array<object>} Array of decomposed object entries with path information
190
+ */
191
+ #decomposeObject(work, path = []) {
192
+ const isObject = this.#isObject
193
+
194
+ const result = []
195
+
196
+ for(const key in work) {
197
+ const currPath = [...path, key]
198
+ const item = work[key]
199
+
200
+ if(isObject(item)) {
201
+ result.push(...this.#decomposeObject(work[key], currPath))
202
+ } else if(Array.isArray(work[key])) {
203
+ item.forEach((item, index) => {
204
+ const path = [...currPath, String(index+1)]
205
+ result.push({
206
+ key,
207
+ value: String(item),
208
+ path,
209
+ flatPath: path.join("."),
210
+ array: {
211
+ path: path.slice(0, -1),
212
+ flatPath: path.slice(0, -1).join("."),
213
+ index
214
+ }
215
+ })
216
+ })
217
+ } else {
218
+ result.push({key, value: String(item), path, flatPath: currPath.join(".")})
219
+ }
220
+ }
221
+
222
+ return result
223
+ }
224
+
225
+ /**
226
+ * Recomposes a decomposed object array back into a hierarchical object structure.
227
+ * Reconstructs nested objects from the flat representation created by decomposeObject.
228
+ *
229
+ * @param {Array<object>} decomposed - Array of decomposed object entries
230
+ * @returns {object} The recomposed hierarchical object
231
+ */
232
+ #composeObject(decomposed) {
233
+ const done = []
234
+
235
+ return decomposed.reduce((acc, curr, index, arr) => {
236
+ // Test for an array
237
+ if("array" in curr) {
238
+ const array = curr.array
239
+ const fp = array.flatPath
240
+
241
+ if(done.includes(array.flatPath))
242
+ return acc
243
+
244
+ const matches = arr.filter(a => "array" in a && a.array.flatPath === fp)
245
+ const fps = matches.map(m => m.array.flatPath)
246
+ const sorted = matches.sort((a,b) => a.array.index - b.array.index)
247
+ const value = sorted.map(m => m.value)
248
+
249
+ done.push(...fps)
250
+ Data.setNestedValue(acc, array.path, value)
251
+ } else {
252
+ if(done.includes(curr.flatPath))
253
+ return acc
254
+
255
+ const keyPath = [...curr.path, curr.key]
256
+
257
+ done.push(curr.flatPath)
258
+ Data.setNestedValue(acc, keyPath, curr.value)
259
+ }
260
+
261
+ return acc
262
+ }, {})
263
+ }
264
+
265
+ /**
266
+ * Composes decomposed object entries into array structures.
267
+ * Reconstructs array-based configurations from decomposed format.
268
+ *
269
+ * @param {Array<object>} decomposed - Array of decomposed object entries
270
+ * @returns {Array} The composed array structure
271
+ */
272
+ #composeArray(decomposed) {
273
+ const sections = decomposed.reduce((acc,curr) => {
274
+ if(!acc.includes(curr.path[0]))
275
+ acc.push(curr.path[0])
276
+
277
+ return acc
278
+ }, [])
279
+ const sorted = sections.sort((a,b) => parseInt(a) - parseInt(b))
280
+
281
+ return sorted.map(curr => {
282
+ const section = decomposed
283
+ .filter(c => c.path[0] === curr)
284
+ .map(c => {
285
+ const [_, newFlatPath] = c.flatPath.match(/^\w+\.(.*)$/)
286
+ const newPath = c.path.slice(1)
287
+
288
+ return Object.assign(c, {
289
+ path: newPath,
290
+ flatPath: newFlatPath
291
+ })
292
+ })
293
+
294
+ return this.#composeObject(section)
295
+ })
296
+ }
297
+
298
+ /**
299
+ * Checks if a value is a plain object (not null or array).
300
+ * Utility method for type checking during compilation.
301
+ *
302
+ * @param {*} value - The value to check
303
+ * @returns {boolean} True if the value is a plain object
304
+ */
305
+ #isObject(value) {
306
+ return typeof value === "object" &&
307
+ value !== null &&
308
+ !Array.isArray(value)
309
+ }
310
+ }