├── .npmrc ├── .gitignore ├── src ├── setKeyQuoteUsage.js ├── index.js └── writeValue.js ├── package.json ├── README.md └── tests └── json5Writer.test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /src/setKeyQuoteUsage.js: -------------------------------------------------------------------------------- 1 | const j = require('jscodeshift') 2 | 3 | function setKeyQuoteUsage(ast, enabled) { 4 | return j(ast.toSource()) 5 | .find(j.ObjectExpression) 6 | .forEach(path => { 7 | path.value.properties.forEach(prop => { 8 | if (enabled) { 9 | quoteKey(prop) 10 | } else { 11 | unquoteKey(prop) 12 | } 13 | }) 14 | }) 15 | } 16 | 17 | function quoteKey(prop) { 18 | if (prop.key.type === 'Identifier') { 19 | prop.key = j.literal(prop.key.name) 20 | } 21 | } 22 | 23 | function unquoteKey(prop) { 24 | if (prop.key.type === 'Literal') { 25 | prop.key = j.identifier(prop.key.value) 26 | } 27 | } 28 | 29 | module.exports = setKeyQuoteUsage 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json5-writer", 3 | "version": "0.1.7", 4 | "description": "Comment-preserving JSON / JSON5 parser", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:noahsug/json5-writer.git" 12 | }, 13 | "keywords": [ 14 | "json5", 15 | "preserve", 16 | "comments", 17 | "parse", 18 | "parser", 19 | "config", 20 | "update", 21 | "ast" 22 | ], 23 | "author": "Noah Sugarman ", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/noahsug/json5-writer/issues" 27 | }, 28 | "homepage": "https://github.com/noahsug/json5-writer#readme", 29 | "dependencies": { 30 | "jscodeshift": "^0.6.3" 31 | }, 32 | "devDependencies": { 33 | "jest": "^24.5.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const j = require('jscodeshift') 2 | const writeValue = require('./writeValue') 3 | const setKeyQuoteUsage = require('./setKeyQuoteUsage') 4 | 5 | function load(src) { 6 | const ast = toAst(src) 7 | const root = ast.nodes()[0].program.body[0].expression 8 | 9 | // @param {Object|Array} value 10 | function write(value) { 11 | root.right = writeValue(root.right, value) 12 | } 13 | 14 | function toSource(options = {}) { 15 | // set default options 16 | options = Object.assign( 17 | { 18 | quote: 'single', 19 | trailingComma: true, 20 | }, 21 | options 22 | ) 23 | 24 | const sourceAst = 25 | options.quoteKeys === undefined 26 | ? ast 27 | : setKeyQuoteUsage(ast, options.quoteKeys) 28 | 29 | // strip the "x=" prefix 30 | return sourceAst.toSource(options).replace(/^x=([{\[])/m, '$1') 31 | } 32 | 33 | function toJSON(options = {}) { 34 | return toSource( 35 | Object.assign( 36 | { 37 | quote: 'double', 38 | trailingComma: false, 39 | quoteKeys: true 40 | }, 41 | options 42 | ) 43 | ) 44 | } 45 | 46 | return { write, toSource, toJSON, ast: j(root.right) } 47 | } 48 | 49 | function toAst(src) { 50 | // find the start of the outermost array or object 51 | const expressionStart = src.match(/^\s*[{\[]/m) 52 | if (expressionStart) { 53 | // hackily insert "x=" so the JSON5 becomes valid JavaScript 54 | const astSrc = src.replace(/^\s*([{\[])/m, 'x=$1') 55 | return j(astSrc) 56 | } 57 | 58 | // no array or object exist in the JSON5 59 | return j('x={}') 60 | } 61 | 62 | module.exports = { load } 63 | -------------------------------------------------------------------------------- /src/writeValue.js: -------------------------------------------------------------------------------- 1 | const j = require('jscodeshift') 2 | 3 | // @param {j.ObjectExpression|j.ArrayExpression|j.Literal} node 4 | function writeValue(node, value) { 5 | if (value === undefined) return node 6 | 7 | node = nodeTypeMatchesValue(node, value) ? node : createEmptyNode(value) 8 | 9 | if (node.type === 'ArrayExpression') { 10 | writeArray(node, value) 11 | } else if (node.type === 'ObjectExpression') { 12 | writeObj(node, value) 13 | } else if (node.type === 'Literal') { 14 | node.value = value 15 | } 16 | return node 17 | } 18 | 19 | function nodeTypeMatchesValue(node, value) { 20 | if (value === undefined || node === undefined) return false 21 | if (isArray(value)) return node.type === 'ArrayExpression' 22 | if (isObject(value)) return node.type === 'ObjectExpression' 23 | return node.type === 'Literal' 24 | } 25 | 26 | function createEmptyNode(value) { 27 | if (isArray(value)) { 28 | return j.arrayExpression([]) 29 | } 30 | if (isObject(value)) { 31 | return j.objectExpression([]) 32 | } 33 | return j.literal('') 34 | } 35 | 36 | function writeArray(node, array) { 37 | array.forEach((value, index) => { 38 | const existingElement = node.elements[index] 39 | node.elements[index] = writeValue(existingElement, value) 40 | }) 41 | node.elements.length = array.length 42 | } 43 | 44 | function writeObj(node, obj) { 45 | const newProperties = [] 46 | Object.keys(obj).forEach((key, index) => { 47 | const existingProperty = findPropertyByKey(node.properties, key) 48 | if (existingProperty) { 49 | existingProperty.value = writeValue(existingProperty.value, obj[key]) 50 | newProperties.push(existingProperty) 51 | } else { 52 | if (obj[key] === undefined) return 53 | const newKey = getNewPropertyKey(node.properties, key) 54 | const newValue = writeValue(undefined, obj[key]) 55 | const newProperty = j.property('init', newKey, newValue) 56 | newProperties.push(newProperty) 57 | } 58 | }) 59 | node.properties = newProperties 60 | } 61 | 62 | function findPropertyByKey(properties, key) { 63 | return properties.find(p => (p.key.name || p.key.value) === key) 64 | } 65 | 66 | function getNewPropertyKey(properties, key) { 67 | // if the key has invalid characters, it has to be a string literal 68 | if (key.match(/[^a-zA-Z0-9_]/)) { 69 | return j.literal(key) 70 | } 71 | 72 | // infer whether to use a literal or identifier by looking at the other keys 73 | const useIdentifier = 74 | properties.length === 0 || properties.some(p => p.key.type === 'Identifier') 75 | return useIdentifier ? j.identifier(key) : j.literal(key) 76 | } 77 | 78 | function isObject(value) { 79 | return typeof value === 'object' && !isArray(value) 80 | } 81 | 82 | function isArray(value) { 83 | return Array.isArray(value) 84 | } 85 | 86 | module.exports = writeValue 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json5-writer 2 | > Comment-preserving JSON / JSON5 parser 3 | 4 | json5-writer provides an API for parsing JSON and JSON5 without losing comments or formatting. It does so by transforming JSON5 into a JavaScript AST and using [jscodeshift](https://github.com/facebook/jscodeshift) to update values. 5 | 6 | ## Example 7 | ```js 8 | const json5Writer = require('json5-writer') 9 | const config = fs.readFileSync('config.json5', 'utf-8') 10 | const writer = json5Writer.load(config) 11 | writer.write({ 12 | 'eat honey': { cooldown: 3 }, 13 | speak: { cooldown: 2 }, 14 | bear: { actions: ['eat honey', 'speak'] }, 15 | }) 16 | fs.writeFileSync('config.json5', writer.toSource(), 'utf-8') 17 | ``` 18 | 19 | `config.json5` diff 20 | ```diff 21 | { 22 | // actions 23 | 'eat honey': { 24 | - cooldown: 4, 25 | + cooldown: 3, 26 | }, 27 | + 28 | + 'speak': { 29 | + cooldown: 2, 30 | + }, 31 | 32 | // Note: A day without a friend is like a pot without a single drop of honey left inside. 33 | 34 | // entities 35 | 'bear': { 36 | - actions: [ 'eat honey' ], 37 | - canSpeak: true, 38 | + actions: ['eat honey', 'speak'], 39 | }, 40 | } 41 | ``` 42 | 43 | ## Installation 44 | ```sh 45 | npm install --save json5-writer 46 | ``` 47 | 48 | ## Usage 49 | ```js 50 | const writerInstance = json5Writer.load(jsonStr) // get a writer instance for the given JSON / JSON5 string 51 | writerInstance.write(objectOrArray) // update jsonStr, preserving comments 52 | const ast = writerInstance.ast // directly access the AST with jscodeshift API 53 | const newJson5 = writerInstance.toSource(options) // get the modified JSON5 string 54 | const newJson = writerInstance.toJSON(options) // get the modified JSON string 55 | ``` 56 | 57 | #### `.write(value)` 58 | Updates the JSON / JSON5 string with the new value. Any field or property that doesn't exist in `value` is removed. 59 | 60 | To keep an existing value, use `undefined`: 61 | ```js 62 | const writer = json5Writer.load(`[{ name: 'Noah' }, { name: 'Nancy' }]`) 63 | writer.write([{ name: undefined, age: 28 }, undefined ]) 64 | write.toSource() // [{ name: 'Noah', age: 28 }, { name: 'Nancy' }] 65 | ``` 66 | 67 | #### `.ast` 68 | Directly access the JSON5-turned-JavaScript AST, wrapped in the [jscodeshift API](https://github.com/facebook/jscodeshift#the-jscodeshift-api). 69 | 70 | ```js 71 | const j = require('jscodeshift') 72 | const writer = json5Writer.load('[1, 2, 3, 4]') 73 | writer.ast.find(j.Literal).forEach(path => { 74 | if (path.value.value % 2 === 0) path.value.value = 0 75 | }) 76 | write.toSource() // [1, 0, 3, 0] 77 | ``` 78 | 79 | #### `.toSource(options)` 80 | Get the modified JSON5 string. 81 | 82 | `options` control what is output. By default, single quotes and trailing commas are enabled and key quote usage is inferred. 83 | 84 | ```js 85 | .toSource({ quote: 'single', trailingComma: true, quoteKeys: undefined }) 86 | ``` 87 | 88 | - `quoteKeys` controls whether object keys are quoted. It can have three different values: 89 | - `false` - no object keys will have quotes 90 | - `true` - all object keys will have quotes 91 | - `undefined` - object key quote usage is inferred [default] 92 | - `quote` can be either `single` or `double` 93 | 94 | View the remaining options [here](https://github.com/benjamn/recast/blob/52a7ec3eaaa37e78436841ed8afc948033a86252/lib/options.js#L61). 95 | 96 | #### `.toJSON(options)` 97 | Same as `.toSource(options)` but with `quote: 'double'`, `trailingComma: false`, `quoteKeys: true` by default. 98 | -------------------------------------------------------------------------------- /tests/json5Writer.test.js: -------------------------------------------------------------------------------- 1 | const j = require('jscodeshift') 2 | const json5Writer = require('../src/index.js') 3 | 4 | it('writes an array to empty source', () => { 5 | const writer = json5Writer.load('') 6 | writer.write(['a']) 7 | expect(writer.toSource()).toBe(`['a']`) 8 | }) 9 | 10 | it('writes an object to empty source', () => { 11 | const writer = json5Writer.load('') 12 | writer.write({ a: 'b' }) 13 | expect(writer.toSource()).toBe(`{ 14 | a: 'b', 15 | }`) 16 | }) 17 | 18 | it('updates the value of an object', () => { 19 | const writer = json5Writer.load('{ a: 5 }') 20 | writer.write({ a: '6' }) 21 | expect(writer.toSource()).toBe(`{ a: '6' }`) 22 | }) 23 | 24 | it(`writes multiple object key/values`, () => { 25 | const writer = json5Writer.load('{ a: 5 }') 26 | writer.write({ a: 6, b: '7' }) 27 | expect(writer.toSource()).toBe(`{ 28 | a: 6, 29 | b: '7', 30 | }`) 31 | }) 32 | 33 | it(`writes a nested object`, () => { 34 | const writer = json5Writer.load('') 35 | writer.write({ a: { b: 5 } }) 36 | expect(writer.toSource()).toBe(`{ 37 | a: { 38 | b: 5, 39 | }, 40 | }`) 41 | }) 42 | 43 | it(`writes a nested array`, () => { 44 | const writer = json5Writer.load('') 45 | writer.write([[[[[5]]]]]) 46 | expect(writer.toSource()).toBe(`[[[[[5]]]]]`) 47 | }) 48 | 49 | it(`overrides key order with what's in the written object`, () => { 50 | const writer = json5Writer.load('{ a: 1, c: 3 }') 51 | writer.write({ c: 3, b: 2, a: 1 }) 52 | expect(writer.toSource()).toBe(`{ 53 | c: 3, 54 | b: 2, 55 | a: 1, 56 | }`) 57 | }) 58 | 59 | it('removes object values not present in the given object', () => { 60 | const writer = json5Writer.load('{ a: 5, b: 8 }') 61 | writer.write({ c: 7 }) 62 | expect(writer.toSource()).toBe(`{ 63 | c: 7, 64 | }`) 65 | }) 66 | 67 | it('removes array values not present in the given array', () => { 68 | const writer = json5Writer.load('[ 1, 2, 3 ]') 69 | writer.write([4]) 70 | expect(writer.toSource()).toBe(`[4]`) 71 | }) 72 | 73 | it(`writes over the existing source that doesn't match`, () => { 74 | const writer = json5Writer.load(`[ 1, 'seven', {} ]`) 75 | writer.write({ hi: 5 }) 76 | expect(writer.toSource()).toBe(`{ 77 | hi: 5, 78 | }`) 79 | }) 80 | 81 | it(`skips over undefined array values`, () => { 82 | const writer = json5Writer.load('[ 1, 2, 3 ]') 83 | writer.write([1, undefined, 3]) 84 | expect(writer.toSource()).toBe(`[ 1, 2, 3 ]`) 85 | }) 86 | 87 | it(`skips over undefined object values`, () => { 88 | const writer = json5Writer.load('{ a: 1, b: 2, c: 3 }') 89 | writer.write({ a: 1, b: undefined, c: 3, d: undefined, e: 2 }) 90 | expect(writer.toSource()).toBe(`{ 91 | a: 1, 92 | b: 2, 93 | c: 3, 94 | e: 2, 95 | }`) 96 | }) 97 | 98 | it('infers object key quote usage', () => { 99 | const writer = json5Writer.load(`[{ a: 1 }, { 'a': 1 }]`) 100 | writer.write([{ a: 1, b: 2 }, { a: 1, b: 2 }]) 101 | expect(writer.toSource()).toBe(`[{ 102 | a: 1, 103 | b: 2, 104 | }, { 105 | 'a': 1, 106 | 'b': 2, 107 | }]`) 108 | }) 109 | 110 | it('uses quotes when object key has invalid characters', () => { 111 | const writer = json5Writer.load(`{ a: 1 }`) 112 | writer.write({ a: 1, 'b-b': 2 }) 113 | expect(writer.toSource()).toBe(`{ 114 | a: 1, 115 | 'b-b': 2, 116 | }`) 117 | }) 118 | 119 | it('quotes object keys when quoteKeys is true', () => { 120 | const writer = json5Writer.load(`{ a: 1 }`) 121 | expect(writer.toSource({ quoteKeys: true })).toBe(`{ 'a': 1 }`) 122 | }) 123 | 124 | it('unquotes object key when quoteKeys is false', () => { 125 | const writer = json5Writer.load(`{ 'a': 1 }`) 126 | expect(writer.toSource({ quoteKeys: false })).toBe(`{ a: 1 }`) 127 | }) 128 | 129 | it('infers object key quote usage when quoteKeys is undefined', () => { 130 | const writer = json5Writer.load(`{ 'a': 1 }`) 131 | expect(writer.toSource({ quoteKeys: undefined })).toBe(`{ 'a': 1 }`) 132 | }) 133 | 134 | it('preserves comments and formatting', () => { 135 | const writer = json5Writer.load(`// don't remove me 136 | { 137 | // another comment 138 | 'a': 5, 139 | 140 | // comment 3 141 | 'b': 6, 142 | } 143 | // trailing comment`) 144 | 145 | writer.write({ c: '8', a: 6, b: 7 }) 146 | 147 | expect(writer.toSource()).toBe(`// don't remove me 148 | { 149 | 'c': '8', 150 | 151 | // another comment 152 | 'a': 6, 153 | 154 | // comment 3 155 | 'b': 7, 156 | } 157 | // trailing comment`) 158 | }) 159 | 160 | it('removes leading whitespace', () => { 161 | const writer = json5Writer.load(` [5]`) 162 | writer.write([6]) 163 | expect(writer.toSource()).toBe(`[6]`) 164 | }) 165 | 166 | it('outputs to source with options', () => { 167 | const writer = json5Writer.load('') 168 | writer.write({ 'car-type': 'honda' }) 169 | expect(writer.toSource({ quote: 'double', trailingComma: false })).toBe(`{ 170 | "car-type": "honda" 171 | }`) 172 | }) 173 | 174 | // TODO: Why is a newline inserted after "effects"? 175 | it('writes to JSON format', () => { 176 | const writer = json5Writer.load('') 177 | writer.write({ 178 | dmg: 8, 179 | effects: ['bleed'], 180 | area: [{ x: 1, y: 0 }], 181 | }) 182 | expect(writer.toJSON()).toBe(`{ 183 | "dmg": 8, 184 | "effects": ["bleed"], 185 | 186 | "area": [{ 187 | "x": 1, 188 | "y": 0 189 | }] 190 | }`) 191 | }) 192 | 193 | it('writes a complicated object', () => { 194 | const writer = json5Writer.load(`{ 195 | // Game Data 196 | 197 | // skills 198 | 'slash': { 199 | dmg: 7, 200 | effects: ['bleed'], 201 | area: [{ x: 1, y: 0 }], 202 | }, 203 | 204 | // TODO: add effects 205 | 206 | // enemies 207 | 'bear': { 208 | health: 17, 209 | skills: ['slash'], 210 | }, 211 | }`) 212 | 213 | writer.write({ 214 | slash: { 215 | dmg: 8, 216 | effects: [], 217 | area: [{ x: 1, y: 0 }, { x: 2, y: 0 }], 218 | }, 219 | 220 | bear: { 221 | health: 14, 222 | skills: ['slash'], 223 | }, 224 | 225 | pig: { 226 | health: 30, 227 | skills: [], 228 | }, 229 | }) 230 | 231 | expect(writer.toSource()).toBe(`{ 232 | // Game Data 233 | 234 | // skills 235 | 'slash': { 236 | dmg: 8, 237 | effects: [], 238 | area: [{ x: 1, y: 0 }, { 239 | x: 2, 240 | y: 0, 241 | }], 242 | }, 243 | 244 | // TODO: add effects 245 | 246 | // enemies 247 | 'bear': { 248 | health: 14, 249 | skills: ['slash'], 250 | }, 251 | 252 | 'pig': { 253 | health: 30, 254 | skills: [], 255 | }, 256 | }`) 257 | }) 258 | 259 | it('writes arrays that contain both strings and objects', () => { 260 | const writer = json5Writer.load(`{ 261 | "array": [ 262 | "string", 263 | { 264 | "object": "" 265 | } 266 | ] 267 | }`) 268 | 269 | writer.write({ array: [{ object: '' } ]}) 270 | 271 | expect(writer.toJSON()).toBe(`{ 272 | "array": [{ 273 | "object": "" 274 | }] 275 | }`) 276 | }) 277 | 278 | it('provides the AST', () => { 279 | const writer = json5Writer.load('{ a: 5 }') 280 | writer.ast.find(j.Property).forEach(path => { 281 | path.value.key = j.literal(path.value.key.name) 282 | }) 283 | expect(writer.toSource()).toBe(`{ 'a': 5 }`) 284 | }) 285 | --------------------------------------------------------------------------------