diff --git a/.changeset/moody-days-yawn.md b/.changeset/moody-days-yawn.md new file mode 100644 index 000000000..6bfd7bca2 --- /dev/null +++ b/.changeset/moody-days-yawn.md @@ -0,0 +1,5 @@ +--- +'providence-analytics': patch +--- + +providence-analytics: add export-map functionality to InputDataService diff --git a/packages-node/providence-analytics/src/cli/cli.js b/packages-node/providence-analytics/src/cli/cli.js index 66f2e83e5..f7748d8c4 100755 --- a/packages-node/providence-analytics/src/cli/cli.js +++ b/packages-node/providence-analytics/src/cli/cli.js @@ -220,7 +220,9 @@ async function cli({ cwd, providenceConf } = {}) { (a dependency installed via npm) or a git repository, different paths will be automatically put in the appropiate mode. 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.`, ) .option( diff --git a/packages-node/providence-analytics/src/program/services/InputDataService.js b/packages-node/providence-analytics/src/program/services/InputDataService.js index d0e645e68..14f22b1c9 100644 --- a/packages-node/providence-analytics/src/program/services/InputDataService.js +++ b/packages-node/providence-analytics/src/program/services/InputDataService.js @@ -205,6 +205,33 @@ function multiGlobSync(patterns, { keepDirs = false, root } = {}) { 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. * It creates an instance on which the 'files' array is stored. @@ -215,22 +242,30 @@ function multiGlobSync(patterns, { keepDirs = false, root } = {}) { class InputDataService { /** * Create an array of ProjectData - * @param {PathFromSystemRoot[]} projectPaths + * @param {PathFromSystemRoot | ProjectInputData []} projectPaths * @param {Partial} gatherFilesConfig * @returns {ProjectInputData[]} */ static createDataObject(projectPaths, gatherFilesConfig = {}) { /** @type {ProjectInputData[]} */ - const inputData = projectPaths.map(projectPath => ({ - project: /** @type {Project} */ ({ - name: pathLib.basename(projectPath), - path: projectPath, - }), - entries: this.gatherFilesFromDir(projectPath, { - ...this.defaultGatherFilesConfig, - ...gatherFilesConfig, - }), - })); + const inputData = projectPaths.map(projectPathOrObj => { + if (typeof projectPathOrObj === 'object') { + // ProjectInputData was provided already manually + return projectPathOrObj; + } + + const projectPath = projectPathOrObj; + return { + project: /** @type {Project} */ ({ + name: pathLib.basename(projectPath), + path: projectPath, + }), + entries: this.gatherFilesFromDir(projectPath, { + ...this.defaultGatherFilesConfig, + ...gatherFilesConfig, + }), + }; + }); // @ts-ignore return this._addMetaToProjectsData(inputData); } @@ -456,7 +491,8 @@ class InputDataService { ...(customConfig.allowlist || []), ]; } - const allowlistModes = ['npm', 'git', 'all']; + + const allowlistModes = ['npm', 'git', 'all', 'export-map']; if (customConfig.allowlistMode && !allowlistModes.includes(customConfig.allowlistMode)) { throw new Error( `[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[]} */ let gitIgnorePaths = []; /** @type {string[]} */ @@ -564,6 +609,62 @@ class InputDataService { // TODO: support forward compatibility for npm? 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; diff --git a/packages-node/providence-analytics/test-node/program/services/InputDataService.test.js b/packages-node/providence-analytics/test-node/program/services/InputDataService.test.js index 968b694aa..6354c4bfc 100644 --- a/packages-node/providence-analytics/test-node/program/services/InputDataService.test.js +++ b/packages-node/providence-analytics/test-node/program/services/InputDataService.test.js @@ -1,9 +1,10 @@ const { expect } = require('chai'); const pathLib = require('path'); -const { InputDataService } = require('../../../src/program/services/InputDataService.js'); +const { InputDataService } = require('../../../src/index.js'); const { restoreMockedProjects, mockProject, + mock, } = require('../../../test-helpers/mock-project-helpers.js'); function restoreOriginalInputDataPaths() { @@ -278,34 +279,94 @@ describe('InputDataService', () => { expect(globOutput).to.eql(['/fictional/project/index.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', - ]); - }); + describe('AllowlistMode', () => { + 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' + ]); - 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': ` + 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('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 # comment /storybook-static/ @@ -313,93 +374,51 @@ describe('InputDataService', () => { build/ !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 () => { - mockProject({ - './dist/bundle.js': '', - './src/file.js': '', - './package.json': JSON.stringify({ - files: ['dist', 'src'], - }), - '.gitignore': ` + it('filters no entries when allowlistMode is "all"', async () => { + mockProject({ + './dist/bundle.js': '', + './src/file.js': '', + './package.json': JSON.stringify({ + files: ['dist', 'src'], + }), + '.gitignore': ` /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 () => { @@ -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' }, + ]); + }); + }); + }); }); });