toonfile 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,222 @@
1
+ import * as fs from 'fs'
2
+ import * as universalify from 'universalify'
3
+ import { parse, stringify, stripBom } from './utils'
4
+ import type { ReadOptions, WriteOptions, Callback } from './types'
5
+
6
+ let _fs: typeof fs
7
+ try {
8
+ _fs = require('graceful-fs') as typeof fs
9
+ } catch (_) {
10
+ _fs = fs
11
+ }
12
+
13
+ // ============ READ FILE ============
14
+
15
+ async function _readFile (file: string, options: ReadOptions | string = {}): Promise<any> {
16
+ if (typeof options === 'string') {
17
+ options = { encoding: options as BufferEncoding }
18
+ }
19
+
20
+ const fsModule = options.fs || _fs
21
+
22
+ const shouldThrow = 'throws' in options ? options.throws : true
23
+
24
+ let data: string
25
+ try {
26
+ const result = await new Promise<Buffer | string>((resolve, reject) => {
27
+ fsModule.readFile(file, options.encoding || 'utf8', (err: any, data: any) => {
28
+ if (err) reject(err)
29
+ else resolve(data)
30
+ })
31
+ })
32
+ data = typeof result === 'string' ? result : result.toString('utf8')
33
+ } catch (err) {
34
+ if (shouldThrow) {
35
+ const error = err as NodeJS.ErrnoException
36
+ error.message = `${file}: ${error.message}`
37
+ throw error
38
+ } else {
39
+ return null
40
+ }
41
+ }
42
+
43
+ data = stripBom(data)
44
+
45
+ let obj: any
46
+ try {
47
+ obj = parse(data, options)
48
+ } catch (err) {
49
+ if (shouldThrow) {
50
+ const error = err as Error
51
+ error.message = `${file}: ${error.message}`
52
+ throw error
53
+ } else {
54
+ return null
55
+ }
56
+ }
57
+
58
+ return obj
59
+ }
60
+
61
+ /**
62
+ * Read and parse a TOON file asynchronously
63
+ *
64
+ * @param file - Path to the TOON file
65
+ * @param options - Read options or encoding string
66
+ * @param callback - Optional callback function
67
+ * @returns Promise that resolves to parsed object (if no callback provided)
68
+ *
69
+ * @example
70
+ * // With promise
71
+ * const data = await readFile('config.toon')
72
+ *
73
+ * @example
74
+ * // With callback
75
+ * readFile('config.toon', (err, data) => {
76
+ * if (err) console.error(err)
77
+ * console.log(data)
78
+ * })
79
+ *
80
+ * @example
81
+ * // With options
82
+ * const data = await readFile('config.toon', { throws: false })
83
+ */
84
+ export const readFile: {
85
+ (file: string, options?: ReadOptions | string): Promise<any>
86
+ (file: string, callback: Callback<any>): void
87
+ (file: string, options: ReadOptions | string, callback: Callback<any>): void
88
+ } = universalify.fromPromise(_readFile) as any
89
+
90
+ /**
91
+ * Read and parse a TOON file synchronously
92
+ *
93
+ * @param file - Path to the TOON file
94
+ * @param options - Read options or encoding string
95
+ * @returns Parsed object
96
+ *
97
+ * @example
98
+ * const data = readFileSync('config.toon')
99
+ *
100
+ * @example
101
+ * const data = readFileSync('config.toon', { throws: false })
102
+ */
103
+ export function readFileSync (file: string, options: ReadOptions | string = {}): any {
104
+ if (typeof options === 'string') {
105
+ options = { encoding: options as BufferEncoding }
106
+ }
107
+
108
+ const fsModule = options.fs || _fs
109
+
110
+ const shouldThrow = 'throws' in options ? options.throws : true
111
+
112
+ try {
113
+ const buffer = fsModule.readFileSync(file, options as any)
114
+ let content = typeof buffer === 'string' ? buffer : buffer.toString('utf8')
115
+ content = stripBom(content)
116
+ return parse(content, options)
117
+ } catch (err) {
118
+ if (shouldThrow) {
119
+ const error = err as NodeJS.ErrnoException
120
+ error.message = `${file}: ${error.message}`
121
+ throw error
122
+ } else {
123
+ return null
124
+ }
125
+ }
126
+ }
127
+
128
+ // ============ WRITE FILE ============
129
+
130
+ async function _writeFile (file: string, obj: any, options: WriteOptions = {}): Promise<void> {
131
+ const fsModule = options.fs || _fs
132
+
133
+ const str = stringify(obj, options)
134
+
135
+ const writeOptions: any = { encoding: options.encoding || 'utf8' }
136
+ if (options.mode) writeOptions.mode = options.mode
137
+ if (options.flag) writeOptions.flag = options.flag
138
+
139
+ await new Promise<void>((resolve, reject) => {
140
+ fsModule.writeFile(file, str, writeOptions, (err: any) => {
141
+ if (err) reject(err)
142
+ else resolve()
143
+ })
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Stringify object and write to TOON file asynchronously
149
+ *
150
+ * @param file - Path to the TOON file
151
+ * @param obj - Object to stringify and write
152
+ * @param options - Write options
153
+ * @param callback - Optional callback function
154
+ * @returns Promise that resolves when file is written (if no callback provided)
155
+ *
156
+ * @example
157
+ * // With promise
158
+ * await writeFile('data.toon', { name: 'Alice', age: 30 })
159
+ *
160
+ * @example
161
+ * // With callback
162
+ * writeFile('data.toon', obj, (err) => {
163
+ * if (err) console.error(err)
164
+ * })
165
+ *
166
+ * @example
167
+ * // With options
168
+ * await writeFile('data.toon', obj, { indentSize: 4, delimiter: '|' })
169
+ */
170
+ export const writeFile: {
171
+ (file: string, obj: any, options?: WriteOptions): Promise<void>
172
+ (file: string, obj: any, callback: Callback<void>): void
173
+ (file: string, obj: any, options: WriteOptions, callback: Callback<void>): void
174
+ } = universalify.fromPromise(_writeFile) as any
175
+
176
+ /**
177
+ * Stringify object and write to TOON file synchronously
178
+ *
179
+ * @param file - Path to the TOON file
180
+ * @param obj - Object to stringify and write
181
+ * @param options - Write options
182
+ *
183
+ * @example
184
+ * writeFileSync('data.toon', { name: 'Bob', age: 25 })
185
+ *
186
+ * @example
187
+ * writeFileSync('data.toon', obj, { indentSize: 4 })
188
+ */
189
+ export function writeFileSync (file: string, obj: any, options: WriteOptions = {}): void {
190
+ const fsModule = options.fs || _fs
191
+
192
+ const str = stringify(obj, options)
193
+ fsModule.writeFileSync(file, str, options as any)
194
+ }
195
+
196
+ // ============ EXPORTS ============
197
+
198
+ // Re-export utility functions
199
+ export { parse, stringify } from './utils'
200
+
201
+ // Re-export types
202
+ export type {
203
+ ReadOptions,
204
+ WriteOptions,
205
+ ParseOptions,
206
+ StringifyOptions,
207
+ Callback,
208
+ ToonValue,
209
+ ToonObject,
210
+ ToonArray
211
+ } from './types'
212
+
213
+ // NOTE: do not change this export format; required for ESM compat
214
+ // Default export for CommonJS compatibility
215
+ export default {
216
+ readFile,
217
+ readFileSync,
218
+ writeFile,
219
+ writeFileSync,
220
+ parse,
221
+ stringify
222
+ }
package/src/types.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * TOON File Types and Interfaces
3
+ */
4
+
5
+ import type * as fs from 'fs'
6
+
7
+ /**
8
+ * Options for reading TOON files
9
+ */
10
+ export interface ReadOptions {
11
+ /**
12
+ * File encoding (default: 'utf8')
13
+ */
14
+ encoding?: BufferEncoding
15
+
16
+ /**
17
+ * Throw error on parse failure. If false, returns null instead.
18
+ * @default true
19
+ */
20
+ throws?: boolean
21
+
22
+ /**
23
+ * Custom fs module for testing or alternative file systems
24
+ */
25
+ fs?: typeof fs
26
+
27
+ /**
28
+ * Transform function applied to parsed values (similar to JSON.parse reviver)
29
+ */
30
+ reviver?: (key: string, value: any) => any
31
+
32
+ /**
33
+ * Indent size for parsing (default: 2)
34
+ */
35
+ indentSize?: number
36
+
37
+ /**
38
+ * Strict mode for parsing
39
+ * @default true
40
+ */
41
+ strict?: boolean
42
+ }
43
+
44
+ /**
45
+ * Options for writing TOON files
46
+ */
47
+ export interface WriteOptions {
48
+ /**
49
+ * File encoding (default: 'utf8')
50
+ */
51
+ encoding?: BufferEncoding
52
+
53
+ /**
54
+ * Custom fs module for testing or alternative file systems
55
+ */
56
+ fs?: typeof fs
57
+
58
+ /**
59
+ * Spaces per indent level
60
+ * @default 2
61
+ */
62
+ indentSize?: number
63
+
64
+ /**
65
+ * Delimiter for arrays: comma, tab, or pipe
66
+ * @default ','
67
+ */
68
+ delimiter?: ',' | '\t' | '|'
69
+
70
+ /**
71
+ * End-of-line character(s)
72
+ * @default '\n'
73
+ */
74
+ EOL?: '\n' | '\r\n' | string
75
+
76
+ /**
77
+ * Include EOL at end of file
78
+ * @default true
79
+ */
80
+ finalEOL?: boolean
81
+
82
+ /**
83
+ * Use tabular format for uniform arrays of objects
84
+ * @default true
85
+ */
86
+ tabularArrays?: boolean
87
+
88
+ /**
89
+ * Minimum array length to use tabular format
90
+ * @default 2
91
+ */
92
+ minArrayLengthForTabular?: number
93
+
94
+ /**
95
+ * Transform function applied before stringifying (similar to JSON.stringify replacer)
96
+ */
97
+ replacer?: (key: string, value: any) => any
98
+
99
+ /**
100
+ * File system flags (e.g., 'w', 'a')
101
+ * @default 'w'
102
+ */
103
+ flag?: string
104
+
105
+ /**
106
+ * File mode (permissions)
107
+ */
108
+ mode?: number
109
+ }
110
+
111
+ /**
112
+ * Options for parsing TOON strings
113
+ */
114
+ export interface ParseOptions {
115
+ /**
116
+ * Transform function applied to parsed values
117
+ */
118
+ reviver?: (key: string, value: any) => any
119
+
120
+ /**
121
+ * Indent size (default: 2)
122
+ */
123
+ indentSize?: number
124
+
125
+ /**
126
+ * Strict mode
127
+ * @default true
128
+ */
129
+ strict?: boolean
130
+ }
131
+
132
+ /**
133
+ * Options for stringifying objects to TOON
134
+ */
135
+ export interface StringifyOptions {
136
+ /**
137
+ * Spaces per indent level
138
+ * @default 2
139
+ */
140
+ indentSize?: number
141
+
142
+ /**
143
+ * Delimiter for arrays
144
+ * @default ','
145
+ */
146
+ delimiter?: ',' | '\t' | '|' | string
147
+
148
+ /**
149
+ * End-of-line character(s)
150
+ * @default '\n'
151
+ */
152
+ EOL?: '\n' | '\r\n' | string
153
+
154
+ /**
155
+ * Include EOL at end of file
156
+ * @default true
157
+ */
158
+ finalEOL?: boolean
159
+
160
+ /**
161
+ * Use tabular format for uniform arrays
162
+ * @default true
163
+ */
164
+ tabularArrays?: boolean
165
+
166
+ /**
167
+ * Minimum array length for tabular format
168
+ * @default 2
169
+ */
170
+ minArrayLengthForTabular?: number
171
+
172
+ /**
173
+ * Transform function applied before stringifying
174
+ */
175
+ replacer?: (key: string, value: any) => any
176
+ }
177
+
178
+ /**
179
+ * Callback function for async operations
180
+ */
181
+ export type Callback<T> = (err: Error | null, data?: T) => void
182
+
183
+ /**
184
+ * TOON value types
185
+ */
186
+ export type ToonValue =
187
+ | string
188
+ | number
189
+ | boolean
190
+ | null
191
+ | ToonArray
192
+ | ToonObject
193
+
194
+ export interface ToonObject {
195
+ [key: string]: ToonValue
196
+ }
197
+
198
+ export type ToonArray = ToonValue[]
@@ -0,0 +1,6 @@
1
+ declare module 'universalify' {
2
+ export function fromCallback<T extends (...args: any[]) => any>(fn: T): T
3
+ export function fromPromise<T extends (...args: any[]) => Promise<any>>(fn: T): T & {
4
+ (...args: [...Parameters<T>, (err: Error | null, result?: any) => void]): void
5
+ }
6
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * TOON Format Parser and Serializer
3
+ *
4
+ * Custom implementation supporting basic TOON features.
5
+ */
6
+
7
+ import type {
8
+ ParseOptions,
9
+ StringifyOptions,
10
+ ToonValue
11
+ } from './types'
12
+
13
+ /**
14
+ * Parse TOON string to JavaScript object
15
+ */
16
+ export function parse(content: string | Buffer, options: ParseOptions = {}): any {
17
+ if (Buffer.isBuffer(content)) {
18
+ content = content.toString('utf8')
19
+ }
20
+
21
+ content = stripBom(content)
22
+
23
+ const lines = content.split(/\r?\n/)
24
+ const indentSize = options.indentSize || 2
25
+
26
+ interface StackItem {
27
+ obj: any
28
+ indent: number
29
+ }
30
+
31
+ const stack: StackItem[] = [{ obj: {}, indent: -indentSize }]
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const line = lines[i]
35
+
36
+ // Skip empty lines and comments
37
+ if (!line.trim() || line.trim().startsWith('#')) continue
38
+
39
+ const indent = getIndentLevel(line)
40
+ const trimmedLine = line.trim()
41
+
42
+ // Pop stack until we find the right parent
43
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
44
+ stack.pop()
45
+ }
46
+
47
+ const parent = stack[stack.length - 1].obj
48
+
49
+ // Parse key-value
50
+ if (trimmedLine.includes(':')) {
51
+ const colonIndex = trimmedLine.indexOf(':')
52
+ const key = trimmedLine.substring(0, colonIndex).trim()
53
+ const valueStr = trimmedLine.substring(colonIndex + 1).trim()
54
+
55
+ // Check for array declaration: key[length]
56
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/)
57
+
58
+ if (arrayMatch) {
59
+ // Inline array: scores[3]: 95,87,92
60
+ const arrayName = arrayMatch[1]
61
+ const arrayLength = parseInt(arrayMatch[2], 10)
62
+
63
+ if (valueStr) {
64
+ parent[arrayName] = parseInlineArray(valueStr, arrayLength)
65
+ } else {
66
+ parent[arrayName] = []
67
+ }
68
+ } else if (valueStr) {
69
+ // Simple key-value
70
+ parent[key] = parseValue(valueStr)
71
+ } else {
72
+ // Nested object
73
+ const newObj: any = {}
74
+ parent[key] = newObj
75
+ stack.push({ obj: newObj, indent })
76
+ }
77
+ }
78
+ }
79
+
80
+ const result = stack[0].obj
81
+
82
+ if (options.reviver) {
83
+ return applyReviver(result, options.reviver)
84
+ }
85
+
86
+ return result
87
+ }
88
+
89
+ /**
90
+ * Stringify JavaScript object to TOON format
91
+ */
92
+ export function stringify(obj: any, options: StringifyOptions = {}): string {
93
+ const indentSize = options.indentSize || 2
94
+ const delimiter = options.delimiter || ','
95
+ const lines: string[] = []
96
+
97
+ function serializeObject(o: any, depth: number = 0): void {
98
+ const indent = ' '.repeat(depth * indentSize)
99
+
100
+ for (const [key, value] of Object.entries(o)) {
101
+ if (Array.isArray(value)) {
102
+ const arrayStr = value.map(v => serializeValue(v)).join(delimiter)
103
+ lines.push(`${indent}${key}[${value.length}]: ${arrayStr}`)
104
+ } else if (value !== null && typeof value === 'object') {
105
+ lines.push(`${indent}${key}:`)
106
+ serializeObject(value, depth + 1)
107
+ } else {
108
+ lines.push(`${indent}${key}: ${serializeValue(value)}`)
109
+ }
110
+ }
111
+ }
112
+
113
+ function serializeValue(value: any): string {
114
+ if (value === null) return 'null'
115
+ if (typeof value === 'boolean') return value.toString()
116
+ if (typeof value === 'number') return value.toString()
117
+ if (typeof value === 'string') {
118
+ if (needsQuotes(value, delimiter)) {
119
+ return `"${escapeString(value)}"`
120
+ }
121
+ return value
122
+ }
123
+ return String(value)
124
+ }
125
+
126
+ serializeObject(obj)
127
+
128
+ let result = lines.join('\n')
129
+
130
+ // Handle finalEOL (default: true)
131
+ const finalEOL = options.finalEOL !== undefined ? options.finalEOL : true
132
+ if (finalEOL && !result.endsWith('\n')) {
133
+ result += '\n'
134
+ } else if (!finalEOL && result.endsWith('\n')) {
135
+ result = result.slice(0, -1)
136
+ }
137
+
138
+ // Handle EOL customization
139
+ const EOL = options.EOL || '\n'
140
+ if (EOL !== '\n') {
141
+ result = result.replace(/\n/g, EOL)
142
+ }
143
+
144
+ return result
145
+ }
146
+
147
+ export function stripBom(content: string | Buffer): string {
148
+ if (Buffer.isBuffer(content)) content = content.toString('utf8')
149
+ return content.replace(/^\uFEFF/, '')
150
+ }
151
+
152
+ function applyReviver(obj: any, reviver: (key: string, value: any) => any): any {
153
+ return reviver('', obj)
154
+ }
155
+
156
+ function parseInlineArray(valueStr: string, expectedLength?: number): any[] {
157
+ const delimiter = detectDelimiter(valueStr)
158
+ const values = valueStr.split(delimiter).map(v => parseValue(v.trim()))
159
+
160
+ if (expectedLength !== undefined && values.length !== expectedLength) {
161
+ console.warn(`[toonfile] Array length mismatch: expected ${expectedLength}, got ${values.length}`)
162
+ }
163
+
164
+ return values
165
+ }
166
+
167
+ function detectDelimiter(str: string): string | RegExp {
168
+ if (str.includes('\t')) return '\t'
169
+ if (str.includes('|')) return '|'
170
+ return ','
171
+ }
172
+
173
+ function parseValue(str: string): ToonValue {
174
+ if (str === 'null') return null
175
+ if (str === 'true') return true
176
+ if (str === 'false') return false
177
+
178
+ if (/^-?\d+(\.\d+)?$/.test(str)) {
179
+ return parseFloat(str)
180
+ }
181
+
182
+ if ((str.startsWith('"') && str.endsWith('"')) ||
183
+ (str.startsWith("'") && str.endsWith("'"))) {
184
+ return unescapeString(str.slice(1, -1))
185
+ }
186
+
187
+ return str
188
+ }
189
+
190
+ function getIndentLevel(line: string): number {
191
+ const match = line.match(/^( *)/)
192
+ return match ? match[1].length : 0
193
+ }
194
+
195
+ function needsQuotes(str: string, delimiter: string): boolean {
196
+ const reservedWords = ['true', 'false', 'null']
197
+ if (reservedWords.includes(str)) return true
198
+ if (str.includes(delimiter)) return true
199
+ if (str.includes(':')) return true
200
+ if (str.includes('\n') || str.includes('\r') || str.includes('\t')) return true
201
+ if (/^-?\d+(\.\d+)?$/.test(str)) return true
202
+ return false
203
+ }
204
+
205
+ function escapeString(str: string): string {
206
+ return str
207
+ .replace(/\\/g, '\\\\')
208
+ .replace(/"/g, '\\"')
209
+ .replace(/\n/g, '\\n')
210
+ .replace(/\r/g, '\\r')
211
+ .replace(/\t/g, '\\t')
212
+ }
213
+
214
+ function unescapeString(str: string): string {
215
+ return str
216
+ .replace(/\\n/g, '\n')
217
+ .replace(/\\r/g, '\r')
218
+ .replace(/\\t/g, '\t')
219
+ .replace(/\\"/g, '"')
220
+ .replace(/\\'/g, "'")
221
+ .replace(/\\\\/g, '\\')
222
+ }