Skip to content

Commit

Permalink
Type only packages (#144)
Browse files Browse the repository at this point in the history
* types only build for single package

* format

* monorepo fixture with types only

* refactor build cmd for efficiency

* correct package.json for types only

* test c in simple-monorepo

* skip exports integrity check for types-only

* changeset
  • Loading branch information
enisdenjo committed Oct 24, 2022
1 parent 4697b5f commit 76fd23c
Show file tree
Hide file tree
Showing 11 changed files with 222 additions and 37 deletions.
5 changes: 5 additions & 0 deletions .changeset/tricky-poems-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bob-the-bundler": patch
---

Correct package.json for types-only packages
114 changes: 77 additions & 37 deletions src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,53 +223,69 @@ async function build({
return;
}

validatePackageJson(pkg, config?.commonjs ?? true);

// remove <project>/dist
await fse.remove(distPath);

// Copy type definitions
await fse.ensureDir(join(distPath, "typings"));

const declarations = await globby("**/*.d.ts", {
cwd: getBuildPath("esm"),
absolute: false,
ignore: filesToExcludeFromDist,
});

await Promise.all(
declarations.map((filePath) =>
limit(() =>
fse.copy(
join(getBuildPath("esm"), filePath),
join(distPath, "typings", filePath)
)
)
)
);

// Move ESM to dist/esm
await fse.ensureDir(join(distPath, "esm"));

const esmFiles = await globby("**/*.js", {
cwd: getBuildPath("esm"),
absolute: false,
ignore: filesToExcludeFromDist,
});

// Check whether al esm files are empty, if not - probably a types only build
let emptyEsmFiles = true;
for (const file of esmFiles) {
const src = await fse.readFile(join(getBuildPath("esm"), file));
if (src.toString().trim() !== "export {};") {
emptyEsmFiles = false;
break;
}
}

// Empty ESM files with existing declarations is a types-only package
const typesOnly = emptyEsmFiles && declarations.length > 0;

validatePackageJson(pkg, {
typesOnly,
includesCommonJS: config?.commonjs ?? true,
});

// remove <project>/dist
await fse.remove(distPath);

// Copy type definitions
await fse.ensureDir(join(distPath, "typings"));
await Promise.all(
esmFiles.map((filePath) =>
declarations.map((filePath) =>
limit(() =>
fse.copy(
join(getBuildPath("esm"), filePath),
join(distPath, "esm", filePath)
join(distPath, "typings", filePath)
)
)
)
);

if (config?.commonjs === undefined) {
// Transpile ESM to CJS and move CJS to dist/cjs
// If ESM files are not empty, copy them to dist/esm
if (!emptyEsmFiles) {
await fse.ensureDir(join(distPath, "esm"));
await Promise.all(
esmFiles.map((filePath) =>
limit(() =>
fse.copy(
join(getBuildPath("esm"), filePath),
join(distPath, "esm", filePath)
)
)
)
);
}

if (!emptyEsmFiles && config?.commonjs === undefined) {
// Transpile ESM to CJS and move CJS to dist/cjs only if there's something to transpile
await fse.ensureDir(join(distPath, "cjs"));

const cjsFiles = await globby("**/*.js", {
Expand Down Expand Up @@ -323,8 +339,9 @@ async function build({
// move the package.json to dist
await fse.writeFile(
join(distPath, "package.json"),
JSON.stringify(rewritePackageJson(pkg), null, 2)
JSON.stringify(rewritePackageJson(pkg, typesOnly), null, 2)
);

// move README.md and LICENSE and other specified files
await copyToDist(
cwd,
Expand All @@ -350,7 +367,7 @@ async function build({
reporter.success(`Built ${pkg.name}`);
}

function rewritePackageJson(pkg: Record<string, any>) {
function rewritePackageJson(pkg: Record<string, any>, typesOnly: boolean) {
const newPkg: Record<string, any> = {};
const fields = [
"name",
Expand Down Expand Up @@ -382,19 +399,26 @@ function rewritePackageJson(pkg: Record<string, any>) {

const distDirStr = `${DIST_DIR}/`;

newPkg.main = newPkg.main.replace(distDirStr, "");
newPkg.module = newPkg.module.replace(distDirStr, "");
if (typesOnly) {
newPkg.main = "";
delete newPkg.module;
delete newPkg.type;
} else {
newPkg.main = newPkg.main.replace(distDirStr, "");
newPkg.module = newPkg.module.replace(distDirStr, "");
}
newPkg.typings = newPkg.typings.replace(distDirStr, "");
newPkg.typescript = {
definition: newPkg.typescript.definition.replace(distDirStr, ""),
};

if (!pkg.exports) {
newPkg.exports = presetFields.exports;
if (!typesOnly) {
if (!pkg.exports) {
newPkg.exports = presetFields.exports;
}
newPkg.exports = rewriteExports(pkg.exports, DIST_DIR);
}

newPkg.exports = rewriteExports(pkg.exports, DIST_DIR);

if (pkg.bin) {
newPkg.bin = {};

Expand All @@ -406,7 +430,13 @@ function rewritePackageJson(pkg: Record<string, any>) {
return newPkg;
}

export function validatePackageJson(pkg: any, includesCommonJS: boolean) {
export function validatePackageJson(
pkg: any,
opts: {
typesOnly: boolean;
includesCommonJS: boolean;
}
) {
function expect(key: string, expected: unknown) {
const received = get(pkg, key);

Expand All @@ -418,13 +448,23 @@ export function validatePackageJson(pkg: any, includesCommonJS: boolean) {
);
}

// Type only packages have simpler rules (following the style of https://github.com/DefinitelyTyped/DefinitelyTyped packages)
if (opts.typesOnly) {
expect("main", "");
expect("module", undefined);
expect("typings", presetFields.typings);
expect("typescript.definition", presetFields.typescript.definition);
expect("exports", undefined);
return;
}

// If the package has NO binary we need to check the exports map.
// a package should either
// 1. have a bin property
// 2. have a exports property
// 3. have an exports and bin property
if (Object.keys(pkg.bin ?? {}).length > 0) {
if (includesCommonJS === true) {
if (opts.includesCommonJS === true) {
expect("main", presetFields.main);
expect("module", presetFields.module);
expect("typings", presetFields.typings);
Expand All @@ -442,7 +482,7 @@ export function validatePackageJson(pkg: any, includesCommonJS: boolean) {
pkg.typings !== undefined ||
pkg.typescript !== undefined
) {
if (includesCommonJS === true) {
if (opts.includesCommonJS === true) {
// if there is no bin property, we NEED to check the exports.
expect("main", presetFields.main);
expect("module", presetFields.module);
Expand Down
11 changes: 11 additions & 0 deletions src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ export const checkCommand = createCommand<{}, {}>((api) => {
const distPackageJSONPath = path.join(cwd, "dist", "package.json");
const distPackageJSON = await fse.readJSON(distPackageJSONPath);

// a tell for a types-only build is the lack of main import and presence of typings
if (
distPackageJSON.main === "" &&
(distPackageJSON.typings || "").endsWith("d.ts")
) {
api.reporter.warn(
`Skip check for '${packageJSON.name}' because it's a types-only package.`
);
return;
}

try {
await checkExportsMapIntegrity({
cwd: path.join(cwd, "dist"),
Expand Down
15 changes: 15 additions & 0 deletions test/__fixtures__/simple-monorepo/packages/c/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "c",
"main": "",
"typings": "dist/typings/index.d.ts",
"typescript": {
"definition": "dist/typings/index.d.ts"
},
"buildOptions": {
"input": "./src/index.ts"
},
"publishConfig": {
"directory": "dist",
"access": "public"
}
}
3 changes: 3 additions & 0 deletions test/__fixtures__/simple-monorepo/packages/c/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SomeType = "type";

export interface SomeInterface {}
1 change: 1 addition & 0 deletions test/__fixtures__/simple-types-only/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hi types!
1 change: 1 addition & 0 deletions test/__fixtures__/simple-types-only/foo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "hi": 1 }
26 changes: 26 additions & 0 deletions test/__fixtures__/simple-types-only/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "simple-types-only",
"main": "",
"typings": "dist/typings/index.d.ts",
"typescript": {
"definition": "dist/typings/index.d.ts"
},
"publishConfig": {
"directory": "dist",
"access": "public"
},
"type": "module",
"bob": {
"build": {
"copy": [
"foo.json",
"src/style.css"
]
},
"check": {
"skip": [
"./file-that-throws"
]
}
}
}
3 changes: 3 additions & 0 deletions test/__fixtures__/simple-types-only/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SomeType = "type";

export interface SomeInterface {}
8 changes: 8 additions & 0 deletions test/__fixtures__/simple-types-only/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "ESNext",
"skipLibCheck": true,
"declaration": true,
"outDir": "dist"
}
}
72 changes: 72 additions & 0 deletions test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ it("can build a monorepo project", async () => {
"b",
"dist"
);
const baseDistCPath = path.resolve(
fixturesFolder,
"simple-monorepo",
"packages",
"c",
"dist"
);
// prettier-ignore
const files = {
a: {
Expand All @@ -136,6 +143,10 @@ it("can build a monorepo project", async () => {
"esm/index.js": path.resolve(baseDistBPath, "esm", "index.js"),
"package.json": path.resolve(baseDistBPath, "package.json"),
},
c: {
"typings/index.d.ts": path.resolve(baseDistCPath, "typings", "index.d.ts"),
"package.json": path.resolve(baseDistCPath, "package.json"),
},
} as const;

expect(fse.readFileSync(files.a["cjs/index.js"], "utf8"))
Expand Down Expand Up @@ -294,6 +305,27 @@ it("can build a monorepo project", async () => {
}"
`);

expect(fse.existsSync(path.resolve(baseDistCPath, "cjs"))).toBeFalsy();
expect(fse.existsSync(path.resolve(baseDistCPath, "esm"))).toBeFalsy();
expect(fse.readFileSync(files.c["typings/index.d.ts"], "utf8"))
.toMatchInlineSnapshot(`
"export declare type SomeType = \\"type\\";
export interface SomeInterface {
}
"
`);
expect(fse.readFileSync(files.c["package.json"], "utf8"))
.toMatchInlineSnapshot(`
"{
\\"name\\": \\"c\\",
\\"main\\": \\"\\",
\\"typings\\": \\"typings/index.d.ts\\",
\\"typescript\\": {
\\"definition\\": \\"typings/index.d.ts\\"
}
}"
`);

await execa("node", [binaryFolder, "check"], {
cwd: path.resolve(fixturesFolder, "simple-monorepo"),
});
Expand Down Expand Up @@ -345,3 +377,43 @@ it("can build an esm only project", async () => {
"
`);
});

it("can build a types only project", async () => {
await fse.remove(path.resolve(fixturesFolder, "simple-types-only", "dist"));
const result = await execa("node", [binaryFolder, "build"], {
cwd: path.resolve(fixturesFolder, "simple-types-only"),
});
expect(result.exitCode).toEqual(0);

const baseDistPath = path.resolve(
fixturesFolder,
"simple-types-only",
"dist"
);

// types-only adjusted package.json
const packageJsonFilePath = path.resolve(baseDistPath, "package.json");
expect(fse.readFileSync(packageJsonFilePath, "utf8")).toMatchInlineSnapshot(`
"{
\\"name\\": \\"simple-types-only\\",
\\"main\\": \\"\\",
\\"typings\\": \\"typings/index.d.ts\\",
\\"typescript\\": {
\\"definition\\": \\"typings/index.d.ts\\"
}
}"
`);

// no cjs or esm files
expect(fse.existsSync(path.resolve(baseDistPath, "cjs"))).toBeFalsy();
expect(fse.existsSync(path.resolve(baseDistPath, "esm"))).toBeFalsy();

// only types
const indexDtsFilePath = path.resolve(baseDistPath, "typings", "index.d.ts");
expect(fse.readFileSync(indexDtsFilePath, "utf8")).toMatchInlineSnapshot(`
"export declare type SomeType = \\"type\\";
export interface SomeInterface {
}
"
`);
});

0 comments on commit 76fd23c

Please sign in to comment.