@cookbook/urlkit 1.1.0 → 1.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/README.md CHANGED
@@ -181,7 +181,7 @@ ArticleUrl.build({ params: { slug: 'post-1' } });
181
181
  // ArticleUrl.build({ pathname: '/articles/post-1' });
182
182
  ```
183
183
 
184
- Path params are inferred from the pattern. Built-in `int`, `decimal` and `range` path constraints parse to numbers in standalone `url(...)` contracts.
184
+ Path params are inferred from the pattern. Built-in `int`, `decimal` and `range` path constraints parse to numbers in standalone `url(...)` contracts. PathKit does not expose a `{param:number}` built-in; use `{param:decimal}` for finite decimal path values.
185
185
 
186
186
  ```ts
187
187
  const UserUrl = url({ path: '/users/{id:int}' });
@@ -192,7 +192,7 @@ const user = UserUrl.parse('/users/42');
192
192
 
193
193
  ### Custom path constraints
194
194
 
195
- URLKit re-exports PathKit's `createConstraint` and provides global registration helpers for reusable path constraints. Custom constraints infer `string` params by default; built-in `int`, `decimal` and `range` still infer `number`.
195
+ URLKit re-exports PathKit's `createConstraint` and provides global registration helpers for reusable path constraints. Custom constraints infer `string` params by default; built-in `int`, `decimal` and `range` still infer `number`. When a PathKit constraint rejects a value, URLKit wraps it as `UrlKitError` with `code: 'invalid-param'` and preserves the original PathKit error in `error.cause`.
196
196
 
197
197
  ```ts
198
198
  import { createConstraint, registerPathConstraint, url } from '@cookbook/urlkit';
@@ -403,35 +403,32 @@ const Reports = search({
403
403
  });
404
404
  ```
405
405
 
406
- | Builder | Serialized format |
407
- | ---------------------------------------- | -------------------------------------- |
408
- | `date()` | Date-only `YYYY-MM-DD`. |
409
- | `dateTime()` | Strict UTC `YYYY-MM-DDTHH:mm:ss.sssZ`. |
410
- | `date({ format: 'unix-seconds' })` | Finite integer seconds. |
411
- | `date({ format: 'unix-ms' })` | Finite integer milliseconds. |
412
- | `date({ format: { parse, serialize } })` | Custom runtime date codec. |
406
+ | Builder | Serialized format |
407
+ | --------------------------------------------- | -------------------------------------- |
408
+ | `date()` | Date-only `YYYY-MM-DD`. |
409
+ | `dateTime()` | Strict UTC `YYYY-MM-DDTHH:mm:ss.sssZ`. |
410
+ | `date({ format: 'dd-MM-yyyy' })` | Strict custom date format string. |
411
+ | `dateTime({ format: 'dd-MM-yyyy HH:mm:ss' })` | Strict custom date-time format string. |
412
+ | `date({ format: { parse, serialize } })` | Custom runtime date codec. |
413
+ | `dateTime({ format: { parse, serialize } })` | Custom runtime date-time codec. |
414
+ | `date({ format: 'unix-seconds' })` | Finite integer seconds. |
415
+ | `date({ format: 'unix-ms' })` | Finite integer milliseconds. |
413
416
 
414
- Custom runtime date codecs are available only in runtime-builder schemas. Static date defaults use serialized values, not `Date` instances.
417
+ Custom date and date-time format strings are available only in runtime-builder schemas. Supported tokens are `yyyy`, `MM`, `dd`, `HH`, `mm`, `ss`, and `SSS`. Static date defaults use serialized values, not `Date` instances.
415
418
 
