kea-typegen 3.7.1 → 3.8.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/package.json +3 -1
  3. package/dist/src/__tests__/inline.d.ts +1 -0
  4. package/dist/src/__tests__/inline.js +224 -0
  5. package/dist/src/__tests__/inline.js.map +1 -0
  6. package/dist/src/cli/typegen.js +4 -0
  7. package/dist/src/cli/typegen.js.map +1 -1
  8. package/dist/src/print/print.d.ts +6 -0
  9. package/dist/src/print/print.js +59 -42
  10. package/dist/src/print/print.js.map +1 -1
  11. package/dist/src/types.d.ts +1 -0
  12. package/dist/src/utils.d.ts +4 -0
  13. package/dist/src/utils.js +55 -0
  14. package/dist/src/utils.js.map +1 -1
  15. package/dist/src/visit/visit.js +6 -0
  16. package/dist/src/visit/visit.js.map +1 -1
  17. package/dist/src/write/writeInlineLogicTypes.d.ts +8 -0
  18. package/dist/src/write/writeInlineLogicTypes.js +236 -0
  19. package/dist/src/write/writeInlineLogicTypes.js.map +1 -0
  20. package/dist/src/write/writeTypeImports.d.ts +8 -0
  21. package/dist/src/write/writeTypeImports.js +3 -0
  22. package/dist/src/write/writeTypeImports.js.map +1 -1
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +3 -1
  25. package/samples/manualTypedLogic.ts +20 -0
  26. package/samples-inline/counterLogic.ts +36 -0
  27. package/samples-inline/manualLogic.ts +20 -0
  28. package/samples-inline/profileLogic.ts +30 -0
  29. package/samples-inline/searchLogic.ts +30 -0
  30. package/samples-inline/tsconfig.json +17 -0
  31. package/samples-inline/types.ts +5 -0
  32. package/src/__tests__/inline.ts +266 -0
  33. package/src/cli/typegen.ts +4 -0
  34. package/src/print/print.ts +102 -57
  35. package/src/types.ts +2 -0
  36. package/src/utils.ts +81 -0
  37. package/src/visit/visit.ts +14 -0
  38. package/src/write/writeInlineLogicTypes.ts +305 -0
  39. package/src/write/writeTypeImports.ts +4 -4
  40. package/tsconfig.json +1 -0
