kea-typegen 3.7.1 → 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 (51) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +33 -0
  3. package/dist/package.json +3 -1
  4. package/dist/src/__tests__/inline.d.ts +1 -0
  5. package/dist/src/__tests__/inline.js +606 -0
  6. package/dist/src/__tests__/inline.js.map +1 -0
  7. package/dist/src/cache.d.ts +1 -0
  8. package/dist/src/cache.js +19 -0
  9. package/dist/src/cache.js.map +1 -1
  10. package/dist/src/cli/typegen.js +9 -0
  11. package/dist/src/cli/typegen.js.map +1 -1
  12. package/dist/src/print/print.d.ts +6 -0
  13. package/dist/src/print/print.js +61 -42
  14. package/dist/src/print/print.js.map +1 -1
  15. package/dist/src/types.d.ts +9 -0
  16. package/dist/src/utils.d.ts +6 -0
  17. package/dist/src/utils.js +82 -1
  18. package/dist/src/utils.js.map +1 -1
  19. package/dist/src/visit/visit.js +7 -0
  20. package/dist/src/visit/visit.js.map +1 -1
  21. package/dist/src/visit/visitConnect.js +40 -0
  22. package/dist/src/visit/visitConnect.js.map +1 -1
  23. package/dist/src/visit/visitSelectors.js +14 -1
  24. package/dist/src/visit/visitSelectors.js.map +1 -1
  25. package/dist/src/write/writeInlineLogicTypes.d.ts +8 -0
  26. package/dist/src/write/writeInlineLogicTypes.js +259 -0
  27. package/dist/src/write/writeInlineLogicTypes.js.map +1 -0
  28. package/dist/src/write/writeTypeImports.d.ts +8 -0
  29. package/dist/src/write/writeTypeImports.js +3 -0
  30. package/dist/src/write/writeTypeImports.js.map +1 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +3 -1
  33. package/samples/manualTypedLogic.ts +20 -0
  34. package/samples-inline/counterLogic.ts +37 -0
  35. package/samples-inline/manualLogic.ts +20 -0
  36. package/samples-inline/profileLogic.ts +31 -0
  37. package/samples-inline/searchLogic.ts +32 -0
  38. package/samples-inline/tsconfig.json +17 -0
  39. package/samples-inline/types.ts +5 -0
  40. package/src/__tests__/inline.ts +747 -0
  41. package/src/cache.ts +19 -0
  42. package/src/cli/typegen.ts +10 -0
  43. package/src/print/print.ts +106 -59
  44. package/src/types.ts +15 -0
  45. package/src/utils.ts +124 -1
  46. package/src/visit/visit.ts +15 -0
  47. package/src/visit/visitConnect.ts +50 -0
  48. package/src/visit/visitSelectors.ts +26 -1
  49. package/src/write/writeInlineLogicTypes.ts +354 -0
  50. package/src/write/writeTypeImports.ts +4 -4
  51. package/tsconfig.json +1 -0
