kea-typegen 3.8.0 → 3.8.1

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 (38) hide show
  1. package/README.md +33 -0
  2. package/dist/package.json +1 -1
  3. package/dist/src/__tests__/inline.js +383 -1
  4. package/dist/src/__tests__/inline.js.map +1 -1
  5. package/dist/src/cache.d.ts +1 -0
  6. package/dist/src/cache.js +19 -0
  7. package/dist/src/cache.js.map +1 -1
  8. package/dist/src/cli/typegen.js +5 -0
  9. package/dist/src/cli/typegen.js.map +1 -1
  10. package/dist/src/print/print.js +6 -4
  11. package/dist/src/print/print.js.map +1 -1
  12. package/dist/src/types.d.ts +8 -0
  13. package/dist/src/utils.d.ts +2 -0
  14. package/dist/src/utils.js +27 -1
  15. package/dist/src/utils.js.map +1 -1
  16. package/dist/src/visit/visit.js +1 -0
  17. package/dist/src/visit/visit.js.map +1 -1
  18. package/dist/src/visit/visitConnect.js +40 -0
  19. package/dist/src/visit/visitConnect.js.map +1 -1
  20. package/dist/src/visit/visitSelectors.js +14 -1
  21. package/dist/src/visit/visitSelectors.js.map +1 -1
  22. package/dist/src/write/writeInlineLogicTypes.js +32 -9
  23. package/dist/src/write/writeInlineLogicTypes.js.map +1 -1
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +1 -1
  26. package/samples-inline/counterLogic.ts +2 -1
  27. package/samples-inline/profileLogic.ts +1 -0
  28. package/samples-inline/searchLogic.ts +2 -0
  29. package/src/__tests__/inline.ts +482 -1
  30. package/src/cache.ts +19 -0
  31. package/src/cli/typegen.ts +6 -0
  32. package/src/print/print.ts +9 -7
  33. package/src/types.ts +13 -0
  34. package/src/utils.ts +43 -1
  35. package/src/visit/visit.ts +1 -0
  36. package/src/visit/visitConnect.ts +50 -0
  37. package/src/visit/visitSelectors.ts +26 -1
  38. package/src/write/writeInlineLogicTypes.ts +67 -18
package/src/utils.ts CHANGED
@@ -99,6 +99,47 @@ export function isGeneratedInlineBlock(
99
99
  return getInlineBlockStatements(declaration, logicName).some(hasKeaTypegenMarker)
100
100
  }
101
101
 
102
+ /** Find a top-level `type logicType = ...` or `interface logicType {...}` declaration in the file */
103
+ export function findLogicTypeDeclaration(
104
+ sourceFile: ts.SourceFile,
105
+ logicTypeName: string,
106
+ ): ts.TypeAliasDeclaration | ts.InterfaceDeclaration | undefined {
107
+ return sourceFile.statements.find(
108
+ (statement): statement is ts.TypeAliasDeclaration | ts.InterfaceDeclaration =>
109
+ (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) &&
110
+ statement.name.text === logicTypeName,
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Should this logic file get inline MakeLogicType blocks instead of a logicType.ts file?
116
+ * True when running with --inline, when the file sits under one of `inlinePaths`, or when the
117
+ * file already carries a generated inline block (so converted files stay inline, whatever the config).
118
+ */
119
+ export function isInlineFile(
120
+ appOptions: AppOptions | undefined,
121
+ sourceFile: ts.SourceFile | undefined,
122
+ parsedLogics: ParsedLogic[],
123
+ ): boolean {
124
+ if (appOptions?.inline) {
125
+ return true
126
+ }
127
+ if (!sourceFile) {
128
+ return false
129
+ }
130
+ for (const inlinePath of appOptions?.inlinePaths ?? []) {
131
+ const resolvedPath = path.resolve(appOptions?.rootPath ?? '.', inlinePath)
132
+ const resolvedFileName = path.resolve(sourceFile.fileName)
133
+ if (resolvedFileName === resolvedPath || resolvedFileName.startsWith(resolvedPath + path.sep)) {
134
+ return true
135
+ }
136
+ }
137
+ return parsedLogics.some((parsedLogic) => {
138
+ const declaration = findLogicTypeDeclaration(sourceFile, parsedLogic.logicTypeName)
139
+ return declaration ? isGeneratedInlineBlock(declaration, parsedLogic.logicName) : false
140
+ })
141
+ }
142
+
102
143
  /**
103
144
  * True if the type argument in `kea<...>()` is a manually written type (e.g. `MakeLogicType<...>`
104
145
  * or a custom interface), as opposed to a type that kea-typegen generates and keeps updated.
@@ -173,7 +214,8 @@ export function getParameterDeclaration(param: ts.ParameterDeclaration) {
173
214
  return factory.createParameterDeclaration(
174
215
  undefined,
175
216
  undefined,
176
- factory.createIdentifier(param.name.getText()),
217
+ // .getText() throws on synthetic nodes (e.g. from signatureToSignatureDeclaration)
218
+ factory.createIdentifier(ts.isIdentifier(param.name) ? param.name.text : param.name.getText()),
177
219
  param.initializer || param.questionToken ? factory.createToken(SyntaxKind.QuestionToken) : undefined,
178
220
  cloneNodeSorted(param.type || factory.createKeywordTypeNode(SyntaxKind.AnyKeyword)),
179
221
  undefined,
@@ -281,6 +281,7 @@ export function visitKeaCalls(
281
281
  hasKeyInLogic: false,
282
282
  hasPathInLogic: false,
283
283
  typeReferencesToImportFromFiles: {},
284
+ selectorParamAnnotations: [],
284
285
  extraActions: {},
285
286
  extraInput: {},
286
287
  extraLogicFields: {},
@@ -1,5 +1,6 @@
1
1
  import { ParsedLogic } from '../types'
2
2
  import * as ts from 'typescript'
3
+ import { NodeBuilderFlags } from 'typescript'
3
4
  import { cloneNodeSorted, gatherImports, getParameterDeclaration } from '../utils'
4
5
  import { Expression, Type } from 'typescript'
5
6
 
@@ -47,6 +48,7 @@ export function visitConnect(parsedLogic: ParsedLogic, type: Type, expression: E
47
48
 
48
49
  const symbol = checker.getSymbolAtLocation(logicReference)
49
50
  const otherLogicType = checker.getTypeOfSymbolAtLocation(symbol, logicReference)
51
+ const sourceLogic = ts.isIdentifier(logicReference) ? logicReference.text : logicReference.getText()
50
52
 
51
53
  if (loaderName === 'actions') {
52
54
  const actionsForLogic = otherLogicType
@@ -85,12 +87,59 @@ export function visitConnect(parsedLogic: ParsedLogic, type: Type, expression: E
85
87
  name: lookup[name],
86
88
  returnTypeNode,
87
89
  parameters,
90
+ sourceLogic,
88
91
  })
89
92
  }
90
93
  }
91
94
  }
92
95
  }
93
96
  }
97
+ } else if (actionsForLogic) {
98
+ // `actionCreators` is not a literal type node we can walk syntactically - e.g. the other
99
+ // logic is typed with MakeLogicType, where actionCreators is a mapped type. Resolve the
100
+ // connected action types semantically instead.
101
+ const actionCreatorsType = checker.getTypeOfSymbolAtLocation(actionsForLogic, logicReference)
102
+ for (const actionProperty of actionCreatorsType.getProperties()) {
103
+ const name = actionProperty.getName()
104
+ if (!lookup[name]) {
105
+ continue
106
+ }
107
+ const actionCreatorType = checker.getTypeOfSymbolAtLocation(actionProperty, logicReference)
108
+ const signature = actionCreatorType.getCallSignatures()[0]
109
+ if (!signature) {
110
+ continue
111
+ }
112
+ const signatureNode = checker.signatureToSignatureDeclaration(
113
+ signature,
114
+ ts.SyntaxKind.FunctionType,
115
+ logicReference,
116
+ NodeBuilderFlags.NoTruncation | NodeBuilderFlags.IgnoreErrors,
117
+ ) as ts.FunctionTypeNode | undefined
118
+ if (!signatureNode) {
119
+ continue
120
+ }
121
+ const parameters = signatureNode.parameters.map((param) => getParameterDeclaration(param))
122
+ let returnType: ts.TypeNode = signatureNode.type
123
+ if (ts.isParenthesizedTypeNode(returnType)) {
124
+ returnType = returnType.type
125
+ }
126
+ if (ts.isTypeLiteralNode(returnType)) {
127
+ const payload = returnType.members.find(
128
+ (m) => m.name && ts.isIdentifier(m.name) && m.name.text === 'payload',
129
+ )
130
+ if (payload && ts.isPropertySignature(payload) && payload.type) {
131
+ const returnTypeNode = cloneNodeSorted(payload.type)
132
+ gatherImports(signatureNode, checker, parsedLogic)
133
+
134
+ parsedLogic.actions.push({
135
+ name: lookup[name],
136
+ returnTypeNode,
137
+ parameters,
138
+ sourceLogic,
139
+ })
140
+ }
141
+ }
142
+ }
94
143
  }
95
144
  }
96
145
 
@@ -109,6 +158,7 @@ export function visitConnect(parsedLogic: ParsedLogic, type: Type, expression: E
109
158
  name: lookup[name],
110
159
  typeNode,
111
160
  functionTypes: [],
161
+ sourceLogic,
112
162
  })
113
163
  }
114
164
  }
@@ -14,8 +14,14 @@ export function visitSelectors(parsedLogic: ParsedLogic, type: Type, expression:
14
14
  const inputFunctionTypeNode = checker.getTypeAtLocation(inputFunction)
15
15
 
16
16
  const selectorInputFunctionType = inputFunctionTypeNode.getCallSignatures()[0]?.getReturnType() as ts.Type
17
+ // IgnoreErrors: the tuple may mention types not referenceable from the logic file
18
+ // (e.g. kea's internal Props); we only consume the elements' return types anyway
17
19
  const selectorInputTypeNode = selectorInputFunctionType
18
- ? checker.typeToTypeNode(selectorInputFunctionType, inputFunction, NodeBuilderFlags.NoTruncation)
20
+ ? checker.typeToTypeNode(
21
+ selectorInputFunctionType,
22
+ inputFunction,
23
+ NodeBuilderFlags.NoTruncation | NodeBuilderFlags.IgnoreErrors,
24
+ )
19
25
  : null
20
26
 
21
27
  let functionNames = []
@@ -75,6 +81,25 @@ export function visitSelectors(parsedLogic: ParsedLogic, type: Type, expression:
75
81
  typeNode,
76
82
  functionTypes,
77
83
  })
84
+
85
+ // combiner parameters without a type annotation, e.g. `(counter) => counter * 2`.
86
+ // In inline mode these get their inferred types written into the source, since
87
+ // MakeLogicType's loose selector typing can no longer infer them contextually.
88
+ computedFunction.parameters.forEach((parameter, index) => {
89
+ const functionType = functionTypes[index]
90
+ if (
91
+ !parameter.type &&
92
+ !parameter.dotDotDotToken &&
93
+ ts.isIdentifier(parameter.name) &&
94
+ functionType &&
95
+ functionType.type.kind !== ts.SyntaxKind.AnyKeyword
96
+ ) {
97
+ parsedLogic.selectorParamAnnotations.push({
98
+ parameter,
99
+ typeNode: functionType.type,
100
+ })
101
+ }
102
+ })
78
103
  }
79
104
  }
80
105
  }
@@ -29,22 +29,44 @@ export function printInlineLogicType(parsedLogic: ParsedLogic): string {
29
29
  const addSection = (suffix: string, members: readonly ts.TypeElement[]) => {
30
30
  if (members.length > 0) {
31
31
  const name = `${parsedLogic.logicName}${suffix}`
32
- statements.push(createExportedInterface(name, members))
32
+ const declaration = createExportedInterface(name, members)
33
+ ts.addSyntheticLeadingComment(declaration, SyntaxKind.SingleLineCommentTrivia, INLINE_TYPE_MARKER, true)
34
+ statements.push(declaration)
33
35
  typeArguments.push(factory.createTypeReferenceNode(factory.createIdentifier(name), undefined))
34
36
  } else {
35
37
  typeArguments.push(factory.createTypeLiteralNode([]))
36
38
  }
37
39
  }
38
40
 
39
- addSection('Values', printValues(parsedLogic).members)
41
+ // mark connected values/actions with the logic they come from, e.g. `user: UserType | null // userLogic`
42
+ const withSourceLogicComment = <T extends ts.TypeElement>(member: T, sourceLogic: string | undefined): T =>
43
+ sourceLogic ? ts.addSyntheticTrailingComment(member, SyntaxKind.SingleLineCommentTrivia, ` ${sourceLogic}`, false) : member
44
+
45
+ const valueSources = new Map<string, string | undefined>()
46
+ for (const value of [...parsedLogic.reducers, ...parsedLogic.selectors]) {
47
+ valueSources.set(value.name, value.sourceLogic)
48
+ }
49
+
50
+ addSection(
51
+ 'Values',
52
+ printValues(parsedLogic).members.map((member) =>
53
+ withSourceLogicComment(
54
+ member,
55
+ member.name && ts.isIdentifier(member.name) ? valueSources.get(member.name.text) : undefined,
56
+ ),
57
+ ),
58
+ )
40
59
  addSection(
41
60
  '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),
61
+ parsedLogic.actions.map(({ name, parameters, returnTypeNode, sourceLogic }) =>
62
+ withSourceLogicComment(
63
+ factory.createPropertySignature(
64
+ undefined,
65
+ factory.createIdentifier(name),
66
+ undefined,
67
+ factory.createFunctionTypeNode(undefined, parameters, returnTypeNode),
68
+ ),
69
+ sourceLogic,
48
70
  ),
49
71
  ),
50
72
  )
@@ -57,15 +79,17 @@ export function printInlineLogicType(parsedLogic: ParsedLogic): string {
57
79
  }
58
80
  }
59
81
 
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
- ),
82
+ const typeAlias = factory.createTypeAliasDeclaration(
83
+ [factory.createModifier(SyntaxKind.ExportKeyword)],
84
+ factory.createIdentifier(parsedLogic.logicTypeName),
85
+ undefined,
86
+ factory.createTypeReferenceNode(factory.createIdentifier('MakeLogicType'), typeArguments),
67
87
  )
68
- ts.addSyntheticLeadingComment(statements[0], SyntaxKind.SingleLineCommentTrivia, INLINE_TYPE_MARKER, true)
88
+ if (statements.length === 0) {
89
+ // no interfaces at all - the alias itself carries the marker
90
+ ts.addSyntheticLeadingComment(typeAlias, SyntaxKind.SingleLineCommentTrivia, INLINE_TYPE_MARKER, true)
91
+ }
92
+ statements.push(typeAlias)
69
93
  return statements.map(nodeToString).join('\n\n')
70
94
  }
71
95
 
@@ -116,12 +140,14 @@ export async function writeInlineLogicTypes(
116
140
  const blockStatements = getInlineBlockStatements(existingDeclaration, parsedLogic.logicName)
117
141
  const start = getBlockStart(blockStatements[0], sourceFile)
118
142
  const end = existingDeclaration.getEnd()
119
- if (rawCode.slice(start, end) !== block) {
143
+ if (!sameGeneratedCode(rawCode.slice(start, end), block)) {
120
144
  edits.push({ start, end, text: block })
121
145
  }
122
146
  } else {
123
147
  const statement = getTopLevelStatement(parsedLogic.node)
124
- const insertPos = statement.getStart(sourceFile)
148
+ // insert above the statement's leading comments, so they stay attached to the logic
149
+ const leadingComments = ts.getLeadingCommentRanges(rawCode, statement.pos) ?? []
150
+ const insertPos = leadingComments.length > 0 ? leadingComments[0].pos : statement.getStart(sourceFile)
125
151
  edits.push({ start: insertPos, end: insertPos, text: `${block}\n\n` })
126
152
  }
127
153
 
@@ -135,6 +161,14 @@ export async function writeInlineLogicTypes(
135
161
  edits.push({ start, end, text })
136
162
  }
137
163
  }
164
+
165
+ // annotate untyped selector combiner parameters, e.g. `(counter) => ...` -> `(counter: number) => ...`.
166
+ // MakeLogicType types these as `any`, so without an explicit annotation the inferred
167
+ // value types would degrade to `any` on the next typegen run.
168
+ for (const { parameter, typeNode } of parsedLogic.selectorParamAnnotations) {
169
+ const insertPos = (parameter.questionToken ?? parameter.name).getEnd()
170
+ edits.push({ start: insertPos, end: insertPos, text: `: ${nodeToString(typeNode)}` })
171
+ }
138
172
  }
139
173
 
140
174
  if (managedLogics.length > 0) {
@@ -240,6 +274,21 @@ export async function writeInlineLogicTypes(
240
274
  return { filesToWrite: 1, writtenFiles: 1 }
241
275
  }
242
276
 
277
+ /**
278
+ * Compare generated code ignoring formatting, so a formatter (prettier, oxfmt, ...) reflowing the
279
+ * block - semicolons, trailing commas, quotes, line breaks - does not make us rewrite it forever.
280
+ */
281
+ function sameGeneratedCode(existingCode: string, generatedCode: string): boolean {
282
+ const normalize = (code: string): string =>
283
+ code
284
+ .replace(/"/g, "'")
285
+ .replace(/[;,]/g, '')
286
+ .replace(/\s+/g, '')
287
+ // leading pipe of a formatter-reflowed union type, e.g. `selection: | number | EditorRange`
288
+ .replace(/([:(<=[])\|/g, '$1')
289
+ return normalize(existingCode) === normalize(generatedCode)
290
+ }
291
+
243
292
  /** Start of the generated block: the kea-typegen marker comment if present, otherwise the declaration itself */
244
293
  function getBlockStart(declaration: ts.Statement, sourceFile: ts.SourceFile): number {
245
294
  const ranges = ts.getLeadingCommentRanges(sourceFile.text, declaration.pos) ?? []