zcb 0.0.6 → 0.0.8

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2023 dylanaubrey
3
+ Copyright (c) 2023 Dylan Aubrey
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,8 +1,364 @@
1
- # zod-config-builder
1
+ # zcb
2
2
 
3
3
  Build configs with type safety from zod schema.
4
4
 
5
- [![npm version](https://badge.fury.io/js/%40zod-config-builder.svg)](https://badge.fury.io/js/%40zod-config-builder)
5
+ [![npm version](https://badge.fury.io/js/zcb.svg)](https://badge.fury.io/js/zcb)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
 
8
- **WORK IN PROGRESS**
8
+ Define a configuration schema with [`zod`](https://github.com/colinhacks/zod) and use the output of that in `zcb` to create a config builder with autocomplete and setter value autocomplete.
9
+
10
+ Use our cli module to build and/or watch a config builder file and transform it into a literally typed config object.
11
+
12
+ Import that literally typed config in your components along with `zcb` and create a config reader with scoping abilities, config path autocomplete and return value preview.
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ npm install zcb
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ * [Create schema](#create-schema)
23
+ * [Create config builder](#create-config-builder)
24
+ * [Transform config builder](#transform-config-builder)
25
+ * [Create config reader](#create-config-reader)
26
+ * [Use config reader](#use-config-reader)
27
+
28
+ ### Create schema
29
+
30
+ Create the schema for your configuration like in the example below.
31
+
32
+ ```ts
33
+ // ./schema.ts
34
+ import { z } from 'zod';
35
+ import {
36
+ countryCodes,
37
+ countryNames,
38
+ distanceUnits,
39
+ languageCodes,
40
+ timezones,
41
+ } from 'zcb';
42
+
43
+ export const baseSectionSchema = z.object({
44
+ name: z.string(),
45
+ });
46
+
47
+ export type SectionType = z.infer<typeof baseSectionSchema> & {
48
+ sections?: SectionType[];
49
+ };
50
+
51
+ export const sectionSchema: z.ZodType<SectionType> = baseSectionSchema.extend({
52
+ sections: z.lazy(() => sectionSchema.array()).optional(),
53
+ });
54
+
55
+ export const pageSchema = z.object({
56
+ name: z.string(),
57
+ path: z.string().optional(),
58
+ queryParams: z.array(z.string()).optional(),
59
+ sections: z.array(sectionSchema).optional(),
60
+ });
61
+
62
+ export type PageType = z.infer<typeof pageSchema>;
63
+
64
+ const baseRouteSchema = z.object({
65
+ aliases: z.array(z.string()).optional(),
66
+ page: z.string(),
67
+ path: z.string(),
68
+ });
69
+
70
+ export type RouteType = z.infer<typeof baseRouteSchema> & {
71
+ routes?: RouteType[];
72
+ };
73
+
74
+ export const routeSchema: z.ZodType<RouteType> = baseRouteSchema.extend({
75
+ routes: z.lazy(() => routeSchema.array()).optional(),
76
+ });
77
+
78
+ export const configSchema = z.object({
79
+ countryCode: z.enum(countryCodes).optional(),
80
+ countryName: z.enum(countryNames).optional(),
81
+ distanceUnit: z.enum(distanceUnits).optional(),
82
+ languageCodes: z.array(z.enum(languageCodes)).optional(),
83
+ locales: z.array(z.string().regex(/[a-z]{2}_[A-Z]{2}/)).optional(),
84
+ name: z.string().optional(),
85
+ pages: z.record(pageSchema).optional(),
86
+ routes: z.array(routeSchema).optional(),
87
+ timeouts: z.record(z.number()).optional(),
88
+ timezone: z.enum(timezones).optional(),
89
+ });
90
+
91
+ export type ConfigType = z.infer<typeof configSchema>;
92
+ ```
93
+
94
+ ### Create config builder
95
+
96
+ Then use the schema and its types to create a config builder and build out your configuration like in the example below. The config builder comes with method autocompletion and value type validation. It is important to default export the config builder as this is what the cli build/watch scripts are expecting when they import the config builder.
97
+
98
+ ```ts
99
+ // ./configBuilder.ts
100
+ import kebabCase from 'lodash/kebabCase.js';
101
+ import { createConfigBuilder } from 'zcb';
102
+ import {
103
+ type ConfigType,
104
+ type PageType,
105
+ type RouteType,
106
+ type SectionType,
107
+ configSchema,
108
+ pageSchema,
109
+ routeSchema,
110
+ sectionSchema,
111
+ } from './schema.ts';
112
+
113
+ const configBuilder = createConfigBuilder<ConfigType>(configSchema);
114
+ const routeBuilder = createConfigBuilder<RouteType>(routeSchema, { path: ({ page }) => kebabCase(page) });
115
+ const pageBuilder = createConfigBuilder<PageType>(pageSchema);
116
+ const sectionBuilder = createConfigBuilder<SectionType>(sectionSchema);
117
+ const subsectionBuilder = sectionBuilder.fork();
118
+
119
+ configBuilder
120
+ .countryCode('GB')
121
+ .countryName('United Kingdom')
122
+ .distanceUnit('km')
123
+ .languageCodes(['en'])
124
+ .locales(({ countryCode, languageCodes }) =>
125
+ languageCodes?.length && countryCode ? languageCodes.map(code => `${code}_${countryCode}`) : []
126
+ )
127
+ .name('alpha')
128
+ .pages({
129
+ contactDetails: pageBuilder
130
+ .name('contactDetails')
131
+ .sections([
132
+ sectionBuilder.name('header').flush(),
133
+ sectionBuilder
134
+ .name('body')
135
+ .sections([subsectionBuilder.name('main').flush(), subsectionBuilder.name('sidebar').flush()])
136
+ .flush(),
137
+ sectionBuilder.name('footer').flush(),
138
+ ])
139
+ .flush(),
140
+ personalDetails: pageBuilder
141
+ .name('personalDetails')
142
+ .sections([
143
+ sectionBuilder.name('header').flush(),
144
+ sectionBuilder
145
+ .name('body')
146
+ .sections([subsectionBuilder.name('main').flush(), subsectionBuilder.name('sidebar').flush()])
147
+ .flush(),
148
+ sectionBuilder.name('footer').flush(),
149
+ ])
150
+ .flush(),
151
+ })
152
+ .routes([routeBuilder.page('personalDetails').flush(), routeBuilder.page('contactDetails').flush()])
153
+ .timeouts({ apollo: 10_000 })
154
+ .timezone('Europe/London');
155
+
156
+ export default configBuilder;
157
+ ```
158
+
159
+ #### builder API
160
+
161
+ **disable: `() => ConfigBuilder`**
162
+
163
+ Use to disable a slice of config. Disabled slices are removed from the config when the config builder is transformed into a literally typed object in the cli build/watch step.
164
+
165
+ **errors: `() => ZodIssue[]`**
166
+
167
+ Use to validate the config against the schema and return any errors. The primary use for this is internal within the cli build/watch step.
168
+
169
+ **experiment: `() => ConfigBuilder`**
170
+
171
+ Use to assign an experiment ID to a slice of config. If an experiment callback file is provided to the cli build/watch step, this ID is used as a marker for where to inject experiment configuration.
172
+
173
+ **extend: `(value: ConfigBuilder) => void`**
174
+
175
+ Use to extend an existing config builder.
176
+
177
+ **flush: `() => JsonObject`**
178
+
179
+ Use to flush the values from a config builder so that it can be immediately reused.
180
+
181
+ **fork: `() => ConfigBuilder`**
182
+
183
+ Create a clone of a config builder. Useful if you need to use the same config builder within itself.
184
+
185
+ **toJson: `() => string`**
186
+
187
+ Returns the config values as a pretty-printed JSON string.
188
+
189
+ **validate: `() => boolean`**
190
+
191
+ Use to validate the config against the schema and return true/false. The primary use for this is internal within the cli build/watch step.
192
+
193
+ **values: `() => JsonObject`**
194
+
195
+ Use to return the config values as an object.
196
+
197
+ ---
198
+
199
+ ### Transform config builder
200
+
201
+ Use the script below or its `build` equivalent to transform a config builder file into a file that default exports a literally typed object like the one in the following example.
202
+
203
+ ```sh
204
+ npx zcb watch ./configBuilder.ts ./builtConfig.ts
205
+ ```
206
+
207
+ ```ts
208
+ // ./builtConfig.ts
209
+ /* eslint-disable prettier/prettier, import/no-default-export, unicorn/numeric-separators-style */
210
+ /* This file is autogenerated, do not edit directly, your changes will not perist. */
211
+
212
+ export default {
213
+ countryCode: "GB",
214
+ countryName: "United Kingdom",
215
+ distanceUnit: "km",
216
+ languageCodes: [
217
+ "en"
218
+ ],
219
+ locales: [
220
+ "en_GB"
221
+ ],
222
+ name: "alpha",
223
+ pages: {
224
+ contactDetails: {
225
+ name: "contactDetails",
226
+ sections: [
227
+ {
228
+ name: "header"
229
+ },
230
+ {
231
+ name: "body",
232
+ sections: [
233
+ {
234
+ name: "main"
235
+ },
236
+ {
237
+ name: "sidebar"
238
+ }
239
+ ]
240
+ },
241
+ {
242
+ name: "footer"
243
+ }
244
+ ]
245
+ },
246
+ personalDetails: {
247
+ name: "personalDetails",
248
+ sections: [
249
+ {
250
+ name: "header"
251
+ },
252
+ {
253
+ name: "body",
254
+ sections: [
255
+ {
256
+ name: "main"
257
+ },
258
+ {
259
+ name: "sidebar"
260
+ }
261
+ ]
262
+ },
263
+ {
264
+ name: "footer"
265
+ }
266
+ ]
267
+ }
268
+ },
269
+ routes: [
270
+ {
271
+ page: "personalDetails",
272
+ path: "personal-details"
273
+ },
274
+ {
275
+ page: "contactDetails",
276
+ path: "contact-details"
277
+ }
278
+ ],
279
+ timeouts: {
280
+ apollo: 10000
281
+ },
282
+ timezone: "Europe/London"
283
+ } as const;
284
+ ```
285
+
286
+ #### cli API
287
+
288
+ * `zcb build <input-file> <output-file>`
289
+
290
+ ```sh
291
+ Write config from a config builder
292
+
293
+ Positionals:
294
+ input-file The relative path to the config builder root file
295
+ [string] [required]
296
+ output-file The relative path to the output config file [string] [required]
297
+
298
+ Options:
299
+ --version Show version number [boolean]
300
+ --help Show help [boolean]
301
+ --experiments-callback-file The relative path to the experiment callback file
302
+ [string]
303
+ ```
304
+
305
+ * `zcb watch <input-file> <output-file>`
306
+
307
+ ```sh
308
+ Watch a config builder and write config
309
+
310
+ Positionals:
311
+ input-file The relative path to the config builder root file
312
+ [string] [required]
313
+ output-file The relative path to the output config file [string] [required]
314
+
315
+ Options:
316
+ --version Show version number [boolean]
317
+ --help Show help [boolean]
318
+ --experiments-callback-file The relative path to the experiment callback file
319
+ [string]
320
+ ```
321
+
322
+ ### Create config reader
323
+
324
+ Then use the autogenerated config to create a config reader that you can access config values with. The autogenerated config will always be a default import.
325
+
326
+ ```ts
327
+ // ./configReader.ts
328
+ import { createConfigParser, createConfigReader } from 'zcb';
329
+ import builtConfig from './builtConfig.ts';
330
+
331
+ export default () => createConfigReader(builtConfig);
332
+ ```
333
+
334
+ ### Use config reader
335
+
336
+ Then import the config reader into the file in which you want access to config values. The config reader comes with config path autocomplete and return value preview.
337
+
338
+ ```ts
339
+ import configReader from './configReader.ts';
340
+
341
+ // scope config path autocompletion and validation
342
+ const scopedReader = configReader.scope('pages.contactDetails')
343
+ .scope('sections.1.sections')
344
+ .scope('0');
345
+ // reader config path autocompletion and validation
346
+ // value and type preview
347
+ const value = scopedReader('name');
348
+ ```
349
+
350
+ #### reader API
351
+
352
+ **scope: `(value: string) => Get<Config, string>`**
353
+
354
+ Use to scope a reader to a slice of config, rather than having to pass in the full config path every time.
355
+
356
+ ---
357
+
358
+ ## Changelog
359
+
360
+ Check out the [features, fixes and more](CHANGELOG.md) that go into each major, minor and patch version.
361
+
362
+ ## License
363
+
364
+ zcb is [MIT Licensed](LICENSE).
package/bin/zcb.mjs CHANGED
@@ -1,3 +1,3 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --loader ts-node/esm
2
2
  const { cli } = await import('../dist/main/cli.mjs'); // eslint-disable-line import/no-unresolved
3
3
  cli();
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.0.6",
4
+ "version": "0.0.8",
5
5
  "author": "Dylan Aubrey",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/badbatch/zod-config-builder",
@@ -48,23 +48,23 @@
48
48
  "@commitlint/config-conventional": "^17.6.3",
49
49
  "@commitlint/prompt-cli": "^17.6.3",
50
50
  "@jest/globals": "^29.5.0",
51
- "@repodog/babel-config": "^1.0.0",
52
- "@repodog/cli": "^1.1.1",
53
- "@repodog/commitlint-config": "^1.0.0",
54
- "@repodog/eslint-config": "^1.0.0",
55
- "@repodog/jest-config": "^1.0.0",
56
- "@repodog/markdownlint-config": "^1.0.0",
57
- "@repodog/prettier-config": "^1.0.0",
58
- "@repodog/rollup-config": "^1.0.0",
59
- "@repodog/syncpack-config": "1.0.0",
60
- "@repodog/ts-config": "^1.0.0",
51
+ "@repodog/babel-config": "^1.1.3",
52
+ "@repodog/cli": "^1.1.4",
53
+ "@repodog/commitlint-config": "^1.1.3",
54
+ "@repodog/eslint-config": "^1.1.3",
55
+ "@repodog/jest-config": "^1.1.3",
56
+ "@repodog/markdownlint-config": "^1.1.3",
57
+ "@repodog/prettier-config": "^1.1.3",
58
+ "@repodog/rollup-config": "^1.1.3",
59
+ "@repodog/syncpack-config": "^1.1.3",
60
+ "@repodog/ts-config": "^1.1.3",
61
61
  "@rollup/plugin-babel": "^6.0.3",
62
62
  "@rollup/plugin-image": "^3.0.2",
63
63
  "@rollup/plugin-json": "^6.0.0",
64
64
  "@rollup/plugin-node-resolve": "^15.0.2",
65
65
  "@rollup/plugin-terser": "^0.4.1",
66
66
  "@types/fs-extra": "^11.0.1",
67
- "@types/jest": "^29.5.1",
67
+ "@types/jest": "^25.1.3",
68
68
  "@types/json-schema": "^7.0.11",
69
69
  "@types/lodash": "^4.14.191",
70
70
  "@types/node": "^18.11.18",
@@ -90,7 +90,7 @@
90
90
  "eslint-plugin-sort-destructure-keys": "^1.5.0",
91
91
  "eslint-plugin-sort-keys-fix": "^1.1.2",
92
92
  "eslint-plugin-typescript-sort-keys": "^2.3.0",
93
- "eslint-plugin-unicorn": "^47.0.0",
93
+ "eslint-plugin-unicorn": "^46.0.1",
94
94
  "generate-changelog": "^1.8.0",
95
95
  "husky": "^8.0.3",
96
96
  "identity-obj-proxy": "^3.0.0",
@@ -108,6 +108,11 @@
108
108
  "type-fest": "^3.10.0",
109
109
  "typescript": "^5.0.3"
110
110
  },
111
+ "keywords": [
112
+ "config",
113
+ "configuration",
114
+ "zod"
115
+ ],
111
116
  "scripts": {
112
117
  "build": "pnpm run clean:dist && pnpm run compile",
113
118
  "clean:deps": "del-cli ./node_modules",
@@ -124,6 +129,7 @@
124
129
  "test": "node --require=suppress-experimental-warnings --experimental-vm-modules node_modules/jest/bin/jest.js",
125
130
  "type-check": "tsc --noEmit",
126
131
  "validate": "syncpack format && syncpack lint-semver-ranges && pnpm run build && pnpm run lint && pnpm run type-check && pnpm run test",
132
+ "zcb:build": "NODE_OPTIONS=\"--loader ts-node/esm\" node ./bin/zcb.mjs build ./src/__testUtils__/configBuilder.ts ./src/__testUtils__/builtConfig.ts",
127
133
  "zcb:watch": "NODE_OPTIONS=\"--loader ts-node/esm\" node ./bin/zcb.mjs watch ./src/__testUtils__/configBuilder.ts ./src/__testUtils__/builtConfig.ts"
128
134
  }
129
135
  }
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable prettier/prettier, import/no-default-export, unicorn/numeric-separators-style */
2
+ /* This file is autogenerated, do not edit directly, your changes will not perist. */
2
3
 
3
4
  export default {
4
5
  countryCode: "GB",
@@ -0,0 +1,7 @@
1
+ import { createConfigParser } from '../createConfigParser.ts';
2
+ import { createConfigReader } from '../createConfigReader.ts';
3
+ import buildConfig from './builtConfig.ts';
4
+
5
+ const config = await createConfigParser(buildConfig);
6
+
7
+ export const createReader = () => createConfigReader(config);
package/src/cli.ts CHANGED
@@ -2,85 +2,65 @@ import { watchFile } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import shelljs from 'shelljs';
4
4
  import yargs from 'yargs';
5
- import type { ExperimentsCallback } from './types.ts';
6
- import { writeConfig } from './utils/writeConfig.ts';
5
+ import { importValidateTransformWriteConfig } from './utils/importValidateTransformWriteConfig.ts';
6
+
7
+ export enum Commands {
8
+ BUILD = 'build',
9
+ WATCH = 'watch',
10
+ }
11
+
12
+ const generateArguments = (cmdYargs: yargs.Argv) =>
13
+ cmdYargs
14
+ .positional('input-file', {
15
+ demandOption: true,
16
+ desc: 'The relative path to the config builder root file',
17
+ type: 'string',
18
+ })
19
+ .positional('output-file', {
20
+ demandOption: true,
21
+ desc: 'The relative path to the output config file',
22
+ type: 'string',
23
+ })
24
+ .option('experiments-callback-file', {
25
+ desc: 'The relative path to the experiment callback file',
26
+ type: 'string',
27
+ });
7
28
 
8
29
  export const cli = () => {
9
30
  yargs
10
31
  .command(
11
- 'watch <inputFile> <outputFile>',
12
- 'watch a config builder and write ',
13
- cmdYargs =>
14
- cmdYargs
15
- .positional('inputFile', {
16
- demandOption: true,
17
- desc: 'The relative path to the config builder root file',
18
- type: 'string',
19
- })
20
- .positional('outputFile', {
21
- demandOption: true,
22
- desc: 'The relative path to the output config file',
23
- type: 'string',
24
- })
25
- .option('experiments-callback', {
26
- desc: 'The relative path to the experiment callback file',
27
- type: 'string',
28
- }),
32
+ 'watch <input-file> <output-file>',
33
+ 'Watch a config builder and write config',
34
+ cmdYargs => generateArguments(cmdYargs),
29
35
  argv => {
30
- const fullWatchFilePath = resolve(process.cwd(), argv.inputFile);
31
- shelljs.echo(`zcd watch => watching file: ${argv.inputFile}`);
32
-
33
- watchFile(fullWatchFilePath, () => {
34
- import(fullWatchFilePath)
35
- .then(
36
- ({
37
- default: configBuilder,
38
- }: {
39
- default: ReturnType<typeof import('./createConfigBuilder.ts')['createConfigBuilder']>;
40
- }) => {
41
- shelljs.echo('zcd watch => file change detected');
42
-
43
- if (!configBuilder.validate()) {
44
- shelljs.echo('zcd watch => invalid config');
45
- shelljs.echo(`zcd watch => errors:\n${JSON.stringify(configBuilder.errors(), undefined, 2)}\n`);
46
- shelljs.exit(1);
47
- }
48
-
49
- shelljs.echo('zcd watch => valid config');
50
- shelljs.echo(`zcd watch => config values:\n${configBuilder.toJson()}\n`);
51
-
52
- if (argv['experiments-callback']) {
53
- import(resolve(process.cwd(), argv['experiments-callback']))
54
- .then(({ default: experimentsCallback }: { default: ExperimentsCallback }) => {
55
- void writeConfig(configBuilder.values(), { experimentsCallback, outputFile: argv.outputFile });
56
- })
57
- .catch((error: unknown) => {
58
- if (error instanceof Error) {
59
- shelljs.echo(`zcd watch => error message: ${error.message}`);
60
-
61
- if (error.stack) {
62
- shelljs.echo(`zcd watch => error stack:\n${error.stack}\n`);
63
- }
64
- }
65
- });
36
+ shelljs.echo(`zcd watch => watching file: ${argv['input-file']}`);
66
37
 
67
- return;
68
- }
38
+ watchFile(resolve(process.cwd(), argv['input-file']), () => {
39
+ shelljs.echo('zcd watch => file change detected');
69
40
 
70
- void writeConfig(configBuilder.values(), { outputFile: argv.outputFile });
71
- }
72
- )
73
- .catch((error: unknown) => {
74
- if (error instanceof Error) {
75
- shelljs.echo(`zcd watch => error message: ${error.message}`);
76
-
77
- if (error.stack) {
78
- shelljs.echo(`zcd watch => error stack:\n${error.stack}\n`);
79
- }
80
- }
81
- });
41
+ importValidateTransformWriteConfig(
42
+ argv['input-file'],
43
+ argv['output-file'],
44
+ Commands.WATCH,
45
+ argv['experiments-callback-file']
46
+ );
82
47
  });
83
48
  }
84
49
  )
50
+ .command(
51
+ 'build <input-file> <output-file>',
52
+ 'Write config from a config builder',
53
+ cmdYargs => generateArguments(cmdYargs),
54
+ argv => {
55
+ shelljs.echo(`zcd build => building file: ${argv['input-file']}`);
56
+
57
+ importValidateTransformWriteConfig(
58
+ argv['input-file'],
59
+ argv['output-file'],
60
+ Commands.BUILD,
61
+ argv['experiments-callback-file']
62
+ );
63
+ }
64
+ )
85
65
  .help().argv;
86
66
  };
@@ -8,6 +8,7 @@ import {
8
8
  pageSchema,
9
9
  routeSchema,
10
10
  } from './__testUtils__/schema.ts';
11
+ import { RESERVED_KEYWORDS } from './utils/isPropertyReservedWord.ts';
11
12
 
12
13
  describe('createConfigBuilder', () => {
13
14
  describe('when a user passes in a schema with a root type other than "object"', () => {
@@ -23,7 +24,7 @@ describe('createConfigBuilder', () => {
23
24
 
24
25
  describe('when a user uses a key in the schema that is a reserved keyword', () => {
25
26
  it('should throw an error', async () => {
26
- const { RESERVED_KEYWORDS, createConfigBuilder } = await import('./createConfigBuilder.ts');
27
+ const { createConfigBuilder } = await import('./createConfigBuilder.ts');
27
28
 
28
29
  const invalidSchema = z.object({
29
30
  values: z.string(),
@@ -2,10 +2,11 @@ import { type JSONSchema7 } from 'json-schema';
2
2
  import { type ZodError, type z } from 'zod';
3
3
  import { zodToJsonSchema } from 'zod-to-json-schema';
4
4
  import { cloneNonEnumerableValues } from './transformers/cloneNonEnumerableValues.ts';
5
+ import { NonEmumeralProperties } from './types.ts';
5
6
  import { arrayHasInvalidDefaults } from './utils/arrayHasInvalidDefaults.ts';
6
7
  import { isDerivedValueCallback } from './utils/isDerivedValueCallback.ts';
7
8
  import { isInvalidPropertyOverride } from './utils/isInvalidPropertyOverride.ts';
8
- import { isPropertyReservedWord } from './utils/isPropertyReservedWord.ts';
9
+ import { RESERVED_KEYWORDS, isPropertyReservedWord } from './utils/isPropertyReservedWord.ts';
9
10
  import { isSchemaValid } from './utils/isSchemaValid.ts';
10
11
  import { isValidPropertyDefinition } from './utils/isValidPropertyDefinition.ts';
11
12
  import { isValidValue } from './utils/isValidValue.ts';
@@ -13,18 +14,6 @@ import { objectHasInvalidDefaults } from './utils/objectHasInvalidDefaults.ts';
13
14
  import { recordHasInvalidDefaults } from './utils/recordHasInvalidDefaults.ts';
14
15
  import { transformConfigSync } from './utils/transformConfig.ts';
15
16
 
16
- export const RESERVED_KEYWORDS = new Set([
17
- 'disable',
18
- 'errors',
19
- 'experiment',
20
- 'extend',
21
- 'flush',
22
- 'fork',
23
- 'toJson',
24
- 'validate',
25
- 'values',
26
- ]);
27
-
28
17
  export const createConfigBuilder = <ZodTypes>(
29
18
  zodSchema: z.ZodSchema,
30
19
  derivedValueCallbacks: Partial<
@@ -65,7 +54,7 @@ export const createConfigBuilder = <ZodTypes>(
65
54
 
66
55
  let config = initialValues as Config;
67
56
 
68
- Object.defineProperty(config, '__zcb', {
57
+ Object.defineProperty(config, NonEmumeralProperties.ZCB, {
69
58
  configurable: false,
70
59
  enumerable: false,
71
60
  value: true,
@@ -75,7 +64,7 @@ export const createConfigBuilder = <ZodTypes>(
75
64
 
76
65
  const configBuilder = {
77
66
  disable: () => {
78
- Object.defineProperty(config, '__disabled', {
67
+ Object.defineProperty(config, NonEmumeralProperties.DISABLED, {
79
68
  configurable: false,
80
69
  enumerable: false,
81
70
  value: true,
@@ -92,7 +81,7 @@ export const createConfigBuilder = <ZodTypes>(
92
81
  }
93
82
  },
94
83
  experiment: (key: string) => {
95
- Object.defineProperty(config, '__experiment', {
84
+ Object.defineProperty(config, NonEmumeralProperties.EXPERIMENT, {
96
85
  configurable: false,
97
86
  enumerable: false,
98
87
  value: key,
@@ -109,7 +98,7 @@ export const createConfigBuilder = <ZodTypes>(
109
98
  const values = configBuilder.values();
110
99
  config = {} as Config;
111
100
 
112
- Object.defineProperty(config, '__zcb', {
101
+ Object.defineProperty(config, NonEmumeralProperties.ZCB, {
113
102
  configurable: false,
114
103
  enumerable: false,
115
104
  value: true,
@@ -141,7 +130,7 @@ export const createConfigBuilder = <ZodTypes>(
141
130
  },
142
131
  } as unknown as ConfigBuilder;
143
132
 
144
- Object.defineProperty(configBuilder, '__callbacks', {
133
+ Object.defineProperty(configBuilder, NonEmumeralProperties.CALLBACKS, {
145
134
  configurable: false,
146
135
  enumerable: false,
147
136
  value: callbacks,
@@ -0,0 +1,16 @@
1
+ import { runExperiments } from './transformers/runExperiments.ts';
2
+ import type { ConfigParserOptions, TransformConfigHandler } from './types.ts';
3
+ import { transformConfig } from './utils/transformConfig.ts';
4
+
5
+ export const createConfigParser = async <Config extends object>(
6
+ config: Config,
7
+ { experimentsCallback }: ConfigParserOptions = {}
8
+ ) => {
9
+ const handlers: TransformConfigHandler[] = [];
10
+
11
+ if (experimentsCallback) {
12
+ handlers.push(runExperiments(experimentsCallback));
13
+ }
14
+
15
+ return handlers.length > 0 ? await transformConfig(config, handlers) : config;
16
+ };
@@ -1,19 +1,17 @@
1
- import { config } from './__testUtils__/config.ts';
1
+ import { createReader } from './__testUtils__/configReader.ts';
2
2
 
3
3
  describe('createConfigReader', () => {
4
4
  describe('when a user accesses a known property', () => {
5
- it('should return the correct value', async () => {
6
- const { createConfigReader } = await import('./createConfigReader.ts');
7
- const reader = createConfigReader(config);
5
+ it('should return the correct value', () => {
6
+ const reader = createReader();
8
7
  const value = reader('countryCode');
9
8
  expect(value).toBe('GB');
10
9
  });
11
10
  });
12
11
 
13
12
  describe('when a user accesses a known nested property', () => {
14
- it('should return the correct value', async () => {
15
- const { createConfigReader } = await import('./createConfigReader.ts');
16
- const reader = createConfigReader(config);
13
+ it('should return the correct value', () => {
14
+ const reader = createReader();
17
15
  const value = reader('pages.contactDetails.name');
18
16
  expect(value).toBe('contactDetails');
19
17
  });
@@ -21,9 +19,8 @@ describe('createConfigReader', () => {
21
19
 
22
20
  describe('when the reader is scoped', () => {
23
21
  describe('when a user accesses a known property', () => {
24
- it('should return the correct value', async () => {
25
- const { createConfigReader } = await import('./createConfigReader.ts');
26
- const reader = createConfigReader(config);
22
+ it('should return the correct value', () => {
23
+ const reader = createReader();
27
24
  const scopedReader = reader.scope('pages.contactDetails');
28
25
  const value = scopedReader('name');
29
26
  expect(value).toBe('contactDetails');
@@ -33,9 +30,8 @@ describe('createConfigReader', () => {
33
30
 
34
31
  describe('when the reader is scoped multiple times', () => {
35
32
  describe('when a user accesses a known property', () => {
36
- it('should return the correct value', async () => {
37
- const { createConfigReader } = await import('./createConfigReader.ts');
38
- const reader = createConfigReader(config);
33
+ it('should return the correct value', () => {
34
+ const reader = createReader();
39
35
  const scopedReader = reader.scope('pages.contactDetails').scope('sections.1.sections').scope('0');
40
36
  const value = scopedReader('name');
41
37
  expect(value).toBe('main');
@@ -1,12 +1,9 @@
1
1
  import get from 'lodash/get.js';
2
- import type { Get, Join } from 'type-fest';
3
- import type { Leaves, Paths } from './types.ts';
2
+ import type { Get } from 'type-fest';
3
+ import type { Path, Scope } from './types.ts';
4
4
 
5
5
  export const createConfigReader = <Config extends object>(config: Config) => {
6
- const configReader = <Path extends Join<Leaves<Config>, '.'>>(path: Path) => get(config, path);
7
-
8
- configReader.scope = <Scope extends Join<Paths<Config>, '.'>>(scope: Scope) =>
9
- createConfigReader(get(config, scope) as Get<Config, Scope>);
10
-
6
+ const configReader = <P extends Path<Config>>(path: P) => get(config, path);
7
+ configReader.scope = <S extends Scope<Config>>(scope: S) => createConfigReader(get(config, scope) as Get<Config, S>);
11
8
  return configReader;
12
9
  };
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export * from './createConfigBuilder.ts';
2
+ export * from './createConfigParser.ts';
3
+ export * from './createConfigReader.ts';
2
4
  export * from './data/countryCodes.ts';
3
5
  export * from './data/countryNames.ts';
4
6
  export * from './data/distanceUnits.ts';
5
7
  export * from './data/languageCodes.ts';
6
8
  export * from './data/timezones.ts';
9
+ export * from './types.ts';
@@ -1,3 +1,4 @@
1
1
  /* eslint-disable prettier/prettier, import/no-default-export, unicorn/numeric-separators-style */
2
+ /* This file is autogenerated, do not edit directly, your changes will not perist. */
2
3
 
3
4
  export default {{{ config }}} as const;
@@ -1,6 +1,6 @@
1
- import type { AnyRecord, TransformConfigHandlerSync } from '../types.ts';
1
+ import { type AnyRecord, NonEmumeralProperties, type TransformConfigHandlerSync } from '../types.ts';
2
2
 
3
- const NON_ENUMERABLE_KEYS = new Set(['__disabled', '__experiment', '__zcb']);
3
+ const NON_ENUMERABLE_KEYS = new Set(Object.values(NonEmumeralProperties));
4
4
 
5
5
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
6
  export const cloneNonEnumerableValues: TransformConfigHandlerSync = <Config extends AnyRecord>(
@@ -1,11 +1,16 @@
1
- import { type AnyRecord, TransformConfigHandlerAction, type TransformConfigHandlerSync } from '../types.ts';
1
+ import {
2
+ type AnyRecord,
3
+ NonEmumeralProperties,
4
+ TransformConfigHandlerAction,
5
+ type TransformConfigHandlerSync,
6
+ } from '../types.ts';
2
7
 
3
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
9
  export const removeDisabledSlices: TransformConfigHandlerSync = <Config extends AnyRecord>(
5
10
  clone: Config,
6
11
  config: Config
7
12
  ) => {
8
- if ('__disabled' in config) {
13
+ if (NonEmumeralProperties.DISABLED in config) {
9
14
  return {
10
15
  action: TransformConfigHandlerAction.DELETE_NODE,
11
16
  };
@@ -0,0 +1,24 @@
1
+ import {
2
+ type AnyRecord,
3
+ type Buckets,
4
+ NonEmumeralProperties,
5
+ type RunExperimentsCallback,
6
+ type TransformConfigHandler,
7
+ } from '../types.ts';
8
+
9
+ export const runExperiments =
10
+ (callback: RunExperimentsCallback): TransformConfigHandler =>
11
+ async <Config extends AnyRecord>(clone: Config, config: Config) => {
12
+ if (NonEmumeralProperties.EXPERIMENT in config && typeof config.__experiment === 'object') {
13
+ const { buckets, id } = config.__experiment as { buckets: Buckets<Config>; id: string };
14
+ const bucket = await callback(id);
15
+
16
+ if (bucket && bucket in buckets && buckets[bucket]) {
17
+ return buckets[bucket]!;
18
+ }
19
+ }
20
+
21
+ return {
22
+ value: clone,
23
+ };
24
+ };
@@ -1,16 +1,20 @@
1
- import type { AnyRecord, ExperimentsCallback, TransformConfigHandler } from '../types.ts';
1
+ import {
2
+ type AnyRecord,
3
+ NonEmumeralProperties,
4
+ type SetupExperimentsCallback,
5
+ type TransformConfigHandler,
6
+ } from '../types.ts';
2
7
 
3
8
  export const setupExperiments =
4
- (callback: ExperimentsCallback): TransformConfigHandler =>
9
+ (callback: SetupExperimentsCallback): TransformConfigHandler =>
5
10
  async <Config extends AnyRecord>(clone: Config, config: Config) => {
6
- if ('__experiment' in config && typeof config.__experiment === 'string') {
7
- const { action, value = {} } = await callback(config.__experiment, clone, config);
11
+ if (NonEmumeralProperties.EXPERIMENT in config && typeof config.__experiment === 'string') {
12
+ const buckets = await callback(config.__experiment, clone, config);
8
13
 
9
14
  // @ts-expect-error private property
10
15
  clone.__experiment = {
11
- action,
16
+ buckets,
12
17
  id: config.__experiment,
13
- value,
14
18
  };
15
19
 
16
20
  return {
package/src/types.ts CHANGED
@@ -1,19 +1,38 @@
1
1
  import type { List } from 'ts-toolbelt';
2
- import type { Includes } from 'type-fest';
2
+ import type { Includes, Join } from 'type-fest';
3
3
 
4
4
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
5
  export type AnyRecord = Record<string, any>;
6
6
 
7
- export interface WriteConfigOptions {
8
- experimentsCallback?: ExperimentsCallback;
9
- outputFile: string;
7
+ export type Buckets<Config extends AnyRecord> = Record<string, Experiment<Config>>;
8
+
9
+ export interface ConfigParserOptions {
10
+ experimentsCallback?: RunExperimentsCallback;
11
+ }
12
+
13
+ export interface Experiment<Config extends AnyRecord> {
14
+ action?: TransformConfigHandlerAction;
15
+ value?: Config;
16
+ }
17
+
18
+ export enum NonEmumeralProperties {
19
+ CALLBACKS = '__callbacks',
20
+ DISABLED = '__disabled',
21
+ EXPERIMENT = '__experiment',
22
+ ZCB = '__zcb',
10
23
  }
11
24
 
12
- export type ExperimentsCallback = <Config extends AnyRecord>(
25
+ export type Path<Config extends object> = Join<Leaves<Config>, '.'>;
26
+
27
+ export type RunExperimentsCallback = (id: string) => Promise<string>;
28
+
29
+ export type Scope<Config extends object> = Join<Paths<Config>, '.'>;
30
+
31
+ export type SetupExperimentsCallback = <Config extends AnyRecord>(
13
32
  id: string,
14
33
  clone: Config,
15
34
  config: Config
16
- ) => Promise<TransformConfigHandlerReturnType<Config>>;
35
+ ) => Promise<Buckets<Config>>;
17
36
 
18
37
  export type TransformConfigHandler = <Config extends AnyRecord>(
19
38
  clone: Config,
@@ -35,6 +54,11 @@ export interface TransformConfigHandlerReturnType<Config extends AnyRecord> {
35
54
  value?: Config;
36
55
  }
37
56
 
57
+ export interface WriteConfigOptions {
58
+ experimentsCallback?: SetupExperimentsCallback;
59
+ outputFile: string;
60
+ }
61
+
38
62
  export type Leaves<T, Path extends string[] = []> = T extends string
39
63
  ? Path
40
64
  : {
@@ -0,0 +1,60 @@
1
+ import { resolve } from 'node:path';
2
+ import shelljs from 'shelljs';
3
+ import type { Commands } from '../cli.ts';
4
+ import type { SetupExperimentsCallback } from '../types.ts';
5
+ import { transformWriteConfig } from './transformWriteConfig.ts';
6
+
7
+ export const importValidateTransformWriteConfig = (
8
+ inputFile: string,
9
+ outputFile: string,
10
+ command: Commands,
11
+ experimentCallbackFile?: string
12
+ ) => {
13
+ import(resolve(process.cwd(), inputFile))
14
+ .then(
15
+ ({
16
+ default: configBuilder,
17
+ }: {
18
+ default: ReturnType<typeof import('../createConfigBuilder.ts')['createConfigBuilder']>;
19
+ }) => {
20
+ if (!configBuilder.validate()) {
21
+ shelljs.echo(`zcd ${command} => invalid config`);
22
+ shelljs.echo(`zcd ${command} => config values:\n${configBuilder.toJson()}\n`);
23
+ shelljs.echo(`zcd ${command} => errors:\n${JSON.stringify(configBuilder.errors(), undefined, 2)}\n`);
24
+ shelljs.exit(1);
25
+ }
26
+
27
+ shelljs.echo('zcd ${command} => valid config');
28
+ shelljs.echo(`zcd ${command} => config values:\n${configBuilder.toJson()}\n`);
29
+
30
+ if (experimentCallbackFile) {
31
+ import(resolve(process.cwd(), experimentCallbackFile))
32
+ .then(({ default: experimentsCallback }: { default: SetupExperimentsCallback }) => {
33
+ void transformWriteConfig(configBuilder.values(), { experimentsCallback, outputFile });
34
+ })
35
+ .catch((error: unknown) => {
36
+ if (error instanceof Error) {
37
+ shelljs.echo(`zcd ${command} => error message: ${error.message}`);
38
+
39
+ if (error.stack) {
40
+ shelljs.echo(`zcd ${command} => error stack:\n${error.stack}\n`);
41
+ }
42
+ }
43
+ });
44
+
45
+ return;
46
+ }
47
+
48
+ void transformWriteConfig(configBuilder.values(), { outputFile });
49
+ }
50
+ )
51
+ .catch((error: unknown) => {
52
+ if (error instanceof Error) {
53
+ shelljs.echo(`zcd ${command} => error message: ${error.message}`);
54
+
55
+ if (error.stack) {
56
+ shelljs.echo(`zcd ${command} => error stack:\n${error.stack}\n`);
57
+ }
58
+ }
59
+ });
60
+ };
@@ -1,3 +1,13 @@
1
- import { RESERVED_KEYWORDS } from '../createConfigBuilder.ts';
1
+ export const RESERVED_KEYWORDS = new Set([
2
+ 'disable',
3
+ 'errors',
4
+ 'experiment',
5
+ 'extend',
6
+ 'flush',
7
+ 'fork',
8
+ 'toJson',
9
+ 'validate',
10
+ 'values',
11
+ ]);
2
12
 
3
13
  export const isPropertyReservedWord = (propertyName: string) => RESERVED_KEYWORDS.has(propertyName);
@@ -9,7 +9,7 @@ import { setupExperiments } from '../transformers/setupExperiments.ts';
9
9
  import type { TransformConfigHandler, WriteConfigOptions } from '../types.ts';
10
10
  import { transformConfig } from './transformConfig.ts';
11
11
 
12
- export const writeConfig = async <Config extends object>(
12
+ export const transformWriteConfig = async <Config extends object>(
13
13
  config: Config,
14
14
  { experimentsCallback, outputFile }: WriteConfigOptions
15
15
  ) => {
@@ -30,5 +30,5 @@ export const writeConfig = async <Config extends object>(
30
30
 
31
31
  shelljs.echo(`zcd watch => writing to file: ${outputFile}`);
32
32
  shelljs.echo(`zcd watch => content to write:\n${output}\n`);
33
- outputFileSync(outputFile, output);
33
+ outputFileSync(resolve(process.cwd(), outputFile), output);
34
34
  };