identifier-js 0.2.0 → 0.3.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/index.d.ts +6 -3
- package/index.js +73 -10
- package/normalization.md +20 -8
- package/package.json +1 -1
- package/readme.md +6 -4
package/index.d.ts
CHANGED
|
@@ -43,13 +43,16 @@ export const toRelativeReference: (target: string, base: string) => string;
|
|
|
43
43
|
/** Map a parsed non-empty registered-name host to caller-owned text. */
|
|
44
44
|
export type RegNameMapper = (regName: string) => string;
|
|
45
45
|
|
|
46
|
-
/** Select
|
|
46
|
+
/** Select a target representation after parsed-result normalization. */
|
|
47
|
+
export type NormalizeTransform = 'URI' | 'IRI';
|
|
48
|
+
|
|
49
|
+
/** Select optional representation transformation and registered-name mapping. */
|
|
47
50
|
export type NormalizeOptions = {
|
|
48
|
-
|
|
51
|
+
transform?: NormalizeTransform;
|
|
49
52
|
mapRegName?: RegNameMapper;
|
|
50
53
|
};
|
|
51
54
|
|
|
52
|
-
/** Provide lazy RFC normalization and optional IRI
|
|
55
|
+
/** Provide lazy RFC normalization and optional URI/IRI transformation on a parsed result. */
|
|
53
56
|
export type NormalizableReference = {
|
|
54
57
|
normalize(options?: NormalizeOptions): string;
|
|
55
58
|
};
|
package/index.js
CHANGED
|
@@ -79,6 +79,12 @@ const iriRules = {
|
|
|
79
79
|
iprivate: '[\\uE000-\\uF8FF\\u{F0000}-\\u{FFFFD}\\u{100000}-\\u{10FFFD}]',
|
|
80
80
|
ucschar: '[\\xA0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF\\u{10000}-\\u{1FFFD}\\u{20000}-\\u{2FFFD}\\u{30000}-\\u{3FFFD}\\u{40000}-\\u{4FFFD}\\u{50000}-\\u{5FFFD}\\u{60000}-\\u{6FFFD}\\u{70000}-\\u{7FFFD}\\u{80000}-\\u{8FFFD}\\u{90000}-\\u{9FFFD}\\u{A0000}-\\u{AFFFD}\\u{B0000}-\\u{BFFFD}\\u{C0000}-\\u{CFFFD}\\u{D0000}-\\u{DFFFD}\\u{E1000}-\\u{EFFFD}]',
|
|
81
81
|
};
|
|
82
|
+
// Reuse the grammar repertoires when selecting URI octets safe for IRI output.
|
|
83
|
+
const uriUnreservedPattern = new RegExp(`^${commonRules.unreserved}$`);
|
|
84
|
+
const iriUcscharPattern = new RegExp(`^${iriRules.ucschar}$`, 'u');
|
|
85
|
+
const iriPrivatePattern = new RegExp(`^${iriRules.iprivate}$`, 'u');
|
|
86
|
+
// Apply the additional RFC 3987 Section 4.1 prose restriction outside the ABNF repertoire.
|
|
87
|
+
const forbiddenIriFormattingPattern = /^[\u200E\u200F\u202A-\u202E]$/u;
|
|
82
88
|
// scheme specific URI reg_name and IRI ireg_name
|
|
83
89
|
const schemeSpecificRules = {
|
|
84
90
|
scheme: implemented_schemes,
|
|
@@ -321,6 +327,10 @@ function isIPv4Address(host) {
|
|
|
321
327
|
}
|
|
322
328
|
return true;
|
|
323
329
|
}
|
|
330
|
+
// Identify ASCII octets through the URI grammar's canonical unreserved repertoire.
|
|
331
|
+
function isAsciiUnreservedOctet(octet) {
|
|
332
|
+
return uriUnreservedPattern.test(String.fromCharCode(octet));
|
|
333
|
+
}
|
|
324
334
|
// Normalize percent triplets while optionally decoding only ASCII unreserved octets.
|
|
325
335
|
function normalizePercentEncoding(value, decodeUnreserved = true) {
|
|
326
336
|
let result = '';
|
|
@@ -332,8 +342,7 @@ function normalizePercentEncoding(value, decodeUnreserved = true) {
|
|
|
332
342
|
}
|
|
333
343
|
const hexadecimal = value.slice(index + 1, index + 3);
|
|
334
344
|
const octet = Number.parseInt(hexadecimal, 16);
|
|
335
|
-
|
|
336
|
-
result += decodeUnreserved && unreserved ? String.fromCharCode(octet) : `%${hexadecimal.toUpperCase()}`;
|
|
345
|
+
result += decodeUnreserved && isAsciiUnreservedOctet(octet) ? String.fromCharCode(octet) : `%${hexadecimal.toUpperCase()}`;
|
|
337
346
|
index += 2;
|
|
338
347
|
}
|
|
339
348
|
return result;
|
|
@@ -459,6 +468,52 @@ function encodeIriComponent(component) {
|
|
|
459
468
|
// Process complete code points so supplementary characters produce one UTF-8 sequence.
|
|
460
469
|
return component.replace(/[^\x00-\x7F]/gu, (character) => encodeURIComponent(character).toUpperCase());
|
|
461
470
|
}
|
|
471
|
+
// Decode the maximal RFC 3987 URI octet repertoire allowed by one IRI component.
|
|
472
|
+
function decodeUriComponentToIri(component, allowPrivate = false) {
|
|
473
|
+
let result = '';
|
|
474
|
+
// Inspect each normalized percent triplet as either ASCII or the lead of one strict UTF-8 scalar.
|
|
475
|
+
for (let index = 0; index < component.length; index++) {
|
|
476
|
+
if (component[index] !== '%' || !/^[0-9A-F]{2}$/.test(component.slice(index + 1, index + 3))) {
|
|
477
|
+
result += component[index];
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const hexadecimal = component.slice(index + 1, index + 3);
|
|
481
|
+
const octet = Number.parseInt(hexadecimal, 16);
|
|
482
|
+
if (octet <= 0x7F) {
|
|
483
|
+
result += isAsciiUnreservedOctet(octet) ? String.fromCharCode(octet) : `%${hexadecimal}`;
|
|
484
|
+
index += 2;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const sequenceLength = octet >= 0xC2 && octet <= 0xDF ? 2 : octet >= 0xE0 && octet <= 0xEF ? 3 : octet >= 0xF0 && octet <= 0xF4 ? 4 : 0;
|
|
488
|
+
let encoded = '';
|
|
489
|
+
// Collect exactly one candidate scalar without consuming malformed trailing input.
|
|
490
|
+
for (let sequenceIndex = 0; sequenceIndex < sequenceLength; sequenceIndex++) {
|
|
491
|
+
const position = index + sequenceIndex * 3;
|
|
492
|
+
if (component[position] !== '%' || !/^[0-9A-F]{2}$/.test(component.slice(position + 1, position + 3))) {
|
|
493
|
+
encoded = '';
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
encoded += component.slice(position, position + 3);
|
|
497
|
+
}
|
|
498
|
+
let character;
|
|
499
|
+
if (encoded) {
|
|
500
|
+
try {
|
|
501
|
+
character = decodeURIComponent(encoded);
|
|
502
|
+
} catch {
|
|
503
|
+
character = undefined;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const allowed = character !== undefined && !forbiddenIriFormattingPattern.test(character) && (iriUcscharPattern.test(character) || (allowPrivate && iriPrivatePattern.test(character)));
|
|
507
|
+
if (allowed) {
|
|
508
|
+
result += character;
|
|
509
|
+
index += encoded.length - 1;
|
|
510
|
+
} else {
|
|
511
|
+
result += `%${hexadecimal}`;
|
|
512
|
+
index += 2;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
462
517
|
// Rebuild authority from normalized values while preserving other empty component delimiters.
|
|
463
518
|
function normalizeAuthority(parts, scheme, mapRegName) {
|
|
464
519
|
if (parts.authority === undefined) return undefined;
|
|
@@ -475,8 +530,8 @@ function normalizeAuthority(parts, scheme, mapRegName) {
|
|
|
475
530
|
function normalizeParsedReference(parts, options = {}) {
|
|
476
531
|
// Validate the optional API settings before they select normalization behavior.
|
|
477
532
|
if (options === null || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Invalid normalization argument type: must be an options object.');
|
|
478
|
-
const {
|
|
479
|
-
if (
|
|
533
|
+
const { transform, mapRegName } = options;
|
|
534
|
+
if (transform !== undefined && transform !== 'URI' && transform !== 'IRI') throw new TypeError('Invalid transform option: must be "URI" or "IRI".');
|
|
480
535
|
if (mapRegName !== undefined && typeof mapRegName !== 'function') throw new TypeError('Invalid registered-name mapper type: must be a function.');
|
|
481
536
|
// Normalize each component independently so encoded delimiters cannot become structure.
|
|
482
537
|
const scheme = parts.scheme === undefined ? undefined : parts.scheme.toLowerCase();
|
|
@@ -496,12 +551,20 @@ function normalizeParsedReference(parts, options = {}) {
|
|
|
496
551
|
// Preserve a no-authority path when reduction would reparse it as an authority.
|
|
497
552
|
if (normalized.authority !== undefined || !reducedPath.startsWith('//')) normalized.path = reducedPath;
|
|
498
553
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
554
|
+
// Select an explicit target representation only after syntax and scheme normalization is complete.
|
|
555
|
+
if (transform === 'URI') {
|
|
556
|
+
// Map every non-ASCII authority, path, query, and fragment scalar under RFC 3987 URI output.
|
|
557
|
+
if (normalized.authority !== undefined) normalized.authority = encodeIriComponent(normalized.authority);
|
|
558
|
+
normalized.path = encodeIriComponent(normalized.path);
|
|
559
|
+
if (normalized.query !== undefined) normalized.query = encodeIriComponent(normalized.query);
|
|
560
|
+
if (normalized.fragment !== undefined) normalized.fragment = encodeIriComponent(normalized.fragment);
|
|
561
|
+
} else if (transform === 'IRI') {
|
|
562
|
+
// Decode valid UTF-8 percent sequences only where the destination component permits their scalar.
|
|
563
|
+
if (normalized.authority !== undefined) normalized.authority = decodeUriComponentToIri(normalized.authority);
|
|
564
|
+
normalized.path = decodeUriComponentToIri(normalized.path);
|
|
565
|
+
if (normalized.query !== undefined) normalized.query = decodeUriComponentToIri(normalized.query, true);
|
|
566
|
+
if (normalized.fragment !== undefined) normalized.fragment = decodeUriComponentToIri(normalized.fragment);
|
|
567
|
+
}
|
|
505
568
|
return compose(normalized);
|
|
506
569
|
}
|
|
507
570
|
// export
|
package/normalization.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# URI and IRI normalization
|
|
2
2
|
|
|
3
|
-
Parsed URI and IRI results expose `normalize()` for syntax-based normalization, the implemented HTTP, HTTPS, WS, and WSS scheme rules, and optional RFC 3987 IRI
|
|
3
|
+
Parsed URI and IRI results expose `normalize()` for syntax-based normalization, the implemented HTTP, HTTPS, WS, and WSS scheme rules, and optional RFC 3987 URI/IRI representation transformation. The method returns a string and leaves the parsed components unchanged.
|
|
4
4
|
|
|
5
5
|
## API
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
type RegNameMapper = (regName: string) => string
|
|
9
9
|
|
|
10
|
+
type NormalizeTransform = 'URI' | 'IRI'
|
|
11
|
+
|
|
10
12
|
type NormalizeOptions = {
|
|
11
|
-
|
|
13
|
+
transform?: NormalizeTransform
|
|
12
14
|
mapRegName?: RegNameMapper
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -51,7 +53,8 @@ parsed.path;
|
|
|
51
53
|
| Path segments | Apply the RFC dot-segment algorithm where the parsed reference can be normalized independently. Preserve unresolved rootless-relative path semantics. | [RFC 3986 §§5.2.4 and 6.2.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4), [RFC 3987 §5.3.2.4](https://www.rfc-editor.org/rfc/rfc3987#section-5.3.2.4) |
|
|
52
54
|
| Component recomposition | Emit authority, query, and fragment delimiters from component presence, including present-empty components. | [RFC 3986 §5.3](https://www.rfc-editor.org/rfc/rfc3986#section-5.3) |
|
|
53
55
|
| IPv6 text | Suppress leading zeroes, compress the longest zero run with first-run tie breaking, and use lowercase hexadecimal. Known embedded-IPv4 forms use mixed notation. | [RFC 5952 §§4–5](https://www.rfc-editor.org/rfc/rfc5952#section-4) |
|
|
54
|
-
| IRI-to-URI output | With `
|
|
56
|
+
| IRI-to-URI output | With `transform: 'URI'`, encode non-ASCII authority, path, query, and fragment characters as uppercase UTF-8 percent triplets. | [RFC 3987 §3.1](https://www.rfc-editor.org/rfc/rfc3987#section-3.1) |
|
|
57
|
+
| URI-to-IRI output | With `transform: 'IRI'`, decode percent-encoded ASCII unreserved characters and strictly legal UTF-8 sequences permitted in each destination component. Retain reserved, malformed, disallowed, and non-UTF-8 octets. | [RFC 3987 §3.2](https://www.rfc-editor.org/rfc/rfc3987#section-3.2) |
|
|
55
58
|
|
|
56
59
|
Without a mapper, normalization retains the parser's host classification as an IP literal, IPv4 address, or registered name. IPvFuture literals use generic host case normalization. Existing non-ASCII IRI host text is retained unless the registered-name mapper supplies another value.
|
|
57
60
|
|
|
@@ -91,7 +94,7 @@ wss://example.com:00443/chat → wss://example.com/chat
|
|
|
91
94
|
ws://example.com?channel=updates → ws://example.com/?channel=updates
|
|
92
95
|
```
|
|
93
96
|
|
|
94
|
-
## Registered-name mapping and
|
|
97
|
+
## Registered-name mapping and representation transformation
|
|
95
98
|
|
|
96
99
|
For a non-empty registered-name host, `options.mapRegName` is called once with the current host spelling before built-in normalization. IP literals, IPv4 addresses, absent hosts, and empty hosts bypass the mapper.
|
|
97
100
|
|
|
@@ -108,18 +111,27 @@ mapped;
|
|
|
108
111
|
|
|
109
112
|
The example deliberately produces text that is not a valid URI or IRI; validating or selecting mapper output belongs to the application.
|
|
110
113
|
|
|
111
|
-
With `
|
|
114
|
+
With `transform: 'URI'`, retained reserved and non-ASCII percent triplets remain encoded, and literal non-ASCII userinfo, mapper output, path, query, and fragment text becomes uppercase UTF-8 percent triplets. A mapper can supply an ASCII hostname when its consuming scheme requires one; this package does not validate mapper output against that scheme.
|
|
112
115
|
|
|
113
116
|
```js
|
|
114
|
-
const { parseIri } = require('identifier-js');
|
|
117
|
+
const { parseIri, parseUri } = require('identifier-js');
|
|
115
118
|
|
|
116
|
-
parseIri('x:/café?q=資料#résultat').normalize({
|
|
119
|
+
parseIri('x:/café?q=資料#résultat').normalize({ transform: 'URI' });
|
|
117
120
|
// x:/caf%C3%A9?q=%E8%B3%87%E6%96%99#r%C3%A9sultat
|
|
121
|
+
|
|
122
|
+
parseUri('x:/caf%C3%A9?q=%E8%B3%87%E6%96%99#r%C3%A9sultat').normalize({ transform: 'IRI' });
|
|
123
|
+
// x:/café?q=資料#résultat
|
|
118
124
|
```
|
|
119
125
|
|
|
126
|
+
With `transform: 'IRI'`, conversion uses UTF-8 exclusively and decodes as many eligible percent-encoded characters as possible. Encoded reserved characters, `%25`, malformed or incomplete UTF-8, legacy character encodings, Unicode outside the RFC 3987 component repertoire, and forbidden bidirectional formatting characters remain percent encoded. Private-use characters are decoded only in the query component. The hexadecimal letters of retained triplets are uppercase.
|
|
127
|
+
|
|
128
|
+
The IRI transformation decodes percent-encoded ASCII unreserved characters even when this changes a registered name into IPv4-looking text. Without an explicit transformation, normalization preserves that registered-name host classification.
|
|
129
|
+
|
|
130
|
+
ACE-to-Unicode and Unicode-to-ACE registered-name conversion remain application policy. `mapRegName` runs before the selected representation transformation, so applications can provide the appropriate mapping in either direction.
|
|
131
|
+
|
|
120
132
|
## Verification
|
|
121
133
|
|
|
122
|
-
The normalization suite covers URI and IRI parser results, component presence, percent triplets, dot segments, host kinds, RFC 5952 output, HTTP and WebSocket scheme rules, registered-name mapping,
|
|
134
|
+
The normalization suite covers URI and IRI parser results, component presence, percent triplets, dot segments, host kinds, RFC 5952 output, HTTP and WebSocket scheme rules, registered-name mapping, both RFC representation transformations, malformed UTF-8 retention, component-specific Unicode repertoires, component non-mutation, round trips, and idempotence.
|
|
123
135
|
|
|
124
136
|
```sh
|
|
125
137
|
npm test
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -213,8 +213,10 @@ Every URI and IRI parse result provides an optional, non-enumerable `normalize()
|
|
|
213
213
|
```ts
|
|
214
214
|
type RegNameMapper = (regName: string) => string
|
|
215
215
|
|
|
216
|
+
type NormalizeTransform = 'URI' | 'IRI'
|
|
217
|
+
|
|
216
218
|
type NormalizeOptions = {
|
|
217
|
-
|
|
219
|
+
transform?: NormalizeTransform
|
|
218
220
|
mapRegName?: RegNameMapper
|
|
219
221
|
}
|
|
220
222
|
|
|
@@ -239,7 +241,7 @@ Normalization implements RFC 3986 and RFC 3987 syntax normalization for scheme a
|
|
|
239
241
|
|
|
240
242
|
For a non-empty registered-name host, `mapRegName` receives the current host spelling before built-in normalization. The mapper exclusively owns validation, representation, and host-kind policy for its returned string. Apart from enforcing the declared string return type, this package does not check whether mapper output is non-empty, remains a registered name, introduces delimiters, resembles an IP address, or satisfies a scheme-specific hostname grammar.
|
|
241
243
|
|
|
242
|
-
With `
|
|
244
|
+
With `transform: 'URI'`, non-ASCII userinfo, mapper output, path, query, and fragment text becomes uppercase UTF-8 percent triplets under RFC 3987 §3.1. With `transform: 'IRI'`, eligible percent-encoded ASCII unreserved characters and strictly legal UTF-8 sequences become IRI characters under RFC 3987 §3.2; reserved, malformed, disallowed, and non-UTF-8 octets remain encoded. Private-use characters are decoded only in queries, and forbidden bidirectional formatting characters remain encoded. A mapper can supply the desired Unicode or ASCII hostname representation; this package does not enforce that policy or validate the complete normalized result.
|
|
243
245
|
|
|
244
246
|
See [`normalization.md`](normalization.md) for the exact RFC section mapping and examples.
|
|
245
247
|
|
|
@@ -433,7 +435,7 @@ RFC 9562 lists database keys, filenames, system identifiers, and transaction ide
|
|
|
433
435
|
- `strict = false` implements RFC 3986 §5.2.2 backward-compatible same-scheme handling.
|
|
434
436
|
- `toAbsoluteReference` removes the fragment from an identifier containing a scheme.
|
|
435
437
|
- `toRelativeReference` generates a reference whose RFC resolution equals the target resolution for supported forms.
|
|
436
|
-
- `normalize()` implements the applicable case, percent-encoding, and path-segment rules from RFC 3986 §§6.2.2.1–6.2.2.3 and RFC 3987 §§5.3.2.1, 5.3.2.3–5.3.2.4, RFC 3987
|
|
438
|
+
- `normalize()` implements the applicable case, percent-encoding, and path-segment rules from RFC 3986 §§6.2.2.1–6.2.2.3 and RFC 3987 §§5.3.2.1, 5.3.2.3–5.3.2.4, RFC 3987 §§3.1–3.2 URI/IRI representation transformation, RFC 5952 IPv6 text, RFC 9110 HTTP(S) port/path forms, and RFC 6455 WS(S) port/resource-name forms.
|
|
437
439
|
|
|
438
440
|
</details>
|
|
439
441
|
|
|
@@ -473,7 +475,7 @@ Run `gh workspace-data load` again to refresh materialized data after public-dat
|
|
|
473
475
|
|
|
474
476
|
### Tests
|
|
475
477
|
|
|
476
|
-
The active suite contains 3,
|
|
478
|
+
The active suite contains 3,137 tests covering URI/IRI validation, parsing, generic normalization, bidirectional URI/IRI representation transformation, scheme-specific hosts, IPv4, IPv6, IPvFuture, ports, UUIDs, RFC 3986 resolution examples, empty components, absolute conversion, and relative-reference round trips, including 2,646 generated combinations of target/base paths, query-presence states, and target-fragment states across equivalent URI and IRI families.
|
|
477
479
|
|
|
478
480
|
<details>
|
|
479
481
|
<summary><strong>Test details</strong></summary>
|