diff --git a/lib/index.js b/lib/index.js index f165ee2..23f326d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,6 +7,7 @@ const updateScripts = require('./update-scripts.js') const updateWorkspaces = require('./update-workspaces.js') const normalize = require('./normalize.js') const { read, parse } = require('./read-package.js') +const { packageSort } = require('./sort.js') // a list of handy specialized helper functions that take // care of special cases that are handled by the npm cli @@ -230,19 +231,23 @@ class PackageJson { return this } - async save () { + async save ({ sort } = {}) { if (!this.#canSave) { throw new Error('No package.json to save to') } const { [Symbol.for('indent')]: indent, [Symbol.for('newline')]: newline, + ...rest } = this.content const format = indent === undefined ? ' ' : indent const eol = newline === undefined ? '\n' : newline + + const content = sort ? packageSort(rest) : rest + const fileContent = `${ - JSON.stringify(this.content, null, format) + JSON.stringify(content, null, format) }\n` .replace(/\n/g, eol) diff --git a/lib/sort.js b/lib/sort.js new file mode 100644 index 0000000..0bd0d51 --- /dev/null +++ b/lib/sort.js @@ -0,0 +1,101 @@ +/** + * arbitrary sort order for package.json largely pulled from: + * https://github.com/keithamus/sort-package-json/blob/main/defaultRules.md + * + * cross checked with: + * https://github.com/npm/types/blob/main/types/index.d.ts#L104 + * https://docs.npmjs.com/cli/configuring-npm/package-json + */ +function packageSort (json) { + const { + name, + version, + private: isPrivate, + description, + keywords, + homepage, + bugs, + repository, + funding, + license, + author, + maintainers, + contributors, + type, + imports, + exports, + main, + browser, + types, + bin, + man, + directories, + files, + workspaces, + scripts, + config, + dependencies, + devDependencies, + peerDependencies, + peerDependenciesMeta, + optionalDependencies, + bundledDependencies, + bundleDependencies, + engines, + os, + cpu, + publishConfig, + devEngines, + licenses, + overrides, + ...rest + } = json + + return { + ...(typeof name !== 'undefined' ? { name } : {}), + ...(typeof version !== 'undefined' ? { version } : {}), + ...(typeof isPrivate !== 'undefined' ? { private: isPrivate } : {}), + ...(typeof description !== 'undefined' ? { description } : {}), + ...(typeof keywords !== 'undefined' ? { keywords } : {}), + ...(typeof homepage !== 'undefined' ? { homepage } : {}), + ...(typeof bugs !== 'undefined' ? { bugs } : {}), + ...(typeof repository !== 'undefined' ? { repository } : {}), + ...(typeof funding !== 'undefined' ? { funding } : {}), + ...(typeof license !== 'undefined' ? { license } : {}), + ...(typeof author !== 'undefined' ? { author } : {}), + ...(typeof maintainers !== 'undefined' ? { maintainers } : {}), + ...(typeof contributors !== 'undefined' ? { contributors } : {}), + ...(typeof type !== 'undefined' ? { type } : {}), + ...(typeof imports !== 'undefined' ? { imports } : {}), + ...(typeof exports !== 'undefined' ? { exports } : {}), + ...(typeof main !== 'undefined' ? { main } : {}), + ...(typeof browser !== 'undefined' ? { browser } : {}), + ...(typeof types !== 'undefined' ? { types } : {}), + ...(typeof bin !== 'undefined' ? { bin } : {}), + ...(typeof man !== 'undefined' ? { man } : {}), + ...(typeof directories !== 'undefined' ? { directories } : {}), + ...(typeof files !== 'undefined' ? { files } : {}), + ...(typeof workspaces !== 'undefined' ? { workspaces } : {}), + ...(typeof scripts !== 'undefined' ? { scripts } : {}), + ...(typeof config !== 'undefined' ? { config } : {}), + ...(typeof dependencies !== 'undefined' ? { dependencies } : {}), + ...(typeof devDependencies !== 'undefined' ? { devDependencies } : {}), + ...(typeof peerDependencies !== 'undefined' ? { peerDependencies } : {}), + ...(typeof peerDependenciesMeta !== 'undefined' ? { peerDependenciesMeta } : {}), + ...(typeof optionalDependencies !== 'undefined' ? { optionalDependencies } : {}), + ...(typeof bundledDependencies !== 'undefined' ? { bundledDependencies } : {}), + ...(typeof bundleDependencies !== 'undefined' ? { bundleDependencies } : {}), + ...(typeof engines !== 'undefined' ? { engines } : {}), + ...(typeof os !== 'undefined' ? { os } : {}), + ...(typeof cpu !== 'undefined' ? { cpu } : {}), + ...(typeof publishConfig !== 'undefined' ? { publishConfig } : {}), + ...(typeof devEngines !== 'undefined' ? { devEngines } : {}), + ...(typeof licenses !== 'undefined' ? { licenses } : {}), + ...(typeof overrides !== 'undefined' ? { overrides } : {}), + ...rest, + } +} + +module.exports = { + packageSort, +} diff --git a/package.json b/package.json index e766e8c..4fe9ab0 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,16 @@ "name": "@npmcli/package-json", "version": "6.0.1", "description": "Programmatic API to update package.json", + "keywords": [ + "npm", + "oss" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/npm/package-json.git" + }, + "license": "ISC", + "author": "GitHub Inc.", "main": "lib/index.js", "files": [ "bin/", @@ -18,19 +28,6 @@ "template-oss-apply": "template-oss-apply --force", "eslint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"" }, - "keywords": [ - "npm", - "oss" - ], - "author": "GitHub Inc.", - "license": "ISC", - "devDependencies": { - "@npmcli/eslint-config": "^5.0.0", - "@npmcli/template-oss": "4.23.3", - "read-package-json": "^7.0.0", - "read-package-json-fast": "^4.0.0", - "tap": "^16.0.1" - }, "dependencies": { "@npmcli/git": "^6.0.0", "glob": "^10.2.2", @@ -40,9 +37,12 @@ "proc-log": "^5.0.0", "semver": "^7.5.3" }, - "repository": { - "type": "git", - "url": "git+https://github.com/npm/package-json.git" + "devDependencies": { + "@npmcli/eslint-config": "^5.0.0", + "@npmcli/template-oss": "4.23.3", + "read-package-json": "^7.0.0", + "read-package-json-fast": "^4.0.0", + "tap": "^16.0.1" }, "engines": { "node": "^18.17.0 || >=20.5.0" diff --git a/tap-snapshots/test/index.js.test.cjs b/tap-snapshots/test/index.js.test.cjs index b091e0f..ca55392 100644 --- a/tap-snapshots/test/index.js.test.cjs +++ b/tap-snapshots/test/index.js.test.cjs @@ -39,6 +39,125 @@ exports[`test/index.js TAP load read, update content and write > should properly ` +exports[`test/index.js TAP load sorts on save > should properly save content to a package.json 1`] = ` +{ + "name": "foo", + "version": "1.0.0", + "description": "A sample package", + "keywords": [ + "sample", + "package" + ], + "homepage": "https://example.com", + "bugs": { + "url": "https://example.com/bugs", + "email": "bugs@example.com" + }, + "repository": { + "type": "git", + "url": "https://example.com/repo.git" + }, + "funding": "https://example.com/funding", + "license": "MIT", + "author": "Author Name ", + "maintainers": [ + "Maintainer One ", + "Maintainer Two " + ], + "contributors": [ + "Contributor One ", + "Contributor Two " + ], + "type": "module", + "imports": { + "#dep": "./src/dep.js" + }, + "exports": { + ".": "./src/index.js" + }, + "main": "index.js", + "browser": "browser.js", + "types": "index.d.ts", + "bin": { + "my-cli": "./bin/cli.js" + }, + "man": [ + "./man/doc.1" + ], + "directories": { + "lib": "lib", + "bin": "bin", + "man": "man" + }, + "files": [ + "lib/**/*.js", + "bin/**/*.js" + ], + "workspaces": [ + "packages/*" + ], + "scripts": { + "start": "node index.js", + "test": "tap test/*.js" + }, + "config": { + "port": "8080" + }, + "dependencies": { + "some-dependency": "^1.0.0" + }, + "devDependencies": { + "some-dev-dependency": "^1.0.0" + }, + "peerDependencies": { + "some-peer-dependency": "^1.0.0" + }, + "peerDependenciesMeta": { + "some-peer-dependency": { + "optional": true + } + }, + "optionalDependencies": { + "some-optional-dependency": "^1.0.0" + }, + "bundledDependencies": [ + "some-bundled-dependency" + ], + "bundleDependencies": [ + "some-bundled-dependency" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "darwin", + "linux" + ], + "cpu": [ + "x64", + "arm64" + ], + "publishConfig": { + "registry": "https://registry.example.com" + }, + "devEngines": { + "node": ">=14.0.0" + }, + "licenses": [ + { + "type": "MIT", + "url": "https://opensource.org/licenses/MIT" + } + ], + "overrides": { + "some-dependency": { + "some-sub-dependency": "1.0.0" + } + } +} + +` + exports[`test/index.js TAP load update long package.json > should only update the defined property 1`] = ` { "version": "7.18.1", diff --git a/test/fixtures/all-fields-populated/package.json b/test/fixtures/all-fields-populated/package.json new file mode 100644 index 0000000..508db47 --- /dev/null +++ b/test/fixtures/all-fields-populated/package.json @@ -0,0 +1,96 @@ +{ + "name": "foo", + "version": "1.0.0", + "private": true, + "description": "A sample package", + "keywords": ["sample", "package"], + "homepage": "https://example.com", + "bugs": { + "url": "https://example.com/bugs", + "email": "bugs@example.com" + }, + "repository": { + "type": "git", + "url": "https://example.com/repo.git" + }, + "funding": "https://example.com/funding", + "license": "MIT", + "author": "Author Name ", + "maintainers": [ + "Maintainer One ", + "Maintainer Two " + ], + "contributors": [ + "Contributor One ", + "Contributor Two " + ], + "type": "module", + "imports": { + "#dep": "./src/dep.js" + }, + "exports": { + ".": "./src/index.js" + }, + "main": "index.js", + "browser": "browser.js", + "types": "index.d.ts", + "bin": { + "my-cli": "./bin/cli.js" + }, + "man": ["./man/doc.1"], + "directories": { + "lib": "lib", + "bin": "bin", + "man": "man" + }, + "files": ["lib/**/*.js", "bin/**/*.js"], + "workspaces": ["packages/*"], + "scripts": { + "start": "node index.js", + "test": "tap test/*.js" + }, + "config": { + "port": "8080" + }, + "dependencies": { + "some-dependency": "^1.0.0" + }, + "devDependencies": { + "some-dev-dependency": "^1.0.0" + }, + "peerDependencies": { + "some-peer-dependency": "^1.0.0" + }, + "peerDependenciesMeta": { + "some-peer-dependency": { + "optional": true + } + }, + "optionalDependencies": { + "some-optional-dependency": "^1.0.0" + }, + "bundledDependencies": ["some-bundled-dependency"], + "bundleDependencies": ["some-bundled-dependency"], + "engines": { + "node": ">=14.0.0" + }, + "os": ["darwin", "linux"], + "cpu": ["x64", "arm64"], + "publishConfig": { + "registry": "https://registry.example.com" + }, + "devEngines": { + "node": ">=14.0.0" + }, + "licenses": [ + { + "type": "MIT", + "url": "https://opensource.org/licenses/MIT" + } + ], + "overrides": { + "some-dependency": { + "some-sub-dependency": "1.0.0" + } + } +} \ No newline at end of file diff --git a/test/fixtures/empty/package.json b/test/fixtures/empty/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/test/fixtures/empty/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/package.json b/test/fixtures/legacy/package.json similarity index 100% rename from test/fixtures/package.json rename to test/fixtures/legacy/package.json diff --git a/test/index.js b/test/index.js index 26fd8cc..baf0564 100644 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,11 @@ const { join, resolve } = require('node:path') const t = require('tap') const PackageJson = require('../lib/index.js') +const getPackageFile = (file) => + JSON.parse( + fs.readFileSync(join(__dirname, 'fixtures', file, 'package.json'), 'utf8') + ) + const redactCwd = (path) => { const normalizePath = p => p .replace(/\\+/g, '/') @@ -75,7 +80,7 @@ t.test('load', t => { ) }) t.test('update long package.json', async t => { - const fixture = resolve(__dirname, 'fixtures', 'package.json') + const fixture = resolve(__dirname, 'fixtures', 'legacy', 'package.json') const path = t.testdir({}) fs.copyFileSync(fixture, resolve(path, 'package.json')) const pkgJson = await PackageJson.load(path) @@ -245,3 +250,80 @@ t.test('read package', async t => { const data = await readPackage(join(path, 'package.json')) t.matchSnapshot(data) }) + +t.test('sorts on save', async t => { + const allFieldsPopulated = getPackageFile('all-fields-populated') + + const path = t.testdir({ + 'package.json': JSON.stringify(allFieldsPopulated, null, 2), + }) + + const pkgJson = await PackageJson.load(path) + + await pkgJson.save({ sort: true }) + + t.strictSame( + allFieldsPopulated, + getPackageFile('all-fields-populated') + ) +}) + +t.test('not the same if name is at the bottom', async t => { + const { name, ...allFieldsPopulated } = getPackageFile('all-fields-populated') + const path = t.testdir({ + 'package.json': JSON.stringify({ + ...allFieldsPopulated, + name, + }, null, 2), + }) + + const pkgJson = await PackageJson.load(path) + + await pkgJson.save({ sort: true }) + + t.strictNotSame( + allFieldsPopulated, + JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + ) +}) + +t.test('unrecognised props at bottom', async t => { + const { name, ...allFieldsPopulated } = getPackageFile('all-fields-populated') + + const path = t.testdir({ + 'package.json': JSON.stringify({ + meow: true, + ...allFieldsPopulated, + name, + }, null, 2), + }) + + const pkgJson = await PackageJson.load(path) + + await pkgJson.save({ sort: true }) + + t.strictSame( + { + name, + ...allFieldsPopulated, + meow: true, + }, + JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + ) +}) + +t.test('empty props at bottom', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + }, null, 2), + }) + + const pkgJson = await PackageJson.load(path) + + await pkgJson.save({ sort: true }) + + t.same( + {}, + JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + ) +})