identifier-js 0.2.0 → 0.4.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
@@ -31,25 +31,32 @@ export const parseIri: (iri: string) => ParsedIdentifierComponents;
31
31
  export const parseIriReference: (iriReference: string) => ParsedRelativeIdentifierComponents;
32
32
  /** @throws {Error} If the absolute-IRI is invalid. */
33
33
  export const parseAbsoluteIri: (iri: string) => ParsedAbsoluteIdentifierComponents;
34
- /** @throws {Error} If the base or the reference is invalid. */
34
+ /** Resolve with generic RFC 3986 semantics; this does not invoke a URN resolution service.
35
+ * @throws {Error} If the base or the reference is invalid.
36
+ */
35
37
  export function resolveReference(reference: string, base: string, strict?: boolean, returnParts?: false): string;
36
38
  export function resolveReference(reference: string, base: string, strict: boolean | undefined, returnParts: true): IdentifierComponents;
37
39
  export function resolveReference(reference: string, base: string, strict: boolean | undefined, returnParts: boolean | undefined): string | IdentifierComponents;
38
40
  /** @throws {Error} If the reference is invalid. */
39
41
  export const toAbsoluteReference: (reference: string) => string;
40
- /** @throws {Error} If the base or the reference is invalid. */
42
+ /** Derive a generic URI reference without scheme-specific relative-URN semantics.
43
+ * @throws {Error} If the base or the reference is invalid.
44
+ */
41
45
  export const toRelativeReference: (target: string, base: string) => string;
42
46
 
43
47
  /** Map a parsed non-empty registered-name host to caller-owned text. */
44
48
  export type RegNameMapper = (regName: string) => string;
45
49
 
46
- /** Select optional URI output and registered-name mapping for parsed-result normalization. */
50
+ /** Select a target representation after parsed-result normalization. */
51
+ export type NormalizeTransform = 'URI' | 'IRI';
52
+
53
+ /** Select optional representation transformation and registered-name mapping. */
47
54
  export type NormalizeOptions = {
48
- toUri?: boolean;
55
+ transform?: NormalizeTransform;
49
56
  mapRegName?: RegNameMapper;
50
57
  };
51
58
 
52
- /** Provide lazy RFC normalization and optional IRI-to-URI output on a parsed result. */
59
+ /** Provide lazy RFC normalization and optional URI/IRI transformation on a parsed result. */
53
60
  export type NormalizableReference = {
54
61
  normalize(options?: NormalizeOptions): string;
55
62
  };
@@ -87,6 +94,18 @@ export type AbsoluteIdentifierComponents = {
87
94
  query?: string;
88
95
  };
89
96
 
90
- export type ParsedIdentifierComponents = IdentifierComponents & NormalizableReference;
91
- export type ParsedRelativeIdentifierComponents = RelativeIdentifierComponents & NormalizableReference;
92
- export type ParsedAbsoluteIdentifierComponents = AbsoluteIdentifierComponents & NormalizableReference;
97
+ // Describe the scheme-specific captures returned by each complete or fragment-free URN grammar.
98
+ type UrnIdentifierComponents = {
99
+ scheme: string;
100
+ nid: string;
101
+ nss: string;
102
+ rComponent?: string;
103
+ qComponent?: string;
104
+ fragment?: string;
105
+ };
106
+
107
+ type AbsoluteUrnIdentifierComponents = Omit<UrnIdentifierComponents, 'fragment'>;
108
+
109
+ export type ParsedIdentifierComponents = (IdentifierComponents | UrnIdentifierComponents) & NormalizableReference;
110
+ export type ParsedRelativeIdentifierComponents = (RelativeIdentifierComponents | UrnIdentifierComponents) & NormalizableReference;
111
+ export type ParsedAbsoluteIdentifierComponents = (AbsoluteIdentifierComponents | AbsoluteUrnIdentifierComponents) & NormalizableReference;
package/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
- // Parse, validate, normalize, resolve, and convert RFC 3986 URI and RFC 3987 IRI references.
3
- // a valid URI is always a valid IRI
2
+ // Parse, validate, normalize, resolve, and convert RFC 3986 URI, RFC 3987 IRI, and RFC 8141 URN references.
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
5
  const patterns = new Map();
