From 0bba745ac742a7e89fd5ea4e7ac857be212ce261 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:10 +0000 Subject: [PATCH 01/21] test(isDate): avoid implementation-defined Date parsing The isDate fixtures used `new Date([2014, 2, 15])`, which converts the array to the string '2014,2,15' and parses it. That format is implementation- defined: V8 and SpiderMonkey give 15 Feb 2014, JavaScriptCore (Safari) gives an Invalid Date, so the test fails in WebKit. Use `new Date(2014, 1, 15)`, the same date V8 produced, so the fixture means the same on every engine. No change to the library; this prepares the suite for the browser runs added later in this series. Co-Authored-By: Claude Opus 5.5 --- test/validators.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/validators.test.js b/test/validators.test.js index 98d2a12ff..0230aa453 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -14396,7 +14396,9 @@ describe('Validators', () => { validator: 'isDate', valid: [ new Date(), - new Date([2014, 2, 15]), + // Not new Date([2014, 2, 15]): parsing '2014,2,15' is implementation-defined + // and gives an Invalid Date in Safari (JavaScriptCore). + new Date(2014, 1, 15), new Date('2014-03-15'), '2020/02/29', '2020-02-19', @@ -14583,7 +14585,7 @@ describe('Validators', () => { '2020-02-29', '15-07/2002', new Date(), - new Date([2014, 2, 15]), + new Date(2014, 1, 15), new Date('2014-03-15'), '-2020-04-19', '-2023/05/24', @@ -14606,7 +14608,7 @@ describe('Validators', () => { args: [{ delimiters: ['/', ' '] }], valid: [ new Date(), - new Date([2014, 2, 15]), + new Date(2014, 1, 15), new Date('2014-03-15'), '2020/02/29', '2020 02 29', @@ -14655,7 +14657,7 @@ describe('Validators', () => { '2020-02-29', '15-07/2002', new Date(), - new Date([2014, 2, 15]), + new Date(2014, 1, 15), new Date('2014-03-15'), '29.02.2020', '02.29.2020.20', From 3ecef3140da0e5b114b954db88bb00328fdab912 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:10 +0000 Subject: [PATCH 02/21] fix(build): keep validator.min.js ES5 uglify-js 3.18 made its `module` option default to true, which lets it emit ES2015 syntax. Since then the minified bundle starts with an arrow function (`((t,e)=>{...`), so validator.min.js throws a SyntaxError in ES5 engines such as IE 11 and Node.js 0.12, even though validator.js itself is ES5. The published 13.12.0 bundle is ES5; 13.15.15 to 13.15.35 are not. Pass --no-module so the minified bundle is ES5 again. A later commit adds a build check so this cannot regress silently. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a76409af..0b96b78ed 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "clean:es": "rimraf es", "clean:browser": "rimraf validator*.js", "clean": "run-p clean:*", - "minify": "uglifyjs validator.js -o validator.min.js --compress --mangle --comments /Copyright/", + "minify": "uglifyjs validator.js -o validator.min.js --no-module --compress --mangle --comments /Copyright/", "build:browser": "node build-browser.mjs && npm run minify", "build:es": "babel src -d es --env-name=es", "build:node": "babel src -d .", From 8f73eab234a77a9388f0bbcbca25962b5c7cdae6 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:10 +0000 Subject: [PATCH 03/21] fix(index): support named imports from native ES modules in Node.js `import { isEmail } from 'validator'` fails in native ES modules in Node.js ("does not provide an export named isEmail"): Babel's add-module-exports ends index.js with `module.exports = exports.default;`, which Node.js' CommonJS export lexer cannot analyse statically. scripts/annotate-cjs-exports.js runs after `babel src -d .` and appends a never-executed hint listing the export names, the pattern esbuild emits for the same purpose: 0 && (module.exports = { isEmail: isEmail, ... }); The names come from the built module itself, so they stay in sync. The hint is dead code in ES5 syntax, so `require('validator')` behaves exactly as before: plain, writable data properties, `.default === module.exports`, and no change to the ES or browser builds. This keeps the runtime semantics that the approach in #2377 (re-exporting a module namespace) would have changed. The new tests import every export by name in a child Node.js process (on Node.js 12.20+/14.13+, where the lexer exists) and check that the CommonJS export stays a plain mutable object. They fail without the hint. Fixes the named-import part of #683. Supersedes #2377. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++ package.json | 2 +- scripts/annotate-cjs-exports.js | 101 ++++++++++++++++++++++++++++++++ test/exports.test.js | 78 ++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 scripts/annotate-cjs-exports.js diff --git a/README.md b/README.md index e8b7aedd9..5216de2c7 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ validator.isEmail('foo@bar.com'); //=> true import validator from 'validator'; ``` +Named imports are also supported, including from native ES modules in Node.js 12.20+ / 14.13+: + +```javascript +import { isEmail } from 'validator'; +``` + Or, import only a subset of the library: ```javascript diff --git a/package.json b/package.json index 0b96b78ed..168228d9a 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "minify": "uglifyjs validator.js -o validator.min.js --no-module --compress --mangle --comments /Copyright/", "build:browser": "node build-browser.mjs && npm run minify", "build:es": "babel src -d es --env-name=es", - "build:node": "babel src -d .", + "build:node": "babel src -d . && node scripts/annotate-cjs-exports.js", "build": "run-p build:*", "pretest": "npm run build && npm run lint", "test": "npm run test:ci", diff --git a/scripts/annotate-cjs-exports.js b/scripts/annotate-cjs-exports.js new file mode 100644 index 000000000..111fc400c --- /dev/null +++ b/scripts/annotate-cjs-exports.js @@ -0,0 +1,101 @@ +/* + * Post-build step for `npm run build:node`. + * + * Babel (with babel-plugin-add-module-exports) ends the CommonJS entry point + * with `module.exports = exports.default;`, which Node.js cannot analyse + * statically, so `import { isEmail } from 'validator'` fails in native ESM. + * + * This script appends a never-executed hint listing the export names, using + * the pattern esbuild emits for the same purpose: + * + * 0 && (module.exports = { isEmail: isEmail, ... }); + * + * Node.js' CommonJS export lexer (cjs-module-lexer / merve) detects the names + * from this hint and reads their values from the real `module.exports` at + * import time. Because the hint is dead code, runtime behaviour of the + * CommonJS build is unchanged. The ES and browser builds are not touched. + * + * Usage: node scripts/annotate-cjs-exports.js [path/to/index.js] + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MARKER = '// Annotate the CommonJS export names for ESM import in Node.js:'; +const BABEL_TAIL = 'module.exports = exports.default;'; +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const RESERVED = [ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', + 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', + 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', + 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', + 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', + 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', + 'await', +]; + +function fail(message) { + throw new Error(`annotate-cjs-exports: ${message}`); +} + +function loadKeys(file) { + delete require.cache[file]; + const exported = require(file); // eslint-disable-line global-require, import/no-dynamic-require + if (!exported || typeof exported !== 'object') { + fail(`${file} does not export an object`); + } + if (exported.default !== exported) { + fail(`${file}: expected module.exports.default === module.exports`); + } + return Object.keys(exported); +} + +function annotate(file) { + let source = fs.readFileSync(file, 'utf8'); + + // Idempotency: drop a hint left by a previous run before re-annotating. + const markerIndex = source.indexOf(MARKER); + if (markerIndex !== -1) { + if (source.indexOf(MARKER, markerIndex + 1) !== -1) { + fail(`${file} contains more than one export-name hint`); + } + source = source.slice(0, markerIndex); + } + + if (source.indexOf(BABEL_TAIL) === -1) { + fail(`${file} does not contain \`${BABEL_TAIL}\`; ` + + 'the Babel output changed shape, so revisit whether this hint is still needed'); + } + + const keys = loadKeys(file); + // Node.js always maps `default` to module.exports, and `default` cannot be + // used as an identifier value in the hint, so it is left out. + const names = keys.filter(key => key !== 'default'); + if (names.length === 0) { + fail(`${file} exports no names`); + } + names.forEach((name) => { + if (!IDENTIFIER.test(name) || RESERVED.indexOf(name) !== -1) { + fail(`export name ${JSON.stringify(name)} cannot be written as \`${name}: ${name}\``); + } + }); + + const hint = `${MARKER}\n0 && (module.exports = {\n${ + names.map(name => ` ${name}: ${name}`).join(',\n') + }\n});\n`; + const separator = /\n$/.test(source) ? '' : '\n'; + fs.writeFileSync(file, source + separator + hint); + + // The hint is dead code: the module must still load with the same exports. + const after = loadKeys(file); + if (after.join(',') !== keys.join(',')) { + fail(`${file} exports changed after annotation`); + } + return names.length; +} + +const target = path.resolve(process.argv[2] || path.join(__dirname, '..', 'index.js')); +const count = annotate(target); +console.log(`annotate-cjs-exports: listed ${count} export names in ${target}`); diff --git a/test/exports.test.js b/test/exports.test.js index 283b11540..acaa86400 100644 --- a/test/exports.test.js +++ b/test/exports.test.js @@ -1,4 +1,9 @@ import assert from 'assert'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import url from 'url'; import validator from '../index'; import { locales as isPostalCodeLocales } from '../src/lib/isPostalCode'; import { locales as isAlphaLocales } from '../src/lib/isAlpha'; @@ -71,3 +76,76 @@ describe('Exports', () => { assert.ok(validator.ibanLocales instanceof Array); }); }); + +// Node.js detects the named exports of a CommonJS module statically +// (cjs-module-lexer) since v12.20.0 and v14.13.0. +function supportsCjsNamedExports() { + const [major, minor] = process.versions.node.split('.').map(Number); + return major > 14 || (major === 14 && minor >= 13) || (major === 12 && minor >= 20); +} + +describe('CommonJS entry point', () => { + /* eslint-disable global-require */ + it('should keep its exports as plain writable data properties', () => { + const cjs = require('../index'); + assert.strictEqual(cjs.default, cjs); + assert.strictEqual(Object.getPrototypeOf(cjs), Object.prototype); + assert.ok(Object.isExtensible(cjs)); + Object.keys(cjs).forEach((key) => { + const descriptor = Object.getOwnPropertyDescriptor(cjs, key); + assert.ok( + 'value' in descriptor && descriptor.writable && descriptor.configurable, + `${key} is not a writable, configurable data property` + ); + }); + + const original = cjs.isEmail; + const stub = () => true; + try { + cjs.isEmail = stub; + assert.strictEqual(require('../index').isEmail, stub); + } finally { + cjs.isEmail = original; + } + assert.strictEqual(require('../index').isEmail, original); + }); + /* eslint-enable global-require */ + + it('should provide every export as a named export to native ES modules', function () { + if (!supportsCjsNamedExports()) { + this.skip(); + return; + } + this.timeout(30000); + + const indexUrl = url.pathToFileURL(path.resolve(__dirname, '../index.js')).href; + const keys = Object.keys(validator); + const specifiers = keys.map((key, i) => `${key} as e${i}`).join(', '); + const source = [ + `import { ${specifiers} } from ${JSON.stringify(indexUrl)};`, + 'import { createRequire } from "module";', + `const cjs = createRequire(${JSON.stringify(indexUrl)})("./index.js");`, + `const imported = [${keys.map((key, i) => `e${i}`).join(', ')}];`, + `const keys = ${JSON.stringify(keys)};`, + 'const mismatched = keys.filter((key, i) => imported[i] !== cjs[key]);', + 'console.log(JSON.stringify({ mismatched, isDefault: cjs.default === cjs }));', + ].join('\n'); + + // Run the source from an .mjs file rather than with `--input-type=module -e`, + // which Deno's Node.js compatibility layer evaluates as a script. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validator-')); + const file = path.join(dir, 'named-exports.mjs'); + fs.writeFileSync(file, source); + let result; + try { + result = spawnSync(process.execPath, [file], { encoding: 'utf8' }); + } finally { + fs.unlinkSync(file); + fs.rmdirSync(dir); + } + if (result.error) throw result.error; + assert.strictEqual(result.status, 0, `ESM import failed:\n${result.stderr}`); + const output = JSON.parse(result.stdout.trim().split('\n').pop()); + assert.deepStrictEqual(output, { mismatched: [], isDefault: true }); + }); +}); From 6d647d9fb4c5187da118f624a0a0c19b6a3930dc Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:10 +0000 Subject: [PATCH 04/21] fix(es): make the es/ build loadable as native ES modules Deep imports such as `import isEmail from 'validator/es/lib/isEmail.js'` fail in native ES modules (Node.js, Deno, browsers without a bundler): the package has no "type": "module", so Node.js parses es/*.js as CommonJS, and es/ files import each other without file extensions. - scripts/babel-plugin-fully-specified.js (es build only) adds `.js` to relative specifiers, resolving each one the way require() would so es/ loads the same files as lib/. It fails the build on anything it cannot resolve. - scripts/write-es-package-json.js writes es/package.json with {"type": "module", "sideEffects": false}. sideEffects is copied from the root package.json; without it in the nested package.json, bundlers stop tree-shaking es/ (esbuild: 5.5 kB -> 43.7 kB for an isEmail-only import). lib/, index.js and the browser bundles are unchanged. webpack 4/5, Rollup, esbuild and Vite produce the same bundles as before. In native ES modules, `validator/es/lib/isMobilePhone.js` also gives the real function, unlike `validator/lib/isMobilePhone.js` (#1988); README now says so and shows the paths that work. Fixes #2387 and #2528. Co-Authored-By: Claude Opus 5.5 --- .babelrc | 3 + README.md | 8 +- package.json | 2 +- scripts/babel-plugin-fully-specified.js | 100 +++++++++++++ scripts/write-es-package-json.js | 52 +++++++ test/esModules.test.js | 184 ++++++++++++++++++++++++ 6 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 scripts/babel-plugin-fully-specified.js create mode 100644 scripts/write-es-package-json.js create mode 100644 test/esModules.test.js diff --git a/.babelrc b/.babelrc index 332bba6f9..f5d9f2ca2 100644 --- a/.babelrc +++ b/.babelrc @@ -5,6 +5,9 @@ ["@babel/preset-env", { "modules": false }] + ], + "plugins": [ + "./scripts/babel-plugin-fully-specified" ] }, "development": { diff --git a/README.md b/README.md index 5216de2c7..f9d0ffb51 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,16 @@ import isEmail from 'validator/lib/isEmail'; #### Tree-shakeable ES imports +The `es` directory contains the library as ES modules. With the `.js` extension, these imports work in bundlers as well as in native ES modules in Node.js 12.17+, Deno, and browsers (using an import map for the bare `validator/...` specifier): + ```javascript -import isEmail from 'validator/es/lib/isEmail'; +import isEmail from 'validator/es/lib/isEmail.js'; +import isMobilePhone, { locales } from 'validator/es/lib/isMobilePhone.js'; +import validator from 'validator/es/index.js'; ``` +In native ES modules, use `validator/es/lib/*.js` rather than `validator/lib/*.js`: CommonJS modules that also export `locales` (such as `isAlpha` and `isMobilePhone`) are imported as an object `{ default, locales }` instead of the function. + ### Client-side usage The library can be loaded either as a standalone script, or through an [AMD][amd]-compatible loader diff --git a/package.json b/package.json index 168228d9a..e2fed8e86 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "clean": "run-p clean:*", "minify": "uglifyjs validator.js -o validator.min.js --no-module --compress --mangle --comments /Copyright/", "build:browser": "node build-browser.mjs && npm run minify", - "build:es": "babel src -d es --env-name=es", + "build:es": "babel src -d es --env-name=es && node scripts/write-es-package-json.js", "build:node": "babel src -d . && node scripts/annotate-cjs-exports.js", "build": "run-p build:*", "pretest": "npm run build && npm run lint", diff --git a/scripts/babel-plugin-fully-specified.js b/scripts/babel-plugin-fully-specified.js new file mode 100644 index 000000000..919b3268f --- /dev/null +++ b/scripts/babel-plugin-fully-specified.js @@ -0,0 +1,100 @@ +/* + * Babel plugin for the `es` build (`npm run build:es`). + * + * Node.js ESM, Deno, browsers without a bundler, and JSR only accept fully + * specified relative imports, so `import x from './util/assertString'` must + * become `import x from './util/assertString.js'` in es/. Sources in src/ keep + * their extensionless specifiers, which the CommonJS and browser builds use + * unchanged. + * + * Each relative specifier is resolved against the source file the way + * Node.js' require() would resolve it (`.js`, then `/index.js`), + * so es/ and lib/ load the same modules. The build fails on anything this + * plugin cannot rewrite safely: bare (package) specifiers, computed dynamic + * imports, and specifiers that do not resolve to a .js file. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const RELATIVE = /^\.\.?(\/|$)/; + +function isFile(file) { + try { + return fs.statSync(file).isFile(); + } catch (e) { + return false; + } +} + +module.exports = function fullySpecified(api) { + api.assertVersion(7); + + function rewrite(source, state) { + const specifier = source.node.value; + const from = state.filename; + if (!from) { + throw source.buildCodeFrameError('fully-specified: the source file name is unknown'); + } + if (!RELATIVE.test(specifier)) { + throw source.buildCodeFrameError(`fully-specified: unexpected non-relative import ${JSON.stringify(specifier)}; ` + + 'validator has no runtime dependencies, and es/ cannot resolve packages'); + } + + const dir = path.dirname(from); + let resolved; + if (/\.js$/.test(specifier) && isFile(path.resolve(dir, specifier))) { + resolved = specifier; + } else if (!/\/$/.test(specifier) && isFile(path.resolve(dir, `${specifier}.js`))) { + resolved = `${specifier}.js`; + } else if (isFile(path.resolve(dir, specifier, 'index.js'))) { + resolved = `${specifier.replace(/\/$/, '')}/index.js`; + } else { + throw source.buildCodeFrameError(`fully-specified: cannot resolve ${JSON.stringify(specifier)} ` + + `from ${from} to a .js file`); + } + + if (resolved !== specifier) { + const literal = api.types.stringLiteral(resolved); + // Keep the original quote style so es/ differs only in the specifier. + const raw = source.node.extra && source.node.extra.raw; + if (raw) { + literal.extra = { rawValue: resolved, raw: `${raw[0]}${resolved}${raw[0]}` }; + } + source.replaceWith(literal); + } + } + + function declaration(nodePath, state) { + if (nodePath.node.source) { + rewrite(nodePath.get('source'), state); + } + } + + return { + name: 'fully-specified', + visitor: { + ImportDeclaration: declaration, + ExportNamedDeclaration: declaration, + ExportAllDeclaration: declaration, + CallExpression(nodePath, state) { + if (!nodePath.get('callee').isImport()) return; + const argument = nodePath.get('arguments')[0]; + if (!argument || !argument.isStringLiteral()) { + throw nodePath.buildCodeFrameError('fully-specified: dynamic import() needs a string literal specifier'); + } + rewrite(argument, state); + }, + // Dynamic import() as parsed with `createImportExpressions` (Babel 8). + ImportExpression(nodePath, state) { + const argument = nodePath.get('source'); + if (!argument.isStringLiteral()) { + throw nodePath.buildCodeFrameError('fully-specified: dynamic import() needs a string literal specifier'); + } + rewrite(argument, state); + }, + }, + }; +}; diff --git a/scripts/write-es-package-json.js b/scripts/write-es-package-json.js new file mode 100644 index 000000000..55aa52f9b --- /dev/null +++ b/scripts/write-es-package-json.js @@ -0,0 +1,52 @@ +/* + * Post-build step for `npm run build:es`. + * + * Writes es/package.json so that Node.js (and tools that follow its rules, + * such as webpack 5) load the files in es/ as ES modules, while the root + * package, lib/ and index.js stay CommonJS: + * + * type "module": es/**\/*.js are ES modules. + * sideEffects Copied from the root package.json. Bundlers read this flag + * from the nearest package.json, so without it the nested file + * would hide the root's `"sideEffects": false` from es/. + * + * No other fields are written: a `name` could collide with the real package + * in tools that index package names, and `main`/`exports` are only honoured + * at a package root. + * + * Usage: node scripts/write-es-package-json.js [path/to/es] + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const esDir = path.resolve(process.argv[2] || path.join(root, 'es')); + +function fail(message) { + throw new Error(`write-es-package-json: ${message}`); +} + +if (!fs.existsSync(path.join(esDir, 'index.js'))) { + fail(`${esDir}/index.js does not exist; run babel for the es build first`); +} + +const rootPackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +if (rootPackage.type !== undefined && rootPackage.type !== 'commonjs') { + fail(`the root package.json has "type": ${JSON.stringify(rootPackage.type)}; ` + + 'revisit whether es/package.json is still needed'); +} + +const esPackage = { type: 'module' }; +if (typeof rootPackage.sideEffects === 'boolean') { + esPackage.sideEffects = rootPackage.sideEffects; +} else if (rootPackage.sideEffects !== undefined) { + // Glob patterns are relative to the package.json that holds them. + fail('the root "sideEffects" is not a boolean; translate its patterns for es/ by hand'); +} + +const file = path.join(esDir, 'package.json'); +fs.writeFileSync(file, `${JSON.stringify(esPackage, null, 2)}\n`); +console.log(`write-es-package-json: wrote ${file}`); diff --git a/test/esModules.test.js b/test/esModules.test.js new file mode 100644 index 000000000..cdfef4781 --- /dev/null +++ b/test/esModules.test.js @@ -0,0 +1,184 @@ +import assert from 'assert'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import url from 'url'; +import { parseSync, traverse } from '@babel/core'; + +const root = path.resolve(__dirname, '..'); +const esRoot = path.join(root, 'es'); + +function listJsFiles(dir) { + return fs.readdirSync(dir).reduce((files, name) => { + const file = path.join(dir, name); + if (fs.statSync(file).isDirectory()) return files.concat(listJsFiles(file)); + return /\.js$/.test(name) ? files.concat(file) : files; + }, []); +} + +function moduleSpecifiers(file) { + const ast = parseSync(fs.readFileSync(file, 'utf8'), { + filename: file, + sourceType: 'module', + babelrc: false, + configFile: false, + }); + const specifiers = []; + const addSource = (nodePath) => { + if (nodePath.node.source) specifiers.push(nodePath.node.source.value); + }; + traverse(ast, { + ImportDeclaration: addSource, + ExportNamedDeclaration: addSource, + ExportAllDeclaration: addSource, + CallExpression(nodePath) { + if (nodePath.get('callee').isImport()) { + const argument = nodePath.node.arguments[0]; + specifiers.push(argument && argument.type === 'StringLiteral' ? argument.value : null); + } + }, + }); + return specifiers; +} + +function runNode(args) { + const result = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8' }); + if (result.error) throw result.error; + assert.strictEqual(result.status, 0, `node ${args[0]} failed:\n${result.stderr}`); + return JSON.parse(result.stdout.trim().split('\n').pop()); +} + +// Run ES module source from an .mjs file rather than with +// `--input-type=module -e`, which Deno's Node.js compatibility layer +// evaluates as a script. +function runModule(source) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'validator-')); + const file = path.join(dir, 'es-build.mjs'); + fs.writeFileSync(file, source); + try { + return runNode([file]); + } finally { + fs.unlinkSync(file); + fs.rmdirSync(dir); + } +} + +// ES modules load from .js files in a "type": "module" scope without a flag +// since Node.js 12.17.0 and 13.2.0. +function supportsNativeEsm() { + const [major, minor] = process.versions.node.split('.').map(Number); + return major > 13 || (major === 13 && minor >= 2) || (major === 12 && minor >= 17); +} + +describe('ES module build (es/)', () => { + it('should be marked as ES modules without hiding the root sideEffects flag', () => { + const esPackage = JSON.parse(fs.readFileSync(path.join(esRoot, 'package.json'), 'utf8')); + // eslint-disable-next-line global-require + const rootPackage = require('../package.json'); + assert.deepStrictEqual(esPackage, { type: 'module', sideEffects: rootPackage.sideEffects }); + }); + + it('should only use relative import specifiers with an extension that resolve to a file', function () { + this.timeout(30000); + const files = listJsFiles(esRoot); + assert.ok(files.length > 100, `expected the full es/ tree, found ${files.length} files`); + + const problems = []; + let checked = 0; + files.forEach((file) => { + moduleSpecifiers(file).forEach((specifier) => { + checked += 1; + const where = `${path.relative(root, file)}: ${JSON.stringify(specifier)}`; + if (typeof specifier !== 'string' || !/^\.\.?\//.test(specifier)) { + problems.push(`${where} is not a relative specifier`); + } else if (!/\.js$/.test(specifier)) { + problems.push(`${where} has no .js extension`); + } else { + const target = path.resolve(path.dirname(file), specifier); + if (!fs.existsSync(target) || !fs.statSync(target).isFile()) { + problems.push(`${where} does not resolve to a file`); + } + } + }); + }); + assert.ok(checked > 200, `expected the es/ imports to be found, found ${checked}`); + assert.deepStrictEqual(problems, []); + }); + + it('should load in native Node.js ESM and behave like the CommonJS build', function () { + if (!supportsNativeEsm()) { + this.skip(); + return; + } + this.timeout(30000); + + const href = file => JSON.stringify(url.pathToFileURL(path.join(root, file)).href); + const source = ` + import validator from ${href('es/index.js')}; + import isEmail from ${href('es/lib/isEmail.js')}; + import isMobilePhone, { locales as mobileLocales } from ${href('es/lib/isMobilePhone.js')}; + import isAlpha, { locales as alphaLocales } from ${href('es/lib/isAlpha.js')}; + import { createRequire } from 'module'; + + const require = createRequire(${href('index.js')}); + const cjs = require('./index.js'); + const cjsMobilePhone = require('./lib/isMobilePhone.js'); + + const keys = Object.keys(cjs).filter(key => key !== 'default'); + const samples = [ + ['isEmail', 'foo@bar.com'], ['isEmail', 'foo'], + ['isMobilePhone', '+14155552671', 'en-US'], ['isMobilePhone', '123', 'any'], + ['isAlpha', 'abc'], ['isAlpha', 'абв', 'ru-RU'], ['isAlpha', 'abc', 'ru-RU'], + ['isIBAN', 'DE89370400440532013000'], ['isPostalCode', '10001', 'US'], + ['isUUID', 'A987FBC9-4BED-4078-8F07-9141BA07C9F3'], ['toBoolean', '1'], + ]; + + console.log(JSON.stringify({ + sameKeys: JSON.stringify(Object.keys(validator).sort()) === JSON.stringify(keys.sort()), + differentValues: keys.filter(key => (typeof cjs[key] === 'function' + ? typeof validator[key] !== 'function' + : JSON.stringify(validator[key]) !== JSON.stringify(cjs[key]))), + deepImportsAreIndexExports: validator.isEmail === isEmail && + validator.isMobilePhone === isMobilePhone && validator.isAlpha === isAlpha && + validator.isAlphaLocales === alphaLocales, + mobileLocales: JSON.stringify(mobileLocales) === JSON.stringify(cjsMobilePhone.locales), + deepResults: [isEmail('foo@bar.com'), isMobilePhone('+14155552671', 'en-US'), isAlpha('abc')], + esm: samples.map(([name, ...args]) => validator[name](...args)), + cjs: samples.map(([name, ...args]) => cjs[name](...args)), + })); + `; + const output = runModule(source); + assert.deepStrictEqual(output.cjs, [ + true, false, true, false, true, true, false, true, true, true, true, + ]); + assert.deepStrictEqual(output, { + sameKeys: true, + differentValues: [], + deepImportsAreIndexExports: true, + mobileLocales: true, + deepResults: [true, true, true], + esm: output.cjs, + cjs: output.cjs, + }); + }); + + it('should be loadable with require() where Node.js supports require(esm)', function () { + if (!process.features || !process.features.require_module) { + this.skip(); + return; + } + this.timeout(30000); + + const output = runNode(['-e', ` + const isEmail = require(${JSON.stringify(path.join(esRoot, 'lib', 'isEmail.js'))}); + const isMobilePhone = require(${JSON.stringify(path.join(esRoot, 'lib', 'isMobilePhone.js'))}); + console.log(JSON.stringify([ + isEmail.default('foo@bar.com'), + isMobilePhone.default('+14155552671', 'en-US'), + Array.isArray(isMobilePhone.locales), + ])); + `]); + assert.deepStrictEqual(output, [true, true, true]); + }); +}); From bdbf947e4439834ebe458ed84a76c77c8e420239 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 05/21] fix: support ES5 runtimes such as Node.js 0.10 without polyfills package.json declares "engines": {"node": ">= 0.10"} and the browser bundle targets IE 11, but Babel only lowers syntax; it does not polyfill built-ins. `require('validator')` throws "Set is not defined" on Node.js 0.10, and Node.js 0.12 and 4 fail on Array.prototype.includes, startsWith, endsWith and find. Replace every ES2015+ built-in in src/ with ES5 code that behaves the same: - util/lookupSet for the ISO tables: a real Set where available (filled with .add, because IE 11 ignores the constructor's iterable), otherwise a has-only object. - util/startsWith and util/endsWith. - Math.floor for Math.trunc (both operands are non-negative), the existing includesArray helper for Array#includes, a loop for Array#find, a surrogate-aware regex instead of Array.from in isStrongPassword. src/.eslintrc.js forbids ES2015+ globals, static methods, prototype methods, non-transpilable regex features, async functions and generators in src/, with a message pointing to src/lib/util/. A differential fuzz (about 1M inputs) found no behaviour change on modern runtimes. Only difference: isDate's undocumented string `delimiters` option is now iterated by character instead of throwing. Co-Authored-By: Claude Opus 5.5 --- src/.eslintrc.js | 73 ++++++++++++++++++++++++++++++++++++ src/lib/isDate.js | 19 ++++++++-- src/lib/isEmail.js | 6 ++- src/lib/isIPRange.js | 3 +- src/lib/isISIN.js | 4 +- src/lib/isISO15924.js | 3 +- src/lib/isISO31661Alpha2.js | 6 ++- src/lib/isISO31661Alpha3.js | 6 ++- src/lib/isISO31661Numeric.js | 3 +- src/lib/isISO4217.js | 3 +- src/lib/isISO6391.js | 3 +- src/lib/isIdentityCard.js | 5 ++- src/lib/isLatLong.js | 6 ++- src/lib/isMailtoURI.js | 6 +-- src/lib/isMobilePhone.js | 3 +- src/lib/isStrongPassword.js | 3 +- src/lib/util/endsWith.js | 5 +++ src/lib/util/lookupSet.js | 30 +++++++++++++++ src/lib/util/startsWith.js | 5 +++ test/util.test.js | 61 ++++++++++++++++++++++++++++++ 20 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 src/.eslintrc.js create mode 100644 src/lib/util/endsWith.js create mode 100644 src/lib/util/lookupSet.js create mode 100644 src/lib/util/startsWith.js diff --git a/src/.eslintrc.js b/src/.eslintrc.js new file mode 100644 index 000000000..dcfdbcc01 --- /dev/null +++ b/src/.eslintrc.js @@ -0,0 +1,73 @@ +// Library code must run on ES5 engines: package.json declares Node.js >= 0.10 +// and the browser build targets IE 11. Babel only transpiles syntax, so ES2015+ +// built-ins are forbidden here; no polyfills are shipped. +const message = 'validator.js supports ES5 runtimes (Node.js >= 0.10, IE 11) without polyfills; ' + + 'use an ES5 equivalent (see src/lib/util/).'; + +const globals = [ + 'AggregateError', 'ArrayBuffer', 'Atomics', 'BigInt', 'BigInt64Array', 'BigUint64Array', + 'DataView', 'FinalizationRegistry', 'Float32Array', 'Float64Array', 'Int8Array', + 'Int16Array', 'Int32Array', 'Intl', 'Map', 'Promise', 'Proxy', 'Reflect', 'Set', + 'SharedArrayBuffer', 'Symbol', 'Uint8Array', 'Uint8ClampedArray', 'Uint16Array', + 'Uint32Array', 'WeakMap', 'WeakRef', 'WeakSet', 'globalThis', +]; + +const staticMembers = { + Array: ['from', 'of'], + Math: [ + 'acosh', 'asinh', 'atanh', 'cbrt', 'clz32', 'cosh', 'expm1', 'fround', 'hypot', 'imul', + 'log10', 'log1p', 'log2', 'sign', 'sinh', 'tanh', 'trunc', + ], + Number: [ + 'EPSILON', 'MAX_SAFE_INTEGER', 'MIN_SAFE_INTEGER', 'isFinite', 'isInteger', 'isNaN', + 'isSafeInteger', 'parseFloat', 'parseInt', + ], + Object: [ + 'assign', 'entries', 'fromEntries', 'getOwnPropertyDescriptors', 'getOwnPropertySymbols', + 'groupBy', 'hasOwn', 'is', 'setPrototypeOf', 'values', + ], + String: ['fromCodePoint', 'raw'], +}; + +// Flagged on any object, since the receiver's type is unknown statically. +const instanceMethods = [ + 'at', 'codePointAt', 'copyWithin', 'endsWith', 'fill', 'find', 'findIndex', 'findLast', + 'findLastIndex', 'flat', 'flatMap', 'includes', 'matchAll', 'normalize', 'padEnd', + 'padStart', 'repeat', 'replaceAll', 'startsWith', 'trimEnd', 'trimStart', +]; + +module.exports = { + rules: { + 'no-restricted-globals': ['error'].concat(globals.map(name => ({ name, message }))), + + 'no-restricted-properties': ['error', + // Kept from eslint-config-airbnb-base (rule options are replaced, not merged), + // except its window/self/global.isNaN and isFinite entries, which suggest + // the ES2015 Number.isNaN and Number.isFinite. + { object: 'arguments', property: 'callee', message: 'arguments.callee is deprecated' }, + { property: '__defineGetter__', message: 'Please use Object.defineProperty instead.' }, + { property: '__defineSetter__', message: 'Please use Object.defineProperty instead.' }, + { object: 'Math', property: 'pow', message: 'Use the exponentiation operator (**) instead.' }, + ] + .concat(...Object.keys(staticMembers).map(object => staticMembers[object] + .map(property => ({ object, property, message })))) + .concat(instanceMethods.map(property => ({ property, message }))), + + 'no-restricted-syntax': ['error', + // Kept from the root .eslintrc. + 'DebuggerStatement', 'LabeledStatement', 'WithStatement', + { + selector: 'Literal[regex.flags=/[yd]/]', + message: 'The y and d regular expression flags cannot be transpiled to ES5.', + }, + { + selector: 'Literal[regex.pattern=/(^|[^\\\\])\\(\\? options.format.indexOf(delimiter) !== -1); + const formatDelimiter = find( + options.delimiters, + delimiter => options.format.indexOf(delimiter) !== -1 + ); const dateDelimiter = options.strictMode ? formatDelimiter - : options.delimiters.find(delimiter => input.indexOf(delimiter) !== -1); + : find(options.delimiters, delimiter => input.indexOf(delimiter) !== -1); const dateAndFormat = zip( input.split(dateDelimiter), options.format.toLowerCase().split(formatDelimiter) @@ -51,7 +62,7 @@ export default function isDate(input, options) { let fullYear = dateObj.y; // Check if the year starts with a hyphen - if (fullYear.startsWith('-')) { + if (startsWith(fullYear, '-')) { return false; // Hyphen before year is not allowed } diff --git a/src/lib/isEmail.js b/src/lib/isEmail.js index abe465052..4aba39b7e 100644 --- a/src/lib/isEmail.js +++ b/src/lib/isEmail.js @@ -5,6 +5,8 @@ import isByteLength from './isByteLength'; import isFQDN from './isFQDN'; import isIP from './isIP'; import merge from './util/merge'; +import startsWith from './util/startsWith'; +import endsWith from './util/endsWith'; const default_email_options = { allow_display_name: false, @@ -78,7 +80,7 @@ export default function isEmail(str, options) { // because there may be a space between display name and email address // eg. myname // the display name is `myname` instead of `myname `, so need to trim the last space - if (display_name.endsWith(' ')) { + if (endsWith(display_name, ' ')) { display_name = display_name.slice(0, -1); } @@ -150,7 +152,7 @@ export default function isEmail(str, options) { } if (!isIP(domain)) { - if (!domain.startsWith('[') || !domain.endsWith(']')) { + if (!startsWith(domain, '[') || !endsWith(domain, ']')) { return false; } diff --git a/src/lib/isIPRange.js b/src/lib/isIPRange.js index 5bb6916c5..6473fdac7 100644 --- a/src/lib/isIPRange.js +++ b/src/lib/isIPRange.js @@ -1,5 +1,6 @@ import assertString from './util/assertString'; import isIP from './isIP'; +import startsWith from './util/startsWith'; const subnetMaybe = /^\d{1,3}$/; const v4Subnet = 32; @@ -19,7 +20,7 @@ export default function isIPRange(str, version = '') { } // Disallow preceding 0 i.e. 01, 02, ... - if (parts[1].length > 1 && parts[1].startsWith('0')) { + if (parts[1].length > 1 && startsWith(parts[1], '0')) { return false; } diff --git a/src/lib/isISIN.js b/src/lib/isISIN.js index a6f6fa645..2ccc82f15 100644 --- a/src/lib/isISIN.js +++ b/src/lib/isISIN.js @@ -22,7 +22,7 @@ export default function isISIN(str) { if (str[i] >= 'A' && str[i] <= 'Z') { const value = str[i].charCodeAt(0) - 55; const lo = value % 10; - const hi = Math.trunc(value / 10); + const hi = Math.floor(value / 10); // letters have two digits, so handle the low order // and high order digits separately. for (const digit of [lo, hi]) { @@ -52,7 +52,7 @@ export default function isISIN(str) { } } - const check = (Math.trunc(((sum + 9) / 10)) * 10) - sum; + const check = (Math.floor(((sum + 9) / 10)) * 10) - sum; return +str[str.length - 1] === check; } diff --git a/src/lib/isISO15924.js b/src/lib/isISO15924.js index 30f9e4a4f..c10a9cc1c 100644 --- a/src/lib/isISO15924.js +++ b/src/lib/isISO15924.js @@ -1,7 +1,8 @@ import assertString from './util/assertString'; +import lookupSet from './util/lookupSet'; // from https://www.unicode.org/iso15924/iso15924-codes.html -const validISO15924Codes = new Set([ +const validISO15924Codes = lookupSet([ 'Adlm', 'Afak', 'Aghb', 'Ahom', 'Arab', 'Aran', 'Armi', 'Armn', 'Avst', 'Bali', 'Bamu', 'Bass', 'Batk', 'Beng', 'Bhks', 'Blis', 'Bopo', 'Brah', 'Brai', 'Bugi', 'Buhd', 'Cakm', 'Cans', 'Cari', 'Cham', 'Cher', 'Chis', 'Chrs', 'Cirt', 'Copt', 'Cpmn', 'Cprt', 'Cyrl', 'Cyrs', diff --git a/src/lib/isISO31661Alpha2.js b/src/lib/isISO31661Alpha2.js index 8757025eb..29babc0ee 100644 --- a/src/lib/isISO31661Alpha2.js +++ b/src/lib/isISO31661Alpha2.js @@ -1,7 +1,9 @@ import assertString from './util/assertString'; +import lookupSet from './util/lookupSet'; +import includes from './util/includesArray'; // from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 -const validISO31661Alpha2CountriesCodes = new Set([ +const validISO31661Alpha2CountriesCodes = lookupSet([ 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', @@ -43,7 +45,7 @@ export default function isISO31661Alpha2(str, options = {}) { return accumulator; }, []); - if (validUserAssignedCodes.includes(str.toUpperCase())) { + if (includes(validUserAssignedCodes, str.toUpperCase())) { return true; } diff --git a/src/lib/isISO31661Alpha3.js b/src/lib/isISO31661Alpha3.js index 29f88c673..3ab84e32a 100644 --- a/src/lib/isISO31661Alpha3.js +++ b/src/lib/isISO31661Alpha3.js @@ -1,7 +1,9 @@ import assertString from './util/assertString'; +import lookupSet from './util/lookupSet'; +import includes from './util/includesArray'; // from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3 -const validISO31661Alpha3CountriesCodes = new Set([ +const validISO31661Alpha3CountriesCodes = lookupSet([ 'AFG', 'ALA', 'ALB', 'DZA', 'ASM', 'AND', 'AGO', 'AIA', 'ATA', 'ATG', 'ARG', 'ARM', 'ABW', 'AUS', 'AUT', 'AZE', 'BHS', 'BHR', 'BGD', 'BRB', 'BLR', 'BEL', 'BLZ', 'BEN', 'BMU', 'BTN', 'BOL', 'BES', 'BIH', 'BWA', 'BVT', 'BRA', 'IOT', 'BRN', 'BGR', 'BFA', 'BDI', 'KHM', 'CMR', 'CAN', 'CPV', 'CYM', 'CAF', 'TCD', 'CHL', 'CHN', 'CXR', 'CCK', @@ -34,7 +36,7 @@ export default function isISO31661Alpha3(str, options = {}) { return accumulator; }, []); - if (validUserAssignedCodes.includes(str.toUpperCase())) { + if (includes(validUserAssignedCodes, str.toUpperCase())) { return true; } diff --git a/src/lib/isISO31661Numeric.js b/src/lib/isISO31661Numeric.js index 8b32a8d79..d366003b5 100644 --- a/src/lib/isISO31661Numeric.js +++ b/src/lib/isISO31661Numeric.js @@ -1,7 +1,8 @@ import assertString from './util/assertString'; +import lookupSet from './util/lookupSet'; // from https://en.wikipedia.org/wiki/ISO_3166-1_numeric -const validISO31661NumericCountriesCodes = new Set([ +const validISO31661NumericCountriesCodes = lookupSet([ '004', '008', '010', '012', '016', '020', '024', '028', '031', '032', '036', '040', '044', '048', '050', '051', '052', '056', '060', '064', '068', '070', '072', '074', '076', '084', '086', '090', '092', '096', '100', '104', '108', '112', '116', '120', '124', '132', '136', '140', '144', '148', '152', '156', '158', '162', '166', '170', diff --git a/src/lib/isISO4217.js b/src/lib/isISO4217.js index bbca596a9..7ab82b7db 100644 --- a/src/lib/isISO4217.js +++ b/src/lib/isISO4217.js @@ -1,7 +1,8 @@ import assertString from './util/assertString'; +import lookupSet from './util/lookupSet'; // from https://en.wikipedia.org/wiki/ISO_4217 -const validISO4217CurrencyCodes = new Set([ +const validISO4217CurrencyCodes = lookupSet([ 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BOV', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHE', 'CHF', 'CHW', 'CLF', 'CLP', 'CNY', 'COP', 'COU', 'CRC', 'CUP', 'CVE', 'CZK', diff --git a/src/lib/isISO6391.js b/src/lib/isISO6391.js index eaa01c5b4..a06872de7 100644 --- a/src/lib/isISO6391.js +++ b/src/lib/isISO6391.js @@ -1,6 +1,7 @@ import assertString from './util/assertString'; +import lookupSet from './util/lookupSet'; -const isISO6391Set = new Set([ +const isISO6391Set = lookupSet([ 'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az', 'az', 'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs', 'ca', 'ce', 'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', diff --git a/src/lib/isIdentityCard.js b/src/lib/isIdentityCard.js index 7e20064e1..20ce562b2 100644 --- a/src/lib/isIdentityCard.js +++ b/src/lib/isIdentityCard.js @@ -1,6 +1,7 @@ import assertString from './util/assertString'; import includes from './util/includesArray'; import isInt from './isInt'; +import endsWith from './util/endsWith'; const validators = { PL: (str) => { @@ -62,7 +63,7 @@ const validators = { // validate the control digit const number = sanitized.slice(0, -1).replace(/[X,Y,Z]/g, char => charsValue[char]); - return sanitized.endsWith(controlDigits[number % 23]); + return endsWith(sanitized, controlDigits[number % 23]); }, FI: (str) => { // https://dvv.fi/en/personal-identity-code#:~:text=control%20character%20for%20a-,personal,-identity%20code%20calculated @@ -400,7 +401,7 @@ const validators = { if (!/^[A-Z][0-9]{9}$/.test(sanitized)) return false; - return Array.from(sanitized).reduce((sum, number, index) => { + return sanitized.split('').reduce((sum, number, index) => { if (index === 0) { const code = ALPHABET_CODES[number]; diff --git a/src/lib/isLatLong.js b/src/lib/isLatLong.js index c4e622753..146eea1e1 100644 --- a/src/lib/isLatLong.js +++ b/src/lib/isLatLong.js @@ -1,6 +1,8 @@ import assertString from './util/assertString'; import merge from './util/merge'; import includes from './util/includesString'; +import startsWith from './util/startsWith'; +import endsWith from './util/endsWith'; const lat = /^\(?[+-]?(90(\.0+)?|[1-8]?\d(\.\d+)?)$/; const long = /^\s?[+-]?(180(\.0+)?|1[0-7]\d(\.\d+)?|\d{1,2}(\.\d+)?)\)?$/; @@ -18,8 +20,8 @@ export default function isLatLong(str, options) { if (!includes(str, ',')) return false; const pair = str.split(','); - if ((pair[0].startsWith('(') && !pair[1].endsWith(')')) - || (pair[1].endsWith(')') && !pair[0].startsWith('('))) return false; + if ((startsWith(pair[0], '(') && !endsWith(pair[1], ')')) + || (endsWith(pair[1], ')') && !startsWith(pair[0], '('))) return false; if (options.checkDMS) { return latDMS.test(pair[0]) && longDMS.test(pair[1]); diff --git a/src/lib/isMailtoURI.js b/src/lib/isMailtoURI.js index 67748a553..a1f5cc27b 100644 --- a/src/lib/isMailtoURI.js +++ b/src/lib/isMailtoURI.js @@ -3,7 +3,7 @@ import isEmail from './isEmail'; import assertString from './util/assertString'; function parseMailtoQueryString(queryString) { - const allowedParams = new Set(['subject', 'body', 'cc', 'bcc']), + const allowedParams = ['subject', 'body', 'cc', 'bcc'], query = { cc: '', bcc: '' }; let isParseFailed = false; @@ -17,7 +17,7 @@ function parseMailtoQueryString(queryString) { const [key, value] = q.split('='); // checked for invalid and duplicated query params - if (key && !allowedParams.has(key)) { + if (key && allowedParams.indexOf(key) === -1) { isParseFailed = true; break; } @@ -27,7 +27,7 @@ function parseMailtoQueryString(queryString) { } if (key) { - allowedParams.delete(key); + allowedParams.splice(allowedParams.indexOf(key), 1); } } diff --git a/src/lib/isMobilePhone.js b/src/lib/isMobilePhone.js index 32379bde5..88f96f208 100644 --- a/src/lib/isMobilePhone.js +++ b/src/lib/isMobilePhone.js @@ -1,4 +1,5 @@ import assertString from './util/assertString'; +import startsWith from './util/startsWith'; /* eslint-disable max-len */ const phones = { @@ -179,7 +180,7 @@ phones['en-CM'] = phones['fr-CM']; export default function isMobilePhone(str, locale, options) { assertString(str); - if (options && options.strictMode && !str.startsWith('+')) { + if (options && options.strictMode && !startsWith(str, '+')) { return false; } if (Array.isArray(locale)) { diff --git a/src/lib/isStrongPassword.js b/src/lib/isStrongPassword.js index 8fe9223b7..9c0e6558d 100644 --- a/src/lib/isStrongPassword.js +++ b/src/lib/isStrongPassword.js @@ -26,7 +26,8 @@ const defaultOptions = { */ function countChars(str) { let result = {}; - Array.from(str).forEach((char) => { + // Iterate by code point like `Array.from(str)` (ES2015): keep surrogate pairs together. + (str.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S]/g) || []).forEach((char) => { let curVal = result[char]; if (curVal) { result[char] += 1; diff --git a/src/lib/util/endsWith.js b/src/lib/util/endsWith.js new file mode 100644 index 000000000..04f513092 --- /dev/null +++ b/src/lib/util/endsWith.js @@ -0,0 +1,5 @@ +// ES5 equivalent of `str.endsWith(suffix)`; String.prototype.endsWith is ES2015. +export default function endsWith(str, suffix) { + suffix = String(suffix); + return str.length >= suffix.length && str.slice(str.length - suffix.length) === suffix; +} diff --git a/src/lib/util/lookupSet.js b/src/lib/util/lookupSet.js new file mode 100644 index 000000000..0925e9cd9 --- /dev/null +++ b/src/lib/util/lookupSet.js @@ -0,0 +1,30 @@ +/** + * Builds a lookup table with a `has(value)` method from a list of strings. + * + * Where the runtime provides `Set`, this returns a native `Set`, so exported + * tables such as `CountryCodes` keep their type. Elements are added one by + * one because IE 11 ignores the iterable argument of the `Set` constructor. + * On ES5 runtimes without `Set` (e.g. Node.js 0.10), it returns a minimal + * object whose `has()` answers like `Set.prototype.has` for these values. + */ +export default function lookupSet(values) { + /* eslint-disable no-restricted-globals */ + if (typeof Set === 'function') { + const set = new Set(); + values.forEach((value) => { + set.add(value); + }); + return set; + } + /* eslint-enable no-restricted-globals */ + + const table = {}; + values.forEach((value) => { + table[value] = true; + }); + return { + has(value) { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(table, value); + }, + }; +} diff --git a/src/lib/util/startsWith.js b/src/lib/util/startsWith.js new file mode 100644 index 000000000..107e60918 --- /dev/null +++ b/src/lib/util/startsWith.js @@ -0,0 +1,5 @@ +// ES5 equivalent of `str.startsWith(prefix)`; String.prototype.startsWith is ES2015. +export default function startsWith(str, prefix) { + prefix = String(prefix); + return str.slice(0, prefix.length) === prefix; +} diff --git a/test/util.test.js b/test/util.test.js index 0146a4e2c..b30f72cfe 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -5,6 +5,9 @@ import assert from 'assert'; import typeOf from '../src/lib/util/typeOf'; import assertString from '../src/lib/util/assertString'; +import startsWith from '../src/lib/util/startsWith'; +import endsWith from '../src/lib/util/endsWith'; +import lookupSet from '../src/lib/util/lookupSet'; describe('Util', () => { @@ -62,3 +65,61 @@ describe('assertString', () => { assert.doesNotThrow(() => { assertString('antidisestablishmentarianism'); }); }); }); + +describe('ES5 replacements for ES2015 built-ins', () => { + it('startsWith should match String.prototype.startsWith', () => { + assert.strictEqual(startsWith('abc', ''), true); + assert.strictEqual(startsWith('abc', 'a'), true); + assert.strictEqual(startsWith('abc', 'abc'), true); + assert.strictEqual(startsWith('abc', 'abcd'), false); + assert.strictEqual(startsWith('abc', 'b'), false); + assert.strictEqual(startsWith('', 'a'), false); + assert.strictEqual(startsWith('undefined!', undefined), true); + }); + + it('endsWith should match String.prototype.endsWith', () => { + assert.strictEqual(endsWith('abc', ''), true); + assert.strictEqual(endsWith('abc', 'c'), true); + assert.strictEqual(endsWith('abc', 'abc'), true); + assert.strictEqual(endsWith('abc', 'zabc'), false); + assert.strictEqual(endsWith('abc', 'b'), false); + assert.strictEqual(endsWith('', 'a'), false); + assert.strictEqual(endsWith('is null', null), true); + }); + + function checkLookupSet(set) { + assert.strictEqual(set.has('AD'), true); + assert.strictEqual(set.has('004'), true); + assert.strictEqual(set.has('ad'), false); + assert.strictEqual(set.has(4), false); + assert.strictEqual(set.has(''), false); + assert.strictEqual(set.has(undefined), false); + assert.strictEqual(set.has('toString'), false); + assert.strictEqual(set.has('hasOwnProperty'), false); + assert.strictEqual(set.has('__proto__'), false); + } + + it('lookupSet should return a native Set where available', function () { + if (typeof Set !== 'function') { + this.skip(); + return; + } + const set = lookupSet(['AD', '004']); + assert.ok(set instanceof Set); + assert.strictEqual(set.size, 2); + checkLookupSet(set); + }); + + it('lookupSet should work without a global Set', () => { + const NativeSet = global.Set; + let set; + global.Set = undefined; + try { + set = lookupSet(['AD', '004']); + } finally { + global.Set = NativeSet; + } + assert.strictEqual(typeof set.has, 'function'); + checkLookupSet(set); + }); +}); From 2358ba75c85bbbf26e2d9131f811e8c13cb15bc1 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 06/21] fix(assertString): work on engines without Function#name (IE 11) assertString compared `input.constructor.name` with 'String'. IE 11 has no Function#name, so on IE 11 every validator threw "Expected a string but received a undefined", even for strings. Read the constructor name from its source when Function#name is missing. Error messages are unchanged on other engines. The new test removes Function#name (where the engine allows it) and checks assertString and public validators; it fails without this change. Co-Authored-By: Claude Opus 5.5 --- src/lib/util/assertString.js | 13 ++++++++++++- test/util.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/lib/util/assertString.js b/src/lib/util/assertString.js index 3baa4452f..99d061bf0 100644 --- a/src/lib/util/assertString.js +++ b/src/lib/util/assertString.js @@ -1,4 +1,15 @@ +// The name of a value's constructor. Engines without Function#name (IE 11) +// fall back to the name in the constructor's source. +function constructorName(input) { + const ctor = input.constructor; + if (!ctor) return String(ctor); + if (ctor.name || typeof ctor !== 'function') return ctor.name; + const match = /^\s*function\s*([\w$]+)/.exec(Function.prototype.toString.call(ctor)); + return match ? match[1] : ''; +} + export default function assertString(input) { if (input === undefined || input === null) throw new TypeError(`Expected a string but received a ${input}`); - if (input.constructor.name !== 'String') throw new TypeError(`Expected a string but received a ${input.constructor.name}`); + const name = constructorName(input); + if (name !== 'String') throw new TypeError(`Expected a string but received a ${name}`); } diff --git a/test/util.test.js b/test/util.test.js index b30f72cfe..ba5e39a6e 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -8,6 +8,7 @@ import assertString from '../src/lib/util/assertString'; import startsWith from '../src/lib/util/startsWith'; import endsWith from '../src/lib/util/endsWith'; import lookupSet from '../src/lib/util/lookupSet'; +import validator from '../src/index'; describe('Util', () => { @@ -64,6 +65,37 @@ describe('assertString', () => { it('Should not throw an error if the argument is a String', () => { assert.doesNotThrow(() => { assertString('antidisestablishmentarianism'); }); }); + + it('Should not throw an error if the argument is a String object', () => { + assert.doesNotThrow(() => { assertString(new String('abc')); }); // eslint-disable-line no-new-wrappers + }); + + it('Should name the received type in the error message', () => { + assert.throws(() => { assertString(2024); }, /^TypeError: Expected a string but received a Number$/); + assert.throws(() => { assertString(null); }, /^TypeError: Expected a string but received a null$/); + assert.throws(() => { assertString(Object.create(null)); }, TypeError); + }); + + // IE 11 has no Function#name, so constructor names come from their source. + it('Should work on engines without Function#name', function () { + const owners = [Function.prototype, String, Number]; + const descriptors = owners.map(owner => Object.getOwnPropertyDescriptor(owner, 'name')); + if (descriptors.some(descriptor => !descriptor || !descriptor.configurable)) { + this.skip(); // Function#name cannot be removed on this engine + return; + } + try { + owners.forEach((owner) => { delete owner.name; }); + assert.strictEqual(String.name, undefined); + assert.doesNotThrow(() => { assertString('abc'); }); + assert.throws(() => { assertString(2024); }, /^TypeError: Expected a string but received a Number$/); + assert.strictEqual(validator.isEmail('foo@bar.com'), true); + assert.strictEqual(validator.isInt('12'), true); + assert.throws(() => { validator.isEmail(12); }, /received a Number/); + } finally { + owners.forEach((owner, i) => { Object.defineProperty(owner, 'name', descriptors[i]); }); + } + }); }); describe('ES5 replacements for ES2015 built-ins', () => { From c078a531cfc8bb91ea848bd169e75c6f3481f89e Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 07/21] build: check that the CommonJS and browser builds are ES5 Add `npm run check:es5`, which parses index.js, lib/, validator.js and validator.min.js as ECMAScript 5 with the parser ESLint already ships, and run it at the end of `npm run build`. It catches ES2015 syntax that slips into the CommonJS or browser builds, such as the uglify-js change fixed earlier in this series. No new dependencies. Co-Authored-By: Claude Opus 5.5 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e2fed8e86..09d969760 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "scripts": { "lint": "eslint src test", "lint:fix": "eslint --fix src test", + "check:es5": "eslint --no-eslintrc --no-ignore --parser-options=ecmaVersion:5 index.js lib validator.js validator.min.js", "clean:node": "rimraf index.js lib", "clean:es": "rimraf es", "clean:browser": "rimraf validator*.js", @@ -65,7 +66,7 @@ "build:browser": "node build-browser.mjs && npm run minify", "build:es": "babel src -d es --env-name=es && node scripts/write-es-package-json.js", "build:node": "babel src -d . && node scripts/annotate-cjs-exports.js", - "build": "run-p build:*", + "build": "run-p build:* && npm run check:es5", "pretest": "npm run build && npm run lint", "test": "npm run test:ci", "test:ci": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" From c5752446b20367eed1446bde5bf34aa4f6d8e7fc Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 08/21] test: run the test suite on Node.js 0.10, 0.12, 4 and 6 Mocha, nyc and npm install do not run on Node.js 0.10-6, so these versions were never tested. scripts/build-es5-tests.js (run on current Node.js in the build job) compiles the existing test files to ES5 in test-es5/, rewriting imports of src/ to the built index.js and lib/, so the suite tests what is published. scripts/es5-test/ holds a small dependency-free describe/it runner with this.skip()/this.timeout() support. Test-only shims cover String#repeat and Buffer.from used by test code; the ESLint rules keep library code from relying on them. Test files that cannot apply to ES5 runtimes are excluded explicitly, with the reason printed. The new CI job installs Node.js 0.10, 0.12, 4 and 6 with nvm (as ljharb/actions does; setup-node's manifest starts at 6.17.1) and runs the compiled suite without npm install. Before the previous commits: 0.10 fails at load (Set), 0.12 has 24 failures, 4 has 11. Now all pass. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 37 ++++++++ .gitignore | 1 + package.json | 2 + scripts/build-es5-tests.js | 142 +++++++++++++++++++++++++++++ scripts/es5-test/.eslintrc.js | 21 +++++ scripts/es5-test/mocha.js | 15 ++++ scripts/es5-test/run.js | 72 +++++++++++++++ scripts/es5-test/runner.js | 145 ++++++++++++++++++++++++++++++ scripts/es5-test/timezone-mock.js | 23 +++++ 9 files changed, 458 insertions(+) create mode 100644 scripts/build-es5-tests.js create mode 100644 scripts/es5-test/.eslintrc.js create mode 100644 scripts/es5-test/mocha.js create mode 100644 scripts/es5-test/run.js create mode 100644 scripts/es5-test/runner.js create mode 100644 scripts/es5-test/timezone-mock.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b6dd4795..841bb4369 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: run: npm run build - name: Lint run: npm run lint + - name: Compile ES5 test suite + run: node scripts/build-es5-tests.js - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -31,6 +33,7 @@ jobs: es/ validator.js validator.min.js + test-es5/ retention-days: 1 test: @@ -60,3 +63,37 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + + # Mocha, nyc and npm install do not work on these Node.js versions, so the + # suite compiled to ES5 by the build job runs with its own dependency-free + # runner, against the built index.js, lib/ and validator(.min).js. + test-es5: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['0.10', '0.12', '4', '6'] + name: Run ES5 test suite on Node.js ${{ matrix.node-version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + # actions/setup-node's version manifest starts at 6.17.1, so install with + # nvm like ljharb/actions does; it downloads and checksums nodejs.org builds. + - name: Install Node.js ${{ matrix.node-version }} with nvm + env: + NODE_VERSION: ${{ matrix.node-version }} + run: | + export NVM_DIR="$RUNNER_TEMP/nvm" + git -c advice.detachedHead=false clone --quiet --depth 1 --branch v0.40.3 https://github.com/nvm-sh/nvm.git "$NVM_DIR" + . "$NVM_DIR/nvm.sh" + nvm install "$NODE_VERSION" + echo "$NVM_BIN" >> "$GITHUB_PATH" + - name: Run ES5 test suite + run: | + node --version + node test-es5/run.js diff --git a/.gitignore b/.gitignore index 86aaedee1..816005fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ yarn.lock /index.js validator.js validator.min.js +/test-es5 diff --git a/package.json b/package.json index 09d969760..136c57d50 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "clean:node": "rimraf index.js lib", "clean:es": "rimraf es", "clean:browser": "rimraf validator*.js", + "clean:test-es5": "rimraf test-es5", "clean": "run-p clean:*", "minify": "uglifyjs validator.js -o validator.min.js --no-module --compress --mangle --comments /Copyright/", "build:browser": "node build-browser.mjs && npm run minify", @@ -69,6 +70,7 @@ "build": "run-p build:* && npm run check:es5", "pretest": "npm run build && npm run lint", "test": "npm run test:ci", + "test:es5": "node scripts/build-es5-tests.js && node test-es5/run.js", "test:ci": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" }, "engines": { diff --git a/scripts/build-es5-tests.js b/scripts/build-es5-tests.js new file mode 100644 index 000000000..338e9b5b0 --- /dev/null +++ b/scripts/build-es5-tests.js @@ -0,0 +1,142 @@ +/* + * Compiles the Mocha test suite (test/**\/*.js) to ES5 in test-es5/, so the + * real tests can run on Node.js versions where Mocha, nyc and npm install are + * not available (see scripts/es5-test/run.js). Run it on a current Node.js + * after `npm run build`: + * + * node scripts/build-es5-tests.js && node test-es5/run.js + * + * Module specifiers are rewritten so that the compiled tests exercise the + * built outputs instead of src/: `src/index` -> `index.js`, `src/lib/x` -> + * `lib/x.js`; `../index`, `../validator` and `../validator.min` are kept. + * test-es5/ is self-contained apart from those outputs and package.json: + * it also holds the runner, `mocha` and `timezone-mock` stand-ins and a copy + * of timezone-mock. + */ + +'use strict'; + +const babel = require('@babel/core'); +const presetEnv = require('@babel/preset-env'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const testDir = path.join(root, 'test'); +const srcDir = path.join(root, 'src'); +const outDir = path.join(root, 'test-es5'); +const runnerDir = path.join(__dirname, 'es5-test'); +const NODE_BUILTINS = ['assert', 'child_process', 'fs', 'os', 'path', 'url', 'util', 'vm']; +const vendored = { + mocha: path.join(outDir, 'mocha.js'), + 'timezone-mock': path.join(outDir, 'timezone-mock.js'), +}; +// Test files that cannot apply to ES5 runtimes, relative to test/, with the reason. +const EXCLUDED = { + 'esModules.test.js': 'covers native ES modules only and parses es/ with @babel/core', +}; + +function listFiles(dir) { + return fs.readdirSync(dir).sort().reduce((files, name) => { + const file = path.join(dir, name); + if (fs.statSync(file).isDirectory()) return files.concat(listFiles(file)); + return /\.js$/.test(name) ? files.concat(file) : files; + }, []); +} + +function isInside(dir, file) { + const relative = path.relative(dir, file); + return relative === '' || (relative.split(path.sep)[0] !== '..' && !path.isAbsolute(relative)); +} + +// Maps a specifier used in `fromFile` (under test/) to the file the compiled test should load. +function resolveTarget(specifier, fromFile) { + if (NODE_BUILTINS.indexOf(specifier) !== -1) return null; + if (vendored[specifier]) return vendored[specifier]; + if (!/^\.\.?\//.test(specifier)) { + throw new Error(`${path.relative(root, fromFile)}: cannot run "${specifier}" without npm install; ` + + 'add it to scripts/build-es5-tests.js'); + } + const target = path.resolve(path.dirname(fromFile), specifier); + if (isInside(srcDir, target)) { + const relative = path.relative(srcDir, target); + if (relative === '' || relative === 'index' || relative === 'index.js') { + return path.join(root, 'index.js'); + } + return path.join(root, relative); + } + if (isInside(testDir, target)) return path.join(outDir, path.relative(testDir, target)); + return target; +} + +function rewriteSpecifiers(fromFile, outFile) { + function rewrite(literal) { + const target = resolveTarget(literal.value, fromFile); + if (target === null) return; + let relative = path.relative(path.dirname(outFile), target).split(path.sep).join('/'); + if (!/^\.\.?\//.test(relative)) relative = `./${relative}`; + literal.value = relative; + } + function isRequire(callee) { + return (callee.type === 'Identifier' && callee.name === 'require') || + (callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && + callee.object.name === 'require' && callee.property.name === 'resolve'); + } + return { + visitor: { + 'ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration': function (nodePath) { + if (nodePath.node.source) rewrite(nodePath.node.source); + }, + CallExpression(nodePath) { + const { callee, arguments: args } = nodePath.node; + if (isRequire(callee) && args.length > 0 && args[0].type === 'StringLiteral') { + rewrite(args[0]); + } + }, + }, + }; +} + +function write(file, contents) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); +} + +fs.rmSync(outDir, { recursive: true, force: true }); + +Object.keys(EXCLUDED).forEach((name) => { + if (!fs.existsSync(path.join(testDir, name))) { + throw new Error(`build-es5-tests: excluded test file ${name} does not exist; update EXCLUDED`); + } + console.log(`build-es5-tests: excluding ${name} (${EXCLUDED[name]})`); +}); + +const testFiles = listFiles(testDir) + .filter(file => !EXCLUDED[path.relative(testDir, file).split(path.sep).join('/')]); +testFiles.forEach((file) => { + const outFile = path.join(outDir, path.relative(testDir, file)); + const { code } = babel.transformFileSync(file, { + babelrc: false, + configFile: false, + sourceType: 'module', + presets: [[presetEnv, { targets: { node: '0.10' }, modules: 'commonjs' }]], + plugins: [() => rewriteSpecifiers(file, outFile)], + }); + write(outFile, code); +}); + +['runner.js', 'run.js', 'mocha.js', 'timezone-mock.js'].forEach((name) => { + write(path.join(outDir, name), fs.readFileSync(path.join(runnerDir, name))); +}); + +const timezoneMock = path.dirname(require.resolve('timezone-mock/package.json')); +['index.js', 'lib/tzdata.js', 'LICENSE', 'package.json'].forEach((name) => { + write(path.join(outDir, 'vendor', 'timezone-mock', name), fs.readFileSync(path.join(timezoneMock, name))); +}); + +const files = testFiles + .filter(file => /\.test\.js$/.test(file)) + .map(file => `./${path.relative(testDir, file).split(path.sep).join('/')}`); +write(path.join(outDir, 'manifest.json'), `${JSON.stringify({ files }, null, 2)}\n`); + +console.log(`build-es5-tests: compiled ${testFiles.length} files (${files.length} test files) to ${path.relative(root, outDir)}/`); diff --git a/scripts/es5-test/.eslintrc.js b/scripts/es5-test/.eslintrc.js new file mode 100644 index 000000000..fc395de5b --- /dev/null +++ b/scripts/es5-test/.eslintrc.js @@ -0,0 +1,21 @@ +// The ES5 test runner runs as-is on Node.js 0.10 (and is meant to be reusable +// in other hosts), so it is parsed as ES5 and ES2015 style rules are off. +module.exports = { + parser: 'espree', + parserOptions: { + ecmaVersion: 5, + sourceType: 'script', + }, + env: { + es6: false, + }, + rules: { + 'no-var': 'off', + 'object-shorthand': 'off', + 'prefer-arrow-callback': 'off', + 'prefer-rest-params': 'off', + 'prefer-spread': 'off', + 'prefer-template': 'off', + 'vars-on-top': 'off', + }, +}; diff --git a/scripts/es5-test/mocha.js b/scripts/es5-test/mocha.js new file mode 100644 index 000000000..ba19383a2 --- /dev/null +++ b/scripts/es5-test/mocha.js @@ -0,0 +1,15 @@ +/* + * Stand-in for `import { describe, it } from 'mocha'` in the ES5 test suite: + * forwards to the describe/it globals installed by the runner. + */ + +'use strict'; + +module.exports = { + describe: function () { + return global.describe.apply(this, arguments); + }, + it: function () { + return global.it.apply(this, arguments); + }, +}; diff --git a/scripts/es5-test/run.js b/scripts/es5-test/run.js new file mode 100644 index 000000000..a8c0f5613 --- /dev/null +++ b/scripts/es5-test/run.js @@ -0,0 +1,72 @@ +/* + * Node.js entry point for the ES5 test suite generated by + * scripts/build-es5-tests.js (copied to test-es5/run.js). It needs no + * npm install and runs on every Node.js version from 0.10 onwards: + * + * node test-es5/run.js [grep-pattern] + * + * The compiled tests load the built CommonJS (index.js, lib/) and browser + * (validator.js, validator.min.js) outputs, never src/. + */ + +'use strict'; + +var path = require('path'); +var createTestRunner = require('./runner'); +var manifest = require('./manifest.json'); // eslint-disable-line import/no-unresolved + +/* + * Test-only shims for ES2015+ APIs that the test files (not the library) use. + * They are installed only when missing. This cannot hide a library dependency + * on them, because the ESLint config forbids these APIs in src/. + */ +var shims = []; +/* eslint-disable no-extend-native */ +if (typeof String.prototype.repeat !== 'function') { + String.prototype.repeat = function (count) { + return new Array(count + 1).join(String(this)); + }; + shims.push('String.prototype.repeat'); +} +/* eslint-enable no-extend-native */ +if (typeof Buffer.from !== 'function') { + Buffer.from = function (string) { + return new Buffer(string); // eslint-disable-line no-buffer-constructor + }; + shims.push('Buffer.from'); +} + +var runner = createTestRunner(); +global.describe = runner.describe; +global.it = runner.it; + +console.log('ES5 test suite on Node.js ' + process.version + + (shims.length ? ' (test-only shims: ' + shims.join(', ') + ')' : '')); + +manifest.files.forEach(function (file) { + runner.load(file, function () { + // eslint-disable-next-line global-require, import/no-dynamic-require + require(path.join(__dirname, file)); + }); +}); + +/* + * Tests that cannot work on a given Node.js version for reasons outside the + * library. They are reported as pending, with the reason. + */ +var skip = {}; +if (/^v0\.10\./.test(process.version)) { + skip['Validators > should bind validator to the window if no module loaders are available'] = + 'Node.js 0.10 vm.createContext() copies the sandbox, so writes to the global never reach `window`'; +} + +var result = runner.run({ + grep: process.argv[2] ? new RegExp(process.argv[2]) : null, + skip: skip, +}); +if (result.failed || result.passed === 0) { + // Exit non-zero once the output is flushed (process.exitCode needs Node.js >= 0.11.8). + process.on('exit', function () { + process.exit(1); + }); +} diff --git a/scripts/es5-test/runner.js b/scripts/es5-test/runner.js new file mode 100644 index 000000000..c9211c6f4 --- /dev/null +++ b/scripts/es5-test/runner.js @@ -0,0 +1,145 @@ +/* + * A minimal, dependency-free describe/it test runner written in plain ES5. + * + * It runs the Mocha-style test suite (compiled to ES5 by + * scripts/build-es5-tests.js) on runtimes where Mocha cannot be installed, + * such as Node.js 0.10. The core uses no Node.js APIs: output goes through an + * injected `print` function and the result is returned to the caller, so the + * same runner can be driven from another host (Deno, Bun, a browser page). + * + * Supported: nested describe(), it(), this.skip(), this.timeout() (ignored). + * run({ skip: { '': '' } }) lets a host mark tests it + * cannot support as pending; the reason is printed. + * Tests must be synchronous; a test that takes a `done` callback or returns a + * promise is reported as a failure rather than silently passing. + */ + +'use strict'; + +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + } else { + root.createTestRunner = factory(); + } +}(this, function () { + var SKIP = {}; + + function describeError(error) { + if (error && error.stack) return String(error.stack); + return String(error); + } + + function isThenable(value) { + return value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof value.then === 'function'; + } + + return function createTestRunner() { + var tests = []; + var suites = []; + var failures = []; + + function fullTitle(title) { + return suites.concat([title]).join(' > '); + } + + function fail(title, error) { + failures.push({ title: title, error: error }); + } + + function describe(title, fn) { + suites.push(title); + try { + fn.call({ timeout: function () {} }); + } catch (error) { + // Mocha aborts the whole run here; recording it keeps other files running. + fail(suites.join(' > ') + ' (describe callback)', error); + } + suites.pop(); + } + + function it(title, fn) { + tests.push({ title: fullTitle(title), fn: fn }); + } + + // Collects the tests of one file; errors thrown while loading it are failures. + function load(title, fn) { + try { + fn(); + } catch (error) { + fail(title + ' (while loading)', error); + } + } + + function run(options) { + var print = (options && options.print) || function (line) { console.log(line); }; + var grep = options && options.grep; + var skip = (options && options.skip) || {}; + var skipped = {}; + var passed = 0; + var pending = 0; + var i, test, context, result; + + for (i = 0; i < tests.length; i++) { + test = tests[i]; + if (Object.prototype.hasOwnProperty.call(skip, test.title)) { + skipped[test.title] = true; + pending += 1; + print(' skipped: ' + test.title + ' (' + skip[test.title] + ')'); + } else if (!grep || grep.test(test.title)) { + context = { + skip: function () { throw SKIP; }, + timeout: function () {}, + slow: function () {}, + }; + try { + if (test.fn.length > 0) { + throw new Error('asynchronous tests (done callback) are not supported by this runner'); + } + result = test.fn.call(context); + if (isThenable(result)) { + throw new Error('asynchronous tests (returned promise) are not supported by this runner'); + } + passed += 1; + } catch (error) { + if (error === SKIP) { + pending += 1; + } else { + fail(test.title, error); + } + } + } + } + + Object.keys(skip).forEach(function (title) { + if (!skipped[title]) print(' warning: no test matches skip entry "' + title + '"'); + }); + + for (i = 0; i < failures.length; i++) { + print(''); + print(' ' + (i + 1) + ') ' + failures[i].title); + print(' ' + describeError(failures[i].error).split('\n').join('\n ')); + } + print(''); + print(' ' + passed + ' passing'); + if (pending) print(' ' + pending + ' pending'); + if (failures.length) print(' ' + failures.length + ' failing'); + + return { + passed: passed, + pending: pending, + failed: failures.length, + failures: failures, + }; + } + + return { + describe: describe, + it: it, + load: load, + run: run, + }; + }; +})); diff --git a/scripts/es5-test/timezone-mock.js b/scripts/es5-test/timezone-mock.js new file mode 100644 index 000000000..eef9ae047 --- /dev/null +++ b/scripts/es5-test/timezone-mock.js @@ -0,0 +1,23 @@ +/* + * Stand-in for the `timezone-mock` devDependency in the ES5 test suite. + * + * timezone-mock's register() needs Array.prototype.includes (ES2016). Rather + * than shimming a built-in that the library itself must not rely on, the + * time-zone emulation is skipped (with a notice) on runtimes without it; the + * surrounding assertions still run in the process' own time zone. + */ + +'use strict'; + +if (typeof Array.prototype.includes === 'function') { + // Copied next to this file by scripts/build-es5-tests.js. + // eslint-disable-next-line global-require, import/no-unresolved, import/extensions + module.exports = require('./vendor/timezone-mock'); +} else { + module.exports = { + register: function (timezone) { + console.log(' note: timezone-mock needs Array.prototype.includes; not emulating ' + timezone); + }, + unregister: function () {}, + }; +} From b8367cb8c8731bf2d0cc1dfde02e060be7f31b1d Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 09/21] chore(lint): lint build scripts and build-browser.mjs `npm run lint` only covered src/ and test/. Extend it to scripts/ and build-browser.mjs, including .mjs and .cjs files. scripts/.eslintrc.js sets script mode for the CommonJS tooling and module mode for .mjs. Fixes the style errors found in build-browser.mjs. Co-Authored-By: Claude Opus 5.5 --- build-browser.mjs | 15 +++++++-------- package.json | 4 ++-- scripts/.eslintrc.js | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 scripts/.eslintrc.js diff --git a/build-browser.mjs b/build-browser.mjs index b770acb94..bab0105a9 100644 --- a/build-browser.mjs +++ b/build-browser.mjs @@ -3,25 +3,25 @@ import fs from 'fs'; import { rollup } from 'rollup'; import { babel } from '@rollup/plugin-babel'; import babelPresetEnv from '@babel/preset-env'; -import pkg from "./package.json" with { type: "json" }; +import pkg from './package.json' with { type: 'json' }; rollup({ input: 'src/index.js', plugins: [ babel({ - presets: [[babelPresetEnv, { + presets: [[babelPresetEnv, { modules: false, targets: { node: '0.10', - ie: '11' - } + ie: '11', + }, }]], babelHelpers: 'bundled', babelrc: false, }), ], }) - .then((bundle) => + .then(bundle => bundle.write({ file: 'validator.js', format: 'umd', @@ -29,10 +29,9 @@ rollup({ banner: `/*!\n${String(fs.readFileSync('./LICENSE')) .trim() .split('\n') - .map((l) => ` * ${l}`) + .map(l => ` * ${l}`) .join('\n')}\n */`, - }) - ) + })) .catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); diff --git a/package.json b/package.json index 136c57d50..13f53693d 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "uglify-js": "^3.0.19" }, "scripts": { - "lint": "eslint src test", - "lint:fix": "eslint --fix src test", + "lint": "eslint --ext .js,.mjs,.cjs src test scripts build-browser.mjs", + "lint:fix": "eslint --fix --ext .js,.mjs,.cjs src test scripts build-browser.mjs", "check:es5": "eslint --no-eslintrc --no-ignore --parser-options=ecmaVersion:5 index.js lib validator.js validator.min.js", "clean:node": "rimraf index.js lib", "clean:es": "rimraf es", diff --git a/scripts/.eslintrc.js b/scripts/.eslintrc.js new file mode 100644 index 000000000..e17d2632c --- /dev/null +++ b/scripts/.eslintrc.js @@ -0,0 +1,22 @@ +// Build and test tooling: CommonJS scripts run directly by Node.js. +module.exports = { + parserOptions: { + sourceType: 'script', + }, + rules: { + strict: ['error', 'global'], + 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], + }, + overrides: [ + { + // ES module scripts (.mjs) run directly by Node.js. + files: ['*.mjs'], + parserOptions: { + sourceType: 'module', + }, + rules: { + strict: 'off', + }, + }, + ], +}; From 41d2fde818ba752525418274bfc4cb855d7206d3 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 10/21] test: run the test suite in Chromium, Firefox and WebKit test/clientSide.test.js only require()s the UMD bundles in Node.js, so the browser global and AMD code paths never ran, and no browser was involved. test/browser/run.mjs bundles the Mocha test files with Rollup, mapping imports of the library to the global from the UMD bundle and Node.js built-ins to small shims. It then runs them with Playwright in headless Chromium, Firefox and WebKit against both validator.js and validator.min.js. Smoke pages cover a plain + + + + diff --git a/test/browser/es-modules.html b/test/browser/es-modules.html new file mode 100644 index 000000000..c1bb70ac4 --- /dev/null +++ b/test/browser/es-modules.html @@ -0,0 +1,30 @@ + + + + + + validator.js ES modules (es/) + + + + + + diff --git a/test/browser/global.html b/test/browser/global.html new file mode 100644 index 000000000..1d768e9e3 --- /dev/null +++ b/test/browser/global.html @@ -0,0 +1,21 @@ + + + + + + validator.js standalone script: __BUNDLE__ + + + + + + + diff --git a/test/browser/run.mjs b/test/browser/run.mjs new file mode 100644 index 000000000..d8de9293d --- /dev/null +++ b/test/browser/run.mjs @@ -0,0 +1,591 @@ +/* + * Runs validator's browser builds in real browsers (Chromium, Firefox and + * WebKit, through Playwright): + * + * 1. The Mocha test suite (test/**\/*.js, minus the Node.js-only parts listed + * in EXCLUDED_FILES and EXCLUDED_TESTS) against the built UMD bundles + * validator.min.js and validator.js, loaded with a plain + + + + + + From 3bfb5d60cab51583c5becbd4c50817b7257ff806 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 11/21] ci: check the packed package with npm, pnpm and Yarn consumers scripts/check-package.mjs packs the package and checks it the way users get it: - the exact tarball file list (validator.min.js for the unpkg link, es/ with its package.json, nothing from src/, scripts/ or test/); - publint (one documented false positive allowed); - CommonJS and ESM consumer projects in test/fixtures/consumers, installed with npm, pnpm and Yarn (Plug'n'Play), covering require('validator'), validator/lib/*, named and default imports, and es/ deep imports; - esbuild bundles that must run, plus a tree-shaking check: an isEmail-only import from es/ must not contain isIBAN's data. The CI job runs it on Node.js 20, 22, 24 and 26. Tools are pinned and run with npx, so there are no new devDependencies. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 30 +++ scripts/check-package.mjs | 364 ++++++++++++++++++++++++++ test/fixtures/consumers/.eslintrc.yml | 13 + test/fixtures/consumers/cjs.cjs | 32 +++ test/fixtures/consumers/esm.mjs | 38 +++ 5 files changed, 477 insertions(+) create mode 100644 scripts/check-package.mjs create mode 100644 test/fixtures/consumers/.eslintrc.yml create mode 100644 test/fixtures/consumers/cjs.cjs create mode 100644 test/fixtures/consumers/esm.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747d276b3..a0be79957 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,3 +129,33 @@ jobs: run: npx playwright install --with-deps chromium firefox webkit - name: Run tests in browsers run: npm run test:browser + + # Packs the package and checks it the way users install it: the tarball file + # list, publint, CommonJS/ESM/deep-import consumers installed with npm, pnpm + # and Yarn (Plug'n'Play), and esbuild bundles (tree-shaking and sizes). + # See scripts/check-package.mjs; its tools are pinned there and run with npx. + package: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node-version: [26, 24, 22, 20] + name: Check the package on Node.js ${{ matrix.node-version }} + steps: + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + - name: Check the packed package + run: node scripts/check-package.mjs diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 000000000..3bb48cafb --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +/* + * Checks the npm package the way users receive it: from the packed tarball. + * Used by the `package`, `deno` and `bun` CI jobs. Needs a built tree + * (`npm run build`) and Node.js 20+ to run this script. + * + * 1. Packs the package with `npm pack` (or uses --tarball). + * 2. Checks the tarball file list: the entry points, README.md, LICENSE, + * lib/ and es/ mirroring src/, and nothing else (no src/, test/, + * scripts/, jsr.json, ...). + * 3. Runs publint on the tarball and fails on any error or warning except + * the accepted ones listed below. + * 4. Installs the tarball into a scratch consumer project with each package + * manager and runs test/fixtures/consumers/{cjs.cjs,esm.mjs} with the + * selected runtime. + * 5. Bundles small entry points with esbuild, runs the bundles, checks that + * importing one validator from es/lib/ is tree-shaken, and prints the + * bundle sizes. + * + * Usage: + * node scripts/check-package.mjs [--runtime=node|deno|bun] [--pm=npm,pnpm,yarn] + * [--tarball=file.tgz] [--skip=publint,bundle] [--keep] + * + * Defaults: --runtime=node runs every step with npm, pnpm and yarn; + * --runtime=deno uses npm only and --runtime=bun uses `bun install`, and both + * skip publint and the bundle step, which do not depend on the runtime. + * + * The tools are pinned here and run with `npx --yes` rather than being + * devDependencies, because the main test matrix installs the devDependencies + * with old Node.js versions that these tools do not support. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import zlib from 'node:zlib'; + +const TOOLS = { + publint: 'publint@0.3.25', + esbuild: 'esbuild@0.28.2', + // pnpm 11+ needs Node.js 22.13+; pnpm 10 still runs on every Node.js + // version in the CI matrix of this check. + pnpm: 'pnpm@10.34.6', + // Yarn Modern (Berry). Corepack is no longer bundled with Node.js 25+, so + // Yarn is run from npm instead. It installs with Plug'n'Play by default. + yarn: '@yarnpkg/cli-dist@4.18.1', +}; + +// publint warnings that are reviewed and accepted. Keep this list short and +// explain each entry. +const ACCEPTED_PUBLINT_WARNINGS = [ + // index.js is Babel output that sets `exports.__esModule` and + // `exports.default`, then replaces module.exports with the validator object + // and sets `module.exports.default` to that same object + // (babel-plugin-add-module-exports with addDefaultProperty). publint only + // sees the static pattern; at run time module.exports has no __esModule + // flag and `default` is the object itself, so a default import gets the + // validator object under every interop rule. The `cjs-root-default.js` + // bundle below checks this with esbuild. + /^pkg\.main sets both exports\.__esModule = true and exports\.default\./, +]; + +// Code that must not end up in a bundle that only imports isEmail: the +// isIBAN data table. And code that must be there: an isEmail option name. +const UNRELATED_MARKER = 'AD[0-9]{2}'; +const IMPORTED_MARKER = 'allow_display_name'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const fixtures = path.join(root, 'test', 'fixtures', 'consumers'); + +function parseArgs(args) { + const parsed = { runtime: 'node', skip: [], keep: false }; + args.forEach((arg) => { + const match = /^--([a-z]+)(?:=(.*))?$/.exec(arg); + if (!match) throw new Error(`unknown argument ${arg}`); + const [, name, value] = match; + if (name === 'keep') parsed.keep = true; + else if (name === 'runtime') parsed.runtime = value; + else if (name === 'pm') parsed.pm = value.split(','); + else if (name === 'skip') parsed.skip = value.split(','); + else if (name === 'tarball') parsed.tarball = path.resolve(value); + else throw new Error(`unknown argument ${arg}`); + }); + if (['node', 'deno', 'bun'].indexOf(parsed.runtime) === -1) { + throw new Error(`unknown runtime ${parsed.runtime}`); + } + if (!parsed.pm) { + parsed.pm = { node: ['npm', 'pnpm', 'yarn'], deno: ['npm'], bun: ['bun'] }[parsed.runtime]; + } + if (parsed.runtime !== 'node') parsed.skip.push('publint', 'bundle'); + return parsed; +} + +const options = parseArgs(process.argv.slice(2)); +const failures = []; + +function step(title) { + console.log(`\n=== ${title}`); +} + +function fail(message) { + failures.push(message); + console.log(`FAIL: ${message}`); +} + +function run(command, args, { cwd = root, env, capture = false } = {}) { + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + stdio: capture ? 'pipe' : 'inherit', + }); + if (result.error) throw result.error; + return result; +} + +function runOrThrow(command, args, opts) { + const result = run(command, args, opts); + if (result.status !== 0) { + if (result.stdout) console.log(result.stdout); + if (result.stderr) console.log(result.stderr); + throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`); + } + return result; +} + +function npx(spec, bin, args) { + return ['npx', ['--yes', `--package=${spec}`, '--', bin, ...args]]; +} + +function listFiles(dir, base = dir) { + return fs.readdirSync(dir).reduce((files, name) => { + const file = path.join(dir, name); + if (fs.statSync(file).isDirectory()) return files.concat(listFiles(file, base)); + return files.concat(path.relative(base, file).split(path.sep).join('/')); + }, []); +} + +function pack(dir) { + step('npm pack'); + const result = runOrThrow('npm', ['pack', '--json', '--pack-destination', dir], { capture: true }); + const json = result.stdout.slice(result.stdout.indexOf('[')); + const [{ filename }] = JSON.parse(json); + const tarball = path.join(dir, filename); + console.log(tarball); + return tarball; +} + +function checkFiles(tarball) { + step('tarball contents'); + const entries = runOrThrow('tar', ['-tzf', tarball], { capture: true }).stdout + .split('\n').filter(Boolean).filter(entry => !/\/$/.test(entry)); + const outside = entries.filter(entry => entry.indexOf('package/') !== 0); + if (outside.length) fail(`entries outside package/: ${outside.join(', ')}`); + const files = entries.map(entry => entry.replace(/^package\//, '')); + + // src/index.js -> index.js and es/index.js; src/lib/x.js -> lib/x.js and es/lib/x.js. + // Babel skips dotfiles such as src/.eslintrc.js, so they are not modules. + const modules = listFiles(path.join(root, 'src')) + .filter(file => /\.js$/.test(file) && !/(^|\/)\./.test(file)); + const expected = [ + 'package.json', 'README.md', 'LICENSE', + 'validator.js', 'validator.min.js', 'es/package.json', + ].concat(modules, modules.map(file => `es/${file}`)); + + const missing = expected.filter(file => files.indexOf(file) === -1); + const unexpected = files.filter(file => expected.indexOf(file) === -1); + if (missing.length) fail(`missing from the tarball: ${missing.join(', ')}`); + if (unexpected.length) fail(`unexpected in the tarball: ${unexpected.join(', ')}`); + console.log(`${files.length} files (${modules.length} modules in lib/ and es/)`); +} + +function checkPublint(tarball) { + step(`publint (${TOOLS.publint})`); + const [command, args] = npx(TOOLS.publint, 'publint', ['run', tarball]); + const result = run(command, args, { capture: true, env: { NO_COLOR: '1' } }); + // eslint-disable-next-line no-control-regex + const output = `${result.stdout}${result.stderr}`.replace(/\u001b\[[0-9;]*m/g, ''); + console.log(output.trim()); + + let level = null; + const messages = []; + output.split('\n').forEach((line) => { + const heading = /^(Errors|Warnings|Suggestions):$/.exec(line.trim()); + const item = /^\d+\. (.*)$/.exec(line.trim()); + if (heading) level = heading[1]; + else if (item && level) messages.push({ level, text: item[1] }); + }); + if (result.status !== 0 && !messages.some(message => message.level === 'Errors')) { + fail(`publint exited with ${result.status}`); + } + messages.forEach(({ level: messageLevel, text }) => { + if (messageLevel === 'Suggestions') return; + if (messageLevel === 'Warnings' && ACCEPTED_PUBLINT_WARNINGS.some(re => re.test(text))) { + console.log(`accepted warning: ${text}`); + return; + } + fail(`publint ${messageLevel.toLowerCase()}: ${text}`); + }); +} + +function install(pm, dir, tarball) { + step(`install with ${pm}`); + fs.mkdirSync(dir); + fs.copyFileSync(tarball, path.join(dir, 'validator.tgz')); + fs.writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify({ + name: `validator-consumer-${pm}`, + version: '0.0.0', + private: true, + dependencies: { validator: 'file:./validator.tgz' }, + }, null, 2)}\n`); + ['cjs.cjs', 'esm.mjs'].forEach((file) => { + fs.copyFileSync(path.join(fixtures, file), path.join(dir, file)); + }); + + const env = {}; + let command; + if (pm === 'npm') { + command = ['npm', ['install', '--no-audit', '--no-fund', '--no-package-lock']]; + } else if (pm === 'pnpm') { + // CI=true makes pnpm default to --frozen-lockfile, and there is no lockfile. + command = npx(TOOLS.pnpm, 'pnpm', ['install', '--no-frozen-lockfile']); + } else if (pm === 'yarn') { + // Mark the directory as a project root, allow creating the lockfile in CI, + // and generate the PnP ESM loader, which Yarn otherwise only enables when + // a workspace or dependency has "type": "module" at its root. + fs.writeFileSync(path.join(dir, 'yarn.lock'), ''); + Object.assign(env, { + YARN_ENABLE_IMMUTABLE_INSTALLS: 'false', + YARN_ENABLE_TELEMETRY: '0', + YARN_PNP_ENABLE_ESM_LOADER: 'true', + }); + command = npx(TOOLS.yarn, 'yarn', ['install']); + } else if (pm === 'bun') { + command = ['bun', ['install', '--no-save']]; + } else { + throw new Error(`unknown package manager ${pm}`); + } + runOrThrow(command[0], command[1], { cwd: dir, env }); + return env; +} + +function runFixtures(pm, dir, env) { + ['cjs.cjs', 'esm.mjs'].forEach((file) => { + let command; + if (options.runtime === 'deno') { + command = ['deno', ['run', '--allow-read', '--allow-env', file]]; + } else if (options.runtime === 'bun') { + command = ['bun', [file]]; + } else if (pm === 'yarn') { + // Plug'n'Play: `yarn node` sets up the PnP resolver and ESM loader. + command = npx(TOOLS.yarn, 'yarn', ['node', file]); + } else { + command = [process.execPath, [file]]; + } + const label = `${options.runtime} ${file} (${pm})`; + const result = run(command[0], command[1], { cwd: dir, env, capture: true }); + if (result.status === 0) { + console.log(`${label}: ${result.stdout.trim().split('\n').pop()}`); + } else { + console.log(result.stdout, result.stderr); + fail(`${label} exited with ${result.status}`); + } + }); +} + +// `treeShaken`: true = must not contain the unrelated marker, false = must +// contain it (a control that shows the marker check works), null = report only. +const BUNDLES = [ + { + name: 'es-lib-isEmail.mjs', + source: "import isEmail from 'validator/es/lib/isEmail.js';\n", + call: 'isEmail', + treeShaken: true, + }, + { + name: 'es-index-default.mjs', + source: "import validator from 'validator/es/index.js';\n", + call: 'validator.isEmail', + treeShaken: null, + }, + { + name: 'cjs-root-named.mjs', + source: "import { isEmail } from 'validator';\n", + call: 'isEmail', + treeShaken: false, + }, + { + // A .js file in a package without "type": the case of the accepted + // publint warning (bundlers apply the __esModule interop here). + name: 'cjs-root-default.js', + source: "import validator from 'validator';\n", + call: 'validator.isEmail', + treeShaken: false, + }, +]; + +function checkBundles(dir) { + step(`bundles (${TOOLS.esbuild}, --bundle --minify --format=esm --platform=browser)`); + const out = path.join(dir, 'dist'); + const sizes = []; + BUNDLES.forEach((bundle) => { + const entry = path.join(dir, bundle.name); + const outfile = path.join(out, bundle.name.replace(/\.m?js$/, '.mjs')); + fs.writeFileSync(entry, `${bundle.source}console.log(JSON.stringify(${bundle.call}('foo@bar.com')));\n`); + const [command, args] = npx(TOOLS.esbuild, 'esbuild', [ + entry, '--bundle', '--minify', '--format=esm', '--platform=browser', + '--log-level=warning', `--outfile=${outfile}`, + ]); + runOrThrow(command, args, { cwd: dir }); + + const code = fs.readFileSync(outfile, 'utf8'); + const result = run(process.execPath, [outfile], { capture: true }); + if (result.status !== 0 || result.stdout.trim() !== 'true') { + fail(`${bundle.name}: running the bundle printed ${JSON.stringify(result.stdout.trim())} ${result.stderr}`); + } + if (code.indexOf(IMPORTED_MARKER) === -1) { + fail(`${bundle.name}: the bundle does not contain isEmail ("${IMPORTED_MARKER}")`); + } + const unrelated = code.indexOf(UNRELATED_MARKER) !== -1; + if (bundle.treeShaken === true && unrelated) { + fail(`${bundle.name}: not tree-shaken, the bundle contains isIBAN ("${UNRELATED_MARKER}")`); + } + if (bundle.treeShaken === false && !unrelated) { + fail(`${bundle.name}: expected the whole library ("${UNRELATED_MARKER}") in this control bundle`); + } + sizes.push({ + bundle: bundle.name, + bytes: Buffer.byteLength(code), + gzip: zlib.gzipSync(code).length, + 'contains isIBAN': unrelated, + }); + }); + console.table(sizes); +} + +function main() { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'validator-package-')); + console.log(`runtime ${options.runtime}, package managers ${options.pm.join(', ')}, work dir ${work}`); + try { + const tarball = options.tarball || pack(work); + checkFiles(tarball); + if (options.skip.indexOf('publint') === -1) checkPublint(tarball); + options.pm.forEach((pm) => { + const dir = path.join(work, `consumer-${pm}`); + const env = install(pm, dir, tarball); + runFixtures(pm, dir, env); + if (pm === options.pm[0] && options.skip.indexOf('bundle') === -1) checkBundles(dir); + }); + } finally { + if (options.keep) console.log(`\nkept ${work}`); + else fs.rmSync(work, { recursive: true, force: true }); + } + + if (failures.length) { + console.log(`\n${failures.length} check(s) failed:\n- ${failures.join('\n- ')}`); + process.exit(1); + } + console.log('\nAll package checks passed.'); +} + +main(); diff --git a/test/fixtures/consumers/.eslintrc.yml b/test/fixtures/consumers/.eslintrc.yml new file mode 100644 index 000000000..52ab5bd2c --- /dev/null +++ b/test/fixtures/consumers/.eslintrc.yml @@ -0,0 +1,13 @@ +# Consumer fixtures run by scripts/check-package.mjs against the packed +# package, which is installed next to them only at test time. They import it +# the way users do, so file extensions follow Node.js rather than our style. +# (YAML rather than .js, so test runners that load test/**/*.js skip it.) +rules: + import/no-unresolved: 'off' + import/extensions: 'off' +overrides: + - files: ['*.cjs'] + parserOptions: + sourceType: script + rules: + strict: ['error', 'global'] diff --git a/test/fixtures/consumers/cjs.cjs b/test/fixtures/consumers/cjs.cjs new file mode 100644 index 000000000..b631c1c42 --- /dev/null +++ b/test/fixtures/consumers/cjs.cjs @@ -0,0 +1,32 @@ +// CommonJS consumer of the installed package. Run by scripts/check-package.mjs +// from a project that installed the packed tarball (not by Mocha). + +'use strict'; + +const assert = require('node:assert'); +const validator = require('validator'); +const isEmail = require('validator/lib/isEmail'); +const isMobilePhone = require('validator/lib/isMobilePhone'); +const browserBuild = require('validator/validator.min.js'); +const { version } = require('validator/package.json'); + +assert.strictEqual(validator.version, version); +assert.strictEqual(validator.default, validator); +assert.strictEqual(validator.isEmail('foo@bar.com'), true); +assert.strictEqual(validator.isEmail('foo'), false); + +assert.strictEqual(isEmail, validator.isEmail); +assert.strictEqual(isEmail.default, isEmail); +assert.strictEqual(isEmail('foo@bar.com'), true); + +// Modules with named exports keep the Babel shape: { default, locales }. +assert.strictEqual(isMobilePhone.default, validator.isMobilePhone); +assert.deepStrictEqual(isMobilePhone.locales, validator.isMobilePhoneLocales); +assert.strictEqual(isMobilePhone.default('+14155552671', 'en-US'), true); + +// The minified UMD build (the unpkg CDN file in the README). +assert.strictEqual(browserBuild.version, version); +assert.strictEqual(browserBuild.isEmail('foo@bar.com'), true); +assert.strictEqual(browserBuild.isIBAN('DE89370400440532013000'), true); + +console.log(`cjs ok (validator ${version})`); diff --git a/test/fixtures/consumers/esm.mjs b/test/fixtures/consumers/esm.mjs new file mode 100644 index 000000000..45cd34e66 --- /dev/null +++ b/test/fixtures/consumers/esm.mjs @@ -0,0 +1,38 @@ +// Native ES module consumer of the installed package. Run by +// scripts/check-package.mjs from a project that installed the packed tarball +// (not by Mocha). +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import validator, { isEmail, isMobilePhoneLocales, version } from 'validator'; +import esValidator from 'validator/es/index.js'; +import esIsEmail from 'validator/es/lib/isEmail.js'; +import esIsMobilePhone, { locales } from 'validator/es/lib/isMobilePhone.js'; + +const pkg = createRequire(import.meta.url)('validator/package.json'); + +// Root entry (CommonJS): default and named imports. +assert.strictEqual(version, pkg.version); +assert.strictEqual(validator.version, pkg.version); +assert.strictEqual(isEmail, validator.isEmail); +assert.strictEqual(isMobilePhoneLocales, validator.isMobilePhoneLocales); +assert.strictEqual(isEmail('foo@bar.com'), true); +assert.strictEqual(isEmail('foo'), false); + +// Deep ES module imports. +assert.strictEqual(typeof esIsEmail, 'function'); +assert.strictEqual(esIsEmail('foo@bar.com'), true); +assert.strictEqual(esIsEmail('foo'), false); +assert.strictEqual(esIsMobilePhone('+14155552671', 'en-US'), true); +assert.ok(locales.indexOf('en-US') !== -1); +assert.deepStrictEqual(locales, validator.isMobilePhoneLocales); + +// The ES module index exposes the same API as the CommonJS entry. +assert.strictEqual(esValidator.version, pkg.version); +assert.deepStrictEqual( + Object.keys(esValidator).sort(), + Object.keys(validator).filter(key => key !== 'default').sort() +); +assert.strictEqual(esValidator.isEmail, esIsEmail); +assert.strictEqual(esValidator.isIBAN('DE89370400440532013000'), true); + +console.log(`esm ok (validator ${pkg.version})`); From aaab0243d74878e6e100236dcf161f878d9fbf69 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 12/21] docs: document ibanLocales and test that README matches the exports ibanLocales is exported but was the only locale list missing from README. Document it like the others. test/readme.test.js checks that every exported function is documented in README's validator and sanitizer tables and vice versa, and that every exported locale list is documented. It checks repository metadata only, so the ES5 and browser runs skip it. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- scripts/build-es5-tests.js | 1 + test/browser/run.mjs | 1 + test/readme.test.js | 81 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/readme.test.js diff --git a/README.md b/README.md index f9d0ffb51..915f2f22d 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Validator | Description **isHexadecimal(str)** | check if the string is a hexadecimal number. **isHexColor(str [, options])** | check if the string is a hexadecimal color.

`options` is an object that defaults to `{ require_hashtag: false }`.
Options:
`require_hashtag`: Enforce # prefix, default false. **isHSL(str)** | check if the string is an HSL (hue, saturation, lightness, optional alpha) color based on [CSS Colors Level 4 specification][CSS Colors Level 4 Specification].

Comma-separated format supported. Space-separated format supported with the exception of a few edge cases (ex: `hsl(200grad+.1%62%/1)`). -**isIBAN(str, [, options])** | check if the string is an IBAN (International Bank Account Number).

`options` is an object which accepts two attributes: `whitelist`: where you can restrict IBAN codes you want to receive data from and `blacklist`: where you can remove some of the countries from the current list. For both you can use an array with the following values `['AD','AE','AL','AT','AZ','BA','BE','BG','BH','BR','BY','CH','CR','CY','CZ','DE','DK','DO','EE','EG','ES','FI','FO','FR','GB','GE','GI','GL','GR','GT','HR','HU','IE','IL','IQ','IR','IS','IT','JO','KW','KZ','LB','LC','LI','LT','LU','LV','MC','MD','ME','MK','MR','MT','MU','MZ','NL','NO','PK','PL','PS','PT','QA','RO','RS','SA','SC','SE','SI','SK','SM','SV','TL','TN','TR','UA','VA','VG','XK']`. +**isIBAN(str, [, options])** | check if the string is an IBAN (International Bank Account Number).

`options` is an object which accepts two attributes: `whitelist`: where you can restrict IBAN codes you want to receive data from and `blacklist`: where you can remove some of the countries from the current list. For both you can use an array with the following values `['AD','AE','AL','AT','AZ','BA','BE','BG','BH','BR','BY','CH','CR','CY','CZ','DE','DK','DO','EE','EG','ES','FI','FO','FR','GB','GE','GI','GL','GR','GT','HR','HU','IE','IL','IQ','IR','IS','IT','JO','KW','KZ','LB','LC','LI','LT','LU','LV','MC','MD','ME','MK','MR','MT','MU','MZ','NL','NO','PK','PL','PS','PT','QA','RO','RS','SA','SC','SE','SI','SK','SM','SV','TL','TN','TR','UA','VA','VG','XK']`. Locale list is `validator.ibanLocales`. **isIdentityCard(str [, locale])** | check if the string is a valid identity card code.

`locale` is one of `['LK', 'PL', 'ES', 'FI', 'IN', 'IT', 'IR', 'MZ', 'NO', 'TH', 'zh-TW', 'he-IL', 'ar-LY', 'ar-TN', 'zh-CN', 'zh-HK', 'PK']` OR `'any'`. If 'any' is used, function will check if any of the locales match.

Defaults to 'any'. **isIMEI(str [, options])** | check if the string is a valid [IMEI number][IMEI]. IMEI should be of format `###############` or `##-######-######-#`.

`options` is an object which can contain the keys `allow_hyphens`. Defaults to first format. If `allow_hyphens` is set to true, the validator will validate the second format. **isIn(str, values)** | check if the string is in an array of allowed values. diff --git a/scripts/build-es5-tests.js b/scripts/build-es5-tests.js index 338e9b5b0..a1573e9c5 100644 --- a/scripts/build-es5-tests.js +++ b/scripts/build-es5-tests.js @@ -34,6 +34,7 @@ const vendored = { // Test files that cannot apply to ES5 runtimes, relative to test/, with the reason. const EXCLUDED = { 'esModules.test.js': 'covers native ES modules only and parses es/ with @babel/core', + 'readme.test.js': 'checks repository metadata, which does not depend on the runtime', }; function listFiles(dir) { diff --git a/test/browser/run.mjs b/test/browser/run.mjs index d8de9293d..f165bc436 100644 --- a/test/browser/run.mjs +++ b/test/browser/run.mjs @@ -56,6 +56,7 @@ const EXCLUDED_FILES = { 'branch of the UMD wrapper); here both bundles run the whole suite through ``` -The library can also be installed through [bower][bower] - -```bash -$ bower install validator-js -``` - CDN ```html @@ -253,7 +247,6 @@ This project is licensed under the [MIT](LICENSE). See the [LICENSE](LICENSE) fi [huntr-image]: https://cdn.huntr.dev/huntr_security_badge_mono.svg [amd]: http://requirejs.org/docs/whyamd.html -[bower]: http://bower.io/ [Crockford Base32]: http://www.crockford.com/base32.html [Base64 URL Safe]: https://base64.guru/standards/base64url diff --git a/bower.json b/bower.json deleted file mode 100644 index 427670cfc..000000000 --- a/bower.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "validator-js", - "main": "validator.js", - "homepage": "https://github.com/validatorjs/validator.js", - "authors": [ - "Chris O'Hara " - ], - "description": "String validation and sanitization", - "license": "MIT", - "ignore": [ - "**/.*", - "index.js", - "build*.js", - "package.json", - "node_modules", - "bower_components", - "test" - ] -} From 2336c42176a5532999f37fd983e212cf03e6dc29 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 17/21] ci: update GitHub Actions to their current major versions Update actions/checkout to v7, actions/setup-node to v7, actions/upload-artifact to v7, actions/download-artifact to v8, codecov/codecov-action to v7 and github/codeql-action to v4, so all actions run on the Node.js 24 runtime. Breaking changes in between don't affect these workflows: artifacts are downloaded by name, download-artifact v8 fails on digest mismatches (a safer default), and setup-node's automatic caching needs a packageManager field we don't have. setup-node v7 no longer exports a dummy NODE_AUTH_TOKEN, which only helps the OIDC publish in npm-publish.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 46 +++++++++++++-------------- .github/workflows/codeql-analysis.yml | 8 ++--- .github/workflows/npm-publish.yml | 4 +-- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fc2f04ff..e99ec2bcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,11 +10,11 @@ jobs: name: Build on Node.js 24 steps: - name: Setup Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install dependencies run: npm install - name: Build @@ -24,7 +24,7 @@ jobs: - name: Compile ES5 test suite run: node scripts/build-es5-tests.js - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: build-artifacts path: | @@ -45,13 +45,13 @@ jobs: name: Run tests on Node.js ${{ matrix.node-version }} steps: - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts - name: Install dependencies @@ -60,7 +60,7 @@ jobs: run: npm run test:ci - if: matrix.node-version == 24 name: Send coverage info to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -77,9 +77,9 @@ jobs: name: Run ES5 test suite on Node.js ${{ matrix.node-version }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts # actions/setup-node's version manifest starts at 6.17.1, so install with @@ -112,15 +112,15 @@ jobs: REQUIREJS_VERSION: 2.3.8 steps: - name: Setup Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: persist-credentials: false - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts - name: Install dependencies, Playwright and RequireJS @@ -146,15 +146,15 @@ jobs: name: Check the package on Node.js ${{ matrix.node-version }} steps: - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: persist-credentials: false - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts - name: Check the packed package @@ -171,7 +171,7 @@ jobs: name: Run tests on Deno 2.x steps: - name: Setup Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 - name: Setup Deno 2.x @@ -179,11 +179,11 @@ jobs: with: deno-version: v2.x - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: persist-credentials: false - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts - name: Install dependencies @@ -204,7 +204,7 @@ jobs: name: Run tests on Bun 1.x steps: - name: Setup Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 - name: Setup Bun 1.x @@ -212,11 +212,11 @@ jobs: with: bun-version: 1.x - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: persist-credentials: false - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts - name: Install dependencies @@ -237,11 +237,11 @@ jobs: name: Check JSR publishing (dry run) steps: - name: Setup Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: persist-credentials: false - name: Publish to JSR (dry run) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e89a9b51e..7abe19bca 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,11 +38,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 32b785885..8e954dbf3 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -10,12 +10,12 @@ jobs: id-token: write steps: - name: Setup Node.js 24 - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: node-version: 24 registry-url: https://registry.npmjs.org/ - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Dependencies run: npm install - name: Run Tests From b1089b6e1b3e719902ef271226ab6c56658a49f6 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:28:11 +0000 Subject: [PATCH 18/21] docs: describe the new build steps and checks in CONTRIBUTING.md and AGENTS.md Document the ES5 constraint for src/, the generated test-es5/ directory and the additional suites (test:es5, test:browser, check-package.mjs, Deno, Bun, JSR dry run) in CONTRIBUTING.md and AGENTS.md. Also correct the browser build script's name (build-browser.mjs). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 ++++++---- CONTRIBUTING.md | 12 +++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d808f2b27..54ac72451 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,10 @@ This file provides guidance to AI coding agents working with this repository. - validator.js is a single-package npm project for string validation and sanitization. - `src/index.js` defines the public exports, and `src/lib` contains validators, sanitizers, normalizers, converters, static format data, and shared utilities. -- `test/validators.test.js` contains most API tests; additional test files cover browser builds and individual utilities. +- `test/validators.test.js` contains most API tests; additional test files cover browser builds, package exports, ES modules and individual utilities. `test/browser/` runs the suite in real browsers, and `test/fixtures/consumers/` holds the projects used by `scripts/check-package.mjs`. - `README.md` is the primary public API reference. Update it when documented API behavior or options change. -- `build-browser.js` creates the browser bundle. Babel creates the Node and ES module distributions. -- `index.js`, `lib/`, `es/`, `validator.js`, and `validator.min.js` are generated. Do not edit or commit them manually. +- `build-browser.mjs` creates the browser bundle. Babel creates the Node and ES module distributions; small post-build steps in `scripts/` add the Node ESM export-name hint to `index.js`, `.js` extensions to `es/` imports, and `es/package.json`. +- `index.js`, `lib/`, `es/`, `validator.js`, `validator.min.js`, and `test-es5/` are generated. Do not edit or commit them manually. - The published package contains only the generated distributions, `README.md`, `LICENSE`, and package metadata listed in the `files` field of `package.json`. - validator.js has no runtime dependencies. The packages in `devDependencies` are used only to build, lint, and test the project. @@ -31,8 +31,10 @@ This file provides guidance to AI coding agents working with this repository. ## Test - `npm test` is the broad local check. Its `pretest` lifecycle builds every distribution and runs ESLint before the Mocha suite. -- `npm run lint` checks `src/` and `test/`. +- `npm run lint` checks `src/`, `test/`, `scripts/` and `build-browser.mjs`. `src/` must stay free of ES2015+ built-ins (`src/.eslintrc.js`), because the library supports ES5 runtimes without polyfills. +- `npm run build` also checks that the CommonJS and browser builds parse as ES5 (`check:es5`). - `npm run build` builds all published forms without running the tests. +- CI also runs `npm run test:es5` (Node.js 0.10–6), `npm run test:browser` (Playwright), `scripts/check-package.mjs` (packed-package consumers), the suite on Deno and Bun, and a JSR dry run. - For a focused Mocha run, use `npm test -- --grep ''`; retain the broad `npm test` run before considering a code change complete. - Tests use Mocha, Babel, and nyc. Coverage output under `coverage/` and `.nyc_output/` is generated and should not be committed. - When changing a shared helper, identify and test its public callers. A helper-level test alone does not establish public API behavior. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1f861379..7c830200b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ In general, we follow the "fork-and-pull" Git workflow. * Install the project using `npm install` * Make your changes and additions - Most of your changes should be focused on src/ and test/ folders and/or [README.md](https://github.com/validatorjs/validator.js/blob/master/README.md). - - Files such as validator.js, validator.min.js and files in lib/ folder are autogenerated when running tests (npm test) and need not to be changed **manually**. + - Files such as validator.js, validator.min.js and files in the lib/ and es/ folders are autogenerated when running tests (npm test) and need not to be changed **manually**. * Change or add tests if needed * Run tests and make sure they pass * Add changes to README.md if needed @@ -28,6 +28,16 @@ Tests are using mocha. To run the tests use: $ npm test ``` +`npm test` builds every distribution, checks that the CommonJS and browser builds are ES5, lints, and runs the suite. The library supports ES5 runtimes without polyfills (Node.js 0.10+ and old browsers), so `src/` must not use ES2015+ built-ins such as `Set`, `Array.from` or `String.prototype.startsWith`; ESLint reports them, and `src/lib/util/` has ES5 helpers. + +CI runs some extra suites that you can also run locally after `npm run build`: + +```sh +$ npm run test:es5 # the suite compiled to ES5, for running on old Node.js versions (e.g. with nvm) +$ npm run test:browser # the suite in Chromium, Firefox and WebKit (needs `playwright` and `requirejs`) +$ node scripts/check-package.mjs # packs the package and installs it into CommonJS/ESM/bundler test projects +``` + ### Financial Contribution We welcome financial contributions on our [open collective](https://opencollective.com/validatorjs). From 92b95db61b38830de4ad51958f324ae124461fb8 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:45:31 +0000 Subject: [PATCH 19/21] docs: note that isDate and isTime return false for non-strings README says "Passing anything other than a string will result in an error", but isDate and isTime return false for null, numbers and other non-string input (isDate also accepts Date objects, as documented). The existing tests assert this on purpose, and changing it would break callers that pass null, so document the exceptions instead. Add TODO(next major) comments in both functions: a future major version should make them throw via assertString like the other validators. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ src/lib/isDate.js | 3 +++ src/lib/isTime.js | 2 ++ 3 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 834a37252..fb6a516fa 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ A library of string validators and sanitizers. If you're not sure if your input is a string, coerce it using `input + ''`. Passing anything other than a string will result in an error. +Exceptions: `toString` accepts any input, and `isDate` also accepts `Date` objects. For other input that is not a string, `isDate` and `isTime` currently return `false` instead of throwing an error. A future major version will change them to throw, like the other validators. + ## Installation and Usage ### Server-side usage diff --git a/src/lib/isDate.js b/src/lib/isDate.js index 324661078..0e02eede1 100644 --- a/src/lib/isDate.js +++ b/src/lib/isDate.js @@ -31,6 +31,9 @@ function find(array, predicate) { } export default function isDate(input, options) { + // TODO(next major): throw on input that is neither a string nor a Date, like the other + // validators (assertString). Returning false is the current, documented behaviour + // (README, "Strings only"). if (typeof options === 'string') { // Allow backward compatibility for old format isDate(input [, format]) options = merge({ format: options }, default_date_options); } else { diff --git a/src/lib/isTime.js b/src/lib/isTime.js index 3169864c2..6d881ba46 100644 --- a/src/lib/isTime.js +++ b/src/lib/isTime.js @@ -20,6 +20,8 @@ const formats = { export default function isTime(input, options) { options = merge(options, default_time_options); + // TODO(next major): throw on non-string input like the other validators (assertString). + // Returning false is the current, documented behaviour (README, "Strings only"). if (typeof input !== 'string') return false; return formats[options.hourFormat][options.mode].test(input); } From 609af4e176a39ca514491cb9e4505e0cc354d1e0 Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:54:09 +0000 Subject: [PATCH 20/21] fix(test-es5): match the test filter as text, not a regular expression `node test-es5/run.js ` passed its argument to `new RegExp()`. CodeQL reports that as regular expression injection from a command-line argument. The person running the script controls the argument, but a plain substring match serves the same purpose, cannot throw on input such as "(", and removes the alert. Co-Authored-By: Claude Opus 5.5 --- scripts/es5-test/run.js | 4 ++-- scripts/es5-test/runner.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/es5-test/run.js b/scripts/es5-test/run.js index a8c0f5613..92e0f76ef 100644 --- a/scripts/es5-test/run.js +++ b/scripts/es5-test/run.js @@ -3,7 +3,7 @@ * scripts/build-es5-tests.js (copied to test-es5/run.js). It needs no * npm install and runs on every Node.js version from 0.10 onwards: * - * node test-es5/run.js [grep-pattern] + * node test-es5/run.js [title-substring] * * The compiled tests load the built CommonJS (index.js, lib/) and browser * (validator.js, validator.min.js) outputs, never src/. @@ -61,7 +61,7 @@ if (/^v0\.10\./.test(process.version)) { } var result = runner.run({ - grep: process.argv[2] ? new RegExp(process.argv[2]) : null, + grep: process.argv[2] || null, // only run tests whose full title contains this text skip: skip, }); if (result.failed || result.passed === 0) { diff --git a/scripts/es5-test/runner.js b/scripts/es5-test/runner.js index c9211c6f4..246254527 100644 --- a/scripts/es5-test/runner.js +++ b/scripts/es5-test/runner.js @@ -88,7 +88,7 @@ skipped[test.title] = true; pending += 1; print(' skipped: ' + test.title + ' (' + skip[test.title] + ')'); - } else if (!grep || grep.test(test.title)) { + } else if (!grep || test.title.indexOf(grep) !== -1) { context = { skip: function () { throw SKIP; }, timeout: function () {}, From 7689137737196db9213657945b9f8fef790d7bde Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Sun, 25 Oct 2026 08:54:09 +0000 Subject: [PATCH 21/21] test(assertString): cover constructors without a name The fallback that reads a constructor's name from its source returns an empty string when the constructor is anonymous. No test reached that branch, which left the patch one partial branch short of the project's 100% coverage. Check that such objects are still rejected. Co-Authored-By: Claude Opus 5.5 --- test/util.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/util.test.js b/test/util.test.js index ba5e39a6e..d690804fb 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -76,6 +76,14 @@ describe('assertString', () => { assert.throws(() => { assertString(Object.create(null)); }, TypeError); }); + it('Should throw an error for an object with an anonymous constructor', () => { + // Returned from a function so that nothing gives the constructor a name. + const makeConstructor = () => function () {}; + const Anonymous = makeConstructor(); + assert.strictEqual(Anonymous.name, ''); + assert.throws(() => { assertString(new Anonymous()); }, /^TypeError: Expected a string but received a $/); + }); + // IE 11 has no Function#name, so constructor names come from their source. it('Should work on engines without Function#name', function () { const owners = [Function.prototype, String, Number];