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 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 optional URI output and registered-name mapping for parsed-result normalization. */
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
- toUri?: boolean;
51
+ transform?: NormalizeTransform;
49
52
  mapRegName?: RegNameMapper;
50
53
  };
51
54
 
52
- /** Provide lazy RFC normalization and optional IRI-to-URI output on a parsed result. */
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
- const unreserved = (octet >= 0x41 && octet <= 0x5A) || (octet >= 0x61 && octet <= 0x7A) || (octet >= 0x30 && octet <= 0x39) || octet === 0x2D || octet === 0x2E || octet === 0x5F || octet === 0x7E;
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 { toUri = false, mapRegName } = options;
479
- if (typeof toUri !== 'boolean') throw new TypeError('Invalid toUri option type: must be a boolean.');
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
- if (!toUri) return compose(normalized);
500
- // Map every non-ASCII authority, path, query, and fragment scalar under RFC 3987 URI output.
501
- if (normalized.authority !== undefined) normalized.authority = encodeIriComponent(normalized.authority);
502
- normalized.path = encodeIriComponent(normalized.path);
503
- if (normalized.query !== undefined) normalized.query = encodeIriComponent(normalized.query);
504
- if (normalized.fragment !== undefined) normalized.fragment = encodeIriComponent(normalized.fragment);
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-to-URI output. The method returns a string and leaves the parsed components unchanged.
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
- toUri?: boolean
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 `toUri: true`, 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) |
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 URI output
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 `toUri: true`, existing percent triplets remain encoded, and 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.
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({ toUri: true });
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, IRI-to-URI output, component non-mutation, and idempotence.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "identifier-js",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A fast URI/IRI parser, validator, normalizer, resolver, and composer based on RFC 3986 and RFC 3987.",
5
5
  "keywords": [
6
6
  "IRI",
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
- toUri?: boolean
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 `toUri: true`, non-ASCII userinfo, mapper output, path, query, and fragment text becomes uppercase UTF-8 percent triplets under RFC 3987 §3.1. A mapper can supply an ASCII hostname when its consuming scheme requires one; this package does not enforce that requirement or validate the complete normalized result.
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 §3.1 IRI-to-URI output, RFC 5952 IPv6 text, RFC 9110 HTTP(S) port/path forms, and RFC 6455 WS(S) port/resource-name forms.
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,130 tests covering URI/IRI validation, parsing, generic normalization, 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.
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>