@@ -0,0 +1,354 @@
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
+ const declaration = createExportedInterface(name, members)
33
+ ts.addSyntheticLeadingComment(declaration, SyntaxKind.SingleLineCommentTrivia, INLINE_TYPE_MARKER, true)
34
+ statements.push(declaration)
35
+ typeArguments.push(factory.createTypeReferenceNode(factory.createIdentifier(name), undefined))
36
+ } else {
37
+ typeArguments.push(factory.createTypeLiteralNode([]))
38
+ }
39
+ }
40
+
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
+ )
59
+ addSection(
60
+ 'Actions',
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,
70
+ ),
71
+ ),
72
+ )
73
+ if (parsedLogic.propsType) {
74
+ if (ts.isTypeLiteralNode(parsedLogic.propsType)) {
75
+ addSection('Props', parsedLogic.propsType.members)
76
+ } else {
77
+ // props are already a named type (e.g. `props: {} as MyProps`) - reference it directly
78
+ typeArguments.push(parsedLogic.propsType)
79
+ }
80
+ }
81
+
82
+ const typeAlias = factory.createTypeAliasDeclaration(
83
+ [factory.createModifier(SyntaxKind.ExportKeyword)],
84
+ factory.createIdentifier(parsedLogic.logicTypeName),
85
+ undefined,
86
+ factory.createTypeReferenceNode(factory.createIdentifier('MakeLogicType'), typeArguments),
87
+ )
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)
93
+ return statements.map(nodeToString).join('\n\n')
94
+ }
95
+
96
+ /**
97
+ * Write/update generated MakeLogicType blocks above every logic in the file,
98
+ * instead of writing a separate logicType.ts file.
99
+ */
100
+ export async function writeInlineLogicTypes(
101
+ program: ts.Program,
102
+ appOptions: AppOptions,
103
+ filename: string,
104
+ parsedLogics: ParsedLogic[],
105
+ nodeModulesPath: string,
106
+ shouldIgnore: (absolutePath: string) => boolean,
107
+ ): Promise<{ filesToWrite: number; writtenFiles: number }> {
108
+ const { log } = appOptions
109
+ const unchanged = { filesToWrite: 0, writtenFiles: 0 }
110
+
111
+ if (!filename.match(/\.tsx?$/)) {
112
+ return unchanged
113
+ }
114
+ const sourceFile = program.getSourceFile(filename)
115
+ if (!sourceFile) {
116
+ throw new Error(`Could not find source file: ${filename}`)
117
+ }
118
+ const rawCode = sourceFile.text
119
+ const edits: TextEdit[] = []
120
+ const managedLogics: ParsedLogic[] = []
121
+
122
+ for (const parsedLogic of parsedLogics) {
123
+ const existingDeclaration = sourceFile.statements.find(
124
+ (statement): statement is ts.TypeAliasDeclaration | ts.InterfaceDeclaration =>
125
+ (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) &&
126
+ statement.name.text === parsedLogic.logicTypeName,
127
+ )
128
+ if (existingDeclaration && !isGeneratedInlineBlock(existingDeclaration, parsedLogic.logicName)) {
129
+ // a manually written type with the same name - leave the logic alone
130
+ continue
131
+ }
132
+ managedLogics.push(parsedLogic)
133
+
134
+ const printedBlock = printInlineLogicType(parsedLogic)
135
+ const block = (
136
+ appOptions.prettier === false ? printedBlock : await runThroughPrettier(printedBlock, filename)
137
+ ).trimEnd()
138
+
139
+ if (existingDeclaration) {
140
+ const blockStatements = getInlineBlockStatements(existingDeclaration, parsedLogic.logicName)
141
+ const start = getBlockStart(blockStatements[0], sourceFile)
142
+ const end = existingDeclaration.getEnd()
143
+ if (!sameGeneratedCode(rawCode.slice(start, end), block)) {
144
+ edits.push({ start, end, text: block })
145
+ }
146
+ } else {
147
+ const statement = getTopLevelStatement(parsedLogic.node)
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)
151
+ edits.push({ start: insertPos, end: insertPos, text: `${block}\n\n` })
152
+ }
153
+
154
+ // make sure the kea call is typed with the local logic type: kea<logicType>(...)
155
+ const callExpression = parsedLogic.node.parent
156
+ if (ts.isCallExpression(callExpression)) {
157
+ const start = callExpression.expression.getEnd()
158
+ const end = callExpression.typeArguments ? getTypeArgumentInsertEnd(callExpression, sourceFile) : start
159
+ const text = `<${parsedLogic.logicTypeName}>`
160
+ if (rawCode.slice(start, end) !== text) {
161
+ edits.push({ start, end, text })
162
+ }
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
+ }
172
+ }
173
+
174
+ if (managedLogics.length > 0) {
175
+ const importDeclarations = sourceFile.statements.filter(ts.isImportDeclaration)
176
+ const newImportLines: string[] = []
177
+
178
+ // make sure MakeLogicType is imported from kea
179
+ if (!importsMakeLogicType(importDeclarations)) {
180
+ const keaNamedImport = importDeclarations.find(
181
+ (declaration) =>
182
+ ts.isStringLiteralLike(declaration.moduleSpecifier) &&
183
+ declaration.moduleSpecifier.text === 'kea' &&
184
+ declaration.importClause?.namedBindings &&
185
+ ts.isNamedImports(declaration.importClause.namedBindings) &&
186
+ declaration.importClause.namedBindings.elements.length > 0,
187
+ )
188
+ if (keaNamedImport) {
189
+ const firstElement = (keaNamedImport.importClause.namedBindings as ts.NamedImports).elements[0]
190
+ const pos = firstElement.getStart(sourceFile)
191
+ edits.push({ start: pos, end: pos, text: 'MakeLogicType, ' })
192
+ } else {
193
+ newImportLines.push(`import type { MakeLogicType } from 'kea'`)
194
+ }
195
+ }
196
+
197
+ // remove the now obsolete `import type { logicType } from './logicType'`, if any
198
+ const typeFileNameBase = parsedLogics[0].typeFileName.replace(/\.[tj]sx?$/, '')
199
+ for (const declaration of importDeclarations) {
200
+ if (!ts.isStringLiteralLike(declaration.moduleSpecifier)) {
201
+ continue
202
+ }
203
+ const specifier = declaration.moduleSpecifier.text
204
+ if (!specifier.startsWith('.')) {
205
+ continue
206
+ }
207
+ if (osPath.resolve(osPath.dirname(filename), specifier) === typeFileNameBase) {
208
+ const start = declaration.getStart(sourceFile)
209
+ let end = declaration.getEnd()
210
+ if (rawCode[end] === '\n') {
211
+ end += 1
212
+ }
213
+ edits.push({ start, end, text: '' })
214
+ }
215
+ }
216
+
217
+ // import referenced types that are not yet available in this file
218
+ const knownNames = collectKnownTypeNames(sourceFile)
219
+ for (const parsedLogic of parsedLogics) {
220
+ knownNames.add(parsedLogic.logicTypeName)
221
+ }
222
+
223
+ const mergedTypeReferences: Record<string, Set<string>> = {}
224
+ for (const parsedLogic of managedLogics) {
225
+ for (const [file, names] of Object.entries(parsedLogic.typeReferencesToImportFromFiles)) {
226
+ mergedTypeReferences[file] = mergedTypeReferences[file] ?? new Set()
227
+ for (const name of names) {
228
+ mergedTypeReferences[file].add(name)
229
+ }
230
+ }
231
+ }
232
+
233
+ for (const entry of buildTypeImportEntries(mergedTypeReferences, filename, nodeModulesPath, shouldIgnore)) {
234
+ const absolutePath = entry.fullPath.startsWith('.')
235
+ ? osPath.resolve(osPath.dirname(filename), entry.fullPath)
236
+ : entry.fullPath
237
+ if (absolutePath === filename || absolutePath === parsedLogics[0].typeFileName) {
238
+ // types declared in this very file, or in the logicType.ts file we're replacing
239
+ continue
240
+ }
241
+ const missing = entry.list.filter((name) => !knownNames.has(name))
242
+ missing.forEach((name) => knownNames.add(name))
243
+ if (missing.length > 0) {
244
+ newImportLines.push(`import type { ${missing.join(', ')} } from '${entry.finalPath}'`)
245
+ }
246
+ }
247
+
248
+ if (newImportLines.length > 0) {
249
+ const insertPos = getImportInsertPosition(sourceFile, rawCode)
250
+ const text =
251
+ importDeclarations.length > 0
252
+ ? `\n${newImportLines.join('\n')}${rawCode.slice(insertPos, insertPos + 1) === '\n' ? '' : '\n'}`
253
+ : `${newImportLines.join('\n')}\n`
254
+ edits.push({ start: insertPos, end: insertPos, text })
255
+ }
256
+ }
257
+
258
+ if (edits.length === 0) {
259
+ if (appOptions.verbose) {
260
+ log(`🤷 Unchanged: ${osPath.relative(process.cwd(), filename)}`)
261
+ }
262
+ return unchanged
263
+ }
264
+
265
+ if (!appOptions.write) {
266
+ log(`❌ Will not write inline logic types: ${osPath.relative(process.cwd(), filename)}`)
267
+ return { filesToWrite: 1, writtenFiles: 0 }
268
+ }
269
+
270
+ const editedCode = applyTextEdits(rawCode, edits)
271
+ const newText = appOptions.prettier === false ? editedCode : await runThroughPrettier(editedCode, filename)
272
+ fs.writeFileSync(filename, newText)
273
+ log(`🔥 Writing inline logic types: ${osPath.relative(process.cwd(), filename)}`)
274
+ return { filesToWrite: 1, writtenFiles: 1 }
275
+ }
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
+
292
+ /** Start of the generated block: the kea-typegen marker comment if present, otherwise the declaration itself */
293
+ function getBlockStart(declaration: ts.Statement, sourceFile: ts.SourceFile): number {
294
+ const ranges = ts.getLeadingCommentRanges(sourceFile.text, declaration.pos) ?? []
295
+ const marker = ranges.find(({ pos, end }) => sourceFile.text.slice(pos, end).includes('kea-typegen'))
296
+ return marker ? marker.pos : declaration.getStart(sourceFile)
297
+ }
298
+
299
+ function getTopLevelStatement(node: ts.Node): ts.Node {
300
+ let statement = node
301
+ while (statement.parent && !ts.isSourceFile(statement.parent)) {
302
+ statement = statement.parent
303
+ }
304
+ return statement
305
+ }
306
+
307
+ function importsMakeLogicType(importDeclarations: ts.ImportDeclaration[]): boolean {
308
+ return importDeclarations.some(
309
+ (declaration) =>
310
+ ts.isStringLiteralLike(declaration.moduleSpecifier) &&
311
+ declaration.moduleSpecifier.text === 'kea' &&
312
+ declaration.importClause?.namedBindings &&
313
+ ts.isNamedImports(declaration.importClause.namedBindings) &&
314
+ declaration.importClause.namedBindings.elements.some(
315
+ (element) => element.name.text === 'MakeLogicType' && !element.propertyName,
316
+ ),
317
+ )
318
+ }
319
+
320
+ /** All type-ish names already bound at the top level of the file */
321
+ function collectKnownTypeNames(sourceFile: ts.SourceFile): Set<string> {
322
+ const names = new Set<string>()
323
+ for (const statement of sourceFile.statements) {
324
+ if (ts.isImportDeclaration(statement) && statement.importClause) {
325
+ if (statement.importClause.name) {
326
+ names.add(statement.importClause.name.text)
327
+ }
328
+ const namedBindings = statement.importClause.namedBindings
329
+ if (namedBindings) {
330
+ if (ts.isNamedImports(namedBindings)) {
331
+ namedBindings.elements.forEach((element) => names.add(element.name.text))
332
+ } else {
333
+ names.add(namedBindings.name.text)
334
+ }
335
+ }
336
+ } else if (
337
+ (ts.isTypeAliasDeclaration(statement) ||
338
+ ts.isInterfaceDeclaration(statement) ||
339
+ ts.isClassDeclaration(statement) ||
340
+ ts.isEnumDeclaration(statement) ||
341
+ ts.isFunctionDeclaration(statement)) &&
342
+ statement.name
343
+ ) {
344
+ names.add(statement.name.text)
345
+ } else if (ts.isVariableStatement(statement)) {
346
+ for (const declaration of statement.declarationList.declarations) {
347
+ if (ts.isIdentifier(declaration.name)) {
348
+ names.add(declaration.name.text)
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return names
354
+ }
@@ -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
  }