@gesslar/sassy 1.2.0 → 2.0.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
@@ -5,7 +5,7 @@
5
5
  "name": "gesslar",
6
6
  "url": "https://gesslar.dev"
7
7
  },
8
- "version": "1.2.0",
8
+ "version": "2.0.0",
9
9
  "license": "Unlicense",
10
10
  "homepage": "https://github.com/gesslar/sassy#readme",
11
11
  "repository": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@gesslar/colours": "^0.8.0",
47
- "@gesslar/toolkit": "^3.29.0",
47
+ "@gesslar/toolkit": "^3.30.0",
48
48
  "chokidar": "^5.0.0",
49
49
  "color-support": "^1.1.3",
50
50
  "commander": "^14.0.2",
@@ -54,6 +54,7 @@
54
54
  "yaml": "^2.8.2"
55
55
  },
56
56
  "devDependencies": {
57
+ "@docusaurus/eslint-plugin": "^3.9.2",
57
58
  "@gesslar/uglier": "^1.2.0",
58
59
  "eslint": "^9.39.2",
59
60
  "typescript": "^5.9.3"
@@ -69,6 +70,9 @@
69
70
  "submit": "pnpm publish --access public --//registry.npmjs.org/:_authToken=\"${NPM_ACCESS_TOKEN}\"",
70
71
  "examples": "node ./examples/validator.js",
71
72
  "update": "pnpm self-update && pnpx npm-check-updates -u && pnpm install",
73
+ "docs:dev": "pnpm --dir docs start",
74
+ "docs:build": "pnpm --dir docs build",
75
+ "docs:deploy": "gh workflow run deploy-docs.yml",
72
76
  "pr": "gt submit -p --ai",
73
77
  "patch": "pnpm version patch",
74
78
  "minor": "pnpm version minor",
@@ -48,39 +48,33 @@ export default class BuildCommand extends Command {
48
48
  return await Util.asyncEmit(this.emitter, event, ...args)
49
49
  }
50
50
 
51
+ /**
52
+ * @typedef {object} BuildCommandOptions
53
+ * @property {boolean} [watch] - Enable watch mode for file changes
54
+ * @property {string} [outputDir] - Custom output directory path
55
+ * @property {boolean} [dryRun] - Print JSON to stdout without writing files
56
+ * @property {boolean} [silent] - Silent mode, only show errors or dry-run output
57
+ */
58
+
51
59
  /**
52
60
  * Executes the build command for the provided theme files.
53
61
  * Processes each file in parallel, optionally watching for changes.
54
62
  *
55
- * @param {string[]} fileNames - Array of theme file paths to process
56
- * @param {object} options - Build options
57
- * @param {boolean} [options.watch] - Enable watch mode for file changes
58
- * @param {string} [options.outputDir] - Custom output directory path
59
- * @param {boolean} [options.dryRun] - Print JSON to stdout without writing files
60
- * @param {boolean} [options.silent] - Silent mode, only show errors or dry-run output
63
+ * @param {Array<string>} fileNames - Array of theme file paths to process
64
+ * @param {BuildCommandOptions} options - {@link BuildCommandOptions}
61
65
  * @returns {Promise<void>} Resolves when all files are processed
62
66
  * @throws {Error} When theme compilation fails
63
67
  */
64
68
  async execute(fileNames, options) {
65
-
66
- /**
67
- * @typedef {object} BuildCommandOptions
68
- * @property {boolean} [watch] Enable watch mode for file changes
69
- * @property {string} [outputDir] Custom output directory path
70
- * @property {boolean} [dryRun] Print JSON to stdout without writing files
71
- * @property {boolean} [silent] Silent mode, only show errors or dry-run output
72
- */
73
69
  const cwd = this.getCwd()
74
70
 
75
71
  if(options.watch) {
76
72
  options.watch && this.#initialiseInputHandler()
77
73
 
78
- this.emitter.on("quit", async() =>
79
- await this.#handleQuit())
80
-
81
- this.emitter.on("building", async() => await this.#startBuilding())
74
+ this.emitter.on("quit", async() => await this.#handleQuit())
75
+ this.emitter.on("building", () => this.#startBuilding())
82
76
  this.emitter.on("finishedBuilding", () => this.#finishBuilding())
83
- this.emitter.on("erasePrompt", async() => await this.#erasePrompt())
77
+ this.emitter.on("erasePrompt", () => this.#erasePrompt())
84
78
  this.emitter.on("printPrompt", () => this.#printPrompt())
85
79
  }
86
80
 
@@ -97,16 +91,6 @@ export default class BuildCommand extends Command {
97
91
  if(Promised.hasRejected(sessionResults))
98
92
  Promised.throw("Creating sessions.", sessionResults)
99
93
 
100
- // if(sessionResults.some(theme => theme.status === "rejected")) {
101
- // const rejected = sessionResults.filter(result => result.status === "rejected")
102
-
103
- // rejected.forEach(item => {
104
- // const sassError = Sass.new("Creating session for theme file.", item.reason)
105
- // sassError.report(options.nerd)
106
- // })
107
- // process.exit(1)
108
- // }
109
-
110
94
  const sessions = Promised.values(sessionResults)
111
95
  const firstRun = await Promised.settle(sessions.map(
112
96
  async session => await session.run()))
@@ -123,7 +107,7 @@ export default class BuildCommand extends Command {
123
107
  async #handleQuit() {
124
108
  await this.asyncEmit("closeSession")
125
109
 
126
- await Term.directWrite("\x1b[?25h")
110
+ Term.write("\x1b[?25h")
127
111
 
128
112
  Term.info()
129
113
  Term.info("Exiting.")
@@ -160,16 +144,16 @@ export default class BuildCommand extends Command {
160
144
  }
161
145
  })
162
146
 
163
- await Term.directWrite("\x1b[?25l")
147
+ Term.write("\x1b[?25l")
164
148
  }
165
149
 
166
- async #printPrompt() {
150
+ #printPrompt() {
167
151
  if(this.#hasPrompt && this.#building > 0)
168
152
  return
169
153
 
170
- await Term.directWrite("\n")
154
+ Term.write("\n")
171
155
 
172
- await Term.directWrite(Term.terminalMessage([
156
+ Term.write(Term.terminalMessage([
173
157
  ["info", "F5", ["<",">"]],
174
158
  "rebuild all,",
175
159
  ["info", "Ctrl-C", ["<",">"]],
@@ -179,17 +163,18 @@ export default class BuildCommand extends Command {
179
163
  this.#hasPrompt = true
180
164
  }
181
165
 
182
- async #erasePrompt() {
166
+ #erasePrompt() {
183
167
  if(!this.#hasPrompt)
184
168
  return
185
169
 
186
170
  this.#hasPrompt = false
187
171
 
188
- await Term.clearLines(1)
172
+ Term.clearLine().moveStart()
189
173
  }
190
174
 
191
- async #startBuilding() {
192
- await this.#erasePrompt()
175
+ #startBuilding() {
176
+ this.#erasePrompt()
177
+
193
178
  this.#building++
194
179
  }
195
180
 
package/src/Command.js CHANGED
@@ -115,7 +115,7 @@ export default class Command {
115
115
  /**
116
116
  * Gets the array of CLI option names.
117
117
  *
118
- * @returns {string[]} Array of option names
118
+ * @returns {Array<string>} Array of option names
119
119
  */
120
120
  getCliOptionNames() {
121
121
  return this.#optionNames
@@ -192,7 +192,7 @@ export default class Command {
192
192
  * Adds a single CLI option to the command.
193
193
  *
194
194
  * @param {string} name - The option name
195
- * @param {string[]} options - Array containing option flag and description
195
+ * @param {Array<string>} options - Array containing option flag and description
196
196
  * @param {boolean} preserve - Whether to preserve this option name in the list
197
197
  * @returns {Promise<this>} Returns this instance for method chaining
198
198
  */
package/src/Compiler.js CHANGED
@@ -222,7 +222,7 @@ export default class Compiler {
222
222
  * evaluation.
223
223
  *
224
224
  * @param {object} work - The object to decompose
225
- * @param {string[]} path - Current path array for nested properties
225
+ * @param {Array<string>} path - Current path array for nested properties
226
226
  * @returns {Array<object>} Array of decomposed object entries with path information
227
227
  */
228
228
  #decomposeObject(work, path = []) {
package/src/Evaluator.js CHANGED
@@ -318,11 +318,16 @@ export default class Evaluator {
318
318
  return null
319
319
 
320
320
  const resolved = value.replace(captured, applied)
321
-
322
- return new ThemeToken(value)
321
+ const token = new ThemeToken(value)
323
322
  .setKind("function")
324
323
  .setRawValue(captured)
325
324
  .setValue(resolved)
325
+
326
+ if(resolved !== applied) {
327
+ token.setFunctionResult(applied)
328
+ }
329
+
330
+ return token
326
331
  }
327
332
 
328
333
  /**
@@ -14,6 +14,9 @@ import Theme from "./Theme.js"
14
14
  * Provides introspection into the theme resolution process and variable dependencies.
15
15
  */
16
16
  export default class ResolveCommand extends Command {
17
+ #extraOptions = null
18
+ #bg = null
19
+
17
20
  /**
18
21
  * Creates a new ResolveCommand instance.
19
22
  *
@@ -28,6 +31,22 @@ export default class ResolveCommand extends Command {
28
31
  "tokenColor": ["-t, --tokenColor <scope>", "resolve a tokenColors scope to its final evaluated value"],
29
32
  "semanticTokenColor": ["-s, --semanticTokenColor <scope>", "resolve a semanticTokenColors scope to its final evaluated value"],
30
33
  })
34
+ this.#extraOptions = {
35
+ "bg": ["--bg <hex>", "background colour for alpha swatch preview (e.g. 1a1a1a or '#1a1a1a')"],
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Builds the CLI command, adding extra options that are not mutually exclusive.
41
+ *
42
+ * @param {object} program - The commander.js program instance
43
+ * @returns {Promise<this>} Returns this instance for method chaining
44
+ */
45
+ async buildCli(program) {
46
+ await super.buildCli(program)
47
+ this.addCliOptions(this.#extraOptions, false)
48
+
49
+ return this
31
50
  }
32
51
 
33
52
  /**
@@ -59,6 +78,16 @@ export default class ResolveCommand extends Command {
59
78
  )
60
79
  }
61
80
 
81
+ if(options.bg) {
82
+ const bg = options.bg.startsWith("#") ? options.bg : `#${options.bg}`
83
+
84
+ if(!Colour.isHex(bg)) {
85
+ throw Sass.new(`Invalid --bg colour: ${options.bg}`)
86
+ }
87
+
88
+ this.#bg = Colour.normaliseHex(bg)
89
+ }
90
+
62
91
  const resolveFunctionName = `resolve${Util.capitalize(optionName)}`
63
92
  const optionValue = options[optionName]
64
93
  const resolverFunction = this[resolveFunctionName]
@@ -334,6 +363,18 @@ export default class ResolveCommand extends Command {
334
363
  }
335
364
  }
336
365
 
366
+ // For function tokens embedded in a larger expression, show the
367
+ // direct function output before showing the full substituted result
368
+ const funcResult = token.getFunctionResult?.()
369
+
370
+ if(funcResult && !steps.some(s => s.value === funcResult)) {
371
+ steps.push({
372
+ value: funcResult,
373
+ type: "result",
374
+ level
375
+ })
376
+ }
377
+
337
378
  // Add final result for this token
338
379
  if(rawValue !== finalValue && !steps.some(s => s.value === finalValue)) {
339
380
  steps.push({
@@ -387,13 +428,13 @@ export default class ResolveCommand extends Command {
387
428
  const {value, depth, type} = step
388
429
  const [line, kind] = this.#formatLeaf(value)
389
430
 
390
- // Simple logic: only hex results get extra indentation with arrow, everything else is clean
431
+ // Simple logic: only hex results get extra indentation with arrow/swatch, everything else is clean
391
432
  if(type === "result" && kind === "hex") {
392
- // Hex results are indented one extra level with just spaces and arrow
433
+ // Hex results are indented one extra level with swatch or arrow
393
434
  const prefix = " ".repeat(depth + 1)
394
- const arrow = c`{arrow}→{/} `
435
+ const indicator = this.#makeIndicator(value, this.#bg)
395
436
 
396
- out.push(`${prefix}${arrow}${line}`)
437
+ out.push(`${prefix}${indicator} ${line}`)
397
438
  } else {
398
439
  // Everything else just gets clean indentation
399
440
  const prefix = " ".repeat(depth)
@@ -409,6 +450,59 @@ export default class ResolveCommand extends Command {
409
450
  #sub = Evaluator.sub
410
451
  #hex = value => Colour.isHex(value)
411
452
 
453
+ /**
454
+ * Creates a truecolor swatch glyph from a hex value.
455
+ *
456
+ * @private
457
+ * @param {string} hex - A 6- or 8-digit hex colour.
458
+ * @returns {string} A truecolor `■` character.
459
+ */
460
+ #swatch(hex) {
461
+ const solid = Colour.parseHexColour(hex).colour
462
+ const r = parseInt(solid.slice(1, 3), 16)
463
+ const g = parseInt(solid.slice(3, 5), 16)
464
+ const b = parseInt(solid.slice(5, 7), 16)
465
+
466
+ return `\x1b[38;2;${r};${g};${b}m■\x1b[0m`
467
+ }
468
+
469
+ /**
470
+ * Creates a colour swatch or fallback arrow indicator for a hex value.
471
+ * When the colour has alpha and no --bg is provided, shows two swatches
472
+ * (against black and white). With --bg, shows a single swatch composited
473
+ * against the specified background.
474
+ *
475
+ * @private
476
+ * @param {string} hex - The hex colour value.
477
+ * @param {string|null} bg - Optional background hex for alpha compositing.
478
+ * @returns {string} Swatch indicator(s) or styled arrow.
479
+ */
480
+ #makeIndicator(hex, bg = null) {
481
+ if(!Term.hasColor) {
482
+ return c`{arrow}→{/}`
483
+ }
484
+
485
+ const parsed = Colour.parseHexColour(hex)
486
+ const hasAlpha = !!parsed.alpha
487
+
488
+ if(!hasAlpha) {
489
+ return this.#swatch(hex)
490
+ }
491
+
492
+ const alphaPercent = Math.round(parsed.alpha.decimal * 100)
493
+
494
+ if(bg) {
495
+ const composited = Colour.mix(parsed.colour, bg, alphaPercent)
496
+
497
+ return this.#swatch(composited)
498
+ }
499
+
500
+ const onBlack = Colour.mix(parsed.colour, "#000000", alphaPercent)
501
+ const onWhite = Colour.mix(parsed.colour, "#ffffff", alphaPercent)
502
+
503
+ return `${this.#swatch(onBlack)}${this.#swatch(onWhite)}`
504
+ }
505
+
412
506
  /**
413
507
  * Formats a single ThemeToken for display in the theme resolution output,
414
508
  * applying colour and style based on its type.
@@ -432,12 +526,12 @@ export default class ResolveCommand extends Command {
432
526
  }
433
527
 
434
528
  if(this.#func.test(value)) {
435
- const result = Evaluator.extractFunctionCall(value)
529
+ const match = this.#func.exec(value)
436
530
 
437
- if(!result)
531
+ if(!match?.groups)
438
532
  return [c`{leaf}${value}{/}`, "literal"]
439
533
 
440
- const {func, args} = result
534
+ const {func, args} = match.groups
441
535
 
442
536
  return [
443
537
  c`{func}${func}{/}{parens}${"("}{/}{leaf}${args}{/}{parens}${")"}{/}`,
package/src/Session.js CHANGED
@@ -6,6 +6,7 @@ import {Promised, Sass, Term, Util} from "@gesslar/toolkit"
6
6
  /**
7
7
  * @import {Command} from "./Command.js"
8
8
  * @import {Theme} from "./Theme.js"
9
+ * @import {FSWatcher} from "chokidar"
9
10
  */
10
11
 
11
12
  /**
@@ -58,7 +59,7 @@ export default class Session {
58
59
  /**
59
60
  * Active file system watcher for theme dependencies.
60
61
  *
61
- * @type {import("chokidar").FSWatcher|null}
62
+ * @type {FSWatcher}
62
63
  * @private
63
64
  */
64
65
  #watcher = null
package/src/ThemeToken.js CHANGED
@@ -30,6 +30,7 @@ export default class ThemeToken {
30
30
  #parentTokenKey = null
31
31
  #trail = new Array()
32
32
  #parsedColor = null
33
+ #functionResult = null
33
34
 
34
35
  /**
35
36
  * Constructs a ThemeToken with a given token name.
@@ -242,6 +243,28 @@ export default class ThemeToken {
242
243
  return this.#parsedColor
243
244
  }
244
245
 
246
+ /**
247
+ * Sets the direct result of a colour function evaluation, before
248
+ * substitution back into the enclosing expression.
249
+ *
250
+ * @param {string} result - The direct function output.
251
+ * @returns {ThemeToken} This token instance.
252
+ */
253
+ setFunctionResult(result) {
254
+ this.#functionResult = result
255
+
256
+ return this
257
+ }
258
+
259
+ /**
260
+ * Gets the direct result of a colour function evaluation.
261
+ *
262
+ * @returns {string|null} The direct function output or null.
263
+ */
264
+ getFunctionResult() {
265
+ return this.#functionResult
266
+ }
267
+
245
268
  /**
246
269
  * Checks if this token has an ancestor with the given token name.
247
270
  *
package/src/cli.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * sourceFile: FileObject // entry theme file
17
17
  * source: object // raw parsed data (must contain `config`)
18
18
  * output: object // final theme JSON object
19
- * dependencies: FileObject[] // secondary sources discovered during compile
19
+ * dependencies: Array<FileObject> // secondary sources discovered during compile
20
20
  * lookup: object // variable lookup data for compilation
21
21
  * breadcrumbs: Map // variable resolution tracking
22
22
  * }
@@ -80,14 +80,13 @@ void (async function main() {
80
80
  c.alias.set("loc", "{F148}")
81
81
 
82
82
  // Resolve command
83
- c.alias.set("head", "{F220}")
84
- c.alias.set("leaf", "{F151}")
85
- c.alias.set("func", "{F111}")
86
- c.alias.set("parens", "{F098}")
87
- c.alias.set("line", "{F142}")
88
- c.alias.set("hex", "{F140}")
89
- c.alias.set("hash", "{F147}{<B}")
90
- c.alias.set("hexAlpha", "{F127}{<I}")
83
+ c.alias.set("head", "{F250}")
84
+ c.alias.set("leaf", "{F243}")
85
+ c.alias.set("func", "{<B}")
86
+ c.alias.set("parens", "{F208}")
87
+ c.alias.set("hash", "{F172}")
88
+ c.alias.set("hex", "{F025}")
89
+ c.alias.set("hexAlpha", "{F073}{<I}")
91
90
  c.alias.set("arrow", "{F033}")
92
91
 
93
92
  const cache = new Cache()