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