From 18a61d1e834452a9fd771bce06dfdeb3df25e686 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 3 Aug 2026 10:53:30 +0800 Subject: [PATCH] feat(fmt): support package.json sorting --- package.json | 1 - packages/rstack/THIRD_PARTY_NOTICES.md | 51 +++++++++++++++++++ packages/rstack/package.json | 1 + packages/rstack/src/fmt/config.ts | 10 ++-- packages/rstack/src/fmt/format.ts | 5 +- packages/rstack/src/fmt/parser.ts | 2 +- packages/rstack/src/fmt/plugins.ts | 4 +- packages/rstack/src/fmt/prettierPlugins.ts | 28 ++++++++-- packages/rstack/src/fmt/serial.ts | 2 +- .../rstack/src/fmt/sortPackageJsonPlugin.ts | 14 +++++ packages/rstack/src/fmt/types.ts | 24 ++++++--- packages/rstack/tests/cli/fmt/index.test.ts | 37 ++++++++++++++ packages/rstack/tests/fmt/format.test.ts | 31 +++++++++++ pnpm-lock.yaml | 34 ++++--------- pnpm-workspace.yaml | 2 +- rstack.config.ts | 2 +- 16 files changed, 201 insertions(+), 47 deletions(-) create mode 100644 packages/rstack/src/fmt/sortPackageJsonPlugin.ts diff --git a/package.json b/package.json index bb481978..3953d1c0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "cspell-ban-words": "catalog:", "heading-case": "catalog:", "prettier": "catalog:", - "prettier-plugin-packagejson": "catalog:", "rstack": "workspace:*", "typescript": "catalog:" }, diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 26fd8c4f..65b0f213 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -500,6 +500,57 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## sort-package-json + +This package includes bundled code from +[sort-package-json](https://github.com/keithamus/sort-package-json). + +License: MIT + +Copyright (c) 2015 Keith Cirkel + +The bundled code also contains MIT-licensed code from: + +- detect-indent 7.0.2, copyright Sindre Sorhus +- detect-newline 4.0.1, copyright Sindre Sorhus +- git-hooks-list 4.2.1, copyright fisker Cheung +- is-plain-obj 4.1.0, copyright Sindre Sorhus +- sort-object-keys 2.1.0, copyright Keith Cirkel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notices and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The bundled code also contains semver 7.8.5, licensed under the ISC License: + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + ## tinyexec This package includes bundled code from [tinyexec](https://github.com/tinylibs/tinyexec). diff --git a/packages/rstack/package.json b/packages/rstack/package.json index 1c9edd73..afda49cf 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -77,6 +77,7 @@ "lint-staged": "catalog:", "micromatch": "catalog:", "rslog": "catalog:", + "sort-package-json": "catalog:", "tiny-readdir": "catalog:", "typescript": "catalog:", "worktank": "catalog:" diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index d27ea93a..559eb38b 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -1,7 +1,11 @@ import { dirname, relative } from 'node:path'; import micromatch from 'micromatch'; -import type { Options as PrettierOptions } from 'prettier'; -import type { FmtConfig, FmtConfigDefinition, ResolvedFmtConfig } from './types.ts'; +import type { + FmtConfig, + FmtConfigDefinition, + ResolvedFmtConfig, + ResolvedFmtOptions, +} from './types.ts'; type ResolveFmtConfigOptions = { definition: FmtConfigDefinition | undefined; @@ -45,7 +49,7 @@ const pathMatchesGlobs = ( }; /** Applies matching overrides to the shared formatter options. */ -const resolveFmtOptions = (filePath: string, config: ResolvedFmtConfig): PrettierOptions => { +const resolveFmtOptions = (filePath: string, config: ResolvedFmtConfig): ResolvedFmtOptions => { if (config.overrides.length === 0) { return config.baseOptions; } diff --git a/packages/rstack/src/fmt/format.ts b/packages/rstack/src/fmt/format.ts index 03660d2e..b6e52c4f 100644 --- a/packages/rstack/src/fmt/format.ts +++ b/packages/rstack/src/fmt/format.ts @@ -24,19 +24,20 @@ const formatText = async ( ...options, filepath: filePath, parser, - plugins: getPrettierPlugins(options.plugins), }; + const plugins = await getPrettierPlugins(formatOptions); if (cursorOffset === undefined) { return { status: 'formatted', - formatted: await format(source, formatOptions), + formatted: await format(source, { ...formatOptions, plugins }), }; } const result = await formatWithCursor(source, { ...formatOptions, cursorOffset, + plugins, }); return { diff --git a/packages/rstack/src/fmt/parser.ts b/packages/rstack/src/fmt/parser.ts index a614daa8..614038db 100644 --- a/packages/rstack/src/fmt/parser.ts +++ b/packages/rstack/src/fmt/parser.ts @@ -16,7 +16,7 @@ const resolveFmtParser = async ( ( await getFileInfo(filePath, { ...fileInfoOptions, - plugins: getPrettierPlugins(options.plugins), + plugins: await getPrettierPlugins(options), }) ).inferredParser; diff --git a/packages/rstack/src/fmt/plugins.ts b/packages/rstack/src/fmt/plugins.ts index 466cbf15..17ebcc24 100644 --- a/packages/rstack/src/fmt/plugins.ts +++ b/packages/rstack/src/fmt/plugins.ts @@ -2,10 +2,10 @@ import { isAbsolute, join, resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { moduleResolve } from 'import-meta-resolve'; import type { Options as PrettierOptions } from 'prettier'; -import type { FmtPluginSpecifier } from './types.ts'; +import type { FmtPluginSpecifier, ResolvedFmtOptions } from './types.ts'; type FmtPlugin = NonNullable[number]; -type FmtPluginResolver = (options: PrettierOptions) => PrettierOptions; +type FmtPluginResolver = (options: ResolvedFmtOptions) => ResolvedFmtOptions; const resolveModuleUrl = (specifier: string, parentUrl: URL): string => moduleResolve(specifier, parentUrl).href; diff --git a/packages/rstack/src/fmt/prettierPlugins.ts b/packages/rstack/src/fmt/prettierPlugins.ts index b76e767d..6d6c763e 100644 --- a/packages/rstack/src/fmt/prettierPlugins.ts +++ b/packages/rstack/src/fmt/prettierPlugins.ts @@ -1,12 +1,30 @@ import * as yukuPlugin from '@prettier/plugin-yuku'; -import type { Options as PrettierOptions } from 'prettier'; +import type { Options as PrettierOptions, Plugin } from 'prettier'; +import type { ResolvedFmtOptions } from './types.ts'; type PrettierPlugins = NonNullable; -const defaultFmtPlugins: PrettierPlugins = [yukuPlugin]; +const fmtOptionsPlugin = { + options: { + sortPackageJson: { + category: 'Global', + default: false, + description: 'Sort package.json fields using sort-package-json.', + type: 'boolean', + }, + }, +} satisfies Plugin; -/** Prepends Yuku so project plugins can override the default parser. */ -const getPrettierPlugins = (plugins: PrettierOptions['plugins']): PrettierPlugins => - plugins?.length ? [...defaultFmtPlugins, ...plugins] : defaultFmtPlugins; +const defaultFmtPlugins: PrettierPlugins = [yukuPlugin, fmtOptionsPlugin]; + +/** Prepends bundled plugins so project plugins can override their parsers. */ +const getPrettierPlugins = async (options: ResolvedFmtOptions): Promise => { + const plugins = + options.sortPackageJson === true && /(^|[/\\])package\.json$/.test(options.filepath ?? '') + ? [...defaultFmtPlugins, (await import('./sortPackageJsonPlugin.ts')).sortPackageJsonPlugin] + : defaultFmtPlugins; + + return options.plugins?.length ? [...plugins, ...options.plugins] : plugins; +}; export { getPrettierPlugins }; diff --git a/packages/rstack/src/fmt/serial.ts b/packages/rstack/src/fmt/serial.ts index e90c60e1..1d1804d3 100644 --- a/packages/rstack/src/fmt/serial.ts +++ b/packages/rstack/src/fmt/serial.ts @@ -12,7 +12,7 @@ const formatFileSerial = async ( const source = await readFile(path, 'utf8'); const formatted = await format(source, { ...options, - plugins: getPrettierPlugins(options.plugins), + plugins: await getPrettierPlugins(options), }); if (source === formatted) { diff --git a/packages/rstack/src/fmt/sortPackageJsonPlugin.ts b/packages/rstack/src/fmt/sortPackageJsonPlugin.ts new file mode 100644 index 00000000..7661db8c --- /dev/null +++ b/packages/rstack/src/fmt/sortPackageJsonPlugin.ts @@ -0,0 +1,14 @@ +import type { Plugin } from 'prettier'; +import { parsers } from 'prettier/plugins/babel'; +import sortPackageJson from 'sort-package-json'; + +const sortPackageJsonPlugin: Plugin = { + parsers: { + 'json-stringify': { + ...parsers['json-stringify'], + preprocess: (source) => sortPackageJson(source), + }, + }, +}; + +export { sortPackageJsonPlugin }; diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index 770f1625..52865a60 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -3,9 +3,20 @@ import type { Config as PrettierConfig, Options as PrettierOptions } from 'prett /** Plugin objects cannot cross worker boundaries and are not planned for support. */ type FmtPluginSpecifier = string | URL; -type FmtOptions = Omit & { - plugins?: FmtPluginSpecifier[]; -}; +interface FmtBuiltinOptions { + /** + * Sort `package.json` fields using `sort-package-json`. + * @default false + */ + sortPackageJson?: boolean; +} + +type ResolvedFmtOptions = PrettierOptions & FmtBuiltinOptions; + +type FmtOptions = Omit & + FmtBuiltinOptions & { + plugins?: FmtPluginSpecifier[]; + }; type PrettierOverride = NonNullable[number]; @@ -13,7 +24,7 @@ type FmtOverride = Omit & { options?: FmtOptions; }; -interface FmtConfig extends Omit { +interface FmtConfig extends Omit, FmtBuiltinOptions { plugins?: FmtPluginSpecifier[]; overrides?: FmtOverride[]; /** Gitignore-compatible patterns relative to the Rstack config root. */ @@ -27,7 +38,7 @@ interface ResolvedFmtConfig { /** Root for relative patterns and plugin paths. */ rootPath: string; /** Shared Prettier options before per-file overrides. */ - baseOptions: PrettierOptions; + baseOptions: ResolvedFmtOptions; /** Per-file override rules. */ overrides: NonNullable; /** Root-relative ignore patterns. */ @@ -56,7 +67,7 @@ interface FmtFileRequest { /** Absolute path to the file. */ path: string; /** Final Prettier options with the parser and file path resolved. */ - options: PrettierOptions & Required>; + options: ResolvedFmtOptions & Required>; } type FmtMode = 'write' | 'check' | 'list-different'; @@ -123,5 +134,6 @@ export type { FormatTextOptions, FormatTextResult, ResolvedFmtConfig, + ResolvedFmtOptions, RunFmtFilesOptions, }; diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 2adca0c0..0ecea399 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -5,6 +5,10 @@ import { afterEach, beforeEach, expect, test } from 'rstack/test'; import { RSTACK_BIN_PATH } from '#test-helpers'; let projectPath: string; +const packageJsonSource = + '{"dependencies":{"z":"1.0.0","a":"1.0.0"},"type":"module","version":"1.0.0","name":"fixture"}'; +const sortedPackageJson = + '{\n "name": "fixture",\n "version": "1.0.0",\n "type": "module",\n "dependencies": {\n "a": "1.0.0",\n "z": "1.0.0"\n }\n}\n'; const writeProjectFile = (filePath: string, content: string): void => { const absolutePath = path.join(projectPath, filePath); @@ -98,6 +102,39 @@ test('formats the current directory with Prettier defaults', () => { expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); }); +test('does not sort package.json by default', () => { + writeProjectFile('package.json', packageJsonSource); + + const result = runFmt(['package.json']); + + expect(result.status).toBe(0); + expect(readProjectFile('package.json')).toContain( + '"dependencies": {\n "z": "1.0.0",\n "a": "1.0.0"', + ); +}); + +test.each([ + ['parallel execution', []], + ['serial execution', ['--no-parallel']], +] as const)('sorts package.json with %s', (_, options) => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ sortPackageJson: true }); +`, + ); + writeProjectFile('package.json', packageJsonSource); + writeProjectFile('packages/example/package.json', packageJsonSource); + + const result = runFmt([...options, 'package.json', 'packages/example/package.json']); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(readProjectFile('package.json')).toBe(sortedPackageJson); + expect(readProjectFile('packages/example/package.json')).toBe(sortedPackageJson); +}); + test.each([ ['disabling parallel execution', ['--no-parallel']], ['configuring parallel worker count', ['--parallel-workers', '1']], diff --git a/packages/rstack/tests/fmt/format.test.ts b/packages/rstack/tests/fmt/format.test.ts index 7be3703c..6b7faed7 100644 --- a/packages/rstack/tests/fmt/format.test.ts +++ b/packages/rstack/tests/fmt/format.test.ts @@ -5,6 +5,8 @@ import { formatText } from '../../src/fmt/format.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; const rootPath = import.meta.dirname; +const packageJsonSource = + '{"dependencies":{"z":"1.0.0","a":"1.0.0"},"version":"1.0.0","name":"fixture"}'; test('applies per-file overrides and maps the cursor', async () => { const source = 'const value={message:"hello"}'; @@ -63,6 +65,35 @@ test('uses an explicit parser for unknown file extensions', async () => { }); }); +test('does not sort package.json by default', async () => { + const result = await formatText(packageJsonSource, { + config: normalizeFmtConfig(undefined, rootPath), + filePath: path.join(rootPath, 'package.json'), + }); + + expect(result).toMatchObject({ + formatted: + '{\n "dependencies": {\n "z": "1.0.0",\n "a": "1.0.0"\n },\n "version": "1.0.0",\n "name": "fixture"\n}\n', + }); +}); + +test('sorts package.json when enabled', async () => { + const result = await formatText(packageJsonSource, { + config: normalizeFmtConfig( + { + overrides: [{ files: 'package.json', options: { sortPackageJson: true } }], + }, + rootPath, + ), + filePath: path.join(rootPath, 'package.json'), + }); + + expect(result).toMatchObject({ + formatted: + '{\n "name": "fixture",\n "version": "1.0.0",\n "dependencies": {\n "a": "1.0.0",\n "z": "1.0.0"\n }\n}\n', + }); +}); + test('supports a plugin path from matching overrides', async () => { await withTempProject(async (projectPath) => { writeProjectFile( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c778963e..7c65f228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,9 +109,6 @@ catalogs: prettier: specifier: 3.9.6 version: 3.9.6 - prettier-plugin-packagejson: - specifier: ^3.0.2 - version: 3.0.2 react: specifier: ^19.2.8 version: 19.2.8 @@ -127,6 +124,9 @@ catalogs: rspress-plugin-font-open-sans: specifier: ^1.0.4 version: 1.0.4 + sort-package-json: + specifier: 4.0.0 + version: 4.0.0 tiny-readdir: specifier: 3.1.1 version: 3.1.1 @@ -159,9 +159,6 @@ importers: prettier: specifier: 'catalog:' version: 3.9.6 - prettier-plugin-packagejson: - specifier: 'catalog:' - version: 3.0.2(prettier@3.9.6) rstack: specifier: workspace:* version: link:packages/rstack @@ -406,6 +403,9 @@ importers: rslog: specifier: 'catalog:' version: 2.3.0 + sort-package-json: + specifier: 'catalog:' + version: 4.0.0 tiny-readdir: specifier: 'catalog:' version: 3.1.1 @@ -1957,14 +1957,6 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} - prettier-plugin-packagejson@3.0.2: - resolution: {integrity: sha512-kmoj3hEynXwoHDo8ZhmWAIjRBoQWCDUVackiWfSDWdgD0rS3LGB61T9zoVbume/cotYdCoadUh4sqViAmXvpBQ==} - peerDependencies: - prettier: ^3 - peerDependenciesMeta: - prettier: - optional: true - prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -2284,9 +2276,9 @@ packages: sort-object-keys@2.1.0: resolution: {integrity: sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g==} - sort-package-json@3.7.1: - resolution: {integrity: sha512-ssk1HG7whF8N/T1IsNAQrtHG5Cbdi0rAgRJZXYBr9hF5xaHnBNzUx/W6LcthEW7FhOwvZssbESZuO+GxssqAyA==} - engines: {node: '>=20'} + sort-package-json@4.0.0: + resolution: {integrity: sha512-6aYOlYI9AWioZ+rzu+4zKLmoFqJP0/fHDxrd7X04yqEibikY+5YVF0EYlyGn4v6X2PJY7yAUWV7oeP+i5rOm/g==} + engines: {node: '>=22'} hasBin: true source-map-js@1.2.1: @@ -4144,12 +4136,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prettier-plugin-packagejson@3.0.2(prettier@3.9.6): - dependencies: - sort-package-json: 3.7.1 - optionalDependencies: - prettier: 3.9.6 - prettier@3.9.6: {} pretty-format@27.5.1: @@ -4479,7 +4465,7 @@ snapshots: sort-object-keys@2.1.0: {} - sort-package-json@3.7.1: + sort-package-json@4.0.0: dependencies: detect-indent: 7.0.2 detect-newline: 4.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 71790ec7..0bbe3873 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,12 +46,12 @@ catalog: 'lint-staged': '^17.2.0' 'micromatch': '4.0.8' prettier: '3.9.6' - 'prettier-plugin-packagejson': '^3.0.2' 'react': '^19.2.8' 'react-dom': '^19.2.8' 'rsbuild-plugin-open-graph': '^1.1.3' rslog: ^2.3.0 'rspress-plugin-font-open-sans': '^1.0.4' + 'sort-package-json': '4.0.0' tiny-readdir: 3.1.1 'typescript': '^7.0.2' worktank: '3.0.2' diff --git a/rstack.config.ts b/rstack.config.ts index 7bac31fa..0753840b 100644 --- a/rstack.config.ts +++ b/rstack.config.ts @@ -41,9 +41,9 @@ define.lint(async () => { define.fmt({ ignorePatterns: ['**/dist/**', 'pnpm-lock.yaml'], - plugins: ['prettier-plugin-packagejson'], printWidth: 100, singleQuote: true, + sortPackageJson: true, }); define.staged({