@gesslar/sassy 0.20.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/sassy",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
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,6 @@
1
1
  import Sass from "./Sass.js"
2
2
  import FileObject from "./FileObject.js"
3
+ import DirectoryObject from "./DirectoryObject.js"
3
4
 
4
5
  /**
5
6
  * Base class for command-line interface commands.
@@ -19,7 +20,7 @@ export default class Command {
19
20
  * Creates a new Command instance.
20
21
  *
21
22
  * @param {object} config - Configuration object
22
- * @param {object} config.cwd - Current working directory object
23
+ * @param {DirectoryObject} config.cwd - Current working directory object
23
24
  * @param {object} config.packageJson - Package.json data
24
25
  */
25
26
  constructor({cwd,packageJson}) {
@@ -27,21 +28,12 @@ export default class Command {
27
28
  this.#packageJson = packageJson
28
29
  }
29
30
 
30
- get cache() {
31
- return this.#cache
32
- }
33
-
34
- set cache(cache) {
35
- if(!this.#cache)
36
- this.#cache = cache
37
- }
38
-
39
31
  /**
40
32
  * Gets the current working directory object.
41
33
  *
42
- * @returns {object} The current working directory
34
+ * @returns {DirectoryObject} The current working directory
43
35
  */
44
- get cwd() {
36
+ getCwd() {
45
37
  return this.#cwd
46
38
  }
47
39
 
@@ -50,44 +42,71 @@ export default class Command {
50
42
  *
51
43
  * @returns {object} The package.json object
52
44
  */
53
- get packageJson() {
45
+ getPackageJson() {
54
46
  return this.#packageJson
55
47
  }
56
48
 
57
49
  /**
58
- * Gets the CLI command string.
50
+ * Sets the cache instance for the command.
59
51
  *
60
- * @returns {string|null} The CLI command string
52
+ * @param {Cache} cache - The cache instance to set
53
+ * @returns {this} Returns this instance for method chaining
61
54
  */
62
- get cliCommand() {
63
- return this.#cliCommand
55
+ setCache(cache) {
56
+ this.#cache = cache
57
+
58
+ return this
59
+ }
60
+
61
+ /**
62
+ * Gets the cache instance.
63
+ *
64
+ * @returns {Cache|null} The cache instance or null if not set
65
+ */
66
+ getCache() {
67
+ return this.#cache
64
68
  }
65
69
 
66
70
  /**
67
71
  * Sets the CLI command string.
68
72
  *
69
73
  * @param {string} data - The CLI command string
74
+ * @returns {this} Returns this instance for method chaining
70
75
  */
71
- set cliCommand(data) {
76
+ setCliCommand(data) {
72
77
  this.#cliCommand = data
78
+
79
+ return this
73
80
  }
74
81
 
75
82
  /**
76
- * Gets the CLI options object.
83
+ * Gets the CLI command string.
77
84
  *
78
- * @returns {object|null} The CLI options configuration
85
+ * @returns {string|null} The CLI command string
79
86
  */
80
- get cliOptions() {
81
- return this.#cliOptions
87
+ getCliCommand() {
88
+ return this.#cliCommand
82
89
  }
83
90
 
84
91
  /**
85
92
  * Sets the CLI options object.
86
93
  *
87
94
  * @param {object} data - The CLI options configuration
95
+ * @returns {this} Returns this instance for method chaining
88
96
  */
89
- set cliOptions(data) {
97
+ setCliOptions(data) {
90
98
  this.#cliOptions = data
99
+
100
+ return this
101
+ }
102
+
103
+ /**
104
+ * Gets the CLI options object.
105
+ *
106
+ * @returns {object|null} The CLI options configuration
107
+ */
108
+ getCliOptions() {
109
+ return this.#cliOptions
91
110
  }
92
111
 
93
112
  /**
@@ -95,10 +114,47 @@ export default class Command {
95
114
  *
96
115
  * @returns {string[]} Array of option names
97
116
  */
98
- get cliOptionNames() {
117
+ getCliOptionNames() {
99
118
  return this.#optionNames
100
119
  }
101
120
 
121
+ /**
122
+ * Checks if the command has a cache instance.
123
+ *
124
+ * @returns {boolean} True if cache is available
125
+ */
126
+ hasCache() {
127
+ return this.#cache !== null
128
+ }
129
+
130
+ /**
131
+ * Checks if the command has a CLI command string configured.
132
+ *
133
+ * @returns {boolean} True if CLI command is set
134
+ */
135
+ hasCliCommand() {
136
+ return this.#cliCommand !== null
137
+ }
138
+
139
+ /**
140
+ * Checks if the command has CLI options configured.
141
+ *
142
+ * @returns {boolean} True if CLI options are set
143
+ */
144
+ hasCliOptions() {
145
+ return this.#cliOptions !== null
146
+ }
147
+
148
+ /**
149
+ * Checks if the command is ready to be built.
150
+ * Requires both CLI command and options to be set.
151
+ *
152
+ * @returns {boolean} True if command can be built
153
+ */
154
+ canBuild() {
155
+ return this.hasCliCommand() && this.hasCliOptions()
156
+ }
157
+
102
158
  /**
103
159
  * Builds the CLI command interface using the commander.js program instance.
104
160
  * Initializes the command with its options and action handler.
@@ -107,22 +163,24 @@ export default class Command {
107
163
  * @returns {Promise<this>} Returns this instance for method chaining
108
164
  */
109
165
  async buildCli(program) {
110
- if(!this.cliCommand)
166
+ if(!this.hasCliCommand())
111
167
  throw Sass.new("This command has no CLI command string.")
112
168
 
113
- if(!this.cliOptions)
169
+ if(!this.hasCliOptions())
114
170
  throw Sass.new("This command has no CLI options.")
115
171
 
116
- this.#command = program.command(this.cliCommand)
172
+ this.#command = program.command(this.getCliCommand())
117
173
  this.#command.action(async(...arg) => {
118
174
  try {
119
175
  await this.execute(...arg)
120
176
  } catch(error) {
121
- throw Sass.new(`Trying to execute ${this.constructor.name} with ${JSON.stringify(...arg)}`, error)
177
+ throw Sass.new(
178
+ `Trying to execute ${this.constructor.name} with `+
179
+ `${JSON.stringify(...arg)}`, error)
122
180
  }
123
181
  })
124
182
 
125
- this.addCliOptions(this.cliOptions, true)
183
+ this.addCliOptions(this.getCliOptions(), true)
126
184
 
127
185
  return this
128
186
  }
@@ -133,7 +191,7 @@ export default class Command {
133
191
  * @param {string} name - The option name
134
192
  * @param {string[]} options - Array containing option flag and description
135
193
  * @param {boolean} preserve - Whether to preserve this option name in the list
136
- * @returns {this} Returns this instance for method chaining
194
+ * @returns {Promise<this>} Returns this instance for method chaining
137
195
  */
138
196
  addCliOption(name, options, preserve) {
139
197
  if(!this.#command)
@@ -185,7 +243,7 @@ export default class Command {
185
243
  * resolve or reject using Promise.allSettled().
186
244
  *
187
245
  * @param {string} event - The event name to emit
188
- * @param {any[]} [arg] - Arguments to pass to event listeners
246
+ * @param {...unknown} [arg] - Arguments to pass to event listeners
189
247
  * @returns {Promise<void>} Resolves when all listeners have completed
190
248
  */
191
249
  async asyncEmit(event, arg) {
@@ -193,7 +251,9 @@ export default class Command {
193
251
  arg = arg || new Array()
194
252
  const listeners = this.emitter.listeners(event)
195
253
 
196
- const settled = await Promise.allSettled(listeners.map(listener => listener(arg)))
254
+ const settled =
255
+ await Promise.allSettled(listeners.map(listener => listener(arg)))
256
+
197
257
  const rejected = settled.filter(reject => reject.status === "rejected")
198
258
 
199
259
  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) {