Skip to content

Commit

Permalink
feat: verify exports field in package.json
Browse files Browse the repository at this point in the history
fixes #159
  • Loading branch information
ext committed Apr 13, 2023
1 parent 3b4ff8f commit 85131f6
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 22 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -51,6 +51,7 @@ Requires files specified in `package.json` to be present.

Verifies the presence of files specified in:

- `exports` (wildcard patterns are ignored)
- `main`
- `browser`
- `module`
Expand Down
50 changes: 50 additions & 0 deletions src/__snapshots__/tarball.spec.ts.snap
Expand Up @@ -43,6 +43,56 @@ exports[`should return error if package.json references missing file browser 1`]
]
`;

exports[`should return error if package.json references missing file exports (condition) 1`] = `
[
{
"column": 1,
"line": 1,
"message": "./index.cjs (pkg.exports) is not present in tarball",
"ruleId": "no-missing-exports",
"severity": 2,
},
{
"column": 1,
"line": 1,
"message": "./index.mjs (pkg.exports) is not present in tarball",
"ruleId": "no-missing-exports",
"severity": 2,
},
]
`;

exports[`should return error if package.json references missing file exports (subpath) 1`] = `
[
{
"column": 1,
"line": 1,
"message": "./index.js (pkg.exports) is not present in tarball",
"ruleId": "no-missing-exports",
"severity": 2,
},
{
"column": 1,
"line": 1,
"message": "./dist/foo.js (pkg.exports) is not present in tarball",
"ruleId": "no-missing-exports",
"severity": 2,
},
]
`;

exports[`should return error if package.json references missing file exports (sugar) 1`] = `
[
{
"column": 1,
"line": 1,
"message": "./index.js (pkg.exports) is not present in tarball",
"ruleId": "no-missing-exports",
"severity": 2,
},
]
`;

exports[`should return error if package.json references missing file jsnext:main 1`] = `
[
{
Expand Down
50 changes: 30 additions & 20 deletions src/tarball.spec.ts
Expand Up @@ -49,16 +49,19 @@ it("should use reportPath if given", async () => {

describe("should return error if package.json references missing file", () => {
it.each`
field | template
${"main"} | ${{ main: "index.js" }}
${"browser"} | ${{ browser: "index.js" }}
${"module"} | ${{ module: "index.js" }}
${"jsnext:main"} | ${{ "jsnext:main": "index.js" }}
${"typings"} | ${{ typings: "index.d.ts" }}
${"bin (single)"} | ${{ bin: "index.js" }}
${"bin (multiple)"} | ${{ bin: { foo: "dist/foo.js", bar: "dist/bar.js" } }}
${"man (single)"} | ${{ man: "man/foo.1" }}
${"man (multiple)"} | ${{ man: ["man/foo.1", "man/bar.1"] }}
field | template
${"main"} | ${{ main: "index.js" }}
${"browser"} | ${{ browser: "index.js" }}
${"module"} | ${{ module: "index.js" }}
${"jsnext:main"} | ${{ "jsnext:main": "index.js" }}
${"typings"} | ${{ typings: "index.d.ts" }}
${"bin (single)"} | ${{ bin: "index.js" }}
${"bin (multiple)"} | ${{ bin: { foo: "dist/foo.js", bar: "dist/bar.js" } }}
${"man (single)"} | ${{ man: "man/foo.1" }}
${"man (multiple)"} | ${{ man: ["man/foo.1", "man/bar.1"] }}
${"exports (sugar)"} | ${{ exports: "./index.js" }}
${"exports (subpath)"} | ${{ exports: { ".": "./index.js", "./foo": "./dist/foo.js" } }}
${"exports (condition)"} | ${{ exports: { ".": { require: "./index.cjs", import: "./index.mjs" } } }}
`("$field", async ({ template }) => {
expect.assertions(3);
require("tar").__setMockFiles([]);
Expand All @@ -76,20 +79,27 @@ describe("should return error if package.json references missing file", () => {

describe("should not return error if package.json references existing file", () => {
it.each`
field | template
${"main"} | ${{ main: "index.js" }}
${"browser"} | ${{ browser: "index.js" }}
${"module"} | ${{ module: "index.js" }}
${"jsnext:main"} | ${{ "jsnext:main": "index.js" }}
${"typings"} | ${{ typings: "index.d.ts" }}
${"bin (single)"} | ${{ bin: "index.js" }}
${"bin (multiple)"} | ${{ bin: { foo: "dist/foo.js", bar: "dist/bar.js" } }}
${"man (single)"} | ${{ man: "man/foo.1" }}
${"man (multiple)"} | ${{ man: ["man/foo.1", "man/bar.1"] }}
field | template
${"main"} | ${{ main: "index.js" }}
${"browser"} | ${{ browser: "index.js" }}
${"module"} | ${{ module: "index.js" }}
${"jsnext:main"} | ${{ "jsnext:main": "index.js" }}
${"typings"} | ${{ typings: "index.d.ts" }}
${"bin (single)"} | ${{ bin: "index.js" }}
${"bin (multiple)"} | ${{ bin: { foo: "dist/foo.js", bar: "dist/bar.js" } }}
${"man (single)"} | ${{ man: "man/foo.1" }}
${"man (multiple)"} | ${{ man: ["man/foo.1", "man/bar.1"] }}
${"exports (sugar)"} | ${{ exports: "./index.js" }}
${"exports (subpath)"} | ${{ exports: { ".": "./index.js", "./foo": "./dist/foo.js" } }}
${"exports (condition)"} | ${{ exports: { ".": { require: "./index.cjs", import: "./index.mjs" } } }}
${"exports (wildcard)"} | ${{ exports: { "./foo/*": "./foo/*.js" } }}
${"exports (null)"} | ${{ exports: { ".": null, "./foo": { condition: null } } }}
`("$field", async ({ template }) => {
expect.assertions(1);
require("tar").__setMockFiles([
"index.js",
"index.cjs",
"index.mjs",
"index.d.ts",
"dist/foo.js",
"dist/bar.js",
Expand Down
49 changes: 48 additions & 1 deletion src/tarball.ts
@@ -1,6 +1,6 @@
import fs from "fs";
import tar, { Parse, ReadEntry } from "tar";
import PackageJson from "./types/package-json";
import PackageJson, { type PackageJsonExports } from "./types/package-json";
import { isBlacklisted } from "./blacklist";
import { Message } from "./message";
import { Result } from "./result";
Expand Down Expand Up @@ -96,6 +96,50 @@ function* yieldRequiredFiles(
}
}

function normalizeExportedField(
exports: PackageJsonExports
): Record<string, Record<string, string | null>> {
if (typeof exports === "string") {
return {
".": {
default: exports,
},
};
}
const entries = Object.entries<string | null | Record<string, string | null>>(exports);
return Object.fromEntries(
entries.map(([key, value]) => {
if (typeof value === "string" || value === null) {
return [key, { default: value }];
} else {
return [key, value];
}
})
);
}

function* yieldExportedFiles(exports: PackageJsonExports): Generator<RequiredFile> {
const normalized = normalizeExportedField(exports);
const entries = Object.entries(normalized);
for (const [key, value] of entries) {
/* ignore wildcards for now */
if (key.includes("*")) {
continue;
}
for (const filename of Object.values(value)) {
/* explicitly denied path, not considered for this purpose */
if (filename === null) {
continue;
}
yield {
field: "exports",
ruleId: "no-missing-exports",
filename,
};
}
}
}

function* requiredFiles(pkg: PackageJson): Generator<RequiredFile> {
if (pkg.main) {
yield* yieldRequiredFiles(pkg.main, { field: "main", ruleId });
Expand All @@ -106,6 +150,9 @@ function* requiredFiles(pkg: PackageJson): Generator<RequiredFile> {
if (pkg.module) {
yield* yieldRequiredFiles(pkg.module, { field: "module", ruleId });
}
if (pkg.exports) {
yield* yieldExportedFiles(pkg.exports);
}
/* eslint-disable-next-line sonarjs/no-duplicate-string -- doesn't help readability */
if (pkg["jsnext:main"]) {
yield* yieldRequiredFiles(pkg["jsnext:main"], {
Expand Down
18 changes: 18 additions & 0 deletions src/types/package-json.ts
Expand Up @@ -5,6 +5,23 @@ interface UrlObject {
email?: string;
}

type Filename = string | null;
type Subpath = string;
type Condition = "node-addons" | "node" | "import" | "require" | "default" | string;

/**
* @internal
*/
export type PackageJsonExports =
/* "exports": "./index.js" */
| string
/* "exports": { ".": "./index.js" } */
| Record<Subpath, Filename>
/* "exports": { ".": { "require": "./index.cjs", "import": "./index.mjs" } } */
| Record<Subpath, Record<Condition, Filename>>
/* "exports": { "require": "./index.cjs", "import": "./index.mjs" } */
| Record<Condition, Filename>;

export default interface PackageJson {
name: string;
version: string;
Expand All @@ -19,6 +36,7 @@ export default interface PackageJson {
browser?: string | Record<string, string | false>;
module?: string;
"jsnext:main"?: string;
exports?: PackageJsonExports;
typings?: string;
bin?: string | Record<string, string>;
man?: string | string[];
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Expand Up @@ -5,7 +5,7 @@
"declaration": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"lib": ["es2017"],
"lib": ["es2020"],
"module": "commonjs",
"noImplicitAny": true,
"noEmit": true,
Expand Down

0 comments on commit 85131f6

Please sign in to comment.