xml-event-parser 0.0.0-stage → 0.1.1
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/LICENSE +23 -0
- package/README.md +222 -2
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +152 -0
- package/dist/index.mjs +1 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019-2026 Xihua Duan and contributors
|
|
4
|
+
|
|
5
|
+
Includes work contributed to osm2geojson-lite by Harel Mazor.
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,223 @@
|
|
|
1
|
-
|
|
1
|
+
xml-event-parser
|
|
2
|
+
================
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
A tiny XML parser with a document tree and path-based events, produced in a
|
|
5
|
+
single pass.
|
|
6
|
+
|
|
7
|
+
- **Zero dependencies**, no DOM, no `node:*` imports, no `eval`.
|
|
8
|
+
- **One pass, two views**: every element is available as a tree node and as an
|
|
9
|
+
`open`/`close` event keyed by its dotted path, so a consumer can convert while
|
|
10
|
+
parsing without a second traversal.
|
|
11
|
+
- **Streaming**: feed `write(chunk)` from a file or socket and call `end()`.
|
|
12
|
+
Chunks may split anywhere - mid-tag, mid-attribute, inside CDATA or a comment -
|
|
13
|
+
and `retain` decides whether the parser keeps the tree, one root, or nothing,
|
|
14
|
+
so a 60 MiB document can be parsed with ~0 MiB of retained heap.
|
|
15
|
+
- **Small**: 6.1 KiB minified, 2.1 KiB gzipped (ESM), streaming included.
|
|
16
|
+
- **Fastest on every shape we benchmarked**: ~1.9x `txml`'s throughput on
|
|
17
|
+
OSM-style documents at ~30% less retained heap, and ahead of `txml` on
|
|
18
|
+
text/entity-heavy input too; ~1.6x `htmlparser2`'s on element callbacks. See
|
|
19
|
+
[Benchmarks](#benchmarks).
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { XmlParser } from 'xml-event-parser';
|
|
23
|
+
|
|
24
|
+
const parser = new XmlParser({ progressive: true });
|
|
25
|
+
|
|
26
|
+
parser.on('</osm.way>', (way) => {
|
|
27
|
+
// way.tagName === 'way', way.id, way.version, way.children ...
|
|
28
|
+
console.log(way.id, way.children?.length ?? 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const roots = parser.parse(xml);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Node shape
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
interface XmlNode {
|
|
38
|
+
tagName: string; // element name, e.g. 'way'
|
|
39
|
+
children?: XmlNode[]; // present only when the element has child elements
|
|
40
|
+
text?: string; // present only for text-only elements
|
|
41
|
+
[attribute: string]: any; // attributes, copied flat onto the node
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Attributes are flat rather than nested in an `attributes` object on purpose: one
|
|
46
|
+
object per element keeps a parsed document small (see the heap column in
|
|
47
|
+
[`bench/RESULTS.md`](bench/RESULTS.md)). Names that collide with `tagName`,
|
|
48
|
+
`children` or `text` are shadowed by the element's own values; if that matters
|
|
49
|
+
for your vocabulary, read attributes off the source XML instead.
|
|
50
|
+
|
|
51
|
+
An element with nested tags is emitted on close **after** its children are
|
|
52
|
+
attached, so `node.children` is complete inside a close listener.
|
|
53
|
+
|
|
54
|
+
## Streaming
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { createReadStream } from 'node:fs';
|
|
58
|
+
import { XmlParser } from 'xml-event-parser';
|
|
59
|
+
|
|
60
|
+
const parser = new XmlParser({ progressive: true, retain: 'none' });
|
|
61
|
+
parser.on('</osm.way>', (way) => {
|
|
62
|
+
// one complete element; `way` is released after this returns
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
for await (const chunk of createReadStream('planet.osm.xml', 'utf8')) {
|
|
66
|
+
parser.write(chunk);
|
|
67
|
+
}
|
|
68
|
+
parser.end();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`write()` consumes everything complete in the buffer and keeps the rest for the
|
|
72
|
+
next call, so a chunk boundary can fall inside a tag, an attribute value, a text
|
|
73
|
+
run, a comment or a `]]>` terminator without changing the result: parsing a
|
|
74
|
+
document whole or in 1-byte chunks produces the same tree (the suite checks every
|
|
75
|
+
split point).
|
|
76
|
+
|
|
77
|
+
`retain` is what makes streaming bounded:
|
|
78
|
+
|
|
79
|
+
| `retain` | keeps | `end()` returns | memory |
|
|
80
|
+
| --- | --- | --- | --- |
|
|
81
|
+
| `'all'` (default) | the document | the roots | grows with the document |
|
|
82
|
+
| `'root'` | the root being built | `[]`; a finished root is emitted and dropped | largest root |
|
|
83
|
+
| `'none'` | nothing | `[]`; every element is emitted and dropped | nesting depth |
|
|
84
|
+
|
|
85
|
+
Measured on a 60 MiB document (ten concatenated extracts, 327k elements):
|
|
86
|
+
`retain: 'all'` holds 302 MiB, `retain: 'none'` holds ~0 MiB, both emit the same
|
|
87
|
+
327k elements. Without listeners, `retain: 'none'` still parses - it just throws
|
|
88
|
+
the result away.
|
|
89
|
+
|
|
90
|
+
## Events
|
|
91
|
+
|
|
92
|
+
| form | fires |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `'<osm.way>'` | when a `<way>` inside `<osm>` opens |
|
|
95
|
+
| `'</osm.way>'` | when it closes, with `children` populated |
|
|
96
|
+
|
|
97
|
+
The path is the dotted chain of ancestors. Event names are only built for paths
|
|
98
|
+
that have a listener, so subscribing to one path costs nothing for the elements
|
|
99
|
+
you ignore. Filter inside the listener:
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
parser.on('</osm.way>', (way) => {
|
|
103
|
+
if (!way.tags) return; // or any other predicate
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Options
|
|
108
|
+
|
|
109
|
+
| option | default | meaning |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `progressive` | `false` | emit events. Without it, `parse()` only builds the tree |
|
|
112
|
+
| `queryParent` | `false` | track parents so `parser.getParent(node)` works (costs a `WeakMap`) |
|
|
113
|
+
| `retain` | `'all'` | what survives an element: `'all'`, `'root'` or `'none'` (see [Streaming](#streaming)) |
|
|
114
|
+
|
|
115
|
+
`parse(xml, parent?, dir?)` also accepts an existing node to attach the parsed
|
|
116
|
+
roots to, and a dotted path prefix used when building event names.
|
|
117
|
+
|
|
118
|
+
The parser is non-validating and never throws on malformed input: unterminated
|
|
119
|
+
tags, comments, processing instructions and doctypes are skipped, and elements
|
|
120
|
+
still open at end of input are closed so a truncated download yields the
|
|
121
|
+
document parsed so far (`'<r><a>text'` returns `r` with an `a` child whose
|
|
122
|
+
`text` is `'text'`).
|
|
123
|
+
|
|
124
|
+
CDATA sections are content, not markup: their text is kept verbatim and is not
|
|
125
|
+
entity-decoded, while text outside them is decoded (the five predefined
|
|
126
|
+
entities and numeric character references).
|
|
127
|
+
|
|
128
|
+
## Benchmarks
|
|
129
|
+
|
|
130
|
+
Six generated shapes, each isolating one cost (per-element, per-child, text,
|
|
131
|
+
nesting). Methodology, per-shape tables and caveats live in
|
|
132
|
+
[`bench/RESULTS.md`](bench/RESULTS.md); rerun with `npm run bench:data && npm run bench`.
|
|
133
|
+
|
|
134
|
+
Realistic Overpass-style document, 6.0 MiB, Apple M4 Pro / Node 26:
|
|
135
|
+
|
|
136
|
+
| workload | xml-event-parser | txml | fast-xml-parser | sax | htmlparser2 |
|
|
137
|
+
| --- | --- | --- | --- | --- | --- |
|
|
138
|
+
| tree (parse + walk) | **26.0 ms, 230 MB/s** | 50.6 ms, 118 MB/s | 167.8 ms, 36 MB/s | — | — |
|
|
139
|
+
| tree, retained heap | **30.0 MiB** | 42.5 MiB | 35.2 MiB | — | — |
|
|
140
|
+
| element callbacks | **27.8 ms, 215 MB/s** | — | — | 100.0 ms, 60 MB/s | 44.9 ms, 133 MB/s |
|
|
141
|
+
| streamed, 64 KiB chunks | **27.3 ms, 219 MB/s** | 77.4 ms, 77 MB/s | — | 103.3 ms, 58 MB/s | 50.8 ms, 118 MB/s |
|
|
142
|
+
|
|
143
|
+
Notes on reading the table:
|
|
144
|
+
|
|
145
|
+
- The callback and streamed rows for `xml-event-parser` use `retain: 'none'`, so
|
|
146
|
+
they do not build a document either; the tree row is the only one that holds
|
|
147
|
+
memory. `txml`'s streaming API is a Node stream (`transformStream`), measured
|
|
148
|
+
separately and asynchronously.
|
|
149
|
+
- Text-heavy documents with many entities and CDATA used to be the weak shape;
|
|
150
|
+
replacing the regex decoder with a manual scan took it from 325 to 442 MB/s,
|
|
151
|
+
ahead of `txml` (382 MB/s).
|
|
152
|
+
|
|
153
|
+
## Status
|
|
154
|
+
|
|
155
|
+
Extracted from [osm2geojson-lite](https://github.com/tibetty/osm2geojson-lite),
|
|
156
|
+
where this parser served until 2.2.0 and was then replaced by a purpose-built
|
|
157
|
+
OSM scanner. The code and its original unit tests are unchanged apart from the
|
|
158
|
+
public naming (`children`/`tagName`/`text` instead of `$innerNodes`/`$tag`/
|
|
159
|
+
`$innerText`) and the removal of the `new Function`-based conditioned-path
|
|
160
|
+
events.
|
|
161
|
+
|
|
162
|
+
Not published yet. The release workflow publishes only when the repository
|
|
163
|
+
variable `NPM_TRUSTED_PUBLISHER` is `true`; before that, a release is published
|
|
164
|
+
locally because npm cannot configure a trusted publisher for a package that does
|
|
165
|
+
not exist yet.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
npm ci
|
|
171
|
+
npm test # unit tests
|
|
172
|
+
npm run typecheck
|
|
173
|
+
npm run build # dist/index.mjs, dist/index.cjs, dist/index.d.ts
|
|
174
|
+
npm run bench:data # generate ~27 MiB benchmark corpus (gitignored)
|
|
175
|
+
npm run bench # compare against txml / sax / htmlparser2 / fast-xml-parser
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Releasing
|
|
179
|
+
|
|
180
|
+
`release.yml` publishes with npm trusted publishing (OIDC) and is gated on the
|
|
181
|
+
repository variable `NPM_TRUSTED_PUBLISHER`, which stays unset until a trusted
|
|
182
|
+
publisher exists.
|
|
183
|
+
|
|
184
|
+
One-time bootstrap, because npm cannot attach a trusted publisher to a package
|
|
185
|
+
that does not exist yet:
|
|
186
|
+
|
|
187
|
+
1. Publish the first version locally:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
npm login
|
|
191
|
+
npm publish --access public
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
2. npmjs.com -> Packages -> `xml-event-parser` -> Settings -> **Trusted
|
|
195
|
+
publishing** -> GitHub Actions:
|
|
196
|
+
|
|
197
|
+
| field | value |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| Organization or user | `tibetty` |
|
|
200
|
+
| Repository | `xml-event-parser` |
|
|
201
|
+
| Workflow filename | `release.yml` (filename only, case-sensitive) |
|
|
202
|
+
| Environment name | leave empty |
|
|
203
|
+
| Allowed actions | tick **npm publish** |
|
|
204
|
+
|
|
205
|
+
Configurations created after September 2026 default to `npm stage publish`
|
|
206
|
+
only, so `npm publish` has to be ticked explicitly. The first publish through
|
|
207
|
+
that workflow must complete within **two days**, or the configuration expires.
|
|
208
|
+
|
|
209
|
+
3. GitHub -> Settings -> Secrets and variables -> Actions -> Variables: add
|
|
210
|
+
`NPM_TRUSTED_PUBLISHER` = `true`.
|
|
211
|
+
|
|
212
|
+
4. Optional hardening, once a workflow publish has succeeded: npmjs.com ->
|
|
213
|
+
package Settings -> Publishing access -> *Require two-factor authentication
|
|
214
|
+
and disallow tokens*.
|
|
215
|
+
|
|
216
|
+
After that, changing `version` in `package.json` on `main` publishes through
|
|
217
|
+
`release.yml`, tags `v<version>` and creates the GitHub release. npm generates
|
|
218
|
+
provenance automatically only for a public repository; from a private one the
|
|
219
|
+
package is published without an attestation.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT. Includes work contributed to `osm2geojson-lite` by Harel Mazor.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});function e(e){switch(e){case`amp`:return`&`;case`lt`:return`<`;case`gt`:return`>`;case`quot`:return`"`;case`apos`:return`'`}if(e.charCodeAt(0)===35){let t=e.charCodeAt(1)===120||e.charCodeAt(1)===88?parseInt(e.slice(2),16):parseInt(e.slice(1),10);if(Number.isFinite(t)&&t>=0&&t<=1114111)return String.fromCodePoint(t)}return null}function t(t){let n=t.indexOf(`&`);if(n<0)return t;let r=t.slice(0,n);for(;n<t.length;){let i=t.indexOf(`;`,n+1);if(i<0){r+=t.slice(n);break}let a=e(t.slice(n+1,i));if(r+=a===null?t.slice(n,i+1):a,n=t.indexOf(`&`,i+1),n<0){r+=t.slice(i+1);break}r+=t.slice(i+1,n)}return r}function n(e){if(e.indexOf(`<![CDATA[`)<0)return e.indexOf(`&`)<0?e:t(e);let n=``,r=0;for(;r<e.length;){let i=e.indexOf(`<![CDATA[`,r);if(i<0){n+=t(e.slice(r));break}i>r&&(n+=t(e.slice(r,i)));let a=e.indexOf(`]]>`,i+9);if(a<0){n+=e.slice(i+9);break}n+=e.slice(i+9,a),r=a+3}return n}function r(e){return e===32||e===9||e===10||e===13}var i=class{constructor(e){this.queryParent=!1,this.progressive=!1,this.retain=`all`,this.parentMap=/* @__PURE__ */ new WeakMap,this.evtListeners={},this.openListenerCount=0,this.watchedPaths=/* @__PURE__ */ new Set,this.watchedChildren=/* @__PURE__ */ new Map,this.watchedTopLevel=/* @__PURE__ */ new Set,this.buffer=``,this.cursor=0,this.stack=[],this.roots=[],this.basePath=``,this.active=!1,e&&(this.queryParent=e.queryParent===!0,this.progressive=e.progressive===!0,e.retain&&(this.retain=e.retain))}write(e){return this.active||this.begin(void 0,void 0),e.length===0||(this.buffer.length===0?this.buffer=e:this.buffer+=e,this.consume(!1)),this}end(){if(!this.active)return[];this.consume(!0),this.finish();let e=this.roots;return this.reset(),e}parse(e,t,n){return this.begin(t,n),this.write(e),this.end()}begin(e,t){this.buffer=``,this.cursor=0,this.stack=[],this.roots=[],this.basePath=t||``,this.top=e?{node:e,fullTag:this.basePath,textStart:0}:void 0,this.rootOwner=this.top,this.parentMap=/* @__PURE__ */ new WeakMap,this.active=!0}reset(){this.buffer=``,this.cursor=0,this.stack=[],this.roots=[],this.top=void 0,this.rootOwner=void 0,this.basePath=``,this.active=!1}attach(e,t){if(this.retain===`none`)return;if(t===void 0){this.retain===`all`&&this.roots.push(e);return}let n=t.children;n?n.push(e):t.children=[e]}consume(e){let t=this.buffer,r=t.length,i=this.progressive,a=i&&this.openListenerCount>0,o=this.cursor,s=-1;for(;o<r;){let c=t.indexOf(`<`,o);if(c<0||(s=c,o=c+1,o>=r))break;let l=t.charCodeAt(o);if(l===47){let a=t.indexOf(`>`,o);if(a<0){if(!e)break;o=r;break}let l=this.stack.pop();if(o=a+1,s=-1,!l)continue;let u=l.node,d=this.stack.length?this.stack[this.stack.length-1]:this.rootOwner,f=d?d.node:void 0;!l.hasChild&&c>l.textStart&&(u.text=n(t.slice(l.textStart,c))),i&&this.watchedPaths.has(l.fullTag)&&this.emit(`</`+l.fullTag+`>`,u,f),this.attach(u,f),d&&(d.hasChild=!0),this.top=d;continue}if(l===63){let n=t.indexOf(`>`,o);if(n<0){if(!e)break;o=r;break}o=n+1,s=-1;continue}if(l===33){let n;if(t.charCodeAt(o+1)===45&&t.charCodeAt(o+2)===45?(n=t.indexOf(`-->`,o+3),n=n<0?-1:n+3):t.startsWith(`![CDATA[`,o)?(n=t.indexOf(`]]>`,o+9),n=n<0?-1:n+3):(n=t.indexOf(`>`,o),n=n<0?-1:n+1),n<0){if(!e)break;o=r;break}o=n,s=-1;continue}let u=this.parseOpenTag(t,o,r,e);if(u===null)break;o=u.end,s=-1;let d=u.node,f=u.tag,p=this.top?this.top.node:void 0;if(this.queryParent&&p&&this.parentMap.set(d,p),u.selfClose){if(i){let e=this.top?this.top.fullTag:this.basePath,t=e?this.watchedChildren.get(e):this.watchedTopLevel;if(t&&t.has(f)){let t=e?e+`.`+f:f;a&&this.emit(`<`+t+`>`,d,p),this.emit(`</`+t+`>`,d,p)}}this.attach(d,p),this.top&&(this.top.hasChild=!0);continue}let m=this.top?this.top.fullTag?this.top.fullTag+`.`+f:f:this.basePath?this.basePath+`.`+f:f;a&&this.watchedPaths.has(m)&&this.emit(`<`+m+`>`,d,p);let h={node:d,fullTag:m,textStart:o};this.stack.push(h),this.top=h}this.cursor=e?r:s>=0?s:o,e||this.compact()}parseOpenTag(e,n,i,a){let o=n;for(;o<i;){let t=e.charCodeAt(o);if(r(t)||t===47||t===62)break;o++}if(o>=i&&!a)return null;let s=e.slice(n,o),c={tagName:s},l=!1;for(;o<i;){let n=e.charCodeAt(o);for(;o<i&&r(n);)n=e.charCodeAt(++o);if(o>=i)break;if(n===62)return o++,{node:c,tag:s,selfClose:l,end:o};if(n===47){l=!0,o++;continue}let u=o;for(;o<i&&(n=e.charCodeAt(o),!(n===61||r(n)||n===62||n===47));)o++;if(o>=i&&!a)return null;let d=e.slice(u,o);for(;o<i&&r(e.charCodeAt(o));)o++;if(o>=i&&!a)return null;if(e.charCodeAt(o)===61){for(o++;o<i&&r(e.charCodeAt(o));)o++;if(o>=i&&!a)return null;let n=e.charCodeAt(o);if(n===34||n===39){o++;let r=e.indexOf(n===34?`"`:`'`,o);if(r<0){if(!a)return null;r=i}let s=e.slice(o,r);c[d]=s.indexOf(`&`)<0?s:t(s),o=r+1}}else d&&(c[d]=``)}return a?{node:c,tag:s,selfClose:l,end:o}:null}finish(){let e=this.buffer,t=e.length;for(;this.stack.length>0;){let r=this.stack.pop(),i=r.node;!r.hasChild&&t>r.textStart&&(i.text=n(e.slice(r.textStart,t)),t=r.textStart);let a=this.stack.length?this.stack[this.stack.length-1]:this.rootOwner,o=a?a.node:void 0;this.attach(i,o)}}compact(){let e=this.cursor;for(let t=this.stack.length-1;t>=0;t--){let n=this.stack[t];if(!n.hasChild){e=n.textStart;break}}if(!(e<=0)){this.buffer=this.buffer.slice(e),this.cursor-=e;for(let t=0;t<this.stack.length;t++){let n=this.stack[t];n.textStart=n.textStart>e?n.textStart-e:0}this.rootOwner&&(this.rootOwner.textStart=0)}}getParent(e){return this.queryParent?this.parentMap.get(e):null}addListener(e,t){this.$addListener(e,t)}removeListener(e,t){this.$removeListener(e,t)}on(e,t){this.addListener(e,t)}off(e,t){this.removeListener(e,t)}$addListener(e,t){let n=this.evtListeners[e];if(n?n.push(t):this.evtListeners[e]=[t],e.charCodeAt(1)!==47&&this.openListenerCount++,e.charCodeAt(0)===60){let t=e.slice(e.charCodeAt(1)===47?2:1,e.length-1);this.watchedPaths.add(t);let n=t.lastIndexOf(`.`);if(n<0)this.watchedTopLevel.add(t);else{let e=t.slice(0,n),r=this.watchedChildren.get(e);r||(r=/* @__PURE__ */ new Set,this.watchedChildren.set(e,r)),r.add(t.slice(n+1))}}}$removeListener(e,t){let n=this.evtListeners[e],r=-1;n&&(r=n.indexOf(t)),r>=0&&(n.splice(r,1),e.charCodeAt(1)!==47&&this.openListenerCount--)}emit(e,t,n){let r=this.evtListeners[e];if(r)for(let e=0;e<r.length;e++)r[e](t,n)}};exports.XmlParser=i,exports.decodeEntities=t;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One parsed element.
|
|
3
|
+
*
|
|
4
|
+
* Attributes are copied onto the node as own properties (flat, so a document
|
|
5
|
+
* costs one object per element), which is why the type carries an index
|
|
6
|
+
* signature. `text` is set only for elements without child elements.
|
|
7
|
+
*/
|
|
8
|
+
interface XmlNode {
|
|
9
|
+
tagName: string;
|
|
10
|
+
children?: XmlNode[];
|
|
11
|
+
text?: string;
|
|
12
|
+
[attribute: string]: any;
|
|
13
|
+
}
|
|
14
|
+
/** Listener signature for the path-based events. */
|
|
15
|
+
type XmlListener = (node: XmlNode, parent?: XmlNode) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Decode XML entities.
|
|
18
|
+
*
|
|
19
|
+
* A manual scan beats `String.replace` with a callback by ~2.5x on OSM-sized
|
|
20
|
+
* attribute values: no regex, no match object and no per-entity closure. The
|
|
21
|
+
* `indexOf` guard keeps entity-free values allocation-free, and unknown
|
|
22
|
+
* entities are left exactly as written.
|
|
23
|
+
*/
|
|
24
|
+
export declare function decodeEntities(value: string): string;
|
|
25
|
+
type Retain = 'all' | 'root' | 'none';
|
|
26
|
+
interface XmlParserOptions {
|
|
27
|
+
/** Emit `<path>` / `</path>` events as elements open and close. Default `false`. */
|
|
28
|
+
progressive?: boolean;
|
|
29
|
+
/** Track parents so `getParent(node)` works. Default `false`. */
|
|
30
|
+
queryParent?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* What the parser keeps after an element closes:
|
|
33
|
+
* - `'all'` (default) the whole document tree, returned by `end()`
|
|
34
|
+
* - `'root'` the root being built; a finished top-level element is emitted
|
|
35
|
+
* and dropped, so memory is bounded by the largest root
|
|
36
|
+
* - `'none'` nothing; every element is emitted and dropped, so memory is
|
|
37
|
+
* bounded by the nesting depth. Combine with listeners: an element
|
|
38
|
+
* nobody listens for is parsed and discarded.
|
|
39
|
+
*/
|
|
40
|
+
retain?: Retain;
|
|
41
|
+
}
|
|
42
|
+
export declare class XmlParser {
|
|
43
|
+
private queryParent;
|
|
44
|
+
private progressive;
|
|
45
|
+
private retain;
|
|
46
|
+
private parentMap;
|
|
47
|
+
private evtListeners;
|
|
48
|
+
/**
|
|
49
|
+
* Number of registered open-tag (`<path>`) listeners; lets the scanner skip
|
|
50
|
+
* building open-event strings for the overwhelmingly common leaf elements.
|
|
51
|
+
*/
|
|
52
|
+
private openListenerCount;
|
|
53
|
+
/**
|
|
54
|
+
* Exact paths that have at least one listener, the child paths keyed by
|
|
55
|
+
* their parent path, and top-level listeners. These let the scanner skip
|
|
56
|
+
* building event strings for the many `<nd>`/`<tag>` leaves that nobody
|
|
57
|
+
* observes.
|
|
58
|
+
*/
|
|
59
|
+
private watchedPaths;
|
|
60
|
+
private watchedChildren;
|
|
61
|
+
private watchedTopLevel;
|
|
62
|
+
/** Input not consumed yet: pending text plus any construct split by a chunk. */
|
|
63
|
+
private buffer;
|
|
64
|
+
/** Next position to scan in `buffer`. */
|
|
65
|
+
private cursor;
|
|
66
|
+
private stack;
|
|
67
|
+
private roots;
|
|
68
|
+
private top;
|
|
69
|
+
private rootOwner;
|
|
70
|
+
private basePath;
|
|
71
|
+
private active;
|
|
72
|
+
constructor(opts?: XmlParserOptions);
|
|
73
|
+
/**
|
|
74
|
+
* Feed a chunk of XML.
|
|
75
|
+
*
|
|
76
|
+
* Chunks may split anywhere: inside a tag, an attribute value, a comment, a
|
|
77
|
+
* CDATA section or a text run. Anything incomplete is kept and resumed on
|
|
78
|
+
* the next `write()`; call `end()` when the input is exhausted.
|
|
79
|
+
*/
|
|
80
|
+
write(chunk: string): this;
|
|
81
|
+
/**
|
|
82
|
+
* Finish the document: close elements still open at the end of input and
|
|
83
|
+
* return the top-level nodes (empty for `retain: 'root' | 'none'`).
|
|
84
|
+
*/
|
|
85
|
+
end(): XmlNode[];
|
|
86
|
+
/**
|
|
87
|
+
* Parse a complete XML string in one pass.
|
|
88
|
+
*
|
|
89
|
+
* Equivalent to `write(xml).end()`, and the only entry point you need
|
|
90
|
+
* unless the document arrives in pieces.
|
|
91
|
+
*
|
|
92
|
+
* @param xml - the XML source to scan.
|
|
93
|
+
* @param parent - optional pre-existing node that owns the parsed content;
|
|
94
|
+
* when supplied, top-level nodes attach to its `children` instead of being
|
|
95
|
+
* returned as roots.
|
|
96
|
+
* @param dir - optional dotted tag-path prefix applied to emitted event names.
|
|
97
|
+
* @returns the top-level nodes (empty when a `parent` owns them).
|
|
98
|
+
*/
|
|
99
|
+
parse(xml: string, parent?: XmlNode, dir?: string): XmlNode[];
|
|
100
|
+
private begin;
|
|
101
|
+
private reset;
|
|
102
|
+
/**
|
|
103
|
+
* Store a closed element according to the retention mode.
|
|
104
|
+
*
|
|
105
|
+
* `parentNode === undefined` means the element is top-level (a root), which
|
|
106
|
+
* `retain: 'root'` drops after emitting it.
|
|
107
|
+
*/
|
|
108
|
+
private attach;
|
|
109
|
+
/** Scan everything in `buffer` that is complete, or all of it at `end()`. */
|
|
110
|
+
private consume;
|
|
111
|
+
/**
|
|
112
|
+
* Parse one opening tag at `from` (just past `<`).
|
|
113
|
+
*
|
|
114
|
+
* Returns `null` when the tag is not complete and more input may follow; at
|
|
115
|
+
* `final` an unterminated tag is accepted as far as it goes, which is what
|
|
116
|
+
* keeps truncated documents usable.
|
|
117
|
+
*/
|
|
118
|
+
private parseOpenTag;
|
|
119
|
+
/**
|
|
120
|
+
* Close elements still open at the end of input instead of dropping them:
|
|
121
|
+
* a truncated download keeps the document parsed so far.
|
|
122
|
+
*/
|
|
123
|
+
private finish;
|
|
124
|
+
/**
|
|
125
|
+
* Drop consumed input.
|
|
126
|
+
*
|
|
127
|
+
* Everything before the innermost open element is dead: its text has not
|
|
128
|
+
* been read yet, and ancestors with children never read theirs. Keeping the
|
|
129
|
+
* buffer from there is what makes a streamed document cost the pending text
|
|
130
|
+
* rather than the whole file.
|
|
131
|
+
*/
|
|
132
|
+
private compact;
|
|
133
|
+
/** Parent of a node parsed with `queryParent: true`, otherwise `null`. */
|
|
134
|
+
getParent(node: any): any;
|
|
135
|
+
/**
|
|
136
|
+
* Register a listener for an event.
|
|
137
|
+
*
|
|
138
|
+
* Event names are the dotted tag path, prefixed with `<` for an opening
|
|
139
|
+
* element and `</` for a closing one: `'<osm.way>'`, `'</osm.way>'`.
|
|
140
|
+
* Filter inside the listener - `if (node.tagName !== 'way') return;` - and
|
|
141
|
+
* an unobserved path costs nothing, because the scanner skips building
|
|
142
|
+
* event names nobody listens for.
|
|
143
|
+
*/
|
|
144
|
+
addListener(evt: string, func: XmlListener): void;
|
|
145
|
+
removeListener(evt: string, func: XmlListener): void;
|
|
146
|
+
on(evt: string, func: XmlListener): void;
|
|
147
|
+
off(evt: string, func: XmlListener): void;
|
|
148
|
+
private $addListener;
|
|
149
|
+
private $removeListener;
|
|
150
|
+
private emit;
|
|
151
|
+
}
|
|
152
|
+
export type { Retain, XmlListener, XmlNode, XmlParserOptions };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function e(e){switch(e){case`amp`:return`&`;case`lt`:return`<`;case`gt`:return`>`;case`quot`:return`"`;case`apos`:return`'`}if(e.charCodeAt(0)===35){let t=e.charCodeAt(1)===120||e.charCodeAt(1)===88?parseInt(e.slice(2),16):parseInt(e.slice(1),10);if(Number.isFinite(t)&&t>=0&&t<=1114111)return String.fromCodePoint(t)}return null}function t(t){let n=t.indexOf(`&`);if(n<0)return t;let r=t.slice(0,n);for(;n<t.length;){let i=t.indexOf(`;`,n+1);if(i<0){r+=t.slice(n);break}let a=e(t.slice(n+1,i));if(r+=a===null?t.slice(n,i+1):a,n=t.indexOf(`&`,i+1),n<0){r+=t.slice(i+1);break}r+=t.slice(i+1,n)}return r}function n(e){if(e.indexOf(`<![CDATA[`)<0)return e.indexOf(`&`)<0?e:t(e);let n=``,r=0;for(;r<e.length;){let i=e.indexOf(`<![CDATA[`,r);if(i<0){n+=t(e.slice(r));break}i>r&&(n+=t(e.slice(r,i)));let a=e.indexOf(`]]>`,i+9);if(a<0){n+=e.slice(i+9);break}n+=e.slice(i+9,a),r=a+3}return n}function r(e){return e===32||e===9||e===10||e===13}var i=class{constructor(e){this.queryParent=!1,this.progressive=!1,this.retain=`all`,this.parentMap=/* @__PURE__ */ new WeakMap,this.evtListeners={},this.openListenerCount=0,this.watchedPaths=/* @__PURE__ */ new Set,this.watchedChildren=/* @__PURE__ */ new Map,this.watchedTopLevel=/* @__PURE__ */ new Set,this.buffer=``,this.cursor=0,this.stack=[],this.roots=[],this.basePath=``,this.active=!1,e&&(this.queryParent=e.queryParent===!0,this.progressive=e.progressive===!0,e.retain&&(this.retain=e.retain))}write(e){return this.active||this.begin(void 0,void 0),e.length===0||(this.buffer.length===0?this.buffer=e:this.buffer+=e,this.consume(!1)),this}end(){if(!this.active)return[];this.consume(!0),this.finish();let e=this.roots;return this.reset(),e}parse(e,t,n){return this.begin(t,n),this.write(e),this.end()}begin(e,t){this.buffer=``,this.cursor=0,this.stack=[],this.roots=[],this.basePath=t||``,this.top=e?{node:e,fullTag:this.basePath,textStart:0}:void 0,this.rootOwner=this.top,this.parentMap=/* @__PURE__ */ new WeakMap,this.active=!0}reset(){this.buffer=``,this.cursor=0,this.stack=[],this.roots=[],this.top=void 0,this.rootOwner=void 0,this.basePath=``,this.active=!1}attach(e,t){if(this.retain===`none`)return;if(t===void 0){this.retain===`all`&&this.roots.push(e);return}let n=t.children;n?n.push(e):t.children=[e]}consume(e){let t=this.buffer,r=t.length,i=this.progressive,a=i&&this.openListenerCount>0,o=this.cursor,s=-1;for(;o<r;){let c=t.indexOf(`<`,o);if(c<0||(s=c,o=c+1,o>=r))break;let l=t.charCodeAt(o);if(l===47){let a=t.indexOf(`>`,o);if(a<0){if(!e)break;o=r;break}let l=this.stack.pop();if(o=a+1,s=-1,!l)continue;let u=l.node,d=this.stack.length?this.stack[this.stack.length-1]:this.rootOwner,f=d?d.node:void 0;!l.hasChild&&c>l.textStart&&(u.text=n(t.slice(l.textStart,c))),i&&this.watchedPaths.has(l.fullTag)&&this.emit(`</`+l.fullTag+`>`,u,f),this.attach(u,f),d&&(d.hasChild=!0),this.top=d;continue}if(l===63){let n=t.indexOf(`>`,o);if(n<0){if(!e)break;o=r;break}o=n+1,s=-1;continue}if(l===33){let n;if(t.charCodeAt(o+1)===45&&t.charCodeAt(o+2)===45?(n=t.indexOf(`-->`,o+3),n=n<0?-1:n+3):t.startsWith(`![CDATA[`,o)?(n=t.indexOf(`]]>`,o+9),n=n<0?-1:n+3):(n=t.indexOf(`>`,o),n=n<0?-1:n+1),n<0){if(!e)break;o=r;break}o=n,s=-1;continue}let u=this.parseOpenTag(t,o,r,e);if(u===null)break;o=u.end,s=-1;let d=u.node,f=u.tag,p=this.top?this.top.node:void 0;if(this.queryParent&&p&&this.parentMap.set(d,p),u.selfClose){if(i){let e=this.top?this.top.fullTag:this.basePath,t=e?this.watchedChildren.get(e):this.watchedTopLevel;if(t&&t.has(f)){let t=e?e+`.`+f:f;a&&this.emit(`<`+t+`>`,d,p),this.emit(`</`+t+`>`,d,p)}}this.attach(d,p),this.top&&(this.top.hasChild=!0);continue}let m=this.top?this.top.fullTag?this.top.fullTag+`.`+f:f:this.basePath?this.basePath+`.`+f:f;a&&this.watchedPaths.has(m)&&this.emit(`<`+m+`>`,d,p);let h={node:d,fullTag:m,textStart:o};this.stack.push(h),this.top=h}this.cursor=e?r:s>=0?s:o,e||this.compact()}parseOpenTag(e,n,i,a){let o=n;for(;o<i;){let t=e.charCodeAt(o);if(r(t)||t===47||t===62)break;o++}if(o>=i&&!a)return null;let s=e.slice(n,o),c={tagName:s},l=!1;for(;o<i;){let n=e.charCodeAt(o);for(;o<i&&r(n);)n=e.charCodeAt(++o);if(o>=i)break;if(n===62)return o++,{node:c,tag:s,selfClose:l,end:o};if(n===47){l=!0,o++;continue}let u=o;for(;o<i&&(n=e.charCodeAt(o),!(n===61||r(n)||n===62||n===47));)o++;if(o>=i&&!a)return null;let d=e.slice(u,o);for(;o<i&&r(e.charCodeAt(o));)o++;if(o>=i&&!a)return null;if(e.charCodeAt(o)===61){for(o++;o<i&&r(e.charCodeAt(o));)o++;if(o>=i&&!a)return null;let n=e.charCodeAt(o);if(n===34||n===39){o++;let r=e.indexOf(n===34?`"`:`'`,o);if(r<0){if(!a)return null;r=i}let s=e.slice(o,r);c[d]=s.indexOf(`&`)<0?s:t(s),o=r+1}}else d&&(c[d]=``)}return a?{node:c,tag:s,selfClose:l,end:o}:null}finish(){let e=this.buffer,t=e.length;for(;this.stack.length>0;){let r=this.stack.pop(),i=r.node;!r.hasChild&&t>r.textStart&&(i.text=n(e.slice(r.textStart,t)),t=r.textStart);let a=this.stack.length?this.stack[this.stack.length-1]:this.rootOwner,o=a?a.node:void 0;this.attach(i,o)}}compact(){let e=this.cursor;for(let t=this.stack.length-1;t>=0;t--){let n=this.stack[t];if(!n.hasChild){e=n.textStart;break}}if(!(e<=0)){this.buffer=this.buffer.slice(e),this.cursor-=e;for(let t=0;t<this.stack.length;t++){let n=this.stack[t];n.textStart=n.textStart>e?n.textStart-e:0}this.rootOwner&&(this.rootOwner.textStart=0)}}getParent(e){return this.queryParent?this.parentMap.get(e):null}addListener(e,t){this.$addListener(e,t)}removeListener(e,t){this.$removeListener(e,t)}on(e,t){this.addListener(e,t)}off(e,t){this.removeListener(e,t)}$addListener(e,t){let n=this.evtListeners[e];if(n?n.push(t):this.evtListeners[e]=[t],e.charCodeAt(1)!==47&&this.openListenerCount++,e.charCodeAt(0)===60){let t=e.slice(e.charCodeAt(1)===47?2:1,e.length-1);this.watchedPaths.add(t);let n=t.lastIndexOf(`.`);if(n<0)this.watchedTopLevel.add(t);else{let e=t.slice(0,n),r=this.watchedChildren.get(e);r||(r=/* @__PURE__ */ new Set,this.watchedChildren.set(e,r)),r.add(t.slice(n+1))}}}$removeListener(e,t){let n=this.evtListeners[e],r=-1;n&&(r=n.indexOf(t)),r>=0&&(n.splice(r,1),e.charCodeAt(1)!==47&&this.openListenerCount--)}emit(e,t,n){let r=this.evtListeners[e];if(r)for(let e=0;e<r.length;e++)r[e](t,n)}};export{i as XmlParser,t as decodeEntities};
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xml-event-parser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Tiny dependency-free XML parser with a tree and path-based events in one pass",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/tibetty/xml-event-parser.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/tibetty/xml-event-parser#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/tibetty/xml-event-parser/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"xml",
|
|
15
|
+
"parser",
|
|
16
|
+
"events",
|
|
17
|
+
"tree",
|
|
18
|
+
"streaming",
|
|
19
|
+
"zero-dependency"
|
|
20
|
+
],
|
|
21
|
+
"main": "dist/index.cjs",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "rolldown -c",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"bench:data": "node bench/generate.mjs",
|
|
27
|
+
"bench": "node --expose-gc bench/run.mjs",
|
|
28
|
+
"prepublishOnly": "npm run build && npm test"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"type": "module",
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"fast-xml-parser": "^5.12.1",
|
|
42
|
+
"htmlparser2": "^12.0.0",
|
|
43
|
+
"rolldown": "^1.3.2",
|
|
44
|
+
"rolldown-plugin-dts": "^0.30.2",
|
|
45
|
+
"sax": "^1.6.1",
|
|
46
|
+
"txml": "^6.0.3",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vitest": "^5.1.0"
|
|
49
|
+
},
|
|
50
|
+
"module": "dist/index.mjs",
|
|
51
|
+
"types": "dist/index.d.ts",
|
|
52
|
+
"exports": {
|
|
53
|
+
".": {
|
|
54
|
+
"types": "./dist/index.d.ts",
|
|
55
|
+
"import": "./dist/index.mjs",
|
|
56
|
+
"require": "./dist/index.cjs"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"sideEffects": false,
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=18"
|
|
62
|
+
}
|
|
63
|
+
}
|