@gesslar/sassy 0.20.2 → 0.21.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/sassy",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
4
4
  "displayName": "Sassy",
5
5
  "description": "Make gorgeous themes that speak as boldly as you do.",
6
6
  "publisher": "gesslar",
@@ -19,7 +19,8 @@
19
19
  "lint": "eslint src/",
20
20
  "submit": "npm publish --access public",
21
21
  "examples": "node ./examples/validator.js",
22
- "test": "node -p \"require('fs').readFileSync('TESTING.txt', 'utf8')\""
22
+ "test": "node -p \"require('fs').readFileSync('TESTING.txt', 'utf8')\"",
23
+ "update": "npx npm-check-updates -u && npm install"
23
24
  },
24
25
  "engines": {
25
26
  "node": ">=20.0.0"
@@ -44,17 +45,20 @@
44
45
  "@gesslar/colours": "^0.0.1",
45
46
  "chokidar": "^4.0.3",
46
47
  "color-support": "^1.1.3",
47
- "commander": "^14.0.0",
48
+ "commander": "^14.0.1",
48
49
  "culori": "^4.0.2",
49
50
  "globby": "^14.1.0",
50
51
  "json5": "^2.2.3",
51
52
  "yaml": "^2.8.1"
52
53
  },
53
54
  "devDependencies": {
54
- "@stylistic/eslint-plugin": "^5.2.3",
55
- "@typescript-eslint/eslint-plugin": "^8.33.0",
56
- "@typescript-eslint/parser": "^8.33.0",
57
- "eslint": "^9.28.0",
58
- "eslint-plugin-jsdoc": "^50.6.17"
55
+ "@stylistic/eslint-plugin": "^5.3.1",
56
+ "@types/vscode": "^1.104.0",
57
+ "@typescript-eslint/eslint-plugin": "^8.44.0",
58
+ "@typescript-eslint/parser": "^8.44.0",
59
+ "esbuild": "^0.25.10",
60
+ "eslint": "^9.35.0",
61
+ "eslint-plugin-jsdoc": "^59.0.1",
62
+ "typescript": "^5.9.2"
59
63
  }
60
64
  }
@@ -28,13 +28,13 @@ export default class BuildCommand extends Command {
28
28
  constructor(base) {
29
29
  super(base)
30
30
 
31
- this.cliCommand = "build <file...>"
32
- this.cliOptions = {
31
+ this.setCliCommand("build <file...>")
32
+ this.setCliOptions({
33
33
  "watch": ["-w, --watch", "watch for changes"],
34
34
  "output-dir": ["-o, --output-dir <dir>", "specify an output directory"],
35
35
  "dry-run": ["-n, --dry-run", "print theme JSON to stdout; do not write files"],
36
36
  "silent": ["-s, --silent", "silent mode. only print errors or dry-run"],
37
- }
37
+ })
38
38
  }
39
39
 
40
40
  /**
@@ -51,7 +51,7 @@ export default class BuildCommand extends Command {
51
51
  * @throws {Error} When theme compilation fails
52
52
  */
53
53
  async execute(fileNames, options) {
54
- const {cwd} = this
54
+ const cwd = this.getCwd()
55
55
 
56
56
  if(options.watch) {
57
57
  options.watch && this.#initialiseInputHandler()
@@ -69,7 +69,7 @@ export default class BuildCommand extends Command {
69
69
  fileNames.map(async fileName => {
70
70
  const fileObject = await this.resolveThemeFileName(fileName, cwd)
71
71
  const theme = new Theme(fileObject, cwd, options)
72
- theme.cache = this.cache
72
+ .setCache(this.getCache())
73
73
 
74
74
  return new Session(this, theme, options)
75
75
  })
@@ -83,12 +83,11 @@ export default class BuildCommand extends Command {
83
83
  }
84
84
 
85
85
  const sessions = sessionResults.map(result => result.value)
86
- const firstRun = await Promise.allSettled(
87
- sessions.map(async session => await session.run())
88
- )
86
+ const firstRun = await Promise.allSettled(sessions.map(
87
+ async session => await session.run()))
89
88
  const rejected = firstRun.filter(reject => reject.status === "rejected")
90
- if(rejected.length > 0) {
91
89
 
90
+ if(rejected.length > 0) {
92
91
  rejected.forEach(reject => Term.error(reject.reason))
93
92
 
94
93
  if(firstRun.length === rejected.length)
package/src/Cache.js CHANGED
@@ -52,6 +52,7 @@ export default class Cache {
52
52
 
53
53
  if(this.#modifiedTimes.has(fileObject.path)) {
54
54
  const lastCached = this.#modifiedTimes.get(fileObject.path)
55
+
55
56
  if(lastModified > lastCached) {
56
57
  this.#cleanup(fileObject)
57
58
  } else {
package/src/Colour.js CHANGED
@@ -23,7 +23,6 @@ const _colourCache = new Map()
23
23
  // Cache for mixed colours to avoid recomputation
24
24
  const _mixCache = new Map()
25
25
 
26
-
27
26
  /**
28
27
  * Parses a colour string into a colour object with caching.
29
28
  *
@@ -58,10 +57,12 @@ const asColour = s => {
58
57
  throw Sass.new("asColour(): received null/undefined")
59
58
 
60
59
  const k = String(s).trim()
60
+
61
61
  if(!k)
62
62
  throw Sass.new("asColour(): received empty string")
63
63
 
64
64
  let v = _colourCache.get(k)
65
+
65
66
  if(!v) {
66
67
  v = parse(k) // returns undefined if invalid
67
68
 
@@ -140,9 +141,11 @@ export default class Colour {
140
141
 
141
142
  // Use multiplicative scaling for more natural results
142
143
  const factor = 1 + (amount / 100)
144
+
143
145
  oklchColor.l = clamp(oklchColor.l * factor, 0, 1)
144
146
 
145
147
  const result = `${formatHex(oklchColor)}${extracted.alpha?.hex??""}`.toLowerCase()
148
+
146
149
  return result
147
150
  }
148
151
 
@@ -177,12 +180,12 @@ export default class Colour {
177
180
 
178
181
  // Use multiplicative scaling
179
182
  const factor = 1 + (amount / 100)
183
+
180
184
  oklchColor.l = clamp(oklchColor.l * factor, 0, 1)
181
185
 
182
186
  return formatHex(oklchColor).toLowerCase()
183
187
  }
184
188
 
185
-
186
189
  /**
187
190
  * Inverts a hex colour by flipping its lightness value.
188
191
  * Preserves hue and saturation while inverting the lightness component.
@@ -193,6 +196,7 @@ export default class Colour {
193
196
  static invert(hex) {
194
197
  const extracted = Colour.parseHexColour(hex)
195
198
  const hslColour = hsl(extracted.colour)
199
+
196
200
  hslColour.l = 1 - hslColour.l // culori uses 0-1 for lightness
197
201
  const modifiedColour = formatHex(hslColour)
198
202
 
@@ -360,6 +364,7 @@ export default class Colour {
360
364
 
361
365
  // memoize by raw inputs (strings) + normalized ratio
362
366
  const key = mixKey(colourA, colourB, t)
367
+
363
368
  if(_mixCache.has(key))
364
369
  return _mixCache.get(key)
365
370
 
@@ -379,6 +384,7 @@ export default class Colour {
379
384
  const out = (a < 1 ? formatHex8(withAlpha) : formatHex(mixed)).toLowerCase()
380
385
 
381
386
  _mixCache.set(key, out)
387
+
382
388
  return out
383
389
  }
384
390
 
package/src/Command.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import Sass from "./Sass.js"
2
2
  import FileObject from "./FileObject.js"
3
+ import DirectoryObject from "./DirectoryObject.js"
4
+ import Cache from "./Cache.js"
3
5
 
4
6
  /**
5
7
  * Base class for command-line interface commands.
@@ -19,7 +21,7 @@ export default class Command {
19
21
  * Creates a new Command instance.
20
22
  *
21
23
  * @param {object} config - Configuration object
22
- * @param {object} config.cwd - Current working directory object
24
+ * @param {DirectoryObject} config.cwd - Current working directory object
23
25
  * @param {object} config.packageJson - Package.json data
24
26
  */
25
27
  constructor({cwd,packageJson}) {
@@ -27,21 +29,12 @@ export default class Command {
27
29
  this.#packageJson = packageJson
28
30
  }
29
31
 
30
- get cache() {
31
- return this.#cache
32
- }
33
-
34
- set cache(cache) {
35
- if(!this.#cache)
36
- this.#cache = cache
37
- }
38
-
39
32
  /**
40
33
  * Gets the current working directory object.
41
34
  *
42
- * @returns {object} The current working directory
35
+ * @returns {DirectoryObject} The current working directory
43
36
  */
44
- get cwd() {
37
+ getCwd() {
45
38
  return this.#cwd
46
39
  }
47
40
 
@@ -50,44 +43,71 @@ export default class Command {
50
43
  *
51
44
  * @returns {object} The package.json object
52
45
  */
53
- get packageJson() {
46
+ getPackageJson() {
54
47
  return this.#packageJson
55
48
  }
56
49
 
57
50
  /**
58
- * Gets the CLI command string.
51
+ * Sets the cache instance for the command.
59
52
  *
60
- * @returns {string|null} The CLI command string
53
+ * @param {Cache} cache - The cache instance to set
54
+ * @returns {this} Returns this instance for method chaining
61
55
  */
62
- get cliCommand() {
63
- return this.#cliCommand
56
+ setCache(cache) {
57
+ this.#cache = cache
58
+
59
+ return this
60
+ }
61
+
62
+ /**
63
+ * Gets the cache instance.
64
+ *
65
+ * @returns {Cache|null} The cache instance or null if not set
66
+ */
67
+ getCache() {
68
+ return this.#cache
64
69
  }
65
70
 
66
71
  /**
67
72
  * Sets the CLI command string.
68
73
  *
69
74
  * @param {string} data - The CLI command string
75
+ * @returns {this} Returns this instance for method chaining
70
76
  */
71
- set cliCommand(data) {
77
+ setCliCommand(data) {
72
78
  this.#cliCommand = data
79
+
80
+ return this
73
81
  }
74
82
 
75
83
  /**
76
- * Gets the CLI options object.
84
+ * Gets the CLI command string.
77
85
  *
78
- * @returns {object|null} The CLI options configuration
86
+ * @returns {string|null} The CLI command string
79
87
  */
80
- get cliOptions() {
81
- return this.#cliOptions
88
+ getCliCommand() {
89
+ return this.#cliCommand
82
90
  }
83
91
 
84
92
  /**
85
93
  * Sets the CLI options object.
86
94
  *
87
95
  * @param {object} data - The CLI options configuration
96
+ * @returns {this} Returns this instance for method chaining
88
97
  */
89
- set cliOptions(data) {
98
+ setCliOptions(data) {
90
99
  this.#cliOptions = data
100
+
101
+ return this
102
+ }
103
+
104
+ /**
105
+ * Gets the CLI options object.
106
+ *
107
+ * @returns {object|null} The CLI options configuration
108
+ */
109
+ getCliOptions() {
110
+ return this.#cliOptions
91
111
  }
92
112
 
93
113
  /**
@@ -95,10 +115,47 @@ export default class Command {
95
115
  *
96
116
  * @returns {string[]} Array of option names
97
117
  */
98
- get cliOptionNames() {
118
+ getCliOptionNames() {
99
119
  return this.#optionNames
100
120
  }
101
121
 
122
+ /**
123
+ * Checks if the command has a cache instance.
124
+ *
125
+ * @returns {boolean} True if cache is available
126
+ */
127
+ hasCache() {
128
+ return this.#cache !== null
129
+ }
130
+
131
+ /**
132
+ * Checks if the command has a CLI command string configured.
133
+ *
134
+ * @returns {boolean} True if CLI command is set
135
+ */
136
+ hasCliCommand() {
137
+ return this.#cliCommand !== null
138
+ }
139
+
140
+ /**
141
+ * Checks if the command has CLI options configured.
142
+ *
143
+ * @returns {boolean} True if CLI options are set
144
+ */
145
+ hasCliOptions() {
146
+ return this.#cliOptions !== null
147
+ }
148
+
149
+ /**
150
+ * Checks if the command is ready to be built.
151
+ * Requires both CLI command and options to be set.
152
+ *
153
+ * @returns {boolean} True if command can be built
154
+ */
155
+ canBuild() {
156
+ return this.hasCliCommand() && this.hasCliOptions()
157
+ }
158
+
102
159
  /**
103
160
  * Builds the CLI command interface using the commander.js program instance.
104
161
  * Initializes the command with its options and action handler.
@@ -107,22 +164,24 @@ export default class Command {
107
164
  * @returns {Promise<this>} Returns this instance for method chaining
108
165
  */
109
166
  async buildCli(program) {
110
- if(!this.cliCommand)
167
+ if(!this.hasCliCommand())
111
168
  throw Sass.new("This command has no CLI command string.")
112
169
 
113
- if(!this.cliOptions)
170
+ if(!this.hasCliOptions())
114
171
  throw Sass.new("This command has no CLI options.")
115
172
 
116
- this.#command = program.command(this.cliCommand)
173
+ this.#command = program.command(this.getCliCommand())
117
174
  this.#command.action(async(...arg) => {
118
175
  try {
119
176
  await this.execute(...arg)
120
177
  } catch(error) {
121
- throw Sass.new(`Trying to execute ${this.constructor.name} with ${JSON.stringify(...arg)}`, error)
178
+ throw Sass.new(
179
+ `Trying to execute ${this.constructor.name} with `+
180
+ `${JSON.stringify(...arg)}`, error)
122
181
  }
123
182
  })
124
183
 
125
- this.addCliOptions(this.cliOptions, true)
184
+ this.addCliOptions(this.getCliOptions(), true)
126
185
 
127
186
  return this
128
187
  }
@@ -133,7 +192,7 @@ export default class Command {
133
192
  * @param {string} name - The option name
134
193
  * @param {string[]} options - Array containing option flag and description
135
194
  * @param {boolean} preserve - Whether to preserve this option name in the list
136
- * @returns {this} Returns this instance for method chaining
195
+ * @returns {Promise<this>} Returns this instance for method chaining
137
196
  */
138
197
  addCliOption(name, options, preserve) {
139
198
  if(!this.#command)
@@ -185,7 +244,7 @@ export default class Command {
185
244
  * resolve or reject using Promise.allSettled().
186
245
  *
187
246
  * @param {string} event - The event name to emit
188
- * @param {any[]} [arg] - Arguments to pass to event listeners
247
+ * @param {...unknown} [arg] - Arguments to pass to event listeners
189
248
  * @returns {Promise<void>} Resolves when all listeners have completed
190
249
  */
191
250
  async asyncEmit(event, arg) {
@@ -193,7 +252,9 @@ export default class Command {
193
252
  arg = arg || new Array()
194
253
  const listeners = this.emitter.listeners(event)
195
254
 
196
- const settled = await Promise.allSettled(listeners.map(listener => listener(arg)))
255
+ const settled =
256
+ await Promise.allSettled(listeners.map(listener => listener(arg)))
257
+
197
258
  const rejected = settled.filter(reject => reject.status === "rejected")
198
259
 
199
260
  if(rejected.length > 0) {
package/src/Compiler.js CHANGED
@@ -29,12 +29,12 @@ export default class Compiler {
29
29
  * Compiles a theme source file into a VS Code colour theme.
30
30
  * Processes configuration, variables, imports, and theme definitions.
31
31
  *
32
- * @param {object} theme - The file object containing source data and metadata
32
+ * @param {Theme} theme - The file object containing source data and metadata
33
33
  * @returns {Promise<void>} Resolves when compilation is complete
34
34
  */
35
35
  async compile(theme) {
36
36
  try {
37
- const source = theme.source
37
+ const source = theme.getSource()
38
38
  const {config: sourceConfig} = source ?? {}
39
39
  const {vars: sourceVars} = source
40
40
  const {theme: sourceTheme} = source
@@ -43,6 +43,7 @@ export default class Compiler {
43
43
  const evaluate = (...arg) => evaluator.evaluate(...arg)
44
44
 
45
45
  const config = this.#decomposeObject(sourceConfig)
46
+
46
47
  evaluate(config)
47
48
  const recompConfig = this.#composeObject(config)
48
49
 
@@ -54,9 +55,12 @@ export default class Compiler {
54
55
 
55
56
  // Let's get all of the imports!
56
57
  const imports = recompConfig.import ?? []
57
- const {imported,importedFiles} = await this.#import(imports, theme)
58
+ const {imported,importByFile} =
59
+ await this.#import(imports, theme)
58
60
 
59
- theme.dependencies = importedFiles
61
+ importByFile.forEach((themeData,file) => {
62
+ theme.addDependency(file,themeData)
63
+ })
60
64
 
61
65
  // Handle tokenColors separately - imports first, then main source
62
66
  // (append-only)
@@ -79,19 +83,28 @@ export default class Compiler {
79
83
 
80
84
  // Shred them up! Kinda. And evaluate the variables in place
81
85
  const vars = this.#decomposeObject(merged.vars)
86
+
82
87
  evaluate(vars)
88
+
83
89
  const workColors = this.#decomposeObject(merged.colors)
90
+
84
91
  evaluate(workColors)
92
+
85
93
  const workTokenColors = this.#decomposeObject(merged.tokenColors)
94
+
86
95
  evaluate(workTokenColors)
87
- const workSemanticTokenColors = this.#decomposeObject(merged.semanticTokenColors)
96
+
97
+ const workSemanticTokenColors =
98
+ this.#decomposeObject(merged.semanticTokenColors)
99
+
88
100
  evaluate(workSemanticTokenColors)
89
101
 
90
- theme.lookup = evaluator.lookup
102
+ theme.setLookup(evaluator.lookup)
91
103
 
92
104
  // Now let's do some reducing... into a form that works for VS Code
93
105
  const reducer = (acc,curr) => {
94
106
  acc[curr.flatPath] = curr.value
107
+
95
108
  return acc
96
109
  }
97
110
 
@@ -107,16 +120,16 @@ export default class Compiler {
107
120
  sourceConfig.custom ?? {},
108
121
  {
109
122
  colors,
123
+ tokenColors,
110
124
  semanticTokenColors,
111
- tokenColors
112
125
  }
113
126
  )
114
127
 
115
128
  // Voilà!
116
- theme.output = output
117
- theme.pool = evaluator.pool
129
+ theme.setOutput(output)
130
+ theme.setPool(evaluator.pool)
118
131
  } catch(error) {
119
- throw Sass.new(`Compiling ${theme.name}`, error)
132
+ throw Sass.new(`Compiling ${theme.getName()}`, error)
120
133
  }
121
134
  }
122
135
 
@@ -126,66 +139,78 @@ export default class Compiler {
126
139
  *
127
140
  * @param {Array<string>} imports - The import filenames.
128
141
  * @param {Theme} theme - The theme object being compiled.
129
- * @returns {Promise<object>} Object containing imported data and file references
142
+ * @returns {Promise<object,Map>} Object containing imported data and file references
130
143
  */
131
144
  async #import(imports, theme) {
132
- const importedFiles = []
133
145
  const imported = {
134
146
  vars: {},
135
147
  colors: {},
136
- tokenColors: []
148
+ tokenColors: [],
149
+ semanticTokenColors: {}
137
150
  }
151
+ const importByFile = new Map()
138
152
 
139
153
  imports = typeof imports === "string"
140
154
  ? [imports]
141
155
  : imports
142
156
 
143
-
144
157
  if(!Data.isArrayUniform(imports, "string"))
145
158
  throw new Sass(
146
159
  `All import entries must be strings. Got ${JSON.stringify(imports)}`
147
160
  )
148
161
 
149
- const loaded = []
162
+ const loaded = new Map()
150
163
 
151
164
  for(const importing of imports) {
152
165
  try {
153
- const file = new FileObject(importing, theme.sourceFile.directory)
154
-
155
- importedFiles.push(file)
166
+ const file = new FileObject(importing, theme.getSourceFile().directory)
156
167
 
157
168
  // Get the cached version or a new version. Who knows? I don't know.
158
169
  const {result, cost} = await Util.time(async() => {
159
- return await theme.cache.loadCachedData(file)
170
+ return await theme.getCache().loadCachedData(file)
160
171
  })
161
172
 
162
- if(theme.options.nerd) {
173
+ if(theme.getOptions().nerd) {
163
174
  Term.status([
164
175
  ["muted", Util.rightAlignText(`${cost.toLocaleString()}ms`, 10), ["[","]"]],
165
176
  "",
166
- ["muted", `${File.relativeOrAbsolutePath(theme.cwd,file)}`],
167
- ["muted", `${theme.name}`,["(",")"]],
168
- ], theme.options)
177
+ ["muted", `${File.relativeOrAbsolutePath(theme.getCwd(),file)}`],
178
+ ["muted", `${theme.getName()}`,["(",")"]],
179
+ ], theme.getOptions())
169
180
  }
170
181
 
171
- if(result) {
172
- loaded.push(result)
173
- }
182
+ if(result)
183
+ loaded.set(file, result)
184
+
174
185
  } catch(error) {
175
186
  throw Sass.new(`Attempting to import ${importing}`, error)
176
187
  }
177
188
  }
178
189
 
179
- loaded.forEach(data => {
180
- const {vars={}} = data ?? {}
181
- const {colors={},tokenColors=[]} = data.theme ?? {}
182
-
183
- imported.vars = Data.mergeObject(imported.vars, vars)
184
- imported.colors = Data.mergeObject(imported.colors, colors)
185
- imported.tokenColors = [...imported.tokenColors, ...tokenColors]
190
+ loaded.forEach((load, file) => {
191
+ const vars = load?.vars ?? {}
192
+ const colors = load?.theme?.colors ?? {}
193
+ const tokenColors = load?.theme?.tokenColors ?? []
194
+ const semanticTokenColors = load?.theme?.semanticTokenColors ?? {}
195
+
196
+ importByFile.set(file, new Map([
197
+ ["vars", vars],
198
+ ["colors", colors],
199
+ ["tokenColors", tokenColors],
200
+ ["semanticTokenColors", semanticTokenColors]
201
+ ]))
202
+
203
+ imported.vars =
204
+ Data.mergeObject(imported.vars, vars)
205
+ imported.colors =
206
+ Data.mergeObject(imported.colors, colors)
207
+ imported.tokenColors =
208
+ [...imported.tokenColors, ...tokenColors]
209
+ imported.semanticTokenColors =
210
+ Data.mergeObject(imported.semanticTokenColors, semanticTokenColors)
186
211
  })
187
212
 
188
- return {imported,importedFiles}
213
+ return {imported,importByFile}
189
214
  }
190
215
 
191
216
  /**
@@ -209,8 +234,9 @@ export default class Compiler {
209
234
  if(isObject(item)) {
210
235
  result.push(...this.#decomposeObject(work[key], currPath))
211
236
  } else if(Array.isArray(work[key])) {
212
- item.forEach((item, index) => {
237
+ work[key].forEach((item, index) => {
213
238
  const path = [...currPath, String(index+1)]
239
+
214
240
  result.push({
215
241
  key,
216
242
  value: String(item),
@@ -308,7 +334,7 @@ export default class Compiler {
308
334
  * Checks if a value is a plain object (not null or array).
309
335
  * Utility method for type checking during compilation.
310
336
  *
311
- * @param {*} value - The value to check
337
+ * @param {unknown} value - The value to check
312
338
  * @returns {boolean} True if the value is a plain object
313
339
  */
314
340
  #isObject(value) {