Skip to content

feat: Nextjs app router file Structure, Duplicate child handled & Import Aliases handled #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
401 changes: 244 additions & 157 deletions src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,62 @@
import * as babelParser from '@babel/parser';
import * as path from 'path';
import * as fs from 'fs';
import { getNonce } from './getNonce';
import { Tree } from './types/Tree';
import { ImportObj } from './types/ImportObj';
import { File } from '@babel/types';
import * as babelParser from "@babel/parser";
import * as path from "path";
import * as fs from "fs";
import { getNonce } from "./getNonce";
import { Tree } from "./types/Tree";
import { ImportObj } from "./types/ImportObj";
import { File } from "@babel/types";

export class Parser {
entryFile: string;
tree: Tree | undefined;
projectRoot: string;
hasSrcDir: boolean;

constructor(filePath: string) {
// Fix when selecting files in wsl file system
this.entryFile = filePath;
if (process.platform === 'linux' && this.entryFile.includes('wsl$')) {
this.entryFile = path.resolve(
filePath.split(path.win32.sep).join(path.posix.sep)
);
this.entryFile = '/' + this.entryFile.split('/').slice(3).join('/');
// Fix for when running wsl but selecting files held on windows file system
} else if (
process.platform === 'linux' &&
/[a-zA-Z]/.test(this.entryFile[0])
) {
const root = `/mnt/${this.entryFile[0].toLowerCase()}`;
this.entryFile = path.join(
root,
filePath.split(path.win32.sep).slice(1).join(path.posix.sep)
);
}

this.entryFile = this.normalizePath(filePath);
this.tree = undefined;
// Break down and reasemble given filePath safely for any OS using path?
this.projectRoot = this.findProjectRoot(this.entryFile);
this.hasSrcDir = fs.existsSync(path.join(this.projectRoot, "src"));
}

private normalizePath(filePath: string): string {
if (process.platform === "linux") {
if (filePath.includes("wsl$")) {
return (
"/" + filePath.split(path.win32.sep).slice(3).join(path.posix.sep)
);
// Fix for when running wsl but selecting files held on windows file system
} else if (/[a-zA-Z]/.test(filePath[0])) {
const root = `/mnt/${filePath[0].toLowerCase()}`;
return path.join(
root,
filePath.split(path.win32.sep).slice(1).join(path.posix.sep)
);
}
}
return filePath;
}
// finds project root directory, the ditectory where "package.json" exists
private findProjectRoot(filePath: string): string {
let currentDir = path.dirname(filePath);
while (currentDir !== path.parse(currentDir).root) {
if (fs.existsSync(path.join(currentDir, "package.json"))) {
return currentDir;
}
currentDir = path.dirname(currentDir);
}
throw new Error("Project root not found");
}

// Public method to generate component tree based on current entryFile
public parse(): Tree {
const rootImportPath = this.hasSrcDir ? "./src/app/" : "./app/";
// Create root Tree node
const root = {
id: getNonce(),
name: path.basename(this.entryFile).replace(/\.(t|j)sx?$/, ''),
name: path.basename(this.entryFile).replace(/\.(t|j)sx?$/, ""),
fileName: path.basename(this.entryFile),
filePath: this.entryFile,
importPath: '/', // this.entryFile here breaks windows file path on root e.g. C:\\ is detected as third party
importPath: rootImportPath,
expanded: false,
depth: 0,
count: 1,
@@ -52,10 +66,11 @@ export class Parser {
children: [],
parentList: [],
props: {},
error: '',
error: "",
};

this.tree = root;
console.log(this.tree);
this.parser(root);
return this.tree;
}
@@ -141,26 +156,6 @@ export class Parser {

// Recursively builds the React component tree structure starting from root node
private parser(componentTree: Tree): Tree | undefined {
// If import is a node module, do not parse any deeper
if (!['\\', '/', '.'].includes(componentTree.importPath[0])) {
componentTree.thirdParty = true;
if (
componentTree.fileName === 'react-router-dom' ||
componentTree.fileName === 'react-router'
) {
componentTree.reactRouter = true;
}
return;
}

// Check that file has valid fileName/Path, if not found, add error to node and halt
const fileName = this.getFileName(componentTree);
if (!fileName) {
componentTree.error = 'File not found.';
return;
}

// If current node recursively calls itself, do not parse any deeper:
if (componentTree.parentList.includes(componentTree.filePath)) {
return;
}
@@ -169,15 +164,15 @@ export class Parser {
let ast: babelParser.ParseResult<File>;
try {
ast = babelParser.parse(
fs.readFileSync(path.resolve(componentTree.filePath), 'utf-8'),
fs.readFileSync(path.resolve(componentTree.filePath), "utf-8"),
{
sourceType: 'module',
sourceType: "module",
tokens: true,
plugins: ['jsx', 'typescript'],
plugins: ["jsx", "typescript"],
}
);
} catch (err) {
componentTree.error = 'Error while processing this file/node';
componentTree.error = "Error while processing this file/node";
return componentTree;
}

@@ -186,11 +181,12 @@ export class Parser {

// Get any JSX Children of current file:
if (ast.tokens) {
componentTree.children = this.getJSXChildren(
const childrenObj = this.getJSXChildren(
ast.tokens,
imports,
componentTree
);
componentTree.children = Object.values(childrenObj);
}

// Check if current node is connected to the Redux store
@@ -204,70 +200,111 @@ export class Parser {
return componentTree;
}

// Finds files where import string does not include a file extension
private getFileName(componentTree: Tree): string | undefined {
const ext = path.extname(componentTree.filePath);
let fileName: string | undefined = componentTree.fileName;

if (!ext) {
// Try and find file extension that exists in directory:
const fileArray = fs.readdirSync(path.dirname(componentTree.filePath));
const regEx = new RegExp(`${componentTree.fileName}.(j|t)sx?$`);
fileName = fileArray.find((fileStr) => fileStr.match(regEx));
fileName ? (componentTree.filePath += path.extname(fileName)) : null;
}

return fileName;
}

// Extracts Imports from current file
// const Page1 = lazy(() => import('./page1')); -> is parsed as 'ImportDeclaration'
// import Page2 from './page2'; -> is parsed as 'VariableDeclaration'
private getImports(body: { [key: string]: any }[]): ImportObj {
const bodyImports = body.filter(
(item) => item.type === 'ImportDeclaration' || 'VariableDeclaration'
(item) => item.type === "ImportDeclaration" || "VariableDeclaration"
);
// console.log('bodyImports are: ', bodyImports);

return bodyImports.reduce((accum, curr) => {
// Import Declarations:
if (curr.type === 'ImportDeclaration') {
if (curr.type === "ImportDeclaration") {
curr.specifiers.forEach(
(i: {
local: { name: string | number };
imported: { name: any };
}) => {
(i: { local: { name: string }; imported: { name: string } }) => {
const { importPath, filePath } = this.resolveImportPath(
curr.source.value
);
accum[i.local.name] = {
importPath: curr.source.value,
importPath,
filePath,
importName: i.imported ? i.imported.name : i.local.name,
};
}
);
}
// Imports Inside Variable Declarations: // Not easy to deal with nested objects
if (curr.type === 'VariableDeclaration') {
// Imports Inside Variable Declarations: // Not easy to deal with nested objects
} else if (curr.type === "VariableDeclaration") {
const importPath = this.findVarDecImports(curr.declarations[0]);
if (importPath) {
if (typeof importPath === "string") {
const importName = curr.declarations[0].id.name;
const { importPath: resolvedImportPath, filePath } =
this.resolveImportPath(importPath);
accum[curr.declarations[0].id.name] = {
importPath,
importPath: resolvedImportPath,
filePath,
importName,
};
}
}
return accum;
}, {});
}, {} as ImportObj);
}

// Finds files name and file extension
private resolveImportPath(importPath: string): {
importPath: string;
filePath: string;
} {
if (importPath.startsWith("@/")) {
const relativePath = importPath.slice(2);
const importPathResolved = this.hasSrcDir
? `./src/${relativePath}`
: `./${relativePath}`;
const filePathResolved = path.join(
this.projectRoot,
this.hasSrcDir ? "src" : "",
relativePath
);
return {
importPath: importPathResolved,
filePath: this.addFileExtension(filePathResolved),
};
} else if (importPath.startsWith("./") || importPath.startsWith("../")) {
const filePathResolved = path.resolve(
path.dirname(this.entryFile),
importPath
);
const importPathResolved = path.relative(
this.projectRoot,
filePathResolved
);
return {
importPath: `./${importPathResolved.replace(/\\/g, "/")}`,
filePath: this.addFileExtension(filePathResolved),
};
} else if (importPath.startsWith("/")) {
const filePathResolved = path.join(this.projectRoot, importPath);
return {
importPath,
filePath: this.addFileExtension(filePathResolved),
};
} else {
// Third-party import
return { importPath, filePath: importPath };
}
}

private addFileExtension(filePath: string): string {
const extensions = [".tsx", ".ts", ".jsx", ".js"];
for (const ext of extensions) {
if (fs.existsSync(`${filePath}${ext}`)) {
return `${filePath}${ext}`;
}
}
return filePath;
}

// Recursive helper method to find import path in Variable Declaration
private findVarDecImports(ast: { [key: string]: any }): string | boolean {
// Base Case, find import path in variable declaration and return it,
if (ast.hasOwnProperty('callee') && ast.callee.type === 'Import') {
if (ast.hasOwnProperty("callee") && ast.callee.type === "Import") {
return ast.arguments[0].value;
}

// Otherwise look for imports in any other non null/undefined objects in the tree:
for (let key in ast) {
if (ast.hasOwnProperty(key) && typeof ast[key] === 'object' && ast[key]) {
if (ast.hasOwnProperty(key) && typeof ast[key] === "object" && ast[key]) {
const importPath = this.findVarDecImports(ast[key]);
if (importPath) {
return importPath;
@@ -283,47 +320,90 @@ export class Parser {
astTokens: any[],
importsObj: ImportObj,
parentNode: Tree
): Tree[] {
let childNodes: { [key: string]: Tree } = {};
): { [fileName: string]: Tree } {
let childNodes: { [fileName: string]: Tree } = {};
let props: { [key: string]: boolean } = {};
let token: { [key: string]: any };
let currentElement: string | null = null;

for (let i = 0; i < astTokens.length; i++) {
const token = astTokens[i];

// Case for finding JSX tags eg <App .../>
if (
astTokens[i].type.label === 'jsxTagStart' &&
astTokens[i + 1].type.label === 'jsxName' &&
importsObj[astTokens[i + 1].value]
) {
token = astTokens[i + 1];
props = this.getJSXProps(astTokens, i + 2);
childNodes = this.getChildNodes(
importsObj,
token,
props,
parentNode,
childNodes
);
if (token.type.label === "jsxTagStart") {
currentElement = null;
props = {};
}

// Case for finding components passed in as props e.g. <Route component={App} />
} else if (
astTokens[i].type.label === 'jsxName' &&
(astTokens[i].value === 'component' ||
astTokens[i].value === 'children') &&
importsObj[astTokens[i + 3].value]
) {
token = astTokens[i + 3];
childNodes = this.getChildNodes(
importsObj,
token,
props,
parentNode,
childNodes
);
// JSX element name
if (token.type.label === "jsxName" && currentElement === null) {
currentElement = token.value;
// Check if this element is an imported component
if (importsObj[currentElement]) {
childNodes = this.getChildNodes(
importsObj,
{ value: currentElement },
props,
parentNode,
childNodes
);
}
}

// JSX props
if (token.type.label === "jsxName" && currentElement !== null) {
const propName = token.value;
if (astTokens[i + 1].type.label === "eq") {
props[propName] = true;

// Check for component passed as prop
if (
astTokens[i + 2].type.label === "jsxTagStart" &&
astTokens[i + 3].type.label === "jsxName" &&
importsObj[astTokens[i + 3].value]
) {
childNodes = this.getChildNodes(
importsObj,
{ value: astTokens[i + 3].value },
{},
parentNode,
childNodes
);
}
}
}

// Handle components in JSX expressions
if (token.type.label === "jsxExpressionStart") {
let j = i + 1;
while (
j < astTokens.length &&
astTokens[j].type.label !== "jsxExpressionEnd"
) {
if (
astTokens[j].type.label === "name" &&
importsObj[astTokens[j].value]
) {
childNodes = this.getChildNodes(
importsObj,
{ value: astTokens[j].value },
{},
parentNode,
childNodes
);
}
j++;
}
i = j; // Skip to end of expression
}

// End of JSX element
if (token.type.label === "jsxTagEnd") {
currentElement = null;
props = {};
}
}

return Object.values(childNodes);
return childNodes;
}

private getChildNodes(
@@ -333,56 +413,63 @@ export class Parser {
parent: Tree,
children: { [key: string]: Tree }
): { [key: string]: Tree } {
if (children[astToken.value]) {
children[astToken.value].count += 1;
children[astToken.value].props = {
...children[astToken.value].props,
const uniqueChildren: { [fileName: string]: Tree } = {};

Object.entries(children).forEach(([key, child]) => {
uniqueChildren[child.fileName] = child;
});

const importInfo = imports[astToken.value];
const isThirdParty = !importInfo.importPath.startsWith(".");
const fileName = path.basename(importInfo.filePath);

if (uniqueChildren[fileName]) {
uniqueChildren[fileName].count += 1;
uniqueChildren[fileName].props = {
...uniqueChildren[fileName].props,
...props,
};
} else {
// Add tree node to childNodes if one does not exist
children[astToken.value] = {
uniqueChildren[fileName] = {
id: getNonce(),
name: imports[astToken.value]['importName'],
fileName: path.basename(imports[astToken.value]['importPath']),
filePath: path.resolve(
path.dirname(parent.filePath),
imports[astToken.value]['importPath']
),
importPath: imports[astToken.value]['importPath'],
name: importInfo.importName,
fileName: fileName,
filePath: importInfo.filePath,
importPath: importInfo.importPath,
expanded: false,
depth: parent.depth + 1,
thirdParty: false,
thirdParty: isThirdParty,
reactRouter: false,
reduxConnect: false,
count: 1,
props: props,
children: [],
parentList: [parent.filePath].concat(parent.parentList),
error: '',
error: "",
};
}

return children;
return uniqueChildren;
}

// Extracts prop names from a JSX element
private getJSXProps(
astTokens: { [key: string]: any }[],
j: number
): { [key: string]: boolean } {
const props: any = {};
while (astTokens[j].type.label !== 'jsxTagEnd') {
if (
astTokens[j].type.label === 'jsxName' &&
astTokens[j + 1].value === '='
) {
props[astTokens[j].value] = true;
}
j += 1;
}
return props;
}
// private getJSXProps(
// astTokens: { [key: string]: any }[],
// j: number
// ): { [key: string]: boolean } {
// const props: any = {};
// while (astTokens[j].type.label !== "jsxTagEnd") {
// if (
// astTokens[j].type.label === "jsxName" &&
// astTokens[j + 1].value === "="
// ) {
// props[astTokens[j].value] = true;
// }
// j += 1;
// }
// return props;
// }

// Checks if current Node is connected to React-Redux Store
private checkForRedux(astTokens: any[], importsObj: ImportObj): boolean {
@@ -391,8 +478,8 @@ export class Parser {
let connectAlias;
Object.keys(importsObj).forEach((key) => {
if (
importsObj[key].importPath === 'react-redux' &&
importsObj[key].importName === 'connect'
importsObj[key].importPath === "react-redux" &&
importsObj[key].importName === "connect"
) {
reduxImported = true;
connectAlias = key;
@@ -406,13 +493,13 @@ export class Parser {
// Check that connect method is invoked and exported in the file
for (let i = 0; i < astTokens.length; i += 1) {
if (
astTokens[i].type.label === 'export' &&
astTokens[i + 1].type.label === 'default' &&
astTokens[i].type.label === "export" &&
astTokens[i + 1].type.label === "default" &&
astTokens[i + 2].value === connectAlias
) {
return true;
}
}
return false;
}
}
}
2 changes: 1 addition & 1 deletion src/types/ImportObj.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type ImportObj = {
[key: string]: { importPath: string; importName: string; };
[key: string]: { importPath: string; importName: string; filePath: string };
};