diff --git a/editor/src/components/custom-code/code-file.spec.ts b/editor/src/components/custom-code/code-file.spec.ts index 4562aa933e10..7cb6d28929d9 100644 --- a/editor/src/components/custom-code/code-file.spec.ts +++ b/editor/src/components/custom-code/code-file.spec.ts @@ -26,7 +26,12 @@ import { textFileContents, unparsed, } from '../../core/shared/project-file-types' -import { addFileToProjectContents, getTextFileByPath } from '../assets' +import { + addFileToProjectContents, + contentsToTree, + getTextFileByPath, + type ProjectContentTreeRoot, +} from '../assets' import type { ExportsInfo, MultiFileBuildResult } from '../../core/workers/common/worker-types' import { createBuiltInDependenciesList } from '../../core/es-modules/package-manager/built-in-dependencies-list' @@ -53,6 +58,15 @@ function transpileCode( const appJSCode = "\nimport * as React from 'react'\nimport {\n Ellipse,\n HelperFunctions,\n Image,\n NodeImplementations,\n Rectangle,\n Text,\n View,\n} from 'utopia-api'\nimport {\n colorTheme,\n Button,\n Dialog,\n Icn,\n Icons,\n LargerIcons,\n FunctionIcons,\n MenuIcons,\n Isolator,\n TabComponent,\n Tooltip,\n ActionSheet,\n Avatar,\n ControlledTextArea,\n Title,\n H1,\n H2,\n H3,\n Subdued,\n InspectorSectionHeader,\n InspectorSubsectionHeader,\n FlexColumn,\n FlexRow,\n ResizableFlexColumn,\n PopupList,\n Section,\n SectionTitleRow,\n SectionBodyArea,\n UtopiaListSelect,\n UtopiaListItem,\n CheckboxInput,\n NumberInput,\n StringInput,\n OnClickOutsideHOC,\n} from 'uuiui'\n\nexport var canvasMetadata = {\n specialNodes: [],\n nodeMetadata: {},\n scenes: [\n {\n component: 'App',\n frame: { height: 812, left: 0, width: 375, top: 0 },\n props: { layout: { top: 0, left: 0, bottom: 0, right: 0 } },\n container: { layoutSystem: 'pinSystem' },\n },\n ],\n elementMetadata: {},\n}\n\nexport var App = (props) => {\n return (\n \n )\n}\n\n" +const SampleSingleFileProjectContents: ProjectContentTreeRoot = contentsToTree({ + '/app.js': textFile( + textFileContents(appJSCode, unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), +}) + const SampleSingleFileBuildResult = transpileCode(['/app.js'], { '/app.js': appJSCode, }) @@ -84,15 +98,40 @@ const SampleSingleFileExportsInfo = [ }, ] +const multiFileAppJSCode = + "\nimport * as React from 'react'\nimport {\n Ellipse,\n HelperFunctions,\n Image,\n NodeImplementations,\n Rectangle,\n Text,\n View,\n} from 'utopia-api'\nimport {\n colorTheme,\n Button,\n Dialog,\n Icn,\n Icons,\n LargerIcons,\n FunctionIcons,\n MenuIcons,\n Isolator,\n TabComponent,\n Tooltip,\n ActionSheet,\n Avatar,\n ControlledTextArea,\n Title,\n H1,\n H2,\n H3,\n Subdued,\n InspectorSectionHeader,\n InspectorSubsectionHeader,\n FlexColumn,\n FlexRow,\n ResizableFlexColumn,\n PopupList,\n Section,\n SectionTitleRow,\n SectionBodyArea,\n UtopiaListSelect,\n UtopiaListItem,\n CheckboxInput,\n NumberInput,\n StringInput,\n OnClickOutsideHOC,\n} from 'uuiui'\n\nexport var canvasMetadata = {\n specialNodes: [],\n nodeMetadata: {},\n scenes: [\n {\n component: 'App',\n frame: { height: 812, left: 0, width: 375, top: 0 },\n props: { layout: { top: 0, left: 0, bottom: 0, right: 0 } },\n container: { layoutSystem: 'pinSystem' },\n },\n ],\n elementMetadata: {},\n}\n\n\nexport var App = (props) => {\n return (\n \n )\n}\n\n" +const multiFileComponentsJSCode = + "// component library\nimport * as React from 'react'\nimport { Text, View } from 'utopia-api'\n\nexport default (props) => (\n \n \n \n)\n\nexport const LABEL = 'press me! 😉'\n\nexport const ComponentWithProps = (props) => {\n return (\n \n {(props.text + ' ').repeat(props.num)}\n \n )\n}\n\nComponentWithProps.propertyControls = {\n text: {\n type: 'string',\n title: 'Title',\n defaultValue: 'Change me',\n },\n num: {\n type: 'number',\n title: 'amount',\n defaultValue: 2,\n },\n pink: {\n type: 'boolean',\n title: 'Enabled',\n defaultValue: true,\n },\n}\n\n" +const multiFilePreviewJSXCode = + 'import * as React from "react";\nimport * as ReactDOM from "react-dom";\nimport { App } from "../app";\n\nconst root = document.getElementById("root");\nif (root != null) {\nReactDOM.render(, root);\n}' + +const SampleMultiFileProjectContents: ProjectContentTreeRoot = contentsToTree({ + '/app.js': textFile( + textFileContents(multiFileAppJSCode, unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), + '/src/components.js': textFile( + textFileContents(multiFileComponentsJSCode, unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), + '/public/preview.jsx': textFile( + textFileContents(multiFilePreviewJSXCode, unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), +}) + const SampleMultiFileBuildResult = transpileCode( ['/app.js', '/src/components.js', '/public/preview.jsx'], { - '/app.js': - "\nimport * as React from 'react'\nimport {\n Ellipse,\n HelperFunctions,\n Image,\n NodeImplementations,\n Rectangle,\n Text,\n View,\n} from 'utopia-api'\nimport {\n colorTheme,\n Button,\n Dialog,\n Icn,\n Icons,\n LargerIcons,\n FunctionIcons,\n MenuIcons,\n Isolator,\n TabComponent,\n Tooltip,\n ActionSheet,\n Avatar,\n ControlledTextArea,\n Title,\n H1,\n H2,\n H3,\n Subdued,\n InspectorSectionHeader,\n InspectorSubsectionHeader,\n FlexColumn,\n FlexRow,\n ResizableFlexColumn,\n PopupList,\n Section,\n SectionTitleRow,\n SectionBodyArea,\n UtopiaListSelect,\n UtopiaListItem,\n CheckboxInput,\n NumberInput,\n StringInput,\n OnClickOutsideHOC,\n} from 'uuiui'\n\nexport var canvasMetadata = {\n specialNodes: [],\n nodeMetadata: {},\n scenes: [\n {\n component: 'App',\n frame: { height: 812, left: 0, width: 375, top: 0 },\n props: { layout: { top: 0, left: 0, bottom: 0, right: 0 } },\n container: { layoutSystem: 'pinSystem' },\n },\n ],\n elementMetadata: {},\n}\n\n\nexport var App = (props) => {\n return (\n \n )\n}\n\n", - '/src/components.js': - "// component library\nimport * as React from 'react'\nimport { Text, View } from 'utopia-api'\n\nexport default (props) => (\n \n \n \n)\n\nexport const LABEL = 'press me! 😉'\n\nexport const ComponentWithProps = (props) => {\n return (\n \n {(props.text + ' ').repeat(props.num)}\n \n )\n}\n\nComponentWithProps.propertyControls = {\n text: {\n type: 'string',\n title: 'Title',\n defaultValue: 'Change me',\n },\n num: {\n type: 'number',\n title: 'amount',\n defaultValue: 2,\n },\n pink: {\n type: 'boolean',\n title: 'Enabled',\n defaultValue: true,\n },\n}\n\n", - '/public/preview.jsx': - 'import * as React from "react";\nimport * as ReactDOM from "react-dom";\nimport { App } from "../app";\n\nconst root = document.getElementById("root");\nif (root != null) {\nReactDOM.render(, root);\n}', + '/app.js': multiFileAppJSCode, + '/src/components.js': multiFileComponentsJSCode, + '/public/preview.jsx': multiFilePreviewJSXCode, }, ) @@ -216,14 +255,25 @@ const SampleExportsInfoWithError = [ }, ] +const exceptionThrowingCodeJSFile = "export const boom = (() => {\n throw Error('booom')\n})()\n" + +const SampleExceptionThrowingProjectContents: ProjectContentTreeRoot = contentsToTree({ + '/src/code.js': textFile( + textFileContents(exceptionThrowingCodeJSFile, unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), +}) + const SampleBuildResultWithException = transpileCode(['/src/code.js'], { - '/src/code.js': "export const boom = (() => {\n throw Error('booom')\n})()\n", + '/src/code.js': exceptionThrowingCodeJSFile, }) const SampleExportsInfoWithException = [ { filename: '/src/code.js', - code: "export const boom = (() => {\n throw Error('booom')\n})()\n", + code: exceptionThrowingCodeJSFile, exportTypes: { boom: { type: 'never', @@ -390,7 +440,9 @@ describe('Creating require function', () => { createBuiltInDependenciesList(null), ) - expect(codeResultCache.curriedRequireFn({})('/', './app', false)).toMatchSnapshot() + expect( + codeResultCache.curriedRequireFn(SampleSingleFileProjectContents)('/', './app', false), + ).toMatchSnapshot() }) it('Creates require function for multi file build result', () => { const codeResultCache = generateCodeResultCache( @@ -403,8 +455,16 @@ describe('Creating require function', () => { createBuiltInDependenciesList(null), ) - expect(codeResultCache.curriedRequireFn({})('/', './app', false)).toMatchSnapshot() - expect(codeResultCache.curriedRequireFn({})('/', './src/components', false)).toMatchSnapshot() + expect( + codeResultCache.curriedRequireFn(SampleMultiFileProjectContents)('/', './app', false), + ).toMatchSnapshot() + expect( + codeResultCache.curriedRequireFn(SampleMultiFileProjectContents)( + '/', + './src/components', + false, + ), + ).toMatchSnapshot() }) it('Require throws exception for module code', () => { const codeResultCache = generateCodeResultCache( @@ -418,7 +478,11 @@ describe('Creating require function', () => { ) expect(() => - codeResultCache.curriedRequireFn({})('/', './src/code', false), + codeResultCache.curriedRequireFn(SampleExceptionThrowingProjectContents)( + '/', + './src/code', + false, + ), ).toThrowErrorMatchingSnapshot() }) it('Require throws exception for import from non-existing module', () => { diff --git a/editor/src/components/custom-code/code-file.ts b/editor/src/components/custom-code/code-file.ts index db71fa3fa70c..373b778925d3 100644 --- a/editor/src/components/custom-code/code-file.ts +++ b/editor/src/components/custom-code/code-file.ts @@ -355,6 +355,9 @@ export function generateCodeResultCache( delete evaluationCache[fileToDelete] } + // FIXME This actually isn't doing anything ever, because it appears that `updatedModules` above is always empty, + // however the require and resolve functions are still used, so we need to figure out which part of this code is + // still alive and which is dead // MUTATION ALERT! This function is mutating editorState.nodeModules.files by inserting the project files into it. incorporateBuildResult(nodeModules, projectContents, projectModules) diff --git a/editor/src/core/es-modules/evaluator/evaluator.ts b/editor/src/core/es-modules/evaluator/evaluator.ts index 089651ed16a7..2c1ff0134993 100644 --- a/editor/src/core/es-modules/evaluator/evaluator.ts +++ b/editor/src/core/es-modules/evaluator/evaluator.ts @@ -92,6 +92,7 @@ function evaluateJs( function firstErrorHandler(error: Error): void { if (isEsModuleError(error)) { + // This is effectively implementing `MAYBE_DETECT_AND_LOAD` from https://nodejs.org/api/modules.html#all-together const { transpiledCode, sourceMap } = transformToCommonJS(filePath, moduleCode) evaluateWithHandler(transpiledCode, sourceMap, secondErrorHandler) } else { diff --git a/editor/src/core/es-modules/package-manager/module-resolution-esm.ts b/editor/src/core/es-modules/package-manager/module-resolution-esm.ts new file mode 100644 index 000000000000..4483cf4bedca --- /dev/null +++ b/editor/src/core/es-modules/package-manager/module-resolution-esm.ts @@ -0,0 +1,228 @@ +import { getPartsFromPath, makePathFromParts, normalizePath } from '../../../utils/path-utils' +import type { ParseResult } from '../../../utils/value-parser-utils' +import { foldEither } from '../../shared/either' +import { isEsCodeFile } from '../../shared/project-file-types' +import type { + ExportsField, + FileForPathParts, + FileLookupFn, + FileLookupResult, + PartialPackageJsonDefinition, +} from './module-resolution-utils' +import { + findClosestPackageScopeToPath, + getPartialPackageJson, + isResolveSuccess, + resolveNotPresent, +} from './module-resolution-utils' + +// Using the logic from https://nodejs.org/api/modules.html#all-together +// This is separated to emphasize that these functions act an ESM compatibility +// layer in CommonJS + +// LOAD_PACKAGE_IMPORTS(X, DIR) +export function loadPackageImports( + path: string, + originPath: string, + packageJsonLookupFn: FileForPathParts, + lookupFn: FileLookupFn, +): FileLookupResult { + // Loading a module from the imports field mapping https://nodejs.org/api/packages.html#imports + // Find the closest package scope SCOPE to DIR. + const { packageJsonFileResult, packageJsonDir } = findClosestPackageScopeToPath( + originPath, + packageJsonLookupFn, + ) + + // If no scope was found, return + if ( + !(isResolveSuccess(packageJsonFileResult) && isEsCodeFile(packageJsonFileResult.success.file)) + ) { + return resolveNotPresent + } + + let possiblePackageJson: ParseResult + try { + possiblePackageJson = getPartialPackageJson(packageJsonFileResult.success.file.fileContents) + } catch { + return resolveNotPresent + } + + return foldEither( + (_) => resolveNotPresent, + (packageJson) => { + // If the SCOPE/package.json "imports" is null or undefined, return + const importsEntry = packageJson['imports'] + if (importsEntry == null) { + return resolveNotPresent + } + + return packageImportsExportsResolve(path, importsEntry, packageJsonDir, lookupFn) + }, + possiblePackageJson, + ) +} + +// LOAD_PACKAGE_EXPORTS(X, DIR) +export function loadPackageExports( + path: string, + originPath: string, + packageJsonLookupFn: FileForPathParts, + lookupFn: FileLookupFn, +): FileLookupResult { + // Try to interpret X as a combination of NAME and SUBPATH where the name + // may have a @scope/ prefix and the subpath begins with a slash (`/`) + const regex = /((?:@[\w|\-|\.]+\/)*[\w|\-|\.]+)(\/[\w|\-|\.]*)*/ + const matches = path.match(regex) + if (matches == null) { + // If X does not match this pattern, return + return resolveNotPresent + } + + const [_combined, name, subpath] = matches + + // If DIR/NAME/package.json is not a file, return + const pathToModule = normalizePath([...getPartsFromPath(originPath), name]) + const packageJsonPathParts = pathToModule.concat('package.json') + const packageJsonFileResult = packageJsonLookupFn(packageJsonPathParts) + if ( + !(isResolveSuccess(packageJsonFileResult) && isEsCodeFile(packageJsonFileResult.success.file)) + ) { + return resolveNotPresent + } + + // Parse DIR/NAME/package.json, and look for "exports" field + let possiblePackageJson: ParseResult + try { + possiblePackageJson = getPartialPackageJson(packageJsonFileResult.success.file.fileContents) + } catch { + return resolveNotPresent + } + + return foldEither( + (_) => resolveNotPresent, + (packageJson) => { + // If "exports" is null or undefined, return. + const exportsEntry = packageJson['exports'] + if (exportsEntry == null) { + return resolveNotPresent + } + + return packageImportsExportsResolve( + `.${subpath}`, + exportsEntry, + makePathFromParts(pathToModule), + lookupFn, + ) + }, + possiblePackageJson, + ) +} + +// LOAD_PACKAGE_SELF(X, DIR) +export function loadPackageSelf( + path: string, + originPath: string, + packageJsonLookupFn: FileForPathParts, + lookupFn: FileLookupFn, +): FileLookupResult { + // Find the closest package scope SCOPE to DIR. + const { packageJsonFileResult, packageJsonDir } = findClosestPackageScopeToPath( + originPath, + packageJsonLookupFn, + ) + + // If no scope was found, return. + if ( + !(isResolveSuccess(packageJsonFileResult) && isEsCodeFile(packageJsonFileResult.success.file)) + ) { + return resolveNotPresent + } + + let possiblePackageJson: ParseResult + try { + possiblePackageJson = getPartialPackageJson(packageJsonFileResult.success.file.fileContents) + } catch { + return resolveNotPresent + } + return foldEither( + (_) => resolveNotPresent, + (packageJson) => { + // If "exports" is null or undefined, return. + const exportsEntry = packageJson['exports'] + if (exportsEntry == null) { + return resolveNotPresent + } + + // If "name" is not the first segment of X, return + const nameEntry = packageJson.name + if (nameEntry == null || !path.startsWith(nameEntry)) { + return resolveNotPresent + } + + return packageImportsExportsResolve( + `.${path.slice(nameEntry.length)}`, + exportsEntry, + packageJsonDir, + lookupFn, + ) + }, + possiblePackageJson, + ) +} + +// PACKAGE_IMPORTS_EXPORTS_RESOLVE +export function packageImportsExportsResolve( + path: string, + importsOrExportsEntry: ExportsField, + packageJsonDir: string, + lookupFn: FileLookupFn, +): FileLookupResult { + // FIXME partial path lookup because the exports field could be along the lines of `{ '@thing': { stuff: { ... }} }` + // in which case we would want to match that against the import `@thing/stuff` + const importsOrExportsResult = + typeof importsOrExportsEntry === 'string' ? importsOrExportsEntry : importsOrExportsEntry[path] + if (importsOrExportsResult == null) { + return resolveNotPresent + } + + if (typeof importsOrExportsResult === 'string') { + // Lookup the import relative to the package.json we have found + return lookupFn(importsOrExportsResult, packageJsonDir) + } + + // Otherwise importsOrExportsResult is an object, so now we need to check for the specific keys we're interested in + const defaultImportsOrExportsEntry = importsOrExportsResult['default'] + const browserImportsOrExportsEntry = importsOrExportsResult['browser'] // The nodejs algorithm uses `node` here + const requireImportsOrExportsEntry = importsOrExportsResult['require'] + + if (defaultImportsOrExportsEntry != null) { + const fileToLookup = + typeof defaultImportsOrExportsEntry === 'string' + ? defaultImportsOrExportsEntry + : defaultImportsOrExportsEntry['default'] + if (fileToLookup != null) { + return lookupFn(fileToLookup, packageJsonDir) + } + } + if (browserImportsOrExportsEntry != null) { + const fileToLookup = + typeof browserImportsOrExportsEntry === 'string' + ? browserImportsOrExportsEntry + : browserImportsOrExportsEntry['default'] + if (fileToLookup != null) { + return lookupFn(fileToLookup, packageJsonDir) + } + } + if (requireImportsOrExportsEntry != null) { + const fileToLookup = + typeof requireImportsOrExportsEntry === 'string' + ? requireImportsOrExportsEntry + : requireImportsOrExportsEntry['default'] + if (fileToLookup != null) { + return lookupFn(fileToLookup, packageJsonDir) + } + } + + return resolveNotPresent +} diff --git a/editor/src/core/es-modules/package-manager/module-resolution-utils.ts b/editor/src/core/es-modules/package-manager/module-resolution-utils.ts new file mode 100644 index 000000000000..0a2bc939216b --- /dev/null +++ b/editor/src/core/es-modules/package-manager/module-resolution-utils.ts @@ -0,0 +1,216 @@ +import type { MapLike } from 'typescript' +import { getPartsFromPath, makePathFromParts, normalizePath } from '../../../utils/path-utils' +import type { ParseResult } from '../../../utils/value-parser-utils' +import { + optionalObjectKeyParser, + parseAlternative, + parseFalse, + parseNull, + parseObject, + parseString, +} from '../../../utils/value-parser-utils' +import { applicative6Either } from '../../shared/either' +import { setOptionalProp } from '../../shared/object-utils' +import type { ESCodeFile, ESRemoteDependencyPlaceholder } from '../../shared/project-file-types' + +import LRU from 'lru-cache' + +export type ImportsExportsObjectField = MapLike< + string | MapLike> +> +export type ExportsField = string | ImportsExportsObjectField + +export interface PartialPackageJsonDefinition { + name?: string + main?: string + module?: string + browser?: string | MapLike + imports?: ImportsExportsObjectField + exports?: ExportsField +} + +const partialPackageJsonCache: LRU> = new LRU({ + max: 20, +}) +export function getPartialPackageJson(contents: string): ParseResult { + const fromCache = partialPackageJsonCache.get(contents) + if (fromCache == null) { + const jsonParsed = JSON.parse(contents) + const result = parsePartialPackageJsonDefinition(jsonParsed) + partialPackageJsonCache.set(contents, result) + return result + } else { + return fromCache + } +} + +export interface ResolveSuccess { + type: 'RESOLVE_SUCCESS' + success: T +} + +export function resolveSuccess(success: T): ResolveSuccess { + return { + type: 'RESOLVE_SUCCESS', + success: success, + } +} + +export interface ResolveNotPresent { + type: 'RESOLVE_NOT_PRESENT' +} + +export const resolveNotPresent: ResolveNotPresent = { + type: 'RESOLVE_NOT_PRESENT', +} + +export interface ResolveSuccessIgnoreModule { + type: 'RESOLVE_SUCCESS_IGNORE_MODULE' +} + +export function isResolveSuccessIgnoreModule( + resolveResult: ResolveResult, +): resolveResult is ResolveSuccessIgnoreModule { + return resolveResult.type === 'RESOLVE_SUCCESS_IGNORE_MODULE' +} + +export const resolveSuccessIgnoreModule: ResolveSuccessIgnoreModule = { + type: 'RESOLVE_SUCCESS_IGNORE_MODULE', +} + +export type ResolveResult = ResolveNotPresent | ResolveSuccessIgnoreModule | ResolveSuccess + +export function isResolveSuccess( + resolveResult: ResolveResult, +): resolveResult is ResolveSuccess { + return resolveResult.type === 'RESOLVE_SUCCESS' +} + +export function isResolveNotPresent( + resolveResult: ResolveResult, +): resolveResult is ResolveNotPresent { + return resolveResult.type === 'RESOLVE_NOT_PRESENT' +} + +export interface FoundFile { + path: string + file: ESCodeFile | ESRemoteDependencyPlaceholder +} + +export type FileLookupResult = ResolveResult + +export function fileLookupResult( + path: string, + file: ESCodeFile | ESRemoteDependencyPlaceholder | null, +): FileLookupResult { + if (file == null) { + return resolveNotPresent + } else { + return resolveSuccess({ + path: path, + file: file, + }) + } +} + +export type FileLookupFn = (path: string, importOrigin: string) => FileLookupResult +export type FileForPathParts = (pathParts: string[]) => FileLookupResult + +const importsExportsObjectParser = parseObject( + parseAlternative>>( + [ + parseString, + parseObject( + parseAlternative>( + [ + parseString, + parseNull, + parseObject( + parseAlternative( + [parseString, parseNull], + `package.imports and package.exports replacement entries must be either string or null`, + ), + ), + ], + `package.imports and package.exports replacement entries must be either string or null`, + ), + ), + ], + 'package.imports and package.exports object must be an object with type {[key: string]: string | null}', + ), +) + +export const exportsParser = parseAlternative( + [parseString, importsExportsObjectParser], + 'package.imports and package.exports field must either be a string or an object with type {[key: string]: string | null}', +) + +export function parsePartialPackageJsonDefinition( + value: unknown, +): ParseResult { + return applicative6Either( + (name, main, module, browser, imports, exports) => { + let result: PartialPackageJsonDefinition = {} + setOptionalProp(result, 'name', name) + setOptionalProp(result, 'main', main) + setOptionalProp(result, 'module', module) + setOptionalProp(result, 'browser', browser) + setOptionalProp(result, 'imports', imports) + setOptionalProp(result, 'exports', exports) + return result + }, + optionalObjectKeyParser(parseString, 'name')(value), + optionalObjectKeyParser(parseString, 'main')(value), + optionalObjectKeyParser(parseString, 'module')(value), + optionalObjectKeyParser( + parseAlternative>( + [ + parseString, + parseObject( + parseAlternative( + [parseString, parseFalse], + `package.browser replacement entries must be either string or 'false' for ignoring a package`, + ), + ), + ], + 'package.browser field must either be a string or an object with type {[key: string]: string | false}', + ), + 'browser', + )(value), + optionalObjectKeyParser(importsExportsObjectParser, 'imports')(value), + optionalObjectKeyParser(exportsParser, 'exports')(value), + ) +} + +export type PackageLookupResult = { + packageJsonFileResult: FileLookupResult + packageJsonDir: string +} +export function findClosestPackageScopeToPath( + path: string, + localFileLookup: FileForPathParts, +): PackageLookupResult { + const originPathPartsToTest = normalizePath(getPartsFromPath(path)) + const allPossiblePackageJsonPaths = [['package.json']] + .concat( + originPathPartsToTest.map((_part, index) => + originPathPartsToTest.slice(0, index + 1).concat('package.json'), + ), + ) + .reverse() + + for (const possiblePath of allPossiblePackageJsonPaths) { + const packageJsonFileResult = localFileLookup(possiblePath) + if (isResolveSuccess(packageJsonFileResult)) { + return { + packageJsonFileResult: packageJsonFileResult, + packageJsonDir: makePathFromParts(possiblePath.slice(0, -1)), + } + } + } + + return { + packageJsonFileResult: resolveNotPresent, + packageJsonDir: '', + } +} diff --git a/editor/src/core/es-modules/package-manager/module-resolution.spec.ts b/editor/src/core/es-modules/package-manager/module-resolution.spec.ts index aa22947b72e7..eb394f94f2c2 100644 --- a/editor/src/core/es-modules/package-manager/module-resolution.spec.ts +++ b/editor/src/core/es-modules/package-manager/module-resolution.spec.ts @@ -7,7 +7,8 @@ import { unparsed, } from '../../shared/project-file-types' import * as moduleResolutionExamples from '../test-cases/module-resolution-examples.json' -import { isResolveSuccess, resolveModule } from './module-resolution' +import { resolveModule } from './module-resolution' +import { isResolveSuccess } from './module-resolution-utils' import { createNodeModules } from './test-utils' const sampleProjectContents: ProjectContentTreeRoot = contentsToTree({ @@ -33,6 +34,34 @@ const sampleProjectContents: ProjectContentTreeRoot = contentsToTree({ null, 0, ), + '/package.json': textFile( + textFileContents( + ` + { + "name": "sample-project", + "exports": { + "./exported-module": "./exported-module-main.js" + }, + "imports": { + "#dep-object": { + "default": "./src/dep-object.js" + }, + "#dep-simple": "./src/dep-simple.js" + } + }`, + unparsed, + RevisionsState.CodeAhead, + ), + null, + null, + 0, + ), + '/exported-module-main.js': textFile( + textFileContents('export const ExportedModuleMain = 1', unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), '/a/some/nested/file.js': textFile( textFileContents('export const Cake = "tasty"', unparsed, RevisionsState.CodeAhead), null, @@ -51,6 +80,18 @@ const sampleProjectContents: ProjectContentTreeRoot = contentsToTree({ null, 0, ), + '/src/dep-object.js': textFile( + textFileContents('export const DepObject = 1', unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), + '/src/dep-simple.js': textFile( + textFileContents('export const DepSimple = 1', unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), '/src/icon.png': { type: 'ASSET_FILE', }, @@ -60,6 +101,12 @@ const sampleProjectContents: ProjectContentTreeRoot = contentsToTree({ null, 0, ), + '/utopia/layout.utopia.js': textFile( + textFileContents('export const Layout = 1', unparsed, RevisionsState.CodeAhead), + null, + null, + 0, + ), }) describe('ES Package Manager Module Resolution', () => { @@ -244,20 +291,69 @@ describe('ES Package Manager Module Resolution', () => { expect(resolveResult.type).toEqual('RESOLVE_SUCCESS_IGNORE_MODULE') }) - // it('loads self references', () => { - // expect( - // resolveModule( - // createNodeModules(moduleResolutionExamples.contents), - // 'my-package-with-package-root/src/deep-folder/moduleA.js', - // 'my-package-with-package-root', - // ), - // ).toEqual('my-package-with-package-root/index.js') - // expect( - // resolveModule( - // createNodeModules(moduleResolutionExamples.contents), - // 'my-package-with-package-root/src/deep-folder/moduleA.js', - // 'my-package-with-package-root/src/other-folder/moduleB', - // ), - // ).toEqual('my-package-with-package-root/src/other-folder/moduleB.js') - // }) + it('resolves package exports for local package', () => { + expect(resolve('/src/app.js', 'sample-project/exported-module')).toEqual( + '/exported-module-main.js', + ) + }) + + it('resolves package imports simple for local package', () => { + expect(resolve('/src/app.js', '#dep-simple')).toEqual('/src/dep-simple.js') + }) + it('resolves package imports object for local package', () => { + expect(resolve('/src/app.js', '#dep-object')).toEqual('/src/dep-object.js') + }) + + it('resolves package main entry for node module with exports with no subpath', () => { + expect(resolve('/src/app.js', 'module-with-exports-and-imports')).toEqual( + '/node_modules/module-with-exports-and-imports/index.main.js', + ) + }) + + it('resolves package exports object main entry for node module', () => { + expect(resolve('/src/app.js', 'module-with-exports-and-imports-objects')).toEqual( + '/node_modules/module-with-exports-and-imports-objects/index.main.js', + ) + }) + it('resolves package exports object submodule export for node module', () => { + expect(resolve('/src/app.js', 'module-with-exports-and-imports-objects/submodule')).toEqual( + '/node_modules/module-with-exports-and-imports-objects/src/submodule.js', + ) + }) + + it('resolves package imports for node module', () => { + expect( + resolve('/node_modules/module-with-exports-and-imports/src/something.js', '#dep'), + ).toEqual('/node_modules/module-with-exports-and-imports/dep.js') + }) + it('resolves package imports object for node module', () => { + expect( + resolve('/node_modules/module-with-exports-and-imports-objects/src/something.js', '#dep'), + ).toEqual('/node_modules/module-with-exports-and-imports-objects/dep.js') + }) + + it('loads self references', () => { + expect( + resolve( + '/node_modules/my-package-with-package-root/src/deep-folder/moduleA.js', + 'my-package-with-package-root', + ), + ).toEqual('/node_modules/my-package-with-package-root/index.js') + expect( + resolve( + '/node_modules/my-package-with-package-root/src/deep-folder/moduleA.js', + 'my-package-with-package-root/src/other-folder/moduleB', + ), + ).toEqual('/node_modules/my-package-with-package-root/src/other-folder/moduleB.js') + }) + + it('resolves clsx', () => { + expect(resolve('/app/components/hydrogen/Button.jsx', 'clsx')).toEqual( + '/node_modules/clsx/dist/clsx.js', + ) + }) + + it('resolves /utopia/layout.utopia', () => { + expect(resolve('/utopia/text.utopia.js', './layout.utopia')).toEqual('/utopia/layout.utopia.js') + }) }) diff --git a/editor/src/core/es-modules/package-manager/module-resolution.ts b/editor/src/core/es-modules/package-manager/module-resolution.ts index 8904005017ff..b570990de635 100644 --- a/editor/src/core/es-modules/package-manager/module-resolution.ts +++ b/editor/src/core/es-modules/package-manager/module-resolution.ts @@ -1,180 +1,261 @@ -import type { - ProjectFile, - NodeModules, - ESCodeFile, - ESRemoteDependencyPlaceholder, -} from '../../shared/project-file-types' -import { isEsCodeFile, esCodeFile } from '../../shared/project-file-types' -import type { ParseResult } from '../../../utils/value-parser-utils' -import { - optionalObjectKeyParser, - parseString, - parseAlternative, - parseObject, - parseFalse, -} from '../../../utils/value-parser-utils' -import type { Either } from '../../shared/either' -import { - applicative3Either, - applicative4Either, - foldEither, - isLeft, - isRight, - left, - right, -} from '../../shared/either' -import { setOptionalProp } from '../../shared/object-utils' +import type { MapLike } from 'typescript' import type { ProjectContentTreeRoot } from '../../../components/assets' import { getContentsTreeFileFromElements } from '../../../components/assets' +import { applyFilePathMappingsToFilePath } from '../../../core/workers/common/project-file-utils' +import { + getParentDirectory, + getPartsFromPath, + makePathFromParts, + normalizePath, + stripTrailingSlash, +} from '../../../utils/path-utils' +import type { ParseResult } from '../../../utils/value-parser-utils' +import { getFilePathMappings } from '../../model/project-file-utils' import { dropLast, last } from '../../shared/array-utils' -import { getPartsFromPath, makePathFromParts, normalizePath } from '../../../utils/path-utils' -import type { MapLike } from 'typescript' - -import LRU from 'lru-cache' +import type { Either } from '../../shared/either' +import { foldEither, isLeft, isRight, left, right } from '../../shared/either' +import type { NodeModules, ProjectFile } from '../../shared/project-file-types' +import { esCodeFile, isEsCodeFile } from '../../shared/project-file-types' import type { BuiltInDependencies } from './built-in-dependencies-list' -import { getFilePathMappings } from '../../model/project-file-utils' -import { applyFilePathMappingsToFilePath } from '../../../core/workers/common/project-file-utils' +import { loadPackageSelf, loadPackageImports, loadPackageExports } from './module-resolution-esm' +import type { + FileForPathParts, + FileLookupFn, + FileLookupResult, + PartialPackageJsonDefinition, +} from './module-resolution-utils' +import { + fileLookupResult, + getPartialPackageJson, + isResolveSuccess, + resolveNotPresent, + resolveSuccessIgnoreModule, +} from './module-resolution-utils' -const partialPackageJsonCache: LRU> = new LRU({ - max: 20, -}) -function getPartialPackageJson(contents: string): ParseResult { - const fromCache = partialPackageJsonCache.get(contents) - if (fromCache == null) { - const jsonParsed = JSON.parse(contents) - const result = parsePartialPackageJsonDefinition(jsonParsed) - partialPackageJsonCache.set(contents, result) - return result - } else { - return fromCache - } -} +// Using the logic from https://nodejs.org/api/modules.html#all-together -interface ResolveSuccess { - type: 'RESOLVE_SUCCESS' - success: T +function loadAsFile( + projectContents: ProjectContentTreeRoot, + pathParts: string[], +): FileLookupResult { + return abstractLoadAsFile(projectContentsFileForPathParts(projectContents), pathParts) } -function resolveSuccess(success: T): ResolveSuccess { - return { - type: 'RESOLVE_SUCCESS', - success: success, +// LOAD_AS_FILE(X) +function abstractLoadAsFile( + fileForPathParts: FileForPathParts, + pathParts: string[], +): FileLookupResult { + const normalisedPathParts = normalizePath(pathParts) + const pathToFile = dropLast(normalisedPathParts) + const lastPart = last(normalisedPathParts) + if (lastPart == null) { + return resolveNotPresent } -} -interface ResolveNotPresent { - type: 'RESOLVE_NOT_PRESENT' -} + // If X is a file, load X as its file extension format + const asFileResult = fileForPathParts(normalisedPathParts) + if (isResolveSuccess(asFileResult)) { + return asFileResult + } -const resolveNotPresent: ResolveNotPresent = { - type: 'RESOLVE_NOT_PRESENT', -} + // If X.js(x) is a file + const withJs = lastPart + '.js' + const withJsResult = fileForPathParts(pathToFile.concat(withJs)) + if (isResolveSuccess(withJsResult)) { + return withJsResult + } -interface ResolveSuccessIgnoreModule { - type: 'RESOLVE_SUCCESS_IGNORE_MODULE' -} + const withJsx = lastPart + '.jsx' + const withJsxResult = fileForPathParts(pathToFile.concat(withJsx)) + if (isResolveSuccess(withJsxResult)) { + return withJsxResult + } -export function isResolveSuccessIgnoreModule( - resolveResult: ResolveResult, -): resolveResult is ResolveSuccessIgnoreModule { - return resolveResult.type === 'RESOLVE_SUCCESS_IGNORE_MODULE' -} + // If X.json is a file, load X.json to a JavaScript Object + const withJson = lastPart + '.json' + const withJsonResult = fileForPathParts(pathToFile.concat(withJson)) + if (isResolveSuccess(withJsonResult)) { + return withJsonResult + } -const resolveSuccessIgnoreModule: ResolveSuccessIgnoreModule = { - type: 'RESOLVE_SUCCESS_IGNORE_MODULE', + return resolveNotPresent } -type ResolveResult = ResolveNotPresent | ResolveSuccessIgnoreModule | ResolveSuccess +function processPackageJson(potentiallyJsonCode: string): string | null { + let possiblePackageJson: ParseResult + try { + possiblePackageJson = getPartialPackageJson(potentiallyJsonCode) + } catch { + return null + } + return foldEither( + (_) => null, + (packageJson) => { + // const moduleName: string | null = packageJson.name ?? null + const browserEntry: string | MapLike | null = packageJson.browser ?? null + const mainEntry: string | null = packageJson.main ?? null + // const moduleEntry: string | null = packageJson.module ?? null -export function isResolveSuccess( - resolveResult: ResolveResult, -): resolveResult is ResolveSuccess { - return resolveResult.type === 'RESOLVE_SUCCESS' -} + if (browserEntry != null && typeof browserEntry === 'string') { + return browserEntry + } else if (mainEntry != null) { + return mainEntry + // } else if (moduleEntry != null) { + // return moduleEntry + // } else if (moduleName != null) { + // return moduleName + } -export function isResolveNotPresent( - resolveResult: ResolveResult, -): resolveResult is ResolveNotPresent { - return resolveResult.type === 'RESOLVE_NOT_PRESENT' + return null + }, + possiblePackageJson, + ) } -interface FoundFile { - path: string - file: ESCodeFile | ESRemoteDependencyPlaceholder +function loadAsDirectory( + projectContents: ProjectContentTreeRoot, + pathParts: string[], +): FileLookupResult { + return abstractLoadAsDirectory(projectContentsFileForPathParts(projectContents), pathParts) } -export type FileLookupResult = ResolveResult - -function fileLookupResult( - path: string, - file: ESCodeFile | ESRemoteDependencyPlaceholder | null, +// LOAD_AS_DIRECTORY(X) +function abstractLoadAsDirectory( + fileForPathParts: FileForPathParts, + pathParts: string[], ): FileLookupResult { - if (file == null) { - return resolveNotPresent - } else { - return resolveSuccess({ - path: path, - file: file, - }) - } -} + const normalisedPathParts = normalizePath(pathParts) // X -type FileLookupFn = (path: string[], direct: boolean) => FileLookupResult + // If X/package.json is a file + const jsonFileResult = fileForPathParts(normalisedPathParts.concat('package.json')) + if (isResolveSuccess(jsonFileResult) && isEsCodeFile(jsonFileResult.success.file)) { + // Parse X/package.json, and look for "browser" or "main" field + const mainEntryPath = processPackageJson(jsonFileResult.success.file.fileContents) -function fallbackLookup( - lookupFn: FileLookupFn, - path: Array, - direct: boolean, -): FileLookupResult { - const asIsPath = normalizePath(path) - const pathToFile = dropLast(asIsPath) - const lastPart = last(asIsPath) - if (lastPart == null) { - return resolveNotPresent - } else { - const asIsResult = lookupFn(asIsPath, direct) - if (isResolveSuccess(asIsResult)) { - return asIsResult - } + if (mainEntryPath != null) { + // let M = X + (json main field) + const mainEntryPathParts = [...normalisedPathParts, ...getPartsFromPath(mainEntryPath)] - if (!direct) { - const withJs = lastPart + '.js' - const withJsResult = lookupFn(pathToFile.concat(withJs), direct) - if (isResolveSuccess(withJsResult)) { - return withJsResult + // LOAD_AS_FILE(M) + const mainEntryResult = abstractLoadAsFile(fileForPathParts, mainEntryPathParts) + if (isResolveSuccess(mainEntryResult)) { + return mainEntryResult } - const withJsx = lastPart + '.jsx' - const withJsxResult = lookupFn(pathToFile.concat(withJsx), direct) - if (isResolveSuccess(withJsxResult)) { - return withJsxResult + // LOAD_INDEX(M) + const loadIndexMResult = loadIndex(fileForPathParts, mainEntryPathParts) + if (isResolveSuccess(loadIndexMResult)) { + return loadIndexMResult } - // TODO this also needs JSON parsing - const withJson = lastPart + '.json' - const withJsonResult = lookupFn(pathToFile.concat(withJson), direct) - if (isResolveSuccess(withJsonResult)) { - return withJsonResult - } + // LOAD_INDEX(X) DEPRECATED - note that this is only deprecated in the case where "main" is truthy + return loadIndex(fileForPathParts, normalisedPathParts) } + } - return resolveNotPresent + // If "main" is a falsy value + // LOAD_INDEX(X) + return loadIndex(fileForPathParts, normalisedPathParts) +} + +// LOAD_INDEX(X) +function loadIndex(fileForPathParts: FileForPathParts, pathParts: string[]): FileLookupResult { + // 1. If X/index.js is a file + // a. Find the closest package scope SCOPE to X. + // b. If no scope was found, load X/index.js as a CommonJS module. STOP. + // c. If the SCOPE/package.json contains "type" field, + // 1. If the "type" field is "module", load X/index.js as an ECMAScript module. STOP. + // 2. Else, load X/index.js as an CommonJS module. STOP. + // Note: We don't attempt to load as an ECMAScript, as we can't - instead we'll try to load whatever + // it is as CommonJS, and if it turns out to be an esm we'll attempt to transpile it to CommonJS + + const indexJSResult = abstractLoadAsFile(fileForPathParts, pathParts.concat('index.js')) + if (isResolveSuccess(indexJSResult)) { + return indexJSResult } + + // If X/index.json is a file, parse X/index.json to a JavaScript object + return abstractLoadAsFile(fileForPathParts, pathParts.concat('index.json')) + + // If X/index.node is a file, load X/index.node as binary addon - not applicable +} + +// NODE_MODULES_PATHS +function nodeModulesPaths(path: string): Array { + // let PARTS = path split(START) + const pathParts = getPartsFromPath(path) + + // let DIRS = [] + let dirs: Array = [] + + // The algorithm builds dirs in descending order, but we'll do it in ascending and reverse + pathParts.forEach((part, index) => { + // if PARTS[I] = "node_modules" CONTINUE + if (part === 'node_modules') { + return + } + + // DIR = path join(PARTS[0 .. I] + "node_modules") + const dir = makePathFromParts(pathParts.slice(0, index + 1).concat('node_modules')) + + // DIRS = DIR + DIRS + dirs.push(dir) + }) + dirs.reverse() + + // return DIRS + GLOBAL_FOLDERS + return dirs.concat('/node_modules/') } -function nodeModulesFileLookup( +// LOAD_NODE_MODULES +function loadNodeModules( nodeModules: NodeModules, - path: Array, - direct: boolean, + path: string, + originPath: string, + lookupFn: FileLookupFn, ): FileLookupResult { - return fallbackLookup( - (innerPath: string[], innerDirect: boolean) => { - const filename = makePathFromParts(innerPath) - return fileLookupResult(filename, nodeModules[filename]) - }, - path, - direct, - ) + const packageJsonLookupFn = nodeModulesFileForPathParts(nodeModules) + + // let DIRS = NODE_MODULES_PATHS(START) + const dirs = nodeModulesPaths(originPath).reverse() + const targetOrigins = [originPath, ...dirs] + + for (const targetOrigin of targetOrigins) { + // for each DIR in DIRS: + + // LOAD_PACKAGE_EXPORTS(X, DIR) + const packageExportsResult = loadPackageExports( + path, + targetOrigin, + packageJsonLookupFn, + lookupFn, + ) + if (isResolveSuccess(packageExportsResult)) { + return packageExportsResult + } + + const pathForLookup = path.startsWith('/') + ? path + : `${stripTrailingSlash(targetOrigin)}/${path}` + + const pathParts = getPartsFromPath(pathForLookup) + const normalisedPathParts = normalizePath(pathParts) + + // LOAD_AS_FILE(DIR/X) + const asFileResult = abstractLoadAsFile(packageJsonLookupFn, normalisedPathParts) + if (isResolveSuccess(asFileResult)) { + return asFileResult + } + + // LOAD_AS_DIRECTORY(DIR/X) + const asDirectoryResult = abstractLoadAsDirectory(packageJsonLookupFn, normalisedPathParts) + if (isResolveSuccess(asDirectoryResult)) { + return asDirectoryResult + } + } + + return resolveNotPresent } function getProjectFileContentsAsString(file: ProjectFile): string | null { @@ -193,240 +274,52 @@ function getProjectFileContentsAsString(file: ProjectFile): string | null { } } -function projectContentsFileLookup( +function projectContentsFileForPathParts( projectContents: ProjectContentTreeRoot, - path: Array, - direct: boolean, -): FileLookupResult { - return fallbackLookup( - (innerPath: string[]) => { - const projectFile = getContentsTreeFileFromElements(projectContents, innerPath) - if (projectFile == null) { +): FileForPathParts { + return (pathParts: string[]) => { + const projectFile = getContentsTreeFileFromElements(projectContents, pathParts) + if (projectFile == null) { + return resolveNotPresent + } else { + const fileContents = getProjectFileContentsAsString(projectFile) + if (fileContents == null) { return resolveNotPresent } else { - const fileContents = getProjectFileContentsAsString(projectFile) - if (fileContents == null) { - return resolveNotPresent - } else { - const filename = makePathFromParts(innerPath) - return fileLookupResult(filename, esCodeFile(fileContents, 'PROJECT_CONTENTS', filename)) - } + const filename = makePathFromParts(pathParts) + return fileLookupResult(filename, esCodeFile(fileContents, 'PROJECT_CONTENTS', filename)) } - }, - path, - direct, - ) -} - -interface PartialPackageJsonDefinition { - name?: string - main?: string - module?: string - browser?: string | MapLike -} - -export function parsePartialPackageJsonDefinition( - value: unknown, -): ParseResult { - return applicative4Either( - (name, main, module, browser) => { - let result: PartialPackageJsonDefinition = {} - setOptionalProp(result, 'name', name) - setOptionalProp(result, 'main', main) - setOptionalProp(result, 'module', module) - setOptionalProp(result, 'browser', browser) - return result - }, - optionalObjectKeyParser(parseString, 'name')(value), - optionalObjectKeyParser(parseString, 'main')(value), - optionalObjectKeyParser(parseString, 'module')(value), - optionalObjectKeyParser( - parseAlternative>( - [ - parseString, - parseObject( - parseAlternative( - [parseString, parseFalse], - `package.browser replacement entries must be either string or 'false' for ignoring a package`, - ), - ), - ], - 'package.browser field must either be a string or an object with type {[key: string]: string}', - ), - 'browser', - )(value), - ) -} - -function processPackageJson( - potentiallyJsonCode: string, - containerFolder: string[], -): ResolveResult> { - let possiblePackageJson: ParseResult - try { - possiblePackageJson = getPartialPackageJson(potentiallyJsonCode) - } catch { - return resolveNotPresent + } } - return foldEither( - (_) => resolveNotPresent, - (packageJson) => { - const moduleName: string | null = packageJson.name ?? null - const browserEntry: string | MapLike | null = packageJson.browser ?? null - const mainEntry: string | null = packageJson.main ?? null - const moduleEntry: string | null = packageJson.module ?? null - - if (browserEntry != null && typeof browserEntry === 'string') { - return resolveSuccess( - normalizePath([...containerFolder, ...getPartsFromPath(browserEntry)]), - ) - } else if (mainEntry != null) { - return resolveSuccess(normalizePath([...containerFolder, ...getPartsFromPath(mainEntry)])) - } else if (moduleEntry != null) { - return resolveSuccess(normalizePath([...containerFolder, ...getPartsFromPath(moduleEntry)])) - } else if (moduleName != null) { - return resolveSuccess(normalizePath([...containerFolder, ...getPartsFromPath(moduleName)])) - } else if (containerFolder.length > 0) { - return resolveSuccess(normalizePath(containerFolder)) - } - - return resolveNotPresent - }, - possiblePackageJson, - ) } -function resolvePackageJson( - fileLookupFn: FileLookupFn, - packageJsonFolder: string[], -): FileLookupResult { - const normalizedFolderPath = normalizePath(packageJsonFolder) - const folderPackageJson = fileLookupFn([...normalizedFolderPath, 'package.json'], true) - if (isResolveSuccess(folderPackageJson) && isEsCodeFile(folderPackageJson.success.file)) { - const mainEntryPath = processPackageJson( - folderPackageJson.success.file.fileContents, - normalizedFolderPath, - ) - if (isResolveSuccess(mainEntryPath)) { - // try loading the entry path as a file - const mainEntryResult = fileLookupFn(mainEntryPath.success, false) - if (isResolveSuccess(mainEntryResult)) { - return mainEntryResult - } else { - // fallback to loading it as a folder with an index.js - const indexJsPath = [...mainEntryPath.success, 'index'] - return fileLookupFn(indexJsPath, false) - } - } else { +function nodeModulesFileForPathParts(nodeModules: NodeModules): FileForPathParts { + return (pathParts: string[]) => { + const filename = makePathFromParts(pathParts) + const nodeModulesFile = nodeModules[filename] + if (nodeModulesFile == null) { return resolveNotPresent + } else { + return fileLookupResult(filename, nodeModulesFile) } } - return resolveNotPresent } -function findPackageJsonForPath(fileLookupFn: FileLookupFn, origin: string[]): FileLookupResult { +function findPackageJsonForPath( + fileLookupFn: FileLookupFn, + originPathParts: string[], +): FileLookupResult { // 1. look for /package.json - const packageJsonResult = fileLookupFn([...origin, 'package.json'], true) + const packageJsonResult = fileLookupFn(`./package.json`, makePathFromParts(originPathParts)) if (isResolveSuccess(packageJsonResult)) { return packageJsonResult } else { // 2. repeat in the parent folder - if (origin.length === 0) { + if (originPathParts.length === 0) { // we exhausted all folders without success return resolveNotPresent } else { - return findPackageJsonForPath(fileLookupFn, origin.slice(0, -1)) - } - } -} - -function resolveNonRelativeModule( - fileLookupFn: FileLookupFn, - importOrigin: string[], - toImport: string[], -): FileLookupResult { - const pathElements = [...importOrigin, 'node_modules', ...toImport] - // 1. look for ./node_modules/.js - const packageNameResult = fileLookupFn(pathElements, false) - if (isResolveSuccess(packageNameResult)) { - return packageNameResult - } else { - // 2. look for ./node_modules//package.json - const packageJsonResult = resolvePackageJson(fileLookupFn, pathElements) - if (isResolveSuccess(packageJsonResult)) { - return packageJsonResult - } else { - // 3. look for ./node_modules//index.js - const indexJsPath = [...pathElements, 'index'] - const indexJSResult = fileLookupFn(indexJsPath, false) - if (isResolveSuccess(indexJSResult)) { - return indexJSResult - } else { - // 4. repeat in the parent folder - if (importOrigin.length === 0) { - // we exhausted all folders without success - return resolveNotPresent - } else { - return resolveNonRelativeModule(fileLookupFn, importOrigin.slice(0, -1), toImport) - } - } - } - } -} - -function resolveRelativeModule( - fileLookupFn: FileLookupFn, - importOrigin: string[], - toImport: string[], -): FileLookupResult { - const pathElements = [...importOrigin, ...toImport] - // 1. look for a file named - const importNameResult = fileLookupFn(pathElements, false) - if (isResolveSuccess(importNameResult)) { - return importNameResult - } else { - // 2. look for /package.json - const packageJsonResult = resolvePackageJson(fileLookupFn, pathElements) - if (isResolveSuccess(packageJsonResult)) { - return packageJsonResult - } else { - // 3. look for /index.js - return fileLookupFn([...pathElements, 'index'], false) - } - } -} - -// Module resolution logic based on what Node / Typescript does https://www.typescriptlang.org/docs/handbook/module-resolution.html#node - -// TODO there's way more module resolution rules here https://parceljs.org/module_resolution.html#module-resolution - -// TODO an even more comprehensive writeup https://nodejs.org/api/modules.html#modules_all_together - -function resolveModuleInternal( - fileLookupFn: FileLookupFn, - importOrigin: string, - toImport: string, -): FileLookupResult { - if (toImport.startsWith('/')) { - // absolute import - return resolveRelativeModule( - fileLookupFn, - [], // this import is relative to the root - getPartsFromPath(toImport), - ) - } else { - if (toImport.startsWith('.')) { - return resolveRelativeModule( - fileLookupFn, - getPartsFromPath(importOrigin).slice(0, -1), - getPartsFromPath(toImport), - ) - } else { - return resolveNonRelativeModule( - fileLookupFn, - getPartsFromPath(importOrigin).slice(0, -1), - getPartsFromPath(toImport), - ) + return findPackageJsonForPath(fileLookupFn, originPathParts.slice(0, -1)) } } } @@ -482,15 +375,64 @@ function resolveModuleAndApplySubstitutions( importOrigin: string, toImport: string, ): FileLookupResult { - function lookupFn(path: string[], direct: boolean): FileLookupResult { - const nodeModulesResult = nodeModulesFileLookup(nodeModules, path, direct) - if (isResolveSuccess(nodeModulesResult)) { - return nodeModulesResult - } else { - return projectContentsFileLookup(projectContents, path, direct) + // require(X) from module at path Y + function lookupFn(path: string, innerImportOrigin: string): FileLookupResult { + // 1 Check if it's a core module - this first step is irrelevant to us, since the browser has no core modules + if (path.startsWith('/') || path.startsWith('./') || path.startsWith('../')) { + // pathForLookup here is Y + X + const pathForLookup = path.startsWith('/') // If X begins with '/' set Y to be the file system root + ? path + : `${stripTrailingSlash(innerImportOrigin)}/${path}` + const pathParts = getPartsFromPath(pathForLookup) + + // LOAD_AS_FILE(Y + X) + const projectContentsFileLookupResult = loadAsFile(projectContents, pathParts) + + if (isResolveSuccess(projectContentsFileLookupResult)) { + return projectContentsFileLookupResult + } + + // LOAD_AS_DIRECTORY(Y + X) + const projectContentsDirectoryLookupResult = loadAsDirectory(projectContents, pathParts) + if (isResolveSuccess(projectContentsDirectoryLookupResult)) { + return projectContentsDirectoryLookupResult + } + } else if (path.startsWith('#')) { + // If X begins with '#' + // LOAD_PACKAGE_IMPORTS(X, dirname(Y)) + return loadPackageImports( + path, + innerImportOrigin, + innerImportOrigin.startsWith('/node_modules') + ? nodeModulesFileForPathParts(nodeModules) + : projectContentsFileForPathParts(projectContents), + lookupFn, + ) + } + + // LOAD_PACKAGE_SELF(X, dirname(Y)) + const packageExportsResult = loadPackageSelf( + path, + innerImportOrigin, + projectContentsFileForPathParts(projectContents), + lookupFn, + ) + + if (isResolveSuccess(packageExportsResult)) { + return packageExportsResult } + + // LOAD_NODE_MODULES(X, dirname(Y)), returning the result here either way in place of `throw 'not fount'` + return loadNodeModules( + nodeModules, + path, + innerImportOrigin.startsWith('/node_modules') ? innerImportOrigin : '/node_modules', + lookupFn, + ) } + // We first need to check for the presence of a `browser` field in the current module's package.json, as that + // can suggest an alternative mapping to use for modules it is importing https://github.com/defunctzombie/package-browser-field-spec const substitutedImport: Either = findSubstitutionsForImport( lookupFn, importOrigin, @@ -505,7 +447,8 @@ function resolveModuleAndApplySubstitutions( substitutedImport.value, filePathMappings, ) - return resolveModuleInternal(lookupFn, importOrigin, unAliasedImport) + + return lookupFn(unAliasedImport, getParentDirectory(importOrigin)) } } diff --git a/editor/src/core/es-modules/package-manager/package-manager.ts b/editor/src/core/es-modules/package-manager/package-manager.ts index 0e90fe32c94a..e7971183db65 100644 --- a/editor/src/core/es-modules/package-manager/package-manager.ts +++ b/editor/src/core/es-modules/package-manager/package-manager.ts @@ -1,12 +1,7 @@ import type { NodeModules, ESCodeFile } from '../../shared/project-file-types' import { isEsCodeFile, isEsRemoteDependencyPlaceholder } from '../../shared/project-file-types' import type { RequireFn, TypeDefinitions } from '../../shared/npm-dependency-types' -import { - isResolveNotPresent, - isResolveSuccess, - isResolveSuccessIgnoreModule, - resolveModule, -} from './module-resolution' +import { resolveModule } from './module-resolution' import { evaluator } from '../evaluator/evaluator' import { fetchMissingFileDependency } from './fetch-packages' import type { EditorDispatch } from '../../../components/editor/action-types' @@ -17,10 +12,13 @@ import { utopiaApiTypings } from './utopia-api-typings' import { resolveBuiltInDependency } from './built-in-dependencies' import type { ProjectContentTreeRoot } from '../../../components/assets' import { applyLoaders } from '../../webpack-loaders/loaders' -import { string } from 'prop-types' -import { Either } from '../../shared/either' import type { CurriedUtopiaRequireFn } from '../../../components/custom-code/code-file' import type { BuiltInDependencies } from './built-in-dependencies-list' +import { + isResolveNotPresent, + isResolveSuccess, + isResolveSuccessIgnoreModule, +} from './module-resolution-utils' import type { FrameworkHooks } from '../../frameworks/framework-hooks' import { getFrameworkHooks } from '../../frameworks/framework-hooks' diff --git a/editor/src/core/es-modules/test-cases/module-resolution-examples.json b/editor/src/core/es-modules/test-cases/module-resolution-examples.json index a0bf7334fa06..f094135cf88f 100644 --- a/editor/src/core/es-modules/test-cases/module-resolution-examples.json +++ b/editor/src/core/es-modules/test-cases/module-resolution-examples.json @@ -230,25 +230,70 @@ "isModule": false, "requires": [] }, - "my-package-with-package-root/src/deep-folder/moduleA.js": { + "/node_modules/module-with-exports-and-imports/package.json": { + "content": "{\"name\": \"module-with-exports-and-imports\", \"main\": \"index.main.js\", \"exports\": \"index.exports.js\", \"imports\": {\"#dep\": \"./dep.js\"}}", + "isModule": false, + "requires": [] + }, + "/node_modules/module-with-exports-and-imports/index.main.js": { "content": "exports.cica = 'kitten';", "isModule": false, "requires": [] }, - "my-package-with-package-root/src/other-folder/moduleB.js": { + "/node_modules/module-with-exports-and-imports/dep.js": { "content": "exports.cica = 'kitten';", "isModule": false, "requires": [] }, - "my-package-with-package-root/index.js": { + "/node_modules/module-with-exports-and-imports-objects/package.json": { + "content": "{\"name\": \"module-with-exports-and-imports-objects\", \"main\": \"index.main.js\", \"exports\": {\".\": \"index.exports.js\", \"./submodule\": \"./src/submodule.js\"}, \"imports\": {\"#dep\": {\"default\": \"./dep.js\"}}}", + "isModule": false, + "requires": [] + }, + "/node_modules/module-with-exports-and-imports-objects/index.main.js": { "content": "exports.cica = 'kitten';", "isModule": false, "requires": [] }, - "my-package-with-package-root/package.json": { + "/node_modules/module-with-exports-and-imports-objects/dep.js": { + "content": "exports.cica = 'kitten';", + "isModule": false, + "requires": [] + }, + "/node_modules/module-with-exports-and-imports-objects/src/submodule.js": { + "content": "exports.cica = 'kitten';", + "isModule": false, + "requires": [] + }, + "/node_modules/my-package-with-package-root/src/deep-folder/moduleA.js": { + "content": "exports.cica = 'kitten';", + "isModule": false, + "requires": [] + }, + "/node_modules/my-package-with-package-root/src/other-folder/moduleB.js": { + "content": "exports.cica = 'kitten';", + "isModule": false, + "requires": [] + }, + "/node_modules/my-package-with-package-root/index.js": { + "content": "exports.cica = 'kitten';", + "isModule": false, + "requires": [] + }, + "/node_modules/my-package-with-package-root/package.json": { "content": "{\"name\": \"my-package-with-package-root\", \"main\": \".index.js\"}", "isModule": false, "requires": [] + }, + "/node_modules/clsx/package.json": { + "content": "{\"name\": \"clsx\", \"main\": \"dist/clsx.js\", \"exports\": {\".\": {\"default\": { \"default\": \"./dist/clsx.js\"}}}}", + "isModule": false, + "requires": [] + }, + "/node_modules/clsx/dist/clsx.js": { + "content": "exports.cica = 'kitten';", + "isModule": false, + "requires": [] } }, "dependency": { diff --git a/editor/src/core/frameworks/framework-hooks-vite.ts b/editor/src/core/frameworks/framework-hooks-vite.ts index c5b67b7cfe2f..fd6fd2992af5 100644 --- a/editor/src/core/frameworks/framework-hooks-vite.ts +++ b/editor/src/core/frameworks/framework-hooks-vite.ts @@ -9,8 +9,9 @@ import type { ComponentToImport, CreationDataFromProject } from '../model/storyb import { namedComponentToImport, PossiblyMainComponentNames } from '../model/storyboard-utils' import { mergeImports } from '../workers/common/project-file-utils' import { absolutePathFromRelativePath } from '../../utils/path-utils' -import type { FileLookupResult } from '../es-modules/package-manager/module-resolution' -import { isResolveSuccess, resolveModule } from '../es-modules/package-manager/module-resolution' +import type { FileLookupResult } from '../es-modules/package-manager/module-resolution-utils' +import { isResolveSuccess } from '../es-modules/package-manager/module-resolution-utils' +import { resolveModule } from '../es-modules/package-manager/module-resolution' import { getMainScriptElement, getRootElement, parseHtml } from '../shared/dom-utils' export class ViteFrameworkHooks implements FrameworkHooks { diff --git a/editor/src/utils/path-utils.ts b/editor/src/utils/path-utils.ts index 13dad8741a71..33fbc1fd77e9 100644 --- a/editor/src/utils/path-utils.ts +++ b/editor/src/utils/path-utils.ts @@ -33,7 +33,7 @@ export function getParentDirectory(filepath: string): string { return makePathFromParts(getPartsFromPath(filepath).slice(0, -1)) } -type SubPathCache = { [key: string]: PathCache } +type SubPathCache = Map interface PathCache { cachedString: string @@ -47,7 +47,7 @@ function pathCache(cachedString: string, subPathCache: SubPathCache): PathCache } } -let rootPathCache: SubPathCache = {} +let rootPathCache: SubPathCache = new Map() function getPathFromCache(parts: Array): string { let workingSubCache: SubPathCache = rootPathCache @@ -55,15 +55,14 @@ function getPathFromCache(parts: Array): string { let partsSoFar: Array = [] for (const part of parts) { partsSoFar.push(part) - if (part in workingSubCache) { - // The `in` check above proves this does not return `undefined`. - workingPathCache = workingSubCache[part]! + if (workingSubCache.has(part)) { + workingPathCache = workingSubCache.get(part)! workingSubCache = workingPathCache.subPathCache } else { const cachedString = `/${partsSoFar.join('/')}` - const newPathCache = pathCache(cachedString, {}) + const newPathCache = pathCache(cachedString, new Map()) workingPathCache = newPathCache - workingSubCache[part] = newPathCache + workingSubCache.set(part, newPathCache) workingSubCache = newPathCache.subPathCache } }