Skip to content

Commit

Permalink
feat(plugin): add ts-morph for client templates (#414)
Browse files Browse the repository at this point in the history
Co-authored-by: Matt Kilpatrick <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 25, 2023
1 parent c0f2c7b commit cb9f598
Show file tree
Hide file tree
Showing 13 changed files with 951 additions and 1 deletion.
157 changes: 157 additions & 0 deletions packages/pages/src/common/src/parsers/sourceFileParser.test.ts
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 packages/pages/src/common/src/parsers/sourceFileParser.ts
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();
}
}
Loading

0 comments on commit cb9f598

Please sign in to comment.