diff --git a/README.md b/README.md
index c991f9cbd..98458ef02 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ Validator | Description
**isCreditCard(str [, options])** | check if the string is a credit card number.
`options` is an optional object that can be supplied with the following key(s): `provider` is an optional key whose value should be a string, and defines the company issuing the credit card. Valid values include `['amex', 'dinersclub', 'discover', 'jcb', 'mastercard', 'unionpay', 'visa']` or blank will check for any provider.
**isCurrency(str [, options])** | check if the string is a valid currency amount.
`options` is an object which defaults to `{ symbol: '$', require_symbol: false, allow_space_after_symbol: false, symbol_after_digits: false, allow_negatives: true, parens_for_negatives: false, negative_sign_before_digits: false, negative_sign_after_digits: false, allow_negative_sign_placeholder: false, thousands_separator: ',', decimal_separator: '.', allow_decimal: true, require_decimal: false, digits_after_decimal: [2], allow_space_after_digits: false }`.
**Note:** The array `digits_after_decimal` is filled with the exact number of digits allowed not a range, for example a range 1 to 3 will be given as [1, 2, 3].
**isDataURI(str)** | check if the string is a [data uri format][Data URI Format].
-**isDate(str [, options])** | check if the string is a valid date. e.g. [`2002-07-15`, new Date()].
`options` is an object which can contain the keys `format`, `strictMode` and/or `delimiters`.
`format` is a string and defaults to `YYYY/MM/DD`.
`strictMode` is a boolean and defaults to `false`. If `strictMode` is set to true, the validator will reject strings different from `format`.
`delimiters` is an array of allowed date delimiters and defaults to `['/', '-']`.
+**isDate(str [, options])** | check if the string is a valid date. e.g. [`2002-07-15`, `20020715`, new Date()].
`options` is an object which can contain the keys `format`, `strictMode`, `delimiters` and/or `allowDelimiterless`.
`format` is a string and defaults to `YYYY/MM/DD`. Supports delimiter-based formats (e.g., `YYYY/MM/DD`, `DD-MM-YYYY`) and ISO 8601 compliant delimiter-less formats (e.g., `YYYYMMDD`, `YYMMDD`) when `allowDelimiterless` is enabled.
`strictMode` is a boolean and defaults to `false`. If `strictMode` is set to true, the validator will reject strings different from `format`.
`delimiters` is an array of allowed date delimiters and defaults to `['/', '-']`. This option is ignored for delimiter-less formats.
`allowDelimiterless` is a boolean and defaults to `false`. Set to `true` to enable ISO 8601 compliant delimiter-less format validation (e.g., `YYYYMMDD`).
**isDecimal(str [, options])** | check if the string represents a decimal number, such as 0.1, .3, 1.1, 1.00003, 4.0, etc.
`options` is an object which defaults to `{force_decimal: false, decimal_digits: '1,', locale: 'en-US'}`.
`locale` determines the decimal separator and is one of `['ar', 'ar-AE', 'ar-BH', 'ar-DZ', 'ar-EG', 'ar-IQ', 'ar-JO', 'ar-KW', 'ar-LB', 'ar-LY', 'ar-MA', 'ar-QA', 'ar-QM', 'ar-SA', 'ar-SD', 'ar-SY', 'ar-TN', 'ar-YE', 'bg-BG', 'cs-CZ', 'da-DK', 'de-DE', 'el-GR', 'en-AU', 'en-GB', 'en-HK', 'en-IN', 'en-NZ', 'en-US', 'en-ZA', 'en-ZM', 'eo', 'es-ES', 'fa', 'fa-AF', 'fa-IR', 'fr-FR', 'fr-CA', 'hu-HU', 'id-ID', 'it-IT', 'ku-IQ', 'nb-NO', 'nl-NL', 'nn-NO', 'pl-PL', 'pl-Pl', 'pt-BR', 'pt-PT', 'ru-RU', 'sl-SI', 'sr-RS', 'sr-RS@latin', 'sv-SE', 'tr-TR', 'uk-UA', 'vi-VN']`.
**Note:** `decimal_digits` is given as a range like '1,3', a specific value like '3' or min like '1,'.
**isDivisibleBy(str, number)** | check if the string is a number that is divisible by another.
**isEAN(str)** | check if the string is an [EAN (European Article Number)][European Article Number].
diff --git a/src/lib/isDate.js b/src/lib/isDate.js
index 3a1e4afd2..bcaba178f 100644
--- a/src/lib/isDate.js
+++ b/src/lib/isDate.js
@@ -4,10 +4,24 @@ const default_date_options = {
format: 'YYYY/MM/DD',
delimiters: ['/', '-'],
strictMode: false,
+ allowDelimiterless: false,
};
-function isValidFormat(format) {
- return /(^(y{4}|y{2})[.\/-](m{1,2})[.\/-](d{1,2})$)|(^(m{1,2})[.\/-](d{1,2})[.\/-]((y{4}|y{2})$))|(^(d{1,2})[.\/-](m{1,2})[.\/-]((y{4}|y{2})$))/gi.test(format);
+function isValidFormat(format, allowDelimiterless) {
+ // Formats with delimiters
+ const withDelimiters = /(^(y{4}|y{2})[\.\/-](m{1,2})[\.\/-](d{1,2})$)|(^(m{1,2})[\.\/-](d{1,2})[\.\/-]((y{4}|y{2})$))|(^(d{1,2})[\.\/-](m{1,2})[\.\/-]((y{4}|y{2})$))/gi;
+
+ if (withDelimiters.test(format)) {
+ return true;
+ }
+
+ // Formats without delimiters (e.g., YYYYMMDD, YYMMDD)
+ if (allowDelimiterless) {
+ const withoutDelimiters = /^(y{4}|y{2})(m{2})(d{2})$/gi;
+ return withoutDelimiters.test(format);
+ }
+
+ return false;
}
function zip(date, format) {
@@ -21,14 +35,85 @@ function zip(date, format) {
return zippedArr;
}
+function hasNoDelimiter(format, delimiters) {
+ return !delimiters.some(delimiter => format.indexOf(delimiter) !== -1);
+}
+
+function parseDelimiterlessFormat(format) {
+ // Extract component positions from format like
+ // YYYYMMDD, YYMMDD (ISO 8601 compliant)
+ const lowerFormat = format.toLowerCase();
+ const components = [];
+ let pos = 0;
+
+ while (pos < lowerFormat.length) {
+ const char = lowerFormat[pos];
+ let len = 0;
+
+ while (pos + len < lowerFormat.length && lowerFormat[pos + len] === char) {
+ len += 1;
+ }
+
+ components.push({
+ type: char,
+ start: pos,
+ length: len,
+ });
+
+ pos += len;
+ }
+
+ return components;
+}
+
+function extractDatePartsWithoutDelimiter(input, format) {
+ const components = parseDelimiterlessFormat(format);
+ const dateObj = {};
+
+ for (const comp of components) {
+ dateObj[comp.type] = input.substring(
+ comp.start,
+ comp.start + comp.length
+ );
+ }
+
+ return dateObj;
+}
+
export default function isDate(input, options) {
- if (typeof options === 'string') { // Allow backward compatibility for old format isDate(input [, format])
+ // Allow backward compatibility for old format isDate(input [, format])
+ if (typeof options === 'string') {
options = merge({ format: options }, default_date_options);
} else {
options = merge(options, default_date_options);
}
- if (typeof input === 'string' && isValidFormat(options.format)) {
- if (options.strictMode && input.length !== options.format.length) return false;
+
+ // Handle non-string input
+ if (typeof input !== 'string' || !isValidFormat(options.format, options.allowDelimiterless)) {
+ if (!options.strictMode) {
+ return (
+ Object.prototype.toString.call(input) === '[object Date]' &&
+ isFinite(input)
+ );
+ }
+ return false;
+ }
+
+ if (options.strictMode && input.length !== options.format.length) {
+ return false;
+ }
+
+ let dateObj;
+
+ // Check if format has no delimiters (e.g., YYYYMMDD)
+ if (hasNoDelimiter(options.format, options.delimiters)) {
+ // Delimiter-less format parsing
+ if (input.length !== options.format.length) {
+ return false;
+ }
+ dateObj = extractDatePartsWithoutDelimiter(input, options.format);
+ } else {
+ // Original delimiter-based parsing
const formatDelimiter = options.delimiters
.find(delimiter => options.format.indexOf(delimiter) !== -1);
const dateDelimiter = options.strictMode
@@ -38,7 +123,7 @@ export default function isDate(input, options) {
input.split(dateDelimiter),
options.format.toLowerCase().split(formatDelimiter)
);
- const dateObj = {};
+ dateObj = {};
for (const [dateWord, formatWord] of dateAndFormat) {
if (!dateWord || !formatWord || dateWord.length !== formatWord.length) {
@@ -47,48 +132,34 @@ export default function isDate(input, options) {
dateObj[formatWord.charAt(0)] = dateWord;
}
+ }
- let fullYear = dateObj.y;
-
- // Check if the year starts with a hyphen
- if (fullYear.startsWith('-')) {
- return false; // Hyphen before year is not allowed
- }
-
- if (dateObj.y.length === 2) {
- const parsedYear = parseInt(dateObj.y, 10);
-
- if (isNaN(parsedYear)) {
- return false;
- }
-
- const currentYearLastTwoDigits = new Date().getFullYear() % 100;
+ let fullYear = dateObj.y;
- if (parsedYear < currentYearLastTwoDigits) {
- fullYear = `20${dateObj.y}`;
- } else {
- fullYear = `19${dateObj.y}`;
- }
- }
+ // Check if the year starts with a hyphen
+ if (fullYear.startsWith('-')) {
+ return false; // Hyphen before year is not allowed
+ }
- let month = dateObj.m;
+ if (dateObj.y.length === 2) {
+ const parsedYear = parseInt(dateObj.y, 10);
- if (dateObj.m.length === 1) {
- month = `0${dateObj.m}`;
+ if (isNaN(parsedYear)) {
+ return false;
}
- let day = dateObj.d;
+ const currentYearLastTwoDigits = new Date().getFullYear() % 100;
- if (dateObj.d.length === 1) {
- day = `0${dateObj.d}`;
+ if (parsedYear < currentYearLastTwoDigits) {
+ fullYear = `20${dateObj.y}`;
+ } else {
+ fullYear = `19${dateObj.y}`;
}
-
- return new Date(`${fullYear}-${month}-${day}T00:00:00.000Z`).getUTCDate() === +dateObj.d;
}
- if (!options.strictMode) {
- return Object.prototype.toString.call(input) === '[object Date]' && isFinite(input);
- }
+ const month = dateObj.m.length === 1 ? `0${dateObj.m}` : dateObj.m;
+ const day = dateObj.d.length === 1 ? `0${dateObj.d}` : dateObj.d;
- return false;
+ const dateString = `${fullYear}-${month}-${day}T00:00:00.000Z`;
+ return new Date(dateString).getUTCDate() === +dateObj.d;
}
diff --git a/test/validators.test.js b/test/validators.test.js
index 9bd00d6ec..f07ce2e22 100644
--- a/test/validators.test.js
+++ b/test/validators.test.js
@@ -14467,6 +14467,64 @@ describe('Validators', () => {
'abc.04.05.2024',
],
});
+ // Test delimiter-less format YYYYMMDD
+ test({
+ validator: 'isDate',
+ args: [{ format: 'YYYYMMDD', allowDelimiterless: true }],
+ valid: [
+ '20200229', // leap year
+ '20200115',
+ '20140215',
+ '20140315',
+ '19991231',
+ '20000101',
+ ],
+ invalid: [
+ '2020-02-29',
+ '2020/02/29',
+ '20200230', // invalid date
+ '20190229', // non-leap year
+ '20200431', // invalid date
+ '2020229', // too short
+ '202002290', // too long
+ '',
+ 'abcdefgh',
+ '2020ab29',
+ '-20200229',
+ ],
+ });
+ // Test delimiter-less format YYMMDD (two-digit year)
+ test({
+ validator: 'isDate',
+ args: [{ format: 'YYMMDD', allowDelimiterless: true }],
+ valid: [
+ '200229', // 2020 leap year
+ '140215',
+ '991231',
+ '000101',
+ ],
+ invalid: [
+ '20200229',
+ '2020-02-29',
+ '20229', // too short
+ '2002290', // too long
+ '',
+ 'abcdef',
+ '20ab29',
+ '-200229',
+ ],
+ });
+ // Test that delimiter-less formats are rejected without allowDelimiterless option
+ test({
+ validator: 'isDate',
+ args: [{ format: 'YYYYMMDD' }],
+ valid: [],
+ invalid: [
+ '20200229',
+ '20020715',
+ '19991231',
+ ],
+ });
// emulating Pacific time zone offset & time
// which could potentially result in UTC conversion issues
timezone_mock.register('US/Pacific');