@jahands/dagger-helpers 0.7.3 → 0.7.5
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 +7 -6
- package/src/constants.spec.ts +16 -0
- package/src/constants.ts +21 -0
- package/src/convert/camel-to-snake.spec.ts +244 -0
- package/src/convert/camel-to-snake.ts +135 -0
- package/src/convert/snake-to-camel.spec.ts +221 -0
- package/src/convert/snake-to-camel.ts +101 -0
- package/src/env.spec.ts +468 -0
- package/src/env.ts +125 -0
- package/src/index.ts +11 -0
- package/src/path.spec.ts +34 -0
- package/src/path.ts +39 -0
- package/src/shell.ts +58 -0
- package/src/test/fixtures/repo/path/to/module/.dagger/src/index.ts +1 -0
- package/src/test/fixtures/repo/path/to/module/dagger.json +0 -0
- package/src/test/fixtures/repo/pnpm-lock.yaml +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jahands/dagger-helpers",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "dagger.io helpers for my own projects - not meant for public use",
|
|
6
6
|
"keywords": [
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"main": "./dist/index.mjs",
|
|
33
33
|
"module": "./dist/index.mjs",
|
|
34
34
|
"files": [
|
|
35
|
-
"dist"
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
36
37
|
],
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@types/node": "22.15.27",
|
|
@@ -40,12 +41,12 @@
|
|
|
40
41
|
"ts-pattern": "5.8.0",
|
|
41
42
|
"typescript": "5.8.2",
|
|
42
43
|
"vitest": "3.2.4",
|
|
43
|
-
"@repo/
|
|
44
|
-
"@repo/
|
|
45
|
-
"@repo/
|
|
44
|
+
"@repo/eslint-config": "0.2.3",
|
|
45
|
+
"@repo/tools": "0.12.3",
|
|
46
|
+
"@repo/typescript-config": "0.3.10"
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|
|
48
|
-
"@dagger.io/dagger": "^0.
|
|
49
|
+
"@dagger.io/dagger": "^0.21.6"
|
|
49
50
|
},
|
|
50
51
|
"publishConfig": {
|
|
51
52
|
"access": "public"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { constants } from './constants.js'
|
|
4
|
+
|
|
5
|
+
describe('constants', () => {
|
|
6
|
+
describe('workersMonorepo', () => {
|
|
7
|
+
describe('ignore', () => {
|
|
8
|
+
it('contains no duplicates', () => {
|
|
9
|
+
const list = constants.workersMonorepo.ignore
|
|
10
|
+
list.sort()
|
|
11
|
+
const unique = Array.from(new Set(list))
|
|
12
|
+
expect(unique).toStrictEqual(list)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
})
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** various constants for dagger modules in my repos */
|
|
2
|
+
export const constants = {
|
|
3
|
+
/** constants for workers-monorepo */
|
|
4
|
+
workersMonorepo: {
|
|
5
|
+
/** default source ignore list */
|
|
6
|
+
ignore: [
|
|
7
|
+
'**/node_modules/',
|
|
8
|
+
'**/.env',
|
|
9
|
+
'**/.secret',
|
|
10
|
+
'**/.wrangler',
|
|
11
|
+
'**/.dev.vars',
|
|
12
|
+
'**/.turbo/',
|
|
13
|
+
'**/dist/',
|
|
14
|
+
'**/dist2/',
|
|
15
|
+
'**/.DS_Store',
|
|
16
|
+
'**/.astro/',
|
|
17
|
+
'**/.next/',
|
|
18
|
+
'*.env',
|
|
19
|
+
] satisfies string[],
|
|
20
|
+
},
|
|
21
|
+
} as const
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, it, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { camelToSnake, convertToSnake } from './camel-to-snake.js'
|
|
4
|
+
|
|
5
|
+
// --- Tests for camelToSnake ---
|
|
6
|
+
describe('camelToSnake', () => {
|
|
7
|
+
test.each([
|
|
8
|
+
// Basic camelCase
|
|
9
|
+
{ input: 'helloWorld', expected: 'HELLO_WORLD' },
|
|
10
|
+
{ input: 'someValue', expected: 'SOME_VALUE' },
|
|
11
|
+
{ input: 'userId', expected: 'USER_ID' },
|
|
12
|
+
|
|
13
|
+
// PascalCase
|
|
14
|
+
{ input: 'HelloWorld', expected: 'HELLO_WORLD' },
|
|
15
|
+
{ input: 'SomeValue', expected: 'SOME_VALUE' },
|
|
16
|
+
{ input: 'UserId', expected: 'USER_ID' },
|
|
17
|
+
|
|
18
|
+
// Acronyms
|
|
19
|
+
{ input: 'someAPIKey', expected: 'SOME_API_KEY' },
|
|
20
|
+
{ input: 'someApiKey', expected: 'SOME_API_KEY' },
|
|
21
|
+
{ input: 'getHTTPResponseCode', expected: 'GET_HTTP_RESPONSE_CODE' },
|
|
22
|
+
{ input: 'parseURL', expected: 'PARSE_URL' },
|
|
23
|
+
{ input: 'URLParser', expected: 'URL_PARSER' }, // Acronym at start
|
|
24
|
+
|
|
25
|
+
// Numbers
|
|
26
|
+
{ input: 'version10', expected: 'VERSION_10' },
|
|
27
|
+
{ input: 'version10Alpha', expected: 'VERSION_10_ALPHA' },
|
|
28
|
+
{ input: 'apiV2Client', expected: 'API_V2_CLIENT' },
|
|
29
|
+
{ input: 'releaseV10', expected: 'RELEASE_V10' },
|
|
30
|
+
|
|
31
|
+
// Single words
|
|
32
|
+
{ input: 'word', expected: 'WORD' },
|
|
33
|
+
{ input: 'token', expected: 'TOKEN' },
|
|
34
|
+
{ input: 'Word', expected: 'WORD' }, // Single PascalCase word
|
|
35
|
+
{ input: 'TOKEN', expected: 'TOKEN' }, // Already uppercase
|
|
36
|
+
|
|
37
|
+
// Already snake_case (should just uppercase)
|
|
38
|
+
{ input: 'hello_world', expected: 'HELLO_WORLD' },
|
|
39
|
+
{ input: 'some_api_key', expected: 'SOME_API_KEY' },
|
|
40
|
+
|
|
41
|
+
// Edge cases
|
|
42
|
+
{ input: '', expected: '' },
|
|
43
|
+
{ input: 'a', expected: 'A' },
|
|
44
|
+
{ input: 'A', expected: 'A' },
|
|
45
|
+
{ input: '1', expected: '1' }, // Single number
|
|
46
|
+
{ input: 'v1', expected: 'V_1' },
|
|
47
|
+
{ input: 'v', expected: 'V' },
|
|
48
|
+
// Note: Behavior for leading/trailing underscores in input is not explicitly defined,
|
|
49
|
+
// the current regexes might produce slightly unexpected results like '__FOO' -> '__FOO'.
|
|
50
|
+
// Add specific tests if that behavior needs to be strictly defined.
|
|
51
|
+
])('should convert "$input" to "$expected"', ({ input, expected }) => {
|
|
52
|
+
expect(camelToSnake(input)).toBe(expected)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// --- Tests for convertToSnake ---
|
|
57
|
+
describe('convertToSnake', () => {
|
|
58
|
+
it('should convert keys of a basic camelCase object', () => {
|
|
59
|
+
const input = {
|
|
60
|
+
firstName: 'Jane',
|
|
61
|
+
lastName: 'Doe',
|
|
62
|
+
userId: 456,
|
|
63
|
+
}
|
|
64
|
+
const expected = {
|
|
65
|
+
FIRST_NAME: 'Jane',
|
|
66
|
+
LAST_NAME: 'Doe',
|
|
67
|
+
USER_ID: 456,
|
|
68
|
+
}
|
|
69
|
+
expect(convertToSnake(input)).toEqual(expected)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should convert keys of a basic PascalCase object', () => {
|
|
73
|
+
const input = {
|
|
74
|
+
FirstName: 'Jane',
|
|
75
|
+
LastName: 'Doe',
|
|
76
|
+
UserId: 456,
|
|
77
|
+
}
|
|
78
|
+
const expected = {
|
|
79
|
+
FIRST_NAME: 'Jane',
|
|
80
|
+
LAST_NAME: 'Doe',
|
|
81
|
+
USER_ID: 456,
|
|
82
|
+
}
|
|
83
|
+
expect(convertToSnake(input)).toEqual(expected)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should handle various value types correctly', () => {
|
|
87
|
+
const input = {
|
|
88
|
+
stringValue: 'test',
|
|
89
|
+
numberValue: 123,
|
|
90
|
+
booleanTrue: true,
|
|
91
|
+
nullValue: null,
|
|
92
|
+
undefinedValue: undefined,
|
|
93
|
+
arrayValue: [1, 'two', { nestedKey: true }], // nested keys are NOT converted
|
|
94
|
+
nestedObject: {
|
|
95
|
+
innerKey1: 'val1', // nested keys are NOT converted
|
|
96
|
+
innerKey2: false,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
const expected = {
|
|
100
|
+
STRING_VALUE: 'test',
|
|
101
|
+
NUMBER_VALUE: 123,
|
|
102
|
+
BOOLEAN_TRUE: true,
|
|
103
|
+
NULL_VALUE: null,
|
|
104
|
+
UNDEFINED_VALUE: undefined,
|
|
105
|
+
ARRAY_VALUE: [1, 'two', { nestedKey: true }],
|
|
106
|
+
NESTED_OBJECT: {
|
|
107
|
+
innerKey1: 'val1',
|
|
108
|
+
innerKey2: false,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
expect(convertToSnake(input)).toEqual(expected)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should return an empty object for an empty input object', () => {
|
|
115
|
+
const input = {}
|
|
116
|
+
const expected = {}
|
|
117
|
+
expect(convertToSnake(input)).toEqual(expected)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should handle keys with acronyms and numbers', () => {
|
|
121
|
+
const input = {
|
|
122
|
+
apiKey: 'abc',
|
|
123
|
+
httpStatusCode: 200,
|
|
124
|
+
version2Data: { id: 1 },
|
|
125
|
+
releaseV10: true,
|
|
126
|
+
}
|
|
127
|
+
const expected = {
|
|
128
|
+
API_KEY: 'abc',
|
|
129
|
+
HTTP_STATUS_CODE: 200,
|
|
130
|
+
VERSION_2_DATA: { id: 1 },
|
|
131
|
+
RELEASE_V10: true,
|
|
132
|
+
}
|
|
133
|
+
expect(convertToSnake(input)).toEqual(expected)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should handle keys that are already snake_case (lowercase or uppercase)', () => {
|
|
137
|
+
const input = {
|
|
138
|
+
already_snake: 'value1',
|
|
139
|
+
ALREADY_SNAKE: 'value2',
|
|
140
|
+
mixed_CASE_key: 'value3',
|
|
141
|
+
}
|
|
142
|
+
// const expected = {
|
|
143
|
+
// ALREADY_SNAKE: 'value1', // Becomes uppercase
|
|
144
|
+
// ALREADY_SNAKE: 'value2', // Stays uppercase
|
|
145
|
+
// MIXED_CASE_KEY: 'value3', // Becomes uppercase
|
|
146
|
+
// }
|
|
147
|
+
// Note: If input has keys that map to the same output key (like above), the last one wins.
|
|
148
|
+
// Test the final state:
|
|
149
|
+
const result = convertToSnake(input)
|
|
150
|
+
expect(result['ALREADY_SNAKE']).toBe('value2') // The second one overwrites the first
|
|
151
|
+
expect(result['MIXED_CASE_KEY']).toBe('value3')
|
|
152
|
+
expect(Object.keys(result).length).toBe(2) // Only two distinct keys remain
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should not include properties from the prototype chain', () => {
|
|
156
|
+
const proto = { protoKey: 'protoValue' }
|
|
157
|
+
const input = Object.create(proto)
|
|
158
|
+
input.ownKey = 'ownValue'
|
|
159
|
+
input.anotherOwn = 'another'
|
|
160
|
+
|
|
161
|
+
const expected = {
|
|
162
|
+
OWN_KEY: 'ownValue',
|
|
163
|
+
ANOTHER_OWN: 'another',
|
|
164
|
+
}
|
|
165
|
+
const result = convertToSnake(input)
|
|
166
|
+
expect(result).toEqual(expected)
|
|
167
|
+
expect(result.PROTO_KEY).toBeUndefined() // Ensure proto property isn't present
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should return a correctly typed object with specific SNAKE_CASE keys (compile-time check)', () => {
|
|
171
|
+
// Input object with various casing and types
|
|
172
|
+
const input = {
|
|
173
|
+
simpleKey: 'value1',
|
|
174
|
+
userID: 101,
|
|
175
|
+
dataPointValue: 99.9,
|
|
176
|
+
isActiveFlag: true,
|
|
177
|
+
addressDetails: { streetName: 'Maple Ave', zipCode: '90210' },
|
|
178
|
+
version2API: 'https://example.com/v2',
|
|
179
|
+
httpStatusCode: 200,
|
|
180
|
+
} as const // Use 'as const' for precise literal type inference
|
|
181
|
+
|
|
182
|
+
// Call the function - TypeScript infers the specific return type
|
|
183
|
+
const result = convertToSnake(input)
|
|
184
|
+
|
|
185
|
+
// --- Compile-Time Type Verification ---
|
|
186
|
+
|
|
187
|
+
// 1. Accessing correctly inferred keys works and has the correct type
|
|
188
|
+
const simple: 'value1' = result.SIMPLE_KEY
|
|
189
|
+
const user: 101 = result.USER_ID
|
|
190
|
+
const data: 99.9 = result.DATA_POINT_VALUE
|
|
191
|
+
const active: true = result.IS_ACTIVE_FLAG
|
|
192
|
+
const addr: { readonly streetName: 'Maple Ave'; readonly zipCode: '90210' } =
|
|
193
|
+
result.ADDRESS_DETAILS
|
|
194
|
+
const v2: 'https://example.com/v2' = result.VERSION_2_API
|
|
195
|
+
const status: 200 = result.HTTP_STATUS_CODE
|
|
196
|
+
|
|
197
|
+
// 2. Attempting to access original camelCase keys now causes a TS error
|
|
198
|
+
// @ts-expect-error Property 'simpleKey' does not exist on type '{...}'
|
|
199
|
+
const invalidAccess1 = result.simpleKey
|
|
200
|
+
// @ts-expect-error Property 'userID' does not exist on type '{...}'
|
|
201
|
+
const invalidAccess2 = result.userID
|
|
202
|
+
// @ts-expect-error Property 'addressDetails' does not exist on type '{...}'
|
|
203
|
+
const invalidAccess3 = result.addressDetails
|
|
204
|
+
|
|
205
|
+
// 3. Attempting to access a non-existent key also causes a TS error
|
|
206
|
+
// @ts-expect-error Property 'NON_EXISTENT_KEY' does not exist on type '{...}'
|
|
207
|
+
const invalidAccess4 = result.NON_EXISTENT_KEY
|
|
208
|
+
|
|
209
|
+
// 4. Assigning to an incorrect type causes a TS error
|
|
210
|
+
// @ts-expect-error Type '101' is not assignable to type 'string'.
|
|
211
|
+
const _wrongType: string = result.USER_ID
|
|
212
|
+
|
|
213
|
+
// --- Runtime Value Verification (Complementary) ---
|
|
214
|
+
// Ensure the actual values match the compile-time expectations
|
|
215
|
+
expect(simple).toBe('value1')
|
|
216
|
+
expect(user).toBe(101)
|
|
217
|
+
expect(data).toBe(99.9)
|
|
218
|
+
expect(active).toBe(true)
|
|
219
|
+
expect(addr.streetName).toBe('Maple Ave')
|
|
220
|
+
expect(v2).toBe('https://example.com/v2')
|
|
221
|
+
expect(status).toBe(200)
|
|
222
|
+
|
|
223
|
+
// Check the exact set of keys produced at runtime
|
|
224
|
+
expect(Object.keys(result).sort()).toStrictEqual(
|
|
225
|
+
[
|
|
226
|
+
'ADDRESS_DETAILS',
|
|
227
|
+
'DATA_POINT_VALUE',
|
|
228
|
+
'HTTP_STATUS_CODE',
|
|
229
|
+
'IS_ACTIVE_FLAG',
|
|
230
|
+
'SIMPLE_KEY',
|
|
231
|
+
'USER_ID',
|
|
232
|
+
'VERSION_2_API',
|
|
233
|
+
].sort()
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
// Runtime check that the invalid accesses actually yield undefined
|
|
237
|
+
expect(invalidAccess1).toBeUndefined()
|
|
238
|
+
expect(invalidAccess2).toBeUndefined()
|
|
239
|
+
expect(invalidAccess3).toBeUndefined()
|
|
240
|
+
expect(invalidAccess4).toBeUndefined()
|
|
241
|
+
// `wrongType` assignment doesn't affect runtime value of result.USER_ID
|
|
242
|
+
expect(result.USER_ID).toBe(101)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Helper to check if a character is lowercase A-Z
|
|
2
|
+
type IsLower<C extends string> =
|
|
3
|
+
Lowercase<C> extends Uppercase<C> ? false : C extends Lowercase<C> ? true : false
|
|
4
|
+
// Helper to check if a character is uppercase A-Z
|
|
5
|
+
type IsUpper<C extends string> =
|
|
6
|
+
Lowercase<C> extends Uppercase<C> ? false : C extends Uppercase<C> ? true : false
|
|
7
|
+
// Helper to check if a character is a digit 0-9
|
|
8
|
+
type IsDigit<C extends string> = C extends `${number}` ? true : false
|
|
9
|
+
|
|
10
|
+
// Recursive inner helper for CamelToSnakeCase
|
|
11
|
+
// PrevChar tracks the *previous* character from the original string
|
|
12
|
+
type CamelToSnakeInner<S extends string, PrevChar extends string = ''> =
|
|
13
|
+
// Base case: If the input string S is empty, return an empty string.
|
|
14
|
+
S extends ''
|
|
15
|
+
? ''
|
|
16
|
+
: // Recursive step: If S has content...
|
|
17
|
+
S extends `${infer First}${infer Rest}`
|
|
18
|
+
? // --- Logic based on the 'First' character ---
|
|
19
|
+
|
|
20
|
+
// 1. Is 'First' an uppercase letter?
|
|
21
|
+
IsUpper<First> extends true
|
|
22
|
+
? // 1a. Was 'PrevChar' lowercase or a digit? (Requires underscore)
|
|
23
|
+
IsLower<PrevChar> extends true
|
|
24
|
+
? `_${Uppercase<First>}${CamelToSnakeInner<Rest, First>}`
|
|
25
|
+
: IsDigit<PrevChar> extends true
|
|
26
|
+
? `_${Uppercase<First>}${CamelToSnakeInner<Rest, First>}`
|
|
27
|
+
: // 1b. 'PrevChar' was uppercase, symbol, or start. Check for acronym ending.
|
|
28
|
+
Rest extends `${infer NextChar}${string}` // Look ahead
|
|
29
|
+
? IsLower<NextChar> extends true // Is the *next* char lowercase?
|
|
30
|
+
? `_${Uppercase<First>}${CamelToSnakeInner<Rest, First>}` // Yes (e.g., API_Key)
|
|
31
|
+
: `${Uppercase<First>}${CamelToSnakeInner<Rest, First>}` // No (e.g., HTTP)
|
|
32
|
+
: // 'First' is the last character or followed by non-lowercase.
|
|
33
|
+
`${Uppercase<First>}${CamelToSnakeInner<Rest, First>}`
|
|
34
|
+
: // 2. Is 'First' a digit? (Only checked if not uppercase)
|
|
35
|
+
IsDigit<First> extends true
|
|
36
|
+
? // 2a. Was 'PrevChar' a lowercase letter? (Requires underscore)
|
|
37
|
+
IsLower<PrevChar> extends true
|
|
38
|
+
? `_${First}${CamelToSnakeInner<Rest, First>}`
|
|
39
|
+
: // 'PrevChar' was not lowercase (digit, uppercase, symbol, start)
|
|
40
|
+
`${First}${CamelToSnakeInner<Rest, First>}`
|
|
41
|
+
: // 3. 'First' must be a lowercase letter or a symbol.
|
|
42
|
+
// (Symbols are handled like lowercase here - no underscore needed before them)
|
|
43
|
+
`${Uppercase<First>}${CamelToSnakeInner<Rest, First>}` // Uppercase letter, append symbol directly
|
|
44
|
+
: // This case should technically not be reachable if S is not '', but included for completeness.
|
|
45
|
+
''
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Type helper to convert a camelCase/PascalCase string literal type
|
|
49
|
+
* to an UPPER_SNAKE_CASE string literal type. Handles common cases
|
|
50
|
+
* including transitions from lower to upper, letters to digits, and basic acronyms.
|
|
51
|
+
*
|
|
52
|
+
* Examples:
|
|
53
|
+
* 'userId' -> 'USER_ID'
|
|
54
|
+
* 'userName' -> 'USER_NAME'
|
|
55
|
+
* 'isActive' -> 'IS_ACTIVE'
|
|
56
|
+
* 'APIKey' -> 'API_KEY'
|
|
57
|
+
* 'version10' -> 'VERSION_10'
|
|
58
|
+
* 'apiV2Client' -> 'API_V2_CLIENT'
|
|
59
|
+
*/
|
|
60
|
+
type CamelToSnakeCase<S extends string> = S extends `${infer First}${infer Rest}`
|
|
61
|
+
? // Uppercase the very first character, then process the rest using the inner helper
|
|
62
|
+
`${Uppercase<First>}${CamelToSnakeInner<Rest, First>}`
|
|
63
|
+
: // Handle single character or empty string
|
|
64
|
+
Uppercase<S>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Converts a camelCase or PascalCase string to UPPER_SNAKE_CASE.
|
|
68
|
+
* Handles acronyms and numbers within the string.
|
|
69
|
+
*
|
|
70
|
+
* **Examples:**
|
|
71
|
+
* ```
|
|
72
|
+
* 'helloWorld' -> 'HELLO_WORLD'
|
|
73
|
+
* 'HelloWorld' -> 'HELLO_WORLD'
|
|
74
|
+
* 'someAPIKey' -> 'SOME_API_KEY'
|
|
75
|
+
* 'getHttpResponseCode' -> 'GET_HTTP_RESPONSE_CODE'
|
|
76
|
+
* 'version10' -> 'VERSION_10' // Fixed
|
|
77
|
+
* 'version10Alpha' -> 'VERSION_10_ALPHA' // Fixed
|
|
78
|
+
* 'apiV2Client' -> 'API_V2_CLIENT'
|
|
79
|
+
* 'releaseV10' -> 'RELEASE_V10'
|
|
80
|
+
* 'version2Data' -> 'VERSION_2_DATA' // Fixed
|
|
81
|
+
* 'word' -> 'WORD'
|
|
82
|
+
*```
|
|
83
|
+
* @param str The input string in camelCase or PascalCase format.
|
|
84
|
+
* @returns The converted string in UPPER_SNAKE_CASE format.
|
|
85
|
+
*/
|
|
86
|
+
export function camelToSnake(str: string): string {
|
|
87
|
+
if (!str) {
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The sequence matters:
|
|
92
|
+
const result = str
|
|
93
|
+
// 1. Add _ before an uppercase letter that is followed by a lowercase letter,
|
|
94
|
+
// but only if the uppercase letter is not the start of the string and
|
|
95
|
+
// is preceded by another uppercase letter (handles acronyms like APIKey -> API_Key).
|
|
96
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
|
|
97
|
+
// 2. Add _ before an uppercase letter that is preceded by a lowercase letter or a digit.
|
|
98
|
+
// (handles helloWorld -> hello_World, version10Update -> version10_Update, apiV2Client -> api_V2Client)
|
|
99
|
+
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
100
|
+
// 3. Add _ between a lowercase letter and a number.
|
|
101
|
+
// (handles version10 -> version_10, but NOT V10 -> V_10 because V is uppercase)
|
|
102
|
+
.replace(/([a-z])(\d)/g, '$1_$2')
|
|
103
|
+
|
|
104
|
+
// Convert the entire result to uppercase
|
|
105
|
+
return result.toUpperCase()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Converts an object's keys from camelCase/PascalCase to UPPER_SNAKE_CASE.
|
|
110
|
+
* Provides compile-time type safety for the converted keys based on common patterns.
|
|
111
|
+
*
|
|
112
|
+
* @template T The type of the input object.
|
|
113
|
+
* @param input The object with camelCase/PascalCase keys.
|
|
114
|
+
* @returns A new object with the same values but UPPER_SNAKE_CASE keys,
|
|
115
|
+
* with an inferred type reflecting the key transformation.
|
|
116
|
+
*/
|
|
117
|
+
export function convertToSnake<T extends Record<string, any>>(
|
|
118
|
+
input: T
|
|
119
|
+
// Apply the mapped type with key remapping using the type helper
|
|
120
|
+
): { [K in keyof T as CamelToSnakeCase<K & string>]: T[K] } {
|
|
121
|
+
const output: any = {} // Initialize as 'any' or '{}'
|
|
122
|
+
|
|
123
|
+
for (const key in input) {
|
|
124
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
|
125
|
+
const snakeCaseKey = camelToSnake(key) // Runtime conversion
|
|
126
|
+
output[snakeCaseKey] = input[key]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Type assertion: We trust the runtime `camelToSnake` logic produces keys
|
|
131
|
+
// matching the structure defined by the `CamelToSnakeCase` type helper.
|
|
132
|
+
// This is needed because TS can't perfectly verify the runtime logic
|
|
133
|
+
// against the complex type-level logic for all possible inputs.
|
|
134
|
+
return output as { [K in keyof T as CamelToSnakeCase<K & string>]: T[K] }
|
|
135
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { convertToCamel, snakeToCamel } from './snake-to-camel.js'
|
|
4
|
+
|
|
5
|
+
// --- Tests for snakeToCamel ---
|
|
6
|
+
describe('snakeToCamel', () => {
|
|
7
|
+
// Test cases using test.each for various formats
|
|
8
|
+
test.each([
|
|
9
|
+
// Basic cases
|
|
10
|
+
{ input: 'HELLO_WORLD', expected: 'helloWorld' },
|
|
11
|
+
{ input: 'SOME_API_KEY', expected: 'someApiKey' },
|
|
12
|
+
{ input: 'USER_ID', expected: 'userId' },
|
|
13
|
+
{ input: 'REQUEST_URL_PATH', expected: 'requestUrlPath' },
|
|
14
|
+
|
|
15
|
+
// All caps single word
|
|
16
|
+
{ input: 'WORD', expected: 'word' },
|
|
17
|
+
{ input: 'TOKEN', expected: 'token' },
|
|
18
|
+
|
|
19
|
+
// Lowercase single word (should ideally remain lowercase, current logic lowercases first char)
|
|
20
|
+
{ input: 'word', expected: 'word' },
|
|
21
|
+
{ input: 'token', expected: 'token' },
|
|
22
|
+
|
|
23
|
+
// Already camelCase (should ideally remain camelCase, current logic lowercases first char)
|
|
24
|
+
{ input: 'helloWorld', expected: 'helloWorld' },
|
|
25
|
+
{ input: 'someApiKey', expected: 'someApiKey' },
|
|
26
|
+
|
|
27
|
+
// PascalCase (should convert to camelCase)
|
|
28
|
+
{ input: 'HelloWorld', expected: 'helloWorld' },
|
|
29
|
+
{ input: 'SomeApiKey', expected: 'someApiKey' },
|
|
30
|
+
|
|
31
|
+
// Leading underscores
|
|
32
|
+
{ input: '_LEADING_UNDERSCORE', expected: 'leadingUnderscore' },
|
|
33
|
+
{ input: '__DOUBLE_LEADING', expected: 'doubleLeading' },
|
|
34
|
+
{ input: '_word', expected: 'word' }, // Single word with leading underscore
|
|
35
|
+
|
|
36
|
+
// Trailing underscores
|
|
37
|
+
{ input: 'TRAILING_UNDERSCORE_', expected: 'trailingUnderscore' },
|
|
38
|
+
{ input: 'DOUBLE_TRAILING__', expected: 'doubleTrailing' },
|
|
39
|
+
{ input: 'word_', expected: 'word' }, // Single word with trailing underscore
|
|
40
|
+
|
|
41
|
+
// Consecutive underscores
|
|
42
|
+
{ input: 'DOUBLE__UNDERSCORE', expected: 'doubleUnderscore' },
|
|
43
|
+
{ input: 'TRIPLE___UNDERSCORE', expected: 'tripleUnderscore' },
|
|
44
|
+
{ input: 'LEADING___MIDDLE__TRAILING_', expected: 'leadingMiddleTrailing' },
|
|
45
|
+
|
|
46
|
+
// Mixed case input parts
|
|
47
|
+
{ input: 'Mixed_Case_Input', expected: 'mixedCaseInput' },
|
|
48
|
+
{ input: 'another_Mixed_CASE', expected: 'anotherMixedCase' },
|
|
49
|
+
|
|
50
|
+
// Keys with numbers
|
|
51
|
+
{ input: 'KEY_123', expected: 'key123' },
|
|
52
|
+
{ input: 'VERSION_1_0', expected: 'version10' },
|
|
53
|
+
{ input: 'API_V2_KEY', expected: 'apiV2Key' },
|
|
54
|
+
{ input: '123_STARTING_NUMBER', expected: '123StartingNumber' }, // Starts with number
|
|
55
|
+
|
|
56
|
+
// Edge cases
|
|
57
|
+
{ input: '', expected: '' }, // Empty string
|
|
58
|
+
{ input: '_', expected: '' }, // Single underscore
|
|
59
|
+
{ input: '__', expected: '' }, // Double underscore
|
|
60
|
+
{ input: '___', expected: '' }, // Triple underscore
|
|
61
|
+
{ input: '_A_', expected: 'a' }, // Underscores around single letter
|
|
62
|
+
{ input: 'A', expected: 'a' }, // Single uppercase letter
|
|
63
|
+
{ input: 'a', expected: 'a' }, // Single lowercase letter
|
|
64
|
+
{ input: '1', expected: '1' }, // Single number as string
|
|
65
|
+
{ input: '123', expected: '123' }, // Multiple numbers as string
|
|
66
|
+
])('should convert "$input" to "$expected"', ({ input, expected }) => {
|
|
67
|
+
expect(snakeToCamel(input)).toBe(expected)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// --- Tests for convertToCamel ---
|
|
72
|
+
describe('convertToCamel', () => {
|
|
73
|
+
it('should convert keys of a basic object', () => {
|
|
74
|
+
const input = {
|
|
75
|
+
FIRST_NAME: 'John',
|
|
76
|
+
LAST_NAME: 'Doe',
|
|
77
|
+
USER_ID: 123,
|
|
78
|
+
}
|
|
79
|
+
const expected = {
|
|
80
|
+
firstName: 'John',
|
|
81
|
+
lastName: 'Doe',
|
|
82
|
+
userId: 123,
|
|
83
|
+
}
|
|
84
|
+
const result = convertToCamel(input)
|
|
85
|
+
expect(result).toStrictEqual(expected)
|
|
86
|
+
// Type check (compile time): result should have the correct inferred type
|
|
87
|
+
expect(result.firstName).toBe('John')
|
|
88
|
+
expect(result.userId).toBe(123)
|
|
89
|
+
// expect(result.FIRST_NAME).toBeUndefined(); // This would be a TS error
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should handle various value types correctly', () => {
|
|
93
|
+
const input = {
|
|
94
|
+
STRING_VALUE: 'hello',
|
|
95
|
+
NUMBER_VALUE: 42,
|
|
96
|
+
BOOLEAN_TRUE: true,
|
|
97
|
+
BOOLEAN_FALSE: false,
|
|
98
|
+
NULL_VALUE: null,
|
|
99
|
+
UNDEFINED_VALUE: undefined,
|
|
100
|
+
ARRAY_VALUE: [1, 'two', { NESTED_KEY: true }],
|
|
101
|
+
NESTED_OBJECT: {
|
|
102
|
+
INNER_KEY_1: 'value1',
|
|
103
|
+
INNER_KEY_2: 99,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
const expected = {
|
|
107
|
+
stringValue: 'hello',
|
|
108
|
+
numberValue: 42,
|
|
109
|
+
booleanTrue: true,
|
|
110
|
+
booleanFalse: false,
|
|
111
|
+
nullValue: null,
|
|
112
|
+
undefinedValue: undefined,
|
|
113
|
+
arrayValue: [1, 'two', { NESTED_KEY: true }], // Note: nested object keys are NOT converted
|
|
114
|
+
nestedObject: {
|
|
115
|
+
INNER_KEY_1: 'value1', // Note: nested object keys are NOT converted
|
|
116
|
+
INNER_KEY_2: 99,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
const result = convertToCamel(input)
|
|
120
|
+
expect(result).toStrictEqual(expected)
|
|
121
|
+
// Check specific inferred types/values
|
|
122
|
+
expect(result.arrayValue).toEqual([1, 'two', { NESTED_KEY: true }])
|
|
123
|
+
expect(result.nestedObject.INNER_KEY_1).toBe('value1')
|
|
124
|
+
expect(result.nullValue).toBeNull()
|
|
125
|
+
expect(result.undefinedValue).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should return an empty object for an empty input object', () => {
|
|
129
|
+
const input = {}
|
|
130
|
+
const expected = {}
|
|
131
|
+
expect(convertToCamel(input)).toStrictEqual(expected)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should handle keys with leading/trailing/multiple underscores', () => {
|
|
135
|
+
const input = {
|
|
136
|
+
_LEADING_KEY: 'val1',
|
|
137
|
+
TRAILING_KEY_: 'val2',
|
|
138
|
+
DOUBLE__KEY: 'val3',
|
|
139
|
+
__BOTH__: 'val4',
|
|
140
|
+
}
|
|
141
|
+
const expected = {
|
|
142
|
+
leadingKey: 'val1',
|
|
143
|
+
trailingKey: 'val2',
|
|
144
|
+
doubleKey: 'val3',
|
|
145
|
+
both: 'val4',
|
|
146
|
+
}
|
|
147
|
+
const result = convertToCamel(input)
|
|
148
|
+
expect(result).toStrictEqual(expected)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should handle keys that are already camelCase or mixed', () => {
|
|
152
|
+
const input = {
|
|
153
|
+
alreadyCamel: 'value1',
|
|
154
|
+
PascalCaseKey: 'value2', // Will become pascalCaseKey
|
|
155
|
+
MIXED_key_STYLE: 'value3',
|
|
156
|
+
}
|
|
157
|
+
const expected = {
|
|
158
|
+
alreadyCamel: 'value1', // Stays camelCase as no underscores
|
|
159
|
+
pascalCaseKey: 'value2', // First letter lowercased
|
|
160
|
+
mixedKeyStyle: 'value3',
|
|
161
|
+
}
|
|
162
|
+
const result = convertToCamel(input)
|
|
163
|
+
expect(result).toStrictEqual(expected)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should handle keys with numbers', () => {
|
|
167
|
+
const input = {
|
|
168
|
+
KEY_1: 1,
|
|
169
|
+
KEY_2_PART: 2,
|
|
170
|
+
VERSION_10X: 'v10',
|
|
171
|
+
_1_START: 'start',
|
|
172
|
+
}
|
|
173
|
+
const expected = {
|
|
174
|
+
key1: 1,
|
|
175
|
+
key2Part: 2,
|
|
176
|
+
version10x: 'v10',
|
|
177
|
+
'1Start': 'start', // Note: snakeToCamel logic produces this
|
|
178
|
+
}
|
|
179
|
+
const result = convertToCamel(input)
|
|
180
|
+
expect(result).toStrictEqual(expected)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should not include properties from the prototype chain', () => {
|
|
184
|
+
const proto = { PROTO_KEY: 'protoValue' }
|
|
185
|
+
const input = Object.create(proto)
|
|
186
|
+
input.OWN_KEY = 'ownValue'
|
|
187
|
+
input.ANOTHER_OWN = 'another'
|
|
188
|
+
|
|
189
|
+
const expected = {
|
|
190
|
+
ownKey: 'ownValue',
|
|
191
|
+
anotherOwn: 'another',
|
|
192
|
+
}
|
|
193
|
+
const result = convertToCamel(input)
|
|
194
|
+
expect(result).toStrictEqual(expected)
|
|
195
|
+
// Ensure proto property isn't present (even in camelCase)
|
|
196
|
+
expect((result as any).protoKey).toBeUndefined()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should produce a correctly typed object (compile-time check)', () => {
|
|
200
|
+
const input = {
|
|
201
|
+
API_TOKEN: 'xyz',
|
|
202
|
+
USER_COUNT: 100,
|
|
203
|
+
} as const // Use 'as const' for more precise type inference if needed
|
|
204
|
+
|
|
205
|
+
const result = convertToCamel(input)
|
|
206
|
+
|
|
207
|
+
// These lines check the type inference at compile time.
|
|
208
|
+
// If you hover over `result` or try to access invalid properties,
|
|
209
|
+
// TypeScript/your IDE should show the correct inferred type/errors.
|
|
210
|
+
const token: string = result.apiToken
|
|
211
|
+
const count: number = result.userCount
|
|
212
|
+
|
|
213
|
+
expect(token).toBe('xyz')
|
|
214
|
+
expect(count).toBe(100)
|
|
215
|
+
|
|
216
|
+
// @ts-expect-error
|
|
217
|
+
const _invalid: string = result.API_TOKEN
|
|
218
|
+
// @ts-expect-error
|
|
219
|
+
const _nonExistent: number = result.someOtherKey
|
|
220
|
+
})
|
|
221
|
+
})
|