@@ -0,0 +1,305 @@
1
+ import * as ts from 'typescript'
2
+ import * as fs from 'fs'
3
+ import * as osPath from 'path'
4
+ import { factory, SyntaxKind } from 'typescript'
5
+ import { AppOptions, ParsedLogic } from '../types'
6
+ import { buildTypeImportEntries, nodeToString, runThroughPrettier } from '../print/print'
7
+ import { printValues } from '../print/printValues'
8
+ import { getInlineBlockStatements, isGeneratedInlineBlock } from '../utils'
9
+ import { applyTextEdits, getImportInsertPosition, getTypeArgumentInsertEnd, TextEdit } from './writeTypeImports'
10
+
11
+ export const INLINE_TYPE_MARKER = ' Generated by kea-typegen. DO NOT EDIT THIS BLOCK MANUALLY.'
12
+
13
+ /**
14
+ * Print a block of interfaces (Values, Actions, and Props when it's a literal), plus an
15
+ * `export type logicType = MakeLogicType<logicValues, logicActions, logicProps>` alias tying them together.
16
+ */
17
+ export function printInlineLogicType(parsedLogic: ParsedLogic): string {
18
+ const statements: ts.Statement[] = []
19
+ const typeArguments: ts.TypeNode[] = []
20
+
21
+ const createExportedInterface = (name: string, members: readonly ts.TypeElement[]) =>
22
+ factory.createInterfaceDeclaration(
23
+ [factory.createModifier(SyntaxKind.ExportKeyword)],
24
+ factory.createIdentifier(name),
25
+ undefined,
26
+ undefined,
27
+ members,
28
+ )
29
+ const addSection = (suffix: string, members: readonly ts.TypeElement[]) => {
30
+ if (members.length > 0) {
31
+ const name = `${parsedLogic.logicName}${suffix}`
32
+ statements.push(createExportedInterface(name, members))
33
+ typeArguments.push(factory.createTypeReferenceNode(factory.createIdentifier(name), undefined))
34
+ } else {
35
+ typeArguments.push(factory.createTypeLiteralNode([]))
36
+ }
37
+ }
38
+
39
+ addSection('Values', printValues(parsedLogic).members)
40
+ addSection(
41
+ 'Actions',
42
+ parsedLogic.actions.map(({ name, parameters, returnTypeNode }) =>
43
+ factory.createPropertySignature(
44
+ undefined,
45
+ factory.createIdentifier(name),
46
+ undefined,
47
+ factory.createFunctionTypeNode(undefined, parameters, returnTypeNode),
48
+ ),
49
+ ),
50
+ )
51
+ if (parsedLogic.propsType) {
52
+ if (ts.isTypeLiteralNode(parsedLogic.propsType)) {
53
+ addSection('Props', parsedLogic.propsType.members)
54
+ } else {
55
+ // props are already a named type (e.g. `props: {} as MyProps`) - reference it directly
56
+ typeArguments.push(parsedLogic.propsType)
57
+ }
58
+ }
59
+
60
+ statements.push(
61
+ factory.createTypeAliasDeclaration(
62
+ [factory.createModifier(SyntaxKind.ExportKeyword)],
63
+ factory.createIdentifier(parsedLogic.logicTypeName),
64
+ undefined,
65
+ factory.createTypeReferenceNode(factory.createIdentifier('MakeLogicType'), typeArguments),
66
+ ),
67
+ )
68
+ ts.addSyntheticLeadingComment(statements[0], SyntaxKind.SingleLineCommentTrivia, INLINE_TYPE_MARKER, true)
69
+ return statements.map(nodeToString).join('\n\n')
70
+ }
71
+
72
+ /**
73
+ * Write/update generated MakeLogicType blocks above every logic in the file,
74
+ * instead of writing a separate logicType.ts file.
75
+ */
76
+ export async function writeInlineLogicTypes(
77
+ program: ts.Program,
78
+ appOptions: AppOptions,
79
+ filename: string,
80
+ parsedLogics: ParsedLogic[],
81
+ nodeModulesPath: string,
82
+ shouldIgnore: (absolutePath: string) => boolean,
83
+ ): Promise<{ filesToWrite: number; writtenFiles: number }> {
84
+ const { log } = appOptions
85
+ const unchanged = { filesToWrite: 0, writtenFiles: 0 }
86
+
87
+ if (!filename.match(/\.tsx?$/)) {
88
+ return unchanged
89
+ }
90
+ const sourceFile = program.getSourceFile(filename)
91
+ if (!sourceFile) {
92
+ throw new Error(`Could not find source file: ${filename}`)
93
+ }
94
+ const rawCode = sourceFile.text
95
+ const edits: TextEdit[] = []
96
+ const managedLogics: ParsedLogic[] = []
97
+
98
+ for (const parsedLogic of parsedLogics) {
99
+ const existingDeclaration = sourceFile.statements.find(
100
+ (statement): statement is ts.TypeAliasDeclaration | ts.InterfaceDeclaration =>
101
+ (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) &&
102
+ statement.name.text === parsedLogic.logicTypeName,
103
+ )
104
+ if (existingDeclaration && !isGeneratedInlineBlock(existingDeclaration, parsedLogic.logicName)) {
105
+ // a manually written type with the same name - leave the logic alone
106
+ continue
107
+ }
108
+ managedLogics.push(parsedLogic)
109
+
110
+ const printedBlock = printInlineLogicType(parsedLogic)
111
+ const block = (
112
+ appOptions.prettier === false ? printedBlock : await runThroughPrettier(printedBlock, filename)
113
+ ).trimEnd()
114
+
115
+ if (existingDeclaration) {
116
+ const blockStatements = getInlineBlockStatements(existingDeclaration, parsedLogic.logicName)
117
+ const start = getBlockStart(blockStatements[0], sourceFile)
118
+ const end = existingDeclaration.getEnd()
119
+ if (rawCode.slice(start, end) !== block) {
120
+ edits.push({ start, end, text: block })
121
+ }
122
+ } else {
123
+ const statement = getTopLevelStatement(parsedLogic.node)
124
+ const insertPos = statement.getStart(sourceFile)
125
+ edits.push({ start: insertPos, end: insertPos, text: `${block}\n\n` })
126
+ }
127
+
128
+ // make sure the kea call is typed with the local logic type: kea<logicType>(...)
129
+ const callExpression = parsedLogic.node.parent
130
+ if (ts.isCallExpression(callExpression)) {
131
+ const start = callExpression.expression.getEnd()
132
+ const end = callExpression.typeArguments ? getTypeArgumentInsertEnd(callExpression, sourceFile) : start
133
+ const text = `<${parsedLogic.logicTypeName}>`
134
+ if (rawCode.slice(start, end) !== text) {
135
+ edits.push({ start, end, text })
136
+ }
137
+ }
138
+ }
139
+
140
+ if (managedLogics.length > 0) {
141
+ const importDeclarations = sourceFile.statements.filter(ts.isImportDeclaration)
142
+ const newImportLines: string[] = []
143
+
144
+ // make sure MakeLogicType is imported from kea
145
+ if (!importsMakeLogicType(importDeclarations)) {
146
+ const keaNamedImport = importDeclarations.find(
147
+ (declaration) =>
148
+ ts.isStringLiteralLike(declaration.moduleSpecifier) &&
149
+ declaration.moduleSpecifier.text === 'kea' &&
150
+ declaration.importClause?.namedBindings &&
151
+ ts.isNamedImports(declaration.importClause.namedBindings) &&
152
+ declaration.importClause.namedBindings.elements.length > 0,
153
+ )
154
+ if (keaNamedImport) {
155
+ const firstElement = (keaNamedImport.importClause.namedBindings as ts.NamedImports).elements[0]
156
+ const pos = firstElement.getStart(sourceFile)
157
+ edits.push({ start: pos, end: pos, text: 'MakeLogicType, ' })
158
+ } else {
159
+ newImportLines.push(`import type { MakeLogicType } from 'kea'`)
160
+ }
161
+ }
162
+
163
+ // remove the now obsolete `import type { logicType } from './logicType'`, if any
164
+ const typeFileNameBase = parsedLogics[0].typeFileName.replace(/\.[tj]sx?$/, '')
165
+ for (const declaration of importDeclarations) {
166
+ if (!ts.isStringLiteralLike(declaration.moduleSpecifier)) {
167
+ continue
168
+ }
169
+ const specifier = declaration.moduleSpecifier.text
170
+ if (!specifier.startsWith('.')) {
171
+ continue
172
+ }
173
+ if (osPath.resolve(osPath.dirname(filename), specifier) === typeFileNameBase) {
174
+ const start = declaration.getStart(sourceFile)
175
+ let end = declaration.getEnd()
176
+ if (rawCode[end] === '\n') {
177
+ end += 1
178
+ }
179
+ edits.push({ start, end, text: '' })
180
+ }
181
+ }
182
+
183
+ // import referenced types that are not yet available in this file
184
+ const knownNames = collectKnownTypeNames(sourceFile)
185
+ for (const parsedLogic of parsedLogics) {
186
+ knownNames.add(parsedLogic.logicTypeName)
187
+ }
188
+
189
+ const mergedTypeReferences: Record<string, Set<string>> = {}
190
+ for (const parsedLogic of managedLogics) {
191
+ for (const [file, names] of Object.entries(parsedLogic.typeReferencesToImportFromFiles)) {
192
+ mergedTypeReferences[file] = mergedTypeReferences[file] ?? new Set()
193
+ for (const name of names) {
194
+ mergedTypeReferences[file].add(name)
195
+ }
196
+ }
197
+ }
198
+
199
+ for (const entry of buildTypeImportEntries(mergedTypeReferences, filename, nodeModulesPath, shouldIgnore)) {
200
+ const absolutePath = entry.fullPath.startsWith('.')
201
+ ? osPath.resolve(osPath.dirname(filename), entry.fullPath)
202
+ : entry.fullPath
203
+ if (absolutePath === filename || absolutePath === parsedLogics[0].typeFileName) {
204
+ // types declared in this very file, or in the logicType.ts file we're replacing
205
+ continue
206
+ }
207
+ const missing = entry.list.filter((name) => !knownNames.has(name))
208
+ missing.forEach((name) => knownNames.add(name))
209
+ if (missing.length > 0) {
210
+ newImportLines.push(`import type { ${missing.join(', ')} } from '${entry.finalPath}'`)
211
+ }
212
+ }
213
+
214
+ if (newImportLines.length > 0) {
215
+ const insertPos = getImportInsertPosition(sourceFile, rawCode)
216
+ const text =
217
+ importDeclarations.length > 0
218
+ ? `\n${newImportLines.join('\n')}${rawCode.slice(insertPos, insertPos + 1) === '\n' ? '' : '\n'}`
219
+ : `${newImportLines.join('\n')}\n`
220
+ edits.push({ start: insertPos, end: insertPos, text })
221
+ }
222
+ }
223
+
224
+ if (edits.length === 0) {
225
+ if (appOptions.verbose) {
226
+ log(`🤷 Unchanged: ${osPath.relative(process.cwd(), filename)}`)
227
+ }
228
+ return unchanged
229
+ }
230
+
231
+ if (!appOptions.write) {
232
+ log(`❌ Will not write inline logic types: ${osPath.relative(process.cwd(), filename)}`)
233
+ return { filesToWrite: 1, writtenFiles: 0 }
234
+ }
235
+
236
+ const editedCode = applyTextEdits(rawCode, edits)
237
+ const newText = appOptions.prettier === false ? editedCode : await runThroughPrettier(editedCode, filename)
238
+ fs.writeFileSync(filename, newText)
239
+ log(`🔥 Writing inline logic types: ${osPath.relative(process.cwd(), filename)}`)
240
+ return { filesToWrite: 1, writtenFiles: 1 }
241
+ }
242
+
243
+ /** Start of the generated block: the kea-typegen marker comment if present, otherwise the declaration itself */
244
+ function getBlockStart(declaration: ts.Statement, sourceFile: ts.SourceFile): number {
245
+ const ranges = ts.getLeadingCommentRanges(sourceFile.text, declaration.pos) ?? []
246
+ const marker = ranges.find(({ pos, end }) => sourceFile.text.slice(pos, end).includes('kea-typegen'))
247
+ return marker ? marker.pos : declaration.getStart(sourceFile)
248
+ }
249
+
250
+ function getTopLevelStatement(node: ts.Node): ts.Node {
251
+ let statement = node
252
+ while (statement.parent && !ts.isSourceFile(statement.parent)) {
253
+ statement = statement.parent
254
+ }
255
+ return statement
256
+ }
257
+
258
+ function importsMakeLogicType(importDeclarations: ts.ImportDeclaration[]): boolean {
259
+ return importDeclarations.some(
260
+ (declaration) =>
261
+ ts.isStringLiteralLike(declaration.moduleSpecifier) &&
262
+ declaration.moduleSpecifier.text === 'kea' &&
263
+ declaration.importClause?.namedBindings &&
264
+ ts.isNamedImports(declaration.importClause.namedBindings) &&
265
+ declaration.importClause.namedBindings.elements.some(
266
+ (element) => element.name.text === 'MakeLogicType' && !element.propertyName,
267
+ ),
268
+ )
269
+ }
270
+
271
+ /** All type-ish names already bound at the top level of the file */
272
+ function collectKnownTypeNames(sourceFile: ts.SourceFile): Set<string> {
273
+ const names = new Set<string>()
274
+ for (const statement of sourceFile.statements) {
275
+ if (ts.isImportDeclaration(statement) && statement.importClause) {
276
+ if (statement.importClause.name) {
277
+ names.add(statement.importClause.name.text)
278
+ }
279
+ const namedBindings = statement.importClause.namedBindings
280
+ if (namedBindings) {
281
+ if (ts.isNamedImports(namedBindings)) {
282
+ namedBindings.elements.forEach((element) => names.add(element.name.text))
283
+ } else {
284
+ names.add(namedBindings.name.text)
285
+ }
286
+ }
287
+ } else if (
288
+ (ts.isTypeAliasDeclaration(statement) ||
289
+ ts.isInterfaceDeclaration(statement) ||
290
+ ts.isClassDeclaration(statement) ||
291
+ ts.isEnumDeclaration(statement) ||
292
+ ts.isFunctionDeclaration(statement)) &&
293
+ statement.name
294
+ ) {
295
+ names.add(statement.name.text)
296
+ } else if (ts.isVariableStatement(statement)) {
297
+ for (const declaration of statement.declarationList.declarations) {
298
+ if (ts.isIdentifier(declaration.name)) {
299
+ names.add(declaration.name.text)
300
+ }
301
+ }
302
+ }
303
+ }
304
+ return names
305
+ }
@@ -3,13 +3,13 @@ import * as ts from 'typescript'
3
3
  import * as osPath from 'path'
