@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/README.md +605 -0
- package/UNLICENSE.txt +24 -0
- package/package.json +60 -0
- package/src/BuildCommand.js +183 -0
- package/src/Cache.js +73 -0
- package/src/Colour.js +414 -0
- package/src/Command.js +212 -0
- package/src/Compiler.js +310 -0
- package/src/Data.js +545 -0
- package/src/DirectoryObject.js +188 -0
- package/src/Evaluator.js +348 -0
- package/src/File.js +334 -0
- package/src/FileObject.js +226 -0
- package/src/LintCommand.js +498 -0
- package/src/ResolveCommand.js +433 -0
- package/src/Sass.js +165 -0
- package/src/Session.js +360 -0
- package/src/Term.js +175 -0
- package/src/Theme.js +289 -0
- package/src/ThemePool.js +139 -0
- package/src/ThemeToken.js +280 -0
- package/src/Type.js +206 -0
- package/src/Util.js +132 -0
- package/src/Valid.js +50 -0
- package/src/cli.js +155 -0
|
@@ -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
|
+
}
|