Skip to content

Instantly share code, notes, and snippets.

@souporserious
Last active January 22, 2025 18:17
Show Gist options
  • Save souporserious/6b2af0e75ec39e568f5ff914b48bcde5 to your computer and use it in GitHub Desktop.
Save souporserious/6b2af0e75ec39e568f5ff914b48bcde5 to your computer and use it in GitHub Desktop.
Using TS Morph to analyze JSX
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import Table from 'ink-table';
import type { ExportedDeclarations, ImportDeclaration } from 'ts-morph';
import { Project, SourceFile, ts } from 'ts-morph';
import { resolve } from 'node:path';
import { promises } from 'node:fs';
const rootDirectoryPath = resolve(process.cwd(), '../../');
async function createProjectFromPath(searchFilePath: string): Promise<{
project: Project;
searchFilePath: string;
sourceFiles: SourceFile[];
}> {
const { findUp } = await import('find-up');
const tsConfigFilePath = await findUp('tsconfig.json', {
cwd: resolve(rootDirectoryPath, searchFilePath),
});
if (!tsConfigFilePath) {
throw new Error('Could not find tsconfig.json near the given file.');
}
const project = new Project({ tsConfigFilePath });
const sourceFilePath = resolve(rootDirectoryPath, searchFilePath);
const isDirectory = (await promises.stat(sourceFilePath)).isDirectory();
/** Find all top-level and directory index source files. */
const sourceFiles = project.addSourceFilesAtPaths(
isDirectory
? [
resolve(sourceFilePath, '*/index.(ts|tsx|js|jsx)'),
resolve(sourceFilePath, '*.(ts|tsx|js|jsx)'),
]
: resolve(rootDirectoryPath, searchFilePath),
);
return { project, searchFilePath, sourceFiles };
}
function getExportedDeclarationReferences(declaration: ExportedDeclarations) {
const references = declaration
.getFirstDescendantByKindOrThrow(ts.SyntaxKind.Identifier)
.findReferences();
return references.flatMap((reference) =>
reference
.getReferences()
.map((reference) => {
const node = reference.getNode();
const filePath = node
.getSourceFile()
.getFilePath()
.replace(`${rootDirectoryPath}/`, '');
const isImportDeclaration = node.getParentIfKind(
ts.SyntaxKind.ImportClause,
);
const isJsxClosingElement = node.getParentIfKind(
ts.SyntaxKind.JsxClosingElement,
);
let propNames: string[] = [];
if (
isImportDeclaration ||
isJsxClosingElement ||
filePath.includes('node_modules')
) {
return null;
}
const jsxElementNode = node.getParentIf((parent) =>
parent
? parent.isKind(ts.SyntaxKind.JsxOpeningElement) ||
parent.isKind(ts.SyntaxKind.JsxSelfClosingElement)
: false,
);
if (jsxElementNode) {
const jsxAttributes = jsxElementNode.getDescendantsOfKind(
ts.SyntaxKind.JsxAttribute,
);
propNames = jsxAttributes.map((attribute) => {
return attribute.getNameNode().getText();
});
}
const lineAndColumn = node
.getSourceFile()
.getLineAndColumnAtPos(node.getStart());
return {
propNames,
source: `${filePath}:${lineAndColumn.line}:${lineAndColumn.column}`,
};
})
.filter(Boolean),
);
}
export default function App() {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [data, setData] = useState<
| {
name: string;
filePath: string;
extension: string;
cssImportDeclaration: ImportDeclaration | null;
importsOutsideOfSearchFile: string[];
isClassNameUsed: boolean;
}[]
| null
>(null);
function handleSubmit() {
setLoading(true);
createProjectFromPath(query).then(
async ({ searchFilePath, sourceFiles }) => {
const exportedDeclarations = sourceFiles.map((sourceFile) => {
const cssImportDeclaration = sourceFile
.getImportDeclarations()
.find((declaration) =>
declaration.getModuleSpecifierValue().includes('.css'),
);
const isClassNameUsed = sourceFile
.getDescendantsOfKind(ts.SyntaxKind.JsxAttribute)
.some((attribute) => {
const nameNode = attribute.getNameNode();
return nameNode.getText() === 'className';
});
/** Gather a unique set of exported source files based on all the exports in the main index source file. */
const exportedSourceFiles = Array.from(
new Set(
Array.from(sourceFile.getExportedDeclarations().values()).map(
([declaration]) => declaration.getSourceFile(),
),
),
);
/**
* Gather all module specifier source files.
* import { usePlaceholder } from '#/lib/use-placeholder-context';
* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
*/
const moduleSpecifierSourceFiles = exportedSourceFiles.map(
(sourceFile) =>
sourceFile
.getImportDeclarations()
.map((declaration) =>
declaration.getModuleSpecifierSourceFile(),
)
.filter(Boolean) as SourceFile[],
);
const importsOutsideOfSearchFile = moduleSpecifierSourceFiles.flatMap(
(sourceFiles) =>
sourceFiles
.map((sourceFile) => sourceFile.getFilePath())
.filter((filePath) => {
const relativePath = filePath.replace(rootDirectoryPath, '');
const nodeModules = relativePath.includes('node_modules');
return !relativePath.includes(searchFilePath) && !nodeModules;
}),
);
let name = sourceFile.getBaseNameWithoutExtension();
if (name === 'index') {
name = sourceFile.getDirectory().getBaseName();
}
return {
name,
ext: sourceFile.getExtension().replace('.', ''),
'class names': isClassNameUsed,
'css modules': Boolean(cssImportDeclaration),
'external imports': importsOutsideOfSearchFile.length > 0,
'file path': sourceFile
.getFilePath()
.replace(`${rootDirectoryPath}/`, ''),
};
});
exportedDeclarations.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
setData(exportedDeclarations);
setLoading(false);
},
);
}
if (data) {
return <Table data={data} />;
}
if (loading) {
return (
<Text>
<Spinner /> Analyzing sources...
</Text>
);
}
return (
<Box>
<Box marginRight={1}>
<Text>Enter relative path:</Text>
</Box>
<TextInput value={query} onChange={setQuery} onSubmit={handleSubmit} />
</Box>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment