zcb 0.3.2 → 0.3.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zcb",
3
3
  "description": "Build configs with type safety from zod schema.",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
5
5
  "author": "miami-man",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/badbatch/zod-config-builder",
@@ -14,6 +14,9 @@
14
14
  "main": "./dist/cjs/index.cjs",
15
15
  "module": "./dist/esm/index.mjs",
16
16
  "types": "./dist/types/cjs/index.d.cts",
17
+ "imports": {
18
+ "#*": "./src/*"
19
+ },
17
20
  "exports": {
18
21
  ".": {
19
22
  "types": {
@@ -62,7 +62,11 @@ describe('createConfigBuilder', () => {
62
62
  it('should copy over all values to the new config', async () => {
63
63
  const { createConfigBuilder } = await import('./createConfigBuilder.ts');
64
64
  const config = createConfigBuilder<ConfigType>(configSchema);
65
- const route = createConfigBuilder<RouteType>(routeSchema, undefined, { path: ({ page }) => kebabCase(page) });
65
+
66
+ const route = createConfigBuilder<RouteType>(routeSchema, undefined, {
67
+ path: ({ page }) => kebabCase(page),
68
+ });
69
+
66
70
  const page = createConfigBuilder<PageType>(pageSchema);
67
71
 
68
72
  config
@@ -85,13 +89,19 @@ describe('createConfigBuilder', () => {
85
89
  it('should copy over all experiments to the new config', async () => {
86
90
  const { createConfigBuilder } = await import('./createConfigBuilder.ts');
87
91
  const config = createConfigBuilder<ConfigType>(configSchema);
88
- const route = createConfigBuilder<RouteType>(routeSchema, undefined, { path: ({ page }) => kebabCase(page) });
92
+
93
+ const route = createConfigBuilder<RouteType>(routeSchema, undefined, {
94
+ path: ({ page }) => kebabCase(page),
95
+ });
96
+
89
97
  const page = createConfigBuilder<PageType>(pageSchema);
90
98
 
91
99
  config
92
100
  .$experiment('FEAT_ALPHA@0.0.1')
93
101
  .name('alpha')
94
- .pages({ contactDetails: page.$experiment('FEAT_BRAVO@0.0.1').name('contactDetails').$flush() })
102
+ .pages({
103
+ contactDetails: page.$experiment('FEAT_BRAVO@0.0.1').name('contactDetails').$flush(),
104
+ })
95
105
  .routes([route.$experiment('FEAT_CHARLIE@0.0.1').page('contactDetails').$flush()]);
96
106
 
97
107
  const childConfig = createConfigBuilder<ConfigType>(configSchema);
@@ -120,13 +130,19 @@ describe('createConfigBuilder', () => {
120
130
  it('should copy over all disabled flags to the new config', async () => {
121
131
  const { createConfigBuilder } = await import('./createConfigBuilder.ts');
122
132
  const config = createConfigBuilder<ConfigType>(configSchema);
123
- const route = createConfigBuilder<RouteType>(routeSchema, undefined, { path: ({ page }) => kebabCase(page) });
133
+
134
+ const route = createConfigBuilder<RouteType>(routeSchema, undefined, {
135
+ path: ({ page }) => kebabCase(page),
136
+ });
137
+
124
138
  const page = createConfigBuilder<PageType>(pageSchema);
125
139
 
126
140
  config
127
141
  .$disable()
128
142
  .name('alpha')
129
- .pages({ contactDetails: page.$disable().name('contactDetails').$flush() })
143
+ .pages({
144
+ contactDetails: page.$disable().name('contactDetails').$flush(),
145
+ })
130
146
  .routes([route.$disable().page('contactDetails').$flush()]);
131
147
 
132
148
  const childConfig = createConfigBuilder<ConfigType>(configSchema);
@@ -154,12 +170,20 @@ describe('createConfigBuilder', () => {
154
170
 
155
171
  it('should copy over all derived value callbacks to the new config builder', async () => {
156
172
  const { createConfigBuilder } = await import('./createConfigBuilder.ts');
157
- const route = createConfigBuilder<RouteType>(routeSchema, undefined, { path: ({ page }) => kebabCase(page) });
173
+
174
+ const route = createConfigBuilder<RouteType>(routeSchema, undefined, {
175
+ path: ({ page }) => kebabCase(page),
176
+ });
177
+
158
178
  route.page('personalDetails');
159
179
  const childRoute = createConfigBuilder<RouteType>(routeSchema);
160
180
  childRoute.$extend(route);
161
181
  childRoute.page('contactDetails', true);
162
- expect(childRoute.$values()).toEqual({ page: 'contactDetails', path: 'contact-details' });
182
+
183
+ expect(childRoute.$values()).toEqual({
184
+ page: 'contactDetails',
185
+ path: 'contact-details',
186
+ });
163
187
  });
164
188
  });
165
189
 
@@ -174,7 +198,23 @@ describe('createConfigBuilder', () => {
174
198
  });
175
199
 
176
200
  const config = createConfigBuilder<z.infer<typeof extendedSchema>>(extendedSchema);
177
- expect(config.$values()).toEqual({ description: 'This is the description.' });
201
+
202
+ expect(config.$values()).toMatchInlineSnapshot(`
203
+ {
204
+ "countryCode": undefined,
205
+ "countryName": undefined,
206
+ "description": "This is the description.",
207
+ "distanceUnit": undefined,
208
+ "enabled": undefined,
209
+ "languageCodes": undefined,
210
+ "locales": undefined,
211
+ "name": undefined,
212
+ "pages": undefined,
213
+ "routes": undefined,
214
+ "timeouts": undefined,
215
+ "timezone": undefined,
216
+ }
217
+ `);
178
218
  });
179
219
  });
180
220
 
@@ -187,7 +227,27 @@ describe('createConfigBuilder', () => {
187
227
  });
188
228
 
189
229
  const config = createConfigBuilder<z.infer<typeof extendedSchema>>(extendedSchema);
190
- expect(config.$values()).toEqual({ flags: { alpha: true, bravo: false, charlie: false } });
230
+
231
+ expect(config.$values()).toMatchInlineSnapshot(`
232
+ {
233
+ "countryCode": undefined,
234
+ "countryName": undefined,
235
+ "distanceUnit": undefined,
236
+ "enabled": undefined,
237
+ "flags": {
238
+ "alpha": true,
239
+ "bravo": false,
240
+ "charlie": false,
241
+ },
242
+ "languageCodes": undefined,
243
+ "locales": undefined,
244
+ "name": undefined,
245
+ "pages": undefined,
246
+ "routes": undefined,
247
+ "timeouts": undefined,
248
+ "timezone": undefined,
249
+ }
250
+ `);
191
251
  });
192
252
  });
193
253
 
@@ -200,7 +260,28 @@ describe('createConfigBuilder', () => {
200
260
  });
201
261
 
202
262
  const config = createConfigBuilder<z.infer<typeof extendedSchema>>(extendedSchema);
203
- expect(config.$values()).toEqual({ colors: ['red', 'yellow', 'pink', 'green'] });
263
+
264
+ expect(config.$values()).toMatchInlineSnapshot(`
265
+ {
266
+ "colors": [
267
+ "red",
268
+ "yellow",
269
+ "pink",
270
+ "green",
271
+ ],
272
+ "countryCode": undefined,
273
+ "countryName": undefined,
274
+ "distanceUnit": undefined,
275
+ "enabled": undefined,
276
+ "languageCodes": undefined,
277
+ "locales": undefined,
278
+ "name": undefined,
279
+ "pages": undefined,
280
+ "routes": undefined,
281
+ "timeouts": undefined,
282
+ "timezone": undefined,
283
+ }
284
+ `);
204
285
  });
205
286
  });
206
287
 
@@ -218,7 +299,26 @@ describe('createConfigBuilder', () => {
218
299
  });
219
300
 
220
301
  const config = createConfigBuilder<z.infer<typeof extendedSchema>>(extendedSchema);
221
- expect(config.$values()).toEqual({ flags: { alpha: true, bravo: true } });
302
+
303
+ expect(config.$values()).toMatchInlineSnapshot(`
304
+ {
305
+ "countryCode": undefined,
306
+ "countryName": undefined,
307
+ "distanceUnit": undefined,
308
+ "enabled": undefined,
309
+ "flags": {
310
+ "alpha": true,
311
+ "bravo": true,
312
+ },
313
+ "languageCodes": undefined,
314
+ "locales": undefined,
315
+ "name": undefined,
316
+ "pages": undefined,
317
+ "routes": undefined,
318
+ "timeouts": undefined,
319
+ "timezone": undefined,
320
+ }
321
+ `);
222
322
  });
223
323
  });
