sass-func 1.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/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # sass-func
2
+
3
+ A collection of Sass utility functions for [lists](#module-list), [strings](#module-string), [numbers](#module-number), and [meta](#module-meta) utilities, inspired by familiar JS APIs. No dependencies beyond the Sass built-in modules. Where behaviour differs from a JS counterpart, the difference is noted.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install sass-func
11
+ ```
12
+
13
+ > Requires `sass >= 1.33.0` or `sass-embedded >= 1.33.0` — install one, not both. `sass-embedded` is recommended: it wraps a native Dart binary and is typically 20–30% faster to compile than the pure-JS `sass` package.
14
+
15
+ ---
16
+
17
+ ## Modules
18
+
19
+ | Module | Import path | Description |
20
+ | ------------------- | ----------------------------------- | -------------------------------------------------------- |
21
+ | [`list`](#module-list) | [`sass-func/list`](./list.scss) | Functions for creating, transforming, and querying lists |
22
+ | [`meta`](#module-meta) | [`sass-func/meta`](./meta.scss) | Functions for type inspection and emptiness checking |
23
+ | [`number`](#module-number) | [`sass-func/number`](./number.scss) | Functions for unit conversion and numeric operations |
24
+ | [`string`](#module-string) | [`sass-func/string`](./string.scss) | Functions for string transformation and parsing |
25
+
26
+ ---
27
+
28
+ ## Module: List
29
+
30
+ ```scss
31
+ @use 'sass-func/list' as l;
32
+ ```
33
+
34
+ ### `concat($lists...)`
35
+
36
+ Concatenates zero or more lists into a single flat list.
37
+
38
+ ```scss
39
+ l.concat((a, b), (c, d)) // → a, b, c, d
40
+ l.concat() // → ()
41
+ ```
42
+
43
+ ### `flat($list, $depth: null)`
44
+
45
+ Recursively flattens nested lists. Pass `$depth` to limit how many levels deep the flattening goes; omit it (or pass `null`) for unlimited depth. Similar to `Array.prototype.flat(depth)`.
46
+
47
+ > **Default depth differs:** Sass defaults to `null` (unlimited flattening); JS defaults to `1`.
48
+
49
+ ```scss
50
+ l.flat((a, (b, (c, d)))) // → a, b, c, d
51
+ l.flat((a, (b, (c, d))), 1) // → a, b, (c, d)
52
+ ```
53
+
54
+ ### `includes($list, $value)`
55
+
56
+ Returns `true` if `$value` is present in `$list`, `false` otherwise. Similar to `Array.prototype.includes(value)`.
57
+
58
+ ```scss
59
+ l.includes((a, b, c), b) // → true
60
+ l.includes((a, b, c), z) // → false
61
+ ```
62
+
63
+ ### `join($list, $separator: ', ')`
64
+
65
+ Serializes a list into a string, inserting `$separator` between elements. Similar to `Array.prototype.join(separator)`.
66
+
67
+ > **Default separator differs:** Sass defaults to `', '` (comma + space); JS defaults to `','` (comma only).
68
+
69
+ ```scss
70
+ l.join((a, b, c)) // → 'a, b, c'
71
+ l.join((a, b, c), ' | ') // → 'a | b | c'
72
+ ```
73
+
74
+ ### `push($list, $val, $separator: comma)`
75
+
76
+ Appends `$val` to the end of `$list`. `$separator` controls the resulting list's separator: `comma`, `space`, `slash`, or `auto`. Similar to `Array.prototype.push(val)` — unlike JS, this returns a new list rather than mutating in place.
77
+
78
+ ```scss
79
+ l.push((a, b), c) // → a, b, c
80
+ ```
81
+
82
+ ### `slice($list, $start: null, $end: null)`
83
+
84
+ Returns the sublist from index `$start` to `$end` (1-based, inclusive). Omitting either bound defaults to the start or end of the list. Similar to `Array.prototype.slice(start, end)`.
85
+
86
+ > **Index base and bounds differ:** Sass uses 1-based indices with an inclusive end; JS uses 0-based indices with an exclusive end. `slice(list, 2, 3)` returns elements 2 and 3; the JS equivalent would be `array.slice(1, 3)`.
87
+
88
+ ```scss
89
+ l.slice((a, b, c, d), 2, 3) // → b, c
90
+ l.slice((a, b, c, d), 3) // → c, d
91
+ ```
92
+
93
+ ### `sort($list, $desc: false)`
94
+
95
+ Sorts a list alphabetically (case-insensitive). Numbers sort by value. Pass `$desc: true` for descending order. Similar to `Array.prototype.sort()`.
96
+
97
+ > **Intentional differences:** Numbers sort by value (JS sorts lexicographically by default, so `[10, 2, 1].sort()` → `[1, 10, 2]` in JS). Sorting is also case-insensitive; JS is case-sensitive by default.
98
+
99
+ ```scss
100
+ l.sort((c, a, b)) // → a, b, c
101
+ l.sort((c, a, b), $desc: true) // → c, b, a
102
+ l.sort((3, 1, 2)) // → 1, 2, 3
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Module: Meta
108
+
109
+ ```scss
110
+ @use 'sass-func/meta' as m;
111
+ ```
112
+
113
+ ### `is-empty($value, $depth: 0)`
114
+
115
+ Returns `true` if `$value` is empty. Covers `null`, blank strings, empty lists, and empty maps. Pass `$depth > 0` or `null` to also check nested structures. Similar to `_.isEmpty(value)` (lodash) — extended with configurable recursion depth.
116
+
117
+ > **Whitespace strings:** A string containing only spaces (e.g. `' '`) is considered empty. JS `_.isEmpty(' ')` returns `false`.
118
+
119
+ ```scss
120
+ m.is-empty(null) // → true
121
+ m.is-empty('') // → true
122
+ m.is-empty(' ') // → true (whitespace-only)
123
+ m.is-empty(()) // → true
124
+ m.is-empty((a: ())) // → false (shallow)
125
+ m.is-empty((a: ()), $depth: 1) // → true (checks nested)
126
+ ```
127
+
128
+ ### `is-type($value, $types)`
129
+
130
+ Returns `true` if `$value`'s type is in the `$types` list.
131
+
132
+ ```scss
133
+ m.is-type(1rem, ('number', 'string')) // → true
134
+ m.is-type(true, ('number', 'string')) // → false
135
+ ```
136
+
137
+ ### `type-of($value)`
138
+
139
+ Returns the Sass type name of any value. A thin wrapper over `meta.type-of` — included for consistent import paths within this package. Similar to `typeof value` — returns Sass-specific type names (`'number'`, `'string'`, `'map'`, `'list'`, `'color'`, `'bool'`, `'null'`).
140
+
141
+ ```scss
142
+ m.type-of(1px) // → 'number'
143
+ m.type-of('hello') // → 'string'
144
+ m.type-of((a, b)) // → 'list'
145
+ m.type-of((a: 1)) // → 'map'
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Module: Number
151
+
152
+ ```scss
153
+ @use 'sass-func/number' as n;
154
+ ```
155
+
156
+ ### `$SUPPORTED_LENGTH_UNITS`
157
+
158
+ A list of the CSS length units supported by `add-unit` and `convert-unit`:
159
+
160
+ ```
161
+ px, rem, em, cm, mm, Q, in, pc, pt
162
+ ```
163
+
164
+ ### `add-unit($number, $unit)`
165
+
166
+ Attaches a CSS length unit to a unitless number.
167
+
168
+ ```scss
169
+ n.add-unit(16, 'px') // → 16px
170
+ n.add-unit(1, 'rem') // → 1rem
171
+ ```
172
+
173
+ ### `convert-unit($number, $from-unit, $to-unit)`
174
+
175
+ Converts a number from one CSS length unit to another.
176
+
177
+ ```scss
178
+ n.convert-unit(16px, 'px', 'rem') // → 1rem
179
+ n.convert-unit(1in, 'in', 'px') // → 96px
180
+ ```
181
+
182
+ ### `resolve-index($index, $length)`
183
+
184
+ Resolves a positive or negative integer index to a 1-based index within a collection of `$length`. Returns `null` if out of bounds. Negative indices count from the end, matching Python and modern JS convention.
185
+
186
+ > **1-based output:** Returns a 1-based index, matching Sass's index convention. Negative indices resolve relative to the end — `-1` on a length-5 list returns `5` (the last position), not `4`.
187
+
188
+ ```scss
189
+ n.resolve-index(2, 5) // → 2
190
+ n.resolve-index(-1, 5) // → 5
191
+ n.resolve-index(0, 5) // → null
192
+ n.resolve-index(6, 5) // → null
193
+ ```
194
+
195
+ ### `to-fixed($number, $digits: 0)`
196
+
197
+ Rounds a number to `$digits` decimal places. Similar to `Number.prototype.toFixed(digits)` — returns a number, not a string.
198
+
199
+ ```scss
200
+ n.to-fixed(3.14159, 2) // → 3.14
201
+ n.to-fixed(1.5) // → 2
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Module: String
207
+
208
+ ```scss
209
+ @use 'sass-func/string' as s;
210
+ ```
211
+
212
+ ### `capitalize($string)`
213
+
214
+ Capitalizes the first character and lowercases the rest. Similar to `_.capitalize(string)` (lodash).
215
+
216
+ > **Input is trimmed first:** Leading and trailing spaces are stripped before capitalizing. JS lodash preserves them.
217
+
218
+ ```scss
219
+ s.capitalize('hello world') // → 'Hello world'
220
+ s.capitalize('HELLO') // → 'Hello'
221
+ ```
222
+
223
+ ### `replace-all($string, $pattern, $replacement: '')`
224
+
225
+ Replaces every occurrence of `$pattern` in `$string` with `$replacement`. Defaults to deletion if `$replacement` is omitted. Similar to `String.prototype.replaceAll(pattern, replacement)`.
226
+
227
+ ```scss
228
+ s.replace-all('foo bar foo', 'foo', 'baz') // → 'baz bar baz'
229
+ s.replace-all('a--b--c', '--') // → 'abc'
230
+ ```
231
+
232
+ ### `slugify($string)`
233
+
234
+ Converts a string to a URL-safe slug: lowercased, spaces replaced with hyphens, special characters removed. Similar to `_.kebabCase(string)` (lodash) — with stricter special-character removal.
235
+
236
+ ```scss
237
+ s.slugify('Hello World!') // → 'hello-world'
238
+ s.slugify('Font Size (lg)') // → 'font-size-lg'
239
+ ```
240
+
241
+ ### `split($string, $separator: null)`
242
+
243
+ Splits a string into a list using `$separator`. An empty string splits into individual characters. `null` returns the string wrapped in a single-element list. Similar to `String.prototype.split(separator)`.
244
+
245
+ ```scss
246
+ s.split('a,b,c', ',') // → a, b, c
247
+ s.split('abc', '') // → a, b, c
248
+ s.split('abc') // → 'abc'
249
+ ```
250
+
251
+ ### `trim($string)`
252
+
253
+ Removes leading and trailing whitespace. Similar to `String.prototype.trim()`.
254
+
255
+ > **Space characters only:** Strips the space character `' '`. JS strips all whitespace (`\t`, `\n`, `\r`, etc.) — Sass has no native regex equivalent for this.
256
+
257
+ ```scss
258
+ s.trim(' hello ') // → 'hello'
259
+ ```
260
+
261
+ ### `upper-snake-case($string)`
262
+
263
+ Converts a string to `UPPER_SNAKE_CASE` by replacing hyphens and spaces with underscores.
264
+
265
+ ```scss
266
+ s.upper-snake-case('font-size') // → 'FONT_SIZE'
267
+ s.upper-snake-case('my variable') // → 'MY_VARIABLE'
268
+ ```
269
+
270
+ [Back to top](#sass-func)
package/list.scss ADDED
@@ -0,0 +1,229 @@
1
+ @use 'sass:list';
2
+ @use 'sass:meta';
3
+ @use 'sass:math';
4
+ @use 'sass:string';
5
+
6
+ // PUBLIC API ──────────────────────────────────────────────────────────────────
7
+
8
+ /// Concatenate zero or more lists into a single flat list
9
+ /// @param {ArgList} $lists... - Zero or more lists to concatenate
10
+ /// @return {List} A single flat list containing all elements
11
+ @function concat($lists...) {
12
+ $result: ();
13
+ @each $list in $lists {
14
+ $result: list.join($result, $list, $separator: auto, $bracketed: auto);
15
+ }
16
+ @return $result;
17
+ }
18
+
19
+ /// Recursively flatten nested lists to $depth levels deep, or fully if null
20
+ /// @param {List | ArgList} $list - The list to flatten
21
+ /// @param {Number | Null} $depth [null] - Flattening depth; null means unlimited
22
+ /// @return {List} Flattened list
23
+ @function flat($list, $depth: null) {
24
+ @if not list.index(('list', 'arglist'), meta.type-of($list)) {
25
+ @error '$list: #{meta.inspect($list)} is not a list or arglist.';
26
+ } @else if $depth != null and meta.type-of($depth) != 'number' {
27
+ @error '$depth: #{meta.inspect($depth)} is not a number or null.';
28
+ }
29
+
30
+ // Depth limit reached — return the list as-is
31
+ @if $depth == 0 {
32
+ @return $list;
33
+ }
34
+
35
+ $result: ();
36
+ @each $element in $list {
37
+ @if meta.type-of($element) != 'list' {
38
+ $result: push($result, $element);
39
+ } @else if $depth == 1 {
40
+ // Depth of 1 — inline sub-list elements directly without recursing
41
+ @each $sub-element in $element {
42
+ $result: push($result, $sub-element);
43
+ }
44
+ } @else {
45
+ // Recurse with decremented depth, or null for unlimited
46
+ $next-depth: null;
47
+ @if $depth == null {
48
+ $next-depth: null;
49
+ } @else {
50
+ $next-depth: $depth - 1;
51
+ }
52
+ $sub-list: flat($element, $next-depth);
53
+ @each $sub-element in $sub-list {
54
+ $result: push($result, $sub-element);
55
+ }
56
+ }
57
+ }
58
+ @return $result;
59
+ }
60
+
61
+ /// Check whether $value is present in $list
62
+ /// @param {List} $list - The list to search
63
+ /// @param {*} $value - The value to look for
64
+ /// @return {Bool} `true` if found, `false` otherwise
65
+ @function includes($list, $value) {
66
+ @return list.index($list, $value) != null;
67
+ }
68
+
69
+ /// Serialize $list elements into a string with $separator between each
70
+ /// @param {List | ArgList} $list - The list to join
71
+ /// @param {String} $separator [', '] - String inserted between elements
72
+ /// @return {String} Joined string
73
+ @function join($list, $separator: ', ') {
74
+ @if not list.index(('list', 'arglist'), meta.type-of($list)) {
75
+ @error '$list: #{meta.inspect($list)} is not a list or arglist.';
76
+ } @else if meta.type-of($separator) != 'string' {
77
+ @error '$separator: #{meta.inspect($separator)} is not a string.';
78
+ }
79
+
80
+ $result: '';
81
+ @each $element in $list {
82
+ @if $result == '' {
83
+ $result: $element;
84
+ } @else {
85
+ $result: $result + $separator + $element;
86
+ }
87
+ }
88
+ @return $result;
89
+ }
90
+
91
+ /// Append $val to the end of $list
92
+ /// @param {List} $list - The list to append to
93
+ /// @param {*} $val - The value to append
94
+ /// @param {String} $separator [comma] - List separator; `comma`, `space`, `slash`, or `auto`
95
+ /// @return {List} New list with $val appended
96
+ @function push($list, $val, $separator: comma) {
97
+ $allowed-separators: (comma, space, slash, auto);
98
+ @if list.index($allowed-separators, $separator) == null {
99
+ @error '$separator: Must be "space", "comma", "slash", or "auto".';
100
+ }
101
+
102
+ // Override separator for empty lists to prevent trailing-comma artifacts
103
+ @if list.length($list) == 0 {
104
+ $separator: space;
105
+ }
106
+
107
+ @return list.append($list, $val, $separator: $separator);
108
+ }
109
+
110
+ /// Return the sublist of $list from $start to $end (1-based, inclusive)
111
+ /// @param {List | ArgList} $list - The list to slice
112
+ /// @param {Number | Null} $start [null] - Start index; defaults to 1
113
+ /// @param {Number | Null} $end [null] - End index; defaults to list length
114
+ /// @return {List} Extracted sublist
115
+ @function slice($list, $start: null, $end: null) {
116
+ @if not list.index(('list', 'arglist'), meta.type-of($list)) {
117
+ @error '$list: #{meta.inspect($list)} is not a list or arglist.';
118
+ } @else if $start != null and meta.type-of($start) != 'number' {
119
+ @error '$start: #{meta.inspect($start)} is not a number or null.';
120
+ } @else if $end != null and meta.type-of($end) != 'number' {
121
+ @error '$end: #{meta.inspect($end)} is not a number or null.';
122
+ }
123
+
124
+ $length: list.length($list);
125
+
126
+ // Default to full-list bounds when not specified
127
+ @if $start == null {
128
+ $start: 1;
129
+ }
130
+ @if $end == null {
131
+ $end: $length;
132
+ }
133
+
134
+ // Clamp bounds to the valid index range of the list
135
+ $start: math.round(math.max(1, math.min($start, $length)));
136
+ @if $end < 1 {
137
+ $end: 0;
138
+ } @else {
139
+ $end: math.round(math.min($end, $length));
140
+ }
141
+
142
+ // Inverted range means no elements to extract
143
+ @if $end < $start {
144
+ @return ();
145
+ }
146
+
147
+ $result: ();
148
+ @for $i from $start through $end {
149
+ $result: push($result, list.nth($list, $i));
150
+ }
151
+ @return $result;
152
+ }
153
+
154
+ /// Sort $list alphabetically — case-insensitive. Numbers sort by value.
155
+ /// @param {List | ArgList} $list - The list to sort
156
+ /// @param {Bool} $desc [false] - Pass `true` for descending order
157
+ /// @return {List} Sorted list
158
+ @function sort($list, $desc: false) {
159
+ @if not list.index(('list', 'arglist'), meta.type-of($list)) {
160
+ @error '$list: #{meta.inspect($list)} is not a list or arglist.';
161
+ } @else if meta.type-of($desc) != 'bool' {
162
+ @error '$desc: #{meta.inspect($desc)} is not a bool.';
163
+ }
164
+
165
+ $n: list.length($list);
166
+ @if $n <= 1 {
167
+ @return $list;
168
+ }
169
+
170
+ // Character weight map — unknown chars (e.g. digits) fall back to 999 and sort to the end
171
+ $chars: 'abcdefghijklmnopqrstuvwxyz-';
172
+
173
+ // Middle-element pivot minimises worst-case depth on already-sorted input
174
+ $pivot: list.nth($list, math.ceil(math.div($n, 2)));
175
+ $pivot-lower: string.to-lower-case('#{$pivot}'); // Hoisted — shared by every item comparison
176
+
177
+ // 3-way partition buckets
178
+ $less: ();
179
+ $equal: ();
180
+ $greater: ();
181
+
182
+ // Partition: each item lands in exactly one bucket relative to the pivot
183
+ @each $item in $list {
184
+ @if $item == $pivot {
185
+ $equal: list.append($equal, $item);
186
+ } @else {
187
+ $is-less: null; // null = no decision yet
188
+
189
+ // Numbers compare by value — string coercion would give wrong order (e.g. '10' < '9')
190
+ @if meta.type-of($item) == 'number' and meta.type-of($pivot) == 'number' {
191
+ $is-less: $item < $pivot;
192
+ } @else {
193
+ // Walk characters until a difference is found, then exit immediately
194
+ $a: string.to-lower-case('#{$item}');
195
+ $b: $pivot-lower;
196
+ $len-a: string.length($a);
197
+ $len-b: string.length($b);
198
+ $min-len: math.min($len-a, $len-b);
199
+ $i: 1;
200
+
201
+ @while $i <= $min-len and $is-less == null {
202
+ $ca: string.slice($a, $i, $i);
203
+ $cb: string.slice($b, $i, $i);
204
+ @if $ca != $cb {
205
+ $is-less: (string.index($chars, $ca) or 999) < (string.index($chars, $cb) or 999);
206
+ }
207
+ $i: $i + 1;
208
+ }
209
+
210
+ // Equal prefix — shorter string sorts first
211
+ @if $is-less == null {
212
+ $is-less: $len-a < $len-b;
213
+ }
214
+ }
215
+
216
+ @if $is-less {
217
+ $less: list.append($less, $item);
218
+ } @else {
219
+ $greater: list.append($greater, $item);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Recurse on each partition, then join — flip less/greater order for descending
225
+ @if $desc {
226
+ @return list.join(list.join(sort($greater, $desc), $equal), sort($less, $desc));
227
+ }
228
+ @return list.join(list.join(sort($less, $desc), $equal), sort($greater, $desc));
229
+ }
package/meta.scss ADDED
@@ -0,0 +1,72 @@
1
+ @use 'sass:meta';
2
+ @use 'sass:list';
3
+ @use 'sass:map';
4
+ @use './string' as s;
5
+
6
+ // PUBLIC API ──────────────────────────────────────────────────────────────────
7
+
8
+ /// Return the Sass type name of $value
9
+ /// @param {*} $value - Any Sass value
10
+ /// @return {String} Type name, e.g. `'number'`, `'string'`, `'map'`, `'list'`
11
+ @function type-of($value) {
12
+ @return meta.type-of($value);
13
+ }
14
+
15
+ /// Check whether $value's type is one of the types in $types
16
+ /// @param {*} $value - The value to check
17
+ /// @param {List} $types - Allowed type names to match against
18
+ /// @return {Bool} `true` if $value's type is in $types, `false` otherwise
19
+ @function is-type($value, $types) {
20
+ @return list.index($types, meta.type-of($value)) != null;
21
+ }
22
+
23
+ /// Check whether $value is empty — pass $depth > 0 to also inspect nested structures
24
+ /// @param {*} $value - The value to check
25
+ /// @param {Number | Null} $depth [0] - Recursion depth; 0 = shallow only, null = unlimited
26
+ /// @return {Bool} `true` if empty at the given depth, `false` otherwise
27
+ @function is-empty($value, $depth: 0) {
28
+ @if $depth != null and meta.type-of($depth) != 'number' {
29
+ @error '$depth: #{meta.inspect($depth)} is not a number or null.';
30
+ }
31
+
32
+ $type: meta.type-of($value);
33
+
34
+ // Shallow emptiness — null, blank strings, and zero-length collections
35
+ @if $value == null {
36
+ @return true;
37
+ } @else if $type == 'string' and s.trim($value) == '' {
38
+ @return true;
39
+ } @else if list.index(('list', 'arglist'), $type) and list.length($value) == 0 {
40
+ @return true;
41
+ } @else if $type == 'map' and list.length(map.keys($value)) == 0 {
42
+ @return true;
43
+ }
44
+
45
+ // No recursion requested — value passed shallow checks, treat as non-empty
46
+ @if $depth == 0 {
47
+ @return false;
48
+ }
49
+
50
+ // Decrement depth for the next level, or carry null for unlimited recursion
51
+ $next-depth: null;
52
+ @if $depth != null {
53
+ $next-depth: $depth - 1;
54
+ }
55
+
56
+ @if list.index(('list', 'arglist'), $type) {
57
+ // Recurse into list and arglist elements
58
+ @each $element in $value {
59
+ @if is-empty($element, $next-depth) {
60
+ @return true;
61
+ }
62
+ }
63
+ } @else if $type == 'map' {
64
+ // Recurse into map keys and values
65
+ @each $k, $v in $value {
66
+ @if is-empty($k, $next-depth) or is-empty($v, $next-depth) {
67
+ @return true;
68
+ }
69
+ }
70
+ }
71
+ @return false;
72
+ }
package/number.scss ADDED
@@ -0,0 +1,159 @@
1
+ @use 'sass:meta';
2
+ @use 'sass:math';
3
+ @use 'sass:map';
4
+
5
+ // CONFIG ──────────────────────────────────────────────────────────────────────
6
+
7
+ // Pixel equivalents for each supported CSS length unit (used as conversion pivot)
8
+ $_LENGTH_UNIT_PX_RATIOS: (
9
+ 'px': 1,
10
+ 'rem': 16,
11
+ 'em': 16,
12
+ 'cm': math.div(96, 2.54),
13
+ 'mm': math.div(96, 25.4),
14
+ 'Q': math.div(96, 101.6),
15
+ 'in': 96,
16
+ 'pc': math.div(96, 6),
17
+ 'pt': math.div(96, 72),
18
+ );
19
+
20
+ // DERIVED ─────────────────────────────────────────────────────────────────────
21
+
22
+ // Expose supported units publicly
23
+ $SUPPORTED_LENGTH_UNITS: map.keys($_LENGTH_UNIT_PX_RATIOS);
24
+
25
+ // PUBLIC API ──────────────────────────────────────────────────────────────────
26
+
27
+ /// Attach a CSS length unit to a unitless number
28
+ /// @param {Number} $number - A unitless number
29
+ /// @param {String} $unit - Target unit; see `$SUPPORTED_LENGTH_UNITS`
30
+ /// @return {Number} Number with the specified unit attached
31
+ @function add-unit($number, $unit) {
32
+ @if meta.type-of($number) != 'number' {
33
+ @error '$number: #{meta.inspect($number)} is not a number.';
34
+ } @else if meta.type-of($unit) != 'string' {
35
+ @error '$unit: #{meta.inspect($unit)} is not a string.';
36
+ } @else if not math.is-unitless($number) {
37
+ @error '$number: #{meta.inspect($number)} is not a unitless number.';
38
+ } @else if not map.has-key($_LENGTH_UNIT_PX_RATIOS, $unit) {
39
+ @error '$unit: Must be "px", "rem", "em", "cm", "mm", "Q", "in", "pc", or "pt".';
40
+ }
41
+
42
+ @if $unit == 'px' {
43
+ @return $number * 1px;
44
+ } @else if $unit == 'rem' {
45
+ @return $number * 1rem;
46
+ } @else if $unit == 'em' {
47
+ @return $number * 1em;
48
+ } @else if $unit == 'cm' {
49
+ @return $number * 1cm;
50
+ } @else if $unit == 'mm' {
51
+ @return $number * 1mm;
52
+ } @else if $unit == 'Q' {
53
+ @return $number * 1Q;
54
+ } @else if $unit == 'in' {
55
+ @return $number * 1in;
56
+ } @else if $unit == 'pc' {
57
+ @return $number * 1pc;
58
+ } @else if $unit == 'pt' {
59
+ @return $number * 1pt;
60
+ }
61
+ }
62
+
63
+ /// Convert a number from one CSS length unit to another
64
+ /// @param {Number} $number - Unitless number, or a number whose unit matches $from-unit
65
+ /// @param {String} $from-unit - Source unit; see `$SUPPORTED_LENGTH_UNITS`
66
+ /// @param {String} $to-unit - Target unit; see `$SUPPORTED_LENGTH_UNITS`
67
+ /// @return {Number} Converted number with the target unit attached
68
+ @function convert-unit($number, $from-unit, $to-unit) {
69
+ @if meta.type-of($number) != 'number' {
70
+ @error '$number: #{meta.inspect($number)} is not a number.';
71
+ } @else if meta.type-of($from-unit) != 'string' {
72
+ @error '$from-unit: #{meta.inspect($from-unit)} is not a string.';
73
+ } @else if meta.type-of($to-unit) != 'string' {
74
+ @error '$to-unit: #{meta.inspect($to-unit)} is not a string.';
75
+ } @else if not map.has-key($_LENGTH_UNIT_PX_RATIOS, $from-unit) {
76
+ @error '$from-unit: Must be "px", "rem", "em", "cm", "mm", "Q", "in", "pc", or "pt".';
77
+ } @else if not map.has-key($_LENGTH_UNIT_PX_RATIOS, $to-unit) {
78
+ @error '$to-unit: Must be "px", "rem", "em", "cm", "mm", "Q", "in", "pc", or "pt".';
79
+ } @else if not math.is-unitless($number) and math.unit($number) != $from-unit {
80
+ @error '$number: Must be unitless or use unit "#{$from-unit}".';
81
+ }
82
+
83
+ @if $from-unit == $to-unit {
84
+ @return $number;
85
+ }
86
+
87
+ $n: math.div($number, $number * 0 + 1); // strip unit
88
+ $result: math.div(
89
+ $n * map.get($_LENGTH_UNIT_PX_RATIOS, $from-unit),
90
+ map.get($_LENGTH_UNIT_PX_RATIOS, $to-unit)
91
+ );
92
+
93
+ @if $to-unit == 'px' {
94
+ @return $result * 1px;
95
+ } @else if $to-unit == 'rem' {
96
+ @return $result * 1rem;
97
+ } @else if $to-unit == 'em' {
98
+ @return $result * 1em;
99
+ } @else if $to-unit == 'cm' {
100
+ @return $result * 1cm;
101
+ } @else if $to-unit == 'mm' {
102
+ @return $result * 1mm;
103
+ } @else if $to-unit == 'Q' {
104
+ @return $result * 1Q;
105
+ } @else if $to-unit == 'in' {
106
+ @return $result * 1in;
107
+ } @else if $to-unit == 'pc' {
108
+ @return $result * 1pc;
109
+ } @else if $to-unit == 'pt' {
110
+ @return $result * 1pt;
111
+ }
112
+ }
113
+
114
+ /// Resolve a relative index (positive or negative) to a valid 1-based index
115
+ /// @param {Number} $index - Positive or negative integer index
116
+ /// @param {Number} $length - Length of the collection
117
+ /// @return {Number | Null} 1-based index, or `null` if out of bounds
118
+ @function resolve-index($index, $length) {
119
+ @if meta.type-of($index) != 'number' {
120
+ @error '$index: #{meta.inspect($index)} is not a number.';
121
+ } @else if meta.type-of($length) != 'number' {
122
+ @error '$length: #{meta.inspect($length)} is not a number.';
123
+ }
124
+
125
+ // Snap fractional inputs to the nearest whole position
126
+ $index: math.round($index);
127
+
128
+ // Translate negative indices from the end of the range
129
+ @if $index < 0 {
130
+ $index: $length + $index + 1;
131
+ }
132
+
133
+ // Signal out-of-bounds by returning null rather than throwing
134
+ @if $index < 1 or $index > $length {
135
+ @return null;
136
+ } @else {
137
+ @return $index;
138
+ }
139
+ }
140
+
141
+ /// Round a number to a fixed number of decimal places
142
+ /// @param {Number} $number - Number to round
143
+ /// @param {Number} $digits [0] - Decimal places to keep (>= 0); fractional values are floored
144
+ /// @return {Number} Rounded number
145
+ @function to-fixed($number, $digits: 0) {
146
+ @if meta.type-of($number) != 'number' {
147
+ @error '$number: #{meta.inspect($number)} is not a number.';
148
+ } @else if meta.type-of($digits) != 'number' {
149
+ @error '$digits: #{meta.inspect($digits)} is not a number.';
150
+ } @else if not math.is-unitless($digits) {
151
+ @error '$digits: #{meta.inspect($digits)} is not a unitless number.';
152
+ } @else if $digits < 0 {
153
+ @error '$digits: Must be 0 or greater.';
154
+ }
155
+
156
+ $digits: math.floor($digits);
157
+ $factor: math.pow(10, $digits);
158
+ @return math.div(math.round($number * $factor), $factor);
159
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "sass-func",
3
+ "version": "1.0.0",
4
+ "description": "A collection of Sass utility functions for lists, strings, numbers, and meta — inspired by familiar JS APIs.",
5
+ "keywords": [
6
+ "sass",
7
+ "scss",
8
+ "functions",
9
+ "utilities",
10
+ "helpers",
11
+ "list",
12
+ "string",
13
+ "number",
14
+ "meta"
15
+ ],
16
+ "homepage": "https://github.com/nicholasgillespie/sass-func#readme",
17
+ "license": "MIT",
18
+ "author": "Nicholas Gillespie (https://github.com/nicholasgillespie)",
19
+ "funding": {
20
+ "type": "github",
21
+ "url": "https://github.com/sponsors/nicholasgillespie"
22
+ },
23
+ "files": [
24
+ "list.scss",
25
+ "meta.scss",
26
+ "number.scss",
27
+ "string.scss",
28
+ "README.md"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/nicholasgillespie/sass-func.git"
33
+ },
34
+ "peerDependencies": {
35
+ "sass": ">=1.33.0",
36
+ "sass-embedded": ">=1.33.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "sass": {
40
+ "optional": true
41
+ },
42
+ "sass-embedded": {
43
+ "optional": true
44
+ }
45
+ }
46
+ }
package/string.scss ADDED
@@ -0,0 +1,145 @@
1
+ @use 'sass:meta';
2
+ @use 'sass:string';
3
+ @use 'sass:list';
4
+
5
+ // PUBLIC API ──────────────────────────────────────────────────────────────────
6
+
7
+ /// Capitalize first letter of $string and lowercase the remainder
8
+ /// @param {String} $string - Input string
9
+ /// @return {String} Capitalized string
10
+ @function capitalize($string) {
11
+ @if meta.type-of($string) != 'string' {
12
+ @error '$string: #{meta.inspect($string)} is not a string.';
13
+ }
14
+ $string: trim($string);
15
+ @if string.length($string) == 0 {
16
+ @return '';
17
+ }
18
+ $first: string.slice($string, 1, 1);
19
+ $rest: string.slice($string, 2);
20
+ @return string.to-upper-case($first) + string.to-lower-case($rest);
21
+ }
22
+
23
+ /// Replace all occurrences of $pattern with $replacement in $string
24
+ /// @param {String} $string - Input string
25
+ /// @param {String} $pattern - Substring to find
26
+ /// @param {String} $replacement [''] - Replacement string
27
+ /// @return {String} String with all $pattern occurrences replaced
28
+ @function replace-all($string, $pattern, $replacement: '') {
29
+ @if meta.type-of($string) != 'string' {
30
+ @error '$string: #{meta.inspect($string)} is not a string.';
31
+ } @else if meta.type-of($pattern) != 'string' {
32
+ @error '$pattern: #{meta.inspect($pattern)} is not a string.';
33
+ } @else if meta.type-of($replacement) != 'string' {
34
+ @error '$replacement: #{meta.inspect($replacement)} is not a string.';
35
+ }
36
+
37
+ // Empty pattern would cause infinite recursion — return early
38
+ @if $pattern == '' {
39
+ @return $string;
40
+ }
41
+
42
+ $index: string.index($string, $pattern);
43
+ @if $index {
44
+ @return string.slice($string, 1, $index - 1) + $replacement +
45
+ replace-all(string.slice($string, $index + string.length($pattern)), $pattern, $replacement);
46
+ }
47
+ @return $string;
48
+ }
49
+
50
+ /// Convert $string to URL-safe slug format (lowercase, hyphens, no special chars)
51
+ /// @param {String} $string - Input string
52
+ /// @return {String} URL-safe slug
53
+ @function slugify($string) {
54
+ @if meta.type-of($string) != 'string' {
55
+ @error '$string: #{meta.inspect($string)} is not a string.';
56
+ }
57
+
58
+ $lower: string.to-lower-case($string);
59
+ $result: '';
60
+ $disallowed-characters: '\'"!@#$%^&*()+=[]{}|;:,.<>?/`~';
61
+
62
+ // Walk each character, keeping only allowed ones
63
+ @for $i from 1 through string.length($lower) {
64
+ $character: string.slice($lower, $i, $i);
65
+ @if not string.index($disallowed-characters, $character) {
66
+ $result: $result + $character;
67
+ }
68
+ }
69
+
70
+ @return replace-all(trim($result), ' ', '-');
71
+ }
72
+
73
+ /// Split $string into a list using $separator
74
+ /// @param {String} $string - Input string
75
+ /// @param {String | Null} $separator [null] - Delimiter; '' splits per char, null wraps in a list
76
+ /// @return {List} List of string segments
77
+ @function split($string, $separator: null) {
78
+ @if meta.type-of($string) != 'string' {
79
+ @error '$string: #{meta.inspect($string)} is not a string.';
80
+ }
81
+
82
+ // Null separator — return the string wrapped in a single-element list
83
+ @if $separator == null {
84
+ @return list.append((), $string, space);
85
+ }
86
+ @if meta.type-of($separator) != 'string' {
87
+ @error '$separator: #{meta.inspect($separator)} is not a string or null.';
88
+ }
89
+
90
+ $result: ();
91
+
92
+ // Empty separator — split into individual characters
93
+ @if $separator == '' {
94
+ @for $i from 1 through string.length($string) {
95
+ $result: list.append($result, string.slice($string, $i, $i), comma);
96
+ }
97
+ @return $result;
98
+ }
99
+
100
+ $sep-length: string.length($separator);
101
+ $index: string.index($string, $separator);
102
+
103
+ // Iteratively slice at each separator match, accumulating parts
104
+ @while $index {
105
+ $part: string.slice($string, 1, $index - 1);
106
+ $result: list.append($result, $part, comma);
107
+ $string: string.slice($string, $index + $sep-length);
108
+ $index: string.index($string, $separator);
109
+ }
110
+
111
+ @return list.append($result, $string, comma);
112
+ }
113
+
114
+ /// Remove leading and trailing whitespace from $string
115
+ /// @param {String} $string - Input string
116
+ /// @return {String} Trimmed string
117
+ @function trim($string) {
118
+ @if meta.type-of($string) != 'string' {
119
+ @error '$string: #{meta.inspect($string)} is not a string.';
120
+ }
121
+
122
+ $start: 1;
123
+ $end: string.length($string);
124
+ @while $start <= $end and string.slice($string, $start, $start) == ' ' {
125
+ $start: $start + 1;
126
+ }
127
+ @while $end >= $start and string.slice($string, $end, $end) == ' ' {
128
+ $end: $end - 1;
129
+ }
130
+ @return string.slice($string, $start, $end);
131
+ }
132
+
133
+ /// Convert $string to UPPER_SNAKE_CASE by replacing hyphens and spaces with underscores
134
+ /// @param {String} $string - Input string
135
+ /// @return {String} UPPER_SNAKE_CASE string
136
+ @function upper-snake-case($string) {
137
+ @if meta.type-of($string) != 'string' {
138
+ @error '$string: #{meta.inspect($string)} is not a string.';
139
+ }
140
+
141
+ $string: trim($string);
142
+ $result: replace-all($string, '-', '_');
143
+ $result: replace-all($result, ' ', '_');
144
+ @return string.to-upper-case($result);
145
+ }