@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/src/Type.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @file Type specification and validation utilities.
3
+ * Provides TypeSpec class for parsing and validating complex type specifications
4
+ * including arrays, unions, and options.
5
+ */
6
+
7
+ import Sass from "./Sass.js"
8
+ import Data from "./Data.js"
9
+
10
+ /**
11
+ * Type specification class for parsing and validating complex type definitions.
12
+ * Supports union types, array types, and validation options.
13
+ */
14
+ export default class TypeSpec {
15
+ #specs
16
+
17
+ /**
18
+ * Creates a new TypeSpec instance.
19
+ *
20
+ * @param {string} string - The type specification string (e.g., "string|number", "object[]")
21
+ * @param {object} options - Additional parsing options
22
+ */
23
+ constructor(string, options) {
24
+ this.#specs = []
25
+ this.#parse(string, options)
26
+ Object.freeze(this.#specs)
27
+ this.specs = this.#specs
28
+ this.length = this.#specs.length
29
+ this.stringRepresentation = this.toString()
30
+ Object.freeze(this)
31
+ }
32
+
33
+ /**
34
+ * Returns a string representation of the type specification.
35
+ *
36
+ * @returns {string} The type specification as a string (e.g., "string|number[]")
37
+ */
38
+ toString() {
39
+ return this.#specs
40
+ .map(spec => {
41
+ return `${spec.typeName}${spec.array ? "[]" : ""}`
42
+ })
43
+ .join("|")
44
+ }
45
+
46
+ /**
47
+ * Returns a JSON representation of the TypeSpec.
48
+ *
49
+ * @returns {object} Object containing specs, length, and string representation
50
+ */
51
+ toJSON() {
52
+ // Serialize as a string representation or as raw data
53
+ return {
54
+ specs: this.#specs,
55
+ length: this.length,
56
+ stringRepresentation: this.toString(),
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Executes a provided function once for each type specification.
62
+ *
63
+ * @param {Function} callback - Function to execute for each spec
64
+ */
65
+ forEach(callback) {
66
+ this.#specs.forEach(callback)
67
+ }
68
+
69
+ /**
70
+ * Tests whether all type specifications pass the provided test function.
71
+ *
72
+ * @param {Function} callback - Function to test each spec
73
+ * @returns {boolean} True if all specs pass the test
74
+ */
75
+ every(callback) {
76
+ return this.#specs.every(callback)
77
+ }
78
+
79
+ /**
80
+ * Tests whether at least one type specification passes the provided test function.
81
+ *
82
+ * @param {Function} callback - Function to test each spec
83
+ * @returns {boolean} True if at least one spec passes the test
84
+ */
85
+ some(callback) {
86
+ return this.#specs.some(callback)
87
+ }
88
+
89
+ /**
90
+ * Creates a new array with all type specifications that pass the provided test function.
91
+ *
92
+ * @param {Function} callback - Function to test each spec
93
+ * @returns {Array} New array with filtered specs
94
+ */
95
+ filter(callback) {
96
+ return this.#specs.filter(callback)
97
+ }
98
+
99
+ /**
100
+ * Creates a new array populated with the results of calling the provided function on every spec.
101
+ *
102
+ * @param {Function} callback - Function to call on each spec
103
+ * @returns {Array} New array with mapped values
104
+ */
105
+ map(callback) {
106
+ return this.#specs.map(callback)
107
+ }
108
+
109
+ /**
110
+ * Executes a reducer function on each spec, resulting in a single output value.
111
+ *
112
+ * @param {Function} callback - Function to execute on each spec
113
+ * @param {*} initialValue - Initial value for the accumulator
114
+ * @returns {*} The final accumulated value
115
+ */
116
+ reduce(callback, initialValue) {
117
+ return this.#specs.reduce(callback, initialValue)
118
+ }
119
+
120
+ /**
121
+ * Returns the first type specification that satisfies the provided testing function.
122
+ *
123
+ * @param {Function} callback - Function to test each spec
124
+ * @returns {object|undefined} The first spec that matches, or undefined
125
+ */
126
+ find(callback) {
127
+ return this.#specs.find(callback)
128
+ }
129
+
130
+ /**
131
+ * Tests whether a value matches any of the type specifications.
132
+ * Handles array types, union types, and empty value validation.
133
+ *
134
+ * @param {*} value - The value to test against the type specifications
135
+ * @param {object} options - Validation options
136
+ * @param {boolean} options.allowEmpty - Whether empty values are allowed
137
+ * @returns {boolean} True if the value matches any type specification
138
+ */
139
+ match(value, options) {
140
+ const allowEmpty = options?.allowEmpty ?? true
141
+ const empty = Data.isEmpty(value)
142
+
143
+ // If we have a list of types, because the string was validly parsed,
144
+ // we need to ensure that all of the types that were parsed are valid types
145
+ // in JavaScript.
146
+ if(this.length && !this.every(t => Data.isValidType(t.typeName)))
147
+ return false
148
+
149
+ // Now, let's do some checking with the types, respecting the array flag
150
+ // with the value
151
+ const valueType = Data.typeOf(value)
152
+ const isArray = valueType === "array"
153
+
154
+ // We need to ensure that we match the type and the consistency of the types
155
+ // in an array, if it is an array and an array is allowed.
156
+ const matchingTypeSpec = this.filter(spec => {
157
+ const {typeName: allowedType, array: allowedArray} = spec
158
+
159
+ if(valueType === allowedType && !isArray && !allowedArray)
160
+ return !allowEmpty ? !empty : true
161
+
162
+ if(isArray) {
163
+ if(allowedType === "array")
164
+ if(!allowedArray)
165
+ return true
166
+
167
+ if(empty)
168
+ if(allowEmpty)
169
+ return true
170
+
171
+ return Data.isArrayUniform(value, allowedType)
172
+ }
173
+ })
174
+
175
+ return matchingTypeSpec.length > 0
176
+ }
177
+
178
+ /**
179
+ * Parses a type specification string into individual type specs.
180
+ * Handles union types separated by delimiters and array notation.
181
+ *
182
+ * @private
183
+ * @param {string} string - The type specification string to parse
184
+ * @param {object} options - Parsing options
185
+ * @param {string} options.delimiter - The delimiter for union types
186
+ * @throws {TypeError} If the type specification is invalid
187
+ */
188
+ #parse(string, options) {
189
+ const delimiter = options?.delimiter ?? "|"
190
+ const parts = string.split(delimiter)
191
+
192
+ this.#specs = parts.map(part => {
193
+ const typeMatches = /(\w+)(\[\])?/.exec(part)
194
+ if(!typeMatches || typeMatches.length !== 3)
195
+ throw Sass.new(`Invalid type: ${part}`)
196
+
197
+ if(!Data.isValidType(typeMatches[1]))
198
+ throw Sass.new(`Invalid type: ${typeMatches[1]}`)
199
+
200
+ return {
201
+ typeName: typeMatches[1],
202
+ array: typeMatches[2] === "[]",
203
+ }
204
+ })
205
+ }
206
+ }
package/src/Util.js ADDED
@@ -0,0 +1,132 @@
1
+ import {createHash} from "node:crypto"
2
+ import {performance} from "node:perf_hooks"
3
+
4
+ /**
5
+ * Utility class providing common helper functions for string manipulation,
6
+ * timing, hashing, and option parsing.
7
+ */
8
+ export default class Util {
9
+ /**
10
+ * Capitalizes the first letter of a string.
11
+ *
12
+ * @param {string} text - The text to capitalize
13
+ * @returns {string} Text with first letter capitalized
14
+ */
15
+ static capitalize(text) {
16
+ return `${text.slice(0,1).toUpperCase()}${text.slice(1)}`
17
+ }
18
+
19
+ /**
20
+ * Measure wall-clock time for an async function.
21
+ *
22
+ * @template T
23
+ * @param {() => Promise<T>} fn - Thunk returning a promise.
24
+ * @returns {Promise<{result: T, cost: number}>} Object containing result and elapsed ms (number, 1 decimal).
25
+ */
26
+ static async time(fn) {
27
+ const t0 = performance.now()
28
+ const result = await fn()
29
+ const cost = Math.round((performance.now() - t0) * 10) / 10
30
+
31
+ return {result, cost}
32
+ }
33
+
34
+ /**
35
+ * Right-align a string inside a fixed width (left pad with spaces).
36
+ * If the string exceeds width it is returned unchanged.
37
+ *
38
+ * @param {string|number} text - Text to align.
39
+ * @param {number} width - Target field width (default 80).
40
+ * @returns {string} Padded string.
41
+ */
42
+ static rightAlignText(text, width=80) {
43
+ const work = String(text)
44
+
45
+ if(work.length > width)
46
+ return work
47
+
48
+ const diff = width-work.length
49
+
50
+ return `${" ".repeat(diff)}${work}`
51
+ }
52
+
53
+ /**
54
+ * Compute sha256 hash (hex) of the provided string.
55
+ *
56
+ * @param {string} s - Input string.
57
+ * @returns {string} 64-char hexadecimal digest.
58
+ */
59
+ static hashOf(s) {
60
+ return createHash("sha256").update(s).digest("hex")
61
+ }
62
+
63
+ /**
64
+ * Extracts canonical option names from a Commander-style options object.
65
+ *
66
+ * Each key in the input object is a string containing one or more option
67
+ * forms, separated by commas (e.g. "-w, --watch"). This function splits each
68
+ * key, trims whitespace, and parses out the long option name (e.g. "watch")
69
+ * for each entry. If no long option ("--") is present, the short option (e.g.
70
+ * "v" from "-v") will be included in the result array. If both are present,
71
+ * the long option is preferred.
72
+ *
73
+ * Example:
74
+ * generateOptionNames({"-w, --watch": "desc", "-v": "desc"})
75
+ * → ["watch", "v"]
76
+ *
77
+ * Edge cases:
78
+ * - If a key contains only a short option ("-v"), that short name will be
79
+ * included in the result.
80
+ * - If multiple long options are present, only the first is used.
81
+ * - If the option string is malformed, may return undefined for that entry
82
+ * (filtered out).
83
+ *
84
+ * @param {object} object - Mapping of option strings to descriptions.
85
+ * @returns {string[]} Array of canonical option names (long preferred, short if no long present).
86
+ */
87
+ static generateOptionNames(object) {
88
+ return Object.keys(object)
89
+ .map(key => {
90
+ return key
91
+ .split(",")
92
+ .map(o => o.trim())
93
+ .map(o => o.match(/^(?<sign>--?)(?<option>[\w-]+)/).groups)
94
+ .reduce((acc, curr) => acc.sign === "--" ? acc : curr, {})
95
+ ?.option
96
+ })
97
+ .filter(Boolean)
98
+ }
99
+
100
+ /**
101
+ * Asynchronously awaits all promises in parallel.
102
+ * Wrapper around Promise.all for consistency with other utility methods.
103
+ *
104
+ * @param {Promise[]} promises - Array of promises to await
105
+ * @returns {Promise<any[]>} Results of all promises
106
+ */
107
+ static async awaitAll(promises) {
108
+ return await Promise.all(promises)
109
+ }
110
+
111
+ /**
112
+ * Settles all promises (both fulfilled and rejected) in parallel.
113
+ * Wrapper around Promise.allSettled for consistency with other utility methods.
114
+ *
115
+ * @param {Promise[]} promises - Array of promises to settle
116
+ * @returns {Promise<Array>} Results of all settled promises with status and value/reason
117
+ */
118
+ static async settleAll(promises) {
119
+ return await Promise.allSettled(promises)
120
+ }
121
+
122
+ /**
123
+ * Returns the first promise to resolve or reject from an array of promises.
124
+ * Wrapper around Promise.race for consistency with other utility methods.
125
+ *
126
+ * @param {Promise[]} promises - Array of promises to race
127
+ * @returns {Promise<any>} Result of the first settled promise
128
+ */
129
+ static async race(promises) {
130
+ return await Promise.race(promises)
131
+ }
132
+ }
package/src/Valid.js ADDED
@@ -0,0 +1,50 @@
1
+ import _assert from "node:assert/strict"
2
+
3
+ import Sass from "./Sass.js"
4
+ import Data from "./Data.js"
5
+
6
+ export default class Valid {
7
+ /**
8
+ * Validates a value against a type
9
+ *
10
+ * @param {*} value - The value to validate
11
+ * @param {string} type - The expected type in the form of "object",
12
+ * "object[]", "object|object[]"
13
+ * @param {object} [options] - Additional options for validation.
14
+ */
15
+ static validType(value, type, options) {
16
+ Valid.assert(
17
+ Data.isType(value, type, options),
18
+ `Invalid type. Expected ${type}, got ${JSON.stringify(value)}`,
19
+ 1,
20
+ )
21
+ }
22
+
23
+ /**
24
+ * Asserts a condition
25
+ *
26
+ * @param {boolean} condition - The condition to assert
27
+ * @param {string} message - The message to display if the condition is not
28
+ * met
29
+ * @param {number} [arg] - The argument to display if the condition is not
30
+ * met (optional)
31
+ */
32
+ static assert(condition, message, arg = null) {
33
+ _assert(
34
+ Data.isType(condition, "boolean"),
35
+ `Condition must be a boolean, got ${condition}`,
36
+ )
37
+ _assert(
38
+ Data.isType(message, "string"),
39
+ `Message must be a string, got ${message}`,
40
+ )
41
+ _assert(
42
+ arg !== null || arg !== undefined && typeof arg === "number",
43
+ `Arg must be a number, got ${arg}`,
44
+ )
45
+
46
+ if(!condition)
47
+ throw Sass.new(`${message}${arg ? `: ${arg}` : ""}`)
48
+ }
49
+
50
+ }
package/src/cli.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file Sassy theme compiler CLI.
5
+ *
6
+ * Responsibilities:
7
+ * - Parse CLI arguments (supports JSON5 / YAML theme entries, globs resolved externally by the shell)
8
+ * - Create Theme instances for compilation units
9
+ * - Delegate compilation to Theme.build() which internally uses Compiler.compile()
10
+ * - Write (or print with --dry-run) the resulting VS Code colour theme JSON
11
+ * - Prevent unnecessary writes by hashing previous output
12
+ * - (Optional) Watch all participating source + imported files and recompile on change
13
+ *
14
+ * Key Concepts:
15
+ * Theme: {
16
+ * sourceFile: FileObject // entry theme file
17
+ * source: object // raw parsed data (must contain `config`)
18
+ * output: object // final theme JSON object
19
+ * dependencies: FileObject[] // secondary sources discovered during compile
20
+ * lookup: object // variable lookup data for compilation
21
+ * breadcrumbs: Map // variable resolution tracking
22
+ * }
23
+ *
24
+ * The Theme class manages its complete lifecycle:
25
+ * - load() - loads and parses the source file
26
+ * - build() - compiles the theme via Compiler
27
+ * - write() - outputs the compiled theme to file or stdout
28
+ * - Internal watch mode support with chokidar integration
29
+ *
30
+ * NOTE: The --profile flag is currently parsed but not yet producing timing output.
31
+ * Future enhancement could surface per-phase timings (load, compile, write, etc.).
32
+ */
33
+
34
+ import {program} from "commander"
35
+ import process from "node:process"
36
+ import url from "node:url"
37
+ import c from "@gesslar/colours"
38
+
39
+ import Cache from "./Cache.js"
40
+ import Sass from "./Sass.js"
41
+ import BuildCommand from "./BuildCommand.js"
42
+ import DirectoryObject from "./DirectoryObject.js"
43
+ import FileObject from "./FileObject.js"
44
+ import LintCommand from "./LintCommand.js"
45
+ import ResolveCommand from "./ResolveCommand.js"
46
+ import Term from "./Term.js"
47
+
48
+ /**
49
+ * Main application entry point.
50
+ * Sets up command line interface, validates input files, and handles compilation.
51
+ * Supports watch mode for automatic recompilation when files change.
52
+ *
53
+ * @returns {Promise<void>} Resolves when build process completes or exits on error.
54
+ */
55
+
56
+ /* =========================
57
+ Main
58
+ ========================= */
59
+
60
+ void (async function main() {
61
+ // we need nerd mode info here so that it's available in 'catch'
62
+ const sassyOptions = {}
63
+
64
+ setupAbortHandlers()
65
+
66
+ try {
67
+ // setup the colour aliases
68
+ // Term status stuff
69
+ c.alias.set("success", "{F070}")
70
+ c.alias.set("success-bracket", "{F076}")
71
+ c.alias.set("info", "{F038}")
72
+ c.alias.set("info-bracket", "{F087}")
73
+ c.alias.set("warn", "{F215}")
74
+ c.alias.set("warn-bracket", "{F208}")
75
+ c.alias.set("error", "{F196}")
76
+ c.alias.set("error-bracket", "{F160}")
77
+ c.alias.set("modified", "{F127}")
78
+ c.alias.set("modified-bracket", "{F165}")
79
+ c.alias.set("muted", "{F240}")
80
+ c.alias.set("muted-bracket", "{F244}")
81
+ // Lint command
82
+ c.alias.set("context", "{F159}")
83
+ // Resolve command
84
+ c.alias.set("head", "{F220}")
85
+ c.alias.set("leaf", "{F151}")
86
+ c.alias.set("func", "{F111}")
87
+ c.alias.set("parens", "{F098}")
88
+ c.alias.set("line", "{F142}")
89
+ c.alias.set("hex", "{F140}")
90
+ c.alias.set("hash", "{F147}{<B}")
91
+ c.alias.set("hexAlpha", "{F127}{<I}")
92
+ c.alias.set("arrow", "{F033}")
93
+
94
+ const cache = new Cache()
95
+ const cr = new DirectoryObject(url.fileURLToPath(new url.URL("..", import.meta.url)))
96
+ const cwd = new DirectoryObject(process.cwd())
97
+ const packageJson = new FileObject("package.json", cr)
98
+ const pkgJsonResult = await cache.loadCachedData(packageJson)
99
+ const pkgJson = pkgJsonResult
100
+
101
+ // These are available to all subcommands in addition to whatever they
102
+ // provide.
103
+ const alwaysAvailable = {
104
+ "nerd": ["--nerd", "enable stack tracing for debug purposes when errors are thrown"]
105
+ }
106
+
107
+ program
108
+ .name(pkgJson.name)
109
+ .description(pkgJson.description)
110
+ .version(pkgJson.version)
111
+
112
+ // Add the build subcommand
113
+ const buildCommand = new BuildCommand({cwd, packageJson: pkgJson})
114
+ buildCommand.cache = cache
115
+
116
+ void(await buildCommand.buildCli(program))
117
+ .addCliOptions(alwaysAvailable, false)
118
+
119
+ // Add the resolve subcommand
120
+ const resolveCommand = new ResolveCommand({cwd, packageJson: pkgJson})
121
+ resolveCommand.cache = cache
122
+
123
+ void(await resolveCommand.buildCli(program))
124
+ .addCliOptions(alwaysAvailable, false)
125
+
126
+ // Add the lint subcommand
127
+ const lintCommand = new LintCommand({cwd, packageJson: pkgJson})
128
+ lintCommand.cache = cache
129
+
130
+ void(await lintCommand.buildCli(program))
131
+ .addCliOptions(alwaysAvailable, false)
132
+
133
+ // Let'er rip, bitches! VROOM VROOM, motherfucker!!
134
+ await program.parseAsync()
135
+
136
+ } catch(error) {
137
+ Sass.new("Starting Sassy.", error)
138
+ .report(sassyOptions.nerd || true)
139
+
140
+ process.exit(1)
141
+ }
142
+
143
+ /**
144
+ * Creates handlers for various reasons that the application may crash.
145
+ */
146
+ function setupAbortHandlers() {
147
+ void["SIGINT", "SIGTERM", "SIGHUP"].forEach(signal => {
148
+ process.on(signal, async() => {
149
+ Term.log(`Received ${signal}, performing graceful shutdown...`)
150
+ await Term.resetTerminal()
151
+ process.exit(0)
152
+ })
153
+ })
154
+ }
155
+ })()