Repository: ProjectEvergreen/greenwood (js)

Issue:
dependencies that reference more than one directory level of traversal (../) in their ESM specifiers do not resolve correctly
## Type of Change
- [ ] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [x] Bug
- [ ] Other (please clarify below)

## Summary
As part of [working on a project](https://github.com/AnalogStudiosRI/www.analogstudios.net/pull/54), came across a [Stencil generated WC](https://github.com/ProjectEvergreen/greenwood/discussions/523#discussioncomment-1789348) that had references to a dependency that did an `export` of [sibling files](https://unpkg.com/browse/@stencil/core@2.11.0/internal/stencil-core/index.js) like so
```js
export {
  ...
} from '../client/index.js';
```

This tracks as we only check for `./` in our code 🤫 


## Details
In addition to this support, I also [found cases](https://unpkg.com/browse/@stencil/core@2.11.0/internal/client/index.js) where I would specifically need to check for a "barrel" / [_index.js_ file at a directory](https://unpkg.com/browse/@stencil/core@2.11.0/internal/app-data/index.js) for bare imports like this?
```js
import { BUILD, NAMESPACE } from '@stencil/core/internal/app-data';
```

Pull request:
Bug/issue 820 deeply relative ESM paths in node modules not working
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #820 

## Summary of Changes
1. Refactored out usages of `.replace('./', '')` with custom functionality to better handle `./` vs `../` using [**@stencil/core**](https://unpkg.com/@stencil/core/) for validation
1. Added tests and did some minor refactoring / commenting including not running `walkModule` on packages that already provide an `exports` map
1. Cleaning up import map snapshot to remove false positive entries

## TODO
1. [x] Sanity test first pass in downstream projects
1. [x] Test with **@stencil/core** for handling `../` paths
1. [x] Fix windows build - lol, I was way overthinking it.  I was just about forcing windows path separators (`\`) to ESM style (`/`)
    - ~~some sort of race condition it seems.  Looks like the [right path from the export map](https://unpkg.com/browse/@babel/runtime@7.13.7/package.json) on Windows is being overwritten (?) later on by further calls to `updateImportMap` from [walking the imports of a related **@babel/runtime** file](https://unpkg.com/browse/@babel/runtime@7.13.7/helpers/interopRequireWildcard/_index.mjs)?  Was able to reproduce the same error on macOS by removing this [one-off pre-check](https://github.com/ProjectEvergreen/greenwood/blob/master/packages/cli/src/plugins/resource/plugin-node-modules.js#L54), so seems like the issue is somewhere in there~~ 🔬 👀 
1. [x] Final sanity test again in downstream projects
    - https://github.com/thegreenhouseio/www.thegreenhouse.io
    - https://github.com/ProjectEvergreen/greenwood-getting-started/pull/45
    - https://github.com/ProjectEvergreen/greenwood-getting-started/pull/42
    - https://github.com/ContributaryCommunity/www.contributary.community
1. [x] Misc TODOs / comment cleanup - deferred to https://github.com/ProjectEvergreen/greenwood/issues/684
1. [ ] (stretch) Test with **@stencil/core** for handling assumed barrel paths (files that assume resolving to a root level _index.js_ via bare specifier (likely will make its own issue as it is a lower priority) - tracked in https://github.com/ProjectEvergreen/greenwood/issues/907

Patch:
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 75f8d149..589ea963 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -56,6 +56,7 @@
     "@lion/calendar": "^0.16.7",
     "@mapbox/rehype-prism": "^0.5.0",
     "@material/mwc-button": "^0.25.2",
+    "@stencil/core": "^2.12.0",
     "@types/trusted-types": "^2.0.2",
     "lit": "^2.0.0",
     "lit-redux-router": "~0.20.0",
diff --git a/packages/cli/src/plugins/resource/plugin-node-modules.js b/packages/cli/src/plugins/resource/plugin-node-modules.js
index 33d4e8ea..b6f31a61 100644
--- a/packages/cli/src/plugins/resource/plugin-node-modules.js
+++ b/packages/cli/src/plugins/resource/plugin-node-modules.js
@@ -21,9 +21,19 @@ const updateImportMap = (entry, entryPath) => {
     entryPath = `${entryPath}.js`;
   }
 
-  importMap[entry] = entryPath;
+  // handle WIn v Unix-style path separators and force to /
+  importMap[entry.replace(/\\/g, '/')] = entryPath.replace(/\\/g, '/');
 };
 
+// handle ESM paths that have varying levels of nesting, e.g. export * from '../../something.js'
+// https://github.com/ProjectEvergreen/greenwood/issues/820
+async function resolveRelativeSpecifier(specifier, modulePath, dependency) {
+  const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency);
+
+  // handle WIn v Unix-style path separators and force to /
+  return `${dependency}${path.join(path.dirname(modulePath), specifier).replace(/\\/g, '/').replace(absoluteNodeModulesLocation.replace(/\\/g, '/', ''), '')}`;
+}
+
 const getPackageEntryPath = async (packageJson) => {
   let entry = packageJson.exports
     ? Object.keys(packageJson.exports) // first favor export maps first
@@ -41,58 +51,64 @@ const getPackageEntryPath = async (packageJson) => {
   return entry;
 };
 
-const walkModule = async (module, dependency) => {
-  walk.simple(acorn.parse(module, {
+const walkModule = async (modulePath, dependency) => {
+  const moduleContents = fs.readFileSync(modulePath, 'utf-8');
+
+  walk.simple(acorn.parse(moduleContents, {
     ecmaVersion: '2020',
     sourceType: 'module'
   }), {
     async ImportDeclaration(node) {
       let { value: sourceValue } = node.source;
       const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency);
+      const isBarePath = sourceValue.indexOf('http') !== 0 && sourceValue.charAt(0) !== '.' && sourceValue.charAt(0) !== path.sep;
+      const hasExtension = path.extname(sourceValue) !== '';
 
-      if (path.extname(sourceValue) === '' && sourceValue.indexOf('http') !== 0 && sourceValue.indexOf('./') < 0) {        
+      if (isBarePath && !hasExtension) {
         if (!importMap[sourceValue]) {
-          // found a _new_ bare import for ${sourceValue}
-          // we should add this to the importMap and walk its package.json for more transitive deps
           updateImportMap(sourceValue, `/node_modules/${sourceValue}`);
         }
         
         await walkPackageJson(path.join(absoluteNodeModulesLocation, 'package.json'));
-      } else if (sourceValue.indexOf('./') < 0) {
-        // adding a relative import
+      } else if (isBarePath) {
         updateImportMap(sourceValue, `/node_modules/${sourceValue}`);
       } else {
         // walk this module for all its dependencies
-        sourceValue = sourceValue.indexOf('.js') < 0
+        sourceValue = !hasExtension
           ? `${sourceValue}.js`
           : sourceValue;
 
         if (fs.existsSync(path.join(absoluteNodeModulesLocation, sourceValue))) {
-          const moduleContents = fs.readFileSync(path.join(absoluteNodeModulesLocation, sourceValue));
-          await walkModule(moduleContents, dependency);
-          updateImportMap(`${dependency}/${sourceValue.replace('./', '')}`, `/node_modules/${dependency}/${sourceValue.replace('./', '')}`);
+          const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`;
+          await walkModule(path.join(absoluteNodeModulesLocation, sourceValue), dependency);
+
+          updateImportMap(path.join(dependency, sourceValue), entry);
         }
       }
-
-      return Promise.resolve();
     },
-    ExportNamedDeclaration(node) {
+    async ExportNamedDeclaration(node) {
       const sourceValue = node && node.source ? node.source.value : '';
 
       if (sourceValue !== '' && sourceValue.indexOf('http') !== 0) {
+        // handle relative specifier
         if (sourceValue.indexOf('.') === 0) {
-          updateImportMap(`${dependency}/${sourceValue.replace('./', '')}`, `/node_modules/${dependency}/${sourceValue.replace('./', '')}`);
+          const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`;
+
+          updateImportMap(path.join(dependency, sourceValue), entry);
         } else {
+          // handle bare specifier
           updateImportMap(sourceValue, `/node_modules/${sourceValue}`);
         }
       }
     },
-    ExportAllDeclaration(node) {
+    async ExportAllDeclaration(node) {
       const sourceValue = node && node.source ? node.source.value : '';
 
       if (sourceValue !== '' && sourceValue.indexOf('http') !== 0) {
         if (sourceValue.indexOf('.') === 0) {
-          updateImportMap(`${dependency}/${sourceValue.replace('./', '')}`, `/node_modules/${dependency}/${sourceValue.replace('./', '')}`);
+          const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`;
+
+          updateImportMap(path.join(dependency, sourceValue), entry);
         } else {
           updateImportMap(sourceValue, `/node_modules/${sourceValue}`);
         }
@@ -107,7 +123,7 @@ const walkPackageJson = async (packageJson = {}) => {
   // and walk its package.json for its dependencies
 
   for (const dependency of Object.keys(packageJson.dependencies || {})) {
-    const dependencyPackageRootPath = path.join(process.cwd(), './node_modules', dependency);
+    const dependencyPackageRootPath = path.join(process.cwd(), 'node_modules', dependency);
     const dependencyPackageJsonPath = path.join(dependencyPackageRootPath, 'package.json');
     const dependencyPackageJson = JSON.parse(fs.readFileSync(dependencyPackageJsonPath, 'utf-8'));
     const entry = await getPackageEntryPath(dependencyPackageJson);
@@ -137,7 +153,7 @@ const walkPackageJson = async (packageJson = {}) => {
                   break;
                 case 'object':
                   const entryTypes = Object.keys(mapItem);
-  
+
                   if (entryTypes.import) {
                     esmPath = entryTypes.import;
                   } else if (entryTypes.require) {
@@ -165,31 +181,26 @@ const walkPackageJson = async (packageJson = {}) => {
             
             // use the dependency itself as an entry in the importMap
             if (entry === '.') {
-              updateImportMap(dependency, `/node_modules/${dependency}/${packageExport.replace('./', '')}`);
+              updateImportMap(dependency, `/node_modules/${path.join(dependency, packageExport)}`);
             }
           } else if (exportMapEntry.endsWith && (exportMapEntry.endsWith('.js') || exportMapEntry.endsWith('.mjs')) && exportMapEntry.indexOf('*') < 0) {
             // is probably a file, so _not_ an export array, package.json, or wildcard export
             packageExport = exportMapEntry;
           }
-  
+
           if (packageExport) {
-            const packageExportLocation = path.join(absoluteNodeModulesLocation, packageExport.replace('./', ''));
+            const packageExportLocation = path.resolve(absoluteNodeModulesLocation, packageExport);
 
-            // check all exports of an exportMap entry
-            // to make sure those deps get added to the importMap
             if (packageExport.endsWith('js')) {
-              const moduleContents = fs.readFileSync(packageExportLocation);
-
-              await walkModule(moduleContents, dependency);
-              updateImportMap(`${dependency}${entry.replace('.', '')}`, `/node_modules/${dependency}/${packageExport.replace('./', '')}`);
+              updateImportMap(path.join(dependency, entry), `/node_modules/${path.join(dependency, packageExport)}`);
             } else if (fs.lstatSync(packageExportLocation).isDirectory()) {
               fs.readdirSync(packageExportLocation)
                 .filter(file => file.endsWith('.js') || file.endsWith('.mjs'))
                 .forEach((file) => {
-                  updateImportMap(`${dependency}/${packageExport.replace('./', '')}${file}`, `/node_modules/${dependency}/${packageExport.replace('./', '')}${file}`);
+                  updateImportMap(path.join(dependency, packageExport, file), `/node_modules/${path.join(dependency, packageExport, file)}`);
                 });
             } else {
-              console.warn('Warning, not able to handle export', `${dependency}/${packageExport}`);
+              console.warn('Warning, not able to handle export', path.join(dependency, packageExport));
             }
           }
         }
@@ -200,10 +211,9 @@ const walkPackageJson = async (packageJson = {}) => {
 
         // sometimes a main file is actually just an empty string... :/
         if (fs.existsSync(packageEntryPointPath)) {
-          const packageEntryModule = fs.readFileSync(packageEntryPointPath, 'utf-8');
-    
-          await walkModule(packageEntryModule, dependency);
-          updateImportMap(dependency, `/node_modules/${dependency}/${entry}`);
+          updateImportMap(dependency, `/node_modules/${path.join(dependency, entry)}`);
+
+          await walkModule(packageEntryPointPath, dependency);
           await walkPackageJson(dependencyPackageJson);
         }
       }
diff --git a/yarn.lock b/yarn.lock
index 5fd2fca1..d21c527d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2481,6 +2481,11 @@
   resolved "https://registry.yarnpkg.com/@rollup/stream/-/stream-2.0.0.tgz#2ada818c2d042e37f63119d7bf8bbfc71792f641"
   integrity sha512-HsCyY/phZMys1zFUYoYlnDJGG9zMmYFfEjDKNQa00CYgjeyGD4cLdO6KNIkBh61AWOZfOsTPuGtNmFCsjQOfFg==
 
+"@stencil/core@^2.12.0":
+  version "2.12.0"
+  resolved "https://registry.yarnpkg.com/@stencil/core/-/core-2.12.0.tgz#5b12517dd367908026692d3b00fa1aab39638ab9"
+  integrity sha512-hQlQKh5CUJe8g3L5avLLsfgVu95HMS2LToTtS7gpvvP0eKes1VvAC56uhI+vH4u44GZl9ck/g1rJBVRmMWu0LA==
+
 "@stylelint/postcss-css-in-js@^0.37.2":
   version "0.37.2"
   resolved "https://registry.yarnpkg.com/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.37.2.tgz#7e5a84ad181f4234a2480803422a47b8749af3d2"


Test patch:
diff --git a/packages/cli/test/cases/develop.default/develop.default.spec.js b/packages/cli/test/cases/develop.default/develop.default.spec.js
index 46ac1a45..4d715f70 100644
--- a/packages/cli/test/cases/develop.default/develop.default.spec.js
+++ b/packages/cli/test/cases/develop.default/develop.default.spec.js
@@ -342,6 +342,18 @@ describe('Develop Greenwood With: ', function() {
         `${process.cwd()}/node_modules/tslib/*.js`,
         `${outputPath}/node_modules/tslib/`
       );
+      const stencilCorePackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@stencil/core/package.json`,
+        `${outputPath}/node_modules/@stencil/core/`
+      );
+      const stencilCoreCoreLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@stencil/core/internal/stencil-core/*.js`,
+        `${outputPath}/node_modules/@stencil/core/internal/stencil-core/`
+      );
+      const stencilCoreClientLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@stencil/core/internal/client/*.js`,
+        `${outputPath}/node_modules/@stencil/core/internal/client/`
+      );
 
       // manually copy all these @babel/runtime files recursively since there are too many of them to do it individually
       const babelRuntimeLibs = await rreaddir(`${process.cwd()}/node_modules/@babel/runtime`);
@@ -437,7 +449,10 @@ describe('Develop Greenwood With: ', function() {
         ...materialThemePackageJson,
         ...materialThemeLibs,
         ...tslibPackageJson,
-        ...tslibLibs
+        ...tslibLibs,
+        ...stencilCorePackageJson,
+        ...stencilCoreCoreLibs,
+        ...stencilCoreClientLibs
       ]);
 
       return new Promise(async (resolve) => {
diff --git a/packages/cli/test/cases/develop.default/import-map.snapshot.json b/packages/cli/test/cases/develop.default/import-map.snapshot.json
index 7f37c15f..ad256ef3 100644
--- a/packages/cli/test/cases/develop.default/import-map.snapshot.json
+++ b/packages/cli/test/cases/develop.default/import-map.snapshot.json
@@ -166,11 +166,8 @@
   "@lion/button": "/node_modules/@lion/button/index.js",
   "@lion/core": "/node_modules/@lion/core/index.js",
   "@lion/core/differentKeyEventNamesShimIE": "/node_modules/@lion/core/src/differentKeyEventNamesShimIE.js",
-  "@lion/button/src/LionButton.js": "/node_modules/@lion/button/src/LionButton.js",
   "@lion/button/define-button": "/node_modules/@lion/button/lion-button.js",
-  "@lion/button/src/LionButtonReset.js": "/node_modules/@lion/button/src/LionButtonReset.js",
   "@lion/button/define-button-reset": "/node_modules/@lion/button/lion-button-reset.js",
-  "@lion/button/src/LionButtonSubmit.js": "/node_modules/@lion/button/src/LionButtonSubmit.js",
   "@lion/button/define-button-submit": "/node_modules/@lion/button/lion-button-submit.js",
   "@lion/button/define": "/node_modules/@lion/button/define.js",
   "lit": "/node_modules/lit/index.js",
@@ -258,17 +255,16 @@
   "lit-element/decorators/state.js": "/node_modules/lit-element/decorators/state.js",
   "lit-element/polyfill-support.js": "/node_modules/lit-element/polyfill-support.js",
   "lit-element/private-ssr-support.js": "/node_modules/lit-element/private-ssr-support.js",
-  "lit-html/lit-html.js": "/node_modules/lit-html/lit-html.js",
   "lit-html/polyfill-support.js": "/node_modules/lit-html/polyfill-support.js",
   "lit-html/private-ssr-support.js": "/node_modules/lit-html/private-ssr-support.js",
   "@lion/calendar": "/node_modules/@lion/calendar/index.js",
   "@lion/localize": "/node_modules/@lion/localize/index.js",
-  "@lion/calendar/src/LionCalendar.js": "/node_modules/@lion/calendar/src/LionCalendar.js",
   "@lion/calendar/define": "/node_modules/@lion/calendar/lion-calendar.js",
   "@lion/calendar/test-helpers": "/node_modules/@lion/calendar/test-helpers/index.js",
   "@lion/localize/test-helpers": "/node_modules/@lion/localize/test-helpers/index.js",
   "@bundled-es-modules/message-format/MessageFormat.js": "/node_modules/@bundled-es-modules/message-format/MessageFormat.js",
   "@bundled-es-modules/message-format": "/node_modules/@bundled-es-modules/message-format/index.js",
-  "singleton-manager/src/SingletonManagerClass.js": "/node_modules/singleton-manager/src/SingletonManagerClass.js",
-  "singleton-manager": "/node_modules/singleton-manager/index.js"
+  "singleton-manager": "/node_modules/singleton-manager/index.js",
+  "@stencil/core": "/node_modules/@stencil/core/internal/stencil-core/index.js",
+  "@stencil/client/index.js": "/node_modules/@stencil/core/internal/client/index.js"
 }
\ No newline at end of file
diff --git a/packages/cli/test/cases/develop.default/package.json b/packages/cli/test/cases/develop.default/package.json
index d148bd15..cb0f93ac 100644
--- a/packages/cli/test/cases/develop.default/package.json
+++ b/packages/cli/test/cases/develop.default/package.json
@@ -6,6 +6,7 @@
     "@lion/button": "^0.14.3",
     "@lion/calendar": "^0.16.5",
     "@material/mwc-button": "^0.25.2",
+    "@stencil/core": "^2.12.0",
     "@types/trusted-types": "^2.0.2",
     "lit": "^2.0.0"
   }