feat(providence-analytics): add export-map functionality to InputDataService

This commit is contained in:
Thijs Louisse 2022-09-14 16:39:04 +02:00 committed by Thijs Louisse
parent b568bcd1dd
commit 9593c45695
4 changed files with 429 additions and 122 deletions

View file

@ -0,0 +1,5 @@
---
'providence-analytics': patch
---
providence-analytics: add export-map functionality to InputDataService

View file

@ -220,7 +220,9 @@ async function cli({ cwd, providenceConf } = {}) {
(a dependency installed via npm) or a git repository, different paths will be (a dependency installed via npm) or a git repository, different paths will be
automatically put in the appropiate mode. automatically put in the appropiate mode.
A mode of 'npm' will look at the package.json "files" entry and a mode of A mode of 'npm' will look at the package.json "files" entry and a mode of
'git' will look at '.gitignore' entry. The mode will be auto detected, but can be overridden 'git' will look at '.gitignore' entry. A mode of 'export-map' will look for all paths
exposed via an export map.
The mode will be auto detected, but can be overridden
via this option.`, via this option.`,
) )
.option( .option(

View file

@ -205,6 +205,33 @@ function multiGlobSync(patterns, { keepDirs = false, root } = {}) {
return Array.from(res); return Array.from(res);
} }
function stripDotSlashFromLocalPath(localPathWithDotSlash) {
return localPathWithDotSlash.replace(/^\.\//, '');
}
function normalizeLocalPathWithDotSlash(localPathWithoutDotSlash) {
if (!localPathWithoutDotSlash.startsWith('.')) {
return `./${localPathWithoutDotSlash}`;
}
return localPathWithoutDotSlash;
}
/**
* @param {{val:object|string;nodeResolveMode:string}} opts
* @returns {string}
*/
function getStringOrObjectValOfExportMapEntry({ val, nodeResolveMode, packageRootPath }) {
if (typeof val !== 'object') {
return val;
}
if (!val[nodeResolveMode]) {
throw new Error(
`[getExportMapExports]: nodeResolveMode "${nodeResolveMode}" not found in package.json of package ${packageRootPath}`,
);
}
return val[nodeResolveMode];
}
/** /**
* To be used in main program. * To be used in main program.
* It creates an instance on which the 'files' array is stored. * It creates an instance on which the 'files' array is stored.
@ -215,22 +242,30 @@ function multiGlobSync(patterns, { keepDirs = false, root } = {}) {
class InputDataService { class InputDataService {
/** /**
* Create an array of ProjectData * Create an array of ProjectData
* @param {PathFromSystemRoot[]} projectPaths * @param {PathFromSystemRoot | ProjectInputData []} projectPaths
* @param {Partial<GatherFilesConfig>} gatherFilesConfig * @param {Partial<GatherFilesConfig>} gatherFilesConfig
* @returns {ProjectInputData[]} * @returns {ProjectInputData[]}
*/ */
static createDataObject(projectPaths, gatherFilesConfig = {}) { static createDataObject(projectPaths, gatherFilesConfig = {}) {
/** @type {ProjectInputData[]} */ /** @type {ProjectInputData[]} */
const inputData = projectPaths.map(projectPath => ({ const inputData = projectPaths.map(projectPathOrObj => {
project: /** @type {Project} */ ({ if (typeof projectPathOrObj === 'object') {
name: pathLib.basename(projectPath), // ProjectInputData was provided already manually
path: projectPath, return projectPathOrObj;
}), }
entries: this.gatherFilesFromDir(projectPath, {
...this.defaultGatherFilesConfig, const projectPath = projectPathOrObj;
...gatherFilesConfig, return {
}), project: /** @type {Project} */ ({
})); name: pathLib.basename(projectPath),
path: projectPath,
}),
entries: this.gatherFilesFromDir(projectPath, {
...this.defaultGatherFilesConfig,
...gatherFilesConfig,
}),
};
});
// @ts-ignore // @ts-ignore
return this._addMetaToProjectsData(inputData); return this._addMetaToProjectsData(inputData);
} }
@ -456,7 +491,8 @@ class InputDataService {
...(customConfig.allowlist || []), ...(customConfig.allowlist || []),
]; ];
} }
const allowlistModes = ['npm', 'git', 'all'];
const allowlistModes = ['npm', 'git', 'all', 'export-map'];
if (customConfig.allowlistMode && !allowlistModes.includes(customConfig.allowlistMode)) { if (customConfig.allowlistMode && !allowlistModes.includes(customConfig.allowlistMode)) {
throw new Error( throw new Error(
`[gatherFilesConfig] Please provide a valid allowListMode like "${allowlistModes.join( `[gatherFilesConfig] Please provide a valid allowListMode like "${allowlistModes.join(
@ -465,6 +501,15 @@ class InputDataService {
); );
} }
if (cfg.allowlistMode === 'export-map') {
const pkgJson = getPackageJson(startPath);
if (!pkgJson.exports) {
LogService.error(`No exports found in package.json of ${startPath}`);
}
const exposedAndInternalPaths = this.getPathsFromExportMap(pkgJson.exports, { packageRootPath: startPath });
return exposedAndInternalPaths.map(p => p.internal);
}
/** @type {string[]} */ /** @type {string[]} */
let gitIgnorePaths = []; let gitIgnorePaths = [];
/** @type {string[]} */ /** @type {string[]} */
@ -564,6 +609,62 @@ class InputDataService {
// TODO: support forward compatibility for npm? // TODO: support forward compatibility for npm?
return undefined; return undefined;
} }
/**
* @param {object} exports
* @param {object} opts
* @param {'default'|'development'|string} [opts.nodeResolveMode='default']
* @param {string} opts.packageRootPath
* @returns {Promise<{internalExportMapPaths:string[]; exposedExportMapPaths:string[]}>}
*/
static getPathsFromExportMap(exports, { nodeResolveMode = 'default', packageRootPath }) {
const exportMapPaths = [];
for (const [key, val] of Object.entries(exports)) {
if (!key.includes('*')) {
exportMapPaths.push({
internal: getStringOrObjectValOfExportMapEntry({ val, nodeResolveMode, packageRootPath }),
exposed: key,
});
// eslint-disable-next-line no-continue
continue;
}
const valueToUseForGlob = stripDotSlashFromLocalPath(
getStringOrObjectValOfExportMapEntry({ val, nodeResolveMode, packageRootPath }),
);
// Generate all possible entries via glob, first strip './'
const internalExportMapPathsForKeyRaw = glob.sync(valueToUseForGlob, {
cwd: packageRootPath,
});
const exposedExportMapPathsForKeyRaw = internalExportMapPathsForKeyRaw.map(pathInside => {
// Say we have "exports": { "./*.js": "./src/*.js" }
// => internalExportMapPathsForKey: ['./src/a.js', './src/b.js']
// => exposedExportMapPathsForKey: ['./a.js', './b.js']
const [, variablePart] = pathInside.match(
new RegExp(valueToUseForGlob.replace('*', '(.*)')),
);
return key.replace('*', variablePart);
});
const internalExportMapPathsForKey = internalExportMapPathsForKeyRaw.map(filePath =>
normalizeLocalPathWithDotSlash(filePath),
);
const exposedExportMapPathsForKey = exposedExportMapPathsForKeyRaw.map(filePath =>
normalizeLocalPathWithDotSlash(filePath),
);
exportMapPaths.push(
...internalExportMapPathsForKey.map((internal, idx) => ({
internal,
exposed: exposedExportMapPathsForKey[idx],
})),
);
}
return exportMapPaths;
}
} }
InputDataService.cacheDisabled = false; InputDataService.cacheDisabled = false;

