-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(plugin): add ts-morph for client templates (#414)
Co-authored-by: Matt Kilpatrick <[email protected]> Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
- Loading branch information
1 parent
c0f2c7b
commit cb9f598
Showing
13 changed files
with
951 additions
and
1 deletion.
There are no files selected for viewing
157 changes: 157 additions & 0 deletions
157
packages/pages/src/common/src/parsers/sourceFileParser.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import { describe, it, expect } from "vitest"; | ||
import path from "node:path"; | ||
import createTestSourceFile from "../../../util/createTestSourceFile.js"; | ||
import SourceFileParser from "./sourceFileParser.js"; | ||
|
||
describe("getDefaultExport", () => { | ||
it("correctly gets default export's name when function", () => { | ||
const parser = createParser( | ||
`export const no = false; export default function test() {}` | ||
); | ||
const defaultExport = parser.getDefaultExport(); | ||
expect(defaultExport).toBe("test"); | ||
}); | ||
|
||
it("correctly gets default export's name when variable", () => { | ||
const parser = createParser(`const test = 5; export default test`); | ||
const defaultExport = parser.getDefaultExport(); | ||
expect(defaultExport).toBe("test"); | ||
}); | ||
}); | ||
|
||
describe("addDefaultExport", () => { | ||
it("correctly adds default export to file", () => { | ||
const parser = createParser(``); | ||
parser.addDefaultExport("test"); | ||
expect(parser.getDefaultExport()).toBe("test"); | ||
}); | ||
}); | ||
|
||
describe("getExpressionByName", () => { | ||
it("correctly returns variable expression when given name", () => { | ||
const parser = createParser(`const foo = 5; const bar = 7;`); | ||
const expression = parser.getExpressionByName("foo"); | ||
expect(expression).toBe("const foo = 5;"); | ||
}); | ||
|
||
it("correctly returns function expression when given name", () => { | ||
const parser = createParser(`function foo(){} const bar = 7;`); | ||
const expression = parser.getExpressionByName("foo"); | ||
expect(expression).toBe("function foo(){}"); | ||
}); | ||
|
||
it("correctly returns empty string when expression doesn't exist", () => { | ||
const parser = createParser(`const test = 5;`); | ||
const expression = parser.getExpressionByName("foo"); | ||
expect(expression).toBe(""); | ||
}); | ||
}); | ||
|
||
describe("addExpressions", () => { | ||
it("correctly adds expression to file", () => { | ||
const parser = createParser(``); | ||
parser.addExpressions(["const test = 5;"]); | ||
expect(parser.getExpressionByName("test")).toBe("const test = 5;"); | ||
}); | ||
}); | ||
|
||
describe("getAllImports", () => { | ||
it("correctly gets all imports from file", () => { | ||
const parser = createParser( | ||
`import * as React from "react"; import Template from "@yext/pages";` | ||
); | ||
const imports = parser.getAllImports(); | ||
expect(imports[0].moduleSpecifier).toBe("react"); | ||
expect(imports[1].moduleSpecifier).toBe("@yext/pages"); | ||
}); | ||
|
||
it("handles file having no imports", () => { | ||
const parser = createParser(`const test = 5;`); | ||
const imports = parser.getAllImports(); | ||
expect(imports).toEqual([]); | ||
}); | ||
|
||
it("handles multiple named imports from one path", () => { | ||
const parser = createParser( | ||
`import {Template, TemplateConfig, TemplateProps} from "@yext/pages";` | ||
); | ||
const imports = parser.getAllImports(); | ||
expect(imports[0].namedImports).toEqual([ | ||
"Template", | ||
"TemplateConfig", | ||
"TemplateProps", | ||
]); | ||
}); | ||
|
||
it("handles named imports and default imports from one path", () => { | ||
const parser = createParser( | ||
`import Foo, {Template, TemplateConfig, TemplateProps} from "@yext/pages";` | ||
); | ||
const imports = parser.getAllImports(); | ||
expect(imports[0].namedImports).toEqual([ | ||
"Template", | ||
"TemplateConfig", | ||
"TemplateProps", | ||
]); | ||
expect(imports[0].defaultImport).toBe("Foo"); | ||
}); | ||
}); | ||
|
||
describe("setAllImports", () => { | ||
it("correctly sets an import into file", () => { | ||
const parser = createParser(``); | ||
parser.setAllImports([ | ||
{ | ||
moduleSpecifier: "index.css", | ||
}, | ||
]); | ||
const imports = parser.getAllImports(); | ||
expect(imports.length).toEqual(1); | ||
expect(imports[0].moduleSpecifier).toBe("index.css"); | ||
}); | ||
|
||
it("handles having no imports to set", () => { | ||
const parser = createParser(``); | ||
parser.setAllImports([]); | ||
const imports = parser.getAllImports(); | ||
expect(imports).toEqual([]); | ||
}); | ||
}); | ||
|
||
describe("getChildExpressions", () => { | ||
it("correctly gets a child const", () => { | ||
const parser = createParser(`const foo = 4; const bar = foo + 3;`); | ||
const childExpressions = ["bar"]; | ||
parser.getChildExpressions("bar", childExpressions); | ||
expect(childExpressions).toEqual(["bar", "foo"]); | ||
}); | ||
|
||
it("correctly gets a child function", () => { | ||
const parser = createParser( | ||
`function foo(){ return 4;} const bar = foo() + 3;` | ||
); | ||
const childExpressions = ["bar"]; | ||
parser.getChildExpressions("bar", childExpressions); | ||
expect(childExpressions).toEqual(["bar", "foo"]); | ||
}); | ||
|
||
it("handles having no child expressions", () => { | ||
const parser = createParser(`const foo = 4; const bar = 3;`); | ||
const childExpressions = ["bar"]; | ||
parser.getChildExpressions("bar", childExpressions); | ||
expect(childExpressions).toEqual(["bar"]); | ||
}); | ||
|
||
it("handles getting invalid expression name", () => { | ||
const parser = createParser(`const foo = 4;`); | ||
const childExpressions: string[] = []; | ||
parser.getChildExpressions("bar", childExpressions); | ||
expect(childExpressions).toEqual([]); | ||
}); | ||
}); | ||
|
||
function createParser(sourceCode: string) { | ||
const filepath = path.resolve(__dirname, "test.tsx"); | ||
const { project } = createTestSourceFile(sourceCode, filepath); | ||
return new SourceFileParser(filepath, project); | ||
} |
207 changes: 207 additions & 0 deletions
207
packages/pages/src/common/src/parsers/sourceFileParser.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
import path from "node:path"; | ||
import { | ||
Project, | ||
SourceFile, | ||
SyntaxKind, | ||
ImportDeclarationStructure, | ||
OptionalKind, | ||
AssertEntryStructure, | ||
} from "ts-morph"; | ||
import typescript from "typescript"; | ||
|
||
export function createTsMorphProject() { | ||
return new Project({ | ||
compilerOptions: { | ||
jsx: typescript.JsxEmit.ReactJSX, | ||
}, | ||
}); | ||
} | ||
/** | ||
* Creates a SourceFileParser to use ts-morph functions. | ||
*/ | ||
export default class SourceFileParser { | ||
private sourceFile: SourceFile; | ||
|
||
constructor( | ||
private filepath: string, | ||
project: Project | ||
) { | ||
if (!project.getSourceFile(filepath)) { | ||
project.addSourceFileAtPath(filepath); | ||
} | ||
this.sourceFile = project.getSourceFileOrThrow(filepath); | ||
} | ||
|
||
getFunctions() { | ||
return this.sourceFile.getFunctions(); | ||
} | ||
|
||
/** | ||
* getChildExpressions looks for expressions called within a parent expression. | ||
* @param parentExpressionName the expression to parse through | ||
* @param allChildExpressions an array to save parsed expression names into | ||
*/ | ||
getChildExpressions( | ||
parentExpressionName: string, | ||
allChildExpressions: string[] | ||
) { | ||
const parentExpression = | ||
this.sourceFile.getVariableStatement(parentExpressionName); | ||
const descendantIdentifiers = parentExpression?.getDescendantsOfKind( | ||
SyntaxKind.Identifier | ||
); | ||
descendantIdentifiers?.forEach((identifier) => { | ||
if (!allChildExpressions.includes(identifier.getText().trim())) { | ||
allChildExpressions.push(identifier.getText().trim()); | ||
this.getChildExpressions( | ||
identifier.getText().trim(), | ||
allChildExpressions | ||
); | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* @param names the names of the expressions | ||
* @returns string[] containing code of each expression | ||
*/ | ||
getExpressionsByName(names: string[]): string[] { | ||
const expressions: string[] = []; | ||
names.forEach((name) => { | ||
expressions.push(this.getExpressionByName(name)); | ||
}); | ||
return expressions; | ||
} | ||
|
||
/** | ||
* Ex. source file contains const foo = 5; | ||
* getExpressionByName("foo") returns "const foo = 5;" | ||
* @param name of expression | ||
* @returns string containing expression's code | ||
*/ | ||
getExpressionByName(name: string) { | ||
if (this.sourceFile.getImportDeclaration(name)) { | ||
return ""; | ||
} | ||
let expression = this.sourceFile.getVariableStatement(name)?.getText(); | ||
expression ??= this.sourceFile.getFunction(name)?.getText(); | ||
expression ??= this.sourceFile.getInterface(name)?.getText(); | ||
expression ??= this.sourceFile.getTypeAlias(name)?.getText(); | ||
return expression ?? ""; | ||
} | ||
|
||
/** | ||
* Adds any strings into source file. | ||
* @param expressions the strings to add | ||
*/ | ||
addExpressions(expressions: string[]) { | ||
this.sourceFile.addStatements(expressions); | ||
} | ||
|
||
/** | ||
* getDefaultExport parses the source file for a default export. | ||
* @returns the default export's name | ||
*/ | ||
getDefaultExport(): string { | ||
const defaultExportSymbol = this.sourceFile.getDefaultExportSymbol(); | ||
if (!defaultExportSymbol) { | ||
return ""; | ||
} | ||
const declarations = defaultExportSymbol.getDeclarations(); | ||
const exportDeclaration = declarations[0]; | ||
if (exportDeclaration.isKind(SyntaxKind.FunctionDeclaration)) { | ||
return exportDeclaration.getName() ?? ""; | ||
} else if (exportDeclaration.isKind(SyntaxKind.ExportAssignment)) { | ||
const expression = this.sourceFile.getExportAssignment( | ||
(d) => !d.isExportEquals() | ||
); | ||
const defaultName = expression?.getChildAtIndex(2).getText(); | ||
return defaultName ?? ""; | ||
} | ||
return ""; | ||
} | ||
|
||
/** | ||
* Adds the default export to source file. | ||
* @param defaultName the default export's name | ||
*/ | ||
addDefaultExport(defaultName: string) { | ||
this.sourceFile.addExportAssignment({ | ||
expression: defaultName, | ||
isExportEquals: false, // set to default | ||
}); | ||
} | ||
|
||
/** | ||
* @returns all imports from source file | ||
*/ | ||
getAllImports(): OptionalKind<ImportDeclarationStructure>[] { | ||
const allImports: OptionalKind<ImportDeclarationStructure>[] = []; | ||
const imports = this.sourceFile.getImportDeclarations(); | ||
imports.forEach((importDec) => { | ||
const moduleSpecifier: string = importDec.getModuleSpecifierValue(); | ||
const namedImportsAsString: string[] = []; | ||
importDec.getNamedImports()?.forEach((namedImport) => { | ||
namedImportsAsString.push(namedImport.getName()); | ||
}); | ||
const assertElements: OptionalKind<AssertEntryStructure>[] = []; | ||
importDec | ||
.getAssertClause() | ||
?.getElements() | ||
?.forEach((element) => { | ||
assertElements.push({ | ||
value: element.getValue().getText(), | ||
name: element.getName(), | ||
}); | ||
}); | ||
allImports.push({ | ||
isTypeOnly: importDec.isTypeOnly(), | ||
defaultImport: importDec.getDefaultImport()?.getText(), | ||
namedImports: namedImportsAsString, | ||
namespaceImport: importDec.getNamespaceImport()?.getText(), | ||
moduleSpecifier: moduleSpecifier, | ||
assertElements: | ||
assertElements.length === 0 ? undefined : assertElements, | ||
}); | ||
}); | ||
|
||
return allImports; | ||
} | ||
|
||
/** | ||
* Adds the imports into source file. | ||
* @param allImports | ||
*/ | ||
setAllImports(allImports: OptionalKind<ImportDeclarationStructure>[]) { | ||
allImports.forEach((importDec) => { | ||
let moduleSpecifier: string | undefined; | ||
this.sourceFile.addImportDeclaration({ | ||
isTypeOnly: importDec.isTypeOnly, | ||
defaultImport: importDec.defaultImport, | ||
namedImports: importDec.namedImports, | ||
namespaceImport: importDec.namespaceImport, | ||
moduleSpecifier: moduleSpecifier ?? importDec.moduleSpecifier, | ||
assertElements: importDec.assertElements, | ||
}); | ||
}); | ||
this.sourceFile | ||
.fixMissingImports() | ||
.organizeImports() | ||
.fixUnusedIdentifiers(); | ||
} | ||
|
||
getFileName(): string { | ||
return this.sourceFile.getBaseName(); | ||
} | ||
|
||
/** | ||
* Saves all changes made to source file. | ||
*/ | ||
save() { | ||
this.sourceFile.saveSync(); | ||
} | ||
|
||
getAllText(): string { | ||
return this.sourceFile.getFullText(); | ||
} | ||
} |
Oops, something went wrong.