diff --git a/__tests__/package-exports-test.js b/__tests__/package-exports-test.js index 3d5117e4..3559420d 100644 --- a/__tests__/package-exports-test.js +++ b/__tests__/package-exports-test.js @@ -1,12 +1,21 @@ /* global require */ /* eslint-disable import/extensions */ + /** - * Verifies that CLI is not exported from the compiled barrel. + * Verifies package export conditions for the barrel, CLI, and representative subpaths. */ test('the CJS barrel does not export CLI', () => { const expensifyCommon = require('../dist/index.js'); expect(expensifyCommon.CLI).toBeUndefined(); expect(expensifyCommon.Str).toBeDefined(); + expect(expensifyCommon.unescapeText).toBeDefined(); +}); + +test('the CJS barrel resolves through package exports', () => { + const expensifyCommon = require('expensify-common'); + expect(expensifyCommon.Str).toBeDefined(); + expect(expensifyCommon.unescapeText).toBeDefined(); + expect(expensifyCommon.Device.getOSAndName).toEqual(expect.any(Function)); }); test('CLI subpath resolves to compiled dist output', () => { @@ -31,3 +40,80 @@ test('CLI subpath default export is the constructor via ESM import', async () => expect(typeof CLI).toBe('function'); expect(CLI.name).toBe('CLI'); }); + +test('Device subpath resolves through package exports', () => { + const {getOSAndName} = require('expensify-common/Device'); + + expect(typeof getOSAndName).toBe('function'); +}); + +test('utils subpath resolves through package exports', () => { + const {unescapeText} = require('expensify-common/utils'); + + expect(unescapeText('&')).toBe('&'); +}); + +test('Device and utils subpaths support ESM import', async () => { + const {getOSAndName} = await import('expensify-common/Device'); + const {unescapeText} = await import('expensify-common/utils'); + + expect(typeof getOSAndName).toBe('function'); + expect(unescapeText('&')).toBe('&'); +}); + +test('barrel supports ESM import through package exports', async () => { + const {Str, unescapeText, Device} = await import('expensify-common'); + + expect(Str).toBeDefined(); + expect(unescapeText('&')).toBe('&'); + expect(typeof Device.getOSAndName).toBe('function'); +}); + +test('nested component subpaths resolve through package exports', () => { + const CopyText = require('expensify-common/components/CopyText'); + + expect(CopyText.default).toBeDefined(); +}); + +test('every compiled CJS entry is exported as a canonical subpath', () => { + const fs = require('fs'); + const path = require('path'); + const packageJson = require('../package.json'); + + function collectCjsEntryPaths(directory, relativePath = '') { + const entries = []; + + for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { + if (entry.name === 'esm') { + continue; + } + + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + entries.push(...collectCjsEntryPaths(path.join(directory, entry.name), entryRelativePath)); + continue; + } + + if (!entry.name.endsWith('.js') || entry.name === 'cli.esm.js') { + continue; + } + + entries.push(entryRelativePath.replace(/\.js$/, '')); + } + + return entries; + } + + const distDir = path.join(require.resolve('../dist/index.js'), '..'); + const entryPaths = collectCjsEntryPaths(distDir); + + for (const entryPath of entryPaths) { + if (entryPath === 'index') { + continue; + } + + expect(packageJson.exports[`./${entryPath}`]).toBeDefined(); + expect(packageJson.exports[`./dist/${entryPath}`]).toBeUndefined(); + } +}); diff --git a/lib/index.ts b/lib/index.ts index afd7d512..a0ada57a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -20,3 +20,4 @@ export {default as Str} from './str'; export {default as TLD_REGEX} from './tlds'; export {default as md5} from './md5'; export {default as SafeString} from './SafeString'; +export {escapeText, isFunction, isNavigatorAvailable, isObject, isWindowAvailable, unescapeText} from './utils'; diff --git a/package.json b/package.json index 20b869f0..09945871 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,231 @@ "exports": { ".": { "types": "./dist/index.d.ts", + "import": "./dist/index.js", "require": "./dist/index.js", "default": "./dist/index.js" }, + "./API": { + "types": "./dist/API.d.ts", + "require": "./dist/API.js", + "import": "./dist/esm/API.js", + "default": "./dist/esm/API.js" + }, + "./APIDeferred": { + "types": "./dist/APIDeferred.d.ts", + "require": "./dist/APIDeferred.js", + "import": "./dist/esm/APIDeferred.js", + "default": "./dist/esm/APIDeferred.js" + }, + "./BrowserDetect": { + "types": "./dist/BrowserDetect.d.ts", + "require": "./dist/BrowserDetect.js", + "import": "./dist/esm/BrowserDetect.js", + "default": "./dist/esm/BrowserDetect.js" + }, "./CLI": { "types": "./dist/CLI.d.ts", - "import": "./dist/esm/CLI.js", "require": "./dist/CLI.js", + "import": "./dist/esm/CLI.js", "default": "./dist/esm/CLI.js" + }, + "./CONST": { + "types": "./dist/CONST.d.ts", + "require": "./dist/CONST.js", + "import": "./dist/esm/CONST.js", + "default": "./dist/esm/CONST.js" + }, + "./Cookie": { + "types": "./dist/Cookie.d.ts", + "require": "./dist/Cookie.js", + "import": "./dist/esm/Cookie.js", + "default": "./dist/esm/Cookie.js" + }, + "./CredentialsWrapper": { + "types": "./dist/CredentialsWrapper.d.ts", + "require": "./dist/CredentialsWrapper.js", + "import": "./dist/esm/CredentialsWrapper.js", + "default": "./dist/esm/CredentialsWrapper.js" + }, + "./Device": { + "types": "./dist/Device.d.ts", + "require": "./dist/Device.js", + "import": "./dist/esm/Device.js", + "default": "./dist/esm/Device.js" + }, + "./ExpenseRule": { + "types": "./dist/ExpenseRule.d.ts", + "require": "./dist/ExpenseRule.js", + "import": "./dist/esm/ExpenseRule.js", + "default": "./dist/esm/ExpenseRule.js" + }, + "./ExpensiMark": { + "types": "./dist/ExpensiMark.d.ts", + "require": "./dist/ExpensiMark.js", + "import": "./dist/esm/ExpensiMark.js", + "default": "./dist/esm/ExpensiMark.js" + }, + "./Func": { + "types": "./dist/Func.d.ts", + "require": "./dist/Func.js", + "import": "./dist/esm/Func.js", + "default": "./dist/esm/Func.js" + }, + "./Log": { + "types": "./dist/Log.d.ts", + "require": "./dist/Log.js", + "import": "./dist/esm/Log.js", + "default": "./dist/esm/Log.js" + }, + "./Logger": { + "types": "./dist/Logger.d.ts", + "require": "./dist/Logger.js", + "import": "./dist/esm/Logger.js", + "default": "./dist/esm/Logger.js" + }, + "./Network": { + "types": "./dist/Network.d.ts", + "require": "./dist/Network.js", + "import": "./dist/esm/Network.js", + "default": "./dist/esm/Network.js" + }, + "./Num": { + "types": "./dist/Num.d.ts", + "require": "./dist/Num.js", + "import": "./dist/esm/Num.js", + "default": "./dist/esm/Num.js" + }, + "./PageEvent": { + "types": "./dist/PageEvent.d.ts", + "require": "./dist/PageEvent.js", + "import": "./dist/esm/PageEvent.js", + "default": "./dist/esm/PageEvent.js" + }, + "./PubSub": { + "types": "./dist/PubSub.d.ts", + "require": "./dist/PubSub.js", + "import": "./dist/esm/PubSub.js", + "default": "./dist/esm/PubSub.js" + }, + "./ReportHistoryStore": { + "types": "./dist/ReportHistoryStore.d.ts", + "require": "./dist/ReportHistoryStore.js", + "import": "./dist/esm/ReportHistoryStore.js", + "default": "./dist/esm/ReportHistoryStore.js" + }, + "./SafeString": { + "types": "./dist/SafeString.d.ts", + "require": "./dist/SafeString.js", + "import": "./dist/esm/SafeString.js", + "default": "./dist/esm/SafeString.js" + }, + "./Templates": { + "types": "./dist/Templates.d.ts", + "require": "./dist/Templates.js", + "import": "./dist/esm/Templates.js", + "default": "./dist/esm/Templates.js" + }, + "./Url": { + "types": "./dist/Url.d.ts", + "require": "./dist/Url.js", + "import": "./dist/esm/Url.js", + "default": "./dist/esm/Url.js" + }, + "./components/CopyText": { + "types": "./dist/components/CopyText.d.ts", + "require": "./dist/components/CopyText.js", + "import": "./dist/esm/components/CopyText.js", + "default": "./dist/esm/components/CopyText.js" + }, + "./components/StepProgressBar": { + "types": "./dist/components/StepProgressBar.d.ts", + "require": "./dist/components/StepProgressBar.js", + "import": "./dist/esm/components/StepProgressBar.js", + "default": "./dist/esm/components/StepProgressBar.js" + }, + "./components/form/element/combobox": { + "types": "./dist/components/form/element/combobox.d.ts", + "require": "./dist/components/form/element/combobox.js", + "import": "./dist/esm/components/form/element/combobox.js", + "default": "./dist/esm/components/form/element/combobox.js" + }, + "./components/form/element/dropdown": { + "types": "./dist/components/form/element/dropdown.d.ts", + "require": "./dist/components/form/element/dropdown.js", + "import": "./dist/esm/components/form/element/dropdown.js", + "default": "./dist/esm/components/form/element/dropdown.js" + }, + "./components/form/element/dropdownItem": { + "types": "./dist/components/form/element/dropdownItem.d.ts", + "require": "./dist/components/form/element/dropdownItem.js", + "import": "./dist/esm/components/form/element/dropdownItem.js", + "default": "./dist/esm/components/form/element/dropdownItem.js" + }, + "./components/form/element/onOffSwitch": { + "types": "./dist/components/form/element/onOffSwitch.d.ts", + "require": "./dist/components/form/element/onOffSwitch.js", + "import": "./dist/esm/components/form/element/onOffSwitch.js", + "default": "./dist/esm/components/form/element/onOffSwitch.js" + }, + "./components/form/element/switch": { + "types": "./dist/components/form/element/switch.d.ts", + "require": "./dist/components/form/element/switch.js", + "import": "./dist/esm/components/form/element/switch.js", + "default": "./dist/esm/components/form/element/switch.js" + }, + "./fastMerge": { + "types": "./dist/fastMerge.d.ts", + "require": "./dist/fastMerge.js", + "import": "./dist/esm/fastMerge.js", + "default": "./dist/esm/fastMerge.js" + }, + "./jquery.expensifyIframify": { + "types": "./dist/jquery.expensifyIframify.d.ts", + "require": "./dist/jquery.expensifyIframify.js", + "import": "./dist/esm/jquery.expensifyIframify.js", + "default": "./dist/esm/jquery.expensifyIframify.js" + }, + "./md5": { + "types": "./dist/md5.d.ts", + "require": "./dist/md5.js", + "import": "./dist/esm/md5.js", + "default": "./dist/esm/md5.js" + }, + "./mixins/PubSub": { + "types": "./dist/mixins/PubSub.d.ts", + "require": "./dist/mixins/PubSub.js", + "import": "./dist/esm/mixins/PubSub.js", + "default": "./dist/esm/mixins/PubSub.js" + }, + "./mixins/extraClasses": { + "types": "./dist/mixins/extraClasses.d.ts", + "require": "./dist/mixins/extraClasses.js", + "import": "./dist/esm/mixins/extraClasses.js", + "default": "./dist/esm/mixins/extraClasses.js" + }, + "./mixins/validationClasses": { + "types": "./dist/mixins/validationClasses.d.ts", + "require": "./dist/mixins/validationClasses.js", + "import": "./dist/esm/mixins/validationClasses.js", + "default": "./dist/esm/mixins/validationClasses.js" + }, + "./str": { + "types": "./dist/str.d.ts", + "require": "./dist/str.js", + "import": "./dist/esm/str.js", + "default": "./dist/esm/str.js" + }, + "./tlds": { + "types": "./dist/tlds.d.ts", + "require": "./dist/tlds.js", + "import": "./dist/esm/tlds.js", + "default": "./dist/esm/tlds.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "require": "./dist/utils.js", + "import": "./dist/esm/utils.js", + "default": "./dist/esm/utils.js" } }, "files": [ @@ -29,9 +246,9 @@ "scripts": { "grunt": "grunt", "typecheck": "tsc --noEmit", - "build": "tsc -p tsconfig.build.json && cp ./lib/*.d.ts ./dist && tsc -p tsconfig.cli.cjs.json && tsc -p tsconfig.cli.esm.json && cp ./lib/esm-package.json ./dist/esm/package.json", + "build": "tsc -p tsconfig.build.json && cp ./lib/*.d.ts ./dist && tsc -p tsconfig.esm.json && cp ./lib/esm-package.json ./dist/esm/package.json && node ./scripts/generate-package-exports.mjs", "test": "jest", - "lint": "eslint lib/ __tests__/", + "lint": "eslint lib/ __tests__/ scripts/", "prettier": "prettier --write lib/ __tests__/", "patch": "npm --no-git-tag-version version patch", "update-tlds": "echo \"$(curl -s https://data.iana.org/TLD/tlds-alpha-by-domain.txt | sed '1d' | awk '{print length, $0}' - | sort -n -r | cut -d \" \" -f2- )|SJC|RNO|LAX\" | tr '\\n' '|' | sed s'/.$//' | printf \"const TLD_REGEX='$(cat -)';\\n\\nexport default TLD_REGEX;\\n\" > lib/tlds.jsx" diff --git a/scripts/generate-package-exports.mjs b/scripts/generate-package-exports.mjs new file mode 100644 index 00000000..7aaef7a3 --- /dev/null +++ b/scripts/generate-package-exports.mjs @@ -0,0 +1,123 @@ +/** + * Generates the package.json `exports` map after the CJS and ESM builds complete. + * + * Scans dist/ for every compiled entry point (top-level modules, components, mixins, etc.) and + * writes canonical subpath exports such as `expensify-common/Device` and `expensify-common/utils`. + * Each subpath points at the matching CJS file for `require`, the ESM file in dist/esm for + * `import` when available, and the shared .d.ts for types. The barrel entry (`.`) is configured + * separately to use the CJS index for both import and require. + * + * Run as the final step of `npm run build`; it overwrites `package.json` exports in place. + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(dirname, ".."); +const distDir = path.join(packageRoot, "dist"); +const esmDir = path.join(distDir, "esm"); + +/** + * Collects every compiled JS entry under dist/, excluding the ESM output tree. + * + * @param {string} directory + * @param {string} [relativePath] + * @returns {string[]} + */ +function collectCjsEntryPaths(directory, relativePath = "") { + const entries = []; + + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name === "esm") { + continue; + } + + const entryRelativePath = relativePath + ? `${relativePath}/${entry.name}` + : entry.name; + + if (entry.isDirectory()) { + entries.push( + ...collectCjsEntryPaths( + path.join(directory, entry.name), + entryRelativePath, + ), + ); + continue; + } + + if (!entry.name.endsWith(".js") || entry.name === "cli.esm.js") { + continue; + } + + entries.push(entryRelativePath.replace(/\.js$/, "")); + } + + return entries.sort(); +} + +function hasEsmBuild(relativePathWithoutExtension) { + return fs.existsSync(path.join(esmDir, `${relativePathWithoutExtension}.js`)); +} + +function createSubpathExport(relativePathWithoutExtension) { + const typesPath = `./dist/${relativePathWithoutExtension}.d.ts`; + const requirePath = `./dist/${relativePathWithoutExtension}.js`; + const exportEntry = { + types: typesPath, + require: requirePath, + }; + + if (hasEsmBuild(relativePathWithoutExtension)) { + exportEntry.import = `./dist/esm/${relativePathWithoutExtension}.js`; + exportEntry.default = exportEntry.import; + } else { + exportEntry.default = requirePath; + } + + return exportEntry; +} + +function buildExportsMap() { + const entryPaths = collectCjsEntryPaths(distDir); + const exportsMap = { + ".": { + types: "./dist/index.d.ts", + // The barrel re-exports the full legacy graph (jQuery, lodash, React, etc.). Keep CJS as the + // ESM entry so native Node `import` and bundlers resolve the same stable build. + import: "./dist/index.js", + require: "./dist/index.js", + default: "./dist/index.js", + }, + }; + + for (const entryPath of entryPaths) { + if (entryPath === "index") { + continue; + } + + const subpathExport = createSubpathExport(entryPath); + + exportsMap[`./${entryPath}`] = subpathExport; + } + + return exportsMap; +} + +function main() { + const packageJsonPath = path.join(packageRoot, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + + packageJson.exports = buildExportsMap(); + + fs.writeFileSync( + packageJsonPath, + `${JSON.stringify(packageJson, null, 2)}\n`, + ); + console.log( + `Updated package.json exports with ${Object.keys(packageJson.exports).length} entries.`, + ); +} + +main(); diff --git a/tsconfig.build.json b/tsconfig.build.json index 29ce4e7b..d04f033a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,5 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", - "include": ["./lib"], - "exclude": ["./lib/CLI.ts"] + "include": ["./lib"] } diff --git a/tsconfig.cli.cjs.json b/tsconfig.cli.cjs.json deleted file mode 100644 index 65521081..00000000 --- a/tsconfig.cli.cjs.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "outDir": "./dist" - }, - "include": ["./lib/CLI.ts"] -} diff --git a/tsconfig.cli.esm.json b/tsconfig.cli.esm.json deleted file mode 100644 index cafb37be..00000000 --- a/tsconfig.cli.esm.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", - "outDir": "./dist/esm", - "rootDir": "./lib", - "declaration": true, - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "types": ["node"] - }, - "include": ["./lib/CLI.ts", "./lib/SafeString.ts"] -} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 00000000..eff08eed --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2020", + "moduleResolution": "node", + "outDir": "./dist/esm", + "rootDir": "./lib" + }, + "include": ["./lib"] +}