6
- const implemented_schemes = '(?:[hH][tT][tT][pP][sS]?|[wW][sS][sS]?|[fF][iI][lL][eE])';
6
+ const implemented_schemes = '(?:[hH][tT][tT][pP][sS]?|[wW][sS][sS]?|[fF][iI][lL][eE]|[uU][rR][nN])';
7
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
8
8
  const commonRules = {
9
9
  implemented_schemes,
@@ -79,6 +79,31 @@ 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.
83
+ const urnRules = {
84
+ scheme: implemented_schemes,
85
+ URI_reference: '{URI}',
86
+ URI: '{namestring}',
87
+ absolute_URI: '{assigned_name}(?:{rq_components})?',
88
+ IRI_reference: '{IRI}',
89
+ IRI: '{URI}',
90
+ absolute_IRI: '{absolute_URI}',
91
+ namestring: '{assigned_name}(?:{rq_components})?(?:#{f_component})?',
92
+ assigned_name: '{scheme}:{NID}:{NSS}',
93
+ NID: '{alpha_digit}{ldh}{0,30}{alpha_digit}',
94
+ ldh: '(?:{alpha_digit}|-)',
95
+ NSS: '{pchar}(?:{pchar}|/)*',
96
+ rq_components: '(?:[?][+]{r_component})?(?:[?]={q_component})?',
97
+ r_component: '{pchar}(?:{pchar}|/|[?](?!=))*',
98
+ q_component: '{pchar}(?:{pchar}|/|[?])*',
99
+ f_component: '{fragment}',
100
+ };
101
+ // Reuse the grammar repertoires when selecting URI octets safe for IRI output.
102
+ const uriUnreservedPattern = new RegExp(`^${commonRules.unreserved}$`);
103
+ const iriUcscharPattern = new RegExp(`^${iriRules.ucschar}$`, 'u');
104
+ const iriPrivatePattern = new RegExp(`^${iriRules.iprivate}$`, 'u');
105
+ // Apply the additional RFC 3987 Section 4.1 prose restriction outside the ABNF repertoire.
106
+ const forbiddenIriFormattingPattern = /^[\u200E\u200F\u202A-\u202E]$/u;
82
107
  // scheme specific URI reg_name and IRI ireg_name
83
108
  const schemeSpecificRules = {
84
109
  scheme: implemented_schemes,
@@ -119,11 +144,16 @@ const groupNames = {
119
144
  ipath_noscheme: 'path',
120
145
  ipath_rootless: 'path',
121
146
  ipath_empty: 'path',
147
+ NID: 'nid',
148
+ NSS: 'nss',
149
+ r_component: 'rComponent',
150
+ q_component: 'qComponent',
122
151
  };
123
- // Select and merge generic, DNS-host, or empty-file-host grammar overrides.
152
+ // Detect schemes for which the package implements grammar beyond generic URI/IRI syntax.
124
153
  const isSpecificScheme = (string) => new RegExp('^' + implemented_schemes + ':').test(string);
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 : {});
154
+ // Select and merge generic, DNS-host, empty-file-host, or URN grammar profiles.
155
+ const schemeProfile = (string) => (string.slice(0, 4).toLowerCase() === 'urn:' ? 'u' : string.slice(0, 8).toLowerCase() === 'file:///' ? 'f' : isSpecificScheme(string) ? 's' : '');
156
+ const rules = (profile) => Object.assign({}, commonRules, uriRules, iriRules, profile === 'u' ? urnRules : profile === 'f' ? emptyFileHostRules : profile ? schemeSpecificRules : {});
127
157
  // parse (slower, it uses regex.exec and includes named capture groups)
128
158
  const parse = (string, rule) => {
129
159
  if (typeof string !== 'string') throw new TypeError(`Invalid ${rule.replace('_', '-')} type: must be a string.`);
@@ -321,6 +351,10 @@ function isIPv4Address(host) {
321
351
  }
322
352
  return true;
323
353
  }
354
+ // Identify ASCII octets through the URI grammar's canonical unreserved repertoire.
355
+ function isAsciiUnreservedOctet(octet) {
356
+ return uriUnreservedPattern.test(String.fromCharCode(octet));
357
+ }
324
358
  // Normalize percent triplets while optionally decoding only ASCII unreserved octets.
325
359
  function normalizePercentEncoding(value, decodeUnreserved = true) {
326
360
  let result = '';
@@ -332,8 +366,7 @@ function normalizePercentEncoding(value, decodeUnreserved = true) {
332
366
  }
333
367
  const hexadecimal = value.slice(index + 1, index + 3);
334
368
  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()}`;
369
+ result += decodeUnreserved && isAsciiUnreservedOctet(octet) ? String.fromCharCode(octet) : `%${hexadecimal.toUpperCase()}`;
337
370
  index += 2;
338
371
  }
339
372
  return result;
@@ -459,6 +492,52 @@ function encodeIriComponent(component) {
459
492
  // Process complete code points so supplementary characters produce one UTF-8 sequence.
460
493
  return component.replace(/[^\x00-\x7F]/gu, (character) => encodeURIComponent(character).toUpperCase());
461
494
  }
495
+ // Decode the maximal RFC 3987 URI octet repertoire allowed by one IRI component.
496
+ function decodeUriComponentToIri(component, allowPrivate = false) {
497
+ let result = '';
498
+ // Inspect each normalized percent triplet as either ASCII or the lead of one strict UTF-8 scalar.
499
+ for (let index = 0; index < component.length; index++) {
500
+ if (component[index] !== '%' || !/^[0-9A-F]{2}$/.test(component.slice(index + 1, index + 3))) {
501
+ result += component[index];
502
+ continue;
503
+ }
504
+ const hexadecimal = component.slice(index + 1, index + 3);
505
+ const octet = Number.parseInt(hexadecimal, 16);
506
+ if (octet <= 0x7F) {
507
+ result += isAsciiUnreservedOctet(octet) ? String.fromCharCode(octet) : `%${hexadecimal}`;
508
+ index += 2;
509
+ continue;
510
+ }
511
+ const sequenceLength = octet >= 0xC2 && octet <= 0xDF ? 2 : octet >= 0xE0 && octet <= 0xEF ? 3 : octet >= 0xF0 && octet <= 0xF4 ? 4 : 0;
512
+ let encoded = '';
513
+ // Collect exactly one candidate scalar without consuming malformed trailing input.
514
+ for (let sequenceIndex = 0; sequenceIndex < sequenceLength; sequenceIndex++) {
515
+ const position = index + sequenceIndex * 3;
516
+ if (component[position] !== '%' || !/^[0-9A-F]{2}$/.test(component.slice(position + 1, position + 3))) {
517
+ encoded = '';
518
+ break;
519
+ }
520
+ encoded += component.slice(position, position + 3);
521
+ }
522
+ let character;
523
+ if (encoded) {
524
+ try {
525
+ character = decodeURIComponent(encoded);
526
+ } catch {
527
+ character = undefined;
528
+ }
529
+ }
530
+ const allowed = character !== undefined && !forbiddenIriFormattingPattern.test(character) && (iriUcscharPattern.test(character) || (allowPrivate && iriPrivatePattern.test(character)));
531
+ if (allowed) {
532
+ result += character;
533
+ index += encoded.length - 1;
534
+ } else {
535
+ result += `%${hexadecimal}`;
536
+ index += 2;
537
+ }
538
+ }
539
+ return result;
540
+ }
462
541
  // Rebuild authority from normalized values while preserving other empty component delimiters.
463
542
  function normalizeAuthority(parts, scheme, mapRegName) {
464
543
  if (parts.authority === undefined) return undefined;
@@ -475,9 +554,16 @@ function normalizeAuthority(parts, scheme, mapRegName) {
475
554
  function normalizeParsedReference(parts, options = {}) {
476
555
  // Validate the optional API settings before they select normalization behavior.
477
556
  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.');
557
+ const { transform, mapRegName } = options;
558
+ if (transform !== undefined && transform !== 'URI' && transform !== 'IRI') throw new TypeError('Invalid transform option: must be "URI" or "IRI".');
480
559
  if (mapRegName !== undefined && typeof mapRegName !== 'function') throw new TypeError('Invalid registered-name mapper type: must be a function.');
560
+ // Normalize captured URN fields without applying generic path or representation processing.
561
+ if (parts.nid !== undefined) {
562
+ const rComponent = parts.rComponent === undefined ? undefined : normalizePercentEncoding(parts.rComponent, false);
563
+ const qComponent = parts.qComponent === undefined ? undefined : normalizePercentEncoding(parts.qComponent, false);
564
+ const query = rComponent !== undefined ? `+${rComponent}${qComponent === undefined ? '' : `?=${qComponent}`}` : qComponent === undefined ? undefined : `=${qComponent}`;
565
+ return compose({ scheme: parts.scheme.toLowerCase(), path: `${parts.nid.toLowerCase()}:${normalizePercentEncoding(parts.nss, false)}`, query, fragment: parts.fragment === undefined ? undefined : normalizePercentEncoding(parts.fragment, false) });
566
+ }
481
567
  // Normalize each component independently so encoded delimiters cannot become structure.
482
568
  const scheme = parts.scheme === undefined ? undefined : parts.scheme.toLowerCase();
483
569
  const normalized = {
@@ -496,12 +582,20 @@ function normalizeParsedReference(parts, options = {}) {
496
582
  // Preserve a no-authority path when reduction would reparse it as an authority.
497
583
  if (normalized.authority !== undefined || !reducedPath.startsWith('//')) normalized.path = reducedPath;
498
584
  }
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);
585
+ // Select an explicit target representation only after syntax and scheme normalization is complete.
586
+ if (transform === 'URI') {
587
+ // Map every non-ASCII authority, path, query, and fragment scalar under RFC 3987 URI output.
588
+ if (normalized.authority !== undefined) normalized.authority = encodeIriComponent(normalized.authority);
589
+ normalized.path = encodeIriComponent(normalized.path);
590
+ if (normalized.query !== undefined) normalized.query = encodeIriComponent(normalized.query);
591
+ if (normalized.fragment !== undefined) normalized.fragment = encodeIriComponent(normalized.fragment);
592
+ } else if (transform === 'IRI') {
593
+ // Decode valid UTF-8 percent sequences only where the destination component permits their scalar.
594
+ if (normalized.authority !== undefined) normalized.authority = decodeUriComponentToIri(normalized.authority);
595
+ normalized.path = decodeUriComponentToIri(normalized.path);
596
+ if (normalized.query !== undefined) normalized.query = decodeUriComponentToIri(normalized.query, true);
597
+ if (normalized.fragment !== undefined) normalized.fragment = decodeUriComponentToIri(normalized.fragment);
598
+ }
505
599
  return compose(normalized);
506
600
  }
507
601
  // export
package/normalization.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # URI and IRI normalization
2
2
 
3
- Parsed URI and IRI results expose `normalize()` for syntax-based normalization, the implemented HTTP, HTTPS, WS, and WSS scheme rules, and optional RFC 3987 IRI-to-URI output. The method returns a string and leaves the parsed components unchanged.
3
+ Parsed URI and IRI results expose `normalize()` for syntax-based normalization, the implemented HTTP, HTTPS, WS, WSS, and URN scheme rules, and optional RFC 3987 URI/IRI representation transformation. The method returns a string and leaves the parsed components unchanged.
4
4
 
5
5
  ## API
6
6
 
7
7
  ```ts
8
8
  type RegNameMapper = (regName: string) => string
9
9
 
10
+ type NormalizeTransform = 'URI' | 'IRI'
11
+
10
12
  type NormalizeOptions = {
11
- toUri?: boolean
13
+ transform?: NormalizeTransform
12
14
  mapRegName?: RegNameMapper
13
15
  }
14
16
 
@@ -47,14 +49,39 @@ parsed.path;
47
49
  | Behavior | Implementation | Source |
48
50
  | --- | --- | --- |
49
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) |
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
+ | 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
54
  | Component recomposition | Emit authority, query, and fragment delimiters from component presence, including present-empty components. | [RFC 3986 §5.3](https://www.rfc-editor.org/rfc/rfc3986#section-5.3) |
53
55
  | IPv6 text | Suppress leading zeroes, compress the longest zero run with first-run tie breaking, and use lowercase hexadecimal. Known embedded-IPv4 forms use mixed notation. | [RFC 5952 §§4–5](https://www.rfc-editor.org/rfc/rfc5952#section-4) |
54
- | IRI-to-URI output | With `toUri: true`, encode non-ASCII authority, path, query, and fragment characters as uppercase UTF-8 percent triplets. | [RFC 3987 §3.1](https://www.rfc-editor.org/rfc/rfc3987#section-3.1) |
56
+ | IRI-to-URI output | With `transform: 'URI'`, encode non-ASCII authority, path, query, and fragment characters as uppercase UTF-8 percent triplets. | [RFC 3987 §3.1](https://www.rfc-editor.org/rfc/rfc3987#section-3.1) |
57
+ | URI-to-IRI output | With `transform: 'IRI'`, decode percent-encoded ASCII unreserved characters and strictly legal UTF-8 sequences permitted in each destination component. Retain reserved, malformed, disallowed, and non-UTF-8 octets. | [RFC 3987 §3.2](https://www.rfc-editor.org/rfc/rfc3987#section-3.2) |
55
58
 
56
59
  Without a mapper, normalization retains the parser's host classification as an IP literal, IPv4 address, or registered name. IPvFuture literals use generic host case normalization. Existing non-ASCII IRI host text is retained unless the registered-name mapper supplies another value.
57
60
 
61
+ ## URNs
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 `fragment` 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
+
58
85
  ## HTTP and HTTPS
59
86
 
60
87
  For `http` and `https`, normalization applies the generic rules and these scheme rules:
@@ -91,7 +118,7 @@ wss://example.com:00443/chat → wss://example.com/chat
91
118
  ws://example.com?channel=updates → ws://example.com/?channel=updates
92
119
  ```
93
120
 
94
- ## Registered-name mapping and URI output
121
+ ## Registered-name mapping and representation transformation
95
122
 
96
123
  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
124
 
@@ -108,18 +135,27 @@ mapped;
108
135
 
109
136
  The example deliberately produces text that is not a valid URI or IRI; validating or selecting mapper output belongs to the application.
110
137
 
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.
138
+ With `transform: 'URI'`, retained reserved and non-ASCII percent triplets remain encoded, and literal non-ASCII userinfo, mapper output, path, query, and fragment text becomes uppercase UTF-8 percent triplets. A mapper can supply an ASCII hostname when its consuming scheme requires one; this package does not validate mapper output against that scheme.
112
139
 
113
140
  ```js
114
- const { parseIri } = require('identifier-js');
141
+ const { parseIri, parseUri } = require('identifier-js');
115
142
 
116
- parseIri('x:/café?q=資料#résultat').normalize({ toUri: true });
143
+ parseIri('x:/café?q=資料#résultat').normalize({ transform: 'URI' });
117
144
  // x:/caf%C3%A9?q=%E8%B3%87%E6%96%99#r%C3%A9sultat
145
+
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
118
148
  ```
119
149
 
150
+ 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
+
152
+ 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
+
154
+ 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
+
120
156
  ## Verification
121
157
 
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.
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.
123
159
 
124
160
  ```sh
125
161
  npm test
@@ -129,6 +165,7 @@ npm test
129
165
 
130
166
  - [RFC 3986 — Uniform Resource Identifier: Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986)
131
167
  - [RFC 3987 — Internationalized Resource Identifiers](https://www.rfc-editor.org/rfc/rfc3987)
168
+ - [RFC 8141 — Uniform Resource Names](https://www.rfc-editor.org/rfc/rfc8141)
132
169
  - [RFC 5952 — A Recommendation for IPv6 Address Text Representation](https://www.rfc-editor.org/rfc/rfc5952)
133
170
  - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
134
171
  - [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "identifier-js",
3
- "version": "0.2.0",
4
- "description": "A fast URI/IRI parser, validator, normalizer, resolver, and composer based on RFC 3986 and RFC 3987.",
3
+ "version": "0.4.0",
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",
7
7
  "URI",
8
8
  "IRI-reference",
9
9
  "URI-reference",
10
+ "URN",
10
11
  "ipv4",
11
12
  "ipv6",
12
13
  "uuid",
13
14
  "parser",
14
15
  "validator",
15
16
  "RFC3986",
16
- "RFC3987"
17
+ "RFC3987",
18
+ "RFC8141"
17
19
  ],
18
20
  "homepage": "https://github.com/SorinGFS/identifier-js#readme",
19
21
  "bugs": {
package/readme.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  title: Identifier JS
4
4
 
5
- description: An RFC 3986 and RFC 3987 parser, validator, normalizer, and reference resolver for Node.js and browser bundles.
5
+ description: RFC 3986/3987 URI and IRI tools with scheme-specific RFC 8141 URN syntax 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). 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 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:
12
12
 
13
- - URI and IRI validation;
14
- - parsed identifier components;
15
- - conservative syntax normalization, recognized-scheme port/path forms, and a registered-name extension point;
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;
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;
@@ -143,6 +143,45 @@ 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.fragment); // 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`, and `qComponent`, while the RFC-defined f-component is exposed as `fragment`. They do not expose generic `path` or `query` 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
+
146
185
  ### Resolve a reference
147
186
 
148
187
  Resolve a URI or IRI reference against an absolute base using RFC 3986 §5.
@@ -173,6 +212,8 @@ console.log(resolveReference('?page=2', 'https://example.com/items?page=1#curren
173
212
 
174
213
  Empty authorities, queries, and fragments are preserved during recomposition.
175
214
 
215
+ This function performs generic RFC 3986 reference resolution only. It does not invoke a URN resolution service or implement scheme-specific URN resolution semantics.
216
+
176
217
  </details>
177
218
 
178
219
  ### Produce absolute and relative forms
@@ -201,6 +242,8 @@ console.log(relative); // ../images/logo.svg
201
242
 
202
243
  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.
203
244
 
245
+ These conversion functions retain their generic URI-reference behavior. They do not construct, resolve, or interpret scheme-specific relative URNs.
246
+
204
247
  </details>
205
248
 
206
249
  ### Normalize parsed URI and IRI references
@@ -213,8 +256,10 @@ Every URI and IRI parse result provides an optional, non-enumerable `normalize()
213
256
  ```ts
214
257
  type RegNameMapper = (regName: string) => string
215
258
 
259
+ type NormalizeTransform = 'URI' | 'IRI'
260
+
216
261
  type NormalizeOptions = {
217
- toUri?: boolean
262
+ transform?: NormalizeTransform
218
263
  mapRegName?: RegNameMapper
219
264
  }
220
265
 
@@ -237,9 +282,11 @@ The method is available from `parseUri`, `parseUriReference`, `parseAbsoluteUri`
237
282
 
238
283
  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
284
 
285
+ 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
+
240
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.
241
288
 
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.
289
+ With `transform: 'URI'`, non-ASCII userinfo, mapper output, path, query, and fragment text becomes uppercase UTF-8 percent triplets under RFC 3987 §3.1. With `transform: 'IRI'`, eligible percent-encoded ASCII unreserved characters and strictly legal UTF-8 sequences become IRI characters under RFC 3987 §3.2; reserved, malformed, disallowed, and non-UTF-8 octets remain encoded. Private-use characters are decoded only in queries, and forbidden bidirectional formatting characters remain encoded. A mapper can supply the desired Unicode or ASCII hostname representation; this package does not enforce that policy or validate the complete normalized result.
243
290
 
244
291
  See [`normalization.md`](normalization.md) for the exact RFC section mapping and examples.
245
292
 
@@ -272,11 +319,11 @@ console.log(isUUIDv4('123e4567-e89b-42d3-9456-426614174000')); // true
272
319
 
273
320
  The parser builds its validation logic from declarative RFC grammar fragments:
274
321
 
275
- 1. Select generic URI/IRI rules or the package's scheme-specific hostname policy.
322
+ 1. Select the applicable generic or scheme-specific URI/IRI syntax.
276
323
  2. Recursively expand grammar references through `url-templates`.
277
- 3. Add named capture groups when parsing is requested.
324
+ 3. Add named captures for the components exposed by the selected syntax.
278
325
  4. Compile the complete expression with Unicode support.
279
- 5. Cache the expression by operation, grammar rule, and scheme-policy class.
326
+ 5. Cache expressions by operation, grammar rule, and scheme class.
280
327
  6. Validate with `RegExp.test()` or parse with `RegExp.exec()`.
281
328
  7. Resolve references by component inheritance, path merging, dot-segment removal, and definedness-preserving recomposition.
282
329
 
@@ -300,7 +347,7 @@ The following schemes trigger DNS-style ASCII or Unicode label rules instead of
300
347
  - `wss`
301
348
  - `file`
302
349
 
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.
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.
304
351
 
305
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.
306
353
 
@@ -416,8 +463,10 @@ RFC 9562 lists database keys, filenames, system identifiers, and transaction ide
416
463
  <details>
417
464
  <summary><strong>Validation and parsing</strong></summary>
418
465
 
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.
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, r-component, q-component, and fragment fields through the URI and IRI parsers.
469
+ - URN validation establishes generic lexical syntax only, not namespace registration, namespace-specific syntax, assignment, resolution, or equivalence.
421
470
  - Validators return `true` or throw at the first grammar violation.
422
471
  - `absolute-URI` and `absolute-IRI` use the fragment-free grammar defined by their RFCs; complete URI and IRI operations accept fragments.
423
472
  - Port syntax follows RFC 3986 `port = *DIGIT`, including empty and leading-zero values.
@@ -433,7 +482,8 @@ RFC 9562 lists database keys, filenames, system identifiers, and transaction ide
433
482
  - `strict = false` implements RFC 3986 §5.2.2 backward-compatible same-scheme handling.
434
483
  - `toAbsoluteReference` removes the fragment from an identifier containing a scheme.
435
484
  - `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.
485
+ - `normalize()` implements the applicable case, percent-encoding, and path-segment rules from RFC 3986 §§6.2.2.1–6.2.2.3 and RFC 3987 §§5.3.2.1, 5.3.2.3–5.3.2.4, RFC 3987 §§3.1–3.2 URI/IRI representation transformation, RFC 5952 IPv6 text, RFC 9110 HTTP(S) port/path forms, RFC 6455 WS(S) port/resource-name forms, and RFC 8141 scheme/NID/percent-triplet normalization without NSS decoding or path reduction.
486
+ - RFC 3986 reference resolution and relative-reference generation receive no URN-specific semantics; RFC 8141 URN resolution services and URN-equivalence APIs are not implemented.
437
487
 
438
488
  </details>
439
489
 
@@ -473,7 +523,7 @@ Run `gh workspace-data load` again to refresh materialized data after public-dat
473
523
 
474
524
  ### Tests
475
525
 
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.
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.
477
527
 
478
528
  <details>
479
529
  <summary><strong>Test details</strong></summary>
@@ -493,7 +543,7 @@ The suite uses the `node:test` module built into Node.js and requires no separat
493
543
 
494
544
  ### Benchmarks
495
545
 
496
- The materialized benchmark suite provides portable, version-aware measurements for every exported function plus isolated package loading, reporting initial-call behavior, warmed latency statistics, integer throughput, workload counts, representative inputs, and environment metadata.
546
+ The 26-scenario materialized benchmark suite provides portable, version-aware measurements for every exported function, isolated package loading, and RFC 8141 URN validation, parsing, and normalization. It reports initial-call behavior, warmed latency statistics, integer throughput, workload counts, representative inputs, and environment metadata.
497
547
 
498
548
  <details>
499
549
  <summary><strong>Benchmark details</strong></summary>
@@ -524,6 +574,7 @@ The generic coordinator delegates version-layer selection and ordered concern di
524
574
 
525
575
  - [RFC 3986 — Uniform Resource Identifier: Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986)
526
576
  - [RFC 3987 — Internationalized Resource Identifiers](https://www.rfc-editor.org/rfc/rfc3987)
577
+ - [RFC 8141 — Uniform Resource Names](https://www.rfc-editor.org/rfc/rfc8141)
527
578
  - [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
528
579
  - [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
529
580
  - [RFC 8089 — The `file` URI Scheme](https://www.rfc-editor.org/rfc/rfc8089)