@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.
@@ -0,0 +1,433 @@
1
+ import c from "@gesslar/colours"
2
+ // import colorSupport from "color-support"
3
+
4
+ import Command from "./Command.js"
5
+ import Sass from "./Sass.js"
6
+ import Colour from "./Colour.js"
7
+ import Evaluator from "./Evaluator.js"
8
+ import Term from "./Term.js"
9
+ import Theme from "./Theme.js"
10
+ import Util from "./Util.js"
11
+ import Data from "./Data.js"
12
+
13
+
14
+ // ansiColors.enabled = colorSupport.hasBasic
15
+
16
+ /**
17
+ * Command handler for resolving theme tokens and variables to their final values.
18
+ * Provides introspection into the theme resolution process and variable dependencies.
19
+ */
20
+ export default class ResolveCommand extends Command {
21
+ /**
22
+ * Creates a new ResolveCommand instance.
23
+ *
24
+ * @param {object} base - Base configuration containing cwd and packageJson
25
+ */
26
+ constructor(base) {
27
+ super(base)
28
+
29
+ this.cliCommand = "resolve <file>"
30
+ this.cliOptions = {
31
+ "color": ["-c, --color <key>", "resolve a color key to its final evaluated value"],
32
+ "tokenColor": ["-t, --tokenColor <scope>", "resolve a tokenColors scope to its final evaluated value"],
33
+ "semanticTokenColor": ["-s, --semanticTokenColor <scope>", "resolve a semanticTokenColors scope to its final evaluated value"],
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Executes the resolve command for a given theme file and option.
39
+ * Validates mutual exclusivity of options and delegates to appropriate resolver.
40
+ *
41
+ * @param {string} inputArg - Path to the theme file to resolve
42
+ * @param {object} options - Resolution options (token, etc.)
43
+ * @returns {Promise<void>} Resolves when resolution is complete
44
+ */
45
+ async execute(inputArg, options={}) {
46
+ const intersection =
47
+ Data.arrayIntersection(this.cliOptionNames, Object.keys(options))
48
+
49
+ if(intersection.length > 1)
50
+ throw Sass.new(
51
+ `The options ${this.cliOptionNames.join(", ")} are ` +
52
+ `mutually exclusive and may only have one expressed in the request.`
53
+ )
54
+
55
+ const {cwd} = this
56
+ const optionName = Object.keys(options??{})
57
+ .find(o => this.cliOptionNames.includes(o))
58
+
59
+ if(!optionName) {
60
+ throw Sass.new(
61
+ `No valid option provided. Please specify one of: ${this.cliOptionNames.join(", ")}.`
62
+ )
63
+ }
64
+
65
+ const resolveFunctionName = `resolve${Util.capitalize(optionName)}`
66
+ const optionValue = options[optionName]
67
+ const resolverFunction = this[resolveFunctionName]
68
+
69
+ if(!(resolverFunction && typeof resolverFunction === "function"))
70
+ throw Sass.new(`No such function ${resolveFunctionName}`)
71
+
72
+ const fileObject = await this.resolveThemeFileName(inputArg, cwd)
73
+ const theme = new Theme(fileObject, cwd, options)
74
+ theme.cache = this.cache
75
+
76
+ await theme.load()
77
+ await theme.build()
78
+
79
+ await resolverFunction.call(this, theme, optionValue)
80
+ }
81
+
82
+ /**
83
+ * Resolves a specific color to its final value and displays the resolution trail.
84
+ * Shows the complete dependency chain for the requested color.
85
+ *
86
+ * @param {object} theme - The compiled theme object with pool
87
+ * @param {string} colorName - The color key to resolve
88
+ * @returns {void}
89
+ */
90
+ async resolveColor(theme, colorName) {
91
+ const pool = theme.pool
92
+ if(!pool || !pool.has(colorName))
93
+ return Term.info(`'${colorName}' not found.`)
94
+
95
+ const tokens = pool.getTokens
96
+ const token = tokens.get(colorName)
97
+ const trail = token.getTrail()
98
+ const fullTrail = this.#buildCompleteTrail(token, trail)
99
+ // Get the final resolved value
100
+ const finalValue = token.getValue()
101
+ const [formattedFinalValue] = this.#formatLeaf(finalValue)
102
+
103
+ const output = c`\n{head}${colorName}{/}:\n\n${this.#formatOutput(fullTrail)}\n\n{head}${"Resolution:"}{/} ${formattedFinalValue}`
104
+
105
+ Term.info(output)
106
+ }
107
+
108
+ /**
109
+ * Resolves a specific tokenColors scope to its final value and displays the resolution trail.
110
+ * Shows all matching scopes with disambiguation when multiple matches are found.
111
+ *
112
+ * @param {object} theme - The compiled theme object with output
113
+ * @param {string} scopeName - The scope to resolve (e.g., "entity.name.class" or "entity.name.class.1")
114
+ * @returns {void}
115
+ */
116
+ async resolveTokenColor(theme, scopeName) {
117
+ const tokenColors = theme.output?.tokenColors || []
118
+
119
+ // Check if this is a disambiguated scope (ends with .1, .2, etc.)
120
+ const disambiguatedMatch = scopeName.match(/^(.+)\.(\d+)$/)
121
+
122
+ if(disambiguatedMatch) {
123
+ const [, baseScope, indexStr] = disambiguatedMatch
124
+ const index = parseInt(indexStr) - 1 // Convert to 0-based index
125
+
126
+ const matches = this.#findScopeMatches(tokenColors, baseScope)
127
+
128
+ if(index >= 0 && index < matches.length) {
129
+ const match = matches[index]
130
+ await this.#resolveScopeMatch(theme, match, `${baseScope}.${indexStr}`)
131
+ return
132
+ } else {
133
+ return Term.info(`'${scopeName}' not found. Available: ${baseScope}.1 through ${baseScope}.${matches.length}`)
134
+ }
135
+ }
136
+
137
+ // Find all matching scopes
138
+ const matches = this.#findScopeMatches(tokenColors, scopeName)
139
+
140
+ if(matches.length === 0) {
141
+ return Term.info(`No tokenColors entries found for scope '${scopeName}'`)
142
+ }
143
+
144
+ if(matches.length === 1) {
145
+ // Single match - resolve directly
146
+ await this.#resolveScopeMatch(theme, matches[0], scopeName)
147
+ } else {
148
+ // Multiple matches - show disambiguation options
149
+ Term.info(`Multiple entries found for '${scopeName}', please try again with the specific query:\n`)
150
+ matches.forEach((match, index) => {
151
+ const name = match.name || `Entry ${index + 1}`
152
+ Term.info(`${name}: ${scopeName}.${index + 1}`)
153
+ })
154
+ }
155
+ }
156
+
157
+ #findScopeMatches(tokenColors, targetScope) {
158
+ return tokenColors.filter(entry => {
159
+ if(!entry.scope)
160
+ return false
161
+
162
+ // Handle comma-separated scopes
163
+ const scopes = entry.scope.split(",").map(s => s.trim())
164
+ return scopes.includes(targetScope)
165
+ })
166
+ }
167
+
168
+ async #resolveScopeMatch(theme, match, displayName) {
169
+ const pool = theme.pool
170
+ const settings = match.settings || {}
171
+ const name = match.name || "Unnamed"
172
+
173
+ // Look for the foreground property specifically
174
+ const foreground = settings.foreground
175
+ if(!foreground) {
176
+ return Term.info(`${displayName} (${name})\n\n(no foreground property)`)
177
+ }
178
+
179
+ // First, try to find the token by looking for variables that resolve to this value
180
+ // but prioritize source variable names over computed results
181
+ const tokens = pool ? pool.getTokens : new Map()
182
+ let bestToken = null
183
+
184
+ // First try to find a scope.* token that matches
185
+ for(const [tokenName, token] of tokens) {
186
+ if(token.getValue() === foreground && tokenName.startsWith("scope.")) {
187
+ bestToken = token
188
+ break
189
+ }
190
+ }
191
+
192
+ // If no scope token found, look for other variable-like tokens
193
+ if(!bestToken) {
194
+ for(const [tokenName, token] of tokens) {
195
+ if(token.getValue() === foreground) {
196
+ // Prefer tokens that look like variable names (scope.*, colors.*, etc.)
197
+ // over computed function results
198
+ if(tokenName.includes(".") && !tokenName.includes("(") && !tokenName.includes("#")) {
199
+ bestToken = token
200
+ break
201
+ } else if(!bestToken) {
202
+ bestToken = token // fallback to any matching token
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ if(!bestToken) {
209
+ return Term.info(`${displayName} (${name})\n\n(resolved to static value: ${foreground})`)
210
+ }
211
+
212
+ const trail = bestToken.getTrail()
213
+ const fullTrail = this.#buildCompleteTrail(bestToken, trail)
214
+ const finalValue = bestToken.getValue()
215
+ const [formattedFinalValue] = this.#formatLeaf(finalValue)
216
+
217
+ const output = c`{head}${displayName}{/} {hex}${(`${name}`)}{/}\n${this.#formatOutput(fullTrail)}\n\n{head}${"Resolution:"}{/} ${formattedFinalValue}`
218
+
219
+ Term.info(output)
220
+ }
221
+
222
+ /**
223
+ * Resolves a specific semanticTokenColors scope to its final value.
224
+ * Uses the same logic as tokenColors since they have identical structure.
225
+ *
226
+ * @param {object} theme - The compiled theme object with output
227
+ * @param {string} scopeName - The scope to resolve (e.g., "keyword" or "keyword.1")
228
+ * @returns {void}
229
+ */
230
+ async resolveSemanticTokenColor(theme, scopeName) {
231
+ // semanticTokenColors has the same structure as tokenColors, so we can reuse the logic
232
+ // but we need to look at the semanticTokenColors array instead
233
+ const originalTokenColors = theme.output?.tokenColors
234
+
235
+ // Temporarily replace tokenColors with semanticTokenColors for resolution
236
+ if(theme.output?.semanticTokenColors) {
237
+ theme.output.tokenColors = theme.output.semanticTokenColors
238
+ }
239
+
240
+ await this.resolveTokenColor(theme, scopeName)
241
+
242
+ // Restore original tokenColors
243
+ if(originalTokenColors) {
244
+ theme.output.tokenColors = originalTokenColors
245
+ }
246
+ }
247
+
248
+ #buildCompleteTrail(rootToken, trail) {
249
+ const steps = []
250
+ const seen = new Set()
251
+
252
+ // Add the root token's original expression
253
+ const rootRaw = rootToken.getRawValue()
254
+ if(rootRaw !== rootToken.getName()) {
255
+ steps.push({
256
+ value: rootRaw,
257
+ type: "expression",
258
+ level: 0
259
+ })
260
+ }
261
+
262
+ // Build a flattened sequence showing the resolution process
263
+ const processToken = (token, level) => {
264
+ if(!token)
265
+ return
266
+
267
+ const id = `${token.getName()}-${token.getRawValue()}`
268
+ if(seen.has(id))
269
+ return
270
+
271
+ seen.add(id)
272
+
273
+ const rawValue = token.getRawValue()
274
+ const finalValue = token.getValue()
275
+ const dependency = token.getDependency()
276
+ const kind = token.getKind()
277
+
278
+ // Add the current step
279
+ if(!steps.some(s => s.value === rawValue)) {
280
+ steps.push({
281
+ value: rawValue,
282
+ type: kind === "function" ? "function" : "variable",
283
+ level
284
+ })
285
+ }
286
+
287
+ // For variables, show what they resolve to
288
+ if(dependency) {
289
+ const depRaw = dependency.getRawValue()
290
+ const depFinal = dependency.getValue()
291
+
292
+ // Add dependency's expression if it's a function call
293
+ if(depRaw !== dependency.getName() && !steps.some(s => s.value === depRaw)) {
294
+ steps.push({
295
+ value: depRaw,
296
+ type: "expression",
297
+ level: level + 1
298
+ })
299
+ }
300
+
301
+ // Process dependency's trail
302
+ const depTrail = dependency.getTrail()
303
+ if(depTrail && depTrail.length > 0) {
304
+ depTrail.forEach(depToken => processToken(depToken, level + 1))
305
+ }
306
+
307
+ // Add resolved color if different
308
+ if(depRaw !== depFinal && !steps.some(s => s.value === depFinal)) {
309
+ steps.push({
310
+ value: depFinal,
311
+ type: "result",
312
+ level: level + 1
313
+ })
314
+ }
315
+ }
316
+
317
+ // Add final result for this token
318
+ if(rawValue !== finalValue && !steps.some(s => s.value === finalValue)) {
319
+ steps.push({
320
+ value: finalValue,
321
+ type: "result",
322
+ level
323
+ })
324
+ }
325
+ }
326
+
327
+ trail.forEach(token => processToken(token, 1))
328
+
329
+ // Normalize levels to reduce excessive nesting
330
+ const levelMap = new Map()
331
+ let normalizedLevel = 0
332
+
333
+ steps.forEach(step => {
334
+ if(!levelMap.has(step.level)) {
335
+ levelMap.set(step.level, Math.min(normalizedLevel++, 4)) // Cap at depth 4
336
+ }
337
+
338
+ step.depth = levelMap.get(step.level)
339
+ })
340
+
341
+ return steps
342
+ }
343
+ /**
344
+ * Formats a list of resolution steps into a visually indented tree structure for display.
345
+ *
346
+ * Each step represents a part of the theme token resolution process, including variables,
347
+ * function calls, expressions, and final results. The output is colorized and indented
348
+ * according to the step's depth and type, making the dependency chain and resolution
349
+ * process easy to follow in terminal output.
350
+ *
351
+ * - Hex color results are indented one extra level and prefixed with an arrow for emphasis.
352
+ * - Other steps (variables, functions, literals) are indented according to their depth.
353
+ *
354
+ * @param {Array} steps - List of resolution steps, each with {value, depth, type}.
355
+ * - value: The string value to display (token, expression, result, etc.)
356
+ * - depth: Indentation level for the step
357
+ * - type: The kind of step ("result", "variable", "function", "expression", etc.)
358
+ * @returns {string} Formatted, colorized, and indented output for terminal display.
359
+ */
360
+ #formatOutput(steps) {
361
+ if(steps.length === 0)
362
+ return ""
363
+
364
+ const out = []
365
+
366
+ steps.forEach(step => {
367
+ const {value, depth, type} = step
368
+ const [line, kind] = this.#formatLeaf(value)
369
+
370
+ // Simple logic: only hex results get extra indentation with arrow, everything else is clean
371
+ if(type === "result" && kind === "hex") {
372
+ // Hex results are indented one extra level with just spaces and arrow
373
+ const prefix = " ".repeat(depth + 1)
374
+ const arrow = c`{arrow}→{/} `
375
+ out.push(`${prefix}${arrow}${line}`)
376
+ } else {
377
+ // Everything else just gets clean indentation
378
+ const prefix = " ".repeat(depth)
379
+ out.push(`${prefix}${line}`)
380
+ }
381
+ })
382
+
383
+ return out.join("\n")
384
+ }
385
+
386
+ #func = /^(?<func>\w+)(?<open>\()(?<args>.*)(?<close>\)$)$/
387
+ #sub = Evaluator.sub
388
+ #hex = value => Colour.isHex(value)
389
+
390
+ /**
391
+ * Formats a single ThemeToken for display in the theme resolution output,
392
+ * applying colour and style based on its type.
393
+ *
394
+ * @param {string} value - The man, the mystrery, the value.
395
+ * @returns {string} The formatted and colourised representation of the token.
396
+ *
397
+ * Uses the token's kind property to determine formatting instead of regex matching.
398
+ * Provides clear visual distinction between tokens, functions, colours, and variables.
399
+ */
400
+ #formatLeaf(value) {
401
+ if(this.#hex(value)) {
402
+ const {colour,alpha} = Colour.longHex.test(value)
403
+ ? Colour.longHex.exec(value).groups
404
+ : Colour.shortHex.exec(value).groups
405
+
406
+ return [
407
+ c`{hash}#{/}{hex}${colour.slice(1)}{/}${alpha?`{hexAlpha}${alpha}{/}`:""}`,
408
+ "hex"
409
+ ]
410
+ }
411
+
412
+ if(this.#func.test(value)) {
413
+ const {func,args} = this.#func.exec(value).groups
414
+ return [
415
+ c`{func}${func}{/}{parens}${"("}{/}{leaf}${args}{/}{parens}${")"}{/}`,
416
+ "function"
417
+ ]
418
+ }
419
+
420
+
421
+ if(this.#sub.test(value)) {
422
+ const {parens,none,braces} = Evaluator.sub.exec(value)?.groups || {}
423
+ const style = (braces && ["{","}"]) || (parens && ["(",")"]) || (none && ["",""])
424
+ const varValue = braces || parens || none || value
425
+ return [
426
+ c`{func}{/}{parens}${style[0]}{/}{leaf}${varValue}{/}{parens}${style[1]}{/}`,
427
+ "variable"
428
+ ]
429
+ }
430
+
431
+ return [c`{leaf}${value}{/}`, "literal"]
432
+ }
433
+ }
package/src/Sass.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @file Sass.js
3
+ *
4
+ * Defines the Sass class, a custom error type for theme compilation
5
+ * errors.
6
+ *
7
+ * Supports error chaining, trace management, and formatted reporting for both
8
+ * user-friendly and verbose (nerd) output.
9
+ *
10
+ * Used throughout the theme engine for structured error handling and
11
+ * debugging.
12
+ */
13
+
14
+ import Term from "./Term.js"
15
+
16
+ /**
17
+ * Custom error class for Sassy theme compilation errors.
18
+ * Provides error chaining, trace management, and formatted error reporting.
19
+ */
20
+ export default class Sass extends Error {
21
+ #trace = []
22
+
23
+ /**
24
+ * Creates a new Sass instance.
25
+ *
26
+ * @param {string} message - The error message
27
+ * @param {...any} arg - Additional arguments passed to parent Error constructor
28
+ */
29
+ constructor(message, ...arg) {
30
+ super(message, ...arg)
31
+
32
+ this.trace = message
33
+ }
34
+
35
+ /**
36
+ * Gets the error trace array.
37
+ *
38
+ * @returns {Array<string>} Array of trace messages
39
+ */
40
+ get trace() {
41
+ return this.#trace
42
+ }
43
+
44
+ /**
45
+ * Adds a message to the beginning of the trace array.
46
+ *
47
+ * @param {string} message - The trace message to add
48
+ */
49
+ set trace(message) {
50
+ this.#trace.unshift(message)
51
+ }
52
+
53
+ /**
54
+ * Adds a trace message and returns this instance for chaining.
55
+ *
56
+ * @param {string} message - The trace message to add
57
+ * @returns {this} This Sass instance for method chaining
58
+ */
59
+ addTrace(message) {
60
+ if(typeof message !== "string")
61
+ throw Sass.new(`Sass.addTrace expected string, got ${JSON.stringify(message)}`)
62
+
63
+ this.trace = message
64
+
65
+ return this
66
+ }
67
+
68
+ /**
69
+ * Reports the error to the terminal with formatted output.
70
+ * Optionally includes detailed stack trace information.
71
+ *
72
+ * @param {boolean} [nerdMode] - Whether to include detailed stack trace
73
+ */
74
+ report(nerdMode=false) {
75
+ Term.error(
76
+ `${Term.terminalBracket(["error", "Something Went Wrong"])}\n` +
77
+ this.trace.join("\n")
78
+ )
79
+
80
+ if(nerdMode) {
81
+ Term.error(
82
+ "\n" +
83
+ `${Term.terminalBracket(["error", "Nerd Vittles"])}\n` +
84
+ this.#fullBodyMassage(this.stack)
85
+ )
86
+
87
+ this.cause?.stack && Term.error(
88
+ "\n" +
89
+ `${Term.terminalBracket(["error", "Rethrown From"])}\n` +
90
+ this.#fullBodyMassage(this.cause?.stack)
91
+ )
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Formats the stack trace for display, removing the first line and
97
+ * formatting each line with appropriate indentation.
98
+ *
99
+ * Note: Returns formatted stack trace or undefined if no stack available.
100
+ *
101
+ * @param {string} stack - The error stack to massage.
102
+ * @returns {string|undefined} Formatted stack trace or undefined
103
+ */
104
+ #fullBodyMassage(stack) {
105
+ // Remove the first line, it's already been reported
106
+
107
+ stack = stack ?? ""
108
+
109
+ const {rest} = stack.match(/^.*?\n(?<rest>[\s\S]+)$/m)?.groups ?? {}
110
+ const lines = []
111
+
112
+ if(rest) {
113
+ lines.push(
114
+ ...rest
115
+ .split("\n")
116
+ .map(line => {
117
+ const at = line.match(/^\s{4}at\s(?<at>.*)$/)?.groups?.at ?? {}
118
+ return at
119
+ ? `* ${at}`
120
+ : line
121
+ })
122
+ )
123
+ }
124
+
125
+ return lines.join("\n")
126
+ }
127
+
128
+ /**
129
+ * Creates an Sass from an existing Error object with additional
130
+ * trace message.
131
+ *
132
+ * @param {Error} error - The original error object
133
+ * @param {string} message - Additional trace message to add
134
+ * @returns {Sass} New Sass instance with trace from the original error
135
+ * @throws {Sass} If the first parameter is not an Error instance
136
+ */
137
+ static from(error, message) {
138
+ if(!(error instanceof Error))
139
+ throw Sass.new("Sass.from must take an Error object.")
140
+
141
+ const oldMessage = error.message
142
+ const newError = new Sass(oldMessage, {cause: error}).addTrace(message)
143
+
144
+ return newError
145
+ }
146
+
147
+ /**
148
+ * Factory method to create or enhance Sass instances.
149
+ * If error parameter is provided, enhances existing Sass or wraps
150
+ * other errors. Otherwise creates a new Sass instance.
151
+ *
152
+ * @param {string} message - The error message
153
+ * @param {Error|Sass} [error] - Optional existing error to wrap or enhance
154
+ * @returns {Sass} New or enhanced Sass instance
155
+ */
156
+ static new(message, error) {
157
+ if(error) {
158
+ return error instanceof Sass
159
+ ? error.addTrace(message)
160
+ : Sass.from(error, message)
161
+ } else {
162
+ return new Sass(message)
163
+ }
164
+ }
165
+ }