Skip to content

Commit 15c9b1e

Browse files
committed
feat: implement new typescript code generators
1 parent 7fa4e65 commit 15c9b1e

24 files changed

Lines changed: 3343 additions & 4 deletions

.eslintrc.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
"no-throw-literal": "off",
1010
"no-warning-comments": "off",
1111
"unicorn/consistent-destructuring": "off",
12+
"unicorn/no-array-for-each": "off",
13+
"unicorn/no-array-reduce": "off",
1214
"unicorn/prefer-ternary": "off",
15+
"unicorn/switch-case-braces": "off",
16+
"padding-line-between-statements": "off",
1317
"prefer-destructuring": "off"
1418
},
1519
"overrides": [

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
"acorn": "^8.10.0",
1212
"acorn-walk": "^8.2.0",
1313
"chalk": "^5.4.1",
14+
"common-tags": "^1.8.2",
1415
"fuzzy": "^0.1.3",
1516
"inquirer-autocomplete-standalone": "^0.8.1",
17+
"lodash.camelcase": "^4.3.0",
1618
"mustache": "^4.2.0",
1719
"zod": "^3.22.4"
1820
},
@@ -21,6 +23,8 @@
2123
"@oclif/prettier-config": "^0.2.1",
2224
"@oclif/test": "^3",
2325
"@types/chai": "^4",
26+
"@types/common-tags": "^1.8.4",
27+
"@types/lodash.camelcase": "^4.3.9",
2428
"@types/mocha": "^10",
2529
"@types/mustache": "^4.2.5",
2630
"@types/node": "^18",

src/codegen/code-generators/base-generator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {type ConfigFile} from '../types.js'
22

