identifier-js 0.1.4 → 0.2.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,47 @@ 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 optional URI output and registered-name mapping for parsed-result normalization. */
47
+ export type NormalizeOptions = {
48
+ toUri?: boolean;
49
+ mapRegName?: RegNameMapper;
50
+ };
51
+
52
+ /** Provide lazy RFC normalization and optional IRI-to-URI output on a parsed result. */
53
+ export type NormalizableReference = {
54
+ normalize(options?: NormalizeOptions): string;
55
+ };
56
+
57
+ // Describe component presence for complete, relative, and fragment-free absolute identifiers.
58
+ export type IdentifierComponents = {
44
59
  scheme: string;
45
- authority: string;
60
+ authority?: string;
46
61
  userinfo?: string;
47
- host: string;
62
+ host?: string;
48
63
  port?: string;
49
64
  path: string;
50
65
  query?: string;
51
66
  fragment?: string;
52
67
  };
53
68
 
54
- type RelativeIdentifierComponents = {
69
+ export type RelativeIdentifierComponents = {
55
70
  scheme?: string;
56
71
  authority?: string;
57
72
  userinfo?: string;
@@ -62,12 +77,16 @@ type RelativeIdentifierComponents = {
62
77
  fragment?: string;
63
78
  };
64
79
 
65
- type AbsoluteIdentifierComponents = {
80
+ export type AbsoluteIdentifierComponents = {
66
81
  scheme: string;
67
- authority: string;
82
+ authority?: string;
68
83
  userinfo?: string;
69
- host: string;
84
+ host?: string;
70
85
  port?: string;
71
86
  path: string;
72
87
  query?: string;
73
88
  };
89
+
90
+ export type ParsedIdentifierComponents = IdentifierComponents & NormalizableReference;
91
+ export type ParsedRelativeIdentifierComponents = RelativeIdentifierComponents & NormalizableReference;
92
+ 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}',
@@ -88,6 +89,12 @@ const schemeSpecificRules = {
88
89
  u_separator: '[\\x2E\\uFF0E\\u3002\\uFF61]',
89
90
  u_char: '[\\p{L}\\p{N}\\p{Mn}\\p{Mc}\\u200C\\u200D\\u00B7\\u0375\\u30FB\\u05F3\\u05F4]',
90
91
  };
92
+ // Recognize RFC 8089's empty file authority without weakening other scheme host policies.
93
+ const emptyFileHostRules = Object.assign({}, schemeSpecificRules, {
94
+ scheme: '[fF][iI][lL][eE]',
95
+ reg_name: '',
96
+ ireg_name: '',
97
+ });
91
98
  // pattern RFC group names
92
99
  const groupNames = {
93
100
  scheme: 'scheme',
@@ -113,26 +120,35 @@ const groupNames = {
113
120
  ipath_rootless: 'path',
114
121
  ipath_empty: 'path',
115
122
  };
116
- // all rules into one map
123
+ // Select and merge generic, DNS-host, or empty-file-host grammar overrides.
117
124
  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));
125
+ const schemeProfile = (string) => (string.slice(0, 8).toLowerCase() === 'file:///' ? 'f' : isSpecificScheme(string) ? 's' : '');
126
+ const rules = (profile) => Object.assign({}, commonRules, uriRules, iriRules, profile === 'f' ? emptyFileHostRules : profile ? schemeSpecificRules : {});
119
127
  // parse (slower, it uses regex.exec and includes named capture groups)
120
128
  const parse = (string, rule) => {
121
129
  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'));
130
+ const profile = schemeProfile(string);
131
+ const addNames = (key) => (groupNames[key] ? `(?<${groupNames[key]}>${rules(profile)[key]})` : rules(profile)[key]);
132
+ const ruleId = '_' + profile + rule;
133
+ if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(profile), rule, addNames)}$`, 'u'));
126
134
  const match = patterns.get(ruleId).exec(string);
127
- if (match) return match.groups;
135
+ if (match) {
136
+ Object.defineProperty(match.groups, 'normalize', {
137
+ // Normalize this parsed result only when its optional method is called.
138
+ value: function normalize(options) {
139
+ return normalizeParsedReference(this, options);
140
+ },
141
+ });
142
+ return match.groups;
143
+ }
128
144
  throw new SyntaxError(`Invalid ${rule.replace('_', '-')}: ${string}`);
129
145
  };
130
146
  // validate (faster, it uses regex.test and does not include named capture groups)
131
147
  const validate = (string, rule) => {
132
148
  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'));
149
+ const profile = schemeProfile(string);
150
+ const ruleId = profile + rule;
151
+ if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(profile), rule)}$`, 'u'));
136
152
  if (patterns.get(ruleId).test(string)) return true;
137
153
  throw new SyntaxError(`Invalid ${rule.replace('_', '-')}: ${string}`);
138
154
  };
@@ -149,7 +165,7 @@ function compose(parts = {}) {
149
165
  // remove dot segments algorithm per RFC 3986 Section 5.2.4 (loop and replace)
150
166
  function removeDotSegments(path) {
151
167
  const output = [];
152
- let input = path;
168
+ let input = path ?? '';
153
169
  while (input.length > 0) {
154
170
  if (input.startsWith('../')) input = input.slice(3);
155
171
  else if (input.startsWith('./')) input = input.slice(2);
@@ -209,27 +225,28 @@ function resolveReference(reference, base, strict = true, parts = false) {
209
225
  }
210
226
 
211
227
  let T;
212
- if (R.scheme && (strict || R.scheme !== B.scheme)) {
228
+ if (R.scheme && (strict || R.scheme.toLowerCase() !== B.scheme.toLowerCase())) {
213
229
  T = R;
230
+ T.path = removeDotSegments(R.path);
214
231
  } else {
215
232
  T = {};
216
233
  T.scheme = B.scheme;
217
234
  if (R.authority !== undefined && R.authority !== null) {
218
235
  T.authority = R.authority;
219
- T.path = R.path;
236
+ T.path = removeDotSegments(R.path);
220
237
  T.query = R.query;
221
238
  } else {
222
239
  T.authority = B.authority;
223
240
  if (R.path && R.path.length > 0) {
224
241
  if (R.path.startsWith('/')) {
225
- T.path = R.path;
242
+ T.path = removeDotSegments(R.path);
226
243
  } else if (B.authority !== undefined && B.authority !== null && (!B.path || B.path.length === 0)) {
227
- T.path = '/' + R.path;
244
+ T.path = removeDotSegments('/' + R.path);
228
245
  } else {
229
- // merge base path and ref path
246
+ // Merge the base directory and reference path before removing complete dot segments.
230
247
  const idx = B.path ? B.path.lastIndexOf('/') : -1;
231
248
  const prefix = idx !== -1 ? B.path.slice(0, idx + 1) : '';
232
- T.path = prefix + R.path;
249
+ T.path = removeDotSegments(prefix + R.path);
233
250
  }
234
251
  T.query = R.query;
235
252
  } else {
@@ -240,14 +257,21 @@ function resolveReference(reference, base, strict = true, parts = false) {
240
257
  }
241
258
  T.fragment = R.fragment;
242
259
  }
243
- T.path = removeDotSegments(T.path || '');
244
260
  if (parts) return T;
245
261
  return compose(T);
246
262
  }
247
- // reverse logic for resolve
263
+ // Convert a complete IRI to fragment-free form without changing its other components.
264
+ function toAbsoluteReference(string) {
265
+ const result = parse(string, 'IRI');
266
+ result.fragment = undefined;
267
+ return compose(result);
268
+ }
269
+ // Generate a relative reference when resolution is stable, otherwise retain the absolute target.
248
270
  const toRelativeReference = (target, base) => {
249
271
  const B = parse(base, 'absolute_IRI');
250
272
  const T = parse(target, 'IRI');
273
+ // Use the absolute target when dot-segment processing makes lexical relative round trips unstable.
274
+ if (/(?:^|\/)\.{1,2}(?=\/|$)/.test(T.path) || /(?:^|\/)\.{1,2}(?=\/|$)/.test(B.path)) return target;
251
275
  if (T.scheme !== B.scheme || T.authority !== B.authority) return target;
252
276
  let result;
253
277
  if (B.path === T.path) {
@@ -287,6 +311,199 @@ const toRelativeReference = (target, base) => {
287
311
  if (T.authority === undefined && !T.path.startsWith('/') && result.startsWith('..')) return target;
288
312
  return result;
289
313
  };
314
+ // Identify RFC 3986 IPv4 literals without misclassifying numeric registered names.
315
+ function isIPv4Address(host) {
316
+ const octets = host.split('.');
317
+ if (octets.length !== 4) return false;
318
+ // Require the parser's decimal-octet spelling and numeric range for every address part.
319
+ for (const octet of octets) {
320
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(octet) || Number(octet) > 255) return false;
321
+ }
322
+ return true;
323
+ }
324
+ // Normalize percent triplets while optionally decoding only ASCII unreserved octets.
325
+ function normalizePercentEncoding(value, decodeUnreserved = true) {
326
+ let result = '';
327
+ // Preserve literal Unicode while processing each already-validated percent triplet atomically.
328
+ for (let index = 0; index < value.length; index++) {
329
+ if (value[index] !== '%' || !/^[0-9A-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) {
330
+ result += value[index];
331
+ continue;
332
+ }
333
+ const hexadecimal = value.slice(index + 1, index + 3);
334
+ 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()}`;
337
+ index += 2;
338
+ }
339
+ return result;
340
+ }
341
+ // Expand any accepted IPv6 spelling into eight numeric 16-bit fields.
342
+ function parseIPv6Words(address) {
343
+ let expanded = address;
344
+ // Convert a dotted-decimal tail to the same two-field representation used by every later step.
345
+ const lastColon = expanded.lastIndexOf(':');
346
+ const lastSegment = expanded.slice(lastColon + 1);
347
+ if (lastSegment.includes('.')) {
348
+ const octets = lastSegment.split('.');
349
+ const high = Number(octets[0]) * 0x100 + Number(octets[1]);
350
+ const low = Number(octets[2]) * 0x100 + Number(octets[3]);
351
+ expanded = `${expanded.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
352
+ }
353
+
354
+ const compression = expanded.indexOf('::');
355
+ const leftText = compression === -1 ? expanded : expanded.slice(0, compression);
356
+ const rightText = compression === -1 ? '' : expanded.slice(compression + 2);
357
+ const left = leftText ? leftText.split(':') : [];
358
+ const right = rightText ? rightText.split(':') : [];
359
+ const words = [];
360
+ // Retain every explicit field before the compressed zero run.
361
+ for (const field of left) words.push(Number.parseInt(field, 16));
362
+ // Expand the single compression marker to the required number of zero fields.
363
+ if (compression !== -1) {
364
+ // Fill the omitted field count determined from both explicit sides.
365
+ for (let index = left.length + right.length; index < 8; index++) words.push(0);
366
+ }
367
+ // Retain every explicit field after the compressed zero run.
368
+ for (const field of right) words.push(Number.parseInt(field, 16));
369
+ return words;
370
+ }
371
+ // Serialize IPv6 fields with maximal first-run zero compression and lowercase digits.
372
+ function serializeIPv6Words(words) {
373
+ let bestStart = -1;
374
+ let bestLength = 0;
375
+ // Select the first longest run containing at least two zero fields.
376
+ for (let start = 0; start < words.length;) {
377
+ if (words[start] !== 0) {
378
+ start++;
379
+ continue;
380
+ }
381
+ let end = start;
382
+ // Measure this complete zero run before comparing it with the retained candidate.
383
+ while (end < words.length && words[end] === 0) end++;
384
+ if (end - start > bestLength) {
385
+ bestStart = start;
386
+ bestLength = end - start;
387
+ }
388
+ start = end;
389
+ }
390
+ if (bestLength < 2) bestStart = -1;
391
+
392
+ const fields = [];
393
+ // Suppress every leading zero by converting each field through its numeric value.
394
+ for (const word of words) fields.push(word.toString(16));
395
+ if (bestStart === -1) return fields.join(':');
396
+ const before = fields.slice(0, bestStart).join(':');
397
+ const after = fields.slice(bestStart + bestLength).join(':');
398
+ return `${before}::${after}`;
399
+ }
400
+ // Canonicalize an IPv6 address under RFC 5952, including known embedded-IPv4 prefixes.
401
+ function normalizeIPv6Address(address) {
402
+ const words = parseIPv6Words(address);
403
+ // Detect standardized prefixes that identify an embedded IPv4 address from address bits alone.
404
+ const low32 = words[6] * 0x10000 + words[7];
405
+ const compatible = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0 && low32 > 1;
406
+ const mapped = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0xFFFF;
407
+ const translated = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0xFFFF && words[5] === 0;
408
+ const nat64 = words[0] === 0x64 && words[1] === 0xFF9B && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0;
409
+ if (compatible || mapped || translated || nat64) {
410
+ const prefix = serializeIPv6Words(words.slice(0, 6));
411
+ const ipv4 = `${words[6] >>> 8}.${words[6] & 0xFF}.${words[7] >>> 8}.${words[7] & 0xFF}`;
412
+ return prefix.endsWith(':') ? prefix + ipv4 : `${prefix}:${ipv4}`;
413
+ }
414
+ return serializeIPv6Words(words);
415
+ }
416
+ // Lowercase an ASCII host without changing uppercase hexadecimal in retained percent triplets.
417
+ function lowercaseAsciiHost(host) {
418
+ let result = '';
419
+ // Treat each retained percent triplet as an indivisible token during host case folding.
420
+ for (let index = 0; index < host.length; index++) {
421
+ if (host[index] === '%' && /^[0-9A-F]{2}$/.test(host.slice(index + 1, index + 3))) {
422
+ result += host.slice(index, index + 3);
423
+ index += 2;
424
+ } else result += host[index].toLowerCase();
425
+ }
426
+ return result;
427
+ }
428
+ // Normalize a parsed host according to its IP-literal, IPv4, or registered-name kind.
429
+ function normalizeHost(host, mapRegName) {
430
+ if (!host) return host;
431
+ // Keep IP hosts outside application registered-name policy.
432
+ if (host.startsWith('[')) {
433
+ const address = host.slice(1, -1);
434
+ return address[0].toLowerCase() === 'v' ? `[${address.toLowerCase()}]` : `[${normalizeIPv6Address(address)}]`;
435
+ }
436
+ if (isIPv4Address(host)) return host;
437
+
438
+ let result = host;
439
+ // Delegate registered-name representation to the mapper before built-in text normalization.
440
+ if (mapRegName) {
441
+ result = mapRegName(host);
442
+ if (typeof result !== 'string') throw new TypeError('Invalid registered-name mapper result: must be a string.');
443
+ }
444
+ // Preserve the parsed host kind only when no mapper has assumed registered-name ownership.
445
+ const encodedResult = result;
446
+ result = normalizePercentEncoding(result);
447
+ if (!mapRegName && isIPv4Address(result)) result = normalizePercentEncoding(encodedResult, false);
448
+ return /[^\x00-\x7F]/u.test(result) ? result : lowercaseAsciiHost(result);
449
+ }
450
+ // Remove an empty or default-valued HTTP or WebSocket port.
451
+ function normalizePort(scheme, port) {
452
+ if (port === undefined) return undefined;
453
+ const defaultPort = scheme === 'http' || scheme === 'ws' ? '80' : scheme === 'https' || scheme === 'wss' ? '443' : undefined;
454
+ if (defaultPort !== undefined && (port === '' || port.replace(/^0+(?=\d)/, '') === defaultPort)) return undefined;
455
+ return port;
456
+ }
457
+ // Encode each non-ASCII Unicode scalar as uppercase UTF-8 percent triplets.
458
+ function encodeIriComponent(component) {
459
+ // Process complete code points so supplementary characters produce one UTF-8 sequence.
460
+ return component.replace(/[^\x00-\x7F]/gu, (character) => encodeURIComponent(character).toUpperCase());
461
+ }
462
+ // Rebuild authority from normalized values while preserving other empty component delimiters.
463
+ function normalizeAuthority(parts, scheme, mapRegName) {
464
+ if (parts.authority === undefined) return undefined;
465
+ const userinfo = parts.userinfo === undefined ? undefined : normalizePercentEncoding(parts.userinfo);
466
+ const host = normalizeHost(parts.host, mapRegName);
467
+ const port = normalizePort(scheme, parts.port);
468
+ let authority = '';
469
+ if (userinfo !== undefined) authority += `${userinfo}@`;
470
+ authority += host;
471
+ if (port !== undefined) authority += `:${port}`;
472
+ return authority;
473
+ }
474
+ // Derive a normalized string from parsed URI/IRI components without modifying them.
475
+ function normalizeParsedReference(parts, options = {}) {
476
+ // Validate the optional API settings before they select normalization behavior.
477
+ 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.');
480
+ if (mapRegName !== undefined && typeof mapRegName !== 'function') throw new TypeError('Invalid registered-name mapper type: must be a function.');
481
+ // Normalize each component independently so encoded delimiters cannot become structure.
482
+ const scheme = parts.scheme === undefined ? undefined : parts.scheme.toLowerCase();
483
+ const normalized = {
484
+ scheme,
485
+ authority: normalizeAuthority(parts, scheme, mapRegName),
486
+ path: normalizePercentEncoding(parts.path),
487
+ query: parts.query === undefined ? undefined : normalizePercentEncoding(parts.query),
488
+ fragment: parts.fragment === undefined ? undefined : normalizePercentEncoding(parts.fragment),
489
+ };
490
+ // Use the slash form defined for an empty HTTP or WebSocket authority path.
491
+ if (normalized.authority !== undefined && normalized.path === '' && (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss')) normalized.path = '/';
492
+ // Limit dot-segment removal to paths whose standalone interpretation remains stable.
493
+ const rootlessRelativePath = normalized.scheme === undefined && normalized.authority === undefined && normalized.path.length > 0 && !normalized.path.startsWith('/');
494
+ if (!rootlessRelativePath) {
495
+ const reducedPath = removeDotSegments(normalized.path);
496
+ // Preserve a no-authority path when reduction would reparse it as an authority.
497
+ if (normalized.authority !== undefined || !reducedPath.startsWith('//')) normalized.path = reducedPath;
498
+ }
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);
505
+ return compose(normalized);
506
+ }
290
507
  // export
291
508
  module.exports = {
292
509
  isUUID: (string) => validate(string, 'UUID'),
@@ -305,6 +522,5 @@ module.exports = {
305
522
  parseAbsoluteIri: (string) => parse(string, 'absolute_IRI'),
306
523
  resolveReference,
307
524
  toRelativeReference,
308
- toAbsoluteReference: (string) => resolveReference('', string),
309
- normalizeReference: (string) => string, // not done yet
525
+ toAbsoluteReference
310
526
  };
package/normalization.md CHANGED
@@ -1,299 +1,134 @@
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 IRI-to-URI output. 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 NormalizeOptions = {
11
+ toUri?: boolean
12
+ mapRegName?: RegNameMapper
13
+ }
14
+
15
+ interface NormalizableReference {
16
+ normalize(options?: NormalizeOptions): string
17
+ }
18
+ ```
19
+
20
+ The method is available on results from:
21
+
22
+ - `parseUri`
23
+ - `parseUriReference`
24
+ - `parseAbsoluteUri`
25
+ - `parseIri`
26
+ - `parseIriReference`
27
+ - `parseAbsoluteIri`
28
+
29
+ It is non-enumerable and reads the result object's current component properties when called.
30
+
31
+ ```js
32
+ const { parseIriReference } = require('identifier-js');
33
+
34
+ const parsed = parseIriReference(
35
+ 'HTTP://Example.COM/%7e/a/../b?x=%2f#%41',
36
+ );
37
+
38
+ parsed.normalize();
39
+ // http://example.com/~/b?x=%2F#A
40
+
41
+ parsed.path;
42
+ // /%7e/a/../b
43
+ ```
44
+
45
+ ## RFC syntax normalization
46
+
47
+ | Behavior | Implementation | Source |
48
+ | --- | --- | --- |
49
+ | 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) |
50
+ | 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) |
51
+ | 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
+ | 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
+ | 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) |
55
+
56
+ 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
+
58
+ ## HTTP and HTTPS
59
+
60
+ For `http` and `https`, normalization applies the generic rules and these scheme rules:
61
+
62
+ | Input component | Output | Source |
63
+ | --- | --- | --- |
64
+ | Empty port | Omit the port delimiter. | [RFC 3986 §3.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.3) |
65
+ | `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) |
66
+ | `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) |
67
+ | Empty authority path | Use `/`. | [RFC 9110 §4.2.3](https://www.rfc-editor.org/rfc/rfc9110#section-4.2.3) |
68
+
69
+ Decimal port comparison includes leading-zero spellings.
70
+
71
+ ```text
72
+ HTTP://Example.COM:80 → http://example.com/
73
+ https://example.com:00443/a → https://example.com/a
74
+ http://example.com:/a → http://example.com/a
75
+ ```
76
+
77
+ ## WS and WSS
78
+
79
+ For `ws` and `wss`, normalization applies the generic rules and these scheme rules:
80
+
81
+ | Input component | Output | Source |
82
+ | --- | --- | --- |
83
+ | Empty port | Omit the port delimiter. | [RFC 3986 §3.2.3](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.3) |
84
+ | `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) |
85
+ | `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) |
86
+ | Empty authority path | Use `/` as the resource-name path. | [RFC 6455 §3](https://www.rfc-editor.org/rfc/rfc6455#section-3) |
87
+
88
+ ```text
89
+ WS://Example.COM:80 → ws://example.com/
90
+ wss://example.com:00443/chat → wss://example.com/chat
91
+ ws://example.com?channel=updates → ws://example.com/?channel=updates
92
+ ```
93
+
94
+ ## Registered-name mapping and URI output
95
+
96
+ 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
+
98
+ 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.
99
+
100
+ ```js
101
+ const mapped = parseIriReference('x://example').normalize({
102
+ mapRegName: () => 'Application Defined',
103
+ });
104
+
105
+ mapped;
106
+ // x://application defined
107
+ ```
108
+
109
+ The example deliberately produces text that is not a valid URI or IRI; validating or selecting mapper output belongs to the application.
110
+
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.
112
+
113
+ ```js
114
+ const { parseIri } = require('identifier-js');
115
+
116
+ parseIri('x:/café?q=資料#résultat').normalize({ toUri: true });
117
+ // x:/caf%C3%A9?q=%E8%B3%87%E6%96%99#r%C3%A9sultat
118
+ ```
119
+
120
+ ## Verification
121
+
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.
123
+
124
+ ```sh
125
+ npm test
126
+ ```
127
+
128
+ ## References
129
+
130
+ - [RFC 3986 — Uniform Resource Identifier: Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986)
131
+ - [RFC 3987 — Internationalized Resource Identifiers](https://www.rfc-editor.org/rfc/rfc3987)
132
+ - [RFC 5952 — A Recommendation for IPv6 Address Text Representation](https://www.rfc-editor.org/rfc/rfc5952)
133
+ - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
134
+ - [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.2.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,49 @@ 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 NormalizeOptions = {
217
+ toUri?: boolean
218
+ mapRegName?: RegNameMapper
219
+ }
220
+
221
+ interface NormalizableReference {
222
+ normalize(options?: NormalizeOptions): string
223
+ }
214
224
  ```
215
225
 
216
226
  ```js
217
- const { normalizeReference } = require('identifier-js');
227
+ const { parseIriReference } = require('identifier-js');
218
228
 
219
- console.log(normalizeReference('HTTP://Example.COM/a/../b'));
220
- // HTTP://Example.COM/a/../b
229
+ const parsed = parseIriReference('HTTP://Example.COM/%7e/a/../b?x=%2f#%41');
230
+ console.log(parsed.host); // Example.COM
231
+ console.log(parsed.normalize());
232
+ // http://example.com/~/b?x=%2F#A
233
+ console.log(parsed.path); // /%7e/a/../b
221
234
  ```
222
235
 
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.
236
+ The method is available from `parseUri`, `parseUriReference`, `parseAbsoluteUri`, `parseIri`, `parseIriReference`, and `parseAbsoluteIri`.
237
+
238
+ 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.
239
+
240
+ 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
+
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.
243
+
244
+ See [`normalization.md`](normalization.md) for the exact RFC section mapping and examples.
224
245
 
225
246
  </details>
226
247
 
@@ -243,7 +264,7 @@ console.log(isUUID('99c17cbb-656f-564a-940f-1a4568f03487')); // true
243
264
  console.log(isUUIDv4('123e4567-e89b-42d3-9456-426614174000')); // true
244
265
  ```
245
266
 
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`.
267
+ `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
268
 
248
269
  </details>
249
270
 
@@ -279,9 +300,9 @@ The following schemes trigger DNS-style ASCII or Unicode label rules instead of
279
300
  - `wss`
280
301
  - `file`
281
302
 
282
- Matching is case-insensitive. Other valid schemes use generic RFC 3986/3987 registered-name syntax.
303
+ 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
304
 
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.
305
+ 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
306
 
286
307
  </details>
287
308
 
@@ -349,7 +370,7 @@ RFC 3987 uses IRIs for internationalized identification while requiring mapping
349
370
 
350
371
  ### Validate and route protocol identifiers
351
372
 
352
- Component parsing supports policy decisions without unsafe string splitting.
373
+ Component parsing provides scheme, authority, host, port, path, and query fields for policy decisions.
353
374
 
354
375
  <details>
355
376
  <summary><strong>Example and context</strong></summary>
@@ -364,7 +385,7 @@ console.log(parts.port); // 8443
364
385
  console.log(parts.path); // /events
365
386
  ```
366
387
 
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.
388
+ 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
389
 
369
390
  </details>
370
391
 
@@ -386,46 +407,41 @@ try {
386
407
  }
387
408
  ```
388
409
 
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.
410
+ RFC 9562 lists database keys, filenames, system identifiers, and transaction identifiers among common UUID uses.
390
411
 
391
412
  </details>
392
413
 
393
- ## Intentional behavior and limitations
414
+ ## Standards behavior
394
415
 
395
416
  <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.
417
+ <summary><strong>Validation and parsing</strong></summary>
418
+
419
+ - Generic URI syntax follows RFC 3986 character and component grammar; recognized schemes select the documented hostname profile.
420
+ - Generic IRI syntax follows the RFC 3987 Unicode extensions to URI grammar; recognized schemes select the documented hostname profile.
421
+ - Validators return `true` or throw at the first grammar violation.
422
+ - `absolute-URI` and `absolute-IRI` use the fragment-free grammar defined by their RFCs; complete URI and IRI operations accept fragments.
423
+ - Port syntax follows RFC 3986 `port = *DIGIT`, including empty and leading-zero values.
424
+ - Parsing records absent optional components as `undefined` and present-empty components as empty strings.
407
425
 
408
426
  </details>
409
427
 
410
428
  <details>
411
- <summary><strong>Resolution and conversion boundaries</strong></summary>
429
+ <summary><strong>Resolution, conversion, and normalization</strong></summary>
412
430
 
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.
431
+ - `resolveReference` implements RFC 3986 §5 component inheritance, path merging, dot-segment removal, and recomposition for URI and IRI text.
432
+ - An empty reference path inherits the base path unchanged.
433
+ - `strict = false` implements RFC 3986 §5.2.2 backward-compatible same-scheme handling.
434
+ - `toAbsoluteReference` removes the fragment from an identifier containing a scheme.
435
+ - `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.
420
437
 
421
438
  </details>
422
439
 
423
440
  <details>
424
- <summary><strong>UUID boundaries</strong></summary>
441
+ <summary><strong>UUID validation</strong></summary>
425
442
 
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.
443
+ - `isUUID` validates the RFC 9562 hexadecimal `8-4-4-4-12` text layout.
444
+ - `isUUIDv4` additionally validates version `4` and the RFC variant bits.
429
445
 
430
446
  </details>
431
447
 
@@ -457,7 +473,7 @@ Run `gh workspace-data load` again to refresh materialized data after public-dat
457
473
 
458
474
  ### Tests
459
475
 
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.
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.
461
477
 
462
478
  <details>
463
479
  <summary><strong>Test details</strong></summary>
@@ -500,7 +516,7 @@ Direct invocation also supports an explicit iteration count; `npm run benchmark`
500
516
  node ./#/public/benchmarks --iterations 250000
501
517
  ```
502
518
 
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.
519
+ 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
520
 
505
521
  </details>
506
522
 
@@ -512,7 +528,3 @@ The generic coordinator delegates version-layer selection and ordered concern di
512
528
  - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
513
529
  - [RFC 8089 — The `file` URI Scheme](https://www.rfc-editor.org/rfc/rfc8089)
514
530
  - [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.