From 798049c20e35aaa652f632668e1d9d30a1727db2 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 24 Apr 2024 15:28:22 +0200 Subject: [PATCH] feat(xml): new lib to parse/format XML --- @vates/xml/.USAGE.md | 56 ++++++++++++++++++++++++ @vates/xml/.npmignore | 1 + @vates/xml/README.md | 89 +++++++++++++++++++++++++++++++++++++++ @vates/xml/cli.js | 30 +++++++++++++ @vates/xml/format.js | 64 ++++++++++++++++++++++++++++ @vates/xml/package.json | 35 +++++++++++++++ @vates/xml/parse.js | 23 ++++++++++ @vates/xml/test/data.json | 25 +++++++++++ @vates/xml/test/data.xml | 7 +++ @vates/xml/test/format.js | 18 ++++++++ @vates/xml/test/parse.js | 14 ++++++ CHANGELOG.unreleased.md | 2 + yarn.lock | 2 +- 13 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 @vates/xml/.USAGE.md create mode 120000 @vates/xml/.npmignore create mode 100644 @vates/xml/README.md create mode 100755 @vates/xml/cli.js create mode 100644 @vates/xml/format.js create mode 100644 @vates/xml/package.json create mode 100644 @vates/xml/parse.js create mode 100644 @vates/xml/test/data.json create mode 100644 @vates/xml/test/data.xml create mode 100644 @vates/xml/test/format.js create mode 100644 @vates/xml/test/parse.js diff --git a/@vates/xml/.USAGE.md b/@vates/xml/.USAGE.md new file mode 100644 index 0000000000..366a6b9bca --- /dev/null +++ b/@vates/xml/.USAGE.md @@ -0,0 +1,56 @@ +### `parseXml(xml, [opts])` + +> Based on [`sax`](https://www.npmjs.com/package/sax) + +`opts` is an optional object which can contain the following options: + +- `normalize = true`: if true, then turn any whitespace into a single space +- `strict = true`: whether or not to be a jerk +- `trim = true`: whether or not to trim text nodes + +```js +import { parseXml } from '@xen-orchestra/xml/parse' + +const tree = parseXml(` + + Text & entities + + + + +`) +// → { +// name: 'tag1', +// attributes: { attr1: 'value1', attr2: 'value2' }, +// children: [ +// 'Text & entities', +// { name: 'tag2', attributes: {}, children: [] }, +// { name: 'tag2', attributes: {}, children: [] }, +// { name: 'ns:tag3', attributes: {}, children: [] } +// ] +// } +``` + +### `formatXml(tree, [opts])` + +`opts` is an optional object which can contain the following options: + +- `includeDeclaration = true`: whether to include an XML declaration +- `indent = 2`: string or number of spaces to use to indent; if an empty string or `0`, no indent or new lines will be used + +```js +import { formatXml } from '@xen-orchestra/xml/format' + +formatXml({ + name: 'tag1', + attributes: { attr1: 'value1', attr2: 'value2' }, + children: ['Text & entities', { name: 'tag2' }, { name: 'tag2' }, { name: 'ns:tag3' }], +}) +// → +// +// Text & entities +// +// +// +// +``` diff --git a/@vates/xml/.npmignore b/@vates/xml/.npmignore new file mode 120000 index 0000000000..008d1b9b98 --- /dev/null +++ b/@vates/xml/.npmignore @@ -0,0 +1 @@ +../../scripts/npmignore \ No newline at end of file diff --git a/@vates/xml/README.md b/@vates/xml/README.md new file mode 100644 index 0000000000..72e7bcd207 --- /dev/null +++ b/@vates/xml/README.md @@ -0,0 +1,89 @@ + + +# @vates/xml + +[![Package Version](https://badgen.net/npm/v/@vates/xml)](https://npmjs.org/package/@vates/xml) ![License](https://badgen.net/npm/license/@vates/xml) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/xml)](https://bundlephobia.com/result?p=@vates/xml) [![Node compatibility](https://badgen.net/npm/node/@vates/xml)](https://npmjs.org/package/@vates/xml) + +> Simple XML formatting/parsing + +## Install + +Installation of the [npm package](https://npmjs.org/package/@vates/xml): + +```sh +npm install --save @vates/xml +``` + +## Usage + +### `parseXml(xml, [opts])` + +> Based on [`sax`](https://www.npmjs.com/package/sax) + +`opts` is an optional object which can contain the following options: + +- `normalize = true`: if true, then turn any whitespace into a single space +- `strict = true`: whether or not to be a jerk +- `trim = true`: whether or not to trim text nodes + +```js +import { parseXml } from '@xen-orchestra/xml/parse' + +const tree = parseXml(` + + Text & entities + + + + +`) +// → { +// name: 'tag1', +// attributes: { attr1: 'value1', attr2: 'value2' }, +// children: [ +// 'Text & entities', +// { name: 'tag2', attributes: {}, children: [] }, +// { name: 'tag2', attributes: {}, children: [] }, +// { name: 'ns:tag3', attributes: {}, children: [] } +// ] +// } +``` + +### `formatXml(tree, [opts])` + +`opts` is an optional object which can contain the following options: + +- `includeDeclaration = true`: whether to include an XML declaration +- `indent = 2`: string or number of spaces to use to indent; if an empty string or `0`, no indent or new lines will be used + +```js +import { formatXml } from '@xen-orchestra/xml/format' + +formatXml({ + name: 'tag1', + attributes: { attr1: 'value1', attr2: 'value2' }, + children: ['Text & entities', { name: 'tag2' }, { name: 'tag2' }, { name: 'ns:tag3' }], +}) +// → +// +// Text & entities +// +// +// +// +``` + +## Contributions + +Contributions are _very_ welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xen-orchestra/issues) + you've encountered; +- fork and create a pull request. + +## License + +[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr) diff --git a/@vates/xml/cli.js b/@vates/xml/cli.js new file mode 100755 index 0000000000..2c28149225 --- /dev/null +++ b/@vates/xml/cli.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +'use strict' + +const { inspect } = require('node:util') +const { formatXml } = require('@vates/xml/format') +const { parseXml } = require('@vates/xml/parse') +const { readFileSync } = require('node:fs') + +function log(val) { + process.stdout.write(inspect(val, false, null, true)) + process.stdout.write('\n') +} + +function main([inputPath = 0]) { + const input = readFileSync(inputPath) + + // attempt to parse from JSON + let data + try { + data = JSON.parse(input) + } catch (error) { + // fallback to XML + log(parseXml(input)) + return + } + + process.stdout.write(formatXml(data)) +} +main(process.argv.slice(2)) diff --git a/@vates/xml/format.js b/@vates/xml/format.js new file mode 100644 index 0000000000..9db4dbf898 --- /dev/null +++ b/@vates/xml/format.js @@ -0,0 +1,64 @@ +'use strict' + +const ENTITIES = { + '"': '"', + '&': '&', + '<': '<', + '>': '>', + "'": ''', +} +const RE = new RegExp(Object.keys(ENTITIES).join('|'), 'g') +function replace(match) { + return ENTITIES[match] +} +function escape(str) { + return str.replace(RE, replace) +} + +function formatNode(node, depth) { + const indent = this.indent.repeat(depth) + + if (typeof node === 'object') { + const line = [indent, '<', node.name] + const { attributes } = node + if (attributes !== undefined) { + for (const name of Object.keys(attributes)) { + line.push(' ', name, '="', escape(attributes[name]), '"') + } + } + + const { children } = node + if (children === undefined || children.length === 0) { + line.push(' />') + this.lines.push(line.join('')) + } else { + line.push('>') + this.lines.push(line.join('')) + + for (const child of children) { + formatNode.call(this, child, depth + 1) + } + + this.lines.push(indent + '') + } + } else { + // string (or scalar) + this.lines.push(indent + escape(String(node))) + } +} + +exports.formatXml = function formatXml(tree, { includeDeclaration = true, indent = 2 } = {}) { + const lines = [] + + if (includeDeclaration) { + lines.push('') + } + + const ctx = { + lines, + indent: typeof indent === 'number' ? ' '.repeat(indent) : indent, + } + formatNode.call(ctx, tree, 0) + + return lines.join(indent !== 0 && indent !== '' ? '\n' : '') +} diff --git a/@vates/xml/package.json b/@vates/xml/package.json new file mode 100644 index 0000000000..3e0e379a7c --- /dev/null +++ b/@vates/xml/package.json @@ -0,0 +1,35 @@ +{ + "private": false, + "name": "@vates/xml", + "description": "Simple XML formatting/parsing", + "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/xml", + "bugs": "https://github.com/vatesfr/xen-orchestra/issues", + "repository": { + "directory": "@vates/xml", + "type": "git", + "url": "https://github.com/vatesfr/xen-orchestra.git" + }, + "author": { + "name": "Vates SAS", + "url": "https://vates.fr" + }, + "license": "ISC", + "version": "0.0.0", + "engines": { + "node": ">=14" + }, + "dependencies": { + "sax": "^1.3.0" + }, + "devDependencies": { + "test": "^3.3.0" + }, + "exports": { + "./format": "./format.js", + "./parse": "./parse.js" + }, + "scripts": { + "postversion": "npm publish --access public", + "test": "node--test" + } +} diff --git a/@vates/xml/parse.js b/@vates/xml/parse.js new file mode 100644 index 0000000000..b363341651 --- /dev/null +++ b/@vates/xml/parse.js @@ -0,0 +1,23 @@ +'use strict' + +const sax = require('sax') + +exports.parseXml = function parseXml(xml, { normalize = true, strict = true, trim = true } = {}) { + const stack = [{ children: [] }] + + const parser = sax.parser(strict, { normalize, trim }) + parser.ontext = text => { + stack[stack.length - 1].children.push(text) + } + parser.onopentag = ({ name, attributes }) => { + stack.push({ name, attributes, children: [] }) + } + parser.onclosetag = () => { + const node = stack.pop() + stack[stack.length - 1].children.push(node) + } + + parser.write(xml).close() + + return stack[0].children[0] +} diff --git a/@vates/xml/test/data.json b/@vates/xml/test/data.json new file mode 100644 index 0000000000..f7e42311d9 --- /dev/null +++ b/@vates/xml/test/data.json @@ -0,0 +1,25 @@ +{ + "name": "tag1", + "attributes": { + "attr1": "value1", + "attr2": "value2" + }, + "children": [ + "Text & entities", + { + "name": "tag2", + "attributes": {}, + "children": [] + }, + { + "name": "tag2", + "attributes": {}, + "children": [] + }, + { + "name": "ns:tag3", + "attributes": {}, + "children": [] + } + ] +} diff --git a/@vates/xml/test/data.xml b/@vates/xml/test/data.xml new file mode 100644 index 0000000000..c4fae3460e --- /dev/null +++ b/@vates/xml/test/data.xml @@ -0,0 +1,7 @@ + + + Text & entities + + + + diff --git a/@vates/xml/test/format.js b/@vates/xml/test/format.js new file mode 100644 index 0000000000..725c79d0e1 --- /dev/null +++ b/@vates/xml/test/format.js @@ -0,0 +1,18 @@ +'use strict' + +const { formatXml } = require('@vates/xml/format') +const { join } = require('node:path') +const { readFileSync } = require('node:fs') +const assert = require('node:assert/strict') +const test = require('test') + +test('format()', function () { + const xml = String(readFileSync(join(__dirname, 'data.xml'))) + const tree = JSON.parse(readFileSync(join(__dirname, 'data.json'))) + + assert.equal(formatXml(tree) + '\n', xml) +}) + +test('supports missing attributes and children', function () { + assert.equal(formatXml({ name: 'foo' }, { includeDeclaration: false }), '') +}) diff --git a/@vates/xml/test/parse.js b/@vates/xml/test/parse.js new file mode 100644 index 0000000000..9e92a0ed63 --- /dev/null +++ b/@vates/xml/test/parse.js @@ -0,0 +1,14 @@ +'use strict' + +const { parseXml } = require('@vates/xml/parse') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const assert = require('node:assert/strict') +const test = require('test') + +test('parse()', function () { + const xml = String(readFileSync(join(__dirname, 'data.xml'))) + const tree = JSON.parse(readFileSync(join(__dirname, 'data.json'))) + + assert.deepEqual(parseXml(xml), tree) +}) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 77ab5de5ab..d23d4449b3 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -27,4 +27,6 @@ +- @vates/xml major + diff --git a/yarn.lock b/yarn.lock index 2a795138f7..ab3613bcf9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19811,7 +19811,7 @@ sax-parser@^2.0.2: resolved "https://registry.yarnpkg.com/sax-parser/-/sax-parser-2.0.2.tgz#7b3b4a25fc69bf4e729ad5f0f98430205d461689" integrity sha512-EjLxlFjZdmv/cpOwV+klYEeOYjR2Dc9C495d2Ruk+N6xknrOnIfjSum2a63hfi9Vox2fCsjYc3NuDVo0YkGpjg== -sax@>=0.6, sax@>=0.6.0: +sax@>=0.6, sax@>=0.6.0, sax@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==