3-
interface BaseGeneratorArgs {
3+
export interface BaseGeneratorArgs {
44
configFile: ConfigFile
55
log: (category: string | unknown, message?: unknown) => void
66
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {z} from 'zod'
2+
3+
import {ZodToTypescriptMapper} from '../language-mappers/zod-to-typescript-mapper.js'
4+
import {SchemaExtractor} from '../schema-extractor.js'
5+
import {BaseGenerator, BaseGeneratorArgs} from './base-generator.js'
6+
7+
export abstract class BaseTypescriptGenerator extends BaseGenerator {
8+
protected MUSTACHE_IMPORT = "import Mustache from 'mustache'"
9+
private schemaExtractor: SchemaExtractor
10+
11+
constructor({configFile, log}: BaseGeneratorArgs) {
12+
super({configFile, log})
13+
this.schemaExtractor = new SchemaExtractor(log)
14+
}
15+
16+
protected configurations() {
17+
return this.configFile.configs
18+
.filter((config) => config.configType === 'FEATURE_FLAG' || config.configType === 'CONFIG')
19+
.filter((config) => config.rows.length > 0)
20+
.sort((a, b) => a.key.localeCompare(b.key))
21+
.map((config) => {
22+
const schema = this.schemaExtractor.execute({
23+
config,
24+
configFile: this.configFile,
25+
durationTypeMap: this.durationTypeMap,
26+
})
27+
28+
return {
29+
configType: config.configType,
30+
hasFunction: schema && new ZodToTypescriptMapper().resolveType(schema).includes('=>'),
31+
key: config.key,
32+
schema,
33+
sendToClientSdk: config.sendToClientSdk ?? false,
34+
}
35+
})
36+
}
37+
38+
protected durationTypeMap(): z.ZodTypeAny {
39+
return z.number()
40+
}
41+
42+
abstract get filename(): string
43+
abstract generate(): string
44+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {stripIndent} from 'common-tags'
2+
import camelCase from 'lodash.camelcase'
3+
4+
import {ZodToTypescriptMapper, type ZodToTypescriptMapperTarget} from '../language-mappers/zod-to-typescript-mapper.js'
5+
import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js'
6+
import {BaseTypescriptGenerator} from './base-typescript-generator.js'
7+
8+
export class NodeTypeScriptGenerator extends BaseTypescriptGenerator {
9+
get filename(): string {
10+
return 'prefab-server.ts'
11+
}
12+
13+
generate(): string {
14+
return stripIndent`
15+
/* eslint-disable */
16+
// AUTOGENERATED by prefab-cli's 'gen' command
17+
import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node'
18+
${this.additionalDependencies().join('\n') || '// No additional dependencies required'}
19+
20+
type ContextObj = Record<string, Record<string, unknown>>
21+
22+
declare namespace PrefabTypeGeneration {
23+
export type NodeServerConfigurationRaw = {
24+
${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'}
25+
}
26+
27+
export type NodeServerConfigurationAccessor = {
28+
${this.generateSchemaTypes().join('\n ') || '// No types generated'}
29+
}
30+
}
31+
32+
export class PrefabTypesafeNode {
33+
constructor(private prefab: Prefab) { }
34+
35+
get<K extends keyof PrefabTypeGeneration.NodeServerConfigurationRaw>(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] {
36+
return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K]
37+
}
38+
39+
${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'}
40+
}
41+
`
42+
}
43+
44+
private additionalDependencies(): string[] {
45+
const dependencies: string[] = []
46+
const hasFunctions = this.configurations().some((c) => c.hasFunction)
47+
48+
if (hasFunctions) {
49+
dependencies.push(this.MUSTACHE_IMPORT)
50+
}
51+
52+
return dependencies
53+
}
54+
55+
private generateAccessorMethods(): string[] {
56+
const uniqueMethods: Record<string, string> = {}
57+
const schemaTypes = this.configurations().map((config) => {
58+
let methodName = camelCase(config.key)
59+
60+
// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
61+
if (/^\d/.test(methodName)) {
62+
methodName = `_${methodName}`
63+
}
64+
65+
console.log(config.key, methodName)
66+
67+
if (uniqueMethods[methodName]) {
68+
throw new Error(
69+
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
70+
)
71+
}
72+
73+
uniqueMethods[methodName] = config.key
74+
75+
if (config.hasFunction) {
76+
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)
77+
78+
return stripIndent`
79+
${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] {
80+
const raw = this.get('${config.key}', contexts)
81+
return ${returnValue}
82+
}
83+
`
84+
}
85+
86+
return stripIndent`
87+
${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] {
88+
return this.get('${config.key}', contexts)
89+
}
90+
`
91+
})
92+
93+
return schemaTypes
94+
}
95+
96+
private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] {
97+
const schemaTypes = this.configurations().flatMap((config) => {
98+
const mapper = new ZodToTypescriptMapper({fieldName: config.key, target})
99+
100+
return mapper.renderField(config.schema)
101+
})
102+
103+
return schemaTypes
104+
}
105+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {stripIndent} from 'common-tags'
2+
import camelCase from 'lodash.camelcase'
3+
import {z} from 'zod'
4+
5+
import {ZodToTypescriptMapper, type ZodToTypescriptMapperTarget} from '../language-mappers/zod-to-typescript-mapper.js'
6+
import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js'
7+
import {BaseTypescriptGenerator} from './base-typescript-generator.js'
8+
9+
export class ReactTypeScriptGenerator extends BaseTypescriptGenerator {
10+
get filename(): string {
11+
return 'prefab-client.ts'
12+
}
13+
14+
protected durationTypeMap(): z.ZodTypeAny {
15+
return z.object({ms: z.number(), seconds: z.number()})
16+
}
17+
18+
generate(): string {
19+
return stripIndent`
20+
/* eslint-disable */
21+
// AUTOGENERATED by prefab-cli's 'gen' command
22+
import { Prefab } from "@prefab-cloud/prefab-cloud-js"
23+
import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react"
24+
${this.additionalDependencies().join('\n') || '// No additional dependencies required'}
25+
26+
declare namespace PrefabTypeGeneration {
27+
export type ReactHookConfigurationRaw = {
28+
${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'}
29+
}
30+
31+
export type ReactHookConfigurationAccessor = {
32+
${this.generateSchemaTypes().join('\n ') || '// No types generated'}
33+
}
34+
}
35+
36+
export class PrefabTypesafeReact {
37+
constructor(private prefab: Prefab) { }
38+
39+
get<K extends keyof PrefabTypeGeneration.ReactHookConfigurationRaw>(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] {
40+
return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K]
41+
}
42+
43+
${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'}
44+
}
45+
46+
export const usePrefab = createPrefabHook(PrefabTypesafeReact)
47+
`
48+
}
49+
50+
private additionalDependencies(): string[] {
51+
const dependencies: string[] = []
52+
const hasFunctions = this.filteredConfigurations().some((c) => c.hasFunction)
53+
54+
if (hasFunctions) {
55+
dependencies.push(this.MUSTACHE_IMPORT)
56+
}
57+
58+
return dependencies
59+
}
60+
61+
private filteredConfigurations() {
62+
return this.configurations().filter(
63+
(config) => config.configType === 'FEATURE_FLAG' || config.sendToClientSdk === true,
64+
)
65+
}
66+
67+
private generateAccessorMethods(): string[] {
68+
const uniqueMethods: Record<string, string> = {}
69+
const schemaTypes = this.filteredConfigurations().map((config) => {
70+
let methodName = camelCase(config.key)
71+
72+
// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
73+
if (/^\d/.test(methodName)) {
74+
methodName = `_${methodName}`
75+
}
76+
77+
if (uniqueMethods[methodName]) {
78+
throw new Error(
79+
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
80+
)
81+
}
82+
83+
uniqueMethods[methodName] = config.key
84+
85+
if (config.configType === 'FEATURE_FLAG') {
86+
return stripIndent`
87+
get ${methodName}(): boolean {
88+
return this.prefab.isEnabled('${config.key}')
89+
}
90+
`
91+
}
92+
93+
if (config.hasFunction) {
94+
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)
95+
96+
return stripIndent`
97+
${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
98+
const raw = this.get('${config.key}')
99+
return ${returnValue}
100+
}
101+
`
102+
}
103+
104+
return stripIndent`
105+
get ${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
106+
return this.get('${config.key}')
107+
}
108+
`
109+
})
110+
111+
return schemaTypes
112+
}
113+
114+
private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] {
115+
const schemaTypes = this.filteredConfigurations().map((config) => {
116+
const mapper = new ZodToTypescriptMapper({fieldName: config.key, target})
117+
118+
return mapper.renderField(config.schema)
119+
})
120+
121+
return schemaTypes
122+
}
123+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {z} from 'zod'
2+
3+
export class JsonToZodMapper {
4+
resolve(data: unknown): z.ZodTypeAny {
5+
if (Array.isArray(data)) {
6+
if (data.length > 0) {
7+
// Check if all elements in the array have the same type
8+
const firstItem = data[0]
9+
10+
const isHomogeneous = data.every((item) => {
11+
const itemsMatch = typeof item === typeof firstItem
12+
13+
// Special handling for objects and arrays
14+
if (typeof firstItem === 'object') {
15+
if (Array.isArray(item)) {
16+
return Array.isArray(firstItem)
17+
}
18+
19+
return !Array.isArray(firstItem)
20+
}
21+
22+
return itemsMatch
23+
})
24+
25+
// For homogeneous arrays, use the first element's type
26+
if (isHomogeneous) {
27+
return z.array(this.resolve(data[0]))
28+
}
29+
30+
// Explicitly do not handle mixed-type arrays
31+
// They could be tuples or heterogeneous arrays
32+
// Instead, we return an array of unknowns
33+
}
34+
35+
return z.array(z.unknown())
36+
}
37+
38+
if (typeof data === 'object' && data !== null) {
39+
const shape: Record<string, z.ZodTypeAny> = {}
40+
const dataRecord = data as Record<string, unknown>
41+
for (const key in dataRecord) {
42+
if (Object.hasOwn(dataRecord, key)) {
43+
shape[key] = this.resolve(dataRecord[key])
44+
}
45+
}
46+
47+
return z.object(shape)
48+
}
49+
50+
if (typeof data === 'string') {
51+
return z.string()
52+
}
53+
54+
if (typeof data === 'number') {
55+
return z.number()
56+
}
57+
58+
if (typeof data === 'boolean') {
59+
return z.boolean()
60+
}
61+
62+
if (data === null) {
63+
return z.null()
64+
}
65+
66+
console.warn(`Unknown json type:`, data)
67+
68+
// If the type is not recognized, default to 'any'
69+
return z.any()
70+
}
71+
}

0 commit comments

Comments
 (0)