From 853b075327e13758ea30b685cb439f59c96adec2 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 8 Nov 2026 05:54:20 +0800 Subject: [PATCH] feat(isISO8601Duration): validate ISO 8601 durations --- README.md | 1 + src/index.js | 2 + src/lib/isISO8601Duration.js | 43 +++++++++ test/iso8601-duration.test.js | 175 ++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 src/lib/isISO8601Duration.js create mode 100644 test/iso8601-duration.test.js diff --git a/README.md b/README.md index e8b7aedd9..a2b2a29b5 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Validator | Description **isISO6346(str)** | check if the string is a valid [ISO 6346](https://en.wikipedia.org/wiki/ISO_6346) shipping container identification. **isISO6391(str)** | check if the string is a valid [ISO 639-1][ISO 639-1] language code. **isISO8601(str [, options])** | check if the string is a valid [ISO 8601][ISO 8601] date.
`options` is an object which defaults to `{ strict: false, strictSeparator: false }`. If `strict` is true, date strings with invalid dates like `2009-02-29` will be invalid. If `strictSeparator` is true, date strings with date and time separated by anything other than a T will be invalid. +**isISO8601Duration(str)** | check if the string is an ISO 8601 duration in uppercase designator form (e.g. `P3Y6M4DT12H30M5S`), standalone weeks (`P2W`), or alternative calendar/ordinal form (`P0003-06-04T12:30:05`, `P0003-178T12:30:05`, and their basic forms without separators). A decimal fraction using `.` or `,` is allowed only on the smallest represented unit. Designator quantities may exceed carry-over points; weeks cannot be combined with other units. Alternative forms use four year digits (or two for centuries), allow date-only and time-only forms, and require a complete date when combined with time. Time may omit minutes/seconds and have a fractional last component. Alternative fields are limited to 12 months, 30 calendar days, 24 hours, 60 minutes and 60 seconds, inclusive (ISO 8601:2004 section 4.4.3.3). Ordinal days are accepted from `000` through `366` without checking a leap year; these are quantities, not calendar dates. Signed durations, mixed weeks, expanded years, intervals and recurring intervals are not supported. **isISO15924(str)** | check if the string is a valid [ISO 15924][ISO 15924] officially assigned script code. **isISO31661Alpha2(str [, options])** | check if the string is a valid [ISO 3166-1 alpha-2][ISO 3166-1 alpha-2] officially assigned country code.
`options` is an object which can contain the key `userAssignedCodes`: an array of custom codes that are not officially assigned (e.g. `['XK']`). **isISO31661Alpha3(str [, options])** | check if the string is a valid [ISO 3166-1 alpha-3][ISO 3166-1 alpha-3] officially assigned country code.
`options` is an object which can contain the key `userAssignedCodes`: an array of custom codes that are not officially assigned (e.g. `['XXK']`). diff --git a/src/index.js b/src/index.js index 5e2cd78a7..47dedc461 100644 --- a/src/index.js +++ b/src/index.js @@ -93,6 +93,7 @@ import isBtcAddress from './lib/isBtcAddress'; import { isISO6346, isFreightContainerID } from './lib/isISO6346'; import isISO6391 from './lib/isISO6391'; import isISO8601 from './lib/isISO8601'; +import isISO8601Duration from './lib/isISO8601Duration'; import isRFC3339 from './lib/isRFC3339'; import isISO15924 from './lib/isISO15924'; import isISO31661Alpha2 from './lib/isISO31661Alpha2'; @@ -213,6 +214,7 @@ const validator = { isFreightContainerID, isISO6391, isISO8601, + isISO8601Duration, isISO15924, isRFC3339, isISO31661Alpha2, diff --git a/src/lib/isISO8601Duration.js b/src/lib/isISO8601Duration.js new file mode 100644 index 000000000..59c9907c6 --- /dev/null +++ b/src/lib/isISO8601Duration.js @@ -0,0 +1,43 @@ +import assertString from './util/assertString'; + +const quantity = '\\d+(?:[.,]\\d+)?'; +const duration = new RegExp(`^P(?=\\d|T\\d)(?:${quantity}Y)?(?:${quantity}M)?(?:${quantity}D)?` + + `(?:T(?=\\d)(?:${quantity}H)?(?:${quantity}M)?(?:${quantity}S)?)?$`); +const weeks = new RegExp(`^P${quantity}W$`); +const fractionalComponent = /^[.,]\d+[YMDHMSW]$/; + +function alternativePattern(dateSeparator, timeSeparator) { + // ISO 8601:2004, 4.4.3.3: these fields describe quantities, not a calendar date. + const completeDate = `\\d{4}${dateSeparator}` + + `(?:(?:0\\d|1[0-2])${dateSeparator}(?:[0-2]\\d|30)|(?:[0-2]\\d{2}|3[0-5]\\d|36[0-6]))`; + // Reduced dates cannot be combined with a time component (4.3.3). + const reducedDate = '(?:\\d{4}(?:-(?:0\\d|1[0-2]))?|\\d{2})(?!T)'; + return new RegExp(`^P(?=\\d|T\\d)(?:${completeDate}|${reducedDate})?` + + `(?:T([01]\\d|2[0-4])(?:${timeSeparator}([0-5]\\d|60)(?:${timeSeparator}([0-5]\\d|60))?)?` + + '([.,]\\d+)?)?$'); +} + +const alternativeBasic = alternativePattern('', ''); +const alternativeExtended = alternativePattern('-', ':'); + +export default function isISO8601Duration(str) { + assertString(str); + + const match = duration.exec(str) || weeks.exec(str); + // JavaScript's $ anchor also matches before a final line terminator. + if (match && match[0].length === str.length) { + // A fraction may only appear in the lowest-order component present. + const fraction = str.search(/[.,]/); + return fraction === -1 || fractionalComponent.test(str.slice(fraction)); + } + + const alternative = alternativeBasic.exec(str) || alternativeExtended.exec(str); + if (!alternative || alternative[0].length !== str.length) { + return false; + } + + // Compare the fraction as text so rounding cannot hide an exceeded carry-over point. + const lowest = alternative[3] || alternative[2] || alternative[1]; + const limit = alternative[2] ? '60' : '24'; + return lowest !== limit || !/[1-9]/.test(alternative[4] || ''); +} diff --git a/test/iso8601-duration.test.js b/test/iso8601-duration.test.js new file mode 100644 index 000000000..c0b1b2a6d --- /dev/null +++ b/test/iso8601-duration.test.js @@ -0,0 +1,175 @@ +import assert from 'assert'; +import validator from '../src/index'; +import test from './testFunctions'; + +describe('ISO 8601 durations', () => { + it('validates ordered date and time components with omitted zero units', () => { + test({ + validator: 'isISO8601Duration', + valid: [ + 'P3Y6M4DT12H30M5S', 'P1Y', 'P1M', 'P1D', 'PT1H', 'PT1M', 'PT1S', + 'P1Y1D', 'P1Y1MT1H1S', 'P1DT1S', 'PT1H1S', 'P0D', 'PT0S', + 'P0Y0M0DT0H0M0S', 'P0001Y02M03DT04H05M06S', + 'P10000Y', 'P13M', 'P40D', 'PT24H', 'PT60M', 'PT60S', + ], + invalid: [ + '', 'P', 'PT', 'P1YT', 'P1DT', 'P1', 'PT1', '1Y', '1H', + 'P1H', 'P1S', 'PT1Y', 'PT1D', 'P1D1M', 'P1M1Y', 'PT1S1M', + 'P1Y1Y', 'P1M1M', 'P1D1D', 'PT1H1H', 'PT1M1M', 'PT1S1S', + 'P1DT1HT1M', 'PT1H1DT1M', 'P1Q', 'PT1MS', 'P1M2', + ], + }); + }); + + it('allows a decimal fraction only on the smallest represented unit', () => { + test({ + validator: 'isISO8601Duration', + valid: [ + 'P0.5Y', 'P1,5M', 'P1Y2.5M', 'P1Y2M3,5D', + 'PT1.5H', 'PT1H30.5M', 'PT1H30M5.123456789123456789S', + 'P1DT0,00000000000001S', 'P0.0D', 'PT0001,50S', + ], + invalid: [ + 'P.5Y', 'PT,5S', 'P1.Y', 'PT1,S', 'PT1.2.3S', 'PT1,2.3S', + 'P1.5Y2M', 'P1,5M2D', 'P1.5DT1H', 'PT1.5H1M', 'PT1,5M1S', + 'P1.0Y0M', 'P1.5Y0M0D', 'PT1.0H0S', 'PT1e3S', 'PTInfinityS', + ], + }); + }); + + it('accepts standalone weeks and rejects mixing weeks with other units', () => { + test({ + validator: 'isISO8601Duration', + valid: ['P1W', 'P0W', 'P001W', 'P52W', 'P0.5W', 'P1,25W'], + invalid: [ + 'PW', 'P1WW', 'PT1W', 'P1W1D', 'P1Y1W', 'P1M1W', + 'P1WT1H', 'P1WT', 'P1.5W1D', 'P1W2W', + ], + }); + }); + + it('accepts calendar and ordinal alternative formats consistently', () => { + test({ + validator: 'isISO8601Duration', + valid: [ + 'P00030604T123005', 'P0003-06-04T12:30:05', + 'P00021015T103020', 'P0002-10-15T10:30:20', + 'P0002178T223355', 'P0002-178T22:33:55', + 'P00000000T000000', 'P0000-00-00T00:00:00', + 'P0000000T000000', 'P0000-000T00:00:00', + 'P0001-02-30T00:00:00', 'P0001-366T00:00:00', + 'P99991230T246060', 'P9999-12-30T24:60:60', + ], + invalid: [ + 'P0003-06-04T123005', 'P00030604T12:30:05', + 'P0002-178T223355', 'P0002178T22:33:55', + 'P0003-0604T12:30:05', 'P000306-04T12:30:05', + 'P0003-06-04T1230:05', 'P0003-06-04T12:3005', + 'P0003-06-04T1:30:05', 'P0003-6-4T12:30:05', + 'P003-06-04T12:30:05', 'P10000-06-04T12:30:05', + 'P0003-13-00T00:00:00', 'P0003-00-31T00:00:00', + 'P0003-367T00:00:00', 'P0003367T000000', + 'P0000-00-00T25:00:00', 'P00000000T250000', + 'P0000-00-00T00:61:00', 'P00000000T006100', + 'P0000-00-00T00:00:61', 'P00000000T000061', + 'P0003-W06-4T12:30:05', 'P0003W064T123005', + 'P0003-06T12:30:05', 'P0003T12:30:05', + 'P0003-06-04T', 'P0003-06-04T12:30:05Z', + 'P0003-06-04T12:30:05+01:00', 'P0003-06-04T12:30:05\n', + ], + }); + }); + + it('accepts reduced time precision and fractions in alternative formats', () => { + test({ + validator: 'isISO8601Duration', + valid: [ + 'P00030604T12', 'P0003-06-04T12', + 'P00030604T1230', 'P0003-06-04T12:30', + 'P0002178T22', 'P0002-178T22:33', + 'P00030604T12.5', 'P0003-06-04T12,5', + 'P00030604T1230.5', 'P0003-06-04T12:30,5', + 'P0002178T223355.5', 'P0002-178T22:33:55,5', + 'P0000-00-00T23.999999999999999999', + 'P0000-00-00T24.000', 'P0000-00-00T00:60,0', + 'P0000-00-00T00:00:60.000', + ], + invalid: [ + 'P00030604T123', 'P00030604T12305', + 'P0003-06-04T12:', 'P0003-06-04T12:30:', + 'P0003-06-04T12.5:30', 'P0003-06-04T12:30,5:05', + 'P0003-06.5-04T12:30:05', 'P0003-06-04.5T12:30:05', + 'P0003-06-04T12.', 'P0003-06-04T12,', + 'P0000-00-00T24.000000000000000001', + 'P0000-00-00T00:60.000000000000000001', + 'P0000-00-00T00:00:60.000000000000000001', + ], + }); + }); + + it('accepts alternative date-only and time-only representations', () => { + test({ + validator: 'isISO8601Duration', + valid: [ + 'P00', 'P20', 'P99', 'P0000', 'P0003', 'P9999', + 'P0003-00', 'P0003-12', 'P00030604', 'P0003-06-04', + 'P0002178', 'P0002-178', 'P0000-000', 'P0001-366', + 'PT12', 'PT1230', 'PT123005', 'PT12:30', 'PT12:30:05', + 'PT00', 'PT12.5', 'PT1230,5', 'PT12:30,5', + 'PT123005.5', 'PT12:30:05.5', 'PT24', 'PT0060', 'PT00:60:60', + ], + invalid: [ + 'P0', 'P000', 'P00000', 'P000306', 'P0003-13', 'P0003-6', + 'P0003-31', 'P0003367', 'P0000-0000', 'P0000-0', + 'P0003-06T12', 'P0003T12', 'P20T12', + 'PT1', 'PT123', 'PT12305', 'PT12300500', 'PT12:', + 'PT12:3005', 'PT1230:05', 'PT25', 'PT00:61', 'PT000061', + 'PT24.000000000000000001', 'PT00:60.000000000000000001', + 'P0003.5', 'P0003-06.5', 'P20.5', 'P0000-000.5', + 'PT12Z', 'P+00003', 'P+00003-06-04T12:30:05', + 'P0003\n', 'PT12\n', 'P0003-06-04\n', + ], + }); + }); + + it('requires complete uppercase duration strings without signs or intervals', () => { + test({ + validator: 'isISO8601Duration', + valid: ['P1D', 'PT12H30M'], + invalid: [ + 'p1d', 'Pt1H', 'PT1h', '+P1D', '-P1D', 'P-1D', 'PT+1S', + ' P1D', 'P1D ', 'P 1D', 'PT1 H', 'P1D\n', 'P1D\r\n', 'P1D\t', + 'P1D\u0000', 'P1D\u2028', 'P1D\u2029', + 'prefixP1D', 'P1Dsuffix', '2024-01-01', '2024-01-01/P1D', + 'P1D/2024-01-02', 'R5/P1D', 'PT1HZ', 'PT1H+01:00', + 'P\u0661D', 'P\uff11D', + ], + }); + }); + + it('validates long quantities without losing precision or matching a prefix', () => { + const digits = '9'.repeat(10000); + test({ + validator: 'isISO8601Duration', + valid: [`P${digits}D`, `PT0.${digits}S`, `P${digits}W`], + invalid: [ + `P${digits}`, `P${digits}X`, `PT${digits}.S`, `PT0.${digits}Sx`, + `P${digits}.5Y1M`, + ], + }); + }); + + it('follows the string-only validator contract', () => { + assert.strictEqual(typeof validator.isISO8601Duration, 'function'); + [null, undefined, 1, true, {}, [], new Date()].forEach((value) => { + assert.throws(() => validator.isISO8601Duration(value), TypeError); + }); + }); + + it('keeps date validation separate from duration validation', () => { + assert.strictEqual(validator.isISO8601Duration('P1D'), true); + assert.strictEqual(validator.isISO8601('P1D'), false); + assert.strictEqual(validator.isISO8601Duration('2024-01-01'), false); + assert.strictEqual(validator.isISO8601('2024-01-01'), true); + }); +});