diff --git a/.eslintignore b/.eslintignore index e26596181..0d016f6ef 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,6 +30,8 @@ /packages/vite/index.d.ts /packages/vite/**/*.js /packages/vite/**/*.d.ts +/packages/reverse-exports/**/*.js +/packages/reverse-exports/**/*.d.ts # unconventional js diff --git a/.prettierignore b/.prettierignore index cac4a26a7..e6bdb9ec3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -37,6 +37,8 @@ /packages/vite/index.mjs /packages/vite/**/*.js /packages/vite/**/*.d.ts +/packages/reverse-exports/**/*.js +/packages/reverse-exports/**/*.d.ts # unconventional js /blueprints/*/files/ diff --git a/packages/reverse-exports/.gitignore b/packages/reverse-exports/.gitignore new file mode 100644 index 000000000..ee7918ebe --- /dev/null +++ b/packages/reverse-exports/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/src/**/*.js +/src/**/*.d.ts +/src/**/*.map +/tests/**/*.js +/tests/**/*.d.ts +/tests/**/*.map diff --git a/packages/reverse-exports/jest.config.js b/packages/reverse-exports/jest.config.js new file mode 100644 index 000000000..cdaa1f960 --- /dev/null +++ b/packages/reverse-exports/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['/tests/**/*.test.js'], +}; diff --git a/packages/reverse-exports/package.json b/packages/reverse-exports/package.json new file mode 100644 index 000000000..e31c00e06 --- /dev/null +++ b/packages/reverse-exports/package.json @@ -0,0 +1,19 @@ +{ + "name": "@embroider/reverse-exports", + "version": "0.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "test": "jest" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/minimatch": "^3.0.4" + }, + "dependencies": { + "minimatch": "^3.0.4", + "resolve.exports": "^2.0.2" + } +} diff --git a/packages/reverse-exports/src/index.ts b/packages/reverse-exports/src/index.ts new file mode 100644 index 000000000..09ccc3ca6 --- /dev/null +++ b/packages/reverse-exports/src/index.ts @@ -0,0 +1,116 @@ +import { posix } from 'path'; +import minimatch from 'minimatch'; +import { exports as resolveExports } from 'resolve.exports'; + +type Exports = string | string[] | { [key: string]: Exports }; + +/** + * An util to find a string value in a nested JSON-like structure. + * + * Receives an object (a netsted JSON-like structure) and a matcher callback + * that is tested against each string value. + * + * When a value is found, returns an object containing a `value` and a `key`. + * The key is one of the parent keys of the found value — the one that starts + * with `.`. + * + * When a value is not found, returns `undefined`. + */ +export function _findPathRecursively( + exportsObj: Exports, + matcher: (path: string) => boolean, + key = '.' +): { key: string; value: Exports } | undefined { + if (typeof exportsObj === 'string') { + return matcher(exportsObj) ? { key, value: exportsObj } : undefined; + } + + if (Array.isArray(exportsObj)) { + const value = exportsObj.find(path => matcher(path)); + + if (value) { + return { key, value }; + } else { + return undefined; + } + } + + if (typeof exportsObj === 'object') { + let result: { key: string; value: Exports } | undefined = undefined; + + for (const candidateKey in exportsObj) { + if (!exportsObj.hasOwnProperty(candidateKey)) { + return; + } + + const candidate = _findPathRecursively(exportsObj[candidateKey], matcher, key); + + if (candidate) { + result = { + key: candidateKey, + value: candidate.value, + }; + + break; + } + } + + if (result) { + if (result.key.startsWith('./')) { + if (key !== '.') { + throw new Error(`exportsObj contains doubly nested path keys: "${key}" and "${result.key}"`); + } + + return { key: result.key, value: result.value }; + } else { + return { key, value: result.value }; + } + } else { + return undefined; + } + } + + throw new Error(`Unexpected type of obj: ${typeof exportsObj}`); +} + +export default function reversePackageExports( + { exports: exportsObj, name }: { exports?: Exports; name: string }, + relativePath: string +): string { + if (!exportsObj) { + return posix.join(name, relativePath); + } + + const maybeKeyValuePair = _findPathRecursively(exportsObj, candidate => { + // miminatch does not treat directories as full of content without glob + if (candidate.endsWith('/')) { + candidate += '**'; + } + + return minimatch(relativePath, candidate); + }); + + if (!maybeKeyValuePair) { + throw new Error( + `You tried to reverse exports for the file \`${relativePath}\` in package \`${name}\` but it does not match any of the exports rules defined in package.json. This means it should not be possible to access directly.` + ); + } + + const { key, value } = maybeKeyValuePair; + + if (typeof value !== 'string') { + throw new Error('Expected value to be a string'); + } + + const maybeResolvedPaths = resolveExports({ name, exports: { [value]: key } }, relativePath); + + if (!maybeResolvedPaths) { + throw new Error( + `Bug Discovered! \`_findPathRecursively()\` must always return a string value but instead it found a ${typeof value}. Please report this as an issue to https://github.com/embroider-build/embroider/issues/new` + ); + } + + const [resolvedPath] = maybeResolvedPaths; + + return resolvedPath.replace(/^./, name); +} diff --git a/packages/reverse-exports/tests/reverse-exports.test.ts b/packages/reverse-exports/tests/reverse-exports.test.ts new file mode 100644 index 000000000..de0892956 --- /dev/null +++ b/packages/reverse-exports/tests/reverse-exports.test.ts @@ -0,0 +1,262 @@ +import reversePackageExports, { _findPathRecursively } from '../src'; + +describe('reverse exports', function () { + it('exports is missing', function () { + expect(reversePackageExports({ name: 'best-addon' }, './dist/_app_/components/face.js')).toBe( + 'best-addon/dist/_app_/components/face.js' + ); + }); + + it('exports is a string', function () { + const actual = reversePackageExports( + { + name: 'my-addon', + exports: './foo.js', + }, + './foo.js' + ); + expect(actual).toBe('my-addon'); + }); + + it('exports is an object with one entry', function () { + const actual = reversePackageExports( + { + name: 'my-addon', + exports: { + '.': './foo.js', + }, + }, + './foo.js' + ); + expect(actual).toBe('my-addon'); + }); + + it('subpath exports', function () { + const packageJson = { + name: 'my-addon', + exports: { + '.': './main.js', + './sub/path': './secondary.js', + './prefix/': './directory/', + './prefix/deep/': './other-directory/', + './other-prefix/*': './yet-another/*/*.js', + './glob/*': './grod/**/*.js', + }, + }; + expect(reversePackageExports(packageJson, './main.js')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './secondary.js')).toBe('my-addon/sub/path'); + expect(reversePackageExports(packageJson, './directory/some/file.js')).toBe('my-addon/prefix/some/file.js'); + expect(reversePackageExports(packageJson, './other-directory/file.js')).toBe('my-addon/prefix/deep/file.js'); + expect(reversePackageExports(packageJson, './yet-another/deep/file.js')).toBe('my-addon/other-prefix/deep/file'); + expect(reversePackageExports(packageJson, './grod/very/deep/file.js')).toBe('my-addon/glob/very/deep/file'); + }); + + it('alternative exports', function () { + const packageJson = { + name: 'my-addon', + exports: { + './things/': ['./good-things/', './bad-things/'], + }, + }; + expect(reversePackageExports(packageJson, './good-things/apple.js')).toBe('my-addon/things/apple.js'); + expect(reversePackageExports(packageJson, './bad-things/apple.js')).toBe('my-addon/things/apple.js'); + }); + + it('conditional exports - simple abbreviated', function () { + const packageJson = { + name: 'my-addon', + exports: { + import: './index-module.js', + require: './index-require.cjs', + default: './index.js', + }, + }; + expect(reversePackageExports(packageJson, './index-module.js')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './index-require.cjs')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './index.js')).toBe('my-addon'); + }); + + it('conditional exports - simple non-abbreviated', function () { + const packageJson = { + name: 'my-addon', + exports: { + '.': { + import: './index-module.js', + require: './index-require.cjs', + default: './index.js', + }, + }, + }; + expect(reversePackageExports(packageJson, './index-module.js')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './index-require.cjs')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './index.js')).toBe('my-addon'); + }); + + it('conditional subpath exports', function () { + const packageJson = { + name: 'my-addon', + exports: { + '.': './index.js', + './feature.js': { + node: './feature-node.cjs', + default: './feature.js', + }, + }, + }; + expect(reversePackageExports(packageJson, './index.js')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './feature-node.cjs')).toBe('my-addon/feature.js'); + expect(reversePackageExports(packageJson, './feature.js')).toBe('my-addon/feature.js'); + }); + + it('nested conditional exports', function () { + const packageJson = { + name: 'my-addon', + exports: { + node: { + import: './feature-node.mjs', + require: './feature-node.cjs', + }, + default: './feature.mjs', + }, + }; + expect(reversePackageExports(packageJson, './feature-node.mjs')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './feature-node.cjs')).toBe('my-addon'); + expect(reversePackageExports(packageJson, './feature.mjs')).toBe('my-addon'); + }); + + it('should throw when no exports entry is matching', function () { + const packageJson = { + name: 'my-addon', + exports: { + node: { + import: './feature-node.mjs', + require: './feature-node.cjs', + }, + default: './feature.mjs', + }, + }; + + expect(() => reversePackageExports(packageJson, './foo.bar')).toThrow( + 'You tried to reverse exports for the file `./foo.bar` in package `my-addon` but it does not match any of the exports rules defined in package.json. This means it should not be possible to access directly.' + ); + }); +}); + +describe('_findKeyRecursively', function () { + it('Returns "." when string is provided and matcher is matching', function () { + expect(_findPathRecursively('foo', str => str === 'foo')).toStrictEqual({ key: '.', value: 'foo' }); + }); + + it('Returns undefined when string is provided and matcher is not matching', function () { + expect(_findPathRecursively('foo', str => str === 'bar')).toBe(undefined); + }); + + it('Returns "." when array is provided and matcher is matching', function () { + expect(_findPathRecursively(['foo', 'bar'], str => str === 'bar')).toStrictEqual({ key: '.', value: 'bar' }); + }); + + it('Returns undefined when array is provided and matcher is not matching', function () { + expect(_findPathRecursively(['foo', 'bar'], str => str === 'baz')).toBe(undefined); + }); + + it('Returns a matching key when a record of valid paths is provided and matcher is matching', function () { + const exports = { + '.': './main.js', + './sub/path': './secondary.js', + './prefix/': './directory/', + './prefix/deep/': './other-directory/', + './other-prefix/*': './yet-another/*/*.js', + './glob/*': './grod/**/*.js', + }; + + expect(_findPathRecursively(exports, str => str === './secondary.js')).toStrictEqual({ + key: './sub/path', + value: './secondary.js', + }); + }); + + it('Returns undefined when a record of valid paths is provided and matcher is not matching', function () { + const exports = { + '.': './main.js', + './sub/path': './secondary.js', + './prefix/': './directory/', + './prefix/deep/': './other-directory/', + './other-prefix/*': './yet-another/*/*.js', + './glob/*': './grod/**/*.js', + }; + + expect(_findPathRecursively(exports, str => str === './non-existent-path')).toBe(undefined); + }); + + it('Returns a matching key when a record of arrays is provided and matcher is matching', function () { + const exports = { + './foo': ['./bar', './baz'], + './zomg': ['./lol', './wtf'], + }; + + expect(_findPathRecursively(exports, str => str === './lol')).toStrictEqual({ key: './zomg', value: './lol' }); + }); + + it('Returns undefined when a record of arrays is provided and matcher is not matching', function () { + const exports = { + './foo': ['./bar', './baz'], + './zomg': ['./lol', './wtf'], + }; + + expect(_findPathRecursively(exports, str => str === './rofl')).toBe(undefined); + }); + + it('Returns a matching key when a record of conditions with paths is provided and matcher is matching', function () { + const exports = { + '.': './index.js', + './feature.js': { + node: './feature-node.js', + default: './feature.js', + }, + }; + + expect(_findPathRecursively(exports, str => str === './feature-node.js')).toStrictEqual({ + key: './feature.js', + value: './feature-node.js', + }); + }); + + it('Returns undefined when a record of conditions with paths is provided and matcher is not matching', function () { + const exports = { + '.': './index.js', + './feature.js': { + node: './feature-node.js', + default: './feature.js', + }, + }; + + expect(_findPathRecursively(exports, str => str === './missing-path.js')).toBe(undefined); + }); + + it('Returns a matching key when a record of conditions withithout paths is provided and matcher is matching', function () { + const exports = { + node: { + import: './feature-node.mjs', + require: './feature-node.cjs', + }, + default: './feature.mjs', + }; + + expect(_findPathRecursively(exports, str => str === './feature-node.cjs')).toStrictEqual({ + key: '.', + value: './feature-node.cjs', + }); + }); + + it('Returns undefined when a record of conditions without paths is provided and matcher is not matching', function () { + const exports = { + node: { + import: './feature-node.mjs', + require: './feature-node.cjs', + }, + default: './feature.mjs', + }; + + expect(_findPathRecursively(exports, str => str === './missing-path.js')).toBe(undefined); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee2846e88..0ed21d239 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,19 @@ importers: specifier: ^5.1.6 version: 5.2.2 + packages/reverse-exports: + dependencies: + minimatch: + specifier: ^3.0.4 + version: 3.1.2 + resolve.exports: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@types/minimatch': + specifier: ^3.0.4 + version: 3.0.5 + packages/router: dependencies: '@ember/test-waiters': @@ -19629,7 +19642,6 @@ packages: /resolve.exports@2.0.2: resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} engines: {node: '>=10'} - dev: true /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}