Skip to content

Instantly share code, notes, and snippets.

@zodern

zodern/index.js Secret

Last active November 8, 2023 19:43
Show Gist options
  • Save zodern/e0cddbdf5ec5c71fb270d8d9ed11adad to your computer and use it in GitHub Desktop.
Save zodern/e0cddbdf5ec5c71fb270d8d9ed11adad to your computer and use it in GitHub Desktop.
Meteor file walker
const fs = require('fs');
const path = require('path');
const readAndParse = require('./parse');
// TODO: the order is important
// extensions of files that are compiled into js
// and can import other js files.
const parseableExt = ['.js', '.jsx', '.svelte', '.ts', '.tsx'];
// These folders are not eagerly loaded by Meteor
// TODO: check if we should only exclude some of these when
// they are at the top level
const notEagerlyLoadedDirs = [
'imports',
'node_modules',
'public',
// TODO: have an option to include tests
'tests',
'test',
'packages',
'private',
];
// The path will start with one of these if
// it imports an app file
const appFileImport = ['.', path.posix.sep, path.win32.sep];
function shouldWalk(folderPath, archList) {
const basename = path.basename(folderPath);
if (basename[0] === '.' || notEagerlyLoadedDirs.includes(basename)) {
return false;
}
const parts = folderPath.split(path.sep);
if (!archList.includes('server') && parts.includes('server')) {
return false;
}
if (!archList.includes('client') && parts.includes('client')) {
return false;
}
return true;
}
function findExt(filePath) {
const ext = parseableExt.find(possibleExt => {
const exists = fs.existsSync(filePath + possibleExt);
return exists;
});
if (ext) {
return filePath + ext;
}
// Maybe it is the index file in a folder
// TODO: check if this should be before or after checking extensions
try {
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
return findExt(`${filePath}${path.sep}index`);
}
} catch (e) {
// TODO: only ignore certain errors
}
}
function shouldParse(filePath) {
const ext = path.extname(filePath);
const basename = path.basename(filePath);
// TODO: have an option to parse test files
if (
basename.endsWith(`.app-tests${ext}`) ||
basename.endsWith(`.spec${ext}`) ||
basename.endsWith(`.test${ext}`)
) {
return false;
}
return basename[0] !== '.' && parseableExt.includes(ext);
}
function isMeteorPackage(importPath) {
return importPath.startsWith('meteor/');
}
function isNpmDependency(importPath) {
return !appFileImport.includes(importPath[0]);
}
const handledFiles = new Set();
const cachedParsedFile = new Map();
function getAbsFilePath(filePath) {
// some files have no ext or are only the ext (.gitignore, .editorconfig, etc.)
const existingExt =
path.extname(filePath) || path.basename(filePath).startsWith('.');
if (!existingExt) {
// TODO: should maybe only do this if a file doesn't exists with the given path
// since we might be importing a file with no extension.
const pathWithExt = findExt(filePath);
if (!pathWithExt) {
console.log('unable to find ext', filePath);
return pathWithExt;
}
return pathWithExt;
}
// TODO: if the file doesn't exist, we must try other extensions
return filePath;
}
function handleFile(_filePath, appPath, onFile, from) {
const filePath = getAbsFilePath(_filePath);
if (!shouldParse(filePath) || handledFiles.has(filePath)) {
return;
}
handledFiles.add(filePath);
const realPath = fs.realpathSync.native(filePath);
const ast = cachedParsedFile.get(realPath) || readAndParse(filePath);
cachedParsedFile.set(realPath, ast);
const imports = readAndParse.findImports(filePath, ast, appPath);
onFile({ path: filePath, ast, imports });
imports
.filter(
({ source }) => !isMeteorPackage(source) && !isNpmDependency(source),
)
.map(({ source }) => {
if (source[0] === '/') {
source = appPath + source;
}
return path.resolve(path.dirname(filePath), source);
})
.forEach(importPath => {
handleFile(importPath, appPath, onFile, filePath);
});
}
function handleFolder(folderPath, appPath, archList, onFile) {
const dirents = fs.readdirSync(folderPath, { withFileTypes: true });
for (let i = 0; i < dirents.length; i += 1) {
if (dirents[i].isDirectory()) {
if (shouldWalk(path.resolve(folderPath, dirents[i].name), archList)) {
handleFolder(
path.resolve(folderPath, dirents[i].name),
appPath,
archList,
onFile,
);
}
} else if (dirents[i].isFile()) {
const filePath = path.resolve(folderPath, dirents[i].name);
handleFile(filePath, appPath, onFile);
}
}
}
module.exports = function walkApp(appPath, archList, onFile) {
console.log('walking', appPath);
handleFolder(appPath, appPath, archList, onFile);
};
const { parse: meteorBabelParser } = require('meteor-babel/parser.js');
const fs = require('fs');
const path = require('path');
const { parse } = require('recast');
const defaultParserOptions = require('reify/lib/parsers/babel.js').options;
// TODO: it would be better to have Babel compile the file first
// instead of handling some plugins specially, but that would
// require copying a large amount of code from Meteor's babel compiler
const { resolvePath } = require('babel-plugin-module-resolver');
const { visit } = require('ast-types');
function findBabelConfig(startDir, appDir) {
const babelRcPath = path.join(startDir, '.babelrc');
const packageJsonPath = path.join(startDir, 'package.json');
if (fs.existsSync(babelRcPath)) {
return babelRcPath;
}
if (fs.existsSync(packageJsonPath)) {
return packageJsonPath;
}
const parentDir = path.resolve(startDir, '..');
if (!parentDir.includes(appDir)) {
return false;
}
return findBabelConfig(parentDir, appDir);
}
function findModuleResolveConfig(filePath, appDir) {
const fileDir = path.dirname(filePath);
const babelConfigPath = findBabelConfig(fileDir, appDir);
if (babelConfigPath) {
const babelConfigContent = fs.readFileSync(babelConfigPath, 'utf-8');
// TODO: error handling
const babelConfig = JSON.parse(babelConfigContent);
const moduleResolvePluginConfig =
babelConfig.plugins.find(plugin => plugin[0] === 'module-resolver') || [];
return moduleResolvePluginConfig[1];
}
}
module.exports = function readAndParse(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const ast = parse(content, {
parser: {
parse: source =>
meteorBabelParser(source, {
...defaultParserOptions,
tokens: true,
}),
},
});
return ast;
};
module.exports.findImports = function findImports(filePath, ast, appDir) {
const moduleResolveConfig = findModuleResolveConfig(filePath, appDir);
const result = [];
// TODO: handle require
visit(ast, {
visitImportDeclaration(nodePath) {
let importPath = nodePath.value.source.value;
if (moduleResolveConfig) {
const origPath = importPath;
importPath =
resolvePath(importPath, filePath, {
...moduleResolveConfig,
cwd: appDir,
}) || origPath;
}
result.push({
source: importPath,
specifiers: nodePath.value.specifiers,
});
return false;
},
visitExportNamedDeclaration(nodePath) {
if (!nodePath.node.source) {
return false;
}
let importPath = nodePath.node.source.value;
if (moduleResolveConfig) {
const origPath = importPath;
importPath =
resolvePath(importPath, filePath, {
...moduleResolveConfig,
cwd: appDir,
}) || origPath;
}
result.push({
source: importPath,
});
return false;
},
visitCallExpression(nodePath) {
if (nodePath.node.callee.type !== 'Import') {
return this.traverse(nodePath);
}
if (nodePath.node.arguments[0].type !== 'StringLiteral') {
throw new Error('Unable to handle non-string dynamic imports');
}
let importPath = nodePath.node.arguments[0].value;
if (moduleResolveConfig) {
const origPath = importPath;
importPath =
resolvePath(importPath, filePath, {
...moduleResolveConfig,
cwd: appDir,
}) || origPath;
}
result.push({
source: importPath,
});
this.traverse(nodePath);
},
});
return result;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment