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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b6dd4795..e99ec2bcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,19 +10,21 @@ 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 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 + uses: actions/upload-artifact@v7 with: name: build-artifacts path: | @@ -31,6 +33,7 @@ jobs: es/ validator.js validator.min.js + test-es5/ retention-days: 1 test: @@ -38,17 +41,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [24, 22, 20, 18, 16, 14, 12, 10, 8] + node-version: [26, 24, 22, 20, 18, 16, 14, 12, 10, 8] 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 @@ -57,6 +60,189 @@ 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 }} + + # 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@v7 + - name: Download build artifacts + uses: actions/download-artifact@v8 + 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 + + browser: + needs: build + runs-on: ubuntu-latest + name: Run tests in Chromium, Firefox and WebKit + timeout-minutes: 15 + permissions: + contents: read + env: + # Pinned; installed with --no-save because Playwright needs a newer + # Node.js than the development dependencies support. + PLAYWRIGHT_VERSION: 1.63.0 + REQUIREJS_VERSION: 2.3.8 + steps: + - name: Setup Node.js 24 + uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-artifacts + - name: Install dependencies, Playwright and RequireJS + run: npm install --legacy-peer-deps --no-save "playwright@$PLAYWRIGHT_VERSION" "requirejs@$REQUIREJS_VERSION" + - name: Install browsers + 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@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-artifacts + - name: Check the packed package + run: node scripts/check-package.mjs + + # Runs the Mocha suite on Deno's Node.js compatibility layer, then imports + # the packed package (CommonJS, ESM, deep es/ paths) from a node_modules + # install with Deno. Tracks the latest Deno 2.x release. + deno: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + name: Run tests on Deno 2.x + steps: + - name: Setup Node.js 24 + uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Setup Deno 2.x + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-artifacts + - name: Install dependencies + run: npm install --legacy-peer-deps + - name: Run tests + run: deno run -A node_modules/mocha/bin/_mocha --require @babel/register --reporter dot --recursive + - name: Check the packed package + run: node scripts/check-package.mjs --runtime=deno + + # Same for Bun: the Mocha suite (Mocha itself, since `bun test` does not + # support Mocha's `this.timeout()`/`this.skip()`), then the packed package + # installed with `bun install`. Tracks the latest Bun 1.x release. + bun: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + name: Run tests on Bun 1.x + steps: + - name: Setup Node.js 24 + uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Setup Bun 1.x + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.x + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-artifacts + - name: Install dependencies + run: npm install --legacy-peer-deps + - name: Run tests + run: bun node_modules/mocha/bin/_mocha --require @babel/register --reporter dot --recursive + - name: Check the packed package + run: node scripts/check-package.mjs --runtime=bun + + # Keeps jsr.json publishable to JSR (dry run only; nothing is published). + # jsr@0.14.3 downloads and verifies a pinned Deno (v2.6.7) to run + # `deno publish`. The `unsupported-javascript-entrypoint` warning is expected + # until the package ships type declarations. + jsr: + runs-on: ubuntu-latest + permissions: + contents: read + name: Check JSR publishing (dry run) + steps: + - name: Setup Node.js 24 + uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Publish to JSR (dry run) + run: npx --yes jsr@0.14.3 publish --dry-run --allow-dirty 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 diff --git a/.gitignore b/.gitignore index 86aaedee1..1116f05bd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ yarn.lock /index.js validator.js validator.min.js +/test-es5 +/deno.lock 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). diff --git a/README.md b/README.md index e8b7aedd9..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 @@ -43,6 +45,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 @@ -51,10 +59,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 @@ -66,12 +80,6 @@ The library can be loaded either as a standalone script, or through an [AMD][amd ``` -The library can also be installed through [bower][bower] - -```bash -$ bower install validator-js -``` - CDN ```html @@ -118,7 +126,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. @@ -241,7 +249,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" - ] -} 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/jsr.json b/jsr.json new file mode 100644 index 000000000..f854eb3cd --- /dev/null +++ b/jsr.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://jsr.io/schema/config-file.v1.json", + "name": "@validatorjs/validator", + "version": "13.15.35", + "license": "MIT", + "exports": "./src/index.js", + "publish": { + "include": [ + "src/**/*.js", + "README.md", + "LICENSE" + ] + } +} diff --git a/package.json b/package.json index 4a76409af..ab0714844 100644 --- a/package.json +++ b/package.json @@ -55,19 +55,23 @@ "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", "clean:browser": "rimraf validator*.js", + "clean:test-es5": "rimraf test-es5", "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 .", - "build": "run-p build:*", + "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:* && 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:browser": "node test/browser/run.mjs", "test:ci": "nyc --reporter=cobertura --reporter=text-summary mocha --require @babel/register --reporter dot --recursive" }, "engines": { 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', + }, + }, + ], +}; 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/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/build-es5-tests.js b/scripts/build-es5-tests.js new file mode 100644 index 000000000..e5f072cdf --- /dev/null +++ b/scripts/build-es5-tests.js @@ -0,0 +1,144 @@ +/* + * 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', + 'readme.test.js': 'checks repository metadata, which does not depend on the runtime', + 'version.test.js': 'checks repository metadata, which does not depend on the runtime', +}; + +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/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/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..92e0f76ef --- /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 [title-substring] + * + * 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] || null, // only run tests whose full title contains this text + 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..246254527 --- /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 || test.title.indexOf(grep) !== -1) { + 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 () {}, + }; +} 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/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 +65,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/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); } 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/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/browser/amd.html b/test/browser/amd.html new file mode 100644 index 000000000..2a8c773f1 --- /dev/null +++ b/test/browser/amd.html @@ -0,0 +1,35 @@ + + + + + + validator.js through RequireJS: __BUNDLE__ + + + + + + + + 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..57a03a760 --- /dev/null +++ b/test/browser/run.mjs @@ -0,0 +1,593 @@ +/* + * 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 + + + + + + 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]); + }); +}); 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 }); + }); +}); 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})`); diff --git a/test/readme.test.js b/test/readme.test.js new file mode 100644 index 000000000..5be7612d8 --- /dev/null +++ b/test/readme.test.js @@ -0,0 +1,81 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import validator from '../src/index'; + +const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8'); + +// Returns the text of a `## ` section of the README. +function section(title) { + const start = readme.indexOf(`\n## ${title}\n`); + assert.notStrictEqual(start, -1, `README has no "## ${title}" section`); + const end = readme.indexOf('\n## ', start + 1); + return end === -1 ? readme.slice(start) : readme.slice(start, end); +} + +// Names documented as table rows such as `**isEmail(str [, options])** | ...`. +function documentedFunctions(title) { + const names = []; + const row = /^\*\*([A-Za-z0-9_$]+)\(/gm; + const text = section(title); + let match = row.exec(text); + while (match) { + names.push(match[1]); + match = row.exec(text); + } + return names; +} + +function sorted(list) { + return list.slice().sort(); +} + +function duplicates(list) { + return list.filter((name, i) => list.indexOf(name) !== i); +} + +describe('README', () => { + // `default` is added by the CommonJS build (babel-plugin-add-module-exports). + const keys = Object.keys(validator).filter(key => key !== 'default'); + const functions = keys.filter(key => typeof validator[key] === 'function'); + const localeLists = keys.filter(key => /Locales$/.test(key)); + + it('should document every exported function exactly once, and nothing else', () => { + const documented = documentedFunctions('Validators').concat(documentedFunctions('Sanitizers')); + assert.ok(documented.length > 100, `expected the README tables, found ${documented.length} rows`); + assert.deepStrictEqual(duplicates(documented), [], 'functions documented more than once'); + assert.deepStrictEqual( + functions.filter(name => documented.indexOf(name) === -1), + [], + 'exported functions missing from the README Validators/Sanitizers tables' + ); + assert.deepStrictEqual( + documented.filter(name => functions.indexOf(name) === -1), + [], + 'README rows for functions that are not exported' + ); + }); + + it('should only export functions, locale lists and version', () => { + const other = keys.filter(key => typeof validator[key] !== 'function'); + assert.deepStrictEqual( + sorted(other), + sorted(localeLists.concat('version')), + 'a new non-function export needs a README entry and a place in this test' + ); + localeLists.forEach((key) => { + assert.ok(Array.isArray(validator[key]), `${key} is not an array`); + }); + }); + + it('should document every exported locale list, and nothing else', () => { + const mentioned = []; + const reference = /`validator\.([A-Za-z0-9_$]+Locales)`/g; + let match = reference.exec(readme); + while (match) { + if (mentioned.indexOf(match[1]) === -1) mentioned.push(match[1]); + match = reference.exec(readme); + } + assert.deepStrictEqual(sorted(mentioned), sorted(localeLists)); + }); +}); diff --git a/test/util.test.js b/test/util.test.js index 0146a4e2c..d690804fb 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -5,6 +5,10 @@ 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'; +import validator from '../src/index'; describe('Util', () => { @@ -61,4 +65,101 @@ 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); + }); + + 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]; + 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', () => { + 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); + }); }); 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', diff --git a/test/validators/isBefore.test.js b/test/validators/isBefore.test.js index 298e5b410..8cb65b81d 100644 --- a/test/validators/isBefore.test.js +++ b/test/validators/isBefore.test.js @@ -1,4 +1,3 @@ -import { describe } from 'mocha'; import test from '../testFunctions'; describe('isBefore', () => { diff --git a/test/version.test.js b/test/version.test.js new file mode 100644 index 000000000..cd61ff939 --- /dev/null +++ b/test/version.test.js @@ -0,0 +1,21 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import validator from '../src/index'; + +function readJson(file) { + return JSON.parse(fs.readFileSync(path.join(__dirname, '..', file), 'utf8')); +} + +describe('Version', () => { + // JSR reads the version from jsr.json only, so a release must bump all three. + it('should be the same in package.json, jsr.json and src/index.js', () => { + const pkg = readJson('package.json'); + const jsr = readJson('jsr.json'); + assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), `unexpected package.json version ${pkg.version}`); + assert.deepStrictEqual( + { 'jsr.json': jsr.version, 'src/index.js': validator.version }, + { 'jsr.json': pkg.version, 'src/index.js': pkg.version } + ); + }); +});