@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,188 @@
1
+ /**
2
+ * @file DirectoryObject.js
3
+ * @description Class representing a directory and its metadata, including path
4
+ * resolution and existence checks.
5
+ */
6
+
7
+ import path from "node:path"
8
+ import util from "node:util"
9
+
10
+ import File from "./File.js"
11
+
12
+ /**
13
+ * DirectoryObject encapsulates metadata and operations for a directory,
14
+ * including path resolution and existence checks.
15
+ *
16
+ * @property {string} supplied - The supplied directory
17
+ * @property {string} path - The resolved path
18
+ * @property {string} uri - The directory URI
19
+ * @property {string} name - The directory name
20
+ * @property {string} module - The directory name without extension
21
+ * @property {string} extension - The directory extension (usually empty)
22
+ * @property {boolean} isFile - Always false
23
+ * @property {boolean} isDirectory - Always true
24
+ * @property {Promise<boolean>} exists - Whether the directory exists (async)
25
+ */
26
+ export default class DirectoryObject {
27
+ /**
28
+ * @type {object}
29
+ * @private
30
+ * @property {string|null} supplied - User-supplied path
31
+ * @property {string|null} path - The absolute file path
32
+ * @property {string|null} uri - The file URI
33
+ * @property {string|null} name - The file name
34
+ * @property {string|null} module - The file name without extension
35
+ * @property {string|null} extension - The file extension
36
+ * @property {boolean} isFile - Always false
37
+ * @property {boolean} isDirectory - Always true
38
+ */
39
+ #meta = Object.seal({
40
+ supplied: null,
41
+ path: null,
42
+ uri: null,
43
+ name: null,
44
+ module: null,
45
+ extension: null,
46
+ isFile: false,
47
+ isDirectory: true,
48
+ directory: null,
49
+ })
50
+
51
+ /**
52
+ * Constructs a DirectoryObject instance.
53
+ *
54
+ * @param {string} directory - The directory path
55
+ */
56
+ constructor(directory) {
57
+ const fixedDir = File.fixSlashes(directory ?? ".")
58
+ const {base,ext} = File.deconstructFilenameToParts(fixedDir)
59
+ const fileUri = File.pathToUri(fixedDir)
60
+ const filePath = File.uriToPath(fileUri)
61
+
62
+ this.#meta.supplied = fixedDir
63
+ this.#meta.path = filePath
64
+ this.#meta.uri = fileUri
65
+ this.#meta.name = base
66
+ this.#meta.extension = ext
67
+ this.#meta.module = path.basename(this.supplied, this.extension)
68
+
69
+ Object.freeze(this.#meta)
70
+ }
71
+
72
+ /**
73
+ * Returns a string representation of the DirectoryObject.
74
+ *
75
+ * @returns {string} string representation of the DirectoryObject
76
+ */
77
+ toString() {
78
+ return `[DirectoryObject: ${this.path}]`
79
+ }
80
+
81
+ /**
82
+ * Returns a JSON representation of the DirectoryObject.
83
+ *
84
+ * @returns {object} JSON representation of the DirectoryObject
85
+ */
86
+ toJSON() {
87
+ return {
88
+ supplied: this.supplied,
89
+ path: this.path,
90
+ uri: this.uri,
91
+ name: this.name,
92
+ module: this.module,
93
+ extension: this.extension,
94
+ isFile: this.isFile,
95
+ isDirectory: this.isDirectory
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Custom inspect method for Node.js console.
101
+ *
102
+ * @returns {object} JSON representation of this object.
103
+ */
104
+ [util.inspect.custom]() {
105
+ return this.toJSON()
106
+ }
107
+
108
+ /**
109
+ * Checks if the directory exists (async).
110
+ *
111
+ * @returns {Promise<boolean>} - A Promise that resolves to true or false
112
+ */
113
+ get exists() {
114
+ return File.directoryExists(this)
115
+ }
116
+
117
+ /**
118
+ * Return the path as passed to the constructor.
119
+ *
120
+ * @returns {string} The directory path
121
+ */
122
+ get supplied() {
123
+ return this.#meta.supplied
124
+ }
125
+
126
+ /**
127
+ * Return the resolved path
128
+ *
129
+ * @returns {string} The directory path
130
+ */
131
+ get path() {
132
+ return this.#meta.path
133
+ }
134
+
135
+ /**
136
+ * Returns the URI of the current directory.
137
+ *
138
+ * @returns {string} The directory URI
139
+ */
140
+ get uri() {
141
+ return this.#meta.uri
142
+ }
143
+
144
+ /**
145
+ * Returns the directory name with extension (if any) without the path.
146
+ *
147
+ * @returns {string} The directory name
148
+ */
149
+ get name() {
150
+ return this.#meta.name
151
+ }
152
+
153
+ /**
154
+ * Returns the directory name without the path or extension.
155
+ *
156
+ * @returns {string} The directory name without extension
157
+ */
158
+ get module() {
159
+ return this.#meta.module
160
+ }
161
+
162
+ /**
163
+ * Returns the directory extension. Will be an empty string if unavailable.
164
+ *
165
+ * @returns {string} The directory extension
166
+ */
167
+ get extension() {
168
+ return this.#meta.extension
169
+ }
170
+
171
+ /**
172
+ * Returns false. Because this is a directory.
173
+ *
174
+ * @returns {boolean} Always false
175
+ */
176
+ get isFile() {
177
+ return this.#meta.isFile
178
+ }
179
+
180
+ /**
181
+ * We're a directory!
182
+ *
183
+ * @returns {boolean} Always true
184
+ */
185
+ get isDirectory() {
186
+ return this.#meta.isDirectory
187
+ }
188
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * @file Evaluator.js
3
+ *
4
+ * Defines the Evaluator class, responsible for variable and token resolution
5
+ * during theme compilation.
6
+ *
7
+ * Handles recursive substitution of variable references and colour function
8
+ * calls within theme configuration objects.
9
+ *
10
+ * Ensures deterministic scoping and supports extension for new colour
11
+ * functions.
12
+ */
13
+
14
+ import {parse} from "culori"
15
+
16
+ import Sass from "./Sass.js"
17
+ import Colour from "./Colour.js"
18
+ import ThemePool from "./ThemePool.js"
19
+ import ThemeToken from "./ThemeToken.js"
20
+
21
+ /**
22
+ * Evaluator class for resolving variables and colour tokens in theme objects.
23
+ * Handles recursive substitution of token references in arrays of objects
24
+ * with support for colour manipulation functions.
25
+ */
26
+ export default class Evaluator {
27
+ /**
28
+ * Maximum number of passes allowed while resolving tokens within a scope.
29
+ * Prevents infinite recursion in the event of cyclical or self-referential
30
+ * variable definitions.
31
+ *
32
+ * @private
33
+ * @type {number}
34
+ */
35
+ #maxIterations = 10
36
+
37
+ /**
38
+ * Regular expression used to locate variable substitution tokens. Supports:
39
+ * - POSIX-ish: $(variable.path)
40
+ * - Legacy: $variable.path
41
+ * - Braced: ${variable.path}
42
+ *
43
+ * Capturing groups allow extraction of the inner path variant irrespective
44
+ * of wrapping style. The pattern captures (entireMatch, posix, legacy,
45
+ * braced).
46
+ *
47
+ * @type {RegExp}
48
+ */
49
+ static sub = /(?<captured>\$\((?<parens>[^()]+)\)|\$(?<none>[\w]+(?:\.[\w]+)*)|\$\{(?<braces>[^()]+)\})/
50
+
51
+ /**
52
+ * Regular expression for matching colour / transformation function calls
53
+ * within token strings, e.g. `darken($(std.accent), 10)`.
54
+ *
55
+ * @type {RegExp}
56
+ */
57
+ static func = /(?<captured>(?<func>\w+)\((?<args>[^()]+)\))/
58
+
59
+ #pool = new ThemePool()
60
+ get pool() {
61
+ return this.#pool
62
+ }
63
+
64
+ /**
65
+ * Resolve variables and theme token entries in two distinct passes to ensure
66
+ * deterministic scoping and to prevent partially-resolved values from
67
+ * leaking between stages:
68
+ *
69
+ * 1. Variable pass: each variable is resolved only with access to the
70
+ * variable set itself (no theme values yet). This ensures variables are
71
+ * self-contained building blocks.
72
+ * 2. Theme pass: theme entries are then resolved against the union of the
73
+ * fully-resolved variables plus (progressively) the theme entries. This
74
+ * allows theme keys to reference variables and other theme keys.
75
+ *
76
+ * Implementation details:
77
+ * - The internal lookup map persists for the lifetime of this instance; new
78
+ * entries overwrite prior values (last write wins) so previously resolved
79
+ * data can seed later evaluations without a rebuild.
80
+ * - Input array is mutated in-place (`value` fields change).
81
+ * - No return value. Evident by the absence of a return statement.
82
+ *
83
+ * @param {Array<{flatPath:string,value:any}>} decomposed - Variable entries to resolve.
84
+ */
85
+ evaluate(decomposed) {
86
+ let it = 0
87
+ do {
88
+ decomposed.forEach(item => {
89
+ const trail = new Array()
90
+
91
+ if(typeof item.value === "string") {
92
+ const raw = item.value
93
+ item.value = this.#evaluateValue(trail, item.flatPath, raw)
94
+ // Keep lookup in sync with latest resolved value for chained deps.
95
+ const token = this.#pool.findToken(item.flatPath)
96
+ this.#pool.resolve(item.flatPath, item.value)
97
+ this.#pool.rawResolve(raw, item.value)
98
+
99
+ if(token) {
100
+ token.setValue(item.value).addTrail(trail)
101
+ } else {
102
+ const newToken = new ThemeToken(item.flatPath)
103
+ .setRawValue(raw)
104
+ .setValue(item.value)
105
+ .setKind("input")
106
+ .addTrail(trail)
107
+
108
+ this.#pool.addToken(newToken)
109
+ }
110
+ }
111
+ })
112
+ } while(
113
+ ++it < this.#maxIterations &&
114
+ this.#hasUnresolvedTokens(decomposed)
115
+ )
116
+
117
+ if(it === this.#maxIterations) {
118
+ const unresolved = decomposed
119
+ .filter(this.#tokenCheck)
120
+ .map(token => token.flatPath)
121
+
122
+ throw Sass.new(
123
+ "Luuuucyyyy! We tried to resolve your tokens, but there were just "+
124
+ "too many! Suspect maybe some circular references are interfering "+
125
+ "with your bliss. These are the ones that remain unresolved: " +
126
+ unresolved.toString()
127
+ )
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Resolve a variable or function token inside a string value; else return
133
+ * the passed value.
134
+ *
135
+ * @private
136
+ * @param {Array<ThemeToken>} trail - Array to track resolution chain.
137
+ * @param {string} parentTokenKeyString - Key string for parent token.
138
+ * @param {string} value - Raw tokenised string to resolve.
139
+ * @returns {string?} Fully resolved string.
140
+ * @throws If we've reached maximum iterations.
141
+ */
142
+ #evaluateValue(trail, parentTokenKeyString, value) {
143
+ let it = 0
144
+
145
+ do {
146
+ let resolved
147
+
148
+ if(Colour.isHex(value))
149
+ resolved = this.#resolveHex(value)
150
+ else if(Evaluator.sub.test(value))
151
+ resolved = this.#resolveVariable(value)
152
+ else if(Evaluator.func.test(value))
153
+ resolved = this.#resolveFunction(value)
154
+ else
155
+ resolved = this.#resolveLiteral(value)
156
+
157
+ if(!resolved || resolved.getValue() === value)
158
+ return value
159
+
160
+ // Otherwise keep processing the new value
161
+ this.#pool.addToken(resolved).setParentTokenKey(parentTokenKeyString)
162
+ trail.push(resolved)
163
+ value = resolved.getValue()
164
+ } while(++it < this.#maxIterations)
165
+
166
+ if(it === this.#maxIterations) {
167
+ throw Sass.new("HMMMMM! It looks like you might have some " +
168
+ "circular resolution happening. We tried to fix it up, but this " +
169
+ "doesn't seem to be working out. Trying to resolve: " +
170
+ `${parentTokenKeyString}, we got as far as ${value}, before we ` +
171
+ "called an end to this interminable game of Duck-Duck-Goose.")
172
+ }
173
+
174
+ return // it'll never reach here, but the linter got mad so i gave it a tit
175
+ }
176
+
177
+ /**
178
+ * Resolve a literal value to a ThemeToken.
179
+ *
180
+ * @private
181
+ * @param {string} value - The literal value.
182
+ * @returns {ThemeToken} The resolved token.
183
+ */
184
+ #resolveLiteral(value) {
185
+ const existing = this.#pool.findToken(value)
186
+
187
+ if(existing)
188
+ return existing
189
+
190
+ const token = new ThemeToken(value)
191
+ .setKind("literal")
192
+ .setRawValue(value)
193
+ .setValue(value)
194
+
195
+ // Check if this is a color function (like oklch, rgb, hsl, etc.)
196
+ const parsedColor = parse(value)
197
+ if(parsedColor) {
198
+ token.setParsedColor(parsedColor)
199
+ }
200
+
201
+ return token
202
+ }
203
+
204
+ /**
205
+ * Resolve a hex colour value to a ThemeToken.
206
+ *
207
+ * @private
208
+ * @param {string} value - The hex colour value.
209
+ * @returns {ThemeToken} The resolved token.
210
+ */
211
+ #resolveHex(value) {
212
+ const hex = Colour.normaliseHex(value)
213
+
214
+ return new ThemeToken(value)
215
+ .setKind("hex")
216
+ .setRawValue(value)
217
+ .setValue(hex)
218
+ }
219
+
220
+ /**
221
+ * Resolve a variable token to its value.
222
+ *
223
+ * @private
224
+ * @param {string} value - The variable token string.
225
+ * @returns {ThemeToken|null} The resolved token or null.
226
+ */
227
+ #resolveVariable(value) {
228
+ const {captured,none,parens,braces} = Evaluator.sub.exec(value).groups
229
+ const work = none ?? parens ?? braces
230
+ const existing = this.#pool.findToken(work)
231
+
232
+ if(!existing)
233
+ return null
234
+
235
+ const resolved = value.replace(captured,existing.getValue())
236
+
237
+ return new ThemeToken(value)
238
+ .setKind("variable")
239
+ .setRawValue(captured)
240
+ .setValue(resolved)
241
+ .setDependency(existing)
242
+ }
243
+
244
+ /**
245
+ * Resolve a function token to its value.
246
+ *
247
+ * @private
248
+ * @param {string} value - The function token string.
249
+ * @returns {ThemeToken|null} The resolved token or null.
250
+ */
251
+ #resolveFunction(value) {
252
+ const {captured,func,args} = Evaluator.func.exec(value).groups
253
+ const split = args?.split(",").map(a => a.trim()) ?? []
254
+
255
+ // Look up source tokens for arguments to preserve color space
256
+ const sourceTokens = split.map(arg => {
257
+ return this.#pool.findToken(arg) ||
258
+ this.#pool.getTokens?.get?.(arg) ||
259
+ null
260
+ })
261
+
262
+ const applied = this.#colourFunction(func, split, value, sourceTokens)
263
+
264
+ if(!applied)
265
+ return null
266
+
267
+ const resolved = value.replace(captured, applied)
268
+
269
+ return new ThemeToken(value)
270
+ .setKind("function")
271
+ .setRawValue(captured)
272
+ .setValue(resolved)
273
+ }
274
+
275
+ /**
276
+ * Execute a supported colour transformation helper.
277
+ *
278
+ * @private
279
+ * @param {string} func - Function name (lighten|darken|fade|alpha|mix|...)
280
+ * @param {Array<string>} args - Raw argument strings (numbers still as text).
281
+ * @param {string} raw - The raw input from the source file.
282
+ * @param {Array<ThemeToken>} sourceTokens - The tokens to apply to.
283
+ * @returns {object} Object with result and colorSpace info.
284
+ */
285
+ #colourFunction(func, args, raw, sourceTokens = []) {
286
+ return (() => {
287
+ try {
288
+ const sourceToken = sourceTokens[0]
289
+
290
+ switch(func) {
291
+ case "lighten":
292
+ return sourceToken
293
+ ? Colour.lightenOrDarkenWithToken(sourceToken, Number(args[1]))
294
+ : Colour.lightenOrDarken(args[0], Number(args[1]))
295
+ case "darken":
296
+ return sourceToken
297
+ ? Colour.lightenOrDarkenWithToken(sourceToken, -Number(args[1]))
298
+ : Colour.lightenOrDarken(args[0], -Number(args[1]))
299
+ case "fade":
300
+ return Colour.addAlpha(args[0], -Number(args[1]))
301
+ case "solidify":
302
+ return Colour.addAlpha(args[0], Number(args[1]))
303
+ case "alpha":
304
+ return Colour.setAlpha(args[0], Number(args[1]))
305
+ case "invert":
306
+ return Colour.invert(args[0])
307
+ case "mix":
308
+ return Colour.mix(
309
+ args[0],
310
+ args[1],
311
+ args[2] ? Number(args[2]) : undefined
312
+ )
313
+ case "css":
314
+ return Colour.toHex(args.toString())
315
+ default:
316
+ return Colour.toHex(raw)
317
+ }
318
+ } catch(e) {
319
+ throw Sass.new(`Performing colour function ${raw}`, e)
320
+ }
321
+ })()
322
+ }
323
+
324
+ /**
325
+ * Determine whether further resolution passes are required for a scope.
326
+ *
327
+ * @private
328
+ * @param {Array<object>} arr - Scope entries to inspect.
329
+ * @returns {boolean} True if any unresolved tokens remain.
330
+ */
331
+ #hasUnresolvedTokens(arr) {
332
+ return arr.some(item => this.#tokenCheck(item))
333
+ }
334
+
335
+ /**
336
+ * Predicate: does this item's value still contain variable or function tokens?
337
+ *
338
+ * @private
339
+ * @param {{value:any}} item - Entry to test.
340
+ * @returns {boolean} True if token patterns present.
341
+ */
342
+ #tokenCheck(item) {
343
+ if(typeof item.value !== "string")
344
+ return false
345
+
346
+ return Evaluator.sub.test(item.value) || Evaluator.func.test(item.value)
347
+ }
348
+ }