identifier-js 0.4.1 → 0.4.3

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.js CHANGED
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
- // Parse, validate, normalize, resolve, and convert RFC 3986 URI, RFC 3987 IRI, and RFC 8141 URN references.
2
+ // Validate UUIDs and parse, validate, normalize, resolve, and convert RFC 3986 URI, RFC 3987 IRI, and RFC 8141 URN references.
3
3
  // A valid URI is always a valid IRI, subject to every implemented scheme's more specific grammar.
4
4
  const { recursiveCompile } = require('url-templates');
5
- const patterns = new Map();
6
- const implemented_schemes = '(?:[hH][tT][tT][pP][sS]?|[wW][sS][sS]?|[fF][iI][lL][eE])';
7
- // RFC3986/RFC3987 common rules + https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2:~:text=DNS%29%2E-,A,of%20%5BRFC1123%5D%2E
5
+ const patternCache = new Map();
6
+ const dnsHostSchemesPattern = '(?:[hH][tT][tT][pP][sS]?|[wW][sS][sS]?|[fF][iI][lL][eE])';
7
+ // Define shared RFC 3986/3987 productions and helper productions used by UUID and scheme-specific grammars.
8
8
  const commonRules = {
9
- implemented_schemes,
10
- scheme: '(?!{implemented_schemes}:)[a-zA-Z][a-zA-Z0-9+.-]*',
9
+ dnsHostSchemesPattern,
10
+ scheme: '(?!{dnsHostSchemesPattern}:)[a-zA-Z][a-zA-Z0-9+.-]*',
11
11
  port: '\\d*',
12
12
  IP_literal: '\\[(?:{IPv6address}|{IPvFuture})\\]',
13
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})?::)',
@@ -26,7 +26,7 @@ const commonRules = {
26
26
  UUID: '{hex_digit}{8}-{hex_digit}{4}-{hex_digit}{4}-{hex_digit}{4}-{hex_digit}{12}',
27
27
  UUID_v4: '{hex_digit}{8}-{hex_digit}{4}-4{hex_digit}{3}-[89abAB]{hex_digit}{3}-{hex_digit}{12}',
28
28
  };
29
- // RFC3986 rules
29
+ // Define RFC 3986 URI productions.
30
30
  const uriRules = {
31
31
  URI_reference: '(?:{URI}|{relative_ref})',
32
32
  URI: '{absolute_URI}(?:#{fragment})?',
@@ -51,7 +51,7 @@ const uriRules = {
51
51
  fragment: '(?:{pchar}|\/|\\?)*',
52
52
  pchar: '(?:{unreserved}|{pct_encoded}|{sub_delims}|:|@)',
53
53
  };
54
- // RFC3987 rules
54
+ // Define RFC 3987 IRI productions.
55
55
  const iriRules = {
56
56
  IRI_reference: '(?:{IRI}|{irelative_ref})',
57
57
  IRI: '{absolute_IRI}(?:#{ifragment})?',
@@ -79,32 +79,34 @@ const iriRules = {
79
79
  iprivate: '[\\uE000-\\uF8FF\\u{F0000}-\\u{FFFFD}\\u{100000}-\\u{10FFFD}]',
80
80
  ucschar: '[\\xA0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF\\u{10000}-\\u{1FFFD}\\u{20000}-\\u{2FFFD}\\u{30000}-\\u{3FFFD}\\u{40000}-\\u{4FFFD}\\u{50000}-\\u{5FFFD}\\u{60000}-\\u{6FFFD}\\u{70000}-\\u{7FFFD}\\u{80000}-\\u{8FFFD}\\u{90000}-\\u{9FFFD}\\u{A0000}-\\u{AFFFD}\\u{B0000}-\\u{BFFFD}\\u{C0000}-\\u{CFFFD}\\u{D0000}-\\u{DFFFD}\\u{E1000}-\\u{EFFFD}]',
81
81
  };
82
- // Define RFC 8141 productions and URI/IRI root overrides for the conditional URN profile.
82
+ // Define a closed RFC 8141 profile whose root overrides and direct fragment expression expose only URN captures.
83
83
  const urnRules = {
84
84
  scheme: '[uU][rR][nN]',
85
+ URI_reference: '{URI}',
85
86
  URI: '{namestring}',
86
87
  absolute_URI: '{assigned_name}(?:{rq_components})?',
88
+ IRI_reference: '{IRI}',
87
89
  IRI: '{URI}',
88
90
  absolute_IRI: '{absolute_URI}',
89
91
  namestring: '{assigned_name}(?:{rq_components})?(?:#{f_component})?',
90
92
  assigned_name: '{scheme}:{NID}:{NSS}',
91
93
  NID: '{alpha_digit}{ldh}{0,30}{alpha_digit}',
92
94
  ldh: '(?:{alpha_digit}|-)',
93
- NSS: '{pchar}(?:{pchar}|/)*',
95
+ NSS: '{pchar}(?:{pchar}|\/)*',
94
96
  rq_components: '(?:[?][+]{r_component})?(?:[?]={q_component})?',
95
- r_component: '{pchar}(?:{pchar}|/|[?](?!=))*',
96
- q_component: '{pchar}(?:{pchar}|/|[?])*',
97
- f_component: '{fragment}',
97
+ r_component: '{pchar}(?:{pchar}|\/|[?](?!=))*',
98
+ q_component: '{pchar}(?:{pchar}|\/|[?])*',
99
+ f_component: uriRules.fragment,
98
100
  };
99
- // Reuse the grammar repertoires when selecting URI octets safe for IRI output.
101
+ // Compile character-repertoire checks used during URI/IRI percent-encoding normalization.
100
102
  const uriUnreservedPattern = new RegExp(`^${commonRules.unreserved}$`);
101
103
  const iriUcscharPattern = new RegExp(`^${iriRules.ucschar}$`, 'u');
102
104
  const iriPrivatePattern = new RegExp(`^${iriRules.iprivate}$`, 'u');
103
105
  // Apply the additional RFC 3987 Section 4.1 prose restriction outside the ABNF repertoire.
104
106
  const forbiddenIriFormattingPattern = /^[\u200E\u200F\u202A-\u202E]$/u;
105
- // scheme specific URI reg_name and IRI ireg_name
106
- const schemeSpecificRules = {
107
- scheme: implemented_schemes,
107
+ // Restrict registered names for selected hierarchical schemes to DNS-style labels.
108
+ const dnsHostRules = {
109
+ scheme: dnsHostSchemesPattern,
108
110
  reg_name: '(?:(?=.{1,255}(?:[:/?#]|$))(?:{a_label})(?:\\.{a_label})*)',
109
111
  a_label: '(?:{alpha_digit})(?:(?:{alpha_digit}|-){0,61}(?:{alpha_digit}))?',
110
112
  ireg_name: '(?:(?=.{1,255}(?:[:/?#]|$))(?:{u_label})(?:{u_separator}(?:{u_label}))*)',
@@ -113,13 +115,13 @@ const schemeSpecificRules = {
113
115
  u_char: '[\\p{L}\\p{N}\\p{Mn}\\p{Mc}\\u200C\\u200D\\u00B7\\u0375\\u30FB\\u05F3\\u05F4]',
114
116
  };
115
117
  // Recognize RFC 8089's empty file authority without weakening other scheme host policies.
116
- const emptyFileHostRules = Object.assign({}, schemeSpecificRules, {
118
+ const emptyFileHostRules = Object.assign({}, dnsHostRules, {
117
119
  scheme: '[fF][iI][lL][eE]',
118
120
  reg_name: '',
119
121
  ireg_name: '',
120
122
  });
121
- // pattern RFC group names
122
- const groupNames = {
123
+ // Map grammar productions to the public named captures returned by parsers.
124
+ const captureGroupNames = {
123
125
  scheme: 'scheme',
124
126
  port: 'port',
125
127
  authority: 'authority',
@@ -148,25 +150,21 @@ const groupNames = {
148
150
  q_component: 'qComponent',
149
151
  f_component: 'fComponent',
150
152
  };
151
- // Keep URN parse results limited to their RFC 8141 component names.
152
- const genericUrnGroupNames = new Set(['authority', 'userinfo', 'host', 'port', 'path', 'query', 'fragment']);
153
- // Detect schemes for which the package implements grammar beyond generic URI/IRI syntax.
154
- const isSpecificScheme = (string) => new RegExp('^' + implemented_schemes + ':').test(string);
155
- // Select and merge generic, DNS-host, empty-file-host, or URN grammar profiles.
156
- const schemeProfile = (string) => (string.slice(0, 4).toLowerCase() === 'urn:' ? 'u' : string.slice(0, 8).toLowerCase() === 'file:///' ? 'f' : isSpecificScheme(string) ? 's' : '');
157
- const rules = (profile) => Object.assign({}, commonRules, uriRules, iriRules, profile === 'u' ? urnRules : profile === 'f' ? emptyFileHostRules : profile ? schemeSpecificRules : {});
158
- // parse (slower, it uses regex.exec and includes named capture groups)
153
+ // Detect schemes whose registered names use the DNS-host grammar.
154
+ const usesDnsHostRules = (string) => new RegExp('^' + dnsHostSchemesPattern + ':').test(string);
155
+ // Grammar profiles: '' is generic, 's' uses DNS-host rules, 'f' permits an empty file host, and 'u' is URN.
156
+ const grammarProfile = (string) => (string.slice(0, 4).toLowerCase() === 'urn:' ? 'u' : string.slice(0, 8).toLowerCase() === 'file:///' ? 'f' : usesDnsHostRules(string) ? 's' : '');
157
+ // Select and merge the rules for the active grammar profile.
158
+ const grammarRules = (profile) => Object.assign({}, commonRules, uriRules, iriRules, profile === 'u' ? urnRules : profile === 'f' ? emptyFileHostRules : profile ? dnsHostRules : {});
159
+ // Compile and execute a grammar with named component captures.
159
160
  const parse = (string, rule) => {
160
161
  if (typeof string !== 'string') throw new TypeError(`Invalid ${rule.replace('_', '-')} type: must be a string.`);
161
- const profile = schemeProfile(string);
162
- // Select only the component captures exposed by the active grammar.
163
- const addNames = (key) => {
164
- const groupName = groupNames[key];
165
- return groupName && !(profile === 'u' && genericUrnGroupNames.has(groupName)) ? `(?<${groupName}>${rules(profile)[key]})` : rules(profile)[key];
166
- };
167
- const ruleId = '_' + profile + rule;
168
- if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(profile), rule, addNames)}$`, 'u'));
169
- const match = patterns.get(ruleId).exec(string);
162
+ const profile = grammarProfile(string);
163
+ // Wrap each public component production in its associated named capture.
164
+ const addNamedCapture = (key) => (captureGroupNames[key] ? `(?<${captureGroupNames[key]}>${grammarRules(profile)[key]})` : grammarRules(profile)[key]);
165
+ const cacheKey = '_' + profile + rule;
166
+ if (!patternCache.has(cacheKey)) patternCache.set(cacheKey, new RegExp(`^${recursiveCompile(grammarRules(profile), rule, addNamedCapture)}$`, 'u'));
167
+ const match = patternCache.get(cacheKey).exec(string);
170
168
  if (match) {
171
169
  Object.defineProperty(match.groups, 'normalize', {
172
170
  // Normalize this parsed result only when its optional method is called.
@@ -178,17 +176,17 @@ const parse = (string, rule) => {
178
176
  }
179
177
  throw new SyntaxError(`Invalid ${rule.replace('_', '-')}: ${string}`);
180
178
  };
181
- // validate (faster, it uses regex.test and does not include named capture groups)
179
+ // Compile and test a capture-free grammar for validation.
182
180
  const validate = (string, rule) => {
183
181
  if (typeof string !== 'string') throw new TypeError(`Invalid ${rule.replace('_', '-')} type: must be a string.`);
184
- const profile = schemeProfile(string);
185
- const ruleId = profile + rule;
186
- if (!patterns.has(ruleId)) patterns.set(ruleId, new RegExp(`^${recursiveCompile(rules(profile), rule)}$`, 'u'));
187
- if (patterns.get(ruleId).test(string)) return true;
182
+ const profile = grammarProfile(string);
183
+ const cacheKey = profile + rule;
184
+ if (!patternCache.has(cacheKey)) patternCache.set(cacheKey, new RegExp(`^${recursiveCompile(grammarRules(profile), rule)}$`, 'u'));
185
+ if (patternCache.get(cacheKey).test(string)) return true;
188
186
  throw new SyntaxError(`Invalid ${rule.replace('_', '-')}: ${string}`);
189
187
  };
190
- // compose as per RFC 3986 Section 5.3 (component recomposition)
191
- function compose(parts = {}) {
188
+ // Serialize scheme, authority, path, query, and fragment slots using RFC 3986 delimiters.
189
+ function composeReference(parts = {}) {
192
190
  let result = '';
193
191
  if (parts.scheme) result += parts.scheme + ':';
194
192
  if (parts.authority !== undefined && parts.authority !== null) result += '//' + parts.authority;
@@ -197,7 +195,8 @@ function compose(parts = {}) {
197
195
  if (parts.fragment !== undefined && parts.fragment !== null) result += '#' + parts.fragment;
198
196
  return result;
199
197
  }
200
- // remove dot segments algorithm per RFC 3986 Section 5.2.4 (loop and replace)
198
+ // Local abbreviations: idx is an index and seg is a path segment.
199
+ // Remove complete dot segments using the RFC 3986 Section 5.2.4 algorithm.
201
200
  function removeDotSegments(path) {
202
201
  const output = [];
203
202
  let input = path ?? '';
@@ -242,8 +241,9 @@ function removeDotSegments(path) {
242
241
  }
243
242
  return output.join('');
244
243
  }
245
- // resolve as per RFC https://datatracker.ietf.org/doc/html/rfc3986#section-5.2
246
- function resolveReference(reference, base, strict = true, parts = false) {
244
+ // RFC 3986 resolution notation: B is the base, R is the reference, and T is the target.
245
+ // Resolve a reference according to RFC 3986 Section 5.2.
246
+ function resolveReference(reference, base, strict = true, returnParts = false) {
247
247
  let B;
248
248
  if (typeof base === 'string') {
249
249
  B = parse(base, 'IRI');
@@ -292,14 +292,14 @@ function resolveReference(reference, base, strict = true, parts = false) {
292
292
  }
293
293
  T.fragment = R.fragment;
294
294
  }
295
- if (parts) return T;
296
- return compose(T);
295
+ if (returnParts) return T;
296
+ return composeReference(T);
297
297
  }
298
298
  // Convert a complete IRI to fragment-free form without changing its other components.
299
299
  function toAbsoluteReference(string) {
300
300
  const result = parse(string, 'IRI');
301
301
  result.fragment = undefined;
302
- return compose(result);
302
+ return composeReference(result);
303
303
  }
304
304
  // Generate a relative reference when resolution is stable, otherwise retain the absolute target.
305
305
  const toRelativeReference = (target, base) => {
@@ -378,27 +378,27 @@ function normalizePercentEncoding(value, decodeUnreserved = true) {
378
378
  }
379
379
  // Expand any accepted IPv6 spelling into eight numeric 16-bit fields.
380
380
  function parseIPv6Words(address) {
381
- let expanded = address;
381
+ let addressText = address;
382
382
  // Convert a dotted-decimal tail to the same two-field representation used by every later step.
383
- const lastColon = expanded.lastIndexOf(':');
384
- const lastSegment = expanded.slice(lastColon + 1);
383
+ const lastColon = addressText.lastIndexOf(':');
384
+ const lastSegment = addressText.slice(lastColon + 1);
385
385
  if (lastSegment.includes('.')) {
386
386
  const octets = lastSegment.split('.');
387
- const high = Number(octets[0]) * 0x100 + Number(octets[1]);
388
- const low = Number(octets[2]) * 0x100 + Number(octets[3]);
389
- expanded = `${expanded.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
387
+ const highWord = Number(octets[0]) * 0x100 + Number(octets[1]);
388
+ const lowWord = Number(octets[2]) * 0x100 + Number(octets[3]);
389
+ addressText = `${addressText.slice(0, lastColon + 1)}${highWord.toString(16)}:${lowWord.toString(16)}`;
390
390
  }
391
391
 
392
- const compression = expanded.indexOf('::');
393
- const leftText = compression === -1 ? expanded : expanded.slice(0, compression);
394
- const rightText = compression === -1 ? '' : expanded.slice(compression + 2);
392
+ const compressionIndex = addressText.indexOf('::');
393
+ const leftText = compressionIndex === -1 ? addressText : addressText.slice(0, compressionIndex);
394
+ const rightText = compressionIndex === -1 ? '' : addressText.slice(compressionIndex + 2);
395
395
  const left = leftText ? leftText.split(':') : [];
396
396
  const right = rightText ? rightText.split(':') : [];
397
397
  const words = [];
398
398
  // Retain every explicit field before the compressed zero run.
399
399
  for (const field of left) words.push(Number.parseInt(field, 16));
400
400
  // Expand the single compression marker to the required number of zero fields.
401
- if (compression !== -1) {
401
+ if (compressionIndex !== -1) {
402
402
  // Fill the omitted field count determined from both explicit sides.
403
403
  for (let index = left.length + right.length; index < 8; index++) words.push(0);
404
404
  }
@@ -428,7 +428,7 @@ function serializeIPv6Words(words) {
428
428
  if (bestLength < 2) bestStart = -1;
429
429
 
430
430
  const fields = [];
431
- // Suppress every leading zero by converting each field through its numeric value.
431
+ // Render each field without leading hexadecimal zeroes.
432
432
  for (const word of words) fields.push(word.toString(16));
433
433
  if (bestStart === -1) return fields.join(':');
434
434
  const before = fields.slice(0, bestStart).join(':');
@@ -440,11 +440,11 @@ function normalizeIPv6Address(address) {
440
440
  const words = parseIPv6Words(address);
441
441
  // Detect standardized prefixes that identify an embedded IPv4 address from address bits alone.
442
442
  const low32 = words[6] * 0x10000 + words[7];
443
- const compatible = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0 && low32 > 1;
444
- const mapped = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0xFFFF;
445
- const translated = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0xFFFF && words[5] === 0;
446
- const nat64 = words[0] === 0x64 && words[1] === 0xFF9B && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0;
447
- if (compatible || mapped || translated || nat64) {
443
+ const isCompatible = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0 && low32 > 1;
444
+ const isMapped = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0xFFFF;
445
+ const isTranslated = words[0] === 0 && words[1] === 0 && words[2] === 0 && words[3] === 0 && words[4] === 0xFFFF && words[5] === 0;
446
+ const isWellKnownNat64 = words[0] === 0x64 && words[1] === 0xFF9B && words[2] === 0 && words[3] === 0 && words[4] === 0 && words[5] === 0;
447
+ if (isCompatible || isMapped || isTranslated || isWellKnownNat64) {
448
448
  const prefix = serializeIPv6Words(words.slice(0, 6));
449
449
  const ipv4 = `${words[6] >>> 8}.${words[6] & 0xFF}.${words[7] >>> 8}.${words[7] & 0xFF}`;
450
450
  return prefix.endsWith(':') ? prefix + ipv4 : `${prefix}:${ipv4}`;
@@ -485,20 +485,20 @@ function normalizeHost(host, mapRegName) {
485
485
  if (!mapRegName && isIPv4Address(result)) result = normalizePercentEncoding(encodedResult, false);
486
486
  return /[^\x00-\x7F]/u.test(result) ? result : lowercaseAsciiHost(result);
487
487
  }
488
- // Remove an empty or default-valued HTTP or WebSocket port.
488
+ // Omit an empty or default port only for HTTP and WebSocket schemes.
489
489
  function normalizePort(scheme, port) {
490
490
  if (port === undefined) return undefined;
491
491
  const defaultPort = scheme === 'http' || scheme === 'ws' ? '80' : scheme === 'https' || scheme === 'wss' ? '443' : undefined;
492
492
  if (defaultPort !== undefined && (port === '' || port.replace(/^0+(?=\d)/, '') === defaultPort)) return undefined;
493
493
  return port;
494
494
  }
495
- // Encode each non-ASCII Unicode scalar as uppercase UTF-8 percent triplets.
496
- function encodeIriComponent(component) {
495
+ // Encode each non-ASCII Unicode scalar as uppercase UTF-8 percent triplets for URI output.
496
+ function encodeNonAsciiForUri(component) {
497
497
  // Process complete code points so supplementary characters produce one UTF-8 sequence.
498
498
  return component.replace(/[^\x00-\x7F]/gu, (character) => encodeURIComponent(character).toUpperCase());
499
499
  }
500
- // Decode the maximal RFC 3987 URI octet repertoire allowed by one IRI component.
501
- function decodeUriComponentToIri(component, allowPrivate = false) {
500
+ // Decode the maximal RFC 3987 character repertoire allowed by one IRI component.
501
+ function decodeUriTextToIri(component, allowPrivateUse = false) {
502
502
  let result = '';
503
503
  // Inspect each normalized percent triplet as either ASCII or the lead of one strict UTF-8 scalar.
504
504
  for (let index = 0; index < component.length; index++) {
@@ -514,28 +514,28 @@ function decodeUriComponentToIri(component, allowPrivate = false) {
514
514
  continue;
515
515
  }
516
516
  const sequenceLength = octet >= 0xC2 && octet <= 0xDF ? 2 : octet >= 0xE0 && octet <= 0xEF ? 3 : octet >= 0xF0 && octet <= 0xF4 ? 4 : 0;
517
- let encoded = '';
517
+ let encodedSequence = '';
518
518
  // Collect exactly one candidate scalar without consuming malformed trailing input.
519
519
  for (let sequenceIndex = 0; sequenceIndex < sequenceLength; sequenceIndex++) {
520
520
  const position = index + sequenceIndex * 3;
521
521
  if (component[position] !== '%' || !/^[0-9A-F]{2}$/.test(component.slice(position + 1, position + 3))) {
522
- encoded = '';
522
+ encodedSequence = '';
523
523
  break;
524
524
  }
525
- encoded += component.slice(position, position + 3);
525
+ encodedSequence += component.slice(position, position + 3);
526
526
  }
527
527
  let character;
528
- if (encoded) {
528
+ if (encodedSequence) {
529
529
  try {
530
- character = decodeURIComponent(encoded);
530
+ character = decodeURIComponent(encodedSequence);
531
531
  } catch {
532
532
  character = undefined;
533
533
  }
534
534
  }
535
- const allowed = character !== undefined && !forbiddenIriFormattingPattern.test(character) && (iriUcscharPattern.test(character) || (allowPrivate && iriPrivatePattern.test(character)));
536
- if (allowed) {
535
+ const isAllowedIriCharacter = character !== undefined && !forbiddenIriFormattingPattern.test(character) && (iriUcscharPattern.test(character) || (allowPrivateUse && iriPrivatePattern.test(character)));
536
+ if (isAllowedIriCharacter) {
537
537
  result += character;
538
- index += encoded.length - 1;
538
+ index += encodedSequence.length - 1;
539
539
  } else {
540
540
  result += `%${hexadecimal}`;
541
541
  index += 2;
@@ -543,7 +543,7 @@ function decodeUriComponentToIri(component, allowPrivate = false) {
543
543
  }
544
544
  return result;
545
545
  }
546
- // Rebuild authority from normalized values while preserving other empty component delimiters.
546
+ // Rebuild authority from normalized userinfo, host, and port while preserving their presence.
547
547
  function normalizeAuthority(parts, scheme, mapRegName) {
548
548
  if (parts.authority === undefined) return undefined;
549
549
  const userinfo = parts.userinfo === undefined ? undefined : normalizePercentEncoding(parts.userinfo);
@@ -566,12 +566,12 @@ function normalizeParsedReference(parts, options = {}) {
566
566
  if (parts.nid !== undefined) {
567
567
  const rComponent = parts.rComponent === undefined ? undefined : normalizePercentEncoding(parts.rComponent, false);
568
568
  const qComponent = parts.qComponent === undefined ? undefined : normalizePercentEncoding(parts.qComponent, false);
569
- const query = rComponent !== undefined ? `+${rComponent}${qComponent === undefined ? '' : `?=${qComponent}`}` : qComponent === undefined ? undefined : `=${qComponent}`;
570
- return compose({ scheme: parts.scheme.toLowerCase(), path: `${parts.nid.toLowerCase()}:${normalizePercentEncoding(parts.nss, false)}`, query, fragment: parts.fComponent === undefined ? undefined : normalizePercentEncoding(parts.fComponent, false) });
569
+ const rqComponentText = rComponent !== undefined ? `+${rComponent}${qComponent === undefined ? '' : `?=${qComponent}`}` : qComponent === undefined ? undefined : `=${qComponent}`;
570
+ return composeReference({ scheme: parts.scheme.toLowerCase(), path: `${parts.nid.toLowerCase()}:${normalizePercentEncoding(parts.nss, false)}`, query: rqComponentText, fragment: parts.fComponent === undefined ? undefined : normalizePercentEncoding(parts.fComponent, false) });
571
571
  }
572
572
  // Normalize each component independently so encoded delimiters cannot become structure.
573
573
  const scheme = parts.scheme === undefined ? undefined : parts.scheme.toLowerCase();
574
- const normalized = {
574
+ const normalizedParts = {
575
575
  scheme,
576
576
  authority: normalizeAuthority(parts, scheme, mapRegName),
577
577
  path: normalizePercentEncoding(parts.path),
@@ -579,31 +579,31 @@ function normalizeParsedReference(parts, options = {}) {
579
579
  fragment: parts.fragment === undefined ? undefined : normalizePercentEncoding(parts.fragment),
580
580
  };
581
581
  // Use the slash form defined for an empty HTTP or WebSocket authority path.
582
- if (normalized.authority !== undefined && normalized.path === '' && (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss')) normalized.path = '/';
582
+ if (normalizedParts.authority !== undefined && normalizedParts.path === '' && (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss')) normalizedParts.path = '/';
583
583
  // Limit dot-segment removal to paths whose standalone interpretation remains stable.
584
- const rootlessRelativePath = normalized.scheme === undefined && normalized.authority === undefined && normalized.path.length > 0 && !normalized.path.startsWith('/');
584
+ const rootlessRelativePath = normalizedParts.scheme === undefined && normalizedParts.authority === undefined && normalizedParts.path.length > 0 && !normalizedParts.path.startsWith('/');
585
585
  if (!rootlessRelativePath) {
586
- const reducedPath = removeDotSegments(normalized.path);
586
+ const reducedPath = removeDotSegments(normalizedParts.path);
587
587
  // Preserve a no-authority path when reduction would reparse it as an authority.
588
- if (normalized.authority !== undefined || !reducedPath.startsWith('//')) normalized.path = reducedPath;
588
+ if (normalizedParts.authority !== undefined || !reducedPath.startsWith('//')) normalizedParts.path = reducedPath;
589
589
  }
590
- // Select an explicit target representation only after syntax and scheme normalization is complete.
590
+ // Select an explicit target representation only after component normalization is complete.
591
591
  if (transform === 'URI') {
592
- // Map every non-ASCII authority, path, query, and fragment scalar under RFC 3987 URI output.
593
- if (normalized.authority !== undefined) normalized.authority = encodeIriComponent(normalized.authority);
594
- normalized.path = encodeIriComponent(normalized.path);
595
- if (normalized.query !== undefined) normalized.query = encodeIriComponent(normalized.query);
596
- if (normalized.fragment !== undefined) normalized.fragment = encodeIriComponent(normalized.fragment);
592
+ // Percent-encode every non-ASCII authority, path, query, and fragment scalar for URI output.
593
+ if (normalizedParts.authority !== undefined) normalizedParts.authority = encodeNonAsciiForUri(normalizedParts.authority);
594
+ normalizedParts.path = encodeNonAsciiForUri(normalizedParts.path);
595
+ if (normalizedParts.query !== undefined) normalizedParts.query = encodeNonAsciiForUri(normalizedParts.query);
596
+ if (normalizedParts.fragment !== undefined) normalizedParts.fragment = encodeNonAsciiForUri(normalizedParts.fragment);
597
597
  } else if (transform === 'IRI') {
598
598
  // Decode valid UTF-8 percent sequences only where the destination component permits their scalar.
599
- if (normalized.authority !== undefined) normalized.authority = decodeUriComponentToIri(normalized.authority);
600
- normalized.path = decodeUriComponentToIri(normalized.path);
601
- if (normalized.query !== undefined) normalized.query = decodeUriComponentToIri(normalized.query, true);
602
- if (normalized.fragment !== undefined) normalized.fragment = decodeUriComponentToIri(normalized.fragment);
599
+ if (normalizedParts.authority !== undefined) normalizedParts.authority = decodeUriTextToIri(normalizedParts.authority);
600
+ normalizedParts.path = decodeUriTextToIri(normalizedParts.path);
601
+ if (normalizedParts.query !== undefined) normalizedParts.query = decodeUriTextToIri(normalizedParts.query, true);
602
+ if (normalizedParts.fragment !== undefined) normalizedParts.fragment = decodeUriTextToIri(normalizedParts.fragment);
603
603
  }
604
- return compose(normalized);
604
+ return composeReference(normalizedParts);
605
605
  }
606
- // export
606
+ // Expose the public validation, parsing, resolution, and conversion API.
607
607
  module.exports = {
608
608
  isUUID: (string) => validate(string, 'UUID'),
609
609
  isUUIDv4: (string) => validate(string, 'UUID_v4'),
package/normalization.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # URI and IRI normalization
2
2
 
3
- Parsed URI and IRI results expose `normalize()` for syntax-based normalization, the implemented HTTP, HTTPS, WS, WSS, and URN scheme rules, and optional RFC 3987 URI/IRI representation transformation. The method returns a string and leaves the parsed components unchanged.
3
+ Parsed URI and IRI results expose `normalize()` for generic syntax normalization, scheme-specific HTTP and WebSocket forms, the separate RFC 8141 URN normalization path, and optional RFC 3987 URI/IRI representation transformation. The method returns a string and leaves the parsed components unchanged.
4
4
 
5
5
  ## API
6
6
 
@@ -44,13 +44,13 @@ parsed.path;
44
44
  // /%7e/a/../b
45
45
  ```
46
46
 
47
- ## RFC syntax normalization
47
+ ## Generic URI and IRI syntax normalization
48
48
 
49
49
  | Behavior | Implementation | Source |
50
50
  | --- | --- | --- |
51
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 | For generic URI/IRI components, decode percent triplets representing ASCII letters, digits, `-`, `.`, `_`, or `~`. Retain percent encoding for reserved octets. URNs use the non-decoding rules below. | [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 a generic parsed reference can be normalized independently. Preserve unresolved rootless-relative path semantics and every URN NSS segment. | [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), [RFC 8141 §§2.2 and 3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.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 a parsed generic 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
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
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
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) |
@@ -58,31 +58,9 @@ parsed.path;
58
58
 
59
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
60
 
61
- ## URNs
61
+ ## Scheme-specific normalization
62
62
 
63
- A parsed value under the case-insensitive `urn` scheme takes a separate RFC 8141 normalization path using its captured `scheme`, `nid`, `nss`, `rComponent`, `qComponent`, and `fComponent` properties.
64
-
65
- | Input component | Output | Source |
66
- | --- | --- | --- |
67
- | Scheme | Convert `urn` to lowercase. | [RFC 8141 §3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.1) |
68
- | NID | Convert ASCII letters to lowercase. | [RFC 8141 §§2.1 and 3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.1) |
69
- | NSS | Uppercase hexadecimal letters in percent triplets without decoding any octet. Preserve literal case, slash structure, and dot segments. | [RFC 8141 §§2.2 and 3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.1) |
70
- | r-, q-, and f-components | Retain the components and their delimiters, uppercasing hexadecimal letters in percent triplets without decoding. | [RFC 8141 §2.3](https://www.rfc-editor.org/rfc/rfc8141#section-2.3), [RFC 3986 §6.2.2.1](https://www.rfc-editor.org/rfc/rfc3986#section-6.2.2.1) |
71
-
72
- ```js
73
- const { parseUri } = require('identifier-js');
74
-
75
- parseUri('URN:EXAMPLE:a%62/./b/../C?+r%2f?=q%2f#f%2f').normalize();
76
- // urn:example:a%62/./b/../C?+r%2F?=q%2F#f%2F
77
- ```
78
-
79
- RFC 8141 URNs remain ASCII, including when parsed through an IRI operation. Consequently, `transform: 'URI'` and `transform: 'IRI'` produce the same URN representation, and `mapRegName` is not called because a URN has no authority or registered-name host.
80
-
81
- For a parsed URN, the current scheme-specific fields are the normalization input. The NSS and optional-component values stay opaque except for percent-triplet letter case. The method leaves every property unchanged.
82
-
83
- Normalization is not a URN-equivalence API. RFC 8141 equivalence compares the normalized assigned-name and ignores r-, q-, and f-components; namespace definitions can add further equivalence rules. This method instead retains those optional components in its returned string. The package does not implement generic or namespace-specific URN-equivalence comparison.
84
-
85
- ## HTTP and HTTPS
63
+ ### HTTP and HTTPS
86
64
 
87
65
  For `http` and `https`, normalization applies the generic rules and these scheme rules:
88
66
 
@@ -101,7 +79,7 @@ https://example.com:00443/a → https://example.com/a
101
79
  http://example.com:/a → http://example.com/a
102
80
  ```
103
81
 
104
- ## WS and WSS
82
+ ### WS and WSS
105
83
 
106
84
  For `ws` and `wss`, normalization applies the generic rules and these scheme rules:
107
85
 
@@ -118,11 +96,47 @@ wss://example.com:00443/chat → wss://example.com/chat
118
96
  ws://example.com?channel=updates → ws://example.com/?channel=updates
119
97
  ```
120
98
 
99
+ ### URNs
100
+
101
+ A parsed value under the case-insensitive `urn` scheme takes the separate RFC 8141 normalization path using its captured `scheme`, `nid`, `nss`, `rComponent`, `qComponent`, and `fComponent` properties.
102
+
103
+ | Input component | Output | Source |
104
+ | --- | --- | --- |
105
+ | Scheme | Convert `urn` to lowercase. | [RFC 8141 §3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.1) |
106
+ | NID | Convert ASCII letters to lowercase. | [RFC 8141 §§2.1 and 3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.1) |
107
+ | NSS | Uppercase hexadecimal letters in percent triplets without decoding any octet. Preserve literal case, slash structure, and dot segments. | [RFC 8141 §§2.2 and 3.1](https://www.rfc-editor.org/rfc/rfc8141#section-3.1) |
108
+ | r-, q-, and f-components | Retain the components and their delimiters, uppercasing hexadecimal letters in percent triplets without decoding. | [RFC 8141 §2.3](https://www.rfc-editor.org/rfc/rfc8141#section-2.3), [RFC 3986 §6.2.2.1](https://www.rfc-editor.org/rfc/rfc3986#section-6.2.2.1) |
109
+
110
+ ```js
111
+ const { parseUri } = require('identifier-js');
112
+
113
+ const input = 'URN:EXAMPLE:a%62/./b/../C?+r%2f?=q%2f#f%2f';
114
+ const output = parseUri(input).normalize();
115
+
116
+ output;
117
+ // urn:example:a%62/./b/../C?+r%2F?=q%2F#f%2F
118
+ ```
119
+
120
+ This example demonstrates each URN normalization rule:
121
+
122
+ - `URN` becomes `urn` because the scheme is case-insensitive and normalized to lowercase.
123
+ - `EXAMPLE` becomes `example` because ASCII letters in the NID are normalized to lowercase.
124
+ - `%62` remains encoded in the NSS rather than becoming `b`; URN normalization does not decode percent-encoded NSS octets.
125
+ - `/./b/../C` remains unchanged because the NSS is opaque to generic path processing: dot segments are not removed, and literal NSS case is preserved.
126
+ - The r-, q-, and f-components and their `?+`, `?=`, and `#` delimiters are retained.
127
+ - `%2f` becomes `%2F` in each optional component because retained percent triplets use uppercase hexadecimal letters without decoding the represented `/`.
128
+
129
+ RFC 8141 URNs remain ASCII, including when parsed through an IRI operation. Consequently, `transform: 'URI'` and `transform: 'IRI'` produce the same URN representation, and `mapRegName` is not called because a URN has no authority or registered-name host.
130
+
131
+ For a parsed URN, the current URN-specific fields are the normalization input. The NSS and optional-component values stay opaque except for percent-triplet letter case. The method leaves every property unchanged.
132
+
133
+ Normalization is not a URN-equivalence API. RFC 8141 equivalence compares the normalized assigned name and ignores r-, q-, and f-components; namespace definitions can add further equivalence rules. This method instead retains those optional components in its returned string. The package does not implement generic or namespace-specific URN-equivalence comparison.
134
+
121
135
  ## Registered-name mapping and representation transformation
122
136
 
123
137
  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.
124
138
 
125
- 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.
139
+ 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 the DNS-host grammar. The returned string then receives percent-triplet and ASCII host-case normalization. Mapper exceptions propagate unchanged.
126
140
 
127
141
  ```js
128
142
  const mapped = parseIriReference('x://example').normalize({
@@ -135,27 +149,61 @@ mapped;
135
149
 
136
150
  The example deliberately produces text that is not a valid URI or IRI; validating or selecting mapper output belongs to the application.
137
151
 
152
+ ### IRI-to-URI transformation
153
+
138
154
  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.
139
155
 
140
156
  ```js
141
- const { parseIri, parseUri } = require('identifier-js');
157
+ const { parseIri } = require('identifier-js');
142
158
 
143
- parseIri('x:/café?q=資料#résultat').normalize({ transform: 'URI' });
144
- // x:/caf%C3%A9?q=%E8%B3%87%E6%96%99#r%C3%A9sultat
159
+ const input = 'x://usér@exämple/latin-é/emoji-😀?native=資料&private=\uE000&reserved=/&encoded=%c3%a9#résultat';
160
+ const output = parseIri(input).normalize({ transform: 'URI' });
145
161
 
146
- parseUri('x:/caf%C3%A9?q=%E8%B3%87%E6%96%99#r%C3%A9sultat').normalize({ transform: 'IRI' });
147
- // x:/café?q=資料#résultat
162
+ output;
163
+ // x://us%C3%A9r@ex%C3%A4mple/latin-%C3%A9/emoji-%F0%9F%98%80?native=%E8%B3%87%E6%96%99&private=%EE%80%80&reserved=/&encoded=%C3%A9#r%C3%A9sultat
148
164
  ```
149
165
 
166
+ This example demonstrates each relevant output rule:
167
+
168
+ - `usér`, `exämple`, `latin-é`, and `résultat` become UTF-8 percent triplets in userinfo, host, path, and fragment text.
169
+ - `😀` is processed as one Unicode scalar and becomes its four UTF-8 octets `%F0%9F%98%80`.
170
+ - `資料` becomes `%E8%B3%87%E6%96%99` in the query.
171
+ - The query's private-use character `\uE000` becomes `%EE%80%80`.
172
+ - The literal reserved `/` remains literal because it is already valid URI query syntax.
173
+ - The existing encoded sequence `%c3%a9` remains encoded while its hexadecimal letters become uppercase as `%C3%A9`.
174
+
175
+ ### URI-to-IRI transformation
176
+
150
177
  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.
151
178
 
179
+ ```js
180
+ const { parseUri } = require('identifier-js');
181
+
182
+ const input = 'x:/ok-%C3%A9/reserved-%2F/percent-%25/malformed-%C3%28/incomplete-%E2%82/latin1-%E9/outside-%EF%B7%90/bidi-%E2%80%8E';
183
+ const output = parseUri(input).normalize({ transform: 'IRI' });
184
+
185
+ output;
186
+ // x:/ok-é/reserved-%2F/percent-%25/malformed-%C3%28/incomplete-%E2%82/latin1-%E9/outside-%EF%B7%90/bidi-%E2%80%8E
187
+ ```
188
+
189
+ This example shows why transformation is not equivalent to applying `decodeURIComponent()` to every triplet:
190
+
191
+ - `%C3%A9` becomes `é` because it is valid UTF-8 for a character permitted in an IRI path.
192
+ - `%2F` remains encoded because `/` is reserved and decoding it could change path structure.
193
+ - `%25` remains encoded because decoding it would introduce a literal percent sign.
194
+ - `%C3%28` remains encoded because it is malformed UTF-8.
195
+ - `%E2%82` remains encoded because it is an incomplete UTF-8 sequence.
196
+ - `%E9` remains encoded because a Latin-1 or Windows-1252 byte is not valid UTF-8 by itself.
197
+ - `%EF%B7%90` remains encoded because it represents U+FDD0, which is outside the RFC 3987 character repertoire.
198
+ - `%E2%80%8E` remains encoded because it represents U+200E, a forbidden bidirectional formatting character.
199
+
152
200
  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.
153
201
 
154
202
  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.
155
203
 
156
204
  ## Verification
157
205
 
158
- 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.
206
+ The normalization suite covers generic URI and IRI parser results, RFC 8141 URNs, 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.
159
207
 
160
208
  ```sh
161
209
  npm test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "identifier-js",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "A fast RFC 3986/3987 URI/IRI parser, validator, normalizer, resolver, and composer with RFC 8141 URN syntax support.",
5
5
  "keywords": [
6
6
  "IRI",
package/readme.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  title: Identifier JS
4
4
 
5
- description: RFC 3986/3987 URI and IRI tools with scheme-specific RFC 8141 URN syntax and normalization support.
5
+ description: RFC 3986/3987 URI and IRI tools with RFC 8141 URN grammar and normalization support.
6
6
 
7
7
  ---
8
8
 
9
9
  # Identifier JS
10
10
 
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), with scheme-specific RFC [8141](https://www.rfc-editor.org/rfc/rfc8141) URN support. Its recognized HTTP, WebSocket, and `file` schemes retain the documented hostname-policy restrictions below. It provides:
11
+ `identifier-js` is a URI/IRI parser, validator, normalizer, resolver, and reference converter based on RFC [3986](https://www.rfc-editor.org/rfc/rfc3986) and RFC [3987](https://www.rfc-editor.org/rfc/rfc3987), with an RFC [8141](https://www.rfc-editor.org/rfc/rfc8141) URN grammar profile. HTTP, WebSocket, and `file` identifiers use the documented DNS-host grammar where applicable. It provides:
12
12
 
13
13
  - URI and IRI validation, including RFC 8141 URN namestring syntax;
14
- - parsed generic URI/IRI components and scheme-specific URN components;
15
- - conservative syntax normalization, recognized-scheme forms, and a registered-name extension point;
14
+ - parsed generic URI/IRI components and URN-specific components;
15
+ - conservative syntax normalization, scheme-specific forms, and a registered-name extension point;
16
16
  - RFC 3986 reference resolution and dot-segment removal;
17
17
  - relative-reference generation with resolution round-trip guarantees for supported forms;
18
18
  - UUID and UUIDv4 lexical validation;
@@ -28,7 +28,7 @@ npm install identifier-js
28
28
 
29
29
  ## API
30
30
 
31
- Every validator returns `true` or throws at the first detected violation. Parsing and reference operations also throw when their input does not satisfy the required grammar.
31
+ Every validator returns `true` for valid input and otherwise throws. Parsing and reference operations also throw when their input does not satisfy the required grammar.
32
32
 
33
33
  ### Validate URI syntax
34
34
 
@@ -143,45 +143,6 @@ console.log(parseIri('https://usér@例え.テスト:8443/résumé?lang=fr#profi
143
143
 
144
144
  </details>
145
145
 
146
- ### Validate and parse RFC 8141 URNs
147
-
148
- URNs use the existing URI and IRI operations because a URN is a URI under the `urn` scheme. Values with a case-insensitive `urn:` prefix are validated against RFC 8141 namestring syntax; no separate `isUrn` or `parseUrn` API is exported.
149
-
150
- ```text
151
- urn:NID:NSS[?+r-component][?=q-component][#f-component]
152
- ```
153
-
154
- The NID contains 2–32 ASCII characters, starts and ends with a letter or digit, and permits letters, digits, or hyphens internally. The NSS begins with an RFC 3986 `pchar` and then permits `pchar` or `/`. The ordered r- and q-components also begin with `pchar` and then permit `pchar`, `/`, or `?`, while an f-component can be empty. The first `?=` sequence after an r-component starts the q-component, and any other question mark outside an optional component is rejected.
155
-
156
- <details>
157
- <summary><strong>API behavior and examples</strong></summary>
158
-
159
- ```js
160
- const { isUri, isIri, parseUri } = require('identifier-js');
161
-
162
- const value = 'URN:Example:a%2f/../B?+service?x?=key=value#part';
163
- console.log(isUri(value)); // true
164
- console.log(isIri(value)); // true
165
-
166
- const parsed = parseUri(value);
167
- console.log(parsed.scheme); // URN
168
- console.log(parsed.nid); // Example
169
- console.log(parsed.nss); // a%2f/../B
170
- console.log(parsed.rComponent); // service?x
171
- console.log(parsed.qComponent); // key=value
172
- console.log(parsed.fComponent); // part
173
- console.log(parsed.normalize());
174
- // urn:example:a%2F/../B?+service?x?=key=value#part
175
- ```
176
-
177
- URN parse results expose `nid`, `nss`, `rComponent`, `qComponent`, and `fComponent`. They do not expose generic `path`, `query`, or `fragment` aliases. To require a URN after parsing a value accepted as a general URI, check `parsed.scheme.toLowerCase() === 'urn'`.
178
-
179
- URNs remain ASCII even through the IRI operations. Callers representing non-ASCII names must first encode them as UTF-8 and then percent-encode the resulting octets; lexical validation does not decode or verify those octet sequences.
180
-
181
- Validation is deliberately lexical and namespace-independent. Success does not prove that an NID is registered or otherwise legitimate, that an NSS obeys a namespace's additional syntax and canonicalization rules, or that the name was legitimately assigned.
182
-
183
- </details>
184
-
185
146
  ### Resolve a reference
186
147
 
187
148
  Resolve a URI or IRI reference against an absolute base using RFC 3986 §5.
@@ -251,7 +212,7 @@ When no safe rootless relative form can round-trip to the target, `toRelativeRef
251
212
  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.
252
213
 
253
214
  <details>
254
- <summary><strong>API, behavior, and examples</strong></summary>
215
+ <summary><strong>API and examples</strong></summary>
255
216
 
256
217
  ```ts
257
218
  type RegNameMapper = (regName: string) => string
@@ -284,7 +245,7 @@ Normalization implements RFC 3986 and RFC 3987 syntax normalization for scheme a
284
245
 
285
246
  URN normalization lowercases the scheme and NID, uppercases percent-triplet hexadecimal letters without decoding, and preserves NSS case, slashes, and dot segments. The r-, q-, and f-components are retained, so normalized-string equality is not the RFC 8141 URN-equivalence procedure. Namespace-specific equivalence and URN resolution are outside this package's scope. URI/IRI transformation and registered-name mapping options do not alter authority-free, ASCII-only URNs.
286
247
 
287
- 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.
248
+ 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 the DNS-host grammar.
288
249
 
289
250
  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.
290
251
 
@@ -319,27 +280,27 @@ console.log(isUUIDv4('123e4567-e89b-42d3-9456-426614174000')); // true
319
280
 
320
281
  The parser builds its validation logic from declarative RFC grammar fragments:
321
282
 
322
- 1. Select the applicable generic or scheme-specific URI/IRI syntax.
323
- 2. Recursively expand grammar references through `url-templates`.
324
- 3. Add named captures for the components exposed by the selected syntax.
283
+ 1. Select the generic, DNS-host, empty-file-host, or URN grammar profile.
284
+ 2. Merge and recursively expand grammar references through `url-templates`.
285
+ 3. Add named captures for the public components exposed by parsing.
325
286
  4. Compile the complete expression with Unicode support.
326
- 5. Cache expressions by operation, grammar rule, and scheme class.
287
+ 5. Cache expressions by operation, grammar rule, and grammar profile.
327
288
  6. Validate with `RegExp.test()` or parse with `RegExp.exec()`.
328
- 7. Resolve references by component inheritance, path merging, dot-segment removal, and definedness-preserving recomposition.
289
+ 7. Resolve references by component inheritance, path merging, dot-segment removal, and component recomposition.
329
290
 
330
291
  <details>
331
292
  <summary><strong>Lazy compilation and cache behavior</strong></summary>
332
293
 
333
- Parsing and validation use separate cached expressions because parsing requires named groups and validation does not. Generic and scheme-specific identifiers also use separate entries.
294
+ Parsing and validation use separate cached expressions because parsing requires named groups and validation does not. Each grammar profile also uses separate entries.
334
295
 
335
- The first call for an operation/rule/policy combination includes recursive grammar expansion and regular-expression compilation. Later calls reuse the cached expression and are considerably faster. No regular expressions are generated during package import.
296
+ The first call for an operation, grammar rule, and grammar profile includes recursive grammar expansion and regular-expression compilation. Later calls reuse the cached expression and are considerably faster. No regular expressions are generated during package import.
336
297
 
337
298
  </details>
338
299
 
339
300
  <details>
340
- <summary><strong>Scheme-specific hostname policy</strong></summary>
301
+ <summary><strong>DNS-host grammar profile</strong></summary>
341
302
 
342
- The following schemes trigger DNS-style ASCII or Unicode label rules instead of the fully generic `reg-name` grammar:
303
+ The following schemes trigger DNS-style ASCII or Unicode label rules instead of the generic `reg-name` grammar:
343
304
 
344
305
  - `http`
345
306
  - `https`
@@ -347,9 +308,46 @@ The following schemes trigger DNS-style ASCII or Unicode label rules instead of
347
308
  - `wss`
348
309
  - `file`
349
310
 
350
- Matching is case-insensitive. Other valid schemes use generic RFC 3986/3987 registered-name syntax; URNs instead follow RFC 8141 namestring 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.
311
+ 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.
312
+
313
+ 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 the DNS-host grammar again.
314
+
315
+ </details>
316
+
317
+ <details>
318
+ <summary><strong>URN grammar profile</strong></summary>
319
+
320
+ URNs use the existing URI and IRI operations because a URN is a URI under the `urn` scheme. Values with a case-insensitive `urn:` prefix select the closed RFC 8141 grammar profile; no separate `isUrn` or `parseUrn` API is exported.
321
+
322
+ ```text
323
+ urn:NID:NSS[?+r-component][?=q-component][#f-component]
324
+ ```
325
+
326
+ The NID contains 2–32 ASCII characters, starts and ends with a letter or digit, and permits letters, digits, or hyphens internally. The NSS begins with an RFC 3986 `pchar` and then permits `pchar` or `/`. The ordered r- and q-components also begin with `pchar` and then permit `pchar`, `/`, or `?`, while an f-component can be empty. The first `?=` sequence after an r-component starts the q-component, and any other question mark outside an optional component is rejected.
351
327
 
352
- 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.
328
+ ```js
329
+ const { isUri, isIri, parseUri } = require('identifier-js');
330
+
331
+ const value = 'URN:Example:a%2f/../B?+service?x?=key=value#part';
332
+ console.log(isUri(value)); // true
333
+ console.log(isIri(value)); // true
334
+
335
+ const parsed = parseUri(value);
336
+ console.log(parsed.scheme); // URN
337
+ console.log(parsed.nid); // Example
338
+ console.log(parsed.nss); // a%2f/../B
339
+ console.log(parsed.rComponent); // service?x
340
+ console.log(parsed.qComponent); // key=value
341
+ console.log(parsed.fComponent); // part
342
+ console.log(parsed.normalize());
343
+ // urn:example:a%2F/../B?+service?x?=key=value#part
344
+ ```
345
+
346
+ URN parse results expose `nid`, `nss`, `rComponent`, `qComponent`, and `fComponent`. They do not expose generic `authority`, `userinfo`, `host`, `port`, `path`, `query`, or `fragment` fields. To require a URN after parsing a value accepted as a general URI, check `parsed.scheme.toLowerCase() === 'urn'`.
347
+
348
+ URNs remain ASCII even through the IRI operations. Callers representing non-ASCII names must first encode them as UTF-8 and then percent-encode the resulting octets; lexical validation does not decode or verify those octet sequences.
349
+
350
+ Validation is deliberately lexical and namespace-independent. Success does not prove that an NID is registered or otherwise legitimate, that an NSS obeys a namespace's additional syntax and canonicalization rules, or that the name was legitimately assigned.
353
351
 
354
352
  </details>
355
353
 
@@ -463,9 +461,9 @@ RFC 9562 lists database keys, filenames, system identifiers, and transaction ide
463
461
  <details>
464
462
  <summary><strong>Validation and parsing</strong></summary>
465
463
 
466
- - Generic URI syntax follows RFC 3986 character and component grammar; HTTP, WebSocket, and `file` schemes apply the documented hostname restrictions.
467
- - Generic IRI syntax follows the RFC 3987 Unicode extensions to URI grammar; HTTP, WebSocket, and `file` schemes apply the documented hostname restrictions.
468
- - Values with the case-insensitive `urn` scheme follow RFC 8141 namestring syntax and expose `nid`, `nss`, `rComponent`, `qComponent`, and `fComponent` fields through the URI and IRI parsers.
464
+ - The generic URI grammar profile follows RFC 3986 character and component syntax; HTTP, WebSocket, and `file` schemes select the documented DNS-host rules.
465
+ - The generic IRI grammar profile follows the RFC 3987 Unicode extensions to URI syntax; HTTP, WebSocket, and `file` schemes select the documented DNS-host rules.
466
+ - Values with the case-insensitive `urn` scheme select the closed RFC 8141 grammar profile and expose `nid`, `nss`, `rComponent`, `qComponent`, and `fComponent` fields through the URI and IRI parsers.
469
467
  - URN validation establishes generic lexical syntax only, not namespace registration, namespace-specific syntax, assignment, resolution, or equivalence.
470
468
  - Validators return `true` or throw at the first grammar violation.
471
469
  - `absolute-URI` and `absolute-IRI` use the fragment-free grammar defined by their RFCs; complete URI and IRI operations accept fragments.
@@ -523,7 +521,7 @@ Run `gh workspace-data load` again to refresh materialized data after public-dat
523
521
 
524
522
  ### Tests
525
523
 
526
- The active suite contains 3,157 tests covering URI/IRI validation and parsing, RFC 8141 URN syntax and normalization, 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.
524
+ The active suite contains 3,157 tests covering URI/IRI validation and parsing, RFC 8141 URN syntax and normalization, generic normalization, bidirectional URI/IRI representation transformation, DNS-host grammar, 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.
527
525
 
528
526
  <details>
529
527
  <summary><strong>Test details</strong></summary>