4
4
  import { runThroughPrettier } from '../print/print'
5
5
  import * as fs from 'fs'
6
- interface TextEdit {
6
+ export interface TextEdit {
7
7
  start: number
8
8
  end: number
9
9
  text: string
10
10
  }
11
11
 
12
- function applyTextEdits(source: string, edits: TextEdit[]): string {
12
+ export function applyTextEdits(source: string, edits: TextEdit[]): string {
13
13
  return edits
14
14
  .sort((a, b) => b.start - a.start || b.end - a.end)
15
15
  .reduce((output, { start, end, text }) => output.slice(0, start) + text + output.slice(end), source)
@@ -19,7 +19,7 @@ function getImportPath(importDeclaration: ts.ImportDeclaration): string | null {
19
19
  return ts.isStringLiteralLike(importDeclaration.moduleSpecifier) ? importDeclaration.moduleSpecifier.text : null
20
20
  }
21
21
 
22
- function getImportInsertPosition(sourceFile: ts.SourceFile, rawCode: string): number {
22
+ export function getImportInsertPosition(sourceFile: ts.SourceFile, rawCode: string): number {
23
23
  const importDeclarations = sourceFile.statements.filter(ts.isImportDeclaration)
24
24
  if (importDeclarations.length > 0) {
25
25
  return importDeclarations[importDeclarations.length - 1].getEnd()
@@ -29,7 +29,7 @@ function getImportInsertPosition(sourceFile: ts.SourceFile, rawCode: string): nu
29
29
  return shebangMatch ? shebangMatch[0].length : 0
30
30
  }
31
31
 
32
- function getTypeArgumentInsertEnd(callExpression: ts.CallExpression, sourceFile: ts.SourceFile): number {
32
+ export function getTypeArgumentInsertEnd(callExpression: ts.CallExpression, sourceFile: ts.SourceFile): number {
33
33
  const openParenToken = callExpression
34
34
  .getChildren(sourceFile)
35
35
  .find((child) => child.kind === ts.SyntaxKind.OpenParenToken)
package/tsconfig.json CHANGED
@@ -17,6 +17,7 @@
17
17
  "node_modules",
18
18
  "dist",
19
19
  "samples",
20
+ "samples-inline",
20
21
  "form-plugin"
21
22
  ]
22
23
  }