speedy-entities 3.0.1 → 3.1.0

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.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2023 Savva Mikhalevski
3
+ Copyright (c) 2025 Savva Mikhalevski
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
- # speedy-entities 🏎💨 [![build](https://github.com/smikhalevski/speedy-entities/actions/workflows/master.yml/badge.svg?branch=master&event=push)](https://github.com/smikhalevski/speedy-entities/actions/workflows/master.yml)
1
+ # speedy-entities 🏎💨
2
2
 
3
3
  [The fastest](#performance) XML/HTML entity encoder/decoder in
4
- [20 kB gzipped](https://bundlephobia.com/package/speedy-entities).
4
+ [13 kB gzipped](https://bundlephobia.com/package/speedy-entities).
5
5
 
6
6
  ```shell
7
7
  npm install --save-prod speedy-entities
@@ -26,26 +26,18 @@ decodeHTML('⪢̸&CounterClockwiseContourIntegral;');
26
26
 
27
27
  ## Custom decoder
28
28
 
29
- You can create a decoder that decodes custom named entities:
29
+ You can create a decoder that decodes your custom character entity references:
30
30
 
31
31
  ```ts
32
- import { arrayTrieEncode, trieCreate, trieSet } from '@smikhalevski/trie';
33
32
  import { createEntityDecoder } from 'speedy-entities';
34
33
 
35
- // Create a trie that would hold entities
36
- const trie = trieCreate<string>();
37
-
38
- // Register named entities
39
- trieSet('foo;', 'okay');
40
- trieSet('qux;', 'yeah');
41
-
42
- // Encode a trie
43
- const entitiesTrie = arrayTrieEncode(trie);
44
-
45
34
  // Create a decoder
46
35
  const decode = createEntityDecoder({
47
- entitiesTrie,
48
- numericReferenceSemicolonRequired: true,
36
+ entities: {
37
+ '&foo;': 'okay',
38
+ '&qux;': 'yeah',
39
+ },
40
+ isNumericReferenceSemicolonRequired: true,
49
41
  });
50
42
 
51
43
  // Decode entities
@@ -78,14 +70,21 @@ escapeXML('&😘❤️');
78
70
 
79
71
  # Performance
80
72
 
81
- Clone this repo and use `npm ci && npm run perf` to run the performance testsuite.
73
+ Results are in millions of operations per second (MHz). The higher number is better.
82
74
 
83
- Results are in millions of operations per second. The higher number is better.
75
+ | Library | Decode HTML | Decode XML | Encode XML | Escape XML |
76
+ | --------------------------------: | ----------: | ---------: | ---------: | ---------: |
77
+ | **speedy-entities**&#x200B;@3.1.0 | **4.9** | **5.2** | **5.3** | **7.2** |
78
+ | **entities**&#x200B;@6.0.1 | 3.4 | 3.6 | 4.5 | 6.1 |
79
+ | **html-entities**&#x200B;@2.6.0 | 2.2 | 2.3 | 3.3 | 5.1 |
80
+ | **he**&#x200B;@1.2.0 | 1.9 | 1.8 | 2.2 | 5.2 |
84
81
 
85
- ### Decode
82
+ Tests were conducted using [TooFast](https://github.com/smikhalevski/toofast#readme) on Apple M1 with Node.js v23.11.1.
86
83
 
87
- ![Decode HTML performance chart](./images/perf-decode-html.svg)
84
+ To reproduce [the performance test suite](./src/test/perf/overall.perf.js) results, clone this repo and run:
88
85
 
89
- ### Encode
90
-
91
- ![Encode XML performance chart](./images/perf-encode-xml.svg)
86
+ ```shell
87
+ npm ci
88
+ npm run build
89
+ npm run perf
90
+ ```
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The options recognized by {@linkcode createEntityDecoder}.
3
+ */
4
+ export interface EntityDecoderOptions {
5
+ /**
6
+ * Mapping from an [character entity reference](https://www.w3.org/TR/html4/charset.html#h-5.3.2) to its decoded value.
7
+ * Entity references should contain only [a-zA-Z] and may optionally start with an ampersand "&" and end with
8
+ * a semicolon ";".
9
+ *
10
+ * ```json
11
+ * {
12
+ * "&Delta;": "\u0394",
13
+ * "LongRightArrow;": "\u27F6"
14
+ * }
15
+ * ```
16
+ */
17
+ entities?: Record<string, string>;
18
+ /**
19
+ * If `true` then [numeric character references](https://www.w3.org/TR/html4/charset.html#h-5.3.1) must be terminated
20
+ * with a semicolon to be decoded. Otherwise, numeric character references are recognized even if they aren't
21
+ * terminated with a semicolon.
22
+ *
23
+ * @default false
24
+ */
25
+ isNumericReferenceSemicolonRequired?: boolean;
26
+ }
27
+ /**
28
+ * Creates an entity decoder that rewrites numeric and named entities found in input to their respective values.
29
+ *
30
+ * @param options The decoder options.
31
+ * @returns A function that decodes entities in the string.
32
+ */
33
+ export declare function createEntityDecoder(options?: EntityDecoderOptions): (input: string) => string;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Creates an entity decoder that rewrites numeric and named entities found in input to their respective values.
3
+ *
4
+ * @param options The decoder options.
5
+ * @returns A function that decodes entities in the string.
6
+ */
7
+ export function createEntityDecoder(options = {}) {
8
+ const { entities, isNumericReferenceSemicolonRequired = false } = options;
9
+ const fromCharCode = String.fromCharCode;
10
+ let entityDereferenceMap;
11
+ let maximumEntityReferenceLength = 32;
12
+ if (entities !== undefined) {
13
+ entityDereferenceMap = new Map();
14
+ for (const entity in entities) {
15
+ const hashCode = getEntityReferenceHashCode(entity);
16
+ if (hashCode !== 0) {
17
+ maximumEntityReferenceLength = Math.max(maximumEntityReferenceLength, entity.length);
18
+ entityDereferenceMap.set(hashCode, entities[entity]);
19
+ }
20
+ }
21
+ }
22
+ return input => {
23
+ let output = '';
24
+ let textIndex = 0;
25
+ let charIndex = 0;
26
+ const inputLength = input.length;
27
+ while (charIndex < inputLength - 1) {
28
+ let startIndex = input.indexOf('&', charIndex);
29
+ if (startIndex === -1) {
30
+ break;
31
+ }
32
+ charIndex = startIndex++;
33
+ let value;
34
+ let endIndex = startIndex;
35
+ let charCode = 0;
36
+ if (startIndex < inputLength - 2 && input.charCodeAt(startIndex) === /* # */ 35) {
37
+ // Numeric character reference
38
+ let codePoint = 0;
39
+ if ((input.charCodeAt(++startIndex) | 32) === /* x */ 120) {
40
+ endIndex = ++startIndex;
41
+ // parseInt of a hexadecimal number
42
+ while (endIndex - startIndex < 6 && endIndex < inputLength) {
43
+ // Convert alpha to lower case
44
+ charCode = input.charCodeAt(endIndex) | 32;
45
+ if ((charCode >= /* 0 */ 48 && charCode <= /* 9 */ 57) ||
46
+ (charCode >= /* a */ 97 && charCode <= /* f */ 102)) {
47
+ // Convert "0" → 0 and "f" → 15
48
+ codePoint = codePoint * 16 + charCode - (charCode & 112) + (charCode >> 6) * 9;
49
+ ++endIndex;
50
+ }
51
+ else {
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ else {
57
+ endIndex = startIndex;
58
+ // parseInt of a decimal number
59
+ while (endIndex - startIndex < 6 && endIndex < inputLength) {
60
+ charCode = input.charCodeAt(endIndex) | 32;
61
+ if (charCode >= /* 0 */ 48 && charCode <= /* 9 */ 57) {
62
+ codePoint = codePoint * 10 + charCode - (charCode & 112);
63
+ ++endIndex;
64
+ }
65
+ else {
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ if (endIndex !== startIndex) {
71
+ // At least one digit must present
72
+ const isTerminated = endIndex < inputLength && input.charCodeAt(endIndex) === /* ; */ 59;
73
+ if (isTerminated || !isNumericReferenceSemicolonRequired) {
74
+ // Convert a code point to a string
75
+ // https://github.com/mathiasbynens/he/blob/master/src/he.js#L106-L134
76
+ if (codePoint === 0 || (codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
77
+ // Null char code, or character reference is outside the permissible Unicode range
78
+ value = '\uFFFD';
79
+ }
80
+ else if (codePoint >= 128 &&
81
+ codePoint <= 195 &&
82
+ codePoint !== 129 &&
83
+ codePoint !== 141 &&
84
+ codePoint !== 143 &&
85
+ codePoint !== 144 &&
86
+ codePoint !== 157) {
87
+ // Overridden char code
88
+ value = entityOverrides[codePoint];
89
+ }
90
+ else if (codePoint > 0xffff) {
91
+ // Surrogate pair
92
+ codePoint -= 0x10000;
93
+ value = fromCharCode(((codePoint >>> 10) & 0x3ff) | 0xd800, 0xdc00 | (codePoint & 0x3ff));
94
+ }
95
+ else {
96
+ // Char code
97
+ value = fromCharCode(codePoint);
98
+ }
99
+ }
100
+ if (isTerminated) {
101
+ ++endIndex;
102
+ }
103
+ }
104
+ }
105
+ else if (entityDereferenceMap !== undefined) {
106
+ // Character entity reference
107
+ let index = startIndex;
108
+ let hashCode = 0;
109
+ let referencedValue;
110
+ while (charCode !== /* ; */ 59 &&
111
+ index < inputLength &&
112
+ index - startIndex < maximumEntityReferenceLength &&
113
+ ((charCode = input.charCodeAt(index) | 0), isEntityReferenceChar(charCode))) {
114
+ ++index;
115
+ hashCode = (hashCode << 5) - hashCode + charCode;
116
+ referencedValue = entityDereferenceMap.get(hashCode);
117
+ if (referencedValue !== undefined) {
118
+ value = referencedValue;
119
+ endIndex = index;
120
+ }
121
+ }
122
+ }
123
+ // Concat decoded entity and preceding substring
124
+ if (value !== undefined) {
125
+ output += textIndex === charIndex ? value : input.slice(textIndex, charIndex) + value;
126
+ textIndex = endIndex;
127
+ }
128
+ charIndex = endIndex;
129
+ }
130
+ return textIndex === 0 ? input : textIndex === inputLength ? output : output + input.slice(textIndex);
131
+ };
132
+ }
133
+ // Standing on the shoulders of giants
134
+ // https://github.com/mathiasbynens/he/blob/master/data/decode-map-overrides.json
135
+ const entityOverrides = {
136
+ 128: '\u20AC',
137
+ 130: '\u201A',
138
+ 131: '\u0192',
139
+ 132: '\u201E',
140
+ 133: '\u2026',
141
+ 134: '\u2020',
142
+ 135: '\u2021',
143
+ 136: '\u02C6',
144
+ 137: '\u2030',
145
+ 138: '\u0160',
146
+ 139: '\u2039',
147
+ 140: '\u0152',
148
+ 142: '\u017D',
149
+ 145: '\u2018',
150
+ 146: '\u2019',
151
+ 147: '\u201C',
152
+ 148: '\u201D',
153
+ 149: '\u2022',
154
+ 150: '\u2013',
155
+ 151: '\u2014',
156
+ 152: '\u02DC',
157
+ 153: '\u2122',
158
+ 154: '\u0161',
159
+ 155: '\u203A',
160
+ 156: '\u0153',
161
+ 158: '\u017E',
162
+ 159: '\u0178',
163
+ };
164
+ function getEntityReferenceHashCode(entity) {
165
+ if (entity.length === 0) {
166
+ return 0;
167
+ }
168
+ let hashCode = 0;
169
+ for (let index = entity.charCodeAt(0) === /* & */ 38 ? 1 : 0; index < entity.length; ++index) {
170
+ const charCode = entity.charCodeAt(index);
171
+ if (!isEntityReferenceChar(charCode)) {
172
+ return 0;
173
+ }
174
+ hashCode = (hashCode << 5) - hashCode + entity.charCodeAt(index);
175
+ }
176
+ return hashCode;
177
+ }
178
+ function isEntityReferenceChar(charCode) {
179
+ return ((charCode >= /* a */ 97 && charCode <= /* z */ 122) ||
180
+ (charCode >= /* A */ 65 && charCode <= /* Z */ 90) ||
181
+ charCode === /* ; */ 59);
182
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Decodes all known HTML entities and numeric character references in the input.
3
+ */
4
+ export declare const decodeHTML: (input: string) => string;
package/decodeHTML.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createEntityDecoder } from './createEntityDecoder.js';
2
+ import htmlEntities from './html-entities.js';
3
+ /**
4
+ * Decodes all known HTML entities and numeric character references in the input.
5
+ */
6
+ export const decodeHTML = createEntityDecoder({
7
+ entities: htmlEntities,
8
+ isNumericReferenceSemicolonRequired: false,
9
+ });
package/decodeXML.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Decodes XML entities and numeric character references in the input.
3
+ */
4
+ export declare const decodeXML: (input: string) => string;
package/decodeXML.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createEntityDecoder } from './createEntityDecoder.js';
2
+ import xmlEntities from './xml-entities.js';
3
+ /**
4
+ * Decodes XML entities and numeric character references in the input.
5
+ */
6
+ export const decodeXML = createEntityDecoder({
7
+ entities: xmlEntities,
8
+ isNumericReferenceSemicolonRequired: true,
9
+ });
package/encodeXML.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function encodeXML(input: string): string;
package/encodeXML.js ADDED
@@ -0,0 +1,38 @@
1
+ const re = /["&'<>\u0080-\uffff]/g;
2
+ export function encodeXML(input) {
3
+ let output = '';
4
+ let textIndex = 0;
5
+ while (re.test(input)) {
6
+ let lastIndex = re.lastIndex;
7
+ const startIndex = lastIndex - 1;
8
+ const charCode = input.charCodeAt(startIndex);
9
+ let entity;
10
+ if (charCode >= 0x80 && charCode <= 0xffff) {
11
+ let codePoint = charCode;
12
+ if ((charCode & 0xfc00) === 0xd800) {
13
+ // Surrogate pair
14
+ codePoint = charCode * 0x400 + input.charCodeAt(lastIndex) - 0x35fdc00;
15
+ re.lastIndex = ++lastIndex;
16
+ }
17
+ entity = '&#x' + codePoint.toString(16) + ';';
18
+ }
19
+ else if (charCode === 34) {
20
+ entity = '&quot;';
21
+ }
22
+ else if (charCode === 38) {
23
+ entity = '&amp;';
24
+ }
25
+ else if (charCode === 39) {
26
+ entity = '&apos;';
27
+ }
28
+ else if (charCode === 60) {
29
+ entity = '&lt;';
30
+ }
31
+ else {
32
+ entity = '&gt;';
33
+ }
34
+ output += textIndex === startIndex ? entity : input.slice(textIndex, startIndex) + entity;
35
+ textIndex = lastIndex;
36
+ }
37
+ return textIndex === 0 ? input : textIndex === input.length ? output : output + input.slice(textIndex);
38
+ }
package/escapeXML.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function escapeXML(input: string): string;
package/escapeXML.js ADDED
@@ -0,0 +1,29 @@
1
+ const re = /["&'<>]/g;
2
+ export function escapeXML(input) {
3
+ let output = '';
4
+ let textIndex = 0;
5
+ while (re.test(input)) {
6
+ const lastIndex = re.lastIndex;
7
+ const startIndex = lastIndex - 1;
8
+ const charCode = input.charCodeAt(startIndex);
9
+ let entity;
10
+ if (charCode === 34) {
11
+ entity = '&quot;';
12
+ }
13
+ else if (charCode === 38) {
14
+ entity = '&amp;';
15
+ }
16
+ else if (charCode === 39) {
17
+ entity = '&apos;';
18
+ }
19
+ else if (charCode === 60) {
20
+ entity = '&lt;';
21
+ }
22
+ else {
23
+ entity = '&gt;';
24
+ }
25
+ output += textIndex === startIndex ? entity : input.slice(textIndex, startIndex) + entity;
26
+ textIndex = lastIndex;
27
+ }
28
+ return textIndex === 0 ? input : textIndex === input.length ? output : output + input.slice(textIndex);
29
+ }