Skip to content

Commit

Permalink
reverse exports: add implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
lolmaus authored and mansona committed Nov 20, 2023
1 parent b368673 commit af0a18b
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 37 deletions.
5 changes: 5 additions & 0 deletions packages/reverse-exports/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@types/minimatch": "^3.0.4"
},
"dependencies": {
"minimatch": "^3.0.4",
"resolve.exports": "^2.0.2"
}
}
114 changes: 108 additions & 6 deletions packages/reverse-exports/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +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(
packageJSON: { exports?: any; name: string },
{ exports: exportsObj, name }: { exports?: Exports; name: string },
relativePath: string
): string {
// TODO add an actual matching system and don't just look for the default
if (packageJSON.exports?.['./*'] === './dist/*.js') {
return posix.join(packageJSON.name, relativePath.replace(/^.\/dist\//, `./`).replace(/\.js$/, ''));
if (!exportsObj) {
return posix.join(name, relativePath);
}

// TODO figure out what the result should be if it doesn't match anything in exports
return posix.join(packageJSON.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);
}
171 changes: 142 additions & 29 deletions packages/reverse-exports/tests/reverse-exports.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,10 @@
import reversePackageExports from '../src';
import reversePackageExports, { _findPathRecursively } from '../src';

describe('reverse exports', function () {
it('correctly reversed exports', function () {
// TODO figure out what the result should be if it doesn't match anything in exports
it('exports is missing', function () {
expect(reversePackageExports({ name: 'best-addon' }, './dist/_app_/components/face.js')).toBe(
'best-addon/dist/_app_/components/face.js'
);

expect(
reversePackageExports(
{
name: 'best-addon',
exports: {
'./*': './dist/*.js',
},
},
'./dist/_app_/components/face.js'
)
).toBe('best-addon/_app_/components/face');
});

it('exports is a string', function () {
Expand All @@ -28,7 +15,6 @@ describe('reverse exports', function () {
},
'./foo.js'
);

expect(actual).toBe('my-addon');
});

Expand All @@ -42,7 +28,6 @@ describe('reverse exports', function () {
},
'./foo.js'
);

expect(actual).toBe('my-addon');
});

Expand All @@ -58,15 +43,12 @@ describe('reverse exports', function () {
'./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('addon/prefix/deep/file.js');

expect(reversePackageExports(packageJson, './yet-another/deep/file.js')).toBe('addon/other-prefix/deep/file');

expect(reversePackageExports(packageJson, './grod/very/deep/file.js')).toBe('addon/glob/very/deep/file');
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 () {
Expand All @@ -76,7 +58,6 @@ describe('reverse exports', function () {
'./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');
});
Expand All @@ -90,7 +71,6 @@ describe('reverse exports', function () {
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');
Expand All @@ -107,7 +87,6 @@ describe('reverse exports', function () {
},
},
};

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');
Expand All @@ -119,12 +98,11 @@ describe('reverse exports', function () {
exports: {
'.': './index.js',
'./feature.js': {
node: './feature-node.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');
Expand All @@ -141,9 +119,144 @@ describe('reverse exports', function () {
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);
});
});
Loading

0 comments on commit af0a18b

Please sign in to comment.