View file

@ -1,9 +1,10 @@
const { expect } = require('chai'); const { expect } = require('chai');
const pathLib = require('path'); const pathLib = require('path');
const { InputDataService } = require('../../../src/program/services/InputDataService.js'); const { InputDataService } = require('../../../src/index.js');
const { const {
restoreMockedProjects, restoreMockedProjects,
mockProject, mockProject,
mock,
} = require('../../../test-helpers/mock-project-helpers.js'); } = require('../../../test-helpers/mock-project-helpers.js');
function restoreOriginalInputDataPaths() { function restoreOriginalInputDataPaths() {
@ -278,34 +279,94 @@ describe('InputDataService', () => {
expect(globOutput).to.eql(['/fictional/project/index.js']); expect(globOutput).to.eql(['/fictional/project/index.js']);
}); });
it('filters npm "files" entries when allowlistMode is "npm"', async () => { describe('AllowlistMode', () => {
mockProject({ it('autodetects allowlistMode', async () => {
'./docs/x.js': '', mockProject({
'./src/y.js': '', './dist/bundle.js': '',
'./file.add.js': '', './package.json': JSON.stringify({
'./omit.js': '', files: ['dist'],
'./package.json': JSON.stringify({ }),
files: ['*.add.js', 'docs', 'src'], '.gitignore': '/dist',
}), });
}); const globOutput = InputDataService.gatherFilesFromDir('/fictional/project');
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', { expect(globOutput).to.eql([
allowlistMode: 'npm', // This means allowlistMode is 'git'
}); ]);
expect(globOutput).to.eql([
'/fictional/project/docs/x.js',
'/fictional/project/file.add.js',
'/fictional/project/src/y.js',
]);
});
it('filters .gitignore entries when allowlistMode is "git"', async () => { restoreOriginalInputDataPaths();
mockProject({ restoreMockedProjects();
'./coverage/file.js': '',
'./storybook-static/index.js': '', mockProject({
'./build/index.js': '', './dist/bundle.js': '',
'./shall/pass.js': '', './package.json': JSON.stringify({
'./keep/it.js': '', files: ['dist'],
'.gitignore': ` }),
});
const globOutput2 = InputDataService.gatherFilesFromDir('/fictional/project');
expect(globOutput2).to.eql([
// This means allowlistMode is 'npm'
'/fictional/project/dist/bundle.js',
]);
mockProject(
{ './dist/bundle.js': '', '.gitignore': '/dist' },
{
projectName: 'detect-as-npm',
projectPath: '/inside/proj/with/node_modules/detect-as-npm',
},
);
const globOutput3 = InputDataService.gatherFilesFromDir(
'/inside/proj/with/node_modules/detect-as-npm',
);
expect(globOutput3).to.eql([
// This means allowlistMode is 'npm' (even though we found .gitignore)
'/inside/proj/with/node_modules/detect-as-npm/dist/bundle.js',
]);
mockProject(
{ './dist/bundle.js': '', '.gitignore': '/dist' },
{
projectName: '@scoped/detect-as-npm',
projectPath: '/inside/proj/with/node_modules/@scoped/detect-as-npm',
},
);
const globOutput4 = InputDataService.gatherFilesFromDir(
'/inside/proj/with/node_modules/@scoped/detect-as-npm',
);
expect(globOutput4).to.eql([
// This means allowlistMode is 'npm' (even though we found .gitignore)
'/inside/proj/with/node_modules/@scoped/detect-as-npm/dist/bundle.js',
]);
});
it('filters npm "files" entries when allowlistMode is "npm"', async () => {
mockProject({
'./docs/x.js': '',
'./src/y.js': '',
'./file.add.js': '',
'./omit.js': '',
'./package.json': JSON.stringify({
files: ['*.add.js', 'docs', 'src'],
}),
});
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', {
allowlistMode: 'npm',
});
expect(globOutput).to.eql([
'/fictional/project/docs/x.js',
'/fictional/project/file.add.js',
'/fictional/project/src/y.js',
]);
});
it('filters .gitignore entries when allowlistMode is "git"', async () => {
mockProject({
'./coverage/file.js': '',
'./storybook-static/index.js': '',
'./build/index.js': '',
'./shall/pass.js': '',
'./keep/it.js': '',
'.gitignore': `
/coverage /coverage
# comment # comment
/storybook-static/ /storybook-static/
@ -313,93 +374,51 @@ describe('InputDataService', () => {
build/ build/
!keep/ !keep/
`, `,
});
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', {
allowlistMode: 'git',
});
expect(globOutput).to.eql([
'/fictional/project/keep/it.js',
'/fictional/project/shall/pass.js',
]);
}); });
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', {
allowlistMode: 'git',
});
expect(globOutput).to.eql([
'/fictional/project/keep/it.js',
'/fictional/project/shall/pass.js',
]);
});
it('filters no entries when allowlistMode is "all"', async () => { it('filters no entries when allowlistMode is "all"', async () => {
mockProject({ mockProject({
'./dist/bundle.js': '', './dist/bundle.js': '',
'./src/file.js': '', './src/file.js': '',
'./package.json': JSON.stringify({ './package.json': JSON.stringify({
files: ['dist', 'src'], files: ['dist', 'src'],
}), }),
'.gitignore': ` '.gitignore': `
/dist /dist
`, `,
});
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', {
allowlistMode: 'all',
});
expect(globOutput).to.eql([
'/fictional/project/dist/bundle.js',
'/fictional/project/src/file.js',
]);
}); });
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', {
allowlistMode: 'all', it('filters npm export map entries when allowlistMode is "export-map"', async () => {
mockProject({
'./internal/file.js': '',
'./non-exposed/file.js': '',
'./package.json': JSON.stringify({
exports: {
'./exposed/*': './internal/*',
},
}),
});
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project', {
allowlistMode: 'export-map',
});
expect(globOutput).to.eql(['./internal/file.js']);
}); });
expect(globOutput).to.eql([
'/fictional/project/dist/bundle.js',
'/fictional/project/src/file.js',
]);
});
it('autodetects allowlistMode', async () => {
mockProject({
'./dist/bundle.js': '',
'./package.json': JSON.stringify({
files: ['dist'],
}),
'.gitignore': '/dist',
});
const globOutput = InputDataService.gatherFilesFromDir('/fictional/project');
expect(globOutput).to.eql([
// This means allowlistMode is 'git'
]);
restoreOriginalInputDataPaths();
restoreMockedProjects();
mockProject({
'./dist/bundle.js': '',
'./package.json': JSON.stringify({
files: ['dist'],
}),
});
const globOutput2 = InputDataService.gatherFilesFromDir('/fictional/project');
expect(globOutput2).to.eql([
// This means allowlistMode is 'npm'
'/fictional/project/dist/bundle.js',
]);
mockProject(
{ './dist/bundle.js': '', '.gitignore': '/dist' },
{
projectName: 'detect-as-npm',
projectPath: '/inside/proj/with/node_modules/detect-as-npm',
},
);
const globOutput3 = InputDataService.gatherFilesFromDir(
'/inside/proj/with/node_modules/detect-as-npm',
);
expect(globOutput3).to.eql([
// This means allowlistMode is 'npm' (even though we found .gitignore)
'/inside/proj/with/node_modules/detect-as-npm/dist/bundle.js',
]);
mockProject(
{ './dist/bundle.js': '', '.gitignore': '/dist' },
{
projectName: '@scoped/detect-as-npm',
projectPath: '/inside/proj/with/node_modules/@scoped/detect-as-npm',
},
);
const globOutput4 = InputDataService.gatherFilesFromDir(
'/inside/proj/with/node_modules/@scoped/detect-as-npm',
);
expect(globOutput4).to.eql([
// This means allowlistMode is 'npm' (even though we found .gitignore)
'/inside/proj/with/node_modules/@scoped/detect-as-npm/dist/bundle.js',
]);
}); });
it('custom "allowlist" will take precedence over "allowlistMode"', async () => { it('custom "allowlist" will take precedence over "allowlistMode"', async () => {
@ -457,5 +476,185 @@ build/
}); });
}); });
}); });
describe('"getPathsFromExportMap"', () => {
it('gets "internalExportMapPaths", "exposedExportMapPaths"', async () => {
const fakeFs = {
'/my/proj/internal-path.js': 'export const x = 0;',
'/my/proj/internal/folder-a/path.js': 'export const a = 1;',
'/my/proj/internal/folder-b/path.js': 'export const b = 2;',
};
mock(fakeFs);
const exports = {
'./exposed-path.js': './internal-path.js',
'./external/*/path.js': './internal/*/path.js',
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './internal-path.js', exposed: './exposed-path.js' },
{ internal: './internal/folder-a/path.js', exposed: './external/folder-a/path.js' },
{ internal: './internal/folder-b/path.js', exposed: './external/folder-b/path.js' },
]);
});
it('supports 1-on-1 path maps in export map entry', async () => {
const fakeFs = {
'/my/proj/internal-path.js': 'export const x = 0;',
};
mock(fakeFs);
const exports = {
'./exposed-path.js': './internal-path.js',
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './internal-path.js', exposed: './exposed-path.js' },
]);
});
it('supports "./*" root mappings', async () => {
const fakeFs = {
'/my/proj/internal-exports-folder/file-a.js': 'export const x = 0;',
'/my/proj/internal-exports-folder/file-b.js': 'export const x = 0;',
'/my/proj/internal-exports-folder/file-c.js': 'export const x = 0;',
};
mock(fakeFs);
const exports = {
'./*': './internal-exports-folder/*',
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './internal-exports-folder/file-a.js', exposed: './file-a.js' },
{ internal: './internal-exports-folder/file-b.js', exposed: './file-b.js' },
{ internal: './internal-exports-folder/file-c.js', exposed: './file-c.js' },
]);
});
it('supports "*" on file level inside key and value of export map entry', async () => {
const fakeFs = {
'/my/proj/internal-folder/file-a.js': 'export const a = 1;',
'/my/proj/internal-folder/file-b.js': 'export const b = 2;',
};
mock(fakeFs);
const exports = {
'./exposed-folder/*.js': './internal-folder/*.js',
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './internal-folder/file-a.js', exposed: './exposed-folder/file-a.js' },
{ internal: './internal-folder/file-b.js', exposed: './exposed-folder/file-b.js' },
]);
});
it('supports "*" on folder level inside key and value of export map entry', async () => {
const fakeFs = {
'/my/proj/folder-a/file.js': 'export const a = 1;',
'/my/proj/folder-b/file.js': 'export const b = 2;',
};
mock(fakeFs);
const exports = {
// Hypothetical example that indicates the * can be placed everywhere
'./exposed-folder/*/file.js': './*/file.js',
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './folder-a/file.js', exposed: './exposed-folder/folder-a/file.js' },
{ internal: './folder-b/file.js', exposed: './exposed-folder/folder-b/file.js' },
]);
});
describe('ResolveMode', () => {
it('has nodeResolveMode "default" when nothing specified', async () => {
const fakeFs = {
'/my/proj/esm-exports/file.js': 'export const x = 0;',
'/my/proj/cjs-exports/file.cjs': 'export const x = 0;',
};
mock(fakeFs);
const exports = {
'./*': { default: './esm-exports/*', require: './cjs-exports/*' },
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './esm-exports/file.js', exposed: './file.js' },
]);
});
it('supports nodeResolveMode "require"', async () => {
const fakeFs = {
'/my/proj/esm-exports/file.js': 'export const x = 0;',
'/my/proj/cjs-exports/file.cjs': 'export const x = 0;',
};
mock(fakeFs);
const exports = {
'./*': { default: './esm-exports/*', require: './cjs-exports/*' },
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
nodeResolveMode: 'require',
});
expect(exportMapPaths).to.eql([
{ internal: './cjs-exports/file.cjs', exposed: './file.cjs' },
]);
});
it('supports other arbitrary nodeResolveModes (like "develop")', async () => {
const fakeFs = {
'/my/proj/esm-exports/file.js': 'export const x = 0;',
'/my/proj/develop-exports/file.js': 'export const x = 0;',
};
mock(fakeFs);
const exports = {
'./*': { default: './esm-exports/*', develop: './develop-exports/*' },
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
nodeResolveMode: 'develop',
});
expect(exportMapPaths).to.eql([
{ internal: './develop-exports/file.js', exposed: './file.js' },
]);
});
it('without "*" in key', async () => {
const fakeFs = {
'/my/proj/index.js': 'export const a = 1;',
'/my/proj/file.js': 'export const b = 2;',
};
mock(fakeFs);
const exports = {
'.': {
default: './index.js',
},
'./exposed-file.js': {
default: './file.js',
},
};
const exportMapPaths = await InputDataService.getPathsFromExportMap(exports, {
packageRootPath: '/my/proj',
});
expect(exportMapPaths).to.eql([
{ internal: './index.js', exposed: '.' },
{ internal: './file.js', exposed: './exposed-file.js' },
]);
});
});
});
}); });
}); });