416
419
  ```ts
417
420
  const CustomDate = search({
418
- from: date({
419
- format: {
420
- parse(value) {
421
- const [day, month, year] = value.split('-');
422
- return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
423
- },
424
- serialize(value) {
425
- const day = String(value.getUTCDate()).padStart(2, '0');
426
- const month = String(value.getUTCMonth() + 1).padStart(2, '0');
427
- return `${day}-${month}-${value.getUTCFullYear()}`;
428
- },
429
- },
430
- }),
421
+ from: date({ format: 'dd-MM-yyyy' }),
422
+ at: dateTime({ format: 'dd-MM-yyyy HH:mm:ss' }).optional(),
431
423
  });
432
424
 
433
- CustomDate.build({ search: { from: new Date('2026-06-02T00:00:00.000Z') } });
434
- // '?from=02-06-2026'
425
+ CustomDate.build({
426
+ search: {
427
+ from: new Date('2026-06-02T00:00:00.000Z'),
428
+ at: new Date('2026-06-02T12:30:05.000Z'),
429
+ },
430
+ });
431
+ // '?from=02-06-2026&at=02-06-2026+12%3A30%3A05'
435
432
  ```
436
433
 
437
434
  ## Object search
@@ -639,16 +636,16 @@ try {
639
636
  }
640
637
  ```
641
638
 
642
- | Code | Meaning |
643
- | -------------------- | -------------------------------------------------------------------------- |
644
- | `invalid-url` | URL input could not be parsed as a URL. |
645
- | `path-mismatch` | URL pathname does not satisfy the path contract. |
646
- | `missing-param` | Required path param is missing. |
647
- | `invalid-param` | Path param is invalid. |
648
- | `missing-search` | Required search field is missing. |
649
- | `invalid-search` | Search value, unknown search behavior, or object search shape is invalid. |
650
- | `invalid-hash` | Hash value is missing or invalid. |
651
- | `invalid-descriptor` | Contract/schema/static descriptor is invalid at construction/compile time. |
639
+ | Code | Meaning |
640
+ | -------------------- | ------------------------------------------------------------------------------------------ |
641
+ | `invalid-url` | URL input could not be parsed as a URL. |
642
+ | `path-mismatch` | URL pathname does not satisfy the path contract. |
643
+ | `missing-param` | Required path param is missing. |
644
+ | `invalid-param` | Path param is invalid. Constraint failures preserve the original PathKit error in `cause`. |
645
+ | `missing-search` | Required search field is missing. |
646
+ | `invalid-search` | Search value, unknown search behavior, or object search shape is invalid. |
647
+ | `invalid-hash` | Hash value is missing or invalid. |
648
+ | `invalid-descriptor` | Contract/schema/static descriptor is invalid at construction/compile time. |
652
649
 
653
650
  ## TypeScript inference
654
651
 
@@ -1,6 +1,7 @@
1
1
  import compile from '@cookbook/pathkit/compile';
2
2
  import match from '@cookbook/pathkit/match';
3
- import { hasConstraint, getConstraint, registerConstraint, createConstraint } from '@cookbook/pathkit/constraints';
3
+ import { hasConstraint, getConstraint, registerConstraint } from '@cookbook/pathkit/constraints';
4
+ import tokenize from '@cookbook/pathkit/tokenize';
4
5
 
5
6
  const defaultMessages = {
6
7
  'invalid-url': 'Invalid URL.',
@@ -325,6 +326,209 @@ function validateBooleanDefault(value, context) {
325
326
  });
326
327
  }
327
328
 
329
+ const supportedTokens = ['yyyy', 'SSS', 'MM', 'dd', 'HH', 'mm', 'ss'];
330
+ const dateTokens = new Set(['yyyy', 'MM', 'dd']);
331
+ const dateTimeTokens = new Set(['yyyy', 'MM', 'dd', 'HH', 'mm', 'ss', 'SSS']);
332
+ function parseDateFormatString(input, format, mode, options = {}) {
333
+ const compiled = compileDateFormatString(format, mode);
334
+ const match = compiled.pattern.exec(input);
335
+ if (!match?.groups) {
336
+ throw createDateFormatStringError(`${getFormatLabel(mode)} value must match format "${format}".`, options);
337
+ }
338
+ const parts = readDateParts(match.groups, compiled);
339
+ assertValidDateParts(parts, mode, options);
340
+ const value = new Date(Date.UTC(0, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, parts.millisecond));
341
+ value.setUTCFullYear(parts.year);
342
+ if (!matchesDateParts(value, parts)) {
343
+ throw createDateFormatStringError(`${getFormatLabel(mode)} value must be a valid UTC calendar ${mode === 'date' ? 'date' : 'instant'}.`, options);
344
+ }
345
+ return value;
346
+ }
347
+ function serializeDateFormatString(input, format, mode, options = {}) {
348
+ if (!(input instanceof Date) || !Number.isFinite(input.getTime())) {
349
+ throw createDateFormatStringError(`${getFormatLabel(mode)} value must be a valid Date.`, options);
350
+ }
351
+ const compiled = compileDateFormatString(format, mode);
352
+ const year = input.getUTCFullYear();
353
+ if (year < 0 || year > 9999) {
354
+ throw createDateFormatStringError(`${getFormatLabel(mode)} value year must be between 0000 and 9999 for yyyy format strings.`, options);
355
+ }
356
+ if (mode === 'date-time' && !compiled.hasMilliseconds && input.getUTCMilliseconds() !== 0) {
357
+ throw createDateFormatStringError('Date-time value cannot be serialized without losing milliseconds. Include SSS in the format or use a Date with zero milliseconds.', options);
358
+ }
359
+ return compiled.parts
360
+ .map((part) => {
361
+ if (part.kind === 'literal') {
362
+ return part.value;
363
+ }
364
+ return serializeToken(input, part.value);
365
+ })
366
+ .join('');
367
+ }
368
+ function validateDateFormatString(format, mode) {
369
+ compileDateFormatString(format, mode);
370
+ }
371
+ function compileDateFormatString(format, mode) {
372
+ const parts = tokenizeDateFormatString(format, mode);
373
+ validateDateFormatParts(format, parts, mode);
374
+ const pattern = new RegExp(`^${parts
375
+ .map((part) => part.kind === 'literal' ? escapeRegExp(part.value) : getTokenPattern(part.value))
376
+ .join('')}$`);
377
+ return {
378
+ format,
379
+ mode,
380
+ parts,
381
+ pattern,
382
+ hasMilliseconds: parts.some((part) => part.kind === 'token' && part.value === 'SSS'),
383
+ };
384
+ }
385
+ function tokenizeDateFormatString(format, mode) {
386
+ if (typeof format !== 'string' || format.length === 0) {
387
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format must be a non-empty string.`);
388
+ }
389
+ const parts = [];
390
+ let index = 0;
391
+ while (index < format.length) {
392
+ if (format[index] === "'") {
393
+ const literalEnd = format.indexOf("'", index + 1);
394
+ if (literalEnd === -1) {
395
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format contains an unterminated quoted literal.`);
396
+ }
397
+ const literal = format.slice(index + 1, literalEnd);
398
+ if (literal.length === 0) {
399
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format contains an empty quoted literal.`);
400
+ }
401
+ parts.push({ kind: 'literal', value: literal });
402
+ index = literalEnd + 1;
403
+ continue;
404
+ }
405
+ const token = findTokenAt(format, index);
406
+ if (token) {
407
+ parts.push({ kind: 'token', value: token });
408
+ index += token.length;
409
+ continue;
410
+ }
411
+ const character = format[index] ?? '';
412
+ if (isAsciiLetter(character)) {
413
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format contains unsupported token near "${format.slice(index)}".`);
414
+ }
415
+ parts.push({ kind: 'literal', value: character });
416
+ index += 1;
417
+ }
418
+ return mergeAdjacentLiterals(parts);
419
+ }
420
+ function findTokenAt(format, index) {
421
+ return supportedTokens.find((token) => format.startsWith(token, index));
422
+ }
423
+ function mergeAdjacentLiterals(parts) {
424
+ const merged = [];
425
+ for (const part of parts) {
426
+ const previous = merged[merged.length - 1];
427
+ if (part.kind === 'literal' && previous?.kind === 'literal') {
428
+ merged[merged.length - 1] = { kind: 'literal', value: `${previous.value}${part.value}` };
429
+ continue;
430
+ }
431
+ merged.push(part);
432
+ }
433
+ return merged;
434
+ }
435
+ function validateDateFormatParts(format, parts, mode) {
436
+ const allowedTokens = mode === 'date' ? dateTokens : dateTimeTokens;
437
+ const requiredTokens = mode === 'date' ? ['yyyy', 'MM', 'dd'] : ['yyyy', 'MM', 'dd', 'HH', 'mm', 'ss'];
438
+ const seenTokens = new Set();
439
+ for (const part of parts) {
440
+ if (part.kind === 'literal') {
441
+ continue;
442
+ }
443
+ if (!allowedTokens.has(part.value)) {
444
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format "${format}" contains unsupported token "${part.value}".`);
445
+ }
446
+ if (seenTokens.has(part.value)) {
447
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format "${format}" contains duplicate token "${part.value}".`);
448
+ }
449
+ seenTokens.add(part.value);
450
+ }
451
+ for (const token of requiredTokens) {
452
+ if (!seenTokens.has(token)) {
453
+ throw createInvalidDateFormatDescriptorError(`${getFormatLabel(mode)} format "${format}" is missing required token "${token}".`);
454
+ }
455
+ }
456
+ }
457
+ function readDateParts(groups, compiled) {
458
+ return {
459
+ year: Number(groups.yyyy),
460
+ month: Number(groups.MM),
461
+ day: Number(groups.dd),
462
+ hour: compiled.mode === 'date-time' ? Number(groups.HH) : 0,
463
+ minute: compiled.mode === 'date-time' ? Number(groups.mm) : 0,
464
+ second: compiled.mode === 'date-time' ? Number(groups.ss) : 0,
465
+ millisecond: compiled.hasMilliseconds ? Number(groups.SSS) : 0,
466
+ };
467
+ }
468
+ function assertValidDateParts(parts, mode, options) {
469
+ if (parts.month < 1 ||
470
+ parts.month > 12 ||
471
+ parts.day < 1 ||
472
+ parts.day > 31 ||
473
+ parts.hour < 0 ||
474
+ parts.hour > 23 ||
475
+ parts.minute < 0 ||
476
+ parts.minute > 59 ||
477
+ parts.second < 0 ||
478
+ parts.second > 59 ||
479
+ parts.millisecond < 0 ||
480
+ parts.millisecond > 999) {
481
+ throw createDateFormatStringError(`${getFormatLabel(mode)} value must be a valid UTC calendar ${mode === 'date' ? 'date' : 'instant'}.`, options);
482
+ }
483
+ }
484
+ function matchesDateParts(value, parts) {
485
+ return (value.getUTCFullYear() === parts.year &&
486
+ value.getUTCMonth() === parts.month - 1 &&
487
+ value.getUTCDate() === parts.day &&
488
+ value.getUTCHours() === parts.hour &&
489
+ value.getUTCMinutes() === parts.minute &&
490
+ value.getUTCSeconds() === parts.second &&
491
+ value.getUTCMilliseconds() === parts.millisecond);
492
+ }
493
+ function serializeToken(input, token) {
494
+ switch (token) {
495
+ case 'yyyy':
496
+ return String(input.getUTCFullYear()).padStart(4, '0');
497
+ case 'MM':
498
+ return String(input.getUTCMonth() + 1).padStart(2, '0');
499
+ case 'dd':
500
+ return String(input.getUTCDate()).padStart(2, '0');
501
+ case 'HH':
502
+ return String(input.getUTCHours()).padStart(2, '0');
503
+ case 'mm':
504
+ return String(input.getUTCMinutes()).padStart(2, '0');
505
+ case 'ss':
506
+ return String(input.getUTCSeconds()).padStart(2, '0');
507
+ case 'SSS':
508
+ return String(input.getUTCMilliseconds()).padStart(3, '0');
509
+ }
510
+ }
511
+ function getTokenPattern(token) {
512
+ return `(?<${token}>\\d{${token === 'yyyy' || token === 'SSS' ? token.length.toString() : '2'}})`;
513
+ }
514
+ function escapeRegExp(value) {
515
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
516
+ }
517
+ function isAsciiLetter(value) {
518
+ return /^[A-Za-z]$/.test(value);
519
+ }
520
+ function getFormatLabel(mode) {
521
+ return mode === 'date' ? 'Date' : 'Date-time';
522
+ }
523
+ function createDateFormatStringError(message, options) {
524
+ return new UrlKitError(options.code ?? 'invalid-search', message, {
525
+ path: [...(options.path ?? [])],
526
+ });
527
+ }
528
+ function createInvalidDateFormatDescriptorError(message) {
529
+ return new UrlKitError('invalid-descriptor', message);
530
+ }
531
+
328
532
  const datePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
329
533
  function parseDate(input, options = {}) {
330
534
  const match = datePattern.exec(input);
@@ -596,21 +800,24 @@ const unixMsCodec = {
596
800
  return serializeUnixMs(input, { code: context.errorCode, path: context.path });
597
801
  },
598
802
  };
599
- function date(options = {}) {
600
- const format = resolveDateFormat(options);
803
+ function date(options = {}, formatStringMode = 'date') {
804
+ const format = resolveDateFormat(options, formatStringMode);
601
805
  return createRuntimeSchemaBuilder({
602
806
  kind: 'date',
603
807
  options: { format },
604
- codec: getDateCodec(format),
808
+ codec: getDateCodec(format, formatStringMode),
605
809
  validateDefault(value, context) {
606
810
  validateDateDefault(value, context);
607
811
  },
608
812
  });
609
813
  }
610
- function getDateCodec(format) {
814
+ function getDateCodec(format, formatStringMode) {
611
815
  if (isDateFormatCodec(format)) {
612
816
  return createCustomDateCodec(format);
613
817
  }
818
+ if (isDateFormatString(format) && !isBuiltInDateFormat(format)) {
819
+ return createDateFormatStringCodec(format, formatStringMode);
820
+ }
614
821
  if (format === 'date-time') {
615
822
  return dateTimeCodec;
616
823
  }
@@ -622,6 +829,25 @@ function getDateCodec(format) {
622
829
  }
623
830
  return dateOnlyCodec;
624
831
  }
832
+ function createDateFormatStringCodec(format, mode) {
833
+ return {
834
+ parse(input, context) {
835
+ return parseDateFormatString(input, format, mode, {
836
+ code: context.errorCode,
837
+ path: context.path,
838
+ });
839
+ },
840
+ normalize(input, context) {
841
+ return validateDate(input, context);
842
+ },
843
+ serialize(input, context) {
844
+ return serializeDateFormatString(input, format, mode, {
845
+ code: context.errorCode,
846
+ path: context.path,
847
+ });
848
+ },
849
+ };
850
+ }
625
851
  function createCustomDateCodec(format) {
626
852
  return {
627
853
  parse(input, context) {
@@ -635,7 +861,7 @@ function createCustomDateCodec(format) {
635
861
  },
636
862
  };
637
863
  }
638
- function resolveDateFormat(options) {
864
+ function resolveDateFormat(options, formatStringMode) {
639
865
  if (!isDateOptions(options)) {
640
866
  throw new UrlKitError('invalid-descriptor', 'Date options must be an object.');
641
867
  }
@@ -643,11 +869,18 @@ function resolveDateFormat(options) {
643
869
  if (isBuiltInDateFormat(format) || isDateFormatCodec(format)) {
644
870
  return format;
645
871
  }
646
- throw new UrlKitError('invalid-descriptor', 'Date format must be a supported built-in format or an explicit codec.');
872
+ if (isDateFormatString(format)) {
873
+ validateDateFormatString(format, formatStringMode);
874
+ return format;
875
+ }
876
+ throw new UrlKitError('invalid-descriptor', 'Date format must be a supported built-in format, a supported format string, or an explicit codec.');
647
877
  }
648
878
  function isDateOptions(input) {
649
879
  return typeof input === 'object' && input !== null && !Array.isArray(input);
650
880
  }
881
+ function isDateFormatString(input) {
882
+ return typeof input === 'string';
883
+ }
651
884
  function isBuiltInDateFormat(input) {
652
885
  return (input === 'date' || input === 'date-time' || input === 'unix-seconds' || input === 'unix-ms');
653
886
  }
@@ -1036,7 +1269,6 @@ function assertPathMatchFailure(pattern, pathname, segments) {
1036
1269
  const segment = segments[index];
1037
1270
  const pathnameSegment = pathnameSegments[index];
1038
1271
  if (!segment || pathnameSegment === undefined) {
1039
- console.log('ERROR', segment);
1040
1272
  throwPathMismatch(pattern, pathname);
1041
1273
  }
1042
1274
  if (segment.kind === 'literal') {
@@ -1045,9 +1277,11 @@ function assertPathMatchFailure(pattern, pathname, segments) {
1045
1277
  }
1046
1278
  continue;
1047
1279
  }
1048
- if (!isValidPathParamSegment(segment, pathnameSegment)) {
1049
- throw new UrlKitError('invalid-param', `Path parameter "${segment.name}" is invalid.`, {
1280
+ const validationFailure = getPathParamValidationFailure(segment, pathnameSegment);
1281
+ if (validationFailure) {
1282
+ throw new UrlKitError('invalid-param', validationFailure.message, {
1050
1283
  path: ['params', segment.name],
1284
+ ...(validationFailure.cause === undefined ? {} : { cause: validationFailure.cause }),
1051
1285
  });
1052
1286
  }
1053
1287
  }
@@ -1063,22 +1297,43 @@ function splitPath(pathname) {
1063
1297
  }
1064
1298
  return Object.freeze(normalized.split('/'));
1065
1299
  }
1066
- function isValidPathParamSegment(segment, value) {
1067
- if (!value || !segment.constraint) {
1068
- return false;
1300
+ function getPathParamValidationFailure(segment, value) {
1301
+ if (!value) {
1302
+ return {
1303
+ message: `Path parameter "${segment.name}" is invalid.`,
1304
+ };
1305
+ }
1306
+ if (!segment.constraint) {
1307
+ return undefined;
1069
1308
  }
1070
1309
  const constraint = getConstraint(segment.constraint);
1071
1310
  if (!constraint) {
1072
- return false;
1311
+ return {
1312
+ message: `Path constraint "${segment.constraint}" is not registered.`,
1313
+ };
1073
1314
  }
1074
1315
  try {
1075
1316
  constraint(segment.name, value, segment.constraintParams ?? '');
1076
- console.log(constraint, segment, value);
1077
- return true;
1317
+ return undefined;
1318
+ }
1319
+ catch (error) {
1320
+ const causeMessage = getCauseMessage(error);
1321
+ return {
1322
+ message: causeMessage
1323
+ ? `Path parameter "${segment.name}" is invalid: ${causeMessage}`
1324
+ : `Path parameter "${segment.name}" is invalid.`,
1325
+ cause: error,
1326
+ };
1327
+ }
1328
+ }
1329
+ function getCauseMessage(error) {
1330
+ if (error instanceof Error && error.message) {
1331
+ return error.message;
1078
1332
  }
1079
- catch {
1080
- return false;
1333
+ if (typeof error === 'string' && error) {
1334
+ return error;
1081
1335
  }
1336
+ return undefined;
1082
1337
  }
1083
1338
  function throwPathMismatch(pattern, pathname) {
1084
1339
  throw new UrlKitError('path-mismatch', `Pathname "${pathname}" does not match pattern "${pattern}".`, {
@@ -1091,14 +1346,18 @@ function getPathParamKind(segment) {
1091
1346
  case 'int':
1092
1347
  return 'int';
1093
1348
  case 'decimal':
1349
+ return 'decimal';
1094
1350
  case 'range':
1095
- return 'number';
1351
+ return 'range';
1096
1352
  case 'regex':
1097
1353
  return 'regex';
1098
1354
  default:
1099
1355
  return 'string';
1100
1356
  }
1101
1357
  }
1358
+ function isNumericPathParamKind(kind) {
1359
+ return kind === 'int' || kind === 'decimal' || kind === 'range';
1360
+ }
1102
1361
 
1103
1362
  function coercePathParam(segment, value, paramsMode) {
1104
1363
  if (paramsMode === 'raw') {
@@ -1114,10 +1373,10 @@ function coercePathParam(segment, value, paramsMode) {
1114
1373
  }
1115
1374
  return parsed;
1116
1375
  }
1117
- if (kind === 'number') {
1376
+ if (isNumericPathParamKind(kind)) {
1118
1377
  const parsed = Number(value);
1119
1378
  if (!Number.isFinite(parsed)) {
1120
- throw new UrlKitError('invalid-param', `Path parameter "${segment.name}" must be a finite number.`, {
1379
+ throw new UrlKitError('invalid-param', `Path parameter "${segment.name}" must be a finite decimal number.`, {
1121
1380
  path: ['params', segment.name],
1122
1381
  });
1123
1382
  }
@@ -1154,79 +1413,57 @@ function isRecord(input) {
1154
1413
  }
1155
1414
 
1156
1415
  function parsePathPattern(pattern) {
1157
- if (pattern === '') {
1158
- return Object.freeze([]);
1159
- }
1160
- const normalized = pattern.startsWith('/') ? pattern.slice(1) : pattern;
1161
- if (normalized === '') {
1162
- return Object.freeze([]);
1163
- }
1164
- return Object.freeze(normalized.split('/').map((segment, index) => parsePathSegment(segment, index)));
1165
- }
1166
- function parsePathSegment(segment, index) {
1167
- if (!segment.startsWith('{') || !segment.endsWith('}')) {
1168
- return Object.freeze({ kind: 'literal', value: segment });
1416
+ try {
1417
+ return Object.freeze(toParsedPathSegments(tokenize(pattern)));
1169
1418
  }
1170
- const token = segment.slice(1, -1);
1171
- const parsed = parseParamToken(token);
1172
- if (!parsed.name) {
1173
- throw new UrlKitError('invalid-descriptor', 'Path parameter name is required.', {
1174
- path: ['path', String(index)],
1419
+ catch (error) {
1420
+ if (error instanceof UrlKitError) {
1421
+ throw error;
1422
+ }
1423
+ const causeMessage = error instanceof Error && error.message ? `: ${error.message}` : '';
1424
+ throw new UrlKitError('invalid-descriptor', `Path pattern is invalid${causeMessage}.`, {
1425
+ path: ['path'],
1426
+ cause: error,
1175
1427
  });
1176
1428
  }
1177
- return Object.freeze({ kind: 'param', ...parsed });
1178
1429
  }
1179
- function parseParamToken(token) {
1180
- const colonIndex = token.indexOf(':');
1181
- if (colonIndex === -1) {
1182
- return { name: token };
1183
- }
1184
- const name = token.slice(0, colonIndex);
1185
- const constraintToken = token.slice(colonIndex + 1);
1186
- const paramsStart = constraintToken.indexOf('(');
1187
- if (paramsStart === -1 || !constraintToken.endsWith(')')) {
1188
- return { name, constraint: constraintToken };
1430
+ function toParsedPathSegments(tokens) {
1431
+ const segments = [];
1432
+ for (const token of tokens) {
1433
+ if (token.type === 'literal') {
1434
+ appendLiteralSegments(segments, token.value);
1435
+ continue;
1436
+ }
1437
+ segments.push(toParsedParamSegment(token));
1189
1438
  }
1190
- return {
1191
- name,
1192
- constraint: constraintToken.slice(0, paramsStart),
1193
- constraintParams: constraintToken.slice(paramsStart + 1, -1),
1194
- };
1439
+ return segments.map((segment) => Object.freeze(segment));
1195
1440
  }
1196
-
1197
- const numberConstraint = createConstraint({
1198
- parse(paramName, value, params) {
1199
- numberConstraint.verify(paramName, params);
1200
- const serialized = String(value);
1201
- if (!isFinitePathNumber(serialized)) {
1202
- throw new UrlKitError('invalid-param', `Path parameter "${paramName}" must be a finite number.`, {
1203
- path: ['params', paramName],
1204
- });
1205
- }
1206
- },
1207
- verify(paramName, params) {
1208
- if (params.trim()) {
1209
- throw new UrlKitError('invalid-descriptor', `Constraint 'number' declared for '${paramName}' parameter does not accept arguments.`, { path: ['path', paramName] });
1441
+ function appendLiteralSegments(segments, value) {
1442
+ if (!value) {
1443
+ return;
1444
+ }
1445
+ for (const segment of value.split('/')) {
1446
+ if (!segment) {
1447
+ continue;
1210
1448
  }
1211
- },
1212
- toRegExp() {
1213
- return '-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)';
1214
- },
1215
- });
1216
- function registerUrlKitPathConstraints() {
1217
- if (!hasConstraint('number')) {
1218
- registerConstraint('number', numberConstraint);
1449
+ segments.push({ kind: 'literal', value: segment });
1219
1450
  }
1220
1451
  }
1221
- function isFinitePathNumber(value) {
1222
- if (!/^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)) {
1223
- return false;
1224
- }
1225
- return Number.isFinite(Number(value));
1452
+ function toParsedParamSegment(token) {
1453
+ const constraint = token.constraints[0];
1454
+ return {
1455
+ kind: 'param',
1456
+ name: token.name,
1457
+ ...(constraint
1458
+ ? {
1459
+ constraint: constraint.type,
1460
+ ...(constraint.params ? { constraintParams: constraint.params } : {}),
1461
+ }
1462
+ : {}),
1463
+ };
1226
1464
  }
1227
1465
 
1228
1466
  function compilePath(pattern, options = {}) {
1229
- registerUrlKitPathConstraints();
1230
1467
  if (options.pathConstraints) {
1231
1468
  registerPathConstraints(options.pathConstraints);
1232
1469
  }
@@ -1301,5 +1538,5 @@ function compilePathPattern(pattern) {
1301
1538
  }
1302
1539
  }
1303
1540
 
1304
- export { UrlKitError as U, compilePath as a, boolean as b, compileSearchSchema as c, date as d, enumOf as e, registerPathConstraints as f, handleRuntimeSchemaAbsence as g, hasPathConstraint as h, int as i, createSchemaValueError as j, createRuntimeSchemaValueContext as k, compileRuntimeSchemaValue as l, createRuntimeSchemaBuilder as m, number as n, normalizeRuntimeSchemaValue as o, getRuntimeSchemaInternals as p, runtimeSchemaSymbol as q, registerPathConstraint as r, string as s, compileRuntimeSchema as t, normalizeCompiledRuntimeSchemaValue as u, compileStaticHashDescriptor as v, parseUnixSeconds as w, parseUnixMs as x, parseDateTime as y, parseDate as z };
1305
- //# sourceMappingURL=compile-path-COU1uxXm.js.map
1541
+ export { parseDate as A, UrlKitError as U, compilePath as a, boolean as b, compileSearchSchema as c, date as d, enumOf as e, registerPathConstraints as f, handleRuntimeSchemaAbsence as g, hasPathConstraint as h, int as i, createSchemaValueError as j, createRuntimeSchemaValueContext as k, compileRuntimeSchemaValue as l, createRuntimeSchemaBuilder as m, number as n, normalizeRuntimeSchemaValue as o, getRuntimeSchemaInternals as p, runtimeSchemaSymbol as q, registerPathConstraint as r, string as s, compileRuntimeSchema as t, normalizeCompiledRuntimeSchemaValue as u, validateDateFormatString as v, compileStaticHashDescriptor as w, parseUnixSeconds as x, parseUnixMs as y, parseDateTime as z };
1542
+ //# sourceMappingURL=compile-path-oMMfpXR8.js.map