diff --git a/packages/migrate/src/helpers/package-json.ts b/packages/migrate/src/helpers/package-json.ts index c5329e9be..bfc8eb550 100644 --- a/packages/migrate/src/helpers/package-json.ts +++ b/packages/migrate/src/helpers/package-json.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs' import { readFile, writeFile } from 'node:fs/promises' import consola from 'consola' import { createPatch } from 'diff' -import { detectIndentation } from '../../../../src/utils/format.ts' +import { detectIndentation } from '../../../../src/utils/json.ts' import pkg from '../../package.json' with { type: 'json' } import { outputDiff, renameKey } from '../utils.ts' diff --git a/src/features/pkg/exports.ts b/src/features/pkg/exports.ts index 706bcc741..4eec06bbe 100644 --- a/src/features/pkg/exports.ts +++ b/src/features/pkg/exports.ts @@ -1,9 +1,8 @@ -import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { RE_CSS, RE_DTS, RE_NODE_MODULES } from 'rolldown-plugin-dts/internal' -import { detectIndentation } from '../../utils/format.ts' import { stripExtname } from '../../utils/fs.ts' import { matchPattern, slash, typeAssert } from '../../utils/general.ts' +import { writeJsonFile } from '../../utils/json.ts' import type { NormalizedFormat, ResolvedConfig } from '../../config/types.ts' import type { ChunksByFormat, @@ -155,15 +154,7 @@ export async function writeExports( } } - const original = readFileSync(pkg.packageJsonPath, 'utf8') - let contents = JSON.stringify(updatedPkg, null, detectIndentation(original)) - if (original.includes('\r\n')) { - contents = contents.replaceAll('\n', '\r\n') - } - if (original.endsWith('\n')) contents += '\n' - if (contents !== original) { - writeFileSync(pkg.packageJsonPath, contents, 'utf8') - } + writeJsonFile(pkg.packageJsonPath, updatedPkg) } type SubExport = Partial> diff --git a/src/run.ts b/src/run.ts old mode 100644 new mode 100755 diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts deleted file mode 100644 index 03943f2d3..000000000 --- a/src/utils/format.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, test } from 'vitest' -import { detectIndentation } from './format.ts' - -describe('detectIndent', () => { - test('two spaces', ({ expect }) => { - expect(detectIndentation(stringifyJson(2))).toBe(2) - }) - test('four spaces', ({ expect }) => { - expect(detectIndentation(stringifyJson(4))).toBe(4) - }) - test('tab', ({ expect }) => { - expect(detectIndentation(stringifyJson('\t'))).toBe('\t') - }) - test('empty', ({ expect }) => { - expect(detectIndentation('')).toBe(2) - }) - test('empty line', ({ expect }) => { - expect(detectIndentation('{\n\n "foo": 42 }')).toBe(2) - }) -}) - -function stringifyJson(indentation: string | number): string { - const contents = JSON.stringify({ foo: 42 }, null, indentation) - return contents -} diff --git a/src/utils/format.ts b/src/utils/format.ts index 85412c62f..cfccf3eff 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -5,19 +5,3 @@ export function formatBytes(bytes: number): string | undefined { } return `${(bytes / 1000).toFixed(2)} kB` } - -export function detectIndentation(jsonText: string): string | number { - const lines = jsonText.split(/\r?\n/) - - for (const line of lines) { - const match = line.match(/^(\s+)\S/) - if (!match) continue - - if (match[1].includes('\t')) { - return '\t' - } - return match[1].length - } - - return 2 -} diff --git a/src/utils/json.test.ts b/src/utils/json.test.ts new file mode 100644 index 000000000..a4ac5503f --- /dev/null +++ b/src/utils/json.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, test } from 'vitest' +import { writeFixtures } from '../../tests/utils.ts' +import { detectIndentation, writeJsonFile } from './json.ts' + +describe('writeJsonFile', () => { + test('creates a new file when it does not exist', async (context) => { + const { testDir } = await writeFixtures(context, { 'placeholder.txt': '' }) + const filePath = path.join(testDir, 'new.json') + writeJsonFile(filePath, { foo: 'bar' }) + expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "bar"\n}') + }) + + test('does not rewrite when keys are reordered but content is deeply equal', async (context) => { + const original = '{"b":1,\n"a":2}' + const { testDir } = await writeFixtures(context, { 'pkg.json': original }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { a: 2, b: 1 }) + expect(readFileSync(filePath, 'utf8')).toBe(original) + }) + + test('does not rewrite when content is identical', async (context) => { + const original = '{\t"foo":"bar"\n }' + const { testDir } = await writeFixtures(context, { 'pkg.json': original }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { foo: 'bar' }) + expect(readFileSync(filePath, 'utf8')).toBe(original) + }) + + test('updates the file when content changes', async (context) => { + const { testDir } = await writeFixtures(context, { + 'pkg.json': '{\n "foo": "bar"\n}', + }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { foo: 'baz' }) + expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "baz"\n}') + }) + + test('preserves tab indentation', async (context) => { + const { testDir } = await writeFixtures(context, { + 'pkg.json': '{\n\t"foo": "bar"\n}', + }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { foo: 'baz' }) + expect(readFileSync(filePath, 'utf8')).toBe('{\n\t"foo": "baz"\n}') + }) + + test('preserves 4-space indentation', async (context) => { + const { testDir } = await writeFixtures(context, { + 'pkg.json': '{\n "foo": "bar"\n}', + }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { foo: 'baz' }) + expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "baz"\n}') + }) + + test('preserves CRLF line endings', async (context) => { + const { testDir } = await writeFixtures(context, { + 'pkg.json': '{\r\n "foo": "bar"\r\n}', + }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { foo: 'baz' }) + expect(readFileSync(filePath, 'utf8')).toBe('{\r\n "foo": "baz"\r\n}') + }) + + test('preserves trailing newline', async (context) => { + const { testDir } = await writeFixtures(context, { + 'pkg.json': '{\n "foo": "bar"\n}\n', + }) + const filePath = path.join(testDir, 'pkg.json') + writeJsonFile(filePath, { foo: 'baz' }) + expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "baz"\n}\n') + }) +}) + +describe('detectIndent', () => { + test('two spaces', ({ expect }) => { + expect(detectIndentation(stringifyJson(2))).toBe(2) + }) + test('four spaces', ({ expect }) => { + expect(detectIndentation(stringifyJson(4))).toBe(4) + }) + test('tab', ({ expect }) => { + expect(detectIndentation(stringifyJson('\t'))).toBe('\t') + }) + test('empty', ({ expect }) => { + expect(detectIndentation('')).toBe(2) + }) + test('empty line', ({ expect }) => { + expect(detectIndentation('{\n\n "foo": 42 }')).toBe(2) + }) +}) + +function stringifyJson(indentation: string | number): string { + const contents = JSON.stringify({ foo: 42 }, null, indentation) + return contents +} diff --git a/src/utils/json.ts b/src/utils/json.ts new file mode 100644 index 000000000..da96e149a --- /dev/null +++ b/src/utils/json.ts @@ -0,0 +1,54 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { isDeepStrictEqual } from 'node:util' + +export function writeJsonFile(filePath: string, content: unknown): void { + let originalContent: unknown = undefined + let originalIndent: string | number = 2 + let originalEOL: string = '\n' + let originalHasTrailingNewline: boolean = false + + try { + const text = readFileSync(filePath, 'utf8') + originalContent = JSON.parse(text) + originalIndent = detectIndentation(text) + if (text.includes('\r\n')) { + originalEOL = '\r\n' + } + if (text.endsWith('\n')) { + originalHasTrailingNewline = true + } + } catch { + // File doesn't exist or isn't valid JSON, we'll overwrite it with our content + } + + if (originalContent && isDeepStrictEqual(originalContent, content)) { + // The content is the same. We just return without updating the file format + return + } + + let jsonString = JSON.stringify(content, null, originalIndent) + if (originalEOL !== '\n') { + jsonString = jsonString.replaceAll('\n', originalEOL) + } + if (originalHasTrailingNewline) { + jsonString += originalEOL + } + + writeFileSync(filePath, jsonString, 'utf8') +} + +export function detectIndentation(jsonText: string): string | number { + const lines = jsonText.split(/\r?\n/) + + for (const line of lines) { + const match = line.match(/^(\s+)\S/) + if (!match) continue + + if (match[1].includes('\t')) { + return '\t' + } + return match[1].length + } + + return 2 +}