139 lines
4.3 KiB
JavaScript
139 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
const root = path.resolve(process.argv[2] ?? ".");
|
|
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
const readmeText = readFileSync(path.join(root, "README.md"), "utf8");
|
|
const filePaths = npmPackFilePaths(root);
|
|
const result = buildPackageContentsChecklist({ filePaths, packageJson, readmeText });
|
|
|
|
printChecklist(result);
|
|
if (result.status === "fail") {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
export function buildPackageContentsChecklist({ filePaths, packageJson, readmeText = "" }) {
|
|
const files = new Set(filePaths);
|
|
const checks = [
|
|
...requiredFileChecks(files),
|
|
...entrypointChecks(files, packageJson),
|
|
...readmeAssetChecks(files, readmeText),
|
|
...forbiddenPathChecks(filePaths),
|
|
];
|
|
|
|
return {
|
|
status: checks.some((check) => check.status === "fail") ? "fail" : "pass",
|
|
entryCount: filePaths.length,
|
|
checks,
|
|
};
|
|
}
|
|
|
|
function requiredFileChecks(files) {
|
|
return [
|
|
"package.json",
|
|
"README.md",
|
|
"CHANGELOG.md",
|
|
"LICENSE",
|
|
"src/cli.js",
|
|
"src/index.js",
|
|
"examples/github-actions-plugin-inspector.yml",
|
|
"examples/plugin-inspector.config.json",
|
|
].map((filePath) => presenceCheck(files, "package-required-file", filePath));
|
|
}
|
|
|
|
function entrypointChecks(files, packageJson) {
|
|
const checks = [];
|
|
for (const [name, filePath] of Object.entries(packageJson.bin ?? {})) {
|
|
checks.push(presenceCheck(files, "package-bin-entry", normalizePackagePath(filePath), name));
|
|
}
|
|
for (const [name, target] of Object.entries(packageJson.exports ?? {})) {
|
|
for (const filePath of exportTargets(target)) {
|
|
checks.push(presenceCheck(files, "package-export-entry", normalizePackagePath(filePath), name));
|
|
}
|
|
}
|
|
return checks;
|
|
}
|
|
|
|
function readmeAssetChecks(files, readmeText) {
|
|
const assetPaths = new Set();
|
|
for (const match of readmeText.matchAll(/<img\s+[^>]*src=["']([^"']+)["']/gi)) {
|
|
assetPaths.add(match[1]);
|
|
}
|
|
for (const match of readmeText.matchAll(/!\[[^\]]*]\(([^)]+)\)/g)) {
|
|
assetPaths.add(match[1]);
|
|
}
|
|
|
|
return [...assetPaths]
|
|
.filter((assetPath) => isPackageRelativeAsset(assetPath))
|
|
.map((assetPath) => presenceCheck(files, "package-readme-asset", normalizePackagePath(assetPath)));
|
|
}
|
|
|
|
function forbiddenPathChecks(filePaths) {
|
|
const forbiddenPrefixes = ["test/", "scripts/", ".github/"];
|
|
return filePaths
|
|
.filter((filePath) => forbiddenPrefixes.some((prefix) => filePath.startsWith(prefix)))
|
|
.map((filePath) => ({
|
|
id: "package-forbidden-path",
|
|
status: "fail",
|
|
message: `${filePath} should not be published in the npm package`,
|
|
}));
|
|
}
|
|
|
|
function presenceCheck(files, id, filePath, detail = filePath) {
|
|
return {
|
|
id,
|
|
status: files.has(filePath) ? "pass" : "fail",
|
|
message: `${detail} includes ${filePath}`,
|
|
expected: filePath,
|
|
actual: files.has(filePath) ? filePath : "missing",
|
|
};
|
|
}
|
|
|
|
function exportTargets(target) {
|
|
if (typeof target === "string") {
|
|
return [target];
|
|
}
|
|
if (target && typeof target === "object") {
|
|
return Object.values(target).flatMap((value) => exportTargets(value));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function isPackageRelativeAsset(assetPath) {
|
|
return !assetPath.startsWith("http://")
|
|
&& !assetPath.startsWith("https://")
|
|
&& !assetPath.startsWith("#")
|
|
&& !assetPath.startsWith("/");
|
|
}
|
|
|
|
function normalizePackagePath(filePath) {
|
|
return filePath.replace(/^\.\//, "");
|
|
}
|
|
|
|
function npmPackFilePaths(root) {
|
|
const output = execFileSync("npm", ["pack", "--dry-run", "--json"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
});
|
|
const pack = JSON.parse(output)[0];
|
|
return pack.files.map((file) => file.path).sort();
|
|
}
|
|
|
|
function printChecklist(result) {
|
|
console.log(`package contents: ${result.status}`);
|
|
console.log(`package entries: ${result.entryCount}`);
|
|
for (const check of result.checks) {
|
|
console.log(`- ${check.status.toUpperCase()} ${check.id}: ${check.message}`);
|
|
if (check.status === "fail" && check.actual !== check.expected) {
|
|
console.log(` expected: ${check.expected}`);
|
|
console.log(` actual: ${check.actual}`);
|
|
}
|
|
}
|
|
}
|