Skip to content

Commit 422b122

Browse files
committed
feat(json): add EditableJson base class with shared formatting utilities
Add a new EditableJson base class for generic JSON file manipulation with formatting preservation, extracted from EditablePackageJson to enable code reuse via composition pattern. Key changes: - Create EditableJson base class in src/json.ts - Extract shared utilities to src/lib/json-format.ts - Refactor EditableJson and EditablePackageJson to use shared utilities - Add comprehensive test coverage (125 new tests) - Export via package.json ./json module refactor(json): reorganize into src/json/ directory structure Reorganize JSON utilities to avoid lib/lib redundancy by creating a dedicated src/json/ directory with modular subfiles. Changes: - Create src/json/ directory structure with submodules: - types.ts: Type definitions and interfaces - parse.ts: JSON parsing utilities (isJsonPrimitive, jsonParse) - format.ts: Formatting utilities (moved from lib/json-format.ts) - editable.ts: EditableJson class for file manipulation - index.ts: Barrel export for all JSON functionality - Remove old src/json.ts file (replaced by modular structure) - Update imports: - src/fs.ts: Import from json/types and json/parse - src/packages/editable.ts: Import from json/format - Update package.json exports: - ./json → ./dist/json/index.js (main export) - ./json/editable, ./json/format, ./json/parse, ./json/types (submodule exports) - Remove ./lib/json-format export - Move test file: test/unit/lib/json-format.test.ts → test/unit/json/format.test.ts - All tests passing (224 tests in json module) refactor(json): remove barrel file, use direct submodule exports Remove the barrel index file to avoid redundant export patterns, requiring direct imports from specific submodules instead. Changes: - Remove src/json/index.ts barrel file - Remove ./json and ./json/index from package.json exports - Update test imports to use direct submodule paths: - '@socketsecurity/lib/json/parse' for jsonParse and isJsonPrimitive - '@socketsecurity/lib/json/editable' for getEditableJsonClass - All tests passing (224 tests) Submodule exports available: - @socketsecurity/lib/json/editable - @socketsecurity/lib/json/format - @socketsecurity/lib/json/parse - @socketsecurity/lib/json/types
1 parent 7c2aa7d commit 422b122

File tree

9 files changed

+2148
-173
lines changed

9 files changed

+2148
-173
lines changed

package.json

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,21 @@
355355
"types": "./dist/ipc.d.ts",
356356
"default": "./dist/ipc.js"
357357
},
358-
"./json": {
359-
"types": "./dist/json.d.ts",
360-
"default": "./dist/json.js"
358+
"./json/editable": {
359+
"types": "./dist/json/editable.d.ts",
360+
"default": "./dist/json/editable.js"
361+
},
362+
"./json/format": {
363+
"types": "./dist/json/format.d.ts",
364+
"default": "./dist/json/format.js"
365+
},
366+
"./json/parse": {
367+
"types": "./dist/json/parse.d.ts",
368+
"default": "./dist/json/parse.js"
369+
},
370+
"./json/types": {
371+
"types": "./dist/json/types.d.ts",
372+
"default": "./dist/json/types.js"
361373
},
362374
"./lifecycle-script-names": {
363375
"types": "./dist/lifecycle-script-names.d.ts",

src/fs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import { getAbortSignal } from './constants/process'
2020
import { isArray } from './arrays'
2121
import { deleteAsync, deleteSync } from './external/del'
2222
import { defaultIgnore, getGlobMatcher } from './globs'
23-
import type { JsonReviver } from './json'
24-
import { jsonParse } from './json'
23+
import type { JsonReviver } from './json/types'
24+
import { jsonParse } from './json/parse'
2525
import { objectFreeze, type Remap } from './objects'
2626
import { normalizePath, pathLikeToString } from './paths/normalize'
2727
import { registerCacheInvalidation } from './paths/rewire'

src/json/editable.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/**
2+
* @fileoverview Editable JSON file manipulation with formatting preservation.
3+
*/
4+
5+
import {
6+
INDENT_SYMBOL,
7+
NEWLINE_SYMBOL,
8+
detectIndent,
9+
detectNewline,
10+
getFormattingFromContent,
11+
shouldSave as shouldSaveUtil,
12+
sortKeys,
13+
stringifyWithFormatting,
14+
stripFormattingSymbols,
15+
} from './format'
16+
import type {
17+
EditableJsonConstructor,
18+
EditableJsonInstance,
19+
EditableJsonOptions,
20+
EditableJsonSaveOptions,
21+
} from './types'
22+
23+
const identSymbol = INDENT_SYMBOL
24+
const newlineSymbol = NEWLINE_SYMBOL
25+
26+
// IMPORTANT: Do not use destructuring here - use direct assignment instead.
27+
// tsgo has a bug that incorrectly transpiles destructured exports, resulting in
28+
// `exports.SomeName = void 0;` which causes runtime errors.
29+
// See: https://github.com/SocketDev/socket-packageurl-js/issues/3
30+
const JSONParse = JSON.parse
31+
32+
let _EditableJsonClass: EditableJsonConstructor | undefined
33+
34+
let _fs: typeof import('node:fs') | undefined
35+
/*@__NO_SIDE_EFFECTS__*/
36+
function getFs() {
37+
if (_fs === undefined) {
38+
// Use non-'node:' prefixed require to avoid Webpack errors.
39+
_fs = /*@__PURE__*/ require('node:fs')
40+
}
41+
return _fs as typeof import('node:fs')
42+
}
43+
44+
/**
45+
* Parse JSON content and extract formatting metadata.
46+
* @private
47+
*/
48+
function parseJson(content: string): unknown {
49+
return JSONParse(content)
50+
}
51+
52+
/**
53+
* Read file content from disk.
54+
* @private
55+
*/
56+
async function readFile(filepath: string): Promise<string> {
57+
const { promises: fsPromises } = getFs()
58+
return await fsPromises.readFile(filepath, 'utf8')
59+
}
60+
61+
/**
62+
* Get the EditableJson class for JSON file manipulation.
63+
*
64+
* @example
65+
* ```ts
66+
* import { getEditableJsonClass } from '@socketsecurity/lib/json'
67+
*
68+
* const EditableJson = getEditableJsonClass<MyConfigType>()
69+
* const config = await EditableJson.load('./config.json')
70+
* config.update({ someField: 'newValue' })
71+
* await config.save({ sort: true })
72+
* ```
73+
*/
74+
/*@__NO_SIDE_EFFECTS__*/
75+
export function getEditableJsonClass<
76+
T = Record<string, unknown>,
77+
>(): EditableJsonConstructor<T> {
78+
if (_EditableJsonClass === undefined) {
79+
_EditableJsonClass = class EditableJson<T = Record<string, unknown>>
80+
implements EditableJsonInstance<T>
81+
{
82+
_canSave = true
83+
_content: T = {} as T
84+
_path: string | undefined = undefined
85+
_readFileContent = ''
86+
_readFileJson: unknown = undefined
87+
88+
get content(): Readonly<T> {
89+
return this._content
90+
}
91+
92+
get filename(): string {
93+
const path = this._path
94+
if (!path) {
95+
return ''
96+
}
97+
return path
98+
}
99+
100+
get path(): string | undefined {
101+
return this._path
102+
}
103+
104+
static async create<T = Record<string, unknown>>(
105+
path: string,
106+
opts: EditableJsonOptions<T> = {},
107+
): Promise<EditableJsonInstance<T>> {
108+
const instance = new EditableJson<T>()
109+
instance.create(path)
110+
return opts.data ? instance.update(opts.data) : instance
111+
}
112+
113+
static async load<T = Record<string, unknown>>(
114+
path: string,
115+
opts: EditableJsonOptions<T> = {},
116+
): Promise<EditableJsonInstance<T>> {
117+
const instance = new EditableJson<T>()
118+
// Avoid try/catch if we aren't going to create
119+
if (!opts.create) {
120+
return await instance.load(path)
121+
}
122+
try {
123+
return await instance.load(path)
124+
} catch (err: unknown) {
125+
if (
126+
!(err as Error).message.includes('ENOENT') &&
127+
!(err as Error).message.includes('no such file')
128+
) {
129+
throw err
130+
}
131+
return instance.create(path)
132+
}
133+
}
134+
135+
create(path: string): this {
136+
this._path = path
137+
this._content = {} as T
138+
this._canSave = true
139+
return this
140+
}
141+
142+
fromContent(data: unknown): this {
143+
this._content = data as T
144+
this._canSave = false
145+
return this
146+
}
147+
148+
fromJSON(data: string): this {
149+
const parsed = parseJson(data) as T & Record<symbol, unknown>
150+
// Extract and preserve formatting metadata
151+
const indent = detectIndent(data)
152+
const newline = detectNewline(data)
153+
154+
// Store formatting metadata using symbols
155+
;(parsed as Record<symbol, unknown>)[identSymbol] = indent
156+
;(parsed as Record<symbol, unknown>)[newlineSymbol] = newline
157+
158+
this._content = parsed as T
159+
return this
160+
}
161+
162+
async load(path: string, create?: boolean): Promise<this> {
163+
this._path = path
164+
let parseErr: unknown
165+
try {
166+
this._readFileContent = await readFile(this.filename)
167+
} catch (err) {
168+
if (!create) {
169+
throw err
170+
}
171+
parseErr = err
172+
}
173+
if (parseErr) {
174+
throw parseErr
175+
}
176+
this.fromJSON(this._readFileContent)
177+
// Add AFTER fromJSON is called in case it errors.
178+
this._readFileJson = parseJson(this._readFileContent)
179+
return this
180+
}
181+
182+
update(content: Partial<T>): this {
183+
this._content = {
184+
...this._content,
185+
...content,
186+
} as T
187+
return this
188+
}
189+
190+
async save(options?: EditableJsonSaveOptions): Promise<boolean> {
191+
if (!this._canSave || this.content === undefined) {
192+
throw new Error('No file path to save to')
193+
}
194+
195+
// Check if save is needed
196+
if (
197+
!shouldSaveUtil(
198+
this.content as Record<string | symbol, unknown>,
199+
this._readFileJson as Record<string | symbol, unknown>,
200+
this._readFileContent,
201+
options,
202+
)
203+
) {
204+
return false
205+
}
206+
207+
// Get content and formatting
208+
const content = stripFormattingSymbols(
209+
this.content as Record<string | symbol, unknown>,
210+
)
211+
const sortedContent = options?.sort ? sortKeys(content) : content
212+
const formatting = getFormattingFromContent(
213+
this.content as Record<string | symbol, unknown>,
214+
)
215+
216+
// Generate file content
217+
const fileContent = stringifyWithFormatting(sortedContent, formatting)
218+
219+
// Save to disk
220+
const { promises: fsPromises } = getFs()
221+
await fsPromises.writeFile(this.filename, fileContent)
222+
this._readFileContent = fileContent
223+
this._readFileJson = parseJson(fileContent)
224+
return true
225+
}
226+
227+
saveSync(options?: EditableJsonSaveOptions): boolean {
228+
if (!this._canSave || this.content === undefined) {
229+
throw new Error('No file path to save to')
230+
}
231+
232+
// Check if save is needed
233+
if (
234+
!shouldSaveUtil(
235+
this.content as Record<string | symbol, unknown>,
236+
this._readFileJson as Record<string | symbol, unknown>,
237+
this._readFileContent,
238+
options,
239+
)
240+
) {
241+
return false
242+
}
243+
244+
// Get content and formatting
245+
const content = stripFormattingSymbols(
246+
this.content as Record<string | symbol, unknown>,
247+
)
248+
const sortedContent = options?.sort ? sortKeys(content) : content
249+
const formatting = getFormattingFromContent(
250+
this.content as Record<string | symbol, unknown>,
251+
)
252+
253+
// Generate file content
254+
const fileContent = stringifyWithFormatting(sortedContent, formatting)
255+
256+
// Save to disk
257+
const fs = getFs()
258+
fs.writeFileSync(this.filename, fileContent)
259+
this._readFileContent = fileContent
260+
this._readFileJson = parseJson(fileContent)
261+
return true
262+
}
263+
264+
willSave(options?: EditableJsonSaveOptions): boolean {
265+
if (!this._canSave || this.content === undefined) {
266+
return false
267+
}
268+
269+
return shouldSaveUtil(
270+
this.content as Record<string | symbol, unknown>,
271+
this._readFileJson as Record<string | symbol, unknown>,
272+
this._readFileContent,
273+
options,
274+
)
275+
}
276+
} as EditableJsonConstructor
277+
}
278+
return _EditableJsonClass as EditableJsonConstructor<T>
279+
}

0 commit comments

Comments
 (0)