diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 780bcc9..0eb163d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: RELEASE_COMMENT_ID: ${{ needs.release.outputs.comment-id }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npm exec --offline -- template-oss-release-manager --lockfile=false + npm exec --offline -- template-oss-release-manager --lockfile=false --publish=true npm run rp-pull-request --ignore-scripts --if-present - name: Commit id: commit @@ -320,41 +320,25 @@ jobs: defaults: run: shell: bash + permissions: + deployments: write steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ fromJSON(needs.release.outputs.release).tagName }} - name: Setup Node uses: actions/setup-node@v3 with: node-version: 18.x - name: Install npm@latest - run: npm i --prefer-online --no-fund --no-audit -g npm@latest - - name: npm Version - run: npm -v - - name: View in Registry run: | - EXIT_CODE=0 - - function is_published { - if npm view "$@" --loglevel=error > /dev/null; then - echo 0 - else - echo 1 - fi - } - - for release in $(echo '${{ needs.release.outputs.releases }}' | jq -r '.[] | @base64'); do - name=$(echo "$release" | base64 --decode | jq -r .pkgName) - version=$(echo "$release" | base64 --decode | jq -r .version) - spec="$name@$version" - status=$(is_published "$spec") - if [[ "$status" -eq 1 ]]; then - echo "$spec ERROR" - EXIT_CODE=$status - else - echo "$spec OK" - fi - done - - exit $EXIT_CODE + npm i --prefer-online --no-fund --no-audit -g npm@latest + npm config set '//registry.npmjs.org/:_authToken'=\${PUBLISH_TOKEN} + - name: Publish + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + run: npm publish post-release-integration: needs: [ release, release-integration ] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e6f8775..411256b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.0.0" + ".": "4.1.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c43708d..88336f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [4.1.0](https://github.com/npm/ini/compare/v4.0.0...v4.1.0) (2023-04-13) + +### Features + +* [`622106a`](https://github.com/npm/ini/commit/622106a1a0bcd2bc32bfd2e6f9cc45a1a1cb53f7) [#205](https://github.com/npm/ini/pull/205) add "align" and "sort" options (#205) (@rquadling) +* [`dc64a1a`](https://github.com/npm/ini/commit/dc64a1a3aa322c0c258383bedcae738aaa67028d) [#204](https://github.com/npm/ini/pull/204) add bracketedArray option (#204) (@wraithgar) +* [`6a3cb38`](https://github.com/npm/ini/commit/6a3cb38b134f674dff1a7b5d3732553e8bace777) [#199](https://github.com/npm/ini/pull/199) add platform option to force line endings (#199) (@wraithgar, Francois-Xavier Kowalski) +* [`b363ae6`](https://github.com/npm/ini/commit/b363ae67ddf9b1232daafb3cc2b2b1e5cb656406) [#198](https://github.com/npm/ini/pull/198) add newline option (#198) (@wraithgar, Francois-Xavier Kowalski) + +### Bug Fixes + +* [`ad4b5d8`](https://github.com/npm/ini/commit/ad4b5d8423f67d67b8683ac8e809b9dd23fe82e2) [#200](https://github.com/npm/ini/pull/200) Refactored section split logic (#200) (@wraithgar, @platinumazure) +* [`5b5c9b7`](https://github.com/npm/ini/commit/5b5c9b777209bae480f62fb80149008350d37bc5) [#123](https://github.com/npm/ini/pull/123) residual space after section causes bad parsing (#123) (@Nautigsam) +* [`fa2c17e`](https://github.com/npm/ini/commit/fa2c17e6e5f4ebaec30e08653b9603049c8cf0c3) [#201](https://github.com/npm/ini/pull/201) ignore all whitespace lines (#201) (@wraithgar) + ## [4.0.0](https://github.com/npm/ini/compare/v3.0.1...v4.0.0) (2023-03-08) ### ⚠️ BREAKING CHANGES diff --git a/README.md b/README.md index 3ff41e8..d775352 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,29 @@ prepended to all sub-sections, see the usage example above. The `options` object may contain the following: -* `section` A string which will be the first `section` in the encoded +* `align` Boolean to specify whether to align the `=` characters for + each section. This option will automatically enable `whitespace`. + Defaults to `false`. +* `section` String which will be the first `section` in the encoded ini data. Defaults to none. +* `sort` Boolean to specify if all keys in each section, as well as + all sections, will be alphabetically sorted. Defaults to `false`. * `whitespace` Boolean to specify whether to put whitespace around the `=` character. By default, whitespace is omitted, to be friendly to some persnickety old parsers that don't tolerate it well. But some find that it's more human-readable and pretty with the whitespace. + Defaults to `false`. +* `newline` Boolean to specify whether to put an additional newline + after a section header. Some INI file parsers (for example the TOSHIBA + FlashAir one) need this to parse the file successfully. By default, + the additional newline is omitted. +* `platform` String to define which platform this INI file is expected + to be used with: when `platform` is `win32`, line terminations are + CR+LF, for other platforms line termination is LF. By default, the + current platform name is used. +* `bracketedArrays` Boolean to specify whether array values are appended + with `[]`. By default this is true but there are some ini parsers + that instead treat duplicate names as arrays. For backwards compatibility reasons, if a `string` options is passed in, then it is assumed to be the `section` value. diff --git a/lib/ini.js b/lib/ini.js index d05682b..763c829 100644 --- a/lib/ini.js +++ b/lib/ini.js @@ -1,49 +1,70 @@ const { hasOwnProperty } = Object.prototype -/* istanbul ignore next */ -const eol = typeof process !== 'undefined' && - process.platform === 'win32' ? '\r\n' : '\n' +const encode = (obj, opt = {}) => { + if (typeof opt === 'string') { + opt = { section: opt } + } + opt.align = opt.align === true + opt.newline = opt.newline === true + opt.sort = opt.sort === true + opt.whitespace = opt.whitespace === true || opt.align === true + /* istanbul ignore next */ + opt.platform = opt.platform || process?.platform + opt.bracketedArray = opt.bracketedArray !== false -const encode = (obj, opt) => { + /* istanbul ignore next */ + const eol = opt.platform === 'win32' ? '\r\n' : '\n' + const separator = opt.whitespace ? ' = ' : '=' const children = [] - let out = '' - if (typeof opt === 'string') { - opt = { - section: opt, - whitespace: false, - } - } else { - opt = opt || Object.create(null) - opt.whitespace = opt.whitespace === true + const keys = opt.sort ? Object.keys(obj).sort() : Object.keys(obj) + + let padToChars = 0 + // If aligning on the separator, then padToChars is determined as follows: + // 1. Get the keys + // 2. Exclude keys pointing to objects unless the value is null or an array + // 3. Add `[]` to array keys + // 4. Ensure non empty set of keys + // 5. Reduce the set to the longest `safe` key + // 6. Get the `safe` length + if (opt.align) { + padToChars = safe( + ( + keys + .filter(k => obj[k] === null || Array.isArray(obj[k]) || typeof obj[k] !== 'object') + .map(k => Array.isArray(obj[k]) ? `${k}[]` : k) + ) + .concat(['']) + .reduce((a, b) => safe(a).length >= safe(b).length ? a : b) + ).length } - const separator = opt.whitespace ? ' = ' : '=' + let out = '' + const arraySuffix = opt.bracketedArray ? '[]' : '' - for (const k of Object.keys(obj)) { + for (const k of keys) { const val = obj[k] if (val && Array.isArray(val)) { for (const item of val) { - out += safe(k + '[]') + separator + safe(item) + eol + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol } } else if (val && typeof val === 'object') { children.push(k) } else { - out += safe(k) + separator + safe(val) + eol + out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol } } if (opt.section && out.length) { - out = '[' + safe(opt.section) + ']' + eol + out + out = '[' + safe(opt.section) + ']' + (opt.newline ? eol + eol : eol) + out } for (const k of children) { - const nk = dotSplit(k).join('\\.') + const nk = splitSections(k, '.').join('\\.') const section = (opt.section ? opt.section + '.' : '') + nk - const { whitespace } = opt const child = encode(obj[k], { + ...opt, section, - whitespace, }) if (out.length && child.length) { out += eol @@ -55,24 +76,44 @@ const encode = (obj, opt) => { return out } -const dotSplit = str => - str.replace(/\1/g, '\u0002LITERAL\\1LITERAL\u0002') - .replace(/\\\./g, '\u0001') - .split(/\./) - .map(part => - part.replace(/\1/g, '\\.') - .replace(/\2LITERAL\\1LITERAL\2/g, '\u0001')) +function splitSections (str, separator) { + var lastMatchIndex = 0 + var lastSeparatorIndex = 0 + var nextIndex = 0 + var sections = [] + + do { + nextIndex = str.indexOf(separator, lastMatchIndex) -const decode = str => { + if (nextIndex !== -1) { + lastMatchIndex = nextIndex + separator.length + + if (nextIndex > 0 && str[nextIndex - 1] === '\\') { + continue + } + + sections.push(str.slice(lastSeparatorIndex, nextIndex)) + lastSeparatorIndex = nextIndex + separator.length + } + } while (nextIndex !== -1) + + sections.push(str.slice(lastSeparatorIndex)) + + return sections +} + +const decode = (str, opt = {}) => { + opt.bracketedArray = opt.bracketedArray !== false const out = Object.create(null) let p = out let section = null - // section |key = value - const re = /^\[([^\]]*)\]$|^([^=]+)(=(.*))?$/i + // section |key = value + const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i const lines = str.split(/[\r\n]+/g) + const duplicates = {} for (const line of lines) { - if (!line || line.match(/^\s*[;#]/)) { + if (!line || line.match(/^\s*[;#]/) || line.match(/^\s*$/)) { continue } const match = line.match(re) @@ -91,7 +132,13 @@ const decode = str => { continue } const keyRaw = unsafe(match[2]) - const isArray = keyRaw.length > 2 && keyRaw.slice(-2) === '[]' + let isArray + if (opt.bracketedArray) { + isArray = keyRaw.length > 2 && keyRaw.slice(-2) === '[]' + } else { + duplicates[keyRaw] = (duplicates?.[keyRaw] || 0) + 1 + isArray = duplicates[keyRaw] > 1 + } const key = isArray ? keyRaw.slice(0, -2) : keyRaw if (key === '__proto__') { continue @@ -132,7 +179,7 @@ const decode = str => { // see if the parent section is also an object. // if so, add it to that, and mark this one for deletion - const parts = dotSplit(k) + const parts = splitSections(k, '.') p = out const l = parts.pop() const nl = l.replace(/\\\./g, '.') diff --git a/package.json b/package.json index 08890d2..5dd968e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "GitHub Inc.", "name": "ini", "description": "An ini encoder/decoder for node", - "version": "4.0.0", + "version": "4.1.0", "repository": { "type": "git", "url": "https://github.com/npm/ini.git" @@ -20,7 +20,7 @@ }, "devDependencies": { "@npmcli/eslint-config": "^4.0.0", - "@npmcli/template-oss": "4.12.0", + "@npmcli/template-oss": "4.13.0", "tap": "^16.0.1" }, "license": "ISC", @@ -33,7 +33,8 @@ }, "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", - "version": "4.12.0" + "version": "4.13.0", + "publish": "true" }, "tap": { "nyc-arg": [ diff --git a/tap-snapshots/test/duplicate-properties.js.test.cjs b/tap-snapshots/test/duplicate-properties.js.test.cjs new file mode 100644 index 0000000..4a88d10 --- /dev/null +++ b/tap-snapshots/test/duplicate-properties.js.test.cjs @@ -0,0 +1,57 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/duplicate-properties.js TAP decode duplicate properties with bracketedArray=false > must match snapshot 1`] = ` +Null Object { + "ar": Array [ + "three", + ], + "ar[]": "one", + "b": Array [ + "2", + "3", + "3", + ], + "brr": "1", + "str": "3", + "zr": "123", + "zr[]": "deedee", +} +` + +exports[`test/duplicate-properties.js TAP decode with duplicate properties > must match snapshot 1`] = ` +Null Object { + "ar": Array [ + "one", + "three", + ], + "brr": "3", + "str": "3", + "zr": Array [ + "deedee", + "123", + ], +} +` + +exports[`test/duplicate-properties.js TAP encode duplicate properties with bracketedArray=false > must match snapshot 1`] = ` +ar=1 +ar=2 +ar=3 +br=1 +br=2 + +` + +exports[`test/duplicate-properties.js TAP encode with duplicate properties > must match snapshot 1`] = ` +ar[]=1 +ar[]=2 +ar[]=3 +br[]=1 +br[]=2 + +` diff --git a/tap-snapshots/test/foo.js.test.cjs b/tap-snapshots/test/foo.js.test.cjs index 20e521b..26b81ce 100644 --- a/tap-snapshots/test/foo.js.test.cjs +++ b/tap-snapshots/test/foo.js.test.cjs @@ -35,6 +35,7 @@ Null Object { "three", "this is included", ], + "b": Null Object {}, "br": "warm", "eq": "eq=eq", "false": false, @@ -110,6 +111,110 @@ noHashComment=this\\# this is not a comment ` +exports[`test/foo.js TAP encode with align > must match snapshot 1`] = ` +o = p +a with spaces = b c +" xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" +"[disturbing]" = hey you never know +s = something +s1 = "something' +s2 = something else +s3 = +s4 = +s5 = " " +s6 = " a " +s7 = true +true = true +false = false +null = null +undefined = undefined +zr[] = deedee +ar[] = one +ar[] = three +ar[] = this is included +br = warm +eq = "eq=eq" + +[a] +av = a val +e = { o: p, a: { av: a val, b: { c: { e: "this [value]" } } } } +j = "\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } } } }\\"" +"[]" = a square? +cr[] = four +cr[] = eight + +[a.b.c] +e = 1 +j = 2 + +[x\\.y\\.z] +x.y.z = xyz + +[x\\.y\\.z.a\\.b\\.c] +a.b.c = abc +nocomment = this\\; this is not a comment +noHashComment = this\\# this is not a comment + +` + +exports[`test/foo.js TAP encode with align and sort > must match snapshot 1`] = ` +" xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" +"[disturbing]" = hey you never know +a with spaces = b c +ar[] = one +ar[] = three +ar[] = this is included +br = warm +eq = "eq=eq" +false = false +null = null +o = p +s = something +s1 = "something' +s2 = something else +s3 = +s4 = +s5 = " " +s6 = " a " +s7 = true +true = true +undefined = undefined +zr[] = deedee + +[a] +"[]" = a square? +av = a val +cr[] = four +cr[] = eight +e = { o: p, a: { av: a val, b: { c: { e: "this [value]" } } } } +j = "\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } } } }\\"" + +[a.b.c] +e = 1 +j = 2 + +[x\\.y\\.z] +x.y.z = xyz + +[x\\.y\\.z.a\\.b\\.c] +a.b.c = abc +noHashComment = this\\# this is not a comment +nocomment = this\\; this is not a comment + +` + +exports[`test/foo.js TAP encode with newline > must match snapshot 1`] = ` +[log] + +type=file + +[log.level] + +label=debug +value=10 + +` + exports[`test/foo.js TAP encode with option > must match snapshot 1`] = ` [prefix.log] type=file @@ -120,6 +225,64 @@ value=10 ` +exports[`test/foo.js TAP encode with platform=win32 > must match snapshot 1`] = ` +Array [ + "[log]", + "type=file", + "", + "[log.level]", + "label=debug", + "value=10", + "", +] +` + +exports[`test/foo.js TAP encode with sort > must match snapshot 1`] = ` +" xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" +"[disturbing]"=hey you never know +a with spaces=b c +ar[]=one +ar[]=three +ar[]=this is included +br=warm +eq="eq=eq" +false=false +null=null +o=p +s=something +s1="something' +s2=something else +s3= +s4= +s5=" " +s6=" a " +s7=true +true=true +undefined=undefined +zr[]=deedee + +[a] +"[]"=a square? +av=a val +cr[]=four +cr[]=eight +e={ o: p, a: { av: a val, b: { c: { e: "this [value]" } } } } +j="\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } } } }\\"" + +[a.b.c] +e=1 +j=2 + +[x\\.y\\.z] +x.y.z=xyz + +[x\\.y\\.z.a\\.b\\.c] +a.b.c=abc +noHashComment=this\\# this is not a comment +nocomment=this\\; this is not a comment + +` + exports[`test/foo.js TAP encode with whitespace > must match snapshot 1`] = ` [log] type = file diff --git a/test/duplicate-properties.js b/test/duplicate-properties.js new file mode 100644 index 0000000..6f47c91 --- /dev/null +++ b/test/duplicate-properties.js @@ -0,0 +1,40 @@ +const i = require('../') +const tap = require('tap') +const test = tap.test +const fs = require('fs') +const path = require('path') + +const fixture = path.resolve(__dirname, './fixtures/duplicate.ini') +const data = fs.readFileSync(fixture, 'utf8') + +tap.cleanSnapshot = s => s.replace(/\r\n/g, '\n') + +test('decode with duplicate properties', function (t) { + const d = i.decode(data) + t.matchSnapshot(d) + t.end() +}) + +test('encode with duplicate properties', function (t) { + const e = i.encode({ + ar: ['1', '2', '3'], + br: ['1', '2'], + }) + t.matchSnapshot(e) + t.end() +}) + +test('decode duplicate properties with bracketedArray=false', function (t) { + const d = i.decode(data, { bracketedArray: false }) + t.matchSnapshot(d) + t.end() +}) + +test('encode duplicate properties with bracketedArray=false', function (t) { + const e = i.encode({ + ar: ['1', '2', '3'], + br: ['1', '2'], + }, { bracketedArray: false }) + t.matchSnapshot(e) + t.end() +}) diff --git a/test/fixtures/duplicate.ini b/test/fixtures/duplicate.ini new file mode 100644 index 0000000..7bce09a --- /dev/null +++ b/test/fixtures/duplicate.ini @@ -0,0 +1,9 @@ +zr[] = deedee +zr=123 +ar[] = one +ar[] = three +str = 3 +brr = 1 +brr = 2 +brr = 3 +brr = 3 diff --git a/test/fixtures/foo.ini b/test/fixtures/foo.ini index 22b72b6..219b4aa 100644 --- a/test/fixtures/foo.ini +++ b/test/fixtures/foo.ini @@ -10,7 +10,7 @@ o = p ; Test single quotes s = 'something' - + ; Test mixing quotes s1 = "something' @@ -69,6 +69,9 @@ j = "{ o: "p", a: { av: "a val", b: { c: { e: "this [value]" } } } }" cr[] = four cr[] = eight +; b section with a space after its title +[b] + ; nested child without middle parent ; should create otherwise-empty a.b [a.b.c] diff --git a/test/foo.js b/test/foo.js index 5165a9a..4d380e3 100644 --- a/test/foo.js +++ b/test/foo.js @@ -44,3 +44,43 @@ test('encode with whitespace', function (t) { t.matchSnapshot(e) t.end() }) + +test('encode with newline', function (t) { + const obj = { log: { type: 'file', level: { label: 'debug', value: 10 } } } + const e = i.encode(obj, { newline: true }) + + t.matchSnapshot(e) + t.end() +}) + +test('encode with platform=win32', function (t) { + const obj = { log: { type: 'file', level: { label: 'debug', value: 10 } } } + const e = i.encode(obj, { platform: 'win32' }) + + t.matchSnapshot(e.split('\r\n')) + t.end() +}) + +test('encode with align', function (t) { + const d = i.decode(data) + const e = i.encode(d, { align: true }) + + t.matchSnapshot(e) + t.end() +}) + +test('encode with sort', function (t) { + const d = i.decode(data) + const e = i.encode(d, { sort: true }) + + t.matchSnapshot(e) + t.end() +}) + +test('encode with align and sort', function (t) { + const d = i.decode(data) + const e = i.encode(d, { align: true, sort: true }) + + t.matchSnapshot(e) + t.end() +})