diff --git a/README.md b/README.md index 0dc376ca..9fd5d4ff 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,8 @@ For templates (experimental): pnpm exec pkg-pr-new publish './packages/A' --template './examples/*' ``` +`catalog:` and `catalog:` dependencies in a template's `package.json` are resolved from the nearest `pnpm-workspace.yaml`, so templates can use [pnpm catalogs](https://pnpm.io/catalogs). + **`--no-template`** By default, pkg.pr.new will generate a template called "default" which includes each built package in the dependencies. This can be disabled with `--no-template`. diff --git a/packages/cli/catalog.test.ts b/packages/cli/catalog.test.ts new file mode 100644 index 00000000..60972221 --- /dev/null +++ b/packages/cli/catalog.test.ts @@ -0,0 +1,100 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PackageJson } from "pkg-types"; +import { resolveCatalogs } from "./catalog"; + +describe("resolveCatalogs", () => { + let workspaceDir: string; + let templateDir: string; + + beforeEach(async () => { + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "pkg-pr-new-")); + templateDir = path.join(workspaceDir, "templates", "example"); + await fs.mkdir(templateDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + const writeWorkspace = (contents: string) => + fs.writeFile(path.join(workspaceDir, "pnpm-workspace.yaml"), contents); + + it("resolves the default catalog", async () => { + await writeWorkspace("catalog:\n vite: ^6.0.0\n"); + const pJson: PackageJson = { devDependencies: { vite: "catalog:" } }; + + await resolveCatalogs(templateDir, pJson); + + expect(pJson.devDependencies).toEqual({ vite: "^6.0.0" }); + }); + + it("resolves catalog:default from catalogs.default", async () => { + await writeWorkspace("catalogs:\n default:\n vite: ^6.0.0\n"); + const pJson: PackageJson = { + dependencies: { vite: "catalog:default" }, + devDependencies: { vite: "catalog:" }, + }; + + await resolveCatalogs(templateDir, pJson); + + expect(pJson.dependencies).toEqual({ vite: "^6.0.0" }); + expect(pJson.devDependencies).toEqual({ vite: "^6.0.0" }); + }); + + it("resolves named catalogs in every dependency field", async () => { + await writeWorkspace( + [ + "catalog:", + " vue: ^3.5.0", + "catalogs:", + " react18:", + " react: ^18.3.1", + " react-dom: ^18.3.1", + " tooling:", + " typescript: ~5.7.0", + "", + ].join("\n"), + ); + const pJson: PackageJson = { + dependencies: { react: "catalog:react18", vue: "catalog:" }, + devDependencies: { typescript: "catalog:tooling" }, + peerDependencies: { "react-dom": "catalog:react18" }, + optionalDependencies: { vue: "catalog:" }, + }; + + await resolveCatalogs(templateDir, pJson); + + expect(pJson).toEqual({ + dependencies: { react: "^18.3.1", vue: "^3.5.0" }, + devDependencies: { typescript: "~5.7.0" }, + peerDependencies: { "react-dom": "^18.3.1" }, + optionalDependencies: { vue: "^3.5.0" }, + }); + }); + + it("keeps specifiers that are not resolvable from a catalog", async () => { + await writeWorkspace("catalog:\n vite: ^6.0.0\n"); + const dependencies = { + vue: "catalog:", + react: "catalog:missing", + lodash: "^4.17.21", + "my-lib": "workspace:*", + }; + const pJson: PackageJson = { dependencies: { ...dependencies } }; + + await resolveCatalogs(templateDir, pJson); + + expect(pJson.dependencies).toEqual(dependencies); + }); + + it("does nothing without a pnpm workspace", async () => { + const pJson: PackageJson = { devDependencies: { vite: "catalog:" } }; + + await resolveCatalogs(templateDir, pJson); + + expect(pJson.devDependencies).toEqual({ vite: "catalog:" }); + }); +}); diff --git a/packages/cli/catalog.ts b/packages/cli/catalog.ts new file mode 100644 index 00000000..898f7a3e --- /dev/null +++ b/packages/cli/catalog.ts @@ -0,0 +1,60 @@ +import fs from "node:fs/promises"; +import { parseYAML } from "confbox"; +import { findNearestFile, type PackageJson } from "pkg-types"; + +type Catalog = Record; +type Catalogs = Record; + +interface PnpmWorkspace { + catalog?: Catalog; + catalogs?: Catalogs; +} + +const CATALOG_PROTOCOL = "catalog:"; + +const dependencyFields = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +export async function resolveCatalogs(dir: string, pJson: PackageJson) { + const catalogs = await readCatalogs(dir); + if (!catalogs) { + return; + } + for (const field of dependencyFields) { + const deps = pJson[field]; + if (!deps) { + continue; + } + for (const [name, spec] of Object.entries(deps)) { + const version = resolveCatalogSpec(catalogs, name, spec); + if (version) { + deps[name] = version; + } + } + } +} + +async function readCatalogs(dir: string): Promise { + const workspacePath = await findNearestFile("pnpm-workspace.yaml", { + startingFrom: dir, + }).catch(() => null); + if (!workspacePath) { + return null; + } + const { catalog, catalogs } = + parseYAML(await fs.readFile(workspacePath, "utf8")) ?? + {}; + return { ...catalogs, default: catalog ?? catalogs?.default }; +} + +function resolveCatalogSpec(catalogs: Catalogs, name: string, spec: string) { + if (!spec.startsWith(CATALOG_PROTOCOL)) { + return; + } + const catalogName = spec.slice(CATALOG_PROTOCOL.length).trim() || "default"; + return catalogs[catalogName]?.[name]; +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 7708bbfc..81ae9baa 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -22,6 +22,7 @@ import { isBinaryFile } from "isbinaryfile"; import { writePackageJSON, type PackageJson } from "pkg-types"; import pkg from "./package.json" with { type: "json" }; import { createDefaultTemplate } from "./template"; +import { resolveCatalogs } from "./catalog"; import * as core from "@actions/core"; declare global { @@ -535,6 +536,8 @@ const main = defineCommand({ console.warn("preparing template:", pJson.name); + await resolveCatalogs(templateDir, pJson); + const restore = await writeDeps( templateDir, pJsonContents, diff --git a/packages/cli/package.json b/packages/cli/package.json index 27db09ba..d9f5d318 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ "@jsdevtools/ez-spawn": "^3.0.4", "@pkg-pr-new/utils": "workspace:^", "citty": "^0.1.6", + "confbox": "^0.2.4", "ignore": "^7.0.5", "isbinaryfile": "5.0.2", "nanotar": "^0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fafc2e89..ae61124b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,9 @@ importers: citty: specifier: ^0.1.6 version: 0.1.6 + confbox: + specifier: ^0.2.4 + version: 0.2.4 ignore: specifier: ^7.0.5 version: 7.0.5