update-ts-references
Version:
Updates TypeScript references automatically while using workspaces
449 lines (397 loc) • 15.4 kB
JavaScript
const glob = require('glob');
const path = require('path');
const fs = require('fs');
const yaml = require('js-yaml');
const minimatch = require('minimatch');
const {
parse,
stringify,
assign
} = require('comment-json')
const assert = require('assert').strict;
const PACKAGE_JSON = 'package.json';
const TSCONFIG_JSON = 'tsconfig.json'
const JSCONFIG_JSON = 'jsconfig.json'
const defaultOptions = {
configName: TSCONFIG_JSON,
rootConfigName: TSCONFIG_JSON,
withoutRootConfig: false,
createTsConfig: false,
cwd: process.cwd(),
verbose: false,
help: false,
check: false,
createPathMappings: false,
usecase: 'update-ts-references.yaml',
strict: false
};
const getAllPackageJsons = async (workspaces, cwd) => {
const ignoreGlobs = [];
const workspaceGlobs = [];
workspaces.forEach((workspaceGlob) => {
if (workspaceGlob.startsWith('!')) {
ignoreGlobs.push(!workspaceGlob.includes('/') ? `${workspaceGlob}/${PACKAGE_JSON}` : workspaceGlob);
} else {
workspaceGlobs.push(workspaceGlob);
}
});
return Promise.all(
workspaceGlobs.map(
(workspace) =>
new Promise((resolve, reject) => {
glob(`${workspace}/${PACKAGE_JSON}`, { cwd }, (error, files) => {
if (error) {
reject(error);
}
resolve(files);
});
}),
[]
)
)
.then((allPackages) =>
allPackages.reduce(
(flattendArray, files) => [...flattendArray, ...files],
[]
)
)
.then((allPackages) =>
allPackages.filter(
(packageName) =>
ignoreGlobs.reduce((prev, actualPattern) => {
if (!prev) return prev;
return minimatch(packageName, actualPattern);
}, true) && !packageName.includes('node_modules')
)
);
};
const detectJSConfig = (directory, configName) => {
const jsConfigName = configName.replace(/^ts/, 'js')
let detectedConfig = fs.existsSync(path.join(directory, jsConfigName)) ? jsConfigName : null
if (jsConfigName !== JSCONFIG_JSON && detectedConfig === null) {
detectedConfig = fs.existsSync(path.join(directory, JSCONFIG_JSON)) ? JSCONFIG_JSON : null
}
return detectedConfig
}
const detectTSConfig = (directory, configName, createConfig, cwd) => {
let detectedConfig = fs.existsSync(path.join(directory, configName)) ? configName : null
if (configName !== TSCONFIG_JSON && detectedConfig === null) {
detectedConfig = fs.existsSync(path.join(directory, TSCONFIG_JSON)) ? TSCONFIG_JSON : null
}
if (detectedConfig === null && createConfig) {
let maybeExtends = {}
if (fs.existsSync(path.join(cwd, 'tsconfig.base.json'))) {
maybeExtends = {
extends: `${path.join(path.relative(directory, cwd), "tsconfig.base.json").split(path.sep).join(path.posix.sep)}`,
}
}
const tsconfigFilePath = path.join(directory, configName);
fs.writeFileSync(tsconfigFilePath, stringify(Object.assign(maybeExtends, {
compilerOptions: {
outDir: "dist",
rootDir: "src"
},
references: [],
}), null, 2) + '\n');
return configName
}
return detectedConfig
}
const getPackageNamesAndPackageDir = (packageFilePaths, cwd) =>
packageFilePaths.reduce((map, packageFilePath) => {
const fullPackageFilePath = path.join(cwd, packageFilePath);
const packageJson = require(fullPackageFilePath);
const { name } = packageJson;
map.set(name, {
packageDir: path.dirname(fullPackageFilePath),
hasTsEntry: /\.(ts|tsx)$/.test((packageJson.main ? packageJson.main : ''))
});
return map;
}, new Map());
const getReferencesFromDependencies = (
configName,
{ packageDir },
packageName,
packagesMap,
verbose
) => {
const packageJsonFilePath = path.join(packageDir, PACKAGE_JSON);
const {
dependencies = {},
peerDependencies = {},
devDependencies = {},
} = require(packageJsonFilePath);
const mergedDependencies = {
...dependencies,
...peerDependencies,
...devDependencies,
};
if (verbose) console.log(`all deps from ${packageName}`, mergedDependencies);
if (Object.keys(mergedDependencies).includes(packageName)) {
throw new Error(
`This package ${packageName} references itself, please check dependencies in package.json`
);
}
return Object.keys(mergedDependencies)
.reduce((referenceArray, dependency) => {
if (packagesMap.has(dependency)) {
const { packageDir: dependencyDir } = packagesMap.get(dependency);
const relativePath = path.relative(packageDir, dependencyDir);
const detectedConfig = detectTSConfig(dependencyDir, configName)
if (detectedConfig !== null) {
return [
...referenceArray,
{
name: dependency,
path: detectedConfig !== TSCONFIG_JSON ? path.join(relativePath, detectedConfig) : relativePath,
folder: relativePath,
},
];
} else {
const detectedJsConfig = detectJSConfig(dependencyDir, configName)
if (detectedJsConfig !== null) {
return [
...referenceArray,
{
name: dependency,
path: path.join(relativePath, detectedJsConfig),
folder: relativePath,
},
];
}
}
}
return referenceArray;
}, [])
.sort((refA, refB) => (refA.path > refB.path ? 1 : -1));
};
const ensurePosixPathStyle = (reference) => ({
...reference,
path: reference.path.split(path.sep).join(path.posix.sep),
folder: reference.folder?.split(path.sep).join(path.posix.sep),
});
const updateTsConfig = (
strict,
configName,
references,
paths,
check,
createPathMappings = false,
{ packageDir } = { packageDir: process.cwd() },
) => {
const tsconfigFilePath = path.join(packageDir, configName);
try {
const config = parse(fs.readFileSync(tsconfigFilePath).toString());
const currentReferences = config.references || [];
const mergedReferences = references.map(({ path }) => ({
path,
...currentReferences.find((currentRef) => currentRef.path === path),
}));
let isEqual = false;
try {
assert.deepEqual(JSON.parse(JSON.stringify(currentReferences)), mergedReferences);
if (createPathMappings)
assert.deepEqual(JSON.parse(JSON.stringify(config?.compilerOptions?.paths ?? {})), paths);
isEqual = true;
} catch (e) {
// ignore me
}
if (!isEqual) {
if (check === false) {
const compilerOptions = config?.compilerOptions ?? {};
if (createPathMappings && paths)
assign(compilerOptions,
paths && Object.keys(paths).length > 0 ? {
paths
} : { paths: undefined })
const newTsConfig = assign(config,
Object.keys(compilerOptions).length > 0 ? {
compilerOptions,
references: mergedReferences.length ? mergedReferences : undefined,
} : { references: mergedReferences.length ? mergedReferences : undefined, }
);
fs.writeFileSync(tsconfigFilePath, stringify(newTsConfig, null, 2) + '\n');
}
return 1;
}
return 0;
} catch (error) {
console.error(`could not read ${tsconfigFilePath}`, error);
if (strict)
throw new Error('Expect always a tsconfig.json in the package directory while running in strict mode')
}
};
function getPathsFromReferences(references, tsconfigMap, jsconfigMap, ignorePathMappings
= []) {
return references.reduce((paths, ref) => {
if (ignorePathMappings.includes(ref.name)) return paths
const config = tsconfigMap[ref.name] ?? jsconfigMap[ref.name]
const rootFolder = config?.compilerOptions?.rootDir ?? 'src'
return {
...paths,
[`${ref.name}`]: [`${ref.folder}${rootFolder === '.' ? '' : `/${rootFolder}`}`],
}
}, {});
}
const execute = async ({
cwd, createTsConfig,
verbose,
check,
usecase,
strict,
...configurable
}) => {
let changesCount = 0;
// eslint-disable-next-line no-console
console.log('updating tsconfigs');
const packageJson = require(path.join(cwd, PACKAGE_JSON));
let workspaces = packageJson.workspaces;
if (workspaces && !Array.isArray(workspaces)) {
workspaces = workspaces.packages;
}
if (!workspaces && fs.existsSync(path.join(cwd, 'lerna.json'))) {
const lernaJson = require(path.join(cwd, 'lerna.json'));
workspaces = lernaJson.packages;
}
if (!workspaces && fs.existsSync(path.join(cwd, 'pnpm-workspace.yaml'))) {
const pnpmConfig = yaml.load(
fs.readFileSync(path.join(cwd, 'pnpm-workspace.yaml'))
);
workspaces = pnpmConfig.packages;
}
let {
configName,
rootConfigName,
createPathMappings,
withoutRootConfig
} = configurable
let ignorePathMappings = []
if (fs.existsSync(path.join(cwd, usecase))) {
const yamlConfig = yaml.load(
fs.readFileSync(path.join(cwd, usecase))
);
configName = yamlConfig.configName ?? configName
rootConfigName = yamlConfig.rootConfigName ?? rootConfigName
createPathMappings = yamlConfig.createPathMappings ?? createPathMappings
withoutRootConfig = yamlConfig.withoutRootConfig ?? withoutRootConfig
workspaces = [...(yamlConfig.packages ? yamlConfig.packages : []), ...(workspaces ? workspaces : [])];
ignorePathMappings = yamlConfig.ignorePathMappings ?? []
if (verbose) {
console.log(`configName ${configName}`);
console.log(`rootConfigName ${rootConfigName}`);
console.log(`createPathMappings ${createPathMappings}`)
console.log('joined workspaces', workspaces);
console.log('ignorePathMappings', ignorePathMappings
);
}
}
if (!workspaces) {
throw new Error(
'could not detect yarn/npm/pnpm workspaces or lerna in this repository'
);
}
const packageFilePaths = await getAllPackageJsons(workspaces, cwd);
if (verbose) {
console.log('packageFilePaths', packageFilePaths);
}
const packagesMap = getPackageNamesAndPackageDir(packageFilePaths, cwd);
if (verbose) {
console.log('packagesMap', packagesMap);
}
let rootReferences = [];
let rootPaths = [];
let tsconfigMap = {}
let jsconfigMap = {}
packagesMap.forEach((packageEntry, packageName) => {
const detectedConfig = detectTSConfig(packageEntry.packageDir, configName, packageEntry.hasTsEntry && createTsConfig, cwd)
if (detectedConfig) {
const compilerOptions = parse(fs.readFileSync(path.join(packageEntry.packageDir, detectedConfig)).toString()).compilerOptions
tsconfigMap = {
...tsconfigMap,
[packageName]: {
detectedConfig,
compilerOptions
}
}
} else {
const detectedJsConfig = detectJSConfig(packageEntry.packageDir, configName)
if (detectedJsConfig) {
let compilerOptions
try {
compilerOptions = parse(fs.readFileSync(path.join(packageEntry.packageDir, detectedJsConfig)).toString()).compilerOptions
} catch {
//ignore
}
jsconfigMap = {
...jsconfigMap,
[packageName]: {
detectedConfig,
compilerOptions
}
}
}
}
});
packagesMap.forEach((packageEntry, packageName) => {
const detectedConfig = tsconfigMap[packageName]?.detectedConfig
if (detectedConfig) {
rootReferences.push({
name: packageName,
path: path.join(path.relative(cwd, packageEntry.packageDir), detectedConfig !== TSCONFIG_JSON ? detectedConfig : ''),
folder: path.relative(cwd, packageEntry.packageDir),
});
const references = (getReferencesFromDependencies(
configName,
packageEntry,
packageName,
packagesMap,
verbose
) || []).map(ensurePosixPathStyle);
const paths = getPathsFromReferences(references, tsconfigMap, jsconfigMap, ignorePathMappings)
if (verbose) {
console.log(`references of ${packageName}`, references);
console.log(`paths of ${packageName}`, paths);
}
changesCount += updateTsConfig(
strict,
detectedConfig,
references,
paths,
check,
createPathMappings,
packageEntry
);
} else {
const detectedJsConfig = jsconfigMap[packageName]?.detectedConfig
if (!detectedJsConfig) {
// eslint-disable-next-line no-console
console.log(`NO ${configName === TSCONFIG_JSON ? configName : `${configName} nor ${TSCONFIG_JSON}`} for ${packageName}`);
}
rootPaths.push({
name: packageName,
path: path.relative(cwd, packageEntry.packageDir),
folder: path.relative(cwd, packageEntry.packageDir),
});
}
});
rootReferences = (rootReferences || []).map(ensurePosixPathStyle);
rootPaths = getPathsFromReferences((rootReferences || []).map(ensurePosixPathStyle), tsconfigMap, {}, ignorePathMappings)
if (verbose) {
console.log('rootReferences', rootReferences);
console.log('rootPaths', rootPaths);
}
if (withoutRootConfig === false) {
changesCount += updateTsConfig(
strict,
rootConfigName,
rootReferences,
rootPaths,
check, createPathMappings, { packageDir: cwd },
);
}
if (verbose) {
console.log(`counted changes ${changesCount}`);
}
return changesCount;
};
module.exports = { execute, defaultOptions };