@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,183 @@
1
+ import {EventEmitter} from "node:events"
2
+ import process from "node:process"
3
+
4
+ import Command from "./Command.js"
5
+ import Sass from "./Sass.js"
6
+ import Session from "./Session.js"
7
+ import Term from "./Term.js"
8
+ import Theme from "./Theme.js"
9
+
10
+ /**
11
+ * Command handler for building VS Code themes from source files.
12
+ * Handles compilation, watching for changes, and output generation.
13
+ */
14
+ export default class BuildCommand extends Command {
15
+ /** @type {EventEmitter} Internal event emitter for watch mode coordination */
16
+ emitter = new EventEmitter()
17
+
18
+ #hasPrompt = false
19
+ #building = 0
20
+
21
+ /**
22
+ * Creates a new BuildCommand instance.
23
+ *
24
+ * @param {object} base - Base configuration object
25
+ * @param {string} base.cwd - Current working directory path
26
+ * @param {object} base.packageJson - Package.json configuration data
27
+ */
28
+ constructor(base) {
29
+ super(base)
30
+
31
+ this.cliCommand = "build <file...>"
32
+ this.cliOptions = {
33
+ "watch": ["-w, --watch", "watch for changes"],
34
+ "output-dir": ["-o, --output-dir <dir>", "specify an output directory"],
35
+ "dry-run": ["-n, --dry-run", "print theme JSON to stdout; do not write files"],
36
+ "silent": ["-s, --silent", "silent mode. only print errors or dry-run"],
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Executes the build command for the provided theme files.
42
+ * Processes each file in parallel, optionally watching for changes.
43
+ *
44
+ * @param {string[]} fileNames - Array of theme file paths to process
45
+ * @param {object} options - Build options
46
+ * @param {boolean} [options.watch] - Enable watch mode for file changes
47
+ * @param {string} [options.output-dir] - Custom output directory path
48
+ * @param {boolean} [options.dry-run] - Print JSON to stdout without writing files
49
+ * @param {boolean} [options.silent] - Silent mode, only show errors or dry-run output
50
+ * @returns {Promise<void>} Resolves when all files are processed
51
+ * @throws {Error} When theme compilation fails
52
+ */
53
+ async execute(fileNames, options) {
54
+ const {cwd} = this
55
+
56
+ if(options.watch) {
57
+ options.watch && this.#initialiseInputHandler()
58
+
59
+ this.emitter.on("quit", async() =>
60
+ await this.#handleQuit())
61
+
62
+ this.emitter.on("building", async() => await this.#startBuilding())
63
+ this.emitter.on("finishedBuilding", () => this.#finishBuilding())
64
+ this.emitter.on("erasePrompt", async() => await this.#erasePrompt())
65
+ this.emitter.on("printPrompt", () => this.#printPrompt())
66
+ }
67
+
68
+ const sessionResults = await Promise.allSettled(
69
+ fileNames.map(async fileName => {
70
+ const fileObject = await this.resolveThemeFileName(fileName, cwd)
71
+ const theme = new Theme(fileObject, cwd, options)
72
+ theme.cache = this.cache
73
+
74
+ return new Session(this, theme, options)
75
+ })
76
+ )
77
+
78
+ if(sessionResults.some(theme => theme.status === "rejected")) {
79
+ const rejected = sessionResults.filter(result => result.status === "rejected")
80
+
81
+ rejected.forEach(item => Term.error(item.reason))
82
+ process.exit(1)
83
+ }
84
+
85
+ const sessions = sessionResults.map(result => result.value)
86
+ const firstRun = await Promise.allSettled(
87
+ sessions.map(async session => await session.run())
88
+ )
89
+ const rejected = firstRun.filter(reject => reject.status === "rejected")
90
+ if(rejected.length > 0) {
91
+
92
+ rejected.forEach(reject => Term.error(reject.reason))
93
+
94
+ if(firstRun.length === rejected.length)
95
+ await this.asyncEmit("quit")
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Handles quitting the watch mode and cleans up watchers.
101
+ *
102
+ * @returns {Promise<void>}
103
+ */
104
+ async #handleQuit() {
105
+ await this.asyncEmit("closeSession")
106
+
107
+ await Term.directWrite("\x1b[?25h")
108
+
109
+ Term.info()
110
+ Term.info("Exiting.")
111
+
112
+ process.stdin.setRawMode(false)
113
+ process.exit(0)
114
+ }
115
+
116
+ /**
117
+ * Initialises the input handler for watch mode (F5=recompile, q=quit).
118
+ * Sets up raw mode input handling for interactive commands.
119
+ *
120
+ * @returns {void}
121
+ */
122
+ async #initialiseInputHandler() {
123
+ process.stdin.setRawMode(true)
124
+ process.stdin.resume()
125
+ process.stdin.setEncoding("utf8")
126
+
127
+ process.stdin.on("data", async key => {
128
+ try {
129
+ if(key === "q" || key === "\u0003") { // Ctrl+C
130
+ await this.asyncEmit("quit")
131
+ } else if(key === "r" || key === "\x1b[15~") { // F5
132
+ await this.asyncEmit("rebuild")
133
+ } else if(key === "\u0013") { // Ctrl+S
134
+ await this.asyncEmit("saveCheckpoint")
135
+ } else if(key === "\u001a") { // Ctrl+Z
136
+ await this.asyncEmit("revertCheckpoint")
137
+ }
138
+ } catch(error) {
139
+ Sass.new("Processing input.", error)
140
+ .report(true)
141
+ }
142
+ })
143
+
144
+ await Term.directWrite("\x1b[?25l")
145
+ }
146
+
147
+ async #printPrompt() {
148
+ if(this.#hasPrompt && this.#building > 0)
149
+ return
150
+
151
+ await Term.directWrite("\n")
152
+
153
+ await Term.directWrite(Term.terminalMessage([
154
+ ["info", "F5", ["<",">"]],
155
+ "rebuild all,",
156
+ ["info", "Ctrl-C", ["<",">"]],
157
+ "quit",
158
+ ]))
159
+
160
+ this.#hasPrompt = true
161
+ }
162
+
163
+ async #erasePrompt() {
164
+ if(!this.#hasPrompt)
165
+ return
166
+
167
+ this.#hasPrompt = false
168
+
169
+ await Term.clearLines(1)
170
+ }
171
+
172
+ async #startBuilding() {
173
+ await this.#erasePrompt()
174
+ this.#building++
175
+ }
176
+
177
+ #finishBuilding() {
178
+ this.#building = Math.max(0, this.#building-1)
179
+
180
+ if(this.#building === 0)
181
+ this.#printPrompt()
182
+ }
183
+ }
package/src/Cache.js ADDED
@@ -0,0 +1,73 @@
1
+ import Sass from "./Sass.js"
2
+ import File from "./File.js"
3
+ import FileObject from "./FileObject.js"
4
+
5
+ /**
6
+ * File system cache for theme compilation data with automatic invalidation.
7
+ * Provides intelligent caching of parsed JSON5/YAML files with mtime-based
8
+ * cache invalidation to optimize parallel theme compilation performance.
9
+ *
10
+ * The cache eliminates redundant file reads and parsing when multiple themes
11
+ * import the same dependency files, while ensuring data freshness through
12
+ * modification time checking.
13
+ */
14
+ export default class Cache {
15
+ /** @type {Map<string, Date>} Map of file paths to last modification times */
16
+ #modifiedTimes = new Map()
17
+ /** @type {Map<string, object>} Map of file paths to parsed file data */
18
+ #dataCache = new Map()
19
+
20
+ /**
21
+ * Removes cached data for a specific file from both time and data maps.
22
+ * Used when files are modified or when cache consistency needs to be
23
+ * maintained.
24
+ *
25
+ * @private
26
+ * @param {FileObject} file - The file object to remove from cache
27
+ * @returns {void}
28
+ */
29
+ #cleanup(file) {
30
+ this.#modifiedTimes.delete(file.path)
31
+ this.#dataCache.delete(file.path)
32
+ }
33
+
34
+ /**
35
+ * Loads and caches parsed file data with automatic invalidation based on
36
+ * modification time.
37
+ *
38
+ * Implements a sophisticated caching strategy that checks file modification
39
+ * times to determine whether cached data is still valid, ensuring data
40
+ * freshness while optimizing performance for repeated file access during
41
+ * parallel theme compilation.
42
+ *
43
+ * @param {FileObject} fileObject - The file object to load and cache
44
+ * @returns {Promise<object>} The parsed file data (JSON5 or YAML)
45
+ * @throws {Sass} If the file cannot be found or accessed
46
+ */
47
+ async loadCachedData(fileObject) {
48
+ const lastModified = await File.fileModified(fileObject)
49
+
50
+ if(lastModified === null)
51
+ throw Sass.new(`Unable to find file '${fileObject.path}'`)
52
+
53
+ if(this.#modifiedTimes.has(fileObject.path)) {
54
+ const lastCached = this.#modifiedTimes.get(fileObject.path)
55
+ if(lastModified > lastCached) {
56
+ this.#cleanup(fileObject)
57
+ } else {
58
+ if(!(this.#dataCache.has(fileObject.path)))
59
+ this.#cleanup(fileObject)
60
+ else {
61
+ return this.#dataCache.get(fileObject.path)
62
+ }
63
+ }
64
+ }
65
+
66
+ const data = await File.loadDataFile(fileObject)
67
+
68
+ this.#modifiedTimes.set(fileObject.path, lastModified)
69
+ this.#dataCache.set(fileObject.path, data)
70
+
71
+ return data
72
+ }
73
+ }
package/src/Colour.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * @file Colour manipulation utilities for theme processing.
3
+ * Provides comprehensive colour operations including lightening, darkening,
4
+ * mixing, alpha manipulation, and format conversions.
5
+ */
6
+
7
+ import {
8
+ converter,
9
+ formatHex,
10
+ formatHex8,
11
+ hsl,
12
+ interpolate,
13
+ parse
14
+ } from "culori"
15
+
16
+ import Sass from "./Sass.js"
17
+ import ThemeToken from "./ThemeToken.js"
18
+ import Util from "./Util.js"
19
+
20
+ // Cache for parsed colours to improve performance
21
+ const _colourCache = new Map()
22
+
23
+ // Cache for mixed colours to avoid recomputation
24
+ const _mixCache = new Map()
25
+
26
+
27
+ /**
28
+ * Parses a colour string into a colour object with caching.
29
+ *
30
+ * @param {string} s - The colour string to parse
31
+ * @returns {object} The parsed colour object
32
+ * @throws {Sass} If the input is null, undefined, or empty
33
+ */
34
+ const asColour = s => {
35
+ // This is a comment explaining that 'x == null' will be true if the function
36
+ // receives 'undefined' or 'null'. Some robot says that I need to document
37
+ // the behaviour, despite it being IMMEDIATELY followed by the throw
38
+ // detailing "received null/undefined", like it's a completely different
39
+ // book. Also, who doesn't know that 'x == null' is true for null/undefined?
40
+ // Maybe they need Udemy, or a refund from Udemy. Something. I'm not a
41
+ // coding BABYSITTER. - gesslar @ 2025-08-13
42
+ //
43
+ // Addendum consequent to a recent robot's review. I will not be removing
44
+ // the above. That you take issue with this is exactly why this comment
45
+ // exists. I will not be judged on the quality of my work by my documentation
46
+ // verbiage. I'm going to say it right here, in plain view: if someone's
47
+ // poor, puritanical little pearls are so delicate as to be abraded by the
48
+ // above message, they should (in any combination of)
49
+ //
50
+ // 1. avoid looking at any of the other comments in this project which are
51
+ // way worse,
52
+ // 2. find another project that is as good or better than this one at its
53
+ // purpose,
54
+ // 3. recall that this project is Unlicensed, and are invited to fork off.
55
+ //
56
+ // snoochie boochies, with love, gesslar @ 2025-09-02
57
+ if(s == null)
58
+ throw Sass.new("asColour(): received null/undefined")
59
+
60
+ const k = String(s).trim()
61
+ if(!k)
62
+ throw Sass.new("asColour(): received empty string")
63
+
64
+ let v = _colourCache.get(k)
65
+ if(!v) {
66
+ v = parse(k) // returns undefined if invalid
67
+
68
+ if(!v)
69
+ throw Sass.new(`Unable to parse colour: ${k}`)
70
+
71
+ _colourCache.set(k, v)
72
+ }
73
+
74
+ return v
75
+ }
76
+
77
+ /**
78
+ * Generates a cache key for colour mixing operations.
79
+ *
80
+ * @param {string} a - First colour string
81
+ * @param {string} b - Second colour string
82
+ * @param {number} t - Mixing ratio (0-1)
83
+ * @returns {string} Cache key
84
+ */
85
+ const mixKey = (a, b, t) => `${a}|${b}|${t}`
86
+
87
+ /**
88
+ * Converts a percentage to a unit value (0-1).
89
+ *
90
+ * @param {number} r - Percentage value
91
+ * @returns {number} Unit value
92
+ */
93
+ const toUnit = r => Math.max(0, Math.min(100, r)) / 100
94
+
95
+ /**
96
+ * Clamps a number between minimum and maximum values.
97
+ *
98
+ * @param {number} num - The number to clamp
99
+ * @param {number} min - The minimum value
100
+ * @param {number} max - The maximum value
101
+ * @returns {number} The clamped value
102
+ */
103
+ const clamp = (num, min, max) => Math.min(Math.max(num, min), max)
104
+
105
+ /**
106
+ * Colour manipulation utility class providing static methods for colour operations.
107
+ * Handles hex colour parsing, alpha manipulation, mixing, and format conversions.
108
+ */
109
+ export default class Colour {
110
+ /**
111
+ * Regular expression for matching long hex colour codes with optional alpha.
112
+ * Matches patterns like #ff0000 or #ff0000ff
113
+ *
114
+ * @type {RegExp}
115
+ */
116
+ static longHex = /^(?<colour>#[a-f0-9]{6})(?<alpha>[a-f0-9]{2})?$/i
117
+
118
+ /**
119
+ * Regular expression for matching short hex colour codes with optional alpha.
120
+ * Matches patterns like #f00 or #f00f
121
+ *
122
+ * @type {RegExp}
123
+ */
124
+ static shortHex = /^(?<colour>#[a-f0-9]{3})(?<alpha>[a-f0-9]{1})?$/i
125
+
126
+ /**
127
+ * Lightens or darkens a hex colour by a specified amount.
128
+ * Always uses OKLCH as the working color space for consistent perceptual results.
129
+ *
130
+ * @param {string} hex - The hex colour code (e.g., "#ff0000" or "#f00")
131
+ * @param {number} amount - The amount to lighten (+) or darken (-) as a percentage
132
+ * @returns {string} The modified hex colour with preserved alpha
133
+ */
134
+ static lightenOrDarken(hex, amount=0) {
135
+ const extracted = Colour.parseHexColour(hex)
136
+ const colour = parse(extracted.colour)
137
+
138
+ // Always convert to OKLCH for lightness math (perceptually uniform)
139
+ const oklchColor = converter("oklch")(colour)
140
+
141
+ // Use multiplicative scaling for more natural results
142
+ const factor = 1 + (amount / 100)
143
+ oklchColor.l = clamp(oklchColor.l * factor, 0, 1)
144
+
145
+ const result = `${formatHex(oklchColor)}${extracted.alpha?.hex??""}`.toLowerCase()
146
+ return result
147
+ }
148
+
149
+ /**
150
+ * Lightens or darkens a color using OKLCH as working space for consistent results.
151
+ * Preserves original color information from tokens when available.
152
+ *
153
+ * @param {ThemeToken|object|string} tokenOrColor - ThemeToken, Culori color object, or hex string
154
+ * @param {number} amount - The amount to lighten (+) or darken (-) as a percentage
155
+ * @returns {string} The modified hex colour
156
+ */
157
+ static lightenOrDarkenWithToken(tokenOrColor, amount=0) {
158
+ let sourceColor
159
+
160
+ if(tokenOrColor?.getParsedColor) {
161
+ // It's a ThemeToken - use the parsed color
162
+ sourceColor = tokenOrColor.getParsedColor()
163
+ } else if(tokenOrColor?.mode) {
164
+ // It's already a parsed Culori color object
165
+ sourceColor = tokenOrColor
166
+ } else {
167
+ // Fallback to string parsing
168
+ sourceColor = parse(tokenOrColor)
169
+ }
170
+
171
+ if(!sourceColor) {
172
+ throw Sass.new(`Cannot parse color from: ${tokenOrColor}`)
173
+ }
174
+
175
+ // Always convert to OKLCH for lightness math (consistent perceptual results)
176
+ const oklchColor = converter("oklch")(sourceColor)
177
+
178
+ // Use multiplicative scaling
179
+ const factor = 1 + (amount / 100)
180
+ oklchColor.l = clamp(oklchColor.l * factor, 0, 1)
181
+
182
+ return formatHex(oklchColor).toLowerCase()
183
+ }
184
+
185
+
186
+ /**
187
+ * Inverts a hex colour by flipping its lightness value.
188
+ * Preserves hue and saturation while inverting the lightness component.
189
+ *
190
+ * @param {string} hex - The hex colour code to invert
191
+ * @returns {string} The inverted hex colour with preserved alpha
192
+ */
193
+ static invert(hex) {
194
+ const extracted = Colour.parseHexColour(hex)
195
+ const hslColour = hsl(extracted.colour)
196
+ hslColour.l = 1 - hslColour.l // culori uses 0-1 for lightness
197
+ const modifiedColour = formatHex(hslColour)
198
+
199
+ const result = `${modifiedColour}${extracted.alpha?.hex??""}`.toLowerCase()
200
+
201
+ return result
202
+ }
203
+
204
+ /**
205
+ * Converts a hex alpha value to a decimal percentage.
206
+ * Takes a 2-digit hex alpha value and converts it to a percentage (0-100).
207
+ *
208
+ * @param {string} hex - The hex alpha value (e.g., "ff", "80")
209
+ * @returns {number} The alpha as a percentage rounded to 2 decimal places
210
+ */
211
+ static hexAlphaToDecimal(hex) {
212
+ // Parse the hex value to a decimal number
213
+ const decimalValue = parseInt(hex, 16)
214
+
215
+ // Convert to a percentage out of 100
216
+ const percentage = (decimalValue / 255) * 100
217
+
218
+ // Return the result rounded to two decimal places
219
+ return Math.round(percentage * 100) / 100
220
+ }
221
+
222
+ /**
223
+ * Converts a decimal percentage to a hex alpha value.
224
+ * Takes a percentage (0-100) and converts it to a 2-digit hex alpha value.
225
+ *
226
+ * @param {number} dec - The alpha percentage (0-100)
227
+ * @returns {string} The hex alpha value (e.g., "ff", "80")
228
+ */
229
+ static decimalAlphaToHex(dec) {
230
+ // Ensure the input is between 0 and 100
231
+ const percentage = clamp(dec, 0, 100)
232
+
233
+ // Convert percentage to decimal (0-255)
234
+ const decimalValue = Math.round((percentage * 255) / 100)
235
+
236
+ // Convert to hex and ensure it's two digits
237
+ return decimalValue.toString(16).padStart(2, "0")
238
+ }
239
+
240
+ static isHex(value) {
241
+ return Colour.shortHex.test(value) ||
242
+ Colour.longHex.test(value)
243
+ }
244
+
245
+ /**
246
+ * Normalises a short hex colour code to a full 6-character format.
247
+ * Converts 3-character hex codes like "#f00" to "#ff0000".
248
+ *
249
+ * @param {string} code - The short hex colour code
250
+ * @returns {string} The normalized 6-character hex colour code
251
+ */
252
+ static normaliseHex(code) {
253
+ // did some rube give us a long hex?
254
+ if(Colour.longHex.test(code))
255
+ // send it back! pshaw!
256
+ return code
257
+
258
+ const matches = code.match(Colour.shortHex)
259
+
260
+ if(!matches)
261
+ throw Sass.new(`Invalid hex format. Expecting #aaa/aaa, got '${code}'`)
262
+
263
+ const [_,hex] = matches
264
+
265
+ return hex.split("").reduce((acc,curr) => acc + curr.repeat(2)).toLowerCase()
266
+ }
267
+
268
+ /**
269
+ * Parses a hex colour string and extracts colour and alpha components.
270
+ * Supports both short (#f00) and long (#ff0000) formats with optional alpha.
271
+ *
272
+ * @param {string} hex - The hex colour string to parse
273
+ * @returns {object} Object containing colour and optional alpha information
274
+ * @throws {Sass} If the hex value is invalid or missing
275
+ */
276
+ static parseHexColour(hex) {
277
+ const parsed =
278
+ hex.match(Colour.longHex)?.groups ||
279
+ hex.match(Colour.shortHex)?.groups ||
280
+ null
281
+
282
+ if(!parsed)
283
+ throw Sass.new(`Missing or invalid hex colour: ${hex}`)
284
+
285
+ const result = {}
286
+
287
+ result.colour = parsed.colour.length === 3
288
+ ? Colour.normaliseHex(parsed.colour)
289
+ : parsed.colour
290
+
291
+ if(parsed.alpha) {
292
+ parsed.alpha = parsed.alpha.length === 1
293
+ ? Colour.normaliseHex(parsed.alpha)
294
+ : parsed.alpha
295
+
296
+ result.alpha = {
297
+ hex: parsed.alpha,
298
+ decimal: Colour.hexAlphaToDecimal(parsed.alpha) / 100.0
299
+ }
300
+ }
301
+
302
+ return result
303
+ }
304
+
305
+ /**
306
+ * Sets the alpha transparency of a hex colour to a specific value.
307
+ * Replaces any existing alpha with the new value.
308
+ *
309
+ * @param {string} hex - The hex colour code
310
+ * @param {number} amount - The alpha value (0-1, where 0 is transparent and 1 is opaque)
311
+ * @returns {string} The hex colour with the new alpha value
312
+ */
313
+ static setAlpha(hex, amount) {
314
+ const work = Colour.parseHexColour(hex)
315
+ const alpha = clamp(amount, 0, 1)
316
+ const colour = parse(work.colour)
317
+ const result = formatHex8({...colour, alpha}).toLowerCase()
318
+
319
+ return result
320
+ }
321
+
322
+ /**
323
+ * Adjusts the alpha transparency of a hex colour by a relative amount.
324
+ * Multiplies the current alpha by (1 + amount) and clamps the result.
325
+ *
326
+ * @param {string} hex - The hex colour code
327
+ * @param {number} amount - The relative amount to adjust alpha (-1 to make transparent, positive to increase)
328
+ * @returns {string} The hex colour with adjusted alpha
329
+ */
330
+ static addAlpha(hex, amount) {
331
+ const work = Colour.parseHexColour(hex)
332
+ const currentAlpha = (work.alpha?.decimal ?? 1)
333
+ const newAlpha = clamp(currentAlpha * (1 + amount), 0, 1)
334
+ const result = Colour.setAlpha(hex, newAlpha)
335
+
336
+ return result
337
+ }
338
+
339
+ /**
340
+ * Removes alpha channel from a hex colour, returning only the solid colour.
341
+ *
342
+ * @param {string} hex - The hex colour code with or without alpha
343
+ * @returns {string} The solid hex colour without alpha
344
+ */
345
+ static solid(hex) {
346
+ return Colour.parseHexColour(hex).colour
347
+ }
348
+
349
+ /**
350
+ * Mixes two hex colours together in a specified ratio.
351
+ * Blends both the colours and their alpha channels if present.
352
+ *
353
+ * @param {string} colourA - The first hex colour
354
+ * @param {string} colourB - The second hex colour
355
+ * @param {number} ratio - The mixing ratio as percentage (0-100, where 50 is equal mix)
356
+ * @returns {string} The mixed hex colour with blended alpha
357
+ */
358
+ static mix(colourA, colourB, ratio = 50) {
359
+ const t = toUnit(ratio)
360
+
361
+ // memoize by raw inputs (strings) + normalized ratio
362
+ const key = mixKey(colourA, colourB, t)
363
+ if(_mixCache.has(key))
364
+ return _mixCache.get(key)
365
+
366
+ const c1 = asColour(colourA)
367
+ const c2 = asColour(colourB)
368
+
369
+ // colour-space mix using culori interpolation
370
+ const colourSpace = (c1.mode === "oklch" || c2.mode === "oklch") ? "oklch" : "rgb"
371
+ const interpolateFn = interpolate([c1, c2], colourSpace)
372
+ const mixed = interpolateFn(t)
373
+
374
+ // alpha blend too
375
+ const a1 = c1.alpha ?? 1
376
+ const a2 = c2.alpha ?? 1
377
+ const a = a1 * (1 - t) + a2 * t
378
+ const withAlpha = {...mixed, alpha: a}
379
+ const out = (a < 1 ? formatHex8(withAlpha) : formatHex(mixed)).toLowerCase()
380
+
381
+ _mixCache.set(key, out)
382
+ return out
383
+ }
384
+
385
+ static async getColourParser(name) {
386
+ const culori = await import("culori")
387
+ const capped = Util.capitalize(name)
388
+ const parserName = `parse${capped}`
389
+ const fn = culori[parserName]
390
+
391
+ return typeof fn === "function" ? fn : null
392
+ }
393
+
394
+ /**
395
+ * Converts colour values from various formats to hex.
396
+ * Supports RGB, RGBA, HSL, HSLA, OKLCH, and OKLCHA colour modes, and MORE!
397
+ *
398
+ * @param {string} input - The colour expression
399
+ * @returns {string} The resulting hex colour
400
+ * @throws {Sass} If the wrong function or value is provided
401
+ */
402
+ static toHex(input) {
403
+ const colourObj = parse(input)
404
+
405
+ if(!colourObj)
406
+ throw Sass.new(`Invalid colour function invocation: ${input}`)
407
+
408
+ const formatter = "alpha" in colourObj
409
+ ? formatHex8
410
+ : formatHex
411
+
412
+ return formatter(colourObj)
413
+ }
414
+ }