Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <br/>`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. <br/>`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. <br/>`options` is an object which can contain the key `userAssignedCodes`: an array of custom codes that are not officially assigned (e.g. `['XXK']`).
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -213,6 +214,7 @@ const validator = {
isFreightContainerID,
isISO6391,
isISO8601,
isISO8601Duration,
isISO15924,
isRFC3339,
isISO31661Alpha2,
Expand Down
43 changes: 43 additions & 0 deletions src/lib/isISO8601Duration.js
Original file line number Diff line number Diff line change
@@ -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] || '');
}
175 changes: 175 additions & 0 deletions test/iso8601-duration.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading