Skip to content

Commit af04999

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

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
// AUTOGENERATED by prefab-cli's 'gen' command
16+
import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node'
17+
${this.additionalDependencies().join('\n') || '// No additional dependencies required'}
18+
19+
type ContextObj = Record<string, Record<string, unknown>>
20+
21+
declare namespace PrefabTypeGeneration {
22+
export type NodeServerConfigurationRaw = {
23+
${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'}
24+
}
25+
26+
export type NodeServerConfigurationAccessor = {
27+
${this.generateSchemaTypes().join('\n ') || '// No types generated'}
28+
}
29+
}
30+
31+
export class PrefabTypesafeNode {
32+
constructor(private prefab: Prefab) { }
33+
34+
get<K extends keyof PrefabTypeGeneration.NodeServerConfigurationRaw>(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] {
35+
return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K]
36+
}
37+
38+
${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'}
39+
}
40+
`
41+
}
42+
43+
private additionalDependencies(): string[] {
44+
const dependencies: string[] = []
45+
const hasFunctions = this.configurations().some((c) => c.hasFunction)
46+
47+
if (hasFunctions) {
48+
dependencies.push(this.MUSTACHE_IMPORT)
49+
}
50+
51+
return dependencies
52+
}
53+
54+
private generateAccessorMethods(): string[] {
55+
const uniqueMethods: Record<string, string> = {}
56+
const schemaTypes = this.configurations().map((config) => {
57+
let methodName = camelCase(config.key)
58+
59+
// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
60+
if (/^\d/.test(methodName)) {
61+
methodName = `_${methodName}`
62+
}
63+
64+
console.log(config.key, methodName)
65+
66+
if (uniqueMethods[methodName]) {
67+
throw new Error(
68+
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
69+
)
70+
}
71+
72+
uniqueMethods[methodName] = config.key
73+
74+
if (config.hasFunction) {
75+
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)
76+
77+
return stripIndent`
78+
${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] {
79+
const raw = this.get('${config.key}', contexts)
80+
return ${returnValue}
81+
}
82+
`
83+
}
84+
85+
return stripIndent`
86+
${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] {
87+
return this.get('${config.key}', contexts)
88+
}
89+
`
90+
})
91+
92+
return schemaTypes
93+
}
94+
95+
private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] {
96+
const schemaTypes = this.configurations().flatMap((config) => {
97+
const mapper = new ZodToTypescriptMapper({fieldName: config.key, target})
98+
99+
return mapper.renderField(config.schema)
100+
})
101+
102+
return schemaTypes
103+
}
104+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
// AUTOGENERATED by prefab-cli's 'gen' command
21+
import { Prefab } from "@prefab-cloud/prefab-cloud-js"
22+
import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react"
23+
${this.additionalDependencies().join('\n') || '// No additional dependencies required'}
24+
25+
declare namespace PrefabTypeGeneration {
26+
export type ReactHookConfigurationRaw = {
27+
${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'}
28+
}
29+
30+
export type ReactHookConfigurationAccessor = {
31+
${this.generateSchemaTypes().join('\n ') || '// No types generated'}
32+
}
33+
}
34+
35+
export class PrefabTypesafeReact {
36+
constructor(private prefab: Prefab) { }
37+
38+
get<K extends keyof PrefabTypeGeneration.ReactHookConfigurationRaw>(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] {
39+
return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K]
40+
}
41+
42+
${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'}
43+
}
44+
45+
export const usePrefab = createPrefabHook(PrefabTypesafeReact)
46+
`
47+
}
48+
49+
private additionalDependencies(): string[] {
50+
const dependencies: string[] = []
51+
const hasFunctions = this.filteredConfigurations().some((c) => c.hasFunction)
52+
53+
if (hasFunctions) {
54+
dependencies.push(this.MUSTACHE_IMPORT)
55+
}
56+
57+
return dependencies
58+
}
59+
60+
private filteredConfigurations() {
61+
return this.configurations().filter(
62+
(config) => config.configType === 'FEATURE_FLAG' || config.sendToClientSdk === true,
63+
)
64+
}
65+
66+
private generateAccessorMethods(): string[] {
67+
const uniqueMethods: Record<string, string> = {}
68+
const schemaTypes = this.filteredConfigurations().map((config) => {
69+
let methodName = camelCase(config.key)
70+
71+
// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
72+
if (/^\d/.test(methodName)) {
73+
methodName = `_${methodName}`
74+
}
75+
76+
if (uniqueMethods[methodName]) {
77+
throw new Error(
78+
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
79+
)
80+
}
81+
82+
uniqueMethods[methodName] = config.key
83+
84+
if (config.configType === 'FEATURE_FLAG') {
85+
return stripIndent`
86+
get ${methodName}(): boolean {
87+
return this.prefab.isEnabled('${config.key}')
88+
}
89+
`
90+
}
91+
92+
if (config.hasFunction) {
93+
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)
94+
95+
return stripIndent`
96+
${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
97+
const raw = this.get('${config.key}')
98+
return ${returnValue}
99+
}
100+
`
101+
}
102+
103+
return stripIndent`
104+
get ${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
105+
return this.get('${config.key}')
106+
}
107+
`
108+
})
109+
110+
return schemaTypes
111+
}
112+
113+
private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] {
114+
const schemaTypes = this.filteredConfigurations().map((config) => {
115+
const mapper = new ZodToTypescriptMapper({fieldName: config.key, target})
116+
117+
return mapper.renderField(config.schema)
118+
})
119+
120+
return schemaTypes
121+
}
122+
}
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)