diff --git a/.changeset/afraid-experts-attack.md b/.changeset/afraid-experts-attack.md new file mode 100644 index 00000000..a540c540 --- /dev/null +++ b/.changeset/afraid-experts-attack.md @@ -0,0 +1,5 @@ +--- +"@preconstruct/cli": patch +--- + +Added experimental `exports` flag. See the docs at the `exports` section of https://preconstruct.tools/configuration. diff --git a/package.json b/package.json index 3cbf0df4..f68d6d71 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "normalize-path": "^3.0.0", "outdent": "^0.7.1", "prettier": "^2.1.2", - "typescript": "^4.5.2" + "typescript": "^4.7.4" }, "jest": { "reporters": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index ac496657..9de351e7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,13 +2,7 @@ "name": "@preconstruct/cli", "version": "2.1.7", "description": "Dev and build your code painlessly in monorepos", - "files": [ - "bin.js", - "cli", - "worker", - "!**/*.d.ts", - "dist" - ], + "files": ["bin.js", "cli", "worker", "!**/*.d.ts", "dist"], "bin": { "preconstruct": "./bin.js" }, @@ -54,10 +48,7 @@ "v8-compile-cache": "^2.1.1" }, "preconstruct": { - "entrypoints": [ - "cli", - "worker" - ] + "entrypoints": ["cli", "worker"] }, "devDependencies": { "escape-string-regexp": "^4.0.0", diff --git a/packages/cli/src/__tests__/dev.ts b/packages/cli/src/__tests__/dev.ts index ac7712ef..b9fc6774 100644 --- a/packages/cli/src/__tests__/dev.ts +++ b/packages/cli/src/__tests__/dev.ts @@ -2,7 +2,7 @@ import spawn from "spawndamnit"; import path from "path"; import * as fs from "fs-extra"; import * as realFs from "fs"; -import { js, testdir, typescriptFixture } from "../../test-utils"; +import { getFiles, js, testdir, typescriptFixture } from "../../test-utils"; import dev from "../dev"; import normalizePath from "normalize-path"; import escapeStringRegexp from "escape-string-regexp"; @@ -305,3 +305,50 @@ test("typescript with typeScriptProxyFileWithImportEqualsRequireAndExportEquals" " `); }); + +test("exports field with worker condition", async () => { + let tmpPath = realFs.realpathSync.native( + await testdir({ + "package.json": JSON.stringify({ + name: "@something/blah", + main: "dist/something-blah.cjs.js", + module: "dist/something-blah.esm.js", + exports: { + ".": { + module: { + worker: "./dist/something-blah.worker.esm.js", + default: "./dist/something-blah.esm.js", + }, + default: "./dist/something-blah.cjs.js", + }, + "./package.json": "./package.json", + }, + preconstruct: { + exports: { + envConditions: ["worker"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "console.log(1)", + }) + ); + await dev(tmpPath); + const files = await getFiles(tmpPath, [ + "dist/**", + "!dist/something-blah.cjs.js", + ]); + expect(files).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/something-blah.esm.js, dist/something-blah.worker.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + console.log(1) + `); + await Promise.all( + Object.keys(files).map(async (filename) => { + expect(await fs.realpath(path.join(tmpPath, filename))).toEqual( + path.join(tmpPath, "src/index.js") + ); + }) + ); +}); diff --git a/packages/cli/src/__tests__/fix.ts b/packages/cli/src/__tests__/fix.ts index dd140830..9adb5627 100644 --- a/packages/cli/src/__tests__/fix.ts +++ b/packages/cli/src/__tests__/fix.ts @@ -9,6 +9,7 @@ import { createPackageCheckTestCreator, testdir, js, + getFiles, } from "../../test-utils"; import { promptInput as _promptInput } from "../prompt"; import fs from "fs-extra"; @@ -70,6 +71,328 @@ test("set main and module field", async () => { `); }); +test("set exports field when opt-in", async () => { + let tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "package-exports", + main: "index.js", + module: "dist/package-exports.esm.js", + preconstruct: { + exports: { + envConditions: ["worker", "browser"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + + await fix(tmpPath); + + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "package-exports", + "main": "dist/package-exports.cjs.js", + "module": "dist/package-exports.esm.js", + "browser": { + "./dist/package-exports.esm.js": "./dist/package-exports.browser.esm.js" + }, + "exports": { + ".": { + "module": { + "worker": "./dist/package-exports.worker.esm.js", + "browser": "./dist/package-exports.browser.esm.js", + "default": "./dist/package-exports.esm.js" + }, + "default": "./dist/package-exports.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": { + "envConditions": [ + "worker", + "browser" + ] + }, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("set exports field when opt-in", async () => { + let tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "package-exports", + main: "index.js", + module: "dist/package-exports.esm.js", + preconstruct: { + exports: { + envConditions: ["worker", "browser"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + + await fix(tmpPath); + + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "package-exports", + "main": "dist/package-exports.cjs.js", + "module": "dist/package-exports.esm.js", + "browser": { + "./dist/package-exports.esm.js": "./dist/package-exports.browser.esm.js" + }, + "exports": { + ".": { + "module": { + "worker": "./dist/package-exports.worker.esm.js", + "browser": "./dist/package-exports.browser.esm.js", + "default": "./dist/package-exports.esm.js" + }, + "default": "./dist/package-exports.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": { + "envConditions": [ + "worker", + "browser" + ] + }, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("set exports field when opt-in with no env conditions", async () => { + let tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "package-exports", + main: "index.js", + module: "dist/package-exports.esm.js", + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + + await fix(tmpPath); + + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "package-exports", + "main": "dist/package-exports.cjs.js", + "module": "dist/package-exports.esm.js", + "exports": { + ".": { + "module": "./dist/package-exports.esm.js", + "default": "./dist/package-exports.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": true, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("set exports field with multiple entrypoints", async () => { + let tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "@blah/something", + main: "index.js", + module: "dist/package-exports.esm.js", + preconstruct: { + entrypoints: ["index.js", "other.js", "deep/something.js"], + exports: { + envConditions: ["worker", "browser"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "other/package.json": JSON.stringify({}), + "deep/something/package.json": JSON.stringify({}), + "src/index.js": "", + "src/other.js": "", + "src/deep/something.js": "", + }); + + await fix(tmpPath); + + expect(await getFiles(tmpPath, ["**/package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ deep/something/package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "main": "dist/blah-something-deep-something.cjs.js", + "module": "dist/blah-something-deep-something.esm.js", + "browser": { + "./dist/blah-something-deep-something.esm.js": "./dist/blah-something-deep-something.browser.esm.js" + } + } + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ other/package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "main": "dist/blah-something-other.cjs.js", + "module": "dist/blah-something-other.esm.js", + "browser": { + "./dist/blah-something-other.esm.js": "./dist/blah-something-other.browser.esm.js" + } + } + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "@blah/something", + "main": "dist/blah-something.cjs.js", + "module": "dist/blah-something.esm.js", + "browser": { + "./dist/blah-something.esm.js": "./dist/blah-something.browser.esm.js" + }, + "exports": { + ".": { + "module": { + "worker": "./dist/blah-something.worker.esm.js", + "browser": "./dist/blah-something.browser.esm.js", + "default": "./dist/blah-something.esm.js" + }, + "default": "./dist/blah-something.cjs.js" + }, + "./other": { + "module": { + "worker": "./other/dist/blah-something-other.worker.esm.js", + "browser": "./other/dist/blah-something-other.browser.esm.js", + "default": "./other/dist/blah-something-other.esm.js" + }, + "default": "./other/dist/blah-something-other.cjs.js" + }, + "./deep/something": { + "module": { + "worker": "./deep/something/dist/blah-something-deep-something.worker.esm.js", + "browser": "./deep/something/dist/blah-something-deep-something.browser.esm.js", + "default": "./deep/something/dist/blah-something-deep-something.esm.js" + }, + "default": "./deep/something/dist/blah-something-deep-something.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "entrypoints": [ + "index.js", + "other.js", + "deep/something.js" + ], + "exports": { + "envConditions": [ + "worker", + "browser" + ] + }, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("set exports field without root entrypoint", async () => { + let tmpPath = await testdir({ + "package.json": + JSON.stringify( + { + name: "@blah/something", + preconstruct: { + entrypoints: ["other.js"], + exports: { + envConditions: ["worker", "browser"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }, + null, + 2 + ) + "\n", + "other/package.json": JSON.stringify({}), + "src/other.js": "", + }); + + await fix(tmpPath); + + expect(await getFiles(tmpPath, ["**/package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ other/package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "main": "dist/blah-something-other.cjs.js", + "module": "dist/blah-something-other.esm.js", + "browser": { + "./dist/blah-something-other.esm.js": "./dist/blah-something-other.browser.esm.js" + } + } + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "@blah/something", + "preconstruct": { + "entrypoints": [ + "other.js" + ], + "exports": { + "envConditions": [ + "worker", + "browser" + ] + }, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + }, + "exports": { + "./other": { + "module": { + "worker": "./other/dist/blah-something-other.worker.esm.js", + "browser": "./other/dist/blah-something-other.browser.esm.js", + "default": "./other/dist/blah-something-other.esm.js" + }, + "default": "./other/dist/blah-something-other.cjs.js" + }, + "./package.json": "./package.json" + } + } + + `); +}); + test("new dist filenames", async () => { let tmpPath = f.copy("basic-package"); @@ -410,7 +733,252 @@ test("unexpected former experimental flag is removed", async () => { export let x = true; `, }); + await fix(tmpPath); + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "pkg-a", + "main": "dist/pkg-a.cjs.js" + } + + `); +}); + +test("no module field with exports field", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await fix(tmpPath); + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "pkg-a", + "main": "dist/pkg-a.cjs.js", + "module": "dist/pkg-a.esm.js", + "exports": { + ".": { + "module": "./dist/pkg-a.esm.js", + "default": "./dist/pkg-a.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": true, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("has browser field but no browser condition", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + browser: { + "./dist/pkg-a.cjs.js": "./dist/pkg-a.browser.cjs.js", + "./dist/pkg-a.esm.js": "./dist/pkg-a.browser.esm.js", + }, + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await fix(tmpPath); + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "pkg-a", + "main": "dist/pkg-a.cjs.js", + "module": "dist/pkg-a.esm.js", + "browser": { + "./dist/pkg-a.esm.js": "./dist/pkg-a.browser.esm.js" + }, + "exports": { + ".": { + "module": { + "browser": "./dist/pkg-a.browser.esm.js", + "default": "./dist/pkg-a.esm.js" + }, + "default": "./dist/pkg-a.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": { + "envConditions": [ + "browser" + ] + }, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } - await expect(fix(tmpPath)); - expect(getPkg(tmpPath)).toMatchInlineSnapshot(`Promise {}`); + `); +}); + +test("has browser condition but no browser field", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + preconstruct: { + exports: { + envConditions: ["browser"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + + await fix(tmpPath); + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "pkg-a", + "main": "dist/pkg-a.cjs.js", + "module": "dist/pkg-a.esm.js", + "browser": { + "./dist/pkg-a.esm.js": "./dist/pkg-a.browser.esm.js" + }, + "exports": { + ".": { + "module": { + "browser": "./dist/pkg-a.browser.esm.js", + "default": "./dist/pkg-a.esm.js" + }, + "default": "./dist/pkg-a.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": { + "envConditions": [ + "browser" + ] + }, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("preconstruct.exports: true no exports field", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await fix(tmpPath); + expect(await getFiles(tmpPath, ["package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "pkg-a", + "main": "dist/pkg-a.cjs.js", + "module": "dist/pkg-a.esm.js", + "exports": { + ".": { + "module": "./dist/pkg-a.esm.js", + "default": "./dist/pkg-a.cjs.js" + }, + "./package.json": "./package.json" + }, + "preconstruct": { + "exports": true, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + + `); +}); + +test("project level exports field config", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify( + { + name: "repo", + preconstruct: { + packages: ["packages/*"], + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }, + null, + 2 + ), + "packages/pkg-a/package.json": JSON.stringify({ + name: "pkg-a", + }), + "packages/pkg-a/src/index.js": "", + }); + await fix(tmpPath); + expect(await getFiles(tmpPath, ["**/package.json"])).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "repo", + "preconstruct": { + "packages": [ + "packages/*" + ], + "exports": true, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "exports": true + } + } + } + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ packages/pkg-a/package.json ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + { + "name": "pkg-a", + "main": "dist/pkg-a.cjs.js", + "module": "dist/pkg-a.esm.js", + "exports": { + ".": { + "module": "./dist/pkg-a.esm.js", + "default": "./dist/pkg-a.cjs.js" + }, + "./package.json": "./package.json" + } + } + + `); }); diff --git a/packages/cli/src/__tests__/validate.ts b/packages/cli/src/__tests__/validate.ts index 1f883086..b935c532 100644 --- a/packages/cli/src/__tests__/validate.ts +++ b/packages/cli/src/__tests__/validate.ts @@ -10,6 +10,7 @@ import { repoNodeModules, } from "../../test-utils"; import { confirms as _confirms } from "../messages"; +import { JSONValue } from "../utils"; const f = fixturez(__dirname); @@ -657,3 +658,204 @@ test("just wrong dist filenames doesn't report about the changed dist filename s ] `); }); + +describe("exports field config", () => { + const exportsFieldConfigTestDir = (config: JSONValue) => { + return testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + exports: { + ".": { + module: "./dist/pkg-a.esm.js", + default: "./dist/pkg-a.cjs.js", + }, + "./package.json": "./package.json", + }, + preconstruct: { + exports: config, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + }; + describe("invalid", () => { + test("null", async () => { + const tmpPath = await exportsFieldConfigTestDir(null); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports" field must be a boolean or an object at the package level]` + ); + }); + test("some string", async () => { + const tmpPath = await exportsFieldConfigTestDir("blah"); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports" field must be a boolean or an object at the package level]` + ); + }); + test("extra not object", async () => { + const tmpPath = await exportsFieldConfigTestDir({ extra: "blah" }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports.extra" field must be an object if it is present]` + ); + }); + test("envConditions not array", async () => { + const tmpPath = await exportsFieldConfigTestDir({ envConditions: {} }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports.envConditions" field must be an array containing zero or more of "worker" and "browser" if it is present]` + ); + }); + test("envConditions duplicates", async () => { + const tmpPath = await exportsFieldConfigTestDir({ + envConditions: ["worker", "worker"], + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports.envConditions" field must not have duplicates]` + ); + }); + test("envConditions invalid condition", async () => { + const tmpPath = await exportsFieldConfigTestDir({ + envConditions: ["worker", "asfdasfd"], + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports.envConditions" field must be an array containing zero or more of "worker" and "browser" if it is present]` + ); + }); + test("unknown key", async () => { + const tmpPath = await exportsFieldConfigTestDir({ + something: true, + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the "preconstruct.exports" field contains an unknown key "something"]` + ); + }); + }); + + describe("true", () => { + const configsEquivalentToTrue = [ + {}, + { envConditions: [] }, + { envConditions: [], extra: {} }, + { extra: {} }, + {}, + true, + ]; + for (const config of configsEquivalentToTrue) { + test(`${JSON.stringify(config)}`, async () => { + const tmpPath = await exportsFieldConfigTestDir(config); + await validate(tmpPath); + }); + } + }); + describe("false", () => { + const configsEquivalentToFalse = [false, undefined]; + for (const config of configsEquivalentToFalse) { + test(`${JSON.stringify(config)}`, async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + preconstruct: { + exports: config, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await validate(tmpPath); + }); + } + }); +}); + +test("no module field with exports field", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: when using the exports field, the module field must also be specified]` + ); +}); + +test("has browser field but no browser condition", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + browser: { + "./dist/pkg-a.cjs.js": "./dist/pkg-a.browser.cjs.js", + "./dist/pkg-a.esm.js": "./dist/pkg-a.browser.esm.js", + }, + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot(` + [Error: 🎁 pkg-a the exports field is configured and the browser field exists in this package but it is not specified in the preconstruct.exports.envConditions field + 🎁 pkg-a browser field is invalid, found \`{"./dist/pkg-a.cjs.js":"./dist/pkg-a.browser.cjs.js","./dist/pkg-a.esm.js":"./dist/pkg-a.browser.esm.js"}\`, expected \`{"./dist/pkg-a.esm.js":"./dist/pkg-a.browser.esm.js"}\`] + `); +}); + +test("has browser condition but no browser field", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + preconstruct: { + exports: { + envConditions: ["browser"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: the exports field is configured and the browser condition is set in preconstruct.exports.envConditions but the field is not present at the top-level]` + ); +}); + +test("preconstruct.exports: true no exports field", async () => { + const tmpPath = await testdir({ + "package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + preconstruct: { + exports: true, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + "src/index.js": "", + }); + await expect(validate(tmpPath)).rejects.toMatchInlineSnapshot( + `[Error: exports field was not found, expected \`{".":{"module":"./dist/pkg-a.esm.js","default":"./dist/pkg-a.cjs.js"},"./package.json":"./package.json"}\`]` + ); +}); diff --git a/packages/cli/src/build/__tests__/build.ts b/packages/cli/src/build/__tests__/build.ts index 75a71c33..5255db16 100644 --- a/packages/cli/src/build/__tests__/build.ts +++ b/packages/cli/src/build/__tests__/build.ts @@ -1,6 +1,7 @@ import build from "../"; import fixturez from "fixturez"; import path from "path"; +import fs from "fs-extra"; import { initBasic, getPkg, @@ -12,9 +13,11 @@ import { repoNodeModules, basicPkgJson, getFiles, + ts, } from "../../../test-utils"; import { doPromptInput as _doPromptInput } from "../../prompt"; import { confirms as _confirms } from "../../messages"; +import spawn from "spawndamnit"; const f = fixturez(__dirname); @@ -656,3 +659,171 @@ test("using @babel/plugin-transform-runtime with useESModules: true", async () = `); }); + +test("worker and browser build", async () => { + let dir = await testdir({ + "package.json": JSON.stringify({ + name: "@exports/test", + main: "dist/exports-test.cjs.js", + module: "dist/exports-test.esm.js", + browser: { + "./dist/exports-test.esm.js": "./dist/exports-test.browser.esm.js", + }, + exports: { + ".": { + module: { + worker: "./dist/exports-test.worker.esm.js", + browser: "./dist/exports-test.browser.esm.js", + default: "./dist/exports-test.esm.js", + }, + default: "./dist/exports-test.cjs.js", + }, + "./package.json": "./package.json", + }, + preconstruct: { + exports: { + envConditions: ["browser", "worker"], + }, + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports: true, + }, + }, + }), + node_modules: { + kind: "symlink", + path: repoNodeModules, + }, + "src/index.js": js` + export const thing = typeof window; + `, + }); + await build(dir); + expect(await getDist(dir)).toMatchInlineSnapshot(` + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/exports-test.browser.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + const thing = "object"; + + export { thing }; + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/exports-test.cjs.dev.js, dist/exports-test.cjs.prod.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + 'use strict'; + + Object.defineProperty(exports, '__esModule', { value: true }); + + const thing = typeof window; + + exports.thing = thing; + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/exports-test.cjs.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + 'use strict'; + + if (process.env.NODE_ENV === "production") { + module.exports = require("./exports-test.cjs.prod.js"); + } else { + module.exports = require("./exports-test.cjs.dev.js"); + } + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/exports-test.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + const thing = typeof window; + + export { thing }; + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ dist/exports-test.worker.esm.js ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + const thing = "undefined"; + + export { thing }; + + `); +}); + +test("typescript with nodenext module resolution", async () => { + let dir = await testdir({ + "package.json": JSON.stringify({ + name: "@exports/repo", + preconstruct: { + packages: ["packages/pkg-a"], + ___experimentalFlags_WILL_CHANGE_IN_PATCH: { exports: true }, + }, + }), + "packages/pkg-a/package.json": JSON.stringify({ + name: "pkg-a", + main: "dist/pkg-a.cjs.js", + module: "dist/pkg-a.esm.js", + exports: { + ".": { + module: "./dist/pkg-a.esm.js", + default: "./dist/pkg-a.cjs.js", + }, + "./something": { + module: "./something/dist/pkg-a-something.esm.js", + default: "./something/dist/pkg-a-something.cjs.js", + }, + "./package.json": "./package.json", + }, + preconstruct: { + entrypoints: ["index.ts", "something.ts"], + exports: true, + }, + }), + "packages/pkg-a/something/package.json": JSON.stringify({ + main: "dist/pkg-a-something.cjs.js", + module: "dist/pkg-a-something.esm.js", + }), + "packages/pkg-a/src/index.ts": ts` + export const thing = "index"; + `, + "packages/pkg-a/src/something.ts": ts` + export const something = "something"; + `, + "packages/pkg-a/not-exported.ts": ts` + export const notExported = true; + `, + + "packages/pkg-a/node_modules": { + kind: "symlink", + path: repoNodeModules, + }, + "blah.ts": ts` + import { thing } from "pkg-a"; + import { something } from "pkg-a/something"; + import { notExported } from "pkg-a/not-exported"; // should error + + function acceptThing(x: T) {} + + acceptThing<"index">(thing); + acceptThing<"something">(something); + + // this is to check that TypeScript is actually checking things + acceptThing<"other">(thing); // should error + acceptThing<"other">(something); // should error + `, + "tsconfig.json": JSON.stringify({ + compilerOptions: { + module: "NodeNext", + moduleResolution: "nodenext", + strict: true, + declaration: true, + }, + }), + }); + await fs.ensureSymlink( + path.join(dir, "packages/pkg-a"), + path.join(dir, "node_modules/pkg-a") + ); + await build(dir); + let { code, stdout, stderr } = await spawn( + path.join( + path.dirname(require.resolve("typescript/package.json")), + "bin/tsc" + ), + [], + { cwd: dir } + ); + expect(code).toBe(2); + expect(stdout.toString("utf8")).toMatchInlineSnapshot(` + "blah.ts(3,29): error TS2307: Cannot find module 'pkg-a/not-exported' or its corresponding type declarations. + blah.ts(11,22): error TS2345: Argument of type '\\"index\\"' is not assignable to parameter of type '\\"other\\"'. + blah.ts(12,22): error TS2345: Argument of type '\\"something\\"' is not assignable to parameter of type '\\"other\\"'. + " + `); + expect(stderr.toString("utf8")).toMatchInlineSnapshot(`""`); +}); diff --git a/packages/cli/src/build/config.ts b/packages/cli/src/build/config.ts index 8ac33e93..e7f93519 100644 --- a/packages/cli/src/build/config.ts +++ b/packages/cli/src/build/config.ts @@ -172,6 +172,8 @@ export function getRollupConfigs(pkg: Package, aliases: Aliases) { }); }); + const exportsFieldConfig = pkg.exportsFieldConfig(); + let hasBrowserField = pkg.entrypoints[0].json.browser !== undefined; if (hasBrowserField) { @@ -184,7 +186,7 @@ export function getRollupConfigs(pkg: Package, aliases: Aliases) { () => {} ), outputs: [ - { + !exportsFieldConfig && { format: "cjs" as const, entryFileNames: "[name].browser.cjs.js", chunkFileNames: "dist/[name]-[hash].browser.cjs.js", @@ -193,16 +195,35 @@ export function getRollupConfigs(pkg: Package, aliases: Aliases) { interop, plugins: cjsPlugins, }, - ...(hasModuleField - ? [ - { - format: "es" as const, - entryFileNames: "[name].browser.esm.js", - chunkFileNames: "dist/[name]-[hash].browser.esm.js", - dir: pkg.directory, - }, - ] - : []), + hasModuleField && { + format: "es" as const, + entryFileNames: "[name].browser.esm.js", + chunkFileNames: "dist/[name]-[hash].browser.esm.js", + dir: pkg.directory, + }, + ].filter( + (value): value is Exclude => value !== false + ), + }); + } + + // note module builds always exist when using the exports field + if (exportsFieldConfig?.envConditions.has("worker")) { + configs.push({ + config: getRollupConfig( + pkg, + pkg.entrypoints, + aliases, + "worker", + () => {} + ), + outputs: [ + { + format: "es" as const, + entryFileNames: "[name].worker.esm.js", + chunkFileNames: "dist/[name]-[hash].worker.esm.js", + dir: pkg.directory, + }, ], }); } diff --git a/packages/cli/src/build/rollup.ts b/packages/cli/src/build/rollup.ts index 564ad484..9b76481c 100644 --- a/packages/cli/src/build/rollup.ts +++ b/packages/cli/src/build/rollup.ts @@ -31,7 +31,12 @@ const makeExternalPredicate = (externalArr: string[]) => { return (id: string) => pattern.test(id); }; -export type RollupConfigType = "umd" | "browser" | "node-dev" | "node-prod"; +export type RollupConfigType = + | "umd" + | "browser" + | "worker" + | "node-dev" + | "node-prod"; export let getRollupConfig = ( pkg: Package, @@ -173,6 +178,7 @@ export let getRollupConfig = ( }), resolve({ extensions: EXTENSIONS, + // only umd builds will actually load dependencies which is where this browser flag actually makes a difference browser: type === "umd", customResolveOptions: { moduleDirectory: type === "umd" ? "node_modules" : [], @@ -193,6 +199,14 @@ export let getRollupConfig = ( }, preventAssignment: true, }), + type === "worker" && + replace({ + values: { + ["typeof " + "document"]: JSON.stringify("undefined"), + ["typeof " + "window"]: JSON.stringify("undefined"), + }, + preventAssignment: true, + }), ].filter((x): x is Plugin => !!x), }; diff --git a/packages/cli/src/dev.ts b/packages/cli/src/dev.ts index b95de07e..d76ec648 100644 --- a/packages/cli/src/dev.ts +++ b/packages/cli/src/dev.ts @@ -1,6 +1,11 @@ import { Project } from "./project"; import { success, info } from "./logger"; -import { tsTemplate, flowTemplate, validFields } from "./utils"; +import { + tsTemplate, + flowTemplate, + validFieldsForEntrypoint, + getExportsFieldOutputPath, +} from "./utils"; import * as babel from "@babel/core"; import * as fs from "fs-extra"; import path from "path"; @@ -69,7 +74,7 @@ export async function writeDevTSFile( entrypointSourceContent: string ) { let cjsDistPath = path - .join(entrypoint.directory, validFields.main(entrypoint)) + .join(entrypoint.directory, validFieldsForEntrypoint.main(entrypoint)) .replace(/\.js$/, ".d.ts"); let output = await (entrypoint.package.project.experimentalFlags @@ -115,7 +120,7 @@ async function writeTypeSystemFile( if (typeSystem === undefined) return; let cjsDistPath = path.join( entrypoint.directory, - validFields.main(entrypoint) + validFieldsForEntrypoint.main(entrypoint) ); if (typeSystem === "flow") { @@ -166,7 +171,10 @@ export default async function dev(projectDir: string) { let promises = [ writeTypeSystemFile(typeSystemPromise, entrypoint), fs.writeFile( - path.join(entrypoint.directory, validFields.main(entrypoint)), + path.join( + entrypoint.directory, + validFieldsForEntrypoint.main(entrypoint) + ), `"use strict"; // this file might look strange and you might be wondering what it's for // it's lets you import your source files by importing this entrypoint @@ -203,17 +211,33 @@ unregister(); promises.push( fs.symlink( entrypoint.source, - path.join(entrypoint.directory, validFields.module(entrypoint)) + path.join( + entrypoint.directory, + validFieldsForEntrypoint.module(entrypoint) + ) + ) + ); + } + + if (pkg.exportsFieldConfig()?.envConditions?.has("worker")) { + promises.push( + fs.symlink( + entrypoint.source, + path.join( + pkg.directory, + getExportsFieldOutputPath(entrypoint, "worker") + ) ) ); } + if (entrypoint.json.browser) { - let browserField = validFields.browser(entrypoint); - for (let key of Object.keys(browserField)) { + let browserField = validFieldsForEntrypoint.browser(entrypoint); + for (let output of Object.values(browserField)) { promises.push( fs.symlink( entrypoint.source, - path.join(entrypoint.directory, browserField[key]) + path.join(entrypoint.directory, output) ) ); } diff --git a/packages/cli/src/entrypoint.ts b/packages/cli/src/entrypoint.ts index e238fc9b..72ead774 100644 --- a/packages/cli/src/entrypoint.ts +++ b/packages/cli/src/entrypoint.ts @@ -1,6 +1,6 @@ import nodePath from "path"; import { Item } from "./item"; -import { Package } from "./package"; +import { Package, ExportsConditions } from "./package"; import { JSONValue } from "./utils"; import normalizePath from "normalize-path"; @@ -9,6 +9,7 @@ export class Entrypoint extends Item<{ module?: JSONValue; "umd:main"?: JSONValue; browser?: JSONValue; + exports?: Record; preconstruct: { source?: JSONValue; umdName?: JSONValue; diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index ed5279f5..0d2e2ce6 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -2,7 +2,7 @@ import { PKG_JSON_CONFIG_FIELD } from "./constants"; import { createPromptConfirmLoader } from "./prompt"; import chalk from "chalk"; -type Field = "main" | "module" | "browser" | "umd:main"; +type Field = "main" | "module" | "browser" | "umd:main" | "exports"; export let errors = { noSource: (source: string) => @@ -20,6 +20,9 @@ export let errors = { "packages must have at least one entrypoint, this package has no entrypoints", fieldMustExistInAllEntrypointsIfExistsDeclinedFixDuringInit: (field: Field) => `all entrypoints in a package must have the same fields and one entrypoint in this package has a ${field} field but you've declined the fix`, + missingBrowserConditionWithFieldPresent: `the exports field is configured and the browser field exists in this package but it is not specified in the preconstruct.exports.envConditions field`, + missingBrowserFieldWithConditionPresent: `the exports field is configured and the browser condition is set in preconstruct.exports.envConditions but the field is not present at the top-level`, + noModuleFieldWithExportsField: `when using the exports field, the module field must also be specified`, }; export let confirms = { diff --git a/packages/cli/src/package.ts b/packages/cli/src/package.ts index a8d1d7e5..ddbd49c5 100644 --- a/packages/cli/src/package.ts +++ b/packages/cli/src/package.ts @@ -11,7 +11,7 @@ import { errors, confirms } from "./messages"; import { Project } from "./project"; import { getUselessGlobsThatArentReallyGlobsForNewEntrypoints } from "./glob-thing"; import { - validFields, + validFieldsForEntrypoint, validFieldsFromPkg, JSONValue, getEntrypointName, @@ -21,13 +21,14 @@ import normalizePath from "normalize-path"; function getFieldsUsedInEntrypoints( descriptors: { contents: string | undefined; filename: string }[] -) { - const fields = new Set(["main"]); +): Set { + const fields = new Set(["main"]); for (let descriptor of descriptors) { if (descriptor.contents !== undefined) { let parsed = jsonParse(descriptor.contents, descriptor.filename); for (let field of ["module", "umd:main", "browser"] as const) { - if (parsed[field] !== undefined) { + const value = parsed[field]; + if (value !== undefined) { fields.add(field); } } @@ -38,13 +39,13 @@ function getFieldsUsedInEntrypoints( function getPlainEntrypointContent( pkg: Package, - fields: Set, + fields: Set, entrypointDir: string, indent: string ) { const obj: Partial + keyof typeof validFieldsForEntrypoint, + string | Record >> = {}; for (const field of fields) { if (field === "browser") { @@ -102,11 +103,23 @@ function createEntrypoints( ); } +export type ExportsConditions = { + module: string | { worker?: string; browser?: string; default: string }; + default: string; +}; + +export type EnvCondition = "browser" | "worker"; + export class Package extends Item<{ name?: JSONValue; preconstruct: { + exports?: { + extra?: Record; + envConditions?: EnvCondition[]; + }; entrypoints?: JSONValue; }; + exports?: Record; dependencies?: Record; peerDependencies?: Record; }> { @@ -143,6 +156,20 @@ export class Package extends Item<{ onlyFiles: true, absolute: true, }); + // sorting the entrypoints is important since we want to have something consistent + // to write into the `exports` field and file systems don't guarantee an order + entrypoints = [ + ...entrypoints.sort((a, b) => { + // shortest entrypoints first since shorter entrypoints + // are generally more commonly used + const comparison = a.length - b.length; + if (comparison !== 0) return comparison; + // then .sort's default behaviour because we just need something stable + if (a < b) return -1; + if (b > a) return 1; + return 0; + }), + ]; if (!entrypoints.length) { let oldEntrypoints = await fastGlob(pkg.configEntrypoints, { cwd: pkg.directory, @@ -269,7 +296,7 @@ export class Package extends Item<{ entrypoint.json = setFieldInOrder( entrypoint.json, field, - validFields[field](entrypoint) + validFieldsForEntrypoint[field](entrypoint) ); }); } @@ -283,4 +310,114 @@ export class Package extends Item<{ } return this.json.name; } + + exportsFieldConfig(): CanonicalExportsFieldConfig { + if (!this.project.experimentalFlags.exports) { + return; + } + let defaultExportsFieldEnabled = false; + if (this.project.directory !== this.directory) { + const exportsFieldConfig = this.project.json.preconstruct.exports; + if (exportsFieldConfig !== undefined) { + if (typeof exportsFieldConfig === "boolean") { + defaultExportsFieldEnabled = exportsFieldConfig; + } else { + throw new FatalError( + 'the "preconstruct.exports" field must be a boolean at the project level', + this.project.name + ); + } + } + } + return parseExportsFieldConfig( + this.json.preconstruct.exports, + defaultExportsFieldEnabled, + this.name + ); + } +} + +type CanonicalExportsFieldConfig = + | undefined + | { + envConditions: Set<"worker" | "browser">; + extra: Record; + }; + +function parseExportsFieldConfig( + _config: unknown, + defaultExportsFieldEnabled: boolean, + name: string +): CanonicalExportsFieldConfig { + // the seperate assignment vs declaration is so that TypeScript's + // control flow analysis does what we want + let config; + config = _config; + if ( + (typeof config !== "boolean" && + typeof config !== "object" && + config !== undefined) || + config === null || + Array.isArray(config) + ) { + throw new FatalError( + 'the "preconstruct.exports" field must be a boolean or an object at the package level', + name + ); + } + if (config === undefined) { + config = defaultExportsFieldEnabled; + } + if (config === false) { + return undefined; + } + const parsedConfig: CanonicalExportsFieldConfig = { + envConditions: new Set(), + extra: {}, + }; + if (config === true) { + return parsedConfig; + } + for (const [key, value] of Object.entries(config) as [string, unknown][]) { + if (key === "extra") { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + parsedConfig.extra = value as Record; + } else { + throw new FatalError( + 'the "preconstruct.exports.extra" field must be an object if it is present', + name + ); + } + } else if (key === "envConditions") { + if ( + Array.isArray(value) && + value.every( + (v): v is "worker" | "browser" => v === "worker" || v === "browser" + ) + ) { + parsedConfig.envConditions = new Set(value); + if (parsedConfig.envConditions.size !== value.length) { + throw new FatalError( + 'the "preconstruct.exports.envConditions" field must not have duplicates', + name + ); + } + } else { + throw new FatalError( + 'the "preconstruct.exports.envConditions" field must be an array containing zero or more of "worker" and "browser" if it is present', + name + ); + } + } else { + throw new FatalError( + `the "preconstruct.exports" field contains an unknown key "${key}"`, + name + ); + } + } + return parsedConfig; } diff --git a/packages/cli/src/project.ts b/packages/cli/src/project.ts index 0646ad5a..3171ada5 100644 --- a/packages/cli/src/project.ts +++ b/packages/cli/src/project.ts @@ -25,7 +25,9 @@ export class Project extends Item<{ globals?: Record; packages?: JSONValue; distFilenameStrategy?: JSONValue; + exports?: JSONValue; ___experimentalFlags_WILL_CHANGE_IN_PATCH: { + exports?: JSONValue; logCompiledFiles?: JSONValue; typeScriptProxyFileWithImportEqualsRequireAndExportEquals?: JSONValue; keepDynamicImportAsDynamicImportInCommonJS?: JSONValue; @@ -36,6 +38,7 @@ export class Project extends Item<{ let config = this.json.preconstruct.___experimentalFlags_WILL_CHANGE_IN_PATCH || {}; return { + exports: !!config.exports, logCompiledFiles: !!config.logCompiledFiles, typeScriptProxyFileWithImportEqualsRequireAndExportEquals: !!config.typeScriptProxyFileWithImportEqualsRequireAndExportEquals, keepDynamicImportAsDynamicImportInCommonJS: !!config.keepDynamicImportAsDynamicImportInCommonJS, diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 2259ad0c..9a9206c8 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,6 +1,6 @@ import normalizePath from "normalize-path"; import { Entrypoint } from "./entrypoint"; -import { Package } from "./package"; +import { Package, ExportsConditions } from "./package"; import * as nodePath from "path"; import { FatalError } from "./errors"; @@ -15,11 +15,12 @@ let fields = [ "module", "umd:main", "browser", + "exports", ]; export function setFieldInOrder< Obj extends { [key: string]: any }, - Key extends "main" | "module" | "umd:main" | "browser", + Key extends "main" | "module" | "umd:main" | "browser" | "exports", Val extends any >(obj: Obj, field: Key, value: Val): Obj & { [k in Key]: Val } { if (field in obj) { @@ -121,17 +122,70 @@ export const validFieldsFromPkg = { browser(pkg: Package, hasModuleBuild: boolean, entrypointName: string) { let safeName = getDistName(pkg, entrypointName); - let obj = { - [`./dist/${safeName}.cjs.js`]: `./dist/${safeName}.browser.cjs.js`, + const moduleBuild = { + [`./dist/${safeName}.esm.js`]: `./dist/${safeName}.browser.esm.js`, }; - if (hasModuleBuild) { - obj[`./dist/${safeName}.esm.js`] = `./dist/${safeName}.browser.esm.js`; + if (pkg.exportsFieldConfig()) { + return moduleBuild; } - return obj; + + return { + [`./dist/${safeName}.cjs.js`]: `./dist/${safeName}.browser.cjs.js`, + ...(hasModuleBuild && moduleBuild), + }; }, }; -export const validFields = { +export function exportsField( + pkg: Package +): Record | undefined { + const exportsFieldConfig = pkg.exportsFieldConfig(); + if (!exportsFieldConfig) { + return; + } + + let output: Record = {}; + pkg.entrypoints.forEach((entrypoint) => { + const esmBuild = getExportsFieldOutputPath(entrypoint, "esm"); + const exportConditions = { + module: exportsFieldConfig.envConditions.size + ? { + ...(exportsFieldConfig.envConditions.has("worker") && { + worker: getExportsFieldOutputPath(entrypoint, "worker"), + }), + ...(exportsFieldConfig.envConditions.has("browser") && { + browser: getExportsFieldOutputPath(entrypoint, "browser"), + }), + default: esmBuild, + } + : esmBuild, + default: getExportsFieldOutputPath(entrypoint, "cjs"), + }; + + output[ + "." + entrypoint.name.replace(entrypoint.package.name, "") + ] = exportConditions; + }); + return { + ...output, + "./package.json": "./package.json", + ...exportsFieldConfig.extra, + }; +} + +export function getExportsFieldOutputPath( + entrypoint: Entrypoint, + type: "cjs" | "esm" | "worker" | "browser" +) { + const safeName = getDistName(entrypoint.package, entrypoint.name); + const format = type === "cjs" ? "cjs" : "esm"; + const env = type === "worker" || type === "browser" ? type : undefined; + + const prefix = entrypoint.name.replace(entrypoint.package.name, ""); + return `.${prefix}/dist/${safeName}.${env ? `${env}.` : ""}${format}.js`; +} + +export const validFieldsForEntrypoint = { main(entrypoint: Entrypoint) { return validFieldsFromPkg.main(entrypoint.package, entrypoint.name); }, diff --git a/packages/cli/src/validate-package.ts b/packages/cli/src/validate-package.ts index 65f0b26f..81812546 100644 --- a/packages/cli/src/validate-package.ts +++ b/packages/cli/src/validate-package.ts @@ -3,6 +3,8 @@ import resolveFrom from "resolve-from"; import chalk from "chalk"; import { errors } from "./messages"; import { Package } from "./package"; +import { isFieldValid } from "./validate"; +import { setFieldInOrder, exportsField } from "./utils"; let keys: (obj: Obj) => (keyof Obj)[] = Object.keys; @@ -10,18 +12,43 @@ export async function fixPackage(pkg: Package) { if (pkg.entrypoints.length === 0) { throw new FatalError(errors.noEntrypoints, pkg.name); } + + const exportsFieldConfig = pkg.exportsFieldConfig(); + let fields = { main: true, - module: pkg.entrypoints.some((x) => x.json.module !== undefined), + module: + pkg.entrypoints.some((x) => x.json.module !== undefined) || + !!exportsFieldConfig, "umd:main": pkg.entrypoints.some((x) => x.json["umd:main"] !== undefined), browser: pkg.entrypoints.some((x) => x.json.browser !== undefined), }; + if (exportsFieldConfig) { + if (fields.browser || exportsFieldConfig.envConditions.has("browser")) { + if (typeof pkg.json.preconstruct.exports !== "object") { + pkg.json.preconstruct.exports = {}; + } + if (!pkg.json.preconstruct.exports.envConditions) { + pkg.json.preconstruct.exports.envConditions = []; + } + if (!pkg.json.preconstruct.exports.envConditions.includes("browser")) { + pkg.json.preconstruct.exports.envConditions.push("browser"); + } + fields.browser = true; + } + } + keys(fields) .filter((x) => fields[x]) .forEach((field) => { pkg.setFieldOnEntrypoints(field); }); + + pkg.json = setFieldInOrder(pkg.json, "exports", exportsField(pkg)); + + await pkg.save(); + return (await Promise.all(pkg.entrypoints.map((x) => x.save()))).some( (x) => x ); @@ -41,8 +68,38 @@ export function validatePackage(pkg: Package) { module: pkg.entrypoints[0].json.module !== undefined, "umd:main": pkg.entrypoints[0].json["umd:main"] !== undefined, browser: pkg.entrypoints[0].json.browser !== undefined, + // "exports" is not here because it is not like these fields, it exists on a package, not an entrypoint }; + const exportsFieldConfig = pkg.exportsFieldConfig(); + + if (exportsFieldConfig) { + if (!fields.module) { + throw new FixableError(errors.noModuleFieldWithExportsField, pkg.name); + } + const hasField = fields.browser; + const hasCondition = exportsFieldConfig.envConditions.has("browser"); + if (hasField && !hasCondition) { + throw new FixableError( + errors.missingBrowserConditionWithFieldPresent, + pkg.name + ); + } + if (!hasField && hasCondition) { + throw new FixableError( + errors.missingBrowserFieldWithConditionPresent, + pkg.name + ); + } + } + + if (!isFieldValid.exports(pkg)) { + throw new FixableError( + errors.invalidField("exports", pkg.json.exports, exportsField(pkg)), + pkg.name + ); + } + pkg.entrypoints.forEach((entrypoint) => { keys(fields).forEach((field) => { if (entrypoint.json[field] && !fields[field]) { diff --git a/packages/cli/src/validate.ts b/packages/cli/src/validate.ts index e0e65cc3..8f7c7e56 100644 --- a/packages/cli/src/validate.ts +++ b/packages/cli/src/validate.ts @@ -1,8 +1,9 @@ import { Project } from "./project"; +import { Package } from "./package"; import { Entrypoint } from "./entrypoint"; import { errors, successes, infos } from "./messages"; import { BatchError, FatalError, FixableError } from "./errors"; -import { validFields } from "./utils"; +import { exportsField, validFieldsForEntrypoint } from "./utils"; import * as logger from "./logger"; import equal from "fast-deep-equal"; import { validatePackage } from "./validate-package"; @@ -14,16 +15,32 @@ import chalk from "chalk"; export const isFieldValid = { main(entrypoint: Entrypoint) { - return entrypoint.json.main === validFields.main(entrypoint); + return entrypoint.json.main === validFieldsForEntrypoint.main(entrypoint); }, module(entrypoint: Entrypoint) { - return entrypoint.json.module === validFields.module(entrypoint); + return ( + entrypoint.json.module === validFieldsForEntrypoint.module(entrypoint) + ); }, "umd:main"(entrypoint: Entrypoint) { - return entrypoint.json["umd:main"] === validFields["umd:main"](entrypoint); + return ( + entrypoint.json["umd:main"] === + validFieldsForEntrypoint["umd:main"](entrypoint) + ); }, browser(entrypoint: Entrypoint): boolean { - return equal(entrypoint.json.browser, validFields.browser(entrypoint)); + return equal( + entrypoint.json.browser, + validFieldsForEntrypoint.browser(entrypoint) + ); + }, + exports(pkg: Package): boolean { + const generated = exportsField(pkg); + if (generated === undefined) { + return true; + } + // JSON.stringify to make sure conditions are in proper order + return JSON.stringify(pkg.json.exports) === JSON.stringify(generated); }, }; @@ -50,7 +67,8 @@ function validateEntrypoint(entrypoint: Entrypoint, log: boolean) { entrypoint.package.project.json.preconstruct.distFilenameStrategy = "unscoped-package-name"; isUsingOldDistFilenames = - validFields[field](entrypoint) === entrypoint.json[field]; + validFieldsForEntrypoint[field](entrypoint) === + entrypoint.json[field]; } finally { if (prevDistFilenameStrategy === undefined) { delete entrypoint.package.project.json.preconstruct @@ -83,7 +101,7 @@ function validateEntrypoint(entrypoint: Entrypoint, log: boolean) { errors.invalidField( field, entrypoint.json[field], - validFields[field](entrypoint) + validFieldsForEntrypoint[field](entrypoint) ), entrypoint.name ) @@ -110,6 +128,7 @@ export const FORMER_FLAGS_THAT_ARE_ENABLED_NOW = new Set([ ]); export const EXPERIMENTAL_FLAGS = new Set([ + "exports", "logCompiledFiles", "typeScriptProxyFileWithImportEqualsRequireAndExportEquals", "keepDynamicImportAsDynamicImportInCommonJS", diff --git a/site/src/pages/configuration.mdx b/site/src/pages/configuration.mdx index 7b6d56e5..a5635b78 100644 --- a/site/src/pages/configuration.mdx +++ b/site/src/pages/configuration.mdx @@ -132,6 +132,97 @@ Packages map 1:1 with npm packages. Along with specifying the `entrypoints` opti } ``` +### `exports` (experimental) + +```ts +| boolean +| { + envConditions?: ("browser" | "worker")[]; + extra?: Record; + }; +``` + +The `exports` config allows you to opt-in to generating an `exports` field. + +Using the `exports` field enables a couple of things: + +- Importing non-root entrypoints in Node.js ESM +- Disallowing importing modules that aren't specified in the `exports` field +- More specific builds for certain environments + +> Note that Preconstruct's support for the `exports` field does not currently include generating ESM compatible with Node.js. While ESM builds are generated, they are targeting bundlers, not Node.js or browsers directly so they use the `module` condition, not the `import` condition. + +Note that adding an `exports` field can arguably be a breaking change, you may want to use the `extra` option to add more exports so that imports that worked previously still work or only add the `exports` field in a major version. + +To opt into this experimental feature, you must enable it in the root of your project by setting the `exports` experimental flag in your preconstruct config section of your `package.json` file to `true`. + +```diff +{ + "name": "@sample/repo", + "version": "0.0.0", + "preconstruct": { ++ "___experimentalFlags_WILL_CHANGE_IN_PATCH": { ++ "exports": true ++ }, + } +} +``` + +The `exports` field feature then needs to be enabled, you can do this at the project or package level like this. The `envConditions` and `extra` options can only be configured at a package level. + +```diff +{ + "name": "@sample/package", + "version": "1.0.0", + "preconstruct": { ++ "exports": true + } +} +``` + +#### `envConditions` + +`Array<"browser" | "worker">` + +Specifying the `envConditions` option adds additional environments that Preconstruct will generate bundles for. This option is currently aimed at generating bundles with `typeof SOME_ENV_SPECIFIC_GLOBAL` replaced with what it would be in that environment. It may be expanded to provide the ability to have Preconstruct resolve a different file or etc. depending on the environment in the future. + +Builds + +- `browser`: Generates a bundle targeting browsers. When this condition is used, the top-level `browser` field will also be set so that older bundlers that do not understand the `exports` field will be able to use the browser build (though when using the exports field, browser CommonJS builds will not be built). When building with this condition, `typeof document` and `typeof window` will be replaced with `"object"` and dead-code elimination will occur based on that. +- `worker`: Generates a bundle targeting web workers/server-side JS runtimes that use web APIs. When building with this condition, `typeof document` and `typeof window` will be replaced with `"undefined"` and dead-code elimination will occur based on that. + +```json +{ + "name": "@sample/package", + "version": "1.0.0", + "preconstruct": { + "exports": { + "envConditions": ["browser", "worker"] + } + } +} +``` + +#### `extra` + +`Record` + +Preconstruct will enforce that the `exports` field that is written can is directly a function of your config, this means that extra properties are not allowed to be written directly in the `exports` field. If you want to add extra entries to the `exports` field, you can use the `extra` option in `preconstruct.exports` and then `preconstruct fix` will add add them to the actual `exports` field. + +```json +{ + "name": "@sample/package", + "version": "1.0.0", + "preconstruct": { + "exports": { + "extra": { + "./something": "./something.js" + } + } + } +} +``` + ## Entrypoints Entrypoints are the lowest level configuration point and describe a set of bundles for a particular entrypoint. They are configured by the `package.json` in the folder of the entrypoint. We also have a guide on [adding a second entrypoint](/guides/adding-a-second-entrypoint) diff --git a/yarn.lock b/yarn.lock index 40519c2f..f9d9086e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15213,10 +15213,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.5.2: - version "4.5.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" - integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw== +typescript@^4.7.4: + version "4.7.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" + integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== typescript@~3.9.7: version "3.9.7"