identifier-js 0.1.4 → 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
@@ -1,3 +1,4 @@
1
+ // Declare the public validation, parsing, resolution, and normalization API.
1
2
  /** @throws {Error} If the UUID is invalid. */
2
3
  export const isUUID: (string: string) => true;
3
4
  /** @throws {Error} If the UUID-v4 is invalid. */
@@ -11,11 +12,11 @@ export const isUriReference: (uriReference: string) => true;
11
12
  export const isAbsoluteUri: (uri: string) => true;
12
13
 
13
14
  /** @throws {Error} If the URI is invalid. */
14
- export const parseUri: (uri: string) => IdentifierComponents;
15
+ export const parseUri: (uri: string) => ParsedIdentifierComponents;
15
16
  /** @throws {Error} If the URI-reference is invalid. */
16
- export const parseUriReference: (uriReference: string) => RelativeIdentifierComponents;
17
+ export const parseUriReference: (uriReference: string) => ParsedRelativeIdentifierComponents;
17
18
  /** @throws {Error} If the absolute-URI is invalid. */
18
- export const parseAbsoluteUri: (uri: string) => AbsoluteIdentifierComponents;
19
+ export const parseAbsoluteUri: (uri: string) => ParsedAbsoluteIdentifierComponents;
19
20
 
20
21
  /** @throws {Error} If the IRI is invalid. */
21
22
  export const isIri: (iri: string) => true;
@@ -25,33 +26,50 @@ export const isIriReference: (iriReference: string) => true;
25
26
  export const isAbsoluteIri: (iri: string) => true;
26
27
 
27
28
  /** @throws {Error} If the IRI is invalid. */
28
- export const parseIri: (iri: string) => IdentifierComponents;
29
+ export const parseIri: (iri: string) => ParsedIdentifierComponents;
29
30
  /** @throws {Error} If the IRI-reference is invalid. */
30
- export const parseIriReference: (iriReference: string) => RelativeIdentifierComponents;
31
+ export const parseIriReference: (iriReference: string) => ParsedRelativeIdentifierComponents;
31
32
  /** @throws {Error} If the absolute-IRI is invalid. */
32
- export const parseAbsoluteIri: (iri: string) => AbsoluteIdentifierComponents;
33
-
34
- /** @throws {Error} If the reference is invalid. */
35
- export const normalizeReference: (reference: string) => string;
33
+ export const parseAbsoluteIri: (iri: string) => ParsedAbsoluteIdentifierComponents;
36
34
  /** @throws {Error} If the base or the reference is invalid. */
37
- export const resolveReference: (reference: string, base: string, strict?: boolean, returnParts?: boolean) => string;
35
+ export function resolveReference(reference: string, base: string, strict?: boolean, returnParts?: false): string;
36
+ export function resolveReference(reference: string, base: string, strict: boolean | undefined, returnParts: true): IdentifierComponents;
37
+ export function resolveReference(reference: string, base: string, strict: boolean | undefined, returnParts: boolean | undefined): string | IdentifierComponents;
38
38
  /** @throws {Error} If the reference is invalid. */
39
39
  export const toAbsoluteReference: (reference: string) => string;
40
40
  /** @throws {Error} If the base or the reference is invalid. */
41
41
  export const toRelativeReference: (target: string, base: string) => string;
42
42
 
43
- type IdentifierComponents = {
43
+ /** Map a parsed non-empty registered-name host to caller-owned text. */
44
+ export type RegNameMapper = (regName: string) => string;
45
+
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. */
50
+ export type NormalizeOptions = {
51
+ transform?: NormalizeTransform;
52
+ mapRegName?: RegNameMapper;
53
+ };
54
+
55
+ /** Provide lazy RFC normalization and optional URI/IRI transformation on a parsed result. */
56
+ export type NormalizableReference = {
57
+ normalize(options?: NormalizeOptions): string;
58
+ };
59
+
60
+ // Describe component presence for complete, relative, and fragment-free absolute identifiers.
61
+ export type IdentifierComponents = {
44
62
  scheme: string;
45
- authority: string;
63
+ authority?: string;
46
64
  userinfo?: string;
47
- host: string;
65
+ host?: string;
48
66
  port?: string;
49
67
  path: string;
50
68
  query?: string;
51
69
  fragment?: string;
52
70
  };
53
71
 
54
- type RelativeIdentifierComponents = {
72
+ export type RelativeIdentifierComponents = {
55
73
  scheme?: string;
56
74
  authority?: string;
57
75
  userinfo?: string;
@@ -62,12 +80,16 @@ type RelativeIdentifierComponents = {
62
80
  fragment?: string;
63
81
  };
64
82
 
65
- type AbsoluteIdentifierComponents = {
83
+ export type AbsoluteIdentifierComponents = {
66
84
  scheme: string;
67
- authority: string;
85
+ authority?: string;
68
86
  userinfo?: string;
69
- host: string;
87
+ host?: string;
70
88
  port?: string;
71
89
  path: string;
72
90
  query?: string;
73
91
  };
92
+
93
+ export type ParsedIdentifierComponents = IdentifierComponents & NormalizableReference;
94
+ export type ParsedRelativeIdentifierComponents = RelativeIdentifierComponents & NormalizableReference;
95
+ export type ParsedAbsoluteIdentifierComponents = AbsoluteIdentifierComponents & NormalizableReference;
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ // Parse, validate, normalize, resolve, and convert RFC 3986 URI and RFC 3987 IRI references.
2
3
  // a valid URI is always a valid IRI
3
4
  const { recursiveCompile } = require('url-templates');
4
5
  const patterns = new Map();
@@ -7,14 +8,14 @@ const implemented_schemes = '(?:[hH][tT][tT][pP][sS]?|[wW][sS][sS]?|[fF][iI][lL]
7
8
  const commonRules = {
8
9
  implemented_schemes,
9
10
  scheme: '(?!{implemented_schemes}:)[a-zA-Z][a-zA-Z0-9+.-]*',
10
- port: '(?:0|[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])?',
11
+ port: '\\d*',
11
12
  IP_literal: '\\[(?:{IPv6address}|{IPvFuture})\\]',
12
13
  IPv6address: '(?:(?:{h16}:){6}{ls32}|::(?:{h16}:){5}{ls32}|(?:(?:{h16})?)::(?:{h16}:){4}{ls32}|(?:(?:{h16}:)?{h16})?::(?:{h16}:){3}{ls32}|(?:(?:{h16}:){0,2}{h16})?::(?:{h16}:){2}{ls32}|(?:(?:{h16}:){0,3}{h16})?::(?:{h16}:){1}{ls32}|(?:(?:{h16}:){0,4}{h16})?::{ls32}|(?:(?:{h16}:){0,5}{h16})?::{h16}|(?:(?:{h16}:){0,6}{h16})?::)',
13
14
  ls32: '(?:{h16}:{h16}|{IPv4address})',
14
15
  h16: '{hex_digit}{1,4}',
15
16
  IPv4address: '(?:{dec_octet}\\.){3}{dec_octet}',
16
17
  dec_octet: '(?:\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])',
17
- IPvFuture: 'v{hex_digit}+\\.(?:{unreserved}|{sub_delims}|:)+',
18
+ IPvFuture: '[vV]{hex_digit}+\\.(?:{unreserved}|{sub_delims}|:)+',
18
19
  unreserved: '[a-zA-Z0-9_.~-]',
19
20
  reserved: '(?:{gen_delims}|{sub_delims})',
20
21
  pct_encoded: '%{hex_digit}{2}',
@@ -78,6 +79,12 @@ const iriRules = {
78
79
  iprivate: '[\\uE000-\\uF8FF\\u{F0000}-\\u{FFFFD}\\u{100000}-\\u{10FFFD}]',
79
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}]',
80
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;
81
88
  // scheme specific URI reg_name and IRI ireg_name
82
89
  const schemeSpecificRules = {
83
90
  scheme: implemented_schemes,
@@ -88,6 +95,12 @@ const schemeSpecificRules = {
88
95
  u_separator: '[\\x2E\\uFF0E\\u3002\\uFF61]',
89
96
  u_char: '[\\p{L}\\p{N}\\p{Mn}\\p{Mc}\\u200C\\u200D\\u00B7\\u0375\\u30FB\\u05F3\\u05F4]',
90
97
  };
98
+ // Recognize RFC 8089's empty file authority without weakening other scheme host policies.
99
+ const emptyFileHostRules = Object.assign({}, schemeSpecificRules, {
100
+ scheme: '[fF][iI][lL][eE]',
101
+ reg_name: '',
102
+ ireg_name: '',
103
+ });
91
104
  // pattern RFC group names
92
105
  const groupNames = {
93
106
  scheme: 'scheme',
@@ -113,26 +126,35 @@ const groupNames = {
113
126
  ipath_rootless: 'path',
114
127
  ipath_empty: 'path',
115
128
  };
116
- // all rules into one map
129
+ // Select and merge generic, DNS-host, or empty-file-host grammar overrides.
117
130
  const isSpecificScheme = (string) => new RegExp('^' + implemented_schemes + ':').test(string);
118
- const rules = (specific) => (specific ? Object.assign({}, commonRules, uriRules, iriRules, schemeSpecificRules) : Object.assign({}, commonRules, uriRules, iriRules));
131
+ const schemeProfile = (string) => (string.slice(0, 8).toLowerCase() === 'file:///' ? 'f' : isSpecificScheme(string) ? 's' : '');
132
+ const rules = (profile) => Object.assign({}, commonRules, uriRules, iriRules, profile === 'f' ? emptyFileHostRules : profile ? schemeSpecificRules : {});
119
133
  // parse (slower, it uses regex.exec and includes named capture groups)
120
134
  const parse = (string, rule) => {
121
135
  if (typeof string !== 'string') throw new TypeError(`Invalid ${rule.replace('_', '-')} type: must be a string.`);
122
- const specific = isSpecificScheme(string) ? 's' : '';
123
- const addNames = (key) => (groupNames[key] ? `(?<${groupNames[key]}>${rules(specific)[key]})` : rules(specific)[key]);
124
- const ruleId = '_' + specific + rule;
125
- if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(specific), rule, addNames)}$`, 'u'));
136
+ const profile = schemeProfile(string);
137
+ const addNames = (key) => (groupNames[key] ? `(?<${groupNames[key]}>${rules(profile)[key]})` : rules(profile)[key]);
138
+ const ruleId = '_' + profile + rule;
139
+ if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(profile), rule, addNames)}$`, 'u'));
126
140
  const match = patterns.get(ruleId).exec(string);
127
- if (match) return match.groups;
141
+ if (match) {
142
+ Object.defineProperty(match.groups, 'normalize', {
143
+ // Normalize this parsed result only when its optional method is called.
144
+ value: function normalize(options) {
145
+ return normalizeParsedReference(this, options);
146
+ },
147
+ });
148
+ return match.groups;
149
+ }
128
150
  throw new SyntaxError(`Invalid ${rule.replace('_', '-')}: ${string}`);
129
151
  };
130
152
  // validate (faster, it uses regex.test and does not include named capture groups)
131
153
  const validate = (string, rule) => {
132
154
  if (typeof string !== 'string') throw new TypeError(`Invalid ${rule.replace('_', '-')} type: must be a string.`);
133
- const specific = isSpecificScheme(string) ? 's' : '';
134
- const ruleId = specific + rule;
135
- if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(specific), rule)}$`, 'u'));
155
+ const profile = schemeProfile(string);
156
+ const ruleId = profile + rule;
157
+ if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(profile), rule)}$`, 'u'));
136
158
  if (patterns.get(ruleId).test(string)) return true;
137
159
  throw new SyntaxError(`Invalid ${rule.replace('_', '-')}: ${string}`);
138
160
  };
@@ -149,7 +171,7 @@ function compose(parts = {}) {
149
171
  // remove dot segments algorithm per RFC 3986 Section 5.2.4 (loop and replace)
150
172
  function removeDotSegments(path) {
151
173
  const output = [];
152
- let input = path;
174
+ let input = path ?? '';
153
175
  while (input.length > 0) {
154
176
  if (input.startsWith('../')) input = input.slice(3);
155
177
  else if (input.startsWith('./')) input = input.slice(2);
@@ -209,27 +231,28 @@ function resolveReference(reference, base, strict = true, parts = false) {
209
231
  }
210
232
 
211
233
  let T;
212
- if (R.scheme && (strict || R.scheme !== B.scheme)) {
234
+ if (R.scheme && (strict || R.scheme.toLowerCase() !== B.scheme.toLowerCase())) {
213
235
  T = R;
236
+ T.path = removeDotSegments(R.path);
214
237
  } else {
215
238
  T = {};
216
239
  T.scheme = B.scheme;
217
240
  if (R.authority !== undefined && R.authority !== null) {
218
241
  T.authority = R.authority;
219
- T.path = R.path;
242
+ T.path = removeDotSegments(R.path);
220
243
  T.query = R.query;
221
244
  } else {
222
245
  T.authority = B.authority;
223
246
  if (R.path && R.path.length > 0) {
224
247
  if (R.path.startsWith('/')) {
225
- T.path = R.path;
248
+ T.path = removeDotSegments(R.path);
226
249
  } else if (B.authority !== undefined && B.authority !== null && (!B.path || B.path.length === 0)) {
227
- T.path = '/' + R.path;
250
+ T.path = removeDotSegments('/' + R.path);
228
251
  } else {
229
- // merge base path and ref path
252
+ // Merge the base directory and reference path before removing complete dot segments.
230
253
  const idx = B.path ? B.path.lastIndexOf('/') : -1;
231
254
  const prefix = idx !== -1 ? B.path.slice(0, idx + 1) : '';
232
- T.path = prefix + R.path;
255
+ T.path = removeDotSegments(prefix + R.path);
233
256
  }
234
257
  T.query = R.query;
235
258
  } else {
@@ -240,14 +263,21 @@ function resolveReference(reference, base, strict = true, parts = false) {
240
263
  }
241
264
  T.fragment = R.fragment;
242
265
  }
243
- T.path = removeDotSegments(T.path || '');
244
266
  if (parts) return T;
245
267
  return compose(T);
246
268
  }
247
- // reverse logic for resolve
269
+ // Convert a complete IRI to fragment-free form without changing its other components.
270
+ function toAbsoluteReference(string) {
271
+ const result = parse(string, 'IRI');
272
+ result.fragment = undefined;
273
+ return compose(result);
274
+ }
275
+ // Generate a relative reference when resolution is stable, otherwise retain the absolute target.
248
276
  const toRelativeReference = (target, base) => {
249
277
  const B = parse(base, 'absolute_IRI');
250
278
  const T = parse(target, 'IRI');
279
+ // Use the absolute target when dot-segment processing makes lexical relative round trips unstable.
280
+ if (/(?:^|\/)\.{1,2}(?=\/|$)/.test(T.path) || /(?:^|\/)\.{1,2}(?=\/|$)/.test(B.path)) return target;
251
281
  if (T.scheme !== B.scheme || T.authority !== B.authority) return target;
252
282
  let result;
253
283
  if (B.path === T.path) {
@@ -287,6 +317,256 @@ const toRelativeReference = (target, base) => {
287
317
  if (T.authority === undefined && !T.path.startsWith('/') && result.startsWith('..')) return target;
288
318
  return result;
289
319
  };
320
+ // Identify RFC 3986 IPv4 literals without misclassifying numeric registered names.
321
+ function isIPv4Address(host) {
322
+ const octets = host.split('.');
323
+ if (octets.length !== 4) return false;
324
+ // Require the parser's decimal-octet spelling and numeric range for every address part.
325
+ for (const octet of octets) {
326
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(octet) || Number(octet) > 255) return false;
327
+ }
328
+ return true;
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
+ }
334
+ // Normalize percent triplets while optionally decoding only ASCII unreserved octets.
335
+ function normalizePercentEncoding(value, decodeUnreserved = true) {
336
+ let result = '';
337
+ // Preserve literal Unicode while processing each already-validated percent triplet atomically.
338
+ for (let index = 0; index < value.length; index++) {
339
+ if (value[index] !== '%' || !/^[0-9A-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) {
340
+ result += value[index];
341
+ continue;
342
+ }
343
+ const hexadecimal = value.slice(index + 1, index + 3);
344
+ const octet = Number.parseInt(hexadecimal, 16);
345
+ result += decodeUnreserved && isAsciiUnreservedOctet(octet) ? String.fromCharCode(octet) : `%${hexadecimal.toUpperCase()}`;
346
+ index += 2;
347
+ }
348
+ return result;
349
+ }
350
+ // Expand any accepted IPv6 spelling into eight numeric 16-bit fields.
351
+ function parseIPv6Words(address) {
352
+ let expanded = address;
353
+ // Convert a dotted-decimal tail to the same two-field representation used by every later step.
354
+ const lastColon = expanded.lastIndexOf(':');
355
+ const lastSegment = expanded.slice(lastColon + 1);
356
+ if (lastSegment.includes('.')) {
357
+ const octets = lastSegment.split('.');
358
+ const high = Number(octets[0]) * 0x100 + Number(octets[1]);
359
+ const low = Number(octets[2]) * 0x100 + Number(octets[3]);
360
+ expanded = `${expanded.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
361
+ }
362
+
363
+ const compression = expanded.indexOf('::');
364
+ const leftText = compression === -1 ? expanded : expanded.slice(0, compression);
365
+ const rightText = compression === -1 ? '' : expanded.slice(compression + 2);
366
+ const left = leftText ? leftText.split(':') : [];
367
+ const right = rightText ? rightText.split(':') : [];
368
+ const words = [];
369
+ // Retain every explicit field before the compressed zero run.
370
+ for (const field of left) words.push(Number.parseInt(field, 16));
371
+ // Expand the single compression marker to the required number of zero fields.
372
+ if (compression !== -1) {
373
+ // Fill the omitted field count determined from both explicit sides.
374
+ for (let index = left.length + right.length; index < 8; index++) words.push(0);
375
+ }
376
+ // Retain every explicit field after the compressed zero run.
377
+ for (const field of right) words.push(Number.parseInt(field, 16));
378
+ return words;
379
+ }
380
+ // Serialize IPv6 fields with maximal first-run zero compression and lowercase digits.
381
+ function serializeIPv6Words(words) {
382
+ let bestStart = -1;
383
+ let bestLength = 0;
384
+ // Select the first longest run containing at least two zero fields.
385
+ for (let start = 0; start < words.length;) {
386
+ if (words[start] !== 0) {
387
+ start++;
388
+ continue;
389
+ }
390
+ let end = start;
391
+ // Measure this complete zero run before comparing it with the retained candidate.
392
+ while (end < words.length && words[end] === 0) end++;
393
+ if (end - start > bestLength) {
394
+ bestStart = start;
395
+ bestLength = end - start;
396
+ }
397
+ start = end;
398
+ }
399
+ if (bestLength < 2) bestStart = -1;
400
+
401
+ const fields = [];
402
+ // Suppress every leading zero by converting each field through its numeric value.
403
+ for (const word of words) fields.push(word.toString(16));
404
+ if (bestStart === -1) return fields.join(':');
405
+ const before = fields.slice(0, bestStart).join(':');
406
+ const after = fields.slice(bestStart + bestLength).join(':');
407
+ return `${before}::${after}`;
408
+ }
409
+ // Canonicalize an IPv6 address under RFC 5952, including known embedded-IPv4 prefixes.
410
+ function normalizeIPv6Address(address) {
411
+ const words = parseIPv6Words(address);
412
+ // Detect standardized prefixes that identify an embedded IPv4 address from address bits alone.
413
+ const low32 = words[6] * 0x10000 + words[7];
414
+ const compatible = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0 && low32 > 1;
415
+ const mapped = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0xFFFF;
416
+ const translated = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0xFFFF && words[5] === 0;
417
+ const nat64 = words[0] === 0x64 && words[1] === 0xFF9B && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0;
418
+ if (compatible || mapped || translated || nat64) {
419
+ const prefix = serializeIPv6Words(words.slice(0, 6));
420
+ const ipv4 = `${words[6] >>> 8}.${words[6] & 0xFF}.${words[7] >>> 8}.${words[7] & 0xFF}`;
421
+ return prefix.endsWith(':') ? prefix + ipv4 : `${prefix}:${ipv4}`;
422
+ }
423
+ return serializeIPv6Words(words);
424
+ }
425
+ // Lowercase an ASCII host without changing uppercase hexadecimal in retained percent triplets.
426
+ function lowercaseAsciiHost(host) {
427
+ let result = '';
428
+ // Treat each retained percent triplet as an indivisible token during host case folding.
429
+ for (let index = 0; index < host.length; index++) {
430
+ if (host[index] === '%' && /^[0-9A-F]{2}$/.test(host.slice(index + 1, index + 3))) {
431
+ result += host.slice(index, index + 3);
432
+ index += 2;
433
+ } else result += host[index].toLowerCase();
434
+ }
435
+ return result;
436
+ }
437
+ // Normalize a parsed host according to its IP-literal, IPv4, or registered-name kind.
438
+ function normalizeHost(host, mapRegName) {
439
+ if (!host) return host;
440
+ // Keep IP hosts outside application registered-name policy.
441
+ if (host.startsWith('[')) {
442
+ const address = host.slice(1, -1);
443
+ return address[0].toLowerCase() === 'v' ? `[${address.toLowerCase()}]` : `[${normalizeIPv6Address(address)}]`;
444
+ }
445
+ if (isIPv4Address(host)) return host;
446
+
447
+ let result = host;
448
+ // Delegate registered-name representation to the mapper before built-in text normalization.
449
+ if (mapRegName) {
450
+ result = mapRegName(host);
451
+ if (typeof result !== 'string') throw new TypeError('Invalid registered-name mapper result: must be a string.');
452
+ }
453
+ // Preserve the parsed host kind only when no mapper has assumed registered-name ownership.
454
+ const encodedResult = result;
455
+ result = normalizePercentEncoding(result);
456
+ if (!mapRegName && isIPv4Address(result)) result = normalizePercentEncoding(encodedResult, false);
457
+ return /[^\x00-\x7F]/u.test(result) ? result : lowercaseAsciiHost(result);
458
+ }
459
+ // Remove an empty or default-valued HTTP or WebSocket port.
460
+ function normalizePort(scheme, port) {
461
+ if (port === undefined) return undefined;
462
+ const defaultPort = scheme === 'http' || scheme === 'ws' ? '80' : scheme === 'https' || scheme === 'wss' ? '443' : undefined;
463
+ if (defaultPort !== undefined && (port === '' || port.replace(/^0+(?=\d)/, '') === defaultPort)) return undefined;
464
+ return port;
465
+ }
466
+ // Encode each non-ASCII Unicode scalar as uppercase UTF-8 percent triplets.
467
+ function encodeIriComponent(component) {
468
+ // Process complete code points so supplementary characters produce one UTF-8 sequence.
469
+ return component.replace(/[^\x00-\x7F]/gu, (character) => encodeURIComponent(character).toUpperCase());
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
+ }
517
+ // Rebuild authority from normalized values while preserving other empty component delimiters.
518
+ function normalizeAuthority(parts, scheme, mapRegName) {
519
+ if (parts.authority === undefined) return undefined;
520
+ const userinfo = parts.userinfo === undefined ? undefined : normalizePercentEncoding(parts.userinfo);
521
+ const host = normalizeHost(parts.host, mapRegName);
522
+ const port = normalizePort(scheme, parts.port);
523
+ let authority = '';
524
+ if (userinfo !== undefined) authority += `${userinfo}@`;
525
+ authority += host;
526
+ if (port !== undefined) authority += `:${port}`;
527
+ return authority;
528
+ }
529
+ // Derive a normalized string from parsed URI/IRI components without modifying them.
530
+ function normalizeParsedReference(parts, options = {}) {
531
+ // Validate the optional API settings before they select normalization behavior.
532
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Invalid normalization argument type: must be an options object.');
533
+ const { transform, mapRegName } = options;
534
+ if (transform !== undefined && transform !== 'URI' && transform !== 'IRI') throw new TypeError('Invalid transform option: must be "URI" or "IRI".');
535
+ if (mapRegName !== undefined && typeof mapRegName !== 'function') throw new TypeError('Invalid registered-name mapper type: must be a function.');
536
+ // Normalize each component independently so encoded delimiters cannot become structure.
537
+ const scheme = parts.scheme === undefined ? undefined : parts.scheme.toLowerCase();
538
+ const normalized = {
539
+ scheme,
540
+ authority: normalizeAuthority(parts, scheme, mapRegName),
541
+ path: normalizePercentEncoding(parts.path),
542
+ query: parts.query === undefined ? undefined : normalizePercentEncoding(parts.query),
543
+ fragment: parts.fragment === undefined ? undefined : normalizePercentEncoding(parts.fragment),
544
+ };
545
+ // Use the slash form defined for an empty HTTP or WebSocket authority path.
546
+ if (normalized.authority !== undefined && normalized.path === '' && (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss')) normalized.path = '/';
547
+ // Limit dot-segment removal to paths whose standalone interpretation remains stable.
548
+ const rootlessRelativePath = normalized.scheme === undefined && normalized.authority === undefined && normalized.path.length > 0 && !normalized.path.startsWith('/');
549
+ if (!rootlessRelativePath) {
550
+ const reducedPath = removeDotSegments(normalized.path);
551
+ // Preserve a no-authority path when reduction would reparse it as an authority.
552
+ if (normalized.authority !== undefined || !reducedPath.startsWith('//')) normalized.path = reducedPath;
553
+ }
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
+ }
568
+ return compose(normalized);
569
+ }
290
570
  // export
291
571
  module.exports = {
292
572
  isUUID: (string) => validate(string, 'UUID'),
@@ -305,6 +585,5 @@ module.exports = {
305
585
  parseAbsoluteIri: (string) => parse(string, 'absolute_IRI'),
306
586
  resolveReference,
307
587
  toRelativeReference,
308
- toAbsoluteReference: (string) => resolveReference('', string),
309
- normalizeReference: (string) => string, // not done yet
588
+ toAbsoluteReference
310
589
  };
package/normalization.md CHANGED
@@ -1,299 +1,146 @@
1
- # Identifier normalization research notes
2
-
3
- These notes summarize the standards and design choices that should be resolved before implementing `normalizeReference`. They are research material, not the current API contract.
4
-
5
- ## Central constraint
6
-
7
- There is no universal canonical form for every URI or IRI. RFC 3986 defines comparison in levels because a transformation that is safe for one scheme or application can merge distinct identifiers in another.
8
-
9
- A normalizer should minimize false negatives without creating false positives:
10
-
11
- 1. **Simple comparison** — compare characters exactly.
12
- 2. **Syntax-based normalization** — apply transformations licensed by generic URI syntax.
13
- 3. **Scheme-based normalization** — apply additional equivalences defined by a scheme.
14
- 4. **Protocol-based normalization** — use equivalences established by observed protocol behavior.
15
-
16
- `normalizeReference` should implement only an explicitly selected level. Protocol-derived normalization does not belong in a deterministic identifier library.
17
-
18
- Reference: [RFC 3986 §6](https://www.rfc-editor.org/rfc/rfc3986#section-6).
19
-
20
- ## Generic syntax-based normalization
21
-
22
- The following transformations are suitable candidates for a generic URI profile.
23
-
24
- ### Scheme and host case
25
-
26
- - Lowercase the scheme.
27
- - Lowercase the host.
28
- - Do not lowercase userinfo, path, query, or fragment.
29
- - Preserve Unicode component case unless a scheme or external policy defines equivalence.
30
-
31
- Example:
32
-
33
- ```text
34
- HTTP://WWW.EXAMPLE.COM/ http://www.example.com/
35
- ```
36
-
37
- Reference: RFC 3986 §6.2.2.1.
38
-
39
- ### Percent-triplet case
40
-
41
- Uppercase hexadecimal letters in every valid percent triplet:
42
-
43
- ```text
44
- %3a → %3A
45
- %2f → %2F
46
- ```
47
-
48
- This changes presentation, not the represented octet.
49
-
50
- Reference: RFC 3986 §§2.1 and 6.2.2.1.
51
-
52
- ### Decode percent-encoded unreserved characters
53
-
54
- Decode a percent triplet only when it represents an ASCII unreserved character:
55
-
56
- ```text
57
- ALPHA / DIGIT / "-" / "." / "_" / "~"
58
- ```
59
-
60
- Examples:
61
-
62
- ```text
63
- %63 c
64
- %7E → ~
65
- ```
66
-
67
- Do not generically decode reserved characters. `%2F` and `/`, for example, can have different structural meanings.
68
-
69
- Parse components before decoding so an encoded delimiter cannot be mistaken for syntax. Never decode the same data twice.
70
-
71
- References: RFC 3986 §§2.2–2.4 and 6.2.2.2.
72
-
73
- ### Remove dot segments
74
-
75
- Apply the RFC 3986 §5.2.4 algorithm to the parsed path:
76
-
77
- ```text
78
- /a/b/c/./../../g → /a/g
79
- ```
80
-
81
- Only complete `.` and `..` path segments are special. Do not process similar text in a query or fragment:
82
-
83
- ```text
84
- g?y/../x
85
- g#s/../x
86
- ```
87
-
88
- References: RFC 3986 §§5.2.4 and 6.2.2.3.
89
-
90
- ### Preserve component presence
91
-
92
- Recomposition must distinguish an absent component from a present but empty component:
93
-
94
- ```text
95
- https://example.com/path
96
- https://example.com/path?
97
- https://example.com/path#
98
- ```
99
-
100
- The same applies to an empty authority. Component delimiters must be emitted based on definedness, not truthiness.
101
-
102
- Reference: RFC 3986 §5.3.
103
-
104
- ## Scheme-based profiles
105
-
106
- Scheme-specific transformations should not run unless the scheme profile is selected or automatic scheme handling is part of the documented contract.
107
-
108
- ### HTTP and HTTPS
109
-
110
- RFC 9110 permits these normal forms:
111
-
112
- - lowercase scheme and host;
113
- - remove port `80` from `http`;
114
- - remove port `443` from `https`;
115
- - remove an explicitly empty port;
116
- - use `/` when authority is present and path is empty;
117
- - decode percent-encoded unreserved characters;
118
- - preserve all other component case.
119
-
120
- Examples:
121
-
122
- ```text
123
- HTTP://Example.COM:80 → http://example.com/
124
- https://example.com:443/a → https://example.com/a
125
- ```
126
-
127
- An HTTP request target does not include a fragment, but identifier normalization should not silently discard a fragment unless the selected operation specifically produces a request target.
128
-
129
- Userinfo in HTTP and HTTPS targets is deprecated and should be rejected or reported by an HTTP policy rather than silently normalized away.
130
-
131
- Reference: [RFC 9110 §§4.2.1–4.2.5](https://www.rfc-editor.org/rfc/rfc9110#section-4.2).
132
-
133
- ### WS and WSS
134
-
135
- Potential scheme-specific rules include:
136
-
137
- - default port `80` for `ws`;
138
- - default port `443` for `wss`;
139
- - `/` as the resource path when the path is empty;
140
- - no fragment identifiers;
141
- - IDN-to-ASCII handling under an explicitly selected hostname policy.
142
-
143
- Fragment rejection is validation, not normalization. A normalizer should not repair a WebSocket URI by silently deleting its fragment.
144
-
145
- Reference: [RFC 6455 §3](https://www.rfc-editor.org/rfc/rfc6455#section-3).
146
-
147
- ### File
148
-
149
- A generic `file` normalizer is not recommended. Behavior varies by platform and filesystem:
150
-
151
- - local empty authority versus `localhost`;
152
- - POSIX roots;
153
- - Windows drive-letter case and UNC paths;
154
- - path case sensitivity;
155
- - backslash handling;
156
- - platform-specific Unicode normalization;
157
- - reserved device names and namespace paths.
158
-
159
- Require an explicit platform profile before applying these transformations.
160
-
161
- Reference: [RFC 8089](https://www.rfc-editor.org/rfc/rfc8089).
162
-
163
- ### Other schemes
164
-
165
- Apply only generic syntax normalization unless the scheme's authoritative specification defines additional equivalences. Do not infer default ports, path case rules, or authority behavior from a similar scheme.
166
-
167
- ## IRI and Unicode decisions
168
-
169
- RFC 3987 recommends that creators produce IRIs in NFC, but comparison code must not arbitrarily normalize an existing Unicode IRI. Normalizing third-party text can merge identifiers that were intentionally distinct.
170
-
171
- Recommended policy:
172
-
173
- - do not apply Unicode normalization by default;
174
- - offer NFC only as an explicit creation or application-policy option;
175
- - do not offer NFKC as a generic identifier transformation;
176
- - retain the original IRI when a normalized form is generated only as a comparison key.
177
-
178
- ### IRI-to-URI mapping is a separate operation
179
-
180
- Mapping an IRI to a URI is not merely normalization:
181
-
182
- 1. encode non-ASCII `ucschar` and `iprivate` characters as UTF-8;
183
- 2. percent-encode each UTF-8 octet as `%HH`;
184
- 3. preserve existing valid percent triplets and URI-allowed characters;
185
- 4. apply an explicit IDNA policy to internationalized hostnames when required.
186
-
187
- The reverse operation must decode only valid UTF-8 and must preserve encoded reserved characters. It must not guess legacy encodings.
188
-
189
- Reference: [RFC 3987 §§3 and 5](https://www.rfc-editor.org/rfc/rfc3987).
190
-
191
- ## Transformations excluded by default
192
-
193
- A generic normalizer should not:
194
-
195
- - decode percent-encoded reserved characters;
196
- - lowercase userinfo, paths, queries, or fragments;
197
- - sort, deduplicate, or reinterpret query parameters;
198
- - remove empty query or fragment delimiters;
199
- - add or remove trailing slashes without scheme authority;
200
- - apply Unicode NFC or NFKC automatically;
201
- - convert an internationalized hostname without a defined IDNA version and policy;
202
- - remove userinfo rather than reporting it;
203
- - infer filesystem semantics for `file`;
204
- - infer equivalence from redirects or successful retrievals;
205
- - convert a relative reference without a supplied base.
206
-
207
- ## Suggested API design
208
-
209
- Avoid a single aggressive operation. Two viable designs are:
210
-
211
- ### Explicit profiles
212
-
213
- ```js
214
- normalizeReference(reference, {
215
- profile: 'generic', // generic | http | https | ws | wss
216
- unicodeNormalization: false,
217
- });
218
- ```
219
-
220
- A `file` profile should additionally require a platform policy.
221
-
222
- ### Separate operations
223
-
224
- ```js
225
- normalizeReference(reference); // generic syntax only
226
- normalizeHttpReference(reference); // HTTP/HTTPS scheme rules
227
- normalizeWebSocketReference(reference); // WS/WSS scheme rules
228
- iriToUri(reference, options); // explicit representation mapping
229
- ```
230
-
231
- Separate operations are harder to misuse and make compatibility changes more visible. A profile-based API is easier to extend. The final choice should follow the expected consumers.
232
-
233
- ## Suggested generic processing order
234
-
235
- 1. Parse and retain whether authority, query, and fragment are absent or empty.
236
- 2. Lowercase scheme and host where generic syntax permits it.
237
- 3. Normalize percent-triplet hexadecimal case.
238
- 4. Decode percent-encoded ASCII unreserved characters component by component.
239
- 5. Remove dot segments from the path.
240
- 6. Apply an explicitly selected scheme profile.
241
- 7. Apply Unicode normalization only when explicitly requested.
242
- 8. Recompose while preserving empty components.
243
- 9. Validate the normalized output under the same identifier and scheme policy.
244
- 10. Optionally verify idempotence:
245
-
246
- ```js
247
- normalizeReference(normalizeReference(value)) === normalizeReference(value)
248
- ```
249
-
250
- ## Acceptance scenarios
251
-
252
- A generic profile should include at least these cases:
253
-
254
- ```text
255
- HTTP://Example.COM/%7euser → http://example.com/~user
256
- http://example.com/a/./b/../c → http://example.com/a/c
257
- http://example.com/path? → http://example.com/path?
258
- http://example.com/path# → http://example.com/path#
259
- http://example.com/%2F → http://example.com/%2F
260
- ```
261
-
262
- An HTTP profile can additionally include:
263
-
264
- ```text
265
- http://example.com:80 → http://example.com/
266
- https://example.com:443/a → https://example.com/a
267
- ```
268
-
269
- Cases that must remain distinct under generic normalization include:
270
-
271
- ```text
272
- http://example.com/a ≠ http://example.com/a/
273
- http://example.com/path ≠ http://example.com/path?
274
- http://example.com/%2F ≠ http://example.com//
275
- http://example.com/A ≠ http://example.com/a
276
- ```
277
-
278
- Testing should cover URI and IRI forms, empty components, rootless paths, percent triplets, Unicode supplementary characters, idempotence, and normalization followed by parsing/recomposition.
279
-
280
- ## Open decisions
281
-
282
- Before implementation, decide:
283
-
284
- 1. Whether `normalizeReference` is generic-only or selects profiles automatically by scheme.
285
- 2. Whether output preserves the input category: URI versus IRI.
286
- 3. Whether relative references are normalized in place or require a base and become absolute.
287
- 4. Whether Unicode NFC is offered, and for which creation contexts.
288
- 5. Whether IDNA conversion belongs here or in a dedicated hostname dependency.
289
- 6. Whether comparison keys and display/transport identifiers use separate APIs.
290
- 7. Whether unsupported scheme profiles throw, fall back to generic rules, or require an explicit option.
291
- 8. Whether current callers can rely on `normalizeReference` remaining an identity function until a major release.
292
-
293
- ## Authoritative references
294
-
295
- - [RFC 3986 — Uniform Resource Identifier: Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986)
296
- - [RFC 3987 — Internationalized Resource Identifiers](https://www.rfc-editor.org/rfc/rfc3987)
297
- - [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
298
- - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
299
- - [RFC 8089 — The `file` URI Scheme](https://www.rfc-editor.org/rfc/rfc8089)
1
+ # URI and IRI normalization
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 URI/IRI representation transformation. The method returns a string and leaves the parsed components unchanged.
4
+
5
+ ## API
6
+
7
+ ```ts
8
+ type RegNameMapper = (regName: string) => string
9
+
10
+ type NormalizeTransform = 'URI' | 'IRI'
11
+
12
+ type NormalizeOptions = {
13
+ transform?: NormalizeTransform
14
+ mapRegName?: RegNameMapper
15
+ }
16
+
17
+ interface NormalizableReference {
18
+ normalize(options?: NormalizeOptions): string
19
+ }
20
+ ```
21
+
22
+ The method is available on results from:
23
+
24
+ - `parseUri`
25
+ - `parseUriReference`
26
+ - `parseAbsoluteUri`
27
+ - `parseIri`
28
+ - `parseIriReference`
29
+ - `parseAbsoluteIri`
30
+
31
+ It is non-enumerable and reads the result object's current component properties when called.
32
+
33
+ ```js
34
+ const { parseIriReference } = require('identifier-js');
35
+
36
+ const parsed = parseIriReference(
37
+ 'HTTP://Example.COM/%7e/a/../b?x=%2f#%41',
38
+ );
39
+
40
+ parsed.normalize();
41
+ // http://example.com/~/b?x=%2F#A
42
+
43
+ parsed.path;
44
+ // /%7e/a/../b
45
+ ```
46
+
47
+ ## RFC syntax normalization
48
+
49
+ | Behavior | Implementation | Source |
50
+ | --- | --- | --- |
51
+ | Case normalization | Lowercase the scheme and an ASCII-only host. Uppercase hexadecimal letters in percent triplets. | [RFC 3986 §6.2.2.1](https://www.rfc-editor.org/rfc/rfc3986#section-6.2.2.1), [RFC 3987 §5.3.2.1](https://www.rfc-editor.org/rfc/rfc3987#section-5.3.2.1) |
52
+ | Percent-encoded unreserved characters | Decode percent triplets representing ASCII letters, digits, `-`, `.`, `_`, or `~`. Retain percent encoding for reserved octets. | [RFC 3986 §§2.2–2.4 and 6.2.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-6.2.2.2), [RFC 3987 §5.3.2.3](https://www.rfc-editor.org/rfc/rfc3987#section-5.3.2.3) |
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) |
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) |
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) |
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) |
58
+
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.
60
+
61
+ ## HTTP and HTTPS
62
+
63
+ For `http` and `https`, normalization applies the generic rules and these scheme rules:
64
+
65
+ | Input component | Output | Source |
66
+ | --- | --- | --- |
67
+ | Empty port | Omit the port delimiter. | [RFC 3986 §3.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.3) |
68
+ | `http` port numerically equal to `80` | Omit the port. | [RFC 9110 §4.2.3](https://www.rfc-editor.org/rfc/rfc9110#section-4.2.3) |
69
+ | `https` port numerically equal to `443` | Omit the port. | [RFC 9110 §4.2.3](https://www.rfc-editor.org/rfc/rfc9110#section-4.2.3) |
70
+ | Empty authority path | Use `/`. | [RFC 9110 §4.2.3](https://www.rfc-editor.org/rfc/rfc9110#section-4.2.3) |
71
+
72
+ Decimal port comparison includes leading-zero spellings.
73
+
74
+ ```text
75
+ HTTP://Example.COM:80 → http://example.com/
76
+ https://example.com:00443/a → https://example.com/a
77
+ http://example.com:/a → http://example.com/a
78
+ ```
79
+
80
+ ## WS and WSS
81
+
82
+ For `ws` and `wss`, normalization applies the generic rules and these scheme rules:
83
+
84
+ | Input component | Output | Source |
85
+ | --- | --- | --- |
86
+ | Empty port | Omit the port delimiter. | [RFC 3986 §3.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.3) |
87
+ | `ws` port numerically equal to `80` | Omit the port. | [RFC 3986 §6.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-6.2.3), [RFC 6455 §3](https://www.rfc-editor.org/rfc/rfc6455#section-3) |
88
+ | `wss` port numerically equal to `443` | Omit the port. | [RFC 3986 §6.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-6.2.3), [RFC 6455 §3](https://www.rfc-editor.org/rfc/rfc6455#section-3) |
89
+ | Empty authority path | Use `/` as the resource-name path. | [RFC 6455 §3](https://www.rfc-editor.org/rfc/rfc6455#section-3) |
90
+
91
+ ```text
92
+ WS://Example.COM:80 → ws://example.com/
93
+ wss://example.com:00443/chat → wss://example.com/chat
94
+ ws://example.com?channel=updates → ws://example.com/?channel=updates
95
+ ```
96
+
97
+ ## Registered-name mapping and representation transformation
98
+
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.
100
+
101
+ The mapper owns the returned text and all registered-name validation, representation, and host-kind policy. This package enforces only the declared string return type. It does not check whether mapper output is non-empty, remains a registered name, introduces component delimiters, resembles an IP address, or satisfies a scheme-specific hostname grammar. The returned string then receives percent-triplet and ASCII host-case normalization. Mapper exceptions propagate unchanged.
102
+
103
+ ```js
104
+ const mapped = parseIriReference('x://example').normalize({
105
+ mapRegName: () => 'Application Defined',
106
+ });
107
+
108
+ mapped;
109
+ // x://application defined
110
+ ```
111
+
112
+ The example deliberately produces text that is not a valid URI or IRI; validating or selecting mapper output belongs to the application.
113
+
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.
115
+
116
+ ```js
117
+ const { parseIri, parseUri } = require('identifier-js');
118
+
119
+ parseIri('x:/café?q=資料#résultat').normalize({ transform: 'URI' });
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
124
+ ```
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
+
132
+ ## Verification
133
+
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.
135
+
136
+ ```sh
137
+ npm test
138
+ ```
139
+
140
+ ## References
141
+
142
+ - [RFC 3986 — Uniform Resource Identifier: Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986)
143
+ - [RFC 3987 Internationalized Resource Identifiers](https://www.rfc-editor.org/rfc/rfc3987)
144
+ - [RFC 5952 — A Recommendation for IPv6 Address Text Representation](https://www.rfc-editor.org/rfc/rfc5952)
145
+ - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
146
+ - [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "identifier-js",
3
- "version": "0.1.4",
4
- "description": "A RFC3986 / RFC3987 compliant fast parser/validator/resolver/composer for NodeJS and browser.",
3
+ "version": "0.3.0",
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",
7
7
  "URI",
package/readme.md CHANGED
@@ -2,18 +2,19 @@
2
2
 
3
3
  title: Identifier JS
4
4
 
5
- description: An RFC 3986 and RFC 3987 parser, validator, and reference resolver for Node.js and browser bundles.
5
+ description: An RFC 3986 and RFC 3987 parser, validator, normalizer, and reference resolver for Node.js and browser bundles.
6
6
 
7
7
  ---
8
8
 
9
9
  # Identifier JS
10
10
 
11
- `identifier-js` is a fully RFC [3986](https://www.rfc-editor.org/rfc/rfc3986) and RFC [3987](https://www.rfc-editor.org/rfc/rfc3987) compliant URI/IRI parser, validator, resolver, and composer. It provides:
11
+ `identifier-js` is a URI/IRI parser, validator, normalizer, resolver, and composer based on RFC [3986](https://www.rfc-editor.org/rfc/rfc3986) and RFC [3987](https://www.rfc-editor.org/rfc/rfc3987). Its recognized HTTP, WebSocket, and `file` schemes retain the documented hostname-policy restrictions below. It provides:
12
12
 
13
13
  - URI and IRI validation;
14
14
  - parsed identifier components;
15
+ - conservative syntax normalization, recognized-scheme port/path forms, and a registered-name extension point;
15
16
  - RFC 3986 reference resolution and dot-segment removal;
16
- - relative-reference generation with round-trip guarantees for supported forms;
17
+ - relative-reference generation with resolution round-trip guarantees for supported forms;
17
18
  - UUID and UUIDv4 lexical validation;
18
19
  - lazily compiled and cached regular expressions.
19
20
 
@@ -62,9 +63,9 @@ Parse URI syntax into scheme, authority, userinfo, host, port, path, query, and
62
63
  <summary><strong>API and examples</strong></summary>
63
64
 
64
65
  ```ts
65
- parseUri(value: string): IdentifierComponents
66
- parseUriReference(value: string): RelativeIdentifierComponents
67
- parseAbsoluteUri(value: string): AbsoluteIdentifierComponents
66
+ parseUri(value: string): ParsedIdentifierComponents
67
+ parseUriReference(value: string): ParsedRelativeIdentifierComponents
68
+ parseAbsoluteUri(value: string): ParsedAbsoluteIdentifierComponents
68
69
  ```
69
70
 
70
71
  ```js
@@ -107,7 +108,7 @@ console.log(isIri('https://例え.テスト/資料?項目=値#概要')); // true
107
108
  console.log(isIriReference('../résumé')); // true
108
109
  ```
109
110
 
110
- IRI support permits RFC 3987 Unicode ranges in applicable components. Complete IDNA processing and conversion to a transport URI are separate responsibilities.
111
+ IRI support permits the RFC 3987 Unicode ranges in applicable components and preserves their parsed Unicode spelling.
111
112
 
112
113
  </details>
113
114
 
@@ -119,9 +120,9 @@ Parse an IRI while preserving its Unicode component values.
119
120
  <summary><strong>API and examples</strong></summary>
120
121
 
121
122
  ```ts
122
- parseIri(value: string): IdentifierComponents
123
- parseIriReference(value: string): RelativeIdentifierComponents
124
- parseAbsoluteIri(value: string): AbsoluteIdentifierComponents
123
+ parseIri(value: string): ParsedIdentifierComponents
124
+ parseIriReference(value: string): ParsedRelativeIdentifierComponents
125
+ parseAbsoluteIri(value: string): ParsedAbsoluteIdentifierComponents
125
126
  ```
126
127
 
127
128
  ```js
@@ -190,7 +191,7 @@ toRelativeReference(target: string, base: string): string
190
191
  const { toAbsoluteReference, toRelativeReference } = require('identifier-js');
191
192
 
192
193
  console.log(toAbsoluteReference('https://example.com/a/../b#section'));
193
- // https://example.com/b
194
+ // https://example.com/a/../b
194
195
 
195
196
  const target = 'https://example.com/docs/images/logo.svg';
196
197
  const base = 'https://example.com/docs/api/page';
@@ -198,29 +199,51 @@ const relative = toRelativeReference(target, base);
198
199
  console.log(relative); // ../images/logo.svg
199
200
  ```
200
201
 
201
- When no safe rootless relative form can round-trip to the target, `toRelativeReference` returns the absolute target. Different schemes or authorities also return the target unchanged.
202
+ When no safe rootless relative form can round-trip to the target, `toRelativeReference` returns the absolute target. Different schemes or authorities also return the target unchanged. Complete dot segments in either path also trigger this fallback because RFC resolution removes them. For those inputs, resolving the result produces the same identifier as resolving the target directly; lexical dot-segment spelling is not preserved.
202
203
 
203
204
  </details>
204
205
 
205
- ### Normalization status
206
+ ### Normalize parsed URI and IRI references
206
207
 
207
- `normalizeReference` is reserved for future normalization policy and currently returns its input unchanged.
208
+ Every URI and IRI parse result provides an optional, non-enumerable `normalize()` method. Parsing remains usable by itself; normalization runs only when the method is called and returns a string without modifying the parsed components.
208
209
 
209
210
  <details>
210
- <summary><strong>Current behavior and research</strong></summary>
211
+ <summary><strong>API, behavior, and examples</strong></summary>
211
212
 
212
213
  ```ts
213
- normalizeReference(reference: string): string
214
+ type RegNameMapper = (regName: string) => string
215
+
216
+ type NormalizeTransform = 'URI' | 'IRI'
217
+
218
+ type NormalizeOptions = {
219
+ transform?: NormalizeTransform
220
+ mapRegName?: RegNameMapper
221
+ }
222
+
223
+ interface NormalizableReference {
224
+ normalize(options?: NormalizeOptions): string
225
+ }
214
226
  ```
215
227
 
216
228
  ```js
217
- const { normalizeReference } = require('identifier-js');
229
+ const { parseIriReference } = require('identifier-js');
218
230
 
219
- console.log(normalizeReference('HTTP://Example.COM/a/../b'));
220
- // HTTP://Example.COM/a/../b
231
+ const parsed = parseIriReference('HTTP://Example.COM/%7e/a/../b?x=%2f#%41');
232
+ console.log(parsed.host); // Example.COM
233
+ console.log(parsed.normalize());
234
+ // http://example.com/~/b?x=%2F#A
235
+ console.log(parsed.path); // /%7e/a/../b
221
236
  ```
222
237
 
223
- URI/IRI normalization has multiple standards-defined levels and scheme-specific tradeoffs. See [`normalization.md`](normalization.md) for implementation options, unsafe transformations, suggested profiles, and acceptance scenarios.
238
+ The method is available from `parseUri`, `parseUriReference`, `parseAbsoluteUri`, `parseIri`, `parseIriReference`, and `parseAbsoluteIri`.
239
+
240
+ Normalization implements RFC 3986 and RFC 3987 syntax normalization for scheme and host case, percent triplets, ASCII unreserved characters, path dot segments, and component recomposition. IPv6 literals use RFC 5952 text. HTTP(S) default ports and empty paths follow RFC 9110; WS(S) defaults and resource-name paths follow RFC 6455.
241
+
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.
243
+
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.
245
+
246
+ See [`normalization.md`](normalization.md) for the exact RFC section mapping and examples.
224
247
 
225
248
  </details>
226
249
 
@@ -243,7 +266,7 @@ console.log(isUUID('99c17cbb-656f-564a-940f-1a4568f03487')); // true
243
266
  console.log(isUUIDv4('123e4567-e89b-42d3-9456-426614174000')); // true
244
267
  ```
245
268
 
246
- `isUUID` validates the `8-4-4-4-12` hexadecimal layout without restricting the version or variant fields. `isUUIDv4` requires version `4` and the RFC variant nibble `8`, `9`, `a`, or `b`.
269
+ `isUUID` validates the `8-4-4-4-12` hexadecimal layout. `isUUIDv4` additionally requires version `4` and the RFC variant nibble `8`, `9`, `a`, or `b`.
247
270
 
248
271
  </details>
249
272
 
@@ -279,9 +302,9 @@ The following schemes trigger DNS-style ASCII or Unicode label rules instead of
279
302
  - `wss`
280
303
  - `file`
281
304
 
282
- Matching is case-insensitive. Other valid schemes use generic RFC 3986/3987 registered-name syntax.
305
+ Matching is case-insensitive. Other valid schemes use generic RFC 3986/3987 registered-name syntax. RFC 8089's empty `file` authority is accepted when followed by an absolute path, as in `file:///path`; empty hosts remain rejected for HTTP and WebSocket schemes.
283
306
 
284
- This policy validates label shape and selected Unicode character classes. It is not complete IDNA processing and does not replace normalization, contextual, bidi, registry, or Punycode validation.
307
+ Parsing validates DNS-style label shape and the selected RFC 3987 Unicode character classes. A registered-name mapper runs later during optional normalization, and its returned string is not submitted to this hostname policy again.
285
308
 
286
309
  </details>
287
310
 
@@ -349,7 +372,7 @@ RFC 3987 uses IRIs for internationalized identification while requiring mapping
349
372
 
350
373
  ### Validate and route protocol identifiers
351
374
 
352
- Component parsing supports policy decisions without unsafe string splitting.
375
+ Component parsing provides scheme, authority, host, port, path, and query fields for policy decisions.
353
376
 
354
377
  <details>
355
378
  <summary><strong>Example and context</strong></summary>
@@ -364,7 +387,7 @@ console.log(parts.port); // 8443
364
387
  console.log(parts.path); // /events
365
388
  ```
366
389
 
367
- Applications can inspect scheme, authority, path, and query before selecting a connector, enforcing an allowlist, constructing an HTTP request target, or routing to a service. Protocol-specific security and semantic validation remains the application's responsibility.
390
+ Applications can inspect scheme, authority, path, and query before selecting a connector, enforcing an allowlist, constructing an HTTP request target, or routing to a service.
368
391
 
369
392
  </details>
370
393
 
@@ -386,46 +409,41 @@ try {
386
409
  }
387
410
  ```
388
411
 
389
- RFC 9562 lists database keys, filenames, system identifiers, and transaction identifiers among common UUID uses. UUID validation does not establish authorization, unpredictability, uniqueness, or safe use as a capability token.
412
+ RFC 9562 lists database keys, filenames, system identifiers, and transaction identifiers among common UUID uses.
390
413
 
391
414
  </details>
392
415
 
393
- ## Intentional behavior and limitations
416
+ ## Standards behavior
394
417
 
395
418
  <details>
396
- <summary><strong>Validation and parsing boundaries</strong></summary>
397
-
398
- - Validators return `true` or throw; they do not return `false`.
399
- - URI functions reject non-ASCII characters where RFC 3986 permits only URI syntax. Use the IRI functions for RFC 3987 Unicode ranges.
400
- - `absolute-URI` and `absolute-IRI` exclude fragments by definition. The complete `URI` and `IRI` functions permit fragments.
401
- - Scheme-specific processing currently specializes hostname syntax; it does not implement every protocol rule for HTTP, WebSocket, or `file` identifiers.
402
- - Scheme-specific Unicode labels are not complete IDNA validation.
403
- - Ports are restricted to an empty value or the numeric range 0–65535. Generic RFC 3986 syntax itself permits any sequence of digits.
404
- - Generic registered names may contain syntax that DNS-style hostnames reject.
405
- - Parsing separates components before any application-level percent decoding.
406
- - The library does not perform network, DNS, filesystem, registry, or authorization checks.
419
+ <summary><strong>Validation and parsing</strong></summary>
420
+
421
+ - Generic URI syntax follows RFC 3986 character and component grammar; recognized schemes select the documented hostname profile.
422
+ - Generic IRI syntax follows the RFC 3987 Unicode extensions to URI grammar; recognized schemes select the documented hostname profile.
423
+ - Validators return `true` or throw at the first grammar violation.
424
+ - `absolute-URI` and `absolute-IRI` use the fragment-free grammar defined by their RFCs; complete URI and IRI operations accept fragments.
425
+ - Port syntax follows RFC 3986 `port = *DIGIT`, including empty and leading-zero values.
426
+ - Parsing records absent optional components as `undefined` and present-empty components as empty strings.
407
427
 
408
428
  </details>
409
429
 
410
430
  <details>
411
- <summary><strong>Resolution and conversion boundaries</strong></summary>
431
+ <summary><strong>Resolution, conversion, and normalization</strong></summary>
412
432
 
413
- - Resolution uses IRI grammar, so Unicode references and bases are accepted.
414
- - Empty authorities, queries, and fragments remain distinct from absent components.
415
- - `strict = false` enables RFC 3986 backward-compatible handling when a reference repeats the base scheme.
416
- - `toAbsoluteReference` requires an identifier containing a scheme and removes its fragment through empty-reference resolution.
417
- - `toRelativeReference` compares scheme and authority text exactly; it does not normalize them first.
418
- - `toRelativeReference` may return an absolute target when a rootless relative path cannot preserve identity.
419
- - `normalizeReference` is intentionally an identity function until a normalization contract is selected.
433
+ - `resolveReference` implements RFC 3986 §5 component inheritance, path merging, dot-segment removal, and recomposition for URI and IRI text.
434
+ - An empty reference path inherits the base path unchanged.
435
+ - `strict = false` implements RFC 3986 §5.2.2 backward-compatible same-scheme handling.
436
+ - `toAbsoluteReference` removes the fragment from an identifier containing a scheme.
437
+ - `toRelativeReference` generates a reference whose RFC resolution equals the target resolution for supported 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.
420
439
 
421
440
  </details>
422
441
 
423
442
  <details>
424
- <summary><strong>UUID boundaries</strong></summary>
443
+ <summary><strong>UUID validation</strong></summary>
425
444
 
426
- - `isUUID` validates canonical hexadecimal layout only; it does not enforce a known version or the RFC variant.
427
- - `isUUIDv4` validates the version and variant fields but does not assess random-number quality.
428
- - A syntactically valid UUID is not proof of uniqueness, integrity, authenticity, or authorization.
445
+ - `isUUID` validates the RFC 9562 hexadecimal `8-4-4-4-12` text layout.
446
+ - `isUUIDv4` additionally validates version `4` and the RFC variant bits.
429
447
 
430
448
  </details>
431
449
 
@@ -457,7 +475,7 @@ Run `gh workspace-data load` again to refresh materialized data after public-dat
457
475
 
458
476
  ### Tests
459
477
 
460
- The active suite contains 3,064 tests covering URI/IRI validation and parsing, scheme-specific hosts, IPv4, IPv6, 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.
461
479
 
462
480
  <details>
463
481
  <summary><strong>Test details</strong></summary>
@@ -500,7 +518,7 @@ Direct invocation also supports an explicit iteration count; `npm run benchmark`
500
518
  node ./#/public/benchmarks --iterations 250000
501
519
  ```
502
520
 
503
- The generic coordinator delegates version-layer selection and ordered concern discovery to the `gh-workspace-data v0.5.0` runtime. The materialized `#/public/benchmarks/README.md` documents concern registration, version eligibility, workload controls, measurement semantics, output fields, and guidance for interpreting results from noisy CI runners. Benchmark values are observations rather than correctness assertions.
521
+ The generic coordinator delegates version-layer selection and ordered concern discovery to the `gh-workspace-data v0.5.0` runtime. The materialized `#/public/benchmarks/README.md` documents concern registration, version eligibility, workload controls, measurement semantics, output fields, and guidance for interpreting results from noisy CI runners.
504
522
 
505
523
  </details>
506
524
 
@@ -512,7 +530,3 @@ The generic coordinator delegates version-layer selection and ordered concern di
512
530
  - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
513
531
  - [RFC 8089 — The `file` URI Scheme](https://www.rfc-editor.org/rfc/rfc8089)
514
532
  - [RFC 9562 — Universally Unique IDentifiers](https://www.rfc-editor.org/rfc/rfc9562)
515
-
516
- ## Disclaimer
517
-
518
- Validation establishes conformance with this implementation's syntax and policy. It does not establish that an identifier is registered, reachable, trustworthy, safe to dereference, or appropriate for a particular protocol operation.