├── .editorconfig ├── .gitattributes ├── .github ├── security.md └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.js ├── license ├── package.json ├── readme.md └── test ├── fixtures └── object.js └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 18 14 | - 16 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import isRegexp from 'is-regexp'; 2 | import isObject from 'is-obj'; 3 | import getOwnEnumerableKeys from 'get-own-enumerable-keys'; 4 | 5 | export default function stringifyObject(input, options, pad) { 6 | const seen = []; 7 | 8 | return (function stringify(input, options = {}, pad = '') { 9 | const indent = options.indent || '\t'; 10 | 11 | let tokens; 12 | if (options.inlineCharacterLimit === undefined) { 13 | tokens = { 14 | newline: '\n', 15 | newlineOrSpace: '\n', 16 | pad, 17 | indent: pad + indent, 18 | }; 19 | } else { 20 | tokens = { 21 | newline: '@@__STRINGIFY_OBJECT_NEW_LINE__@@', 22 | newlineOrSpace: '@@__STRINGIFY_OBJECT_NEW_LINE_OR_SPACE__@@', 23 | pad: '@@__STRINGIFY_OBJECT_PAD__@@', 24 | indent: '@@__STRINGIFY_OBJECT_INDENT__@@', 25 | }; 26 | } 27 | 28 | const expandWhiteSpace = string => { 29 | if (options.inlineCharacterLimit === undefined) { 30 | return string; 31 | } 32 | 33 | const oneLined = string 34 | .replace(new RegExp(tokens.newline, 'g'), '') 35 | .replace(new RegExp(tokens.newlineOrSpace, 'g'), ' ') 36 | .replace(new RegExp(tokens.pad + '|' + tokens.indent, 'g'), ''); 37 | 38 | if (oneLined.length <= options.inlineCharacterLimit) { 39 | return oneLined; 40 | } 41 | 42 | return string 43 | .replace(new RegExp(tokens.newline + '|' + tokens.newlineOrSpace, 'g'), '\n') 44 | .replace(new RegExp(tokens.pad, 'g'), pad) 45 | .replace(new RegExp(tokens.indent, 'g'), pad + indent); 46 | }; 47 | 48 | if (seen.includes(input)) { 49 | return '"[Circular]"'; 50 | } 51 | 52 | if ( 53 | input === null 54 | || input === undefined 55 | || typeof input === 'number' 56 | || typeof input === 'boolean' 57 | || typeof input === 'function' 58 | || typeof input === 'symbol' 59 | || isRegexp(input) 60 | ) { 61 | return String(input); 62 | } 63 | 64 | if (input instanceof Date) { 65 | return `new Date('${input.toISOString()}')`; 66 | } 67 | 68 | if (Array.isArray(input)) { 69 | if (input.length === 0) { 70 | return '[]'; 71 | } 72 | 73 | seen.push(input); 74 | 75 | const returnValue = '[' + tokens.newline + input.map((element, i) => { 76 | const eol = input.length - 1 === i ? tokens.newline : ',' + tokens.newlineOrSpace; 77 | 78 | let value = stringify(element, options, pad + indent); 79 | if (options.transform) { 80 | value = options.transform(input, i, value); 81 | } 82 | 83 | return tokens.indent + value + eol; 84 | }).join('') + tokens.pad + ']'; 85 | 86 | seen.pop(); 87 | 88 | return expandWhiteSpace(returnValue); 89 | } 90 | 91 | if (isObject(input)) { 92 | let objectKeys = getOwnEnumerableKeys(input); 93 | 94 | if (options.filter) { 95 | // eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument 96 | objectKeys = objectKeys.filter(element => options.filter(input, element)); 97 | } 98 | 99 | if (objectKeys.length === 0) { 100 | return '{}'; 101 | } 102 | 103 | seen.push(input); 104 | 105 | const returnValue = '{' + tokens.newline + objectKeys.map((element, index) => { 106 | const eol = objectKeys.length - 1 === index ? tokens.newline : ',' + tokens.newlineOrSpace; 107 | const isSymbol = typeof element === 'symbol'; 108 | const isClassic = !isSymbol && /^[a-z$_][$\w]*$/i.test(element); 109 | const key = isSymbol || isClassic ? element : stringify(element, options); 110 | 111 | let value = stringify(input[element], options, pad + indent); 112 | if (options.transform) { 113 | value = options.transform(input, element, value); 114 | } 115 | 116 | return tokens.indent + String(key) + ': ' + value + eol; 117 | }).join('') + tokens.pad + '}'; 118 | 119 | seen.pop(); 120 | 121 | return expandWhiteSpace(returnValue); 122 | } 123 | 124 | input = input.replace(/\\/g, '\\\\'); 125 | input = String(input).replace(/[\r\n]/g, x => x === '\n' ? '\\n' : '\\r'); 126 | 127 | if (options.singleQuotes === false) { 128 | input = input.replace(/"/g, '\\"'); 129 | return `"${input}"`; 130 | } 131 | 132 | input = input.replace(/'/g, '\\\''); 133 | return `'${input}'`; 134 | })(input, options, pad); 135 | } 136 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 2 | Copyright (c) Yeoman team (https://github.com/yeoman) 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stringify-object", 3 | "version": "5.0.0", 4 | "description": "Stringify an object/array like JSON.stringify just without all the double-quotes", 5 | "license": "BSD-2-Clause", 6 | "repository": "sindresorhus/stringify-object", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": "./index.js", 15 | "sideEffects": false, 16 | "engines": { 17 | "node": ">=14.16" 18 | }, 19 | "scripts": { 20 | "test": "xo && ava" 21 | }, 22 | "files": [ 23 | "index.js" 24 | ], 25 | "keywords": [ 26 | "object", 27 | "stringify", 28 | "pretty", 29 | "print", 30 | "dump", 31 | "format", 32 | "type", 33 | "json" 34 | ], 35 | "dependencies": { 36 | "get-own-enumerable-keys": "^1.0.0", 37 | "is-obj": "^3.0.0", 38 | "is-regexp": "^3.1.0" 39 | }, 40 | "devDependencies": { 41 | "ava": "^5.1.1", 42 | "xo": "^0.53.1" 43 | }, 44 | "xo": { 45 | "ignores": [ 46 | "test/fixtures/*.js" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # stringify-object 2 | 3 | > Stringify an object/array like JSON.stringify just without all the double-quotes 4 | 5 | Useful for when you want to get the string representation of an object in a formatted way. 6 | 7 | It also handles circular references and lets you specify quote type. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install stringify-object 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import stringifyObject from 'stringify-object'; 19 | 20 | const object = { 21 | foo: 'bar', 22 | 'arr': [1, 2, 3], 23 | nested: { 24 | hello: "world" 25 | } 26 | }; 27 | 28 | const pretty = stringifyObject(object, { 29 | indent: ' ', 30 | singleQuotes: false 31 | }); 32 | 33 | console.log(pretty); 34 | /* 35 | { 36 | foo: "bar", 37 | arr: [ 38 | 1, 39 | 2, 40 | 3 41 | ], 42 | nested: { 43 | hello: "world" 44 | } 45 | } 46 | */ 47 | ``` 48 | 49 | ## API 50 | 51 | ### stringifyObject(input, options?) 52 | 53 | Circular references will be replaced with `"[Circular]"`. 54 | 55 | Object keys are only quoted when necessary, for example, `{'foo-bar': true}`. 56 | 57 | #### input 58 | 59 | Type: `object | Array` 60 | 61 | #### options 62 | 63 | Type: `object` 64 | 65 | ##### indent 66 | 67 | Type: `string`\ 68 | Default: `\t` 69 | 70 | Preferred indentation. 71 | 72 | ##### singleQuotes 73 | 74 | Type: `boolean`\ 75 | Default: `true` 76 | 77 | Set to false to get double-quoted strings. 78 | 79 | ##### filter(object, property) 80 | 81 | Type: `Function` 82 | 83 | Expected to return a `boolean` of whether to include the property `property` of the object `object` in the output. 84 | 85 | ##### transform(object, property, originalResult) 86 | 87 | Type: `Function`\ 88 | Default: `undefined` 89 | 90 | Expected to return a `string` that transforms the string that resulted from stringifying `object[property]`. This can be used to detect special types of objects that need to be stringified in a particular way. The `transform` function might return an alternate string in this case, otherwise returning the `originalResult`. 91 | 92 | Here's an example that uses the `transform` option to mask fields named "password": 93 | 94 | ```js 95 | import stringifyObject from 'stringify-object'; 96 | 97 | const object = { 98 | user: 'becky', 99 | password: 'secret' 100 | }; 101 | 102 | const pretty = stringifyObject(object, { 103 | transform: (object, property, originalResult) => { 104 | if (property === 'password') { 105 | return originalResult.replace(/\w/g, '*'); 106 | } 107 | 108 | return originalResult; 109 | } 110 | }); 111 | 112 | console.log(pretty); 113 | /* 114 | { 115 | user: 'becky', 116 | password: '******' 117 | } 118 | */ 119 | ``` 120 | 121 | ##### inlineCharacterLimit 122 | 123 | Type: `number` 124 | 125 | When set, will inline values up to `inlineCharacterLimit` length for the sake of more terse output. 126 | 127 | For example, given the example at the top of the README: 128 | 129 | ```js 130 | import stringifyObject from 'stringify-object'; 131 | 132 | const object = { 133 | foo: 'bar', 134 | 'arr': [1, 2, 3], 135 | nested: { 136 | hello: "world" 137 | } 138 | }; 139 | 140 | const pretty = stringifyObject(object, { 141 | indent: ' ', 142 | singleQuotes: false, 143 | inlineCharacterLimit: 12 144 | }); 145 | 146 | console.log(pretty); 147 | /* 148 | { 149 | foo: "bar", 150 | arr: [1, 2, 3], 151 | nested: { 152 | hello: "world" 153 | } 154 | } 155 | */ 156 | ``` 157 | 158 | As you can see, `arr` was printed as a one-liner because its string was shorter than 12 characters. 159 | -------------------------------------------------------------------------------- /test/fixtures/object.js: -------------------------------------------------------------------------------- 1 | { 2 | foo: "bar 'bar'", 3 | foo2: [ 4 | "foo", 5 | "bar", 6 | { 7 | foo: "bar 'bar'" 8 | } 9 | ], 10 | "foo-foo": "bar", 11 | "2foo": "bar", 12 | "@#": "bar", 13 | $el: "bar", 14 | _private: "bar", 15 | number: 1, 16 | boolean: true, 17 | date: new Date('2014-01-29T22:41:05.665Z'), 18 | escapedString: "\"\"", 19 | null: null, 20 | undefined: undefined, 21 | fn: function fn() {}, 22 | regexp: /./, 23 | NaN: NaN, 24 | Infinity: Infinity, 25 | newlines: "foo\nbar\r\nbaz", 26 | circular: "[Circular]", 27 | Symbol(): Symbol(), 28 | Symbol(foo): Symbol(foo), 29 | Symbol(foo): Symbol(foo) 30 | } 31 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import {fileURLToPath} from 'node:url'; 4 | import test from 'ava'; 5 | import stringifyObject from '../index.js'; 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | 9 | test('stringify an object', t => { 10 | /* eslint-disable quotes, object-shorthand */ 11 | const object = { 12 | foo: 'bar \'bar\'', 13 | foo2: [ 14 | 'foo', 15 | 'bar', 16 | { 17 | foo: "bar 'bar'", 18 | }, 19 | ], 20 | 'foo-foo': 'bar', 21 | '2foo': 'bar', 22 | '@#': "bar", 23 | $el: 'bar', 24 | _private: 'bar', 25 | number: 1, 26 | boolean: true, 27 | date: new Date("2014-01-29T22:41:05.665Z"), 28 | escapedString: "\"\"", 29 | null: null, 30 | undefined: undefined, 31 | fn: function fn() {}, // eslint-disable-line func-names 32 | regexp: /./, 33 | NaN: Number.NaN, 34 | Infinity: Number.POSITIVE_INFINITY, 35 | newlines: "foo\nbar\r\nbaz", 36 | [Symbol()]: Symbol(), // eslint-disable-line symbol-description 37 | [Symbol('foo')]: Symbol('foo'), 38 | [Symbol.for('foo')]: Symbol.for('foo'), 39 | }; 40 | /* eslint-enable */ 41 | 42 | object.circular = object; 43 | 44 | const actual = stringifyObject(object, { 45 | indent: ' ', 46 | singleQuotes: false, 47 | }); 48 | 49 | t.is(actual + '\n', fs.readFileSync(path.resolve(__dirname, 'fixtures/object.js'), 'utf8')); 50 | t.is( 51 | stringifyObject({foo: 'a \' b \' c \\\' d'}, {singleQuotes: true}), 52 | '{\n\tfoo: \'a \\\' b \\\' c \\\\\\\' d\'\n}', 53 | ); 54 | }); 55 | 56 | test('string escaping works properly', t => { 57 | t.is(stringifyObject('\\', {singleQuotes: true}), '\'\\\\\''); // \ 58 | t.is(stringifyObject('\\\'', {singleQuotes: true}), '\'\\\\\\\'\''); // \' 59 | t.is(stringifyObject('\\"', {singleQuotes: true}), '\'\\\\"\''); // \" 60 | t.is(stringifyObject('\\', {singleQuotes: false}), '"\\\\"'); // \ 61 | t.is(stringifyObject('\\\'', {singleQuotes: false}), '"\\\\\'"'); // \' 62 | t.is(stringifyObject('\\"', {singleQuotes: false}), '"\\\\\\""'); // \" 63 | /* eslint-disable no-eval */ 64 | t.is(eval(stringifyObject('\\\'')), '\\\''); 65 | t.is(eval(stringifyObject('\\\'', {singleQuotes: false})), '\\\''); 66 | /* eslint-enable */ 67 | // Regression test for #40 68 | t.is(stringifyObject("a'a"), '\'a\\\'a\''); // eslint-disable-line quotes 69 | }); 70 | 71 | test('detect reused object values as circular reference', t => { 72 | const value = {val: 10}; 73 | const object = {foo: value, bar: value}; 74 | t.is(stringifyObject(object), '{\n\tfoo: {\n\t\tval: 10\n\t},\n\tbar: {\n\t\tval: 10\n\t}\n}'); 75 | }); 76 | 77 | test('detect reused array values as false circular references', t => { 78 | const value = [10]; 79 | const object = {foo: value, bar: value}; 80 | t.is(stringifyObject(object), '{\n\tfoo: [\n\t\t10\n\t],\n\tbar: [\n\t\t10\n\t]\n}'); 81 | }); 82 | 83 | test('considering filter option to stringify an object', t => { 84 | const value = {val: 10}; 85 | const object = {foo: value, bar: value}; 86 | const actual = stringifyObject(object, { 87 | filter: (object, prop) => prop !== 'foo', 88 | }); 89 | t.is(actual, '{\n\tbar: {\n\t\tval: 10\n\t}\n}'); 90 | 91 | const actual2 = stringifyObject(object, { 92 | filter: (object, prop) => prop !== 'bar', 93 | }); 94 | t.is(actual2, '{\n\tfoo: {\n\t\tval: 10\n\t}\n}'); 95 | 96 | const actual3 = stringifyObject(object, { 97 | filter: (object, prop) => prop !== 'val' && prop !== 'bar', 98 | }); 99 | t.is(actual3, '{\n\tfoo: {}\n}'); 100 | }); 101 | 102 | test('allows an object to be transformed', t => { 103 | const object = { 104 | foo: { 105 | val: 10, 106 | }, 107 | bar: 9, 108 | baz: [8], 109 | }; 110 | 111 | const actual = stringifyObject(object, { 112 | transform(object, prop, result) { 113 | if (prop === 'val') { 114 | return String(object[prop] + 1); 115 | } 116 | 117 | if (prop === 'bar') { 118 | return '\'' + result + 'L\''; 119 | } 120 | 121 | if (object[prop] === 8) { 122 | return 'LOL'; 123 | } 124 | 125 | return result; 126 | }, 127 | }); 128 | 129 | t.is(actual, '{\n\tfoo: {\n\t\tval: 11\n\t},\n\tbar: \'9L\',\n\tbaz: [\n\t\tLOL\n\t]\n}'); 130 | }); 131 | 132 | test('doesn\'t crash with circular references in arrays', t => { 133 | const array = []; 134 | array.push(array); 135 | t.notThrows(() => { 136 | stringifyObject(array); 137 | }); 138 | 139 | const nestedArray = [[]]; 140 | nestedArray[0][0] = nestedArray; 141 | t.notThrows(() => { 142 | stringifyObject(nestedArray); 143 | }); 144 | }); 145 | 146 | test('handle circular references in arrays', t => { 147 | const array2 = []; 148 | const array = [array2]; 149 | array2[0] = array2; 150 | 151 | t.notThrows(() => { 152 | stringifyObject(array); 153 | }); 154 | }); 155 | 156 | test('stringify complex circular arrays', t => { 157 | const array = [[[]]]; 158 | array[0].push(array); 159 | array[0][0].push(array, 10); 160 | array[0][0][0] = array; 161 | t.is(stringifyObject(array), '[\n\t[\n\t\t[\n\t\t\t"[Circular]",\n\t\t\t10\n\t\t],\n\t\t"[Circular]"\n\t]\n]'); 162 | }); 163 | 164 | test('allows short objects to be one-lined', t => { 165 | const object = {id: 8, name: 'Jane'}; 166 | 167 | t.is(stringifyObject(object), '{\n\tid: 8,\n\tname: \'Jane\'\n}'); 168 | t.is(stringifyObject(object, {inlineCharacterLimit: 21}), '{id: 8, name: \'Jane\'}'); 169 | t.is(stringifyObject(object, {inlineCharacterLimit: 20}), '{\n\tid: 8,\n\tname: \'Jane\'\n}'); 170 | }); 171 | 172 | test('allows short arrays to be one-lined', t => { 173 | const array = ['foo', {id: 8, name: 'Jane'}, 42]; 174 | 175 | t.is(stringifyObject(array), '[\n\t\'foo\',\n\t{\n\t\tid: 8,\n\t\tname: \'Jane\'\n\t},\n\t42\n]'); 176 | t.is(stringifyObject(array, {inlineCharacterLimit: 34}), '[\'foo\', {id: 8, name: \'Jane\'}, 42]'); 177 | t.is(stringifyObject(array, {inlineCharacterLimit: 33}), '[\n\t\'foo\',\n\t{id: 8, name: \'Jane\'},\n\t42\n]'); 178 | }); 179 | 180 | test('does not mess up indents for complex objects', t => { 181 | const object = { 182 | arr: [1, 2, 3], 183 | nested: {hello: 'world'}, 184 | }; 185 | 186 | t.is(stringifyObject(object), '{\n\tarr: [\n\t\t1,\n\t\t2,\n\t\t3\n\t],\n\tnested: {\n\t\thello: \'world\'\n\t}\n}'); 187 | t.is(stringifyObject(object, {inlineCharacterLimit: 12}), '{\n\tarr: [1, 2, 3],\n\tnested: {\n\t\thello: \'world\'\n\t}\n}'); 188 | }); 189 | 190 | test('handles non-plain object', t => { 191 | // TODO: It should work without `fileURLToPath` but currently it throws for an unknown reason. 192 | t.not(stringifyObject(fs.statSync(fileURLToPath(import.meta.url))), '[object Object]'); 193 | }); 194 | 195 | test('don\'t stringify non-enumerable symbols', t => { 196 | const object = { 197 | [Symbol('for enumerable key')]: undefined, 198 | }; 199 | const symbol = Symbol('for non-enumerable key'); 200 | Object.defineProperty(object, symbol, {enumerable: false}); 201 | 202 | t.is(stringifyObject(object), '{\n\tSymbol(for enumerable key): undefined\n}'); 203 | }); 204 | --------------------------------------------------------------------------------