224
324
  });
@@ -399,7 +499,11 @@ describe('createConfigBuilder', () => {
399
499
  it('should add the value to the config', async () => {
400
500
  const { createConfigBuilder } = await import('./createConfigBuilder.ts');
401
501
  const config = createConfigBuilder<ConfigType>(configSchema);
402
- const route = createConfigBuilder<RouteType>(routeSchema, undefined, { path: ({ page }) => kebabCase(page) });
502
+
503
+ const route = createConfigBuilder<RouteType>(routeSchema, undefined, {
504
+ path: ({ page }) => kebabCase(page),
505
+ });
506
+
403
507
  const subRoute = route.$fork();
404
508
 
405
509
  config.routes([
@@ -428,7 +532,10 @@ describe('createConfigBuilder', () => {
428
532
  it('should be a valid config', async () => {
429
533
  const { createConfigBuilder } = await import('./createConfigBuilder.ts');
430
534
  const config = createConfigBuilder<ConfigType>(configSchema);
431
- const route = createConfigBuilder<RouteType>(routeSchema, undefined, { path: ({ page }) => kebabCase(page) });
535
+
536
+ const route = createConfigBuilder<RouteType>(routeSchema, undefined, {
537
+ path: ({ page }) => kebabCase(page),
538
+ });
432
539
 
433
540
  config.routes([
434
541
  route.page('personalDetails').$flush(),
@@ -483,7 +590,11 @@ describe('createConfigBuilder', () => {
483
590
  languageCodes?.length && countryCode ? languageCodes.map(code => `${code}_${countryCode}` as const) : [],
484
591
  );
485
592
 
486
- expect(config.$values()).toEqual({ countryCode: 'GB', languageCodes: ['en'], locales: ['en_GB'] });
593
+ expect(config.$values()).toEqual({
594
+ countryCode: 'GB',
595
+ languageCodes: ['en'],
596
+ locales: ['en_GB'],
597
+ });
487
598
  });
488
599
 
489
600
  it('should update the value to the config automatically', async () => {
@@ -499,7 +610,11 @@ describe('createConfigBuilder', () => {
499
610
  )
500
611
  .languageCodes(['fr'], true);
501
612
 
502
- expect(config.$values()).toEqual({ countryCode: 'GB', languageCodes: ['fr'], locales: ['fr_GB'] });
613
+ expect(config.$values()).toEqual({
614
+ countryCode: 'GB',
615
+ languageCodes: ['fr'],
616
+ locales: ['fr_GB'],
617
+ });
503
618
  });
504
619
 
505
620
  it('should be a valid config', async () => {
@@ -190,6 +190,11 @@ export const createConfigBuilder = <ZodTypes>(
190
190
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
191
191
  const castPropertyName = propertyName as keyof Config;
192
192
 
193
+ if (!jsonSchema.required?.includes(propertyName) && !(propertyName in config)) {
194
+ // @ts-expect-error Optional properties we want to exist in the config with undefined initial values.
195
+ config[propertyName] = undefined;
196
+ }
197
+
193
198
  // Aimed at making it easier for Typescript to derive type.
194
199
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
195
200
  configBuilder[castPropertyName] = ((value: Config[keyof Config] | DerivedValueCallback, override?: boolean) => {
@@ -56,6 +56,7 @@ describe('createConfigReader', () => {
56
56
  it('should throw the expected error', () => {
57
57
  const reader = createReader();
58
58
 
59
+ // @ts-expect-error Required to test error
59
60
  expect(() => reader.read('pages.contactDetails.sections')).toThrow(
60
61
  'Path resolved to an object or an array of objects, but `read` can only resolve to a primitive value or an array of primitives. Use the `scope` method instead',
61
62
  );
package/src/types.ts CHANGED
@@ -55,16 +55,11 @@ export type Primitive = string | number | boolean | bigint | symbol | null | und
55
55
 
56
56
  export type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];
57
57
 
58
- export type Leaves<T, LeafPath extends string[] = [], Depth extends number = 10> = Depth extends never
59
- ? never
60
- : T extends Primitive
61
- ? LeafPath
62
- : // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
- T extends readonly any[]
64
- ? LeafPath
65
- : {
66
- [K in keyof T & string]: Leaves<T[K], [...LeafPath, K], Prev[Depth]>;
67
- }[keyof T & string];
58
+ export type IsArrayOfPrimitives<T> = T extends readonly (infer U)[]
59
+ ? Exclude<U, Primitive> extends never
60
+ ? true
61
+ : false
62
+ : false;
68
63
 
69
64
  export type OwnKeys<T> = Exclude<
70
65
  {
@@ -74,6 +69,16 @@ export type OwnKeys<T> = Exclude<
74
69
  `${string}[Symbol.${string}`
75
70
  >;
76
71
 
72
+ export type Leaves<T, LeafPath extends string[] = [], Depth extends number = 10> = Depth extends never
73
+ ? never
74
+ : T extends Primitive
75
+ ? LeafPath
76
+ : IsArrayOfPrimitives<T> extends true
77
+ ? LeafPath
78
+ : {
79
+ [K in OwnKeys<T>]: Leaves<T[K], [...LeafPath, K], Prev[Depth]>;
80
+ }[OwnKeys<T>];
81
+
77
82
  export type Paths<T, ScopePath extends string[] = [], Depth extends number = 10> = Depth extends never
78
83
  ? never
79
84
  : T extends Primitive
@@ -17,18 +17,23 @@ describe('transformConfig', () => {
17
17
  "locales": [
18
18
  "en_GB",
19
19
  ],
20
+ "name": undefined,
20
21
  "pages": {
21
22
  "contactDetails": {
22
23
  "name": "contactDetails",
24
+ "path": undefined,
25
+ "queryParams": undefined,
23
26
  "sections": [
24
27
  {
25
28
  "name": "header",
29
+ "sections": undefined,
26
30
  },
27
31
  {
28
32
  "name": "body",
29
33
  "sections": [
30
34
  {
31
35
  "name": "main",
36
+ "sections": undefined,
32
37
  },
33
38
  {
34
39
  "name": "sidebar",
@@ -65,8 +70,10 @@ describe('transformConfig', () => {
65
70
  },
66
71
  "routes": [
67
72
  {
73
+ "aliases": undefined,
68
74
  "page": "personalDetails",
69
75
  "path": "personal-details",
76
+ "routes": undefined,
70
77
  },
71
78
  {
72
79
  "page": "contactDetails",
@@ -100,18 +107,23 @@ describe('transformConfigSync', () => {
100
107
  "locales": [
101
108
  "en_GB",
102
109
  ],
110
+ "name": undefined,
103
111
  "pages": {
104
112
  "contactDetails": {
105
113
  "name": "contactDetails",
114
+ "path": undefined,
115
+ "queryParams": undefined,
106
116
  "sections": [
107
117
  {
108
118
  "name": "header",
119
+ "sections": undefined,
109
120
  },
110
121
  {
111
122
  "name": "body",
112
123
  "sections": [
113
124
  {
114
125
  "name": "main",
126
+ "sections": undefined,
115
127
  },
116
128
  {
117
129
  "name": "sidebar",
@@ -148,8 +160,10 @@ describe('transformConfigSync', () => {
148
160
  },
149
161
  "routes": [
150
162
  {
163
+ "aliases": undefined,
151
164
  "page": "personalDetails",
152
165
  "path": "personal-details",
166
+ "routes": undefined,
153
167
  },
154
168
  {
155
169
  "page": "contactDetails",
@@ -0,0 +1,111 @@
1
+ import { jest } from '@jest/globals';
2
+ import configBuilder from '../__testUtils__/configBuilder.ts';
3
+
4
+ jest.unstable_mockModule('fs-extra/esm', () => ({
5
+ outputFileSync: jest.fn(),
6
+ }));
7
+
8
+ jest.unstable_mockModule('shelljs', () => ({
9
+ default: {
10
+ echo: jest.fn(),
11
+ exec: jest.fn(),
12
+ exit: jest.fn(),
13
+ },
14
+ }));
15
+
16
+ const { outputFileSync } = jest.mocked(await import('fs-extra/esm'));
17
+ const { transformWriteConfig } = await import('./transformWriteConfig.ts');
18
+
19
+ describe('transformWriteConfig', () => {
20
+ it('should call outputFileSync with the expected file content', async () => {
21
+ await transformWriteConfig(configBuilder.$values(), {
22
+ outputFile: 'config.ts',
23
+ });
24
+
25
+ expect(outputFileSync.mock.calls[0]?.[1]).toMatchInlineSnapshot(`
26
+ "/* eslint-disable prettier/prettier, import-x/no-default-export, unicorn/numeric-separators-style, unicorn/no-null */
27
+ /* This file is autogenerated, do not edit directly, your changes will not perist. */
28
+
29
+ export default {
30
+ countryCode: "GB",
31
+ countryName: null,
32
+ distanceUnit: "km",
33
+ enabled: true,
34
+ languageCodes: [
35
+ "en"
36
+ ],
37
+ locales: [
38
+ "en_GB"
39
+ ],
40
+ name: undefined,
41
+ pages: {
42
+ contactDetails: {
43
+ path: undefined,
44
+ queryParams: undefined,
45
+ sections: [
46
+ {
47
+ sections: undefined,
48
+ name: "header"
49
+ },
50
+ {
51
+ name: "body",
52
+ sections: [
53
+ {
54
+ sections: undefined,
55
+ name: "main"
56
+ },
57
+ {
58
+ name: "sidebar"
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ name: "footer"
64
+ }
65
+ ],
66
+ name: "contactDetails"
67
+ },
68
+ personalDetails: {
69
+ name: "personalDetails",
70
+ sections: [
71
+ {
72
+ name: "header"
73
+ },
74
+ {
75
+ name: "body",
76
+ sections: [
77
+ {
78
+ name: "main"
79
+ },
80
+ {
81
+ name: "sidebar"
82
+ }
83
+ ]
84
+ },
85
+ {
86
+ name: "footer"
87
+ }
88
+ ]
89
+ }
90
+ },
91
+ routes: [
92
+ {
93
+ aliases: undefined,
94
+ routes: undefined,
95
+ page: "personalDetails",
96
+ path: "personal-details"
97
+ },
98
+ {
99
+ page: "contactDetails",
100
+ path: "contact-details"
101
+ }
102
+ ],
103
+ timeouts: {
104
+ apollo: 10000
105
+ },
106
+ timezone: "Europe/London"
107
+ } as const;
108
+ "
109
+ `);
110
+ });
111
+ });
@@ -20,13 +20,18 @@ export const transformWriteConfig = async <Config extends object>(
20
20
  }
21
21
 
22
22
  const template = Handlebars.compile(
23
- readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), './templates/config.ts.hbs'), { encoding: 'utf8' }),
23
+ readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../templates/config.ts.hbs'), { encoding: 'utf8' }),
24
24
  );
25
25
 
26
- const output = template({ config: JSON.stringify(await transformConfig(config, handlers), undefined, 2) }).replaceAll(
27
- /"([A-Za-z][\dA-Za-z]+)":/g,
28
- '$1:',
29
- );
26
+ const output = template({
27
+ config: JSON.stringify(
28
+ await transformConfig(config, handlers),
29
+ (_k, v: unknown) => (v === undefined ? 'undefined' : v),
30
+ 2,
31
+ ),
32
+ })
33
+ .replaceAll(/"([A-Za-z][\dA-Za-z]+)":/g, '$1:')
34
+ .replaceAll('"undefined"', 'undefined');
30
35
 
31
36
  shelljs.echo(`zcd watch => writing to file: ${outputFile}`);
32
37
  shelljs.echo(`zcd watch => content to write:\n${output}\n`);