@stonyx/utils 0.1.0 → 0.2.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,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - dev
7
+ - main
8
+
9
+ concurrency:
10
+ group: ci-${{ github.head_ref || github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - name: Checkout code
19
+ uses: actions/checkout@v3
20
+
21
+ - name: Set up Node.js
22
+ uses: actions/setup-node@v3
23
+ with:
24
+ node-version: 22.18.0
25
+ cache: 'npm'
26
+
27
+ - name: Install dependencies
28
+ run: npm ci
29
+
30
+ - name: Run tests
31
+ run: npm test
package/README.md CHANGED
@@ -1,29 +1,226 @@
1
1
  # stonyx-utils
2
2
 
3
- ## Running the test suite
3
+ Utilities module for the Stonyx Framework. Provides helpers for files, objects, strings, dates, and promises.
4
+
5
+ ---
6
+
7
+ ## Quick Reference Table
8
+
9
+ | Category | Function | Description |
10
+ | ----------- | ----------------------- | -------------------------------------------------------- |
11
+ | **File** | `createFile` | Create a file (supports JSON serialization). |
12
+ | | `updateFile` | Atomically update a file. |
13
+ | | `copyFile` | Copy a file, with optional overwrite. |
14
+ | | `readFile` | Read a file (JSON optional), with missing file callback. |
15
+ | | `deleteFile` | Delete a file (ignore missing optional). |
16
+ | | `deleteDirectory` | Recursively delete a directory. |
17
+ | | `createDirectory` | Recursively create a directory. |
18
+ | | `forEachFileImport` | Dynamically import all JS files in a directory. |
19
+ | | `fileExists` | Check if a file exists. |
20
+ | **Object** | `deepCopy` | Deep clone an object or array. |
21
+ | | `objToJson` | Convert object to formatted JSON string. |
22
+ | | `makeArray` | Wrap a value in an array. |
23
+ | | `mergeObject` | Deep merge two objects (ignore new keys optional). |
24
+ | | `get` | Get nested property safely via dot path. |
25
+ | | `getOrSet` | Get or set value in a Map. |
26
+ | **String** | `kebabCaseToCamelCase` | Convert kebab-case to camelCase. |
27
+ | | `kebabCaseToPascalCase` | Convert kebab-case to PascalCase. |
28
+ | | `generateRandomString` | Generate a random alphanumeric string. |
29
+ | | `pluralize` | Return plural form of English nouns. |
30
+ | **Date** | `getTimestamp` | Return current UNIX timestamp in seconds. |
31
+ | **Promise** | `sleep` | Async delay for a given number of seconds. |
32
+
33
+ ---
34
+
35
+ ## Table of Contents
36
+
37
+ * [Installation](#installation)
38
+ * [Running the Test Suite](#running-the-test-suite)
39
+ * [File Utils](#file-utils)
40
+ * [Object Utils](#object-utils)
41
+ * [String Utils](#string-utils)
42
+ * [Date Utils](#date-utils)
43
+ * [Promise Utils](#promise-utils)
44
+ * [License](#license)
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ npm install @stonyx/utils
4
52
  ```
5
- npm test
53
+
54
+ ---
55
+
56
+ ## File Utils
57
+
58
+ The File utils wrap the `path` and `fs` libraries to manipulate the local file system asynchronously with full async/await support. Includes creation, reading, updating, copying, deleting files and directories, checking existence, and dynamic importing via `forEachFileImport`.
59
+
60
+ ### Functions
61
+
62
+ #### `createFile(filePath, data, options={})`
63
+
64
+ Creates a file at the given path.
65
+
66
+ * `options.json` — boolean, if true, data will be serialized to JSON.
67
+
68
+ #### `updateFile(filePath, data, options={})`
69
+
70
+ Updates a file atomically by writing to a temporary file first.
71
+
72
+ * `options.json` — boolean, serialize as JSON.
73
+
74
+ #### `copyFile(sourcePath, targetPath, options={})`
75
+
76
+ Copies a file from source to target.
77
+
78
+ * `options.overwrite` — boolean, default false.
79
+
80
+ #### `readFile(filePath, options={})`
81
+
82
+ Reads a file, optionally parsing JSON.
83
+
84
+ * `options.json` — boolean
85
+ * `options.missingFileCallback` — function called if file doesn’t exist.
86
+
87
+ #### `deleteFile(filePath, options={})`
88
+
89
+ Deletes a file.
90
+
91
+ * `options.ignoreAccessFailure` — boolean, ignores errors if file missing.
92
+
93
+ #### `deleteDirectory(dir)`
94
+
95
+ Recursively deletes a directory.
96
+
97
+ #### `createDirectory(dir)`
98
+
99
+ Recursively creates a directory.
100
+
101
+ #### `forEachFileImport(dir, callback, options={})`
102
+
103
+ Dynamically imports all `.js` files in a directory and calls `callback(exports, details)`.
104
+
105
+ | Option | Type | Default | Description |
106
+ | :-------------------: | :-----: | :-----: | :-------------------------------------------------------- |
107
+ | `fullExport` | Boolean | false | If true, callback receives all exports, not just default. |
108
+ | `rawName` | Boolean | false | If true, the file name is not converted to camelCase. |
109
+ | `ignoreAccessFailure` | Boolean | false | If true, directory access errors are ignored. |
110
+
111
+ Example:
112
+
113
+ ```js
114
+ import { forEachFileImport } from '@stonyx/utils/file';
115
+
116
+ await forEachFileImport('./utils', (exports, details) => {
117
+ console.log(details.name, exports);
118
+ }, { fullExport: true });
6
119
  ```
7
120
 
8
- ## File utils
121
+ #### `fileExists(filePath)`
122
+
123
+ Returns `true` if file exists, else `false`.
124
+
125
+ ---
126
+
127
+ ## Object Utils
128
+
129
+ Helpers for working with objects and arrays.
9
130
 
10
- TODO: Update documentation to instruct a broader audience
131
+ ### Functions
11
132
 
12
- The File utils wrap the `path` and `fs` library to allow consuming classes to manipulate the local file system with full async/await support. Additionally it exposes the `forEachFileImport` method which lets us dynamically and flexibly import dependencies.
133
+ #### `deepCopy(obj)`
13
134
 
14
- ### Usage example
135
+ Returns a deep copy of an object or array.
136
+
137
+ #### `objToJson(obj, format='\t')`
138
+
139
+ Returns a formatted JSON string.
140
+
141
+ #### `makeArray(obj)`
142
+
143
+ Wraps a value in an array if it isn’t already one.
144
+
145
+ #### `mergeObject(obj1, obj2, options={})`
146
+
147
+ Deep merges two objects.
148
+
149
+ * `options.ignoreNewKeys` — boolean, if true, keys not in `obj1` are ignored.
150
+
151
+ #### `get(obj, path)`
152
+
153
+ Safely gets a nested property using dot notation. Returns `null` if path not found.
154
+
155
+ #### `getOrSet(map, key, defaultValue)`
156
+
157
+ Gets the value for a key in a `Map`, or sets it to `defaultValue` if missing.
158
+
159
+ ---
160
+
161
+ ## String Utils
162
+
163
+ ### Functions
164
+
165
+ #### `kebabCaseToCamelCase(str)`
166
+
167
+ Converts `'my-string'` → `'myString'`.
168
+
169
+ #### `kebabCaseToPascalCase(str)`
170
+
171
+ Converts `'my-string'` → `'MyString'`.
172
+
173
+ #### `generateRandomString(length=8)`
174
+
175
+ Generates a random alphanumeric string.
176
+
177
+ #### `pluralize(word)`
178
+
179
+ Returns the plural form of an English noun, handling irregulars and uncountable nouns.
180
+
181
+ Example:
182
+
183
+ ```js
184
+ import { pluralize } from '@stonyx/utils/string';
185
+
186
+ console.log(pluralize('person')); // people
187
+ console.log(pluralize('box')); // boxes
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Date Utils
193
+
194
+ ### Functions
195
+
196
+ #### `getTimestamp()`
197
+
198
+ Returns the current UNIX timestamp (seconds since epoch).
15
199
 
16
200
  ```js
17
- await forEachFileImport(targetDirectory, (exports, details) => {
18
- // Insert logic per export
19
- }, options);
201
+ import { getTimestamp } from '@stonyx/utils/date';
202
+
203
+ console.log(getTimestamp()); // e.g., 1693564800
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Promise Utils
209
+
210
+ ### Functions
211
+
212
+ #### `sleep(seconds)`
213
+
214
+ Delays execution for the given number of seconds.
215
+
216
+ ```js
217
+ import { sleep } from '@stonyx/utils/promise';
218
+
219
+ await sleep(2); // waits 2 seconds
20
220
  ```
21
221
 
22
- ### Valid Options
222
+ ---
23
223
 
24
- | Option | Type | Default | Description |
25
- | :---: | :---: | :---: | :--- |
26
- | `fullExport` | **Boolean** | *false* | When set to true, The `exports` parameter will be all exports, and not just the default one. |
27
- | `rawName` | **Boolean** | *false* | When set to true, `forEachFileImport` will not convert the file name to be camelCase and leave it raw instead |
28
- | `ignoreAccessFailure` | **Boolean** | *false* | When set to true, failure to load directory will be ignored |
224
+ ## License
29
225
 
226
+ Apache — do what you want, just keep attribution.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.1.0",
6
+ "version": "0.2.0",
7
7
  "description": "Utils module for Stonyx Framework",
8
8
  "type": "module",
9
9
  "exports": {
@@ -23,6 +23,7 @@
23
23
  ],
24
24
  "devDependencies": {
25
25
  "fs": "^0.0.1-security",
26
- "qunit": "^2.24.1"
26
+ "qunit": "^2.24.1",
27
+ "sinon": "^21.0.0"
27
28
  }
28
29
  }
package/src/file.js CHANGED
@@ -4,20 +4,23 @@ import { objToJson } from '@stonyx/utils/object';
4
4
  import { promises as fsp } from 'fs';
5
5
  import path from 'path';
6
6
 
7
- export async function createFile(filePath, data, { json }) {
7
+ export async function createFile(filePath, data, options={}) {
8
8
  try {
9
- await fsp.writeFile(filePath, json ? objToJson(data) : data, 'utf8');
9
+ filePath = path.resolve(filePath);
10
+
11
+ await createDirectory(path.dirname(filePath));
12
+ await fsp.writeFile(filePath, options.json ? objToJson(data) : data, 'utf8');
10
13
  } catch (error) {
11
14
  throw new Error(error);
12
15
  }
13
16
  }
14
17
 
15
- export async function updateFile(filePath, data, { json }) {
18
+ export async function updateFile(filePath, data, options={}) {
16
19
  try {
17
20
  await fsp.access(filePath);
18
21
 
19
22
  const swapFile = `${filePath}.temp-${getTimestamp()}`;
20
- await fsp.writeFile(swapFile, json ? objToJson(data) : data);
23
+ await fsp.writeFile(swapFile, options.json ? objToJson(data) : data);
21
24
  await fsp.rename(swapFile, filePath);
22
25
  } catch (error) {
23
26
 
@@ -25,15 +28,40 @@ export async function updateFile(filePath, data, { json }) {
25
28
  }
26
29
  }
27
30
 
28
- export async function readFile(filePath, { json, missingFileCallback }) {
31
+ export async function copyFile(sourcePath, targetPath, options={}) {
32
+ try {
33
+ sourcePath = path.resolve(sourcePath);
34
+ targetPath = path.resolve(targetPath);
35
+ await fsp.access(sourcePath);
36
+ } catch (error) {
37
+ throw new Error(error);
38
+ }
39
+
40
+ try {
41
+ await fsp.access(targetPath);
42
+ if (!options.overwrite) return false;
43
+ } catch {}
44
+
45
+ try {
46
+ await fsp.copyFile(sourcePath, targetPath);
47
+ } catch (error) {
48
+ throw new Error(error);
49
+ }
50
+
51
+ return true;
52
+ }
53
+
54
+ export async function readFile(filePath, options={}) {
29
55
  try {
30
56
  filePath = path.resolve(filePath);
31
57
 
32
58
  await fsp.access(filePath);
33
59
  const fileData = await fsp.readFile(filePath, 'utf8');
34
60
 
35
- return json ? JSON.parse(fileData) : fileData;
61
+ return options.json ? JSON.parse(fileData) : fileData;
36
62
  } catch (error) {
63
+ const { missingFileCallback } = options;
64
+
37
65
  if (error.code === 'ENOENT' && missingFileCallback) {
38
66
  return missingFileCallback(filePath);
39
67
  }
@@ -90,3 +118,13 @@ export async function forEachFileImport(dir, callback, options={}) {
90
118
  callback(output, { name, stats, path: filePath });
91
119
  }
92
120
  }
121
+
122
+ export async function fileExists(filePath) {
123
+ try {
124
+ filePath = path.resolve(filePath);
125
+ await fsp.access(filePath);
126
+ return true;
127
+ } catch (error) {
128
+ return false;
129
+ }
130
+ }
package/src/object.js CHANGED
@@ -2,46 +2,60 @@ export function deepCopy(obj) {
2
2
  return JSON.parse(JSON.stringify(obj));
3
3
  }
4
4
 
5
- export function objToJson(obj) {
6
- return JSON.stringify(obj);
5
+ export function objToJson(obj, format='\t') {
6
+ return JSON.stringify(obj, null, format);
7
7
  }
8
8
 
9
- export function mergeObject(obj1, obj2) {
9
+ export function makeArray(obj) {
10
+ return Array.isArray(obj) ? obj : [obj];
11
+ }
12
+
13
+ function cloneShallow(value) {
14
+ if (Array.isArray(value)) return value.slice();
15
+ if (value && typeof value === 'object') return { ...value };
16
+ return value;
17
+ }
18
+
19
+ export function mergeObject(obj1, obj2, options={}) {
10
20
  if (Array.isArray(obj1) || Array.isArray(obj2)) throw new Error('Cannot merge arrays.');
11
- if (typeof obj1 !== 'object' || obj1 === null) return structuredClone(obj2);
12
- if (typeof obj2 !== 'object' || obj2 === null) return structuredClone(obj1);
21
+
22
+ if (obj1 === null || typeof obj1 !== 'object') return cloneShallow(obj2);
23
+ if (obj2 === null || typeof obj2 !== 'object') return cloneShallow(obj1);
13
24
 
14
25
  const result = {};
15
- const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
16
26
 
17
- for (const key of keys) {
27
+ for (const key of Object.keys(obj1)) result[key] = cloneShallow(obj1[key]);
28
+ for (const key of Object.keys(obj2)) {
29
+ if (options.ignoreNewKeys && !(key in obj1)) continue;
30
+
18
31
  const val1 = obj1[key];
19
32
  const val2 = obj2[key];
20
-
21
- if (val2 === undefined) {
22
- result[key] = structuredClone(val1);
23
- } else if (val1 === undefined) {
24
- result[key] = structuredClone(val2);
25
- } else if (
26
- typeof val1 === 'object' &&
27
- typeof val2 === 'object' &&
28
- val1 !== null &&
29
- val2 !== null &&
30
- !Array.isArray(val1) &&
31
- !Array.isArray(val2)
32
- ) {
33
- result[key] = mergeObject(val1, val2);
34
- } else {
35
- result[key] = structuredClone(val2);
36
- }
33
+ const shouldMerge = val1 && val2 && typeof val1 === 'object' && typeof val2 === 'object' && !Array.isArray(val1) && !Array.isArray(val2);
34
+ result[key] = shouldMerge ? mergeObject(val1, val2, options) : cloneShallow(val2);
35
+
37
36
  }
38
37
 
39
38
  return result;
40
39
  }
41
40
 
42
- export function makeArray(value) {
43
- if (Array.isArray(value)) return value;
44
- if (value === null || value === undefined) return [];
41
+ export function get(obj, path) {
42
+ if (arguments.length !== 2) return console.error('Get must be called with two arguments; an object and a property key.');
43
+ if (!obj) return console.error(`Cannot call get with '${path}' on an undefined object.`);
44
+ if (typeof path !== 'string') return console.error('The path provided to get must be a string.');
45
+
46
+ for (const key of path.split('.')) {
47
+ if (obj[key] === undefined) return null;
48
+
49
+ obj = obj[key];
50
+ }
51
+
52
+ return obj;
53
+ }
54
+
55
+ export function getOrSet(map, key, defaultValue) {
56
+ if (!(map instanceof Map)) throw new Error('First argument to getOrSet must be a Map.');
57
+
58
+ if (!map.has(key)) map.set(key, typeof defaultValue === "function" ? defaultValue() : defaultValue);
45
59
 
46
- return [value];
60
+ return map.get(key);
47
61
  }
@@ -0,0 +1,105 @@
1
+ // --- Irregular nouns ---
2
+ const irregular = {
3
+ person: 'people',
4
+ man: 'men',
5
+ woman: 'women',
6
+ child: 'children',
7
+ tooth: 'teeth',
8
+ foot: 'feet',
9
+ mouse: 'mice',
10
+ goose: 'geese',
11
+ ox: 'oxen',
12
+ cactus: 'cacti',
13
+ nucleus: 'nuclei',
14
+ syllabus: 'syllabi',
15
+ focus: 'foci',
16
+ fungus: 'fungi',
17
+ appendix: 'appendices',
18
+ index: 'indices',
19
+ criterion: 'criteria',
20
+ phenomenon: 'phenomena',
21
+ die: 'dice',
22
+ thesis: 'theses',
23
+ analysis: 'analyses',
24
+ crisis: 'crises',
25
+ radius: 'radii',
26
+ corpus: 'corpora',
27
+ };
28
+
29
+ // --- Uncountables ---
30
+ const uncountable = new Set([
31
+ 'sheep', 'fish', 'deer', 'series', 'species', 'news', 'information',
32
+ 'rice', 'moose', 'bison', 'salmon', 'aircraft', 'offspring'
33
+ ]);
34
+
35
+ // --- Exceptions ---
36
+ const fExceptions = new Set(['chief', 'roof', 'belief', 'chef', 'cliff', 'reef', 'proof', 'brief']);
37
+
38
+ // Keep only true irregular -o exceptions (consonant + o but take just "s")
39
+ const oExceptions = new Set(['piano', 'photo', 'halo', 'canto', 'solo']);
40
+
41
+ // --- Utility to preserve casing ---
42
+ function applyCasing(original, plural) {
43
+ if (original === original.toUpperCase()) return plural.toUpperCase();
44
+ if (original === original.toLowerCase()) return plural.toLowerCase();
45
+ if (original[0] === original[0].toUpperCase()) {
46
+ return plural.charAt(0).toUpperCase() + plural.slice(1);
47
+ }
48
+ return plural;
49
+ }
50
+
51
+ // --- Rule-based pluralization ---
52
+ const rules = [
53
+ // quiz → quizzes, waltz → waltzes, topaz → topazes
54
+ [/z$/i, w => (/iz$/i.test(w) ? w + 'zes' : w + 'es')],
55
+
56
+ // bus → buses, box → boxes, church → churches, but stomach → stomachs (exclude -ach)
57
+ [/(s|x|ch|sh)$/i, w => (/ach$/i.test(w) ? w + 's' : w + 'es')],
58
+
59
+ // vowel + y → +s (key → keys)
60
+ [/[aeiou]y$/i, w => w + 's'],
61
+
62
+ // consonant + y → -ies (city → cities)
63
+ [/y$/i, w => w.slice(0, -1) + 'ies'],
64
+
65
+ // -fe → -ves (knife → knives), but not chief/roof/etc
66
+ [/fe$/i, w => (fExceptions.has(w) ? w + 's' : w.slice(0, -2) + 'ves')],
67
+
68
+ // -f → -ves (wolf → wolves), but not cliff/etc
69
+ [/f$/i, w => (fExceptions.has(w) ? w + 's' : w.slice(0, -1) + 'ves')],
70
+
71
+ // -sis → -ses (analysis → analyses, thesis → theses)
72
+ [/sis$/i, w => w.slice(0, -2) + 'ses'],
73
+
74
+ // vowel + o → +s (zoo → zoos, video → videos, patio → patios)
75
+ [/[aeiou]o$/i, w => w + 's'],
76
+
77
+ // consonant + o → usually +es, unless in oExceptions
78
+ [/o$/i, w => (oExceptions.has(w) ? w + 's' : w + 'es')],
79
+
80
+ // default: just +s
81
+ [/$/i, w => w + 's']
82
+ ];
83
+
84
+ // --- Exported pluralizer ---
85
+ export default function pluralize(word) {
86
+ if (typeof word !== 'string' || !/^[a-zA-Z]+$/.test(word)) {
87
+ throw new Error('Input must be a single word containing only letters.');
88
+ }
89
+
90
+ const lower = word.toLowerCase();
91
+
92
+ if (uncountable.has(lower)) return word;
93
+
94
+ if (irregular[lower]) {
95
+ return applyCasing(word, irregular[lower]);
96
+ }
97
+
98
+ for (const [pattern, transform] of rules) {
99
+ if (pattern.test(lower)) {
100
+ return applyCasing(word, transform(lower));
101
+ }
102
+ }
103
+
104
+ return word; // fallback (shouldn't hit)
105
+ }
package/src/string.js CHANGED
@@ -23,11 +23,9 @@ export function kebabCaseToPascalCase(str) {
23
23
  return kebabToCase(str, true);
24
24
  }
25
25
 
26
- export function pluralize(str) {
27
- return `${str}s`;
28
- }
29
-
30
26
  export function generateRandomString(length=8) {
31
27
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
32
28
  return Array(length).fill('').map(() => characters.charAt(Math.floor(Math.random() * characters.length))).join('');
33
- }
29
+ }
30
+
31
+ export { default as pluralize } from './plurarize.js';
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- v22.18.0
@@ -1,32 +0,0 @@
1
- import Qunit from 'qunit';
2
- import { mergeObject } from '@stonyx/utils/object';
3
-
4
- const { module, test } = Qunit;
5
-
6
- module('[Unit] Object', function() {
7
- test('mergeObject works as expected', async function(assert) {
8
- assert.deepEqual(
9
- mergeObject({ a: 1, b: 2 }, { c: 3 }),
10
- { a: 1, b: 2, c: 3 },
11
- 'New properties are added'
12
- );
13
-
14
- assert.deepEqual(
15
- mergeObject({ a: 1, b: 2, c: 3 }, { c: 4 }),
16
- { a: 1, b: 2, c: 4 },
17
- 'Old properties are overwritten'
18
- );
19
-
20
- assert.deepEqual(
21
- mergeObject({ a: 1, b: 2, c: 3 }, { a: 7, c: 23, d: { a: 1, b: 2, c: 3 } }),
22
- { a: 7, b: 2, c: 23, d: { a: 1, b: 2, c: 3 } },
23
- 'Objects are merged recursively'
24
- );
25
-
26
- try {
27
- mergeObject([], {});
28
- } catch {
29
- assert.ok(true, 'Array inputs throws error');
30
- }
31
- });
32
- });