UNPKG

@jsenv/node-module-import-map

Version:
375 lines (337 loc) 11 kB
import { createDetailedMessage } from "@jsenv/logger" import { resolveUrl, readFile, urlToExtension, urlToRelativeUrl } from "@jsenv/util" import { normalizeImportMap, resolveImport, sortImportMap, composeTwoImportMaps, } from "@jsenv/import-map" import { isSpecifierForNodeCoreModule } from "@jsenv/import-map/src/isSpecifierForNodeCoreModule.js" import { memoizeAsyncFunctionByUrl, memoizeAsyncFunctionBySpecifierAndImporter, } from "../memoizeAsyncFunction.js" import { parseSpecifiersFromFile } from "./parseSpecifiersFromFile.js" import { showSource } from "./showSource.js" import { resolveFile } from "../resolveFile.js" import { createPackageNameMustBeAStringWarning } from "../warnings.js" const BARE_SPECIFIER_ERROR = {} export const getImportMapFromJsFiles = async ({ logger, warn, projectDirectoryUrl, importMap, magicExtensions, runtime, treeshakeMappings, jsFilesParsingOptions, }) => { const projectPackageFileUrl = resolveUrl("./package.json", projectDirectoryUrl) const imports = {} const scopes = {} const addMapping = ({ scope, from, to }) => { if (scope) { scopes[scope] = { ...(scopes[scope] || {}), [from]: to, } } else { imports[from] = to } } const topLevelMappingsUsed = [] const scopedMappingsUsed = {} const markMappingAsUsed = ({ scope, from, to }) => { if (scope) { if (scope in scopedMappingsUsed) { scopedMappingsUsed[scope].push({ from, to }) } else { scopedMappingsUsed[scope] = [{ from, to }] } } else { topLevelMappingsUsed.push({ from, to }) } } const importMapNormalized = normalizeImportMap(importMap, projectDirectoryUrl) const trackAndResolveImport = (specifier, importer) => { return resolveImport({ specifier, importer, importMap: importMapNormalized, defaultExtension: false, onImportMapping: ({ scope, from }) => { if (scope) { // make scope relative again scope = `./${urlToRelativeUrl(scope, projectDirectoryUrl)}` // make from relative again if (from.startsWith(projectDirectoryUrl)) { from = `./${urlToRelativeUrl(from, projectDirectoryUrl)}` } } markMappingAsUsed({ scope, from, to: scope ? importMap.scopes[scope][from] : importMap.imports[from], }) }, createBareSpecifierError: () => BARE_SPECIFIER_ERROR, }) } const resolveFileSystemUrl = memoizeAsyncFunctionBySpecifierAndImporter( async (specifier, importer, { importedBy }) => { if (runtime === "node" && isSpecifierForNodeCoreModule(specifier)) { return null } let fileUrl let gotBareSpecifierError = false try { fileUrl = trackAndResolveImport(specifier, importer) } catch (e) { if (e !== BARE_SPECIFIER_ERROR) { throw e } if (importer === projectPackageFileUrl) { // cannot find package main file (package.main is "" for instance) // we can't discover main file and parse dependencies return null } gotBareSpecifierError = true fileUrl = resolveUrl(specifier, importer) } const fileUrlOnFileSystem = await resolveFile(fileUrl, { magicExtensions: magicExtensionWithImporterExtension(magicExtensions, importer), }) if (!fileUrlOnFileSystem) { warn( createFileNotFoundWarning({ specifier, importedBy, fileUrl, magicExtensions, }), ) return null } const needsAutoMapping = fileUrlOnFileSystem !== fileUrl || gotBareSpecifierError if (needsAutoMapping) { const packageDirectoryUrl = packageDirectoryUrlFromUrl(fileUrl, projectDirectoryUrl) const packageFileUrl = resolveUrl("package.json", packageDirectoryUrl) const autoMapping = { scope: packageFileUrl === projectPackageFileUrl ? undefined : `./${urlToRelativeUrl(packageDirectoryUrl, projectDirectoryUrl)}`, from: specifier, to: `./${urlToRelativeUrl(fileUrlOnFileSystem, projectDirectoryUrl)}`, } addMapping(autoMapping) markMappingAsUsed(autoMapping) const closestPackageObject = await readFile(packageFileUrl, { as: "json" }) // it's imprecise because we are not ensuring the wildcard correspond to automapping // but good enough for now const containsWildcard = Object.keys(closestPackageObject.exports || {}).some((key) => key.includes("*"), ) const autoMappingWarning = formatAutoMappingSpecifierWarning({ specifier, importedBy, autoMapping, closestPackageDirectoryUrl: packageDirectoryUrl, closestPackageObject, }) if (containsWildcard) { logger.debug(autoMappingWarning) } else { warn(autoMappingWarning) } } return fileUrlOnFileSystem }, ) const visitFile = memoizeAsyncFunctionByUrl(async (fileUrl) => { const fileContent = await readFile(fileUrl, { as: "string" }) const specifiers = await parseSpecifiersFromFile(fileUrl, { fileContent, jsFilesParsingOptions, }) const dependencies = await Promise.all( Object.keys(specifiers).map(async (specifier) => { const specifierInfo = specifiers[specifier] const dependencyUrlOnFileSystem = await resolveFileSystemUrl(specifier, fileUrl, { importedBy: showSource({ url: fileUrl, line: specifierInfo.line, column: specifierInfo.column, source: fileContent, }), }) return dependencyUrlOnFileSystem }), ) const dependenciesToVisit = dependencies.filter((dependency) => { return dependency && !visitFile.isInMemory(dependency) }) await Promise.all( dependenciesToVisit.map((dependency) => { return visitFile(dependency) }), ) }) const projectPackageObject = await readFile(projectPackageFileUrl, { as: "json" }) const projectPackageName = projectPackageObject.name if (typeof projectPackageName !== "string") { warn( createPackageNameMustBeAStringWarning({ packageName: projectPackageName, packageFileUrl: projectPackageFileUrl, }), ) return importMap } const projectMainFileUrlOnFileSystem = await resolveFileSystemUrl( projectPackageName, projectPackageFileUrl, { importedBy: projectPackageObject.exports ? `${projectPackageFileUrl}#exports` : `${projectPackageFileUrl}`, }, ) if (projectMainFileUrlOnFileSystem) { await visitFile(projectMainFileUrlOnFileSystem) } if (treeshakeMappings) { const importsUsed = {} topLevelMappingsUsed.forEach(({ from, to }) => { importsUsed[from] = to }) const scopesUsed = {} Object.keys(scopedMappingsUsed).forEach((scope) => { const mappingsUsed = scopedMappingsUsed[scope] const scopedMappings = {} mappingsUsed.forEach(({ from, to }) => { scopedMappings[from] = to }) scopesUsed[scope] = scopedMappings }) return sortImportMap({ imports: importsUsed, scopes: scopesUsed, }) } return sortImportMap(composeTwoImportMaps(importMap, { imports, scopes })) } const packageDirectoryUrlFromUrl = (url, projectDirectoryUrl) => { const relativeUrl = urlToRelativeUrl(url, projectDirectoryUrl) const lastNodeModulesDirectoryStartIndex = relativeUrl.lastIndexOf("node_modules/") if (lastNodeModulesDirectoryStartIndex === -1) { return projectDirectoryUrl } const lastNodeModulesDirectoryEndIndex = lastNodeModulesDirectoryStartIndex + `node_modules/`.length const beforeNodeModulesLastDirectory = relativeUrl.slice(0, lastNodeModulesDirectoryEndIndex) const afterLastNodeModulesDirectory = relativeUrl.slice(lastNodeModulesDirectoryEndIndex) const remainingDirectories = afterLastNodeModulesDirectory.split("/") if (afterLastNodeModulesDirectory[0] === "@") { // scoped package return `${projectDirectoryUrl}${beforeNodeModulesLastDirectory}${remainingDirectories .slice(0, 2) .join("/")}` } return `${projectDirectoryUrl}${beforeNodeModulesLastDirectory}${remainingDirectories[0]}/` } const magicExtensionWithImporterExtension = (magicExtensions, importer) => { const importerExtension = urlToExtension(importer) const magicExtensionsWithoutImporterExtension = magicExtensions.filter( (ext) => ext !== importerExtension, ) return [importerExtension, ...magicExtensionsWithoutImporterExtension] } const createFileNotFoundWarning = ({ specifier, importedBy, fileUrl, magicExtensions }) => { return { code: "FILE_NOT_FOUND", message: createDetailedMessage(`Cannot find file for "${specifier}"`, { "specifier origin": importedBy, "file url tried": fileUrl, ...(urlToExtension(fileUrl) === "" ? { ["extensions tried"]: magicExtensions.join(`, `) } : {}), }), } } const formatAutoMappingSpecifierWarning = ({ importedBy, autoMapping, closestPackageDirectoryUrl, closestPackageObject, }) => { return { code: "AUTO_MAPPING", message: createDetailedMessage(`Auto mapping ${autoMapping.from} to ${autoMapping.to}.`, { "specifier origin": importedBy, "suggestion": decideAutoMappingSuggestion({ autoMapping, closestPackageDirectoryUrl, closestPackageObject, }), }), } } const decideAutoMappingSuggestion = ({ autoMapping, closestPackageDirectoryUrl, closestPackageObject, }) => { if (typeof closestPackageObject.importmap === "string") { const packageImportmapFileUrl = resolveUrl( closestPackageObject.importmap, closestPackageDirectoryUrl, ) return `To get rid of this warning, add an explicit mapping into importmap file. ${mappingToImportmapString(autoMapping)} into ${packageImportmapFileUrl}.` } return `To get rid of this warning, add an explicit mapping into package.json. ${mappingToExportsFieldString(autoMapping)} into ${closestPackageDirectoryUrl}package.json.` } const mappingToImportmapString = ({ scope, from, to }) => { if (scope) { return JSON.stringify( { scopes: { [scope]: { [from]: to, }, }, }, null, " ", ) } return JSON.stringify( { imports: { [from]: to, }, }, null, " ", ) } const mappingToExportsFieldString = ({ scope, from, to }) => { if (scope) { const scopeUrl = resolveUrl(scope, "file://") const toUrl = resolveUrl(to, "file://") to = `./${urlToRelativeUrl(toUrl, scopeUrl)}` } return JSON.stringify( { exports: { [from]: to, }, }, null, " ", ) }