├── .clean-publish ├── .commitlintrc.json ├── .czrc ├── .editorconfig ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ ├── feature-request.yml │ └── question.yml ├── renovate.json └── workflows │ ├── checks.yml │ ├── commit.yml │ ├── release.yml │ ├── tests.yml │ └── website.yml ├── .gitignore ├── .nano-staged.json ├── .npmrc ├── .simple-git-hooks.json ├── .size-limit.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── .eslintrc.json ├── EXAMPLES.md ├── buildDemo.js └── demojs │ ├── index.actual.js │ ├── index.modern.js │ └── index.old.js ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── browsers │ ├── browserslist.spec.ts │ ├── browserslist.ts │ ├── index.ts │ ├── optimize.spec.ts │ ├── optimize.ts │ ├── types.ts │ ├── utils.spec.ts │ └── utils.ts ├── cli.ts ├── index.ts ├── numbers │ ├── index.ts │ ├── range.spec.ts │ ├── range.ts │ ├── ray.spec.ts │ ├── ray.ts │ ├── segment.spec.ts │ ├── segment.ts │ └── utils.ts ├── regex │ ├── index.ts │ ├── nodes.ts │ ├── optimize.spec.ts │ ├── optimize.ts │ ├── regex.ts │ └── utils.ts ├── semver │ ├── index.ts │ ├── semver.spec.ts │ ├── semver.ts │ └── types.ts ├── useragent │ ├── index.ts │ ├── types.ts │ ├── useragent.spec.ts │ ├── useragent.ts │ ├── utils.spec.ts │ └── utils.ts ├── useragentRegex │ ├── index.ts │ ├── types.ts │ ├── useragentRegex.spec.ts │ ├── useragentRegex.ts │ └── utils.ts ├── utils │ ├── index.ts │ └── utils.spec.ts └── versions │ ├── index.ts │ ├── utils.spec.ts │ ├── utils.ts │ ├── versions.spec.ts │ └── versions.ts ├── test ├── tsconfig.json └── useragents.ts ├── tsconfig.json └── vite.config.js /.clean-publish: -------------------------------------------------------------------------------- 1 | { 2 | "withoutPublish": true, 3 | "tempDir": "package" 4 | } 5 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "body-max-line-length": [0] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "@commitlint/cz-commitlint" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@trigen/eslint-config", 4 | "@trigen/eslint-config/esm", 5 | "@trigen/eslint-config/tsm", 6 | "@trigen/eslint-config/typescript", 7 | "@trigen/eslint-config/typescript-requiring-type-checking", 8 | "@trigen/eslint-config/jest" 9 | ], 10 | "env": { 11 | "node": true 12 | }, 13 | "parserOptions": { 14 | "tsconfigRootDir": "./", 15 | "project": ["./tsconfig.json", "./test/tsconfig.json"] 16 | }, 17 | "rules": { 18 | "@typescript-eslint/no-magic-numbers": "off", 19 | "@typescript-eslint/no-unsafe-argument": "off", 20 | "@typescript-eslint/no-misused-promises": "off", 21 | "array-element-newline": "off", 22 | "prefer-destructuring": "off", 23 | "@typescript-eslint/keyword-spacing": ["error", {}] 24 | }, 25 | "ignorePatterns": [ 26 | "rollup.config.js", 27 | "dist" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: browserslist-useragent-regexp 2 | ko_fi: dangreen 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "If something isn't working as expected." 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible. 9 | 10 | - type: checkboxes 11 | id: input1 12 | attributes: 13 | label: Would you like to work on a fix? 14 | options: 15 | - label: Check this if you would like to implement a PR, we are more than happy to help you go through the process. 16 | 17 | - type: textarea 18 | attributes: 19 | label: Current and expected behavior 20 | description: A clear and concise description of what the library is doing and what you would expect. 21 | validations: 22 | required: true 23 | 24 | - type: input 25 | attributes: 26 | label: Reproduction 27 | description: | 28 | Please provide issue reproduction. 29 | You can give a link to a repository with the reproduction or make a [sandbox](https://codesandbox.io/) and reproduce the issue there. 30 | validations: 31 | required: true 32 | 33 | - type: input 34 | attributes: 35 | label: browserslist-useragent-regexp version 36 | description: Which version of `browserslist-useragent-regexp` are you using? 37 | placeholder: v0.0.0 38 | validations: 39 | required: true 40 | 41 | - type: textarea 42 | attributes: 43 | label: Possible solution 44 | description: If you have suggestions on a fix for the bug. 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: "🚀 Feature Request" 2 | description: "I have a specific suggestion!" 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Thanks for taking the time to suggest a new feature! Please fill out this form as completely as possible. 8 | 9 | - type: checkboxes 10 | id: input1 11 | attributes: 12 | label: Would you like to work on this feature? 13 | options: 14 | - label: Check this if you would like to implement a PR, we are more than happy to help you go through the process. 15 | 16 | - type: textarea 17 | attributes: 18 | label: What problem are you trying to solve? 19 | description: | 20 | A concise description of what the problem is. 21 | placeholder: | 22 | I have an issue when [...] 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | attributes: 28 | label: Describe the solution you'd like 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | attributes: 34 | label: Describe alternatives you've considered 35 | 36 | - type: textarea 37 | attributes: 38 | label: Documentation, Adoption, Migration Strategy 39 | description: | 40 | If you can, explain how users will be able to use this and how it might be documented. Maybe a mock-up? 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: "❓ Question" 2 | description: "Have a Question?" 3 | labels: ["question"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Describe your question 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | size: 8 | runs-on: ubuntu-latest 9 | name: size-limit 10 | steps: 11 | - name: Checkout the repository 12 | uses: actions/checkout@v4 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v2 15 | with: 16 | version: 8 17 | - name: Install Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 16 21 | cache: 'pnpm' 22 | - name: Check size 23 | uses: andresz1/size-limit-action@master 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | editorconfig: 27 | runs-on: ubuntu-latest 28 | name: editorconfig 29 | steps: 30 | - name: Checkout the repository 31 | uses: actions/checkout@v4 32 | - name: Create config 33 | run: | 34 | echo '{"Exclude": ["docs"]}' > .ecrc 35 | - name: Check editorconfig 36 | uses: editorconfig-checker/action-editorconfig-checker@main 37 | package-json: 38 | runs-on: ubuntu-latest 39 | name: package.json 40 | steps: 41 | - name: Checkout the repository 42 | uses: actions/checkout@v4 43 | - name: Install pnpm 44 | uses: pnpm/action-setup@v2 45 | with: 46 | version: 8 47 | - name: Install Node.js 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: 16 51 | cache: 'pnpm' 52 | - name: Check package.json files 53 | run: pnpm dlx @trigen/lint-package-json 54 | -------------------------------------------------------------------------------- /.github/workflows/commit.yml: -------------------------------------------------------------------------------- 1 | name: Commit 2 | on: 3 | push: 4 | jobs: 5 | commitlint: 6 | runs-on: ubuntu-latest 7 | name: commitlint 8 | steps: 9 | - name: Checkout the repository 10 | uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v2 15 | with: 16 | version: 8 17 | - name: Install Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 16 21 | cache: 'pnpm' 22 | - name: Install dependencies 23 | run: pnpm install 24 | - name: Run commitlint 25 | run: pnpm commitlint --from=HEAD~1 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Publish package 9 | steps: 10 | - name: Checkout the repository 11 | uses: actions/checkout@v4 12 | - name: Install pnpm 13 | uses: pnpm/action-setup@v2 14 | with: 15 | version: 8 16 | - name: Install Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 16 20 | cache: 'pnpm' 21 | registry-url: 'https://registry.npmjs.org' 22 | - name: Install dependencies 23 | run: pnpm install 24 | - name: Publish 25 | run: pnpm publish --no-git-checks --tag $NPM_TAG 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | NPM_TAG: ${{ contains(github.ref_name, '-beta') && 'next' || 'latest' }} 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | jobs: 9 | types: 10 | runs-on: ubuntu-latest 11 | name: types 12 | steps: 13 | - name: Checkout the repository 14 | uses: actions/checkout@v4 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v2 17 | with: 18 | version: 8 19 | - name: Install Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 16 23 | cache: 'pnpm' 24 | - name: Install dependencies 25 | run: pnpm install 26 | - name: Check types 27 | run: pnpm test:types 28 | unit: 29 | runs-on: ubuntu-latest 30 | name: unit 31 | steps: 32 | - name: Checkout the repository 33 | uses: actions/checkout@v4 34 | - name: Install pnpm 35 | uses: pnpm/action-setup@v2 36 | with: 37 | version: 8 38 | - name: Install Node.js 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: 16 42 | cache: 'pnpm' 43 | - name: Install dependencies 44 | run: pnpm install 45 | - name: Run tests 46 | run: pnpm test 47 | - name: Collect coverage 48 | uses: codecov/codecov-action@v4 49 | if: success() 50 | with: 51 | files: ./coverage/lcov.info 52 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website 2 | on: 3 | push: 4 | branches: 5 | - master 6 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | # Allow one concurrent deployment 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: true 15 | jobs: 16 | deploy: 17 | environment: 18 | name: github-pages 19 | url: ${{ steps.deployment.outputs.page_url }} 20 | runs-on: ubuntu-latest 21 | name: deploy website 22 | steps: 23 | - name: Checkout the repository 24 | uses: actions/checkout@v4 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v2 27 | with: 28 | version: 8 29 | - name: Install Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 16 33 | cache: 'pnpm' 34 | - name: Install dependencies 35 | run: pnpm install 36 | - name: Build website 37 | run: | 38 | pnpm build 39 | pnpm build:docs 40 | pnpm build:demo 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v5 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v3 45 | with: 46 | path: './docs' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Coverage directory used by tools like istanbul 7 | coverage 8 | 9 | # Dependency directory 10 | node_modules 11 | 12 | # Optional npm cache directory 13 | .npm 14 | 15 | # OS stuff 16 | ._* 17 | .DS_Store 18 | 19 | # Some caches 20 | .*cache 21 | 22 | # Compiled dist 23 | dist 24 | package 25 | build 26 | 27 | # Env files 28 | .env 29 | -------------------------------------------------------------------------------- /.nano-staged.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts,tsx}": "eslint --fix" 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | -------------------------------------------------------------------------------- /.simple-git-hooks.json: -------------------------------------------------------------------------------- 1 | { 2 | "commit-msg": "pnpm commitlint --edit \"$1\"", 3 | "pre-commit": "pnpm nano-staged", 4 | "pre-push": "pnpm test" 5 | } 6 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/index.js", 4 | "limit": "8 KB" 5 | }, 6 | { 7 | "path": "dist/cli.js", 8 | "limit": "1.45 KB" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [4.1.3](https://github.com/browserslist/browserslist-useragent-regexp/compare/v4.1.2...v4.1.3) (2024-04-10) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * restore types field in package.json ([#1546](https://github.com/browserslist/browserslist-useragent-regexp/issues/1546)) ([3fdde6a](https://github.com/browserslist/browserslist-useragent-regexp/commit/3fdde6a8a8b5a2c634e4812da069678152d8f047)), closes [#1545](https://github.com/browserslist/browserslist-useragent-regexp/issues/1545) 11 | 12 | ### [4.1.2](https://github.com/browserslist/browserslist-useragent-regexp/compare/v4.1.1...v4.1.2) (2024-04-05) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * fix splitCommonDiff function to work correctly on common digits after first difference ([#1543](https://github.com/browserslist/browserslist-useragent-regexp/issues/1543)) ([ae3981c](https://github.com/browserslist/browserslist-useragent-regexp/commit/ae3981ce92408c4a04175f1ad0ee446024ddbbc1)) 18 | 19 | ### [4.1.1](https://github.com/browserslist/browserslist-useragent-regexp/compare/v4.1.0...v4.1.1) (2023-12-24) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * fix Edge browser regex, minor version is required ([#1533](https://github.com/browserslist/browserslist-useragent-regexp/issues/1533)) ([f6108cf](https://github.com/browserslist/browserslist-useragent-regexp/commit/f6108cfb3921bf2822f4235eeb4ba893409e4bb8)), closes [#1530](https://github.com/browserslist/browserslist-useragent-regexp/issues/1530) 25 | 26 | ## [4.1.0](https://github.com/browserslist/browserslist-useragent-regexp/compare/v4.0.0...v4.1.0) (2023-06-16) 27 | 28 | 29 | ### Features 30 | 31 | * allow all browserslist options via JS API ([#1489](https://github.com/browserslist/browserslist-useragent-regexp/issues/1489)) ([38d1e23](https://github.com/browserslist/browserslist-useragent-regexp/commit/38d1e23fe8e59875208426f9a32b9a4d06577e28)) 32 | 33 | ## [4.0.0](https://github.com/browserslist/browserslist-useragent-regexp/compare/v4.0.0-beta.1...v4.0.0) (2022-11-15) 34 | 35 | ## [4.0.0-beta.1](https://github.com/browserslist/browserslist-useragent-regexp/compare/v4.0.0-beta.0...v4.0.0-beta.1) (2022-11-06) 36 | 37 | ## [4.0.0-beta.0](https://github.com/browserslist/browserslist-useragent-regexp/compare/v3.0.0...v4.0.0-beta.0) (2022-10-15) 38 | 39 | 40 | ### ⚠ BREAKING CHANGES 41 | 42 | * regexp -> regex in JS API naming, new regexes from ua-regexes-lite 43 | * now browserslist is peer dependency 44 | * NodeJS >= 14 is required, no commonjs support 45 | 46 | ### Features 47 | 48 | * `bluare` binary alias ([288b473](https://github.com/browserslist/browserslist-useragent-regexp/commit/288b4732490977e7e70038b72d94476d735214da)) 49 | * browserslist as peer dependency ([eedbcc5](https://github.com/browserslist/browserslist-useragent-regexp/commit/eedbcc58794cb8cbf491027ffd651abadd27d5ed)) 50 | * move to ESM ([#1450](https://github.com/browserslist/browserslist-useragent-regexp/issues/1450)) ([41456bc](https://github.com/browserslist/browserslist-useragent-regexp/commit/41456bc22b789fee57384a00abb64e0690ded08a)) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * typo in cli option ([e11f219](https://github.com/browserslist/browserslist-useragent-regexp/commit/e11f2196b5b291f31f81057fa5d468c51f48e1a6)) 56 | 57 | 58 | * rename js api, ua-regexes-lite instead of useragents ([#1454](https://github.com/browserslist/browserslist-useragent-regexp/issues/1454)) ([332b7d8](https://github.com/browserslist/browserslist-useragent-regexp/commit/332b7d87cc83e749109f973671239eddcd026bff)) 59 | 60 | ## [3.0.0] - 2021-02-03 61 | ### Breaking 62 | - Requires `Node 12+` 63 | 64 | ## [2.1.1] - 2020-10-17 65 | ### Fixed 66 | - [#472](https://github.com/browserslist/browserslist-useragent-regexp/issues/472) 67 | 68 | ## [2.1.0] - 2020-06-11 69 | ### Fixed 70 | - Extracting browser family from regexp fix. 71 | 72 | ### Changed 73 | - `HeadlessChrome` regexp was removed, works with regular Chrome regexp. 74 | 75 | ## [2.0.5] - 2020-05-12 76 | ### Fixed 77 | - [#434](https://github.com/browserslist/browserslist-useragent-regexp/issues/434) 78 | 79 | ## [2.0.4] - 2020-04-23 80 | ### Fixed 81 | - Desktop Safari regexp. 82 | 83 | ## [2.0.3] - 2020-04-22 84 | ### Fixed 85 | - [#420](https://github.com/browserslist/browserslist-useragent-regexp/issues/420) 86 | 87 | ## [2.0.2] - 2020-04-16 88 | ### Fixed 89 | - [#409](https://github.com/browserslist/browserslist-useragent-regexp/issues/409) 90 | 91 | ## [2.0.0] - 2020-01-25 92 | ### Breaking 93 | - Requires `Node 10+` 94 | 95 | ## [1.3.1-beta] - 2019-07-30 96 | ### Changed 97 | - Dependencies update. 98 | 99 | ## [1.3.0-beta] - 2019-06-09 100 | ### Added 101 | - `trigen-scripts` dev tool. 102 | 103 | ### Changed 104 | - Dependencies update. 105 | 106 | ## [1.2.0-beta] - 2019-05-09 107 | ### Fixed 108 | - [#23](https://github.com/browserslist/browserslist-useragent-regexp/issues/23): Patch Chrome regexp to ignore Edge useragent. 109 | 110 | ## [1.1.1-beta] - 2019-05-08 111 | ### Fixed 112 | - [#11](https://github.com/browserslist/browserslist-useragent-regexp/issues/11): `rollup-plugin-shebang` -> `rollup-plugin-add-shebang` 113 | 114 | ## [1.1.0-beta] - 2019-05-01 115 | ### Summary 116 | The size of RegExp for `defaults` has decreased by ~63%. 117 | ### Changed 118 | - Result RegExps optimizations; 119 | - Removing of useless RegExps from result. 120 | 121 | ## [1.0.3-beta] - 2019-04-07 122 | ### Fixed 123 | - [#11](https://github.com/browserslist/browserslist-useragent-regexp/issues/11) 124 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 - present, TrigenSoftware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # browserslist-useragent-regexp 2 | 3 | [![ESM-only package][package]][package-url] 4 | [![NPM version][npm]][npm-url] 5 | [![Node version][node]][node-url] 6 | [![Dependencies status][deps]][deps-url] 7 | [![Install size][size]][size-url] 8 | [![Build status][build]][build-url] 9 | [![Coverage status][coverage]][coverage-url] 10 | [![Documentation badge][documentation]][documentation-url] 11 | 12 | [package]: https://img.shields.io/badge/package-ESM--only-ffe536.svg 13 | [package-url]: https://nodejs.org/api/esm.html 14 | 15 | [npm]: https://img.shields.io/npm/v/browserslist-useragent-regexp.svg 16 | [npm-url]: https://npmjs.com/package/browserslist-useragent-regexp 17 | 18 | [node]: https://img.shields.io/node/v/browserslist-useragent-regexp.svg 19 | [node-url]: https://nodejs.org 20 | 21 | [deps]: https://img.shields.io/librariesio/release/npm/browserslist-useragent-regexp 22 | [deps-url]: https://libraries.io/npm/browserslist-useragent-regexp/tree 23 | 24 | [size]: https://packagephobia.com/badge?p=browserslist-useragent-regexp 25 | [size-url]: https://packagephobia.com/result?p=browserslist-useragent-regexp 26 | 27 | [build]: https://img.shields.io/github/actions/workflow/status/browserslist/browserslist-useragent-regexp/tests.yml?branch=master 28 | [build-url]: https://github.com/browserslist/browserslist-useragent-regexp/actions 29 | 30 | [coverage]: https://img.shields.io/codecov/c/github/browserslist/browserslist-useragent-regexp.svg 31 | [coverage-url]: https://app.codecov.io/gh/browserslist/browserslist-useragent-regexp 32 | 33 | [documentation]: https://img.shields.io/badge/API-Documentation-2b7489.svg 34 | [documentation-url]: https://browserslist.github.io/browserslist-useragent-regexp 35 | 36 | A utility to compile [browserslist query](https://github.com/browserslist/browserslist#queries) to a regex to test browser useragent. Simplest example: you can detect supported browsers on client-side. 37 | 38 | 1) Create `.browserslistrc` config, for example like this: 39 | 40 | ``` 41 | last 2 versions 42 | not dead 43 | ``` 44 | 45 | 2) Add script to `package.json`: 46 | 47 | ```json 48 | { 49 | "scripts": { 50 | "supportedBrowsers": "echo \"export default $(browserslist-useragent-regexp --allowHigherVersions);\" > supportedBrowsers.js" 51 | } 52 | } 53 | ``` 54 | 55 |
56 | for Windows users 57 | 58 | ```json 59 | { 60 | "scripts": { 61 | "supportedBrowsers": "(echo export default && browserslist-useragent-regexp --allowHigherVersions) > supportedBrowsers.js" 62 | } 63 | } 64 | ``` 65 | 66 |
67 | 68 | 3) Run this script, to compile regex: 69 | 70 | ```bash 71 | pnpm supportedBrowsers 72 | # or 73 | npm run supportedBrowsers 74 | # or 75 | yarn supportedBrowsers 76 | ``` 77 | 78 | `supportedBrowsers.js`: 79 | 80 | ```js 81 | export default /Edge?\/(10[5-9]|1[1-9]\d|[2-9]\d\d|\d{4,})(\.\d+|)(\.\d+|)|Firefox\/(10[4-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+(\.\d+|)|Chrom(ium|e)\/(10[5-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+(\.\d+|)|Maci.* Version\/(15\.([6-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})\.\d+)([,.]\d+|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/(9\d|\d{3,})\.\d+\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(15[._]([6-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})[._]\d+)([._]\d+|)|Opera Mini|Android:?[ /\-](10[6-9]|1[1-9]\d|[2-9]\d{2}|\d{4,})(\.\d+|)(\.\d+|)|Mobile Safari.+OPR\/(6[4-9]|[7-9]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(10[5-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(10[6-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+(\.\d+|)|Android.+(UC? ?Browser|UCWEB|U3)[ /]?(13\.([4-9]|\d{2,})|(1[4-9]|[2-9]\d|\d{3,})\.\d+)\.\d+|SamsungBrowser\/(1[7-9]|[2-9]\d|\d{3,})\.\d+|Android.+MQQBrowser\/(13(\.([1-9]|\d{2,})|)|(1[4-9]|[2-9]\d|\d{3,})(\.\d+|))(\.\d+|)|K[Aa][Ii]OS\/(2\.([5-9]|\d{2,})|([3-9]|\d{2,})\.\d+)(\.\d+|)/; 82 | ``` 83 | 84 | 4) Import regex from created file: 85 | 86 | ```js 87 | import supportedBrowsers from './supportedBrowsers.js'; 88 | 89 | if (supportedBrowsers.test(navigator.userAgent)) { 90 | alert('Your browser is supported.'); 91 | } 92 | ``` 93 | 94 | ## Install 95 | 96 | ```bash 97 | pnpm add -D browserslist-useragent-regexp 98 | # or 99 | npm i -D browserslist-useragent-regexp 100 | # or 101 | yarn add -D browserslist-useragent-regexp 102 | ``` 103 | 104 | ## Why? 105 | 106 | As was written in article ["Smart Bundling: Shipping legacy code to only legacy browsers"](https://www.smashingmagazine.com/2018/10/smart-bundling-legacy-code-browsers/): you can determinate, which bundle you should give to browser from server with [`browserslist-useragent`](https://github.com/browserslist/browserslist-useragent). But in this case you must have your own server with special logic. Now, with `browserslist-useragent-regexp`, you can move that to client-side. 107 | 108 | Development was inspired by [this proposal from Mathias Bynens](https://twitter.com/mathias/status/1105857829393653761). 109 | 110 | How to make differential resource loading and other optimizations with `browserslist-useragent-regexp` you can read in article ["Speed up with Browserslist"](https://dev.to/dangreen/speed-up-with-browserslist-30lh). 111 | 112 | [Demo](https://browserslist.github.io/browserslist-useragent-regexp/demo.html) ([sources](https://github.com/browserslist/browserslist-useragent-regexp/blob/7cf6afb7da2b6c77179abb8b8bd1bbcb61cf376a/docs/demo.html#L17-L29), [build script](https://github.com/browserslist/browserslist-useragent-regexp/blob/7cf6afb7da2b6c77179abb8b8bd1bbcb61cf376a/examples/buildDemo.js#L61-L74)). 113 | 114 | Also, testing useragents using generated regex [is faster](https://gist.github.com/dangreen/55c41072d8891efd3a772a4739d6cd9d) than using the `matchesUA` method from browserslist-useragent. 115 | 116 | ## CLI 117 | 118 | ```bash 119 | pnpm browserslist-useragent-regexp [query] [...options] 120 | # or 121 | npx browserslist-useragent-regexp [query] [...options] 122 | # or 123 | yarn exec -- browserslist-useragent-regexp [query] [...options] 124 | ``` 125 | 126 | Also, short alias is available: 127 | 128 | ```bash 129 | pnpm bluare [query] [...options] 130 | ``` 131 | 132 | | Option | Description | Default | 133 | |--------|-------------|---------| 134 | | query | Manually provide a browserslist query. Specifying this overrides the browserslist configuration specified in your project. | | 135 | | ‑‑help, -h | Print this message. | | 136 | | ‑‑verbose, -v | Print additional info about regexes. | | 137 | | ‑‑ignorePatch | Ignore differences in patch browser numbers. | `true` | 138 | | ‑‑ignoreMinor | Ignore differences in minor browser versions. | `false` | 139 | | ‑‑allowHigherVersions | For all the browsers in the browserslist query, return a match if the useragent version is equal to or higher than the one specified in browserslist. | `false` | 140 | | ‑‑allowZeroSubversions | Ignore match of patch or patch and minor, if they are 0. | `false` | 141 | 142 | ## JS API basics 143 | 144 | Module exposes two main methods: 145 | 146 | ### [getUserAgentRegexes(options)](https://browserslist.github.io/browserslist-useragent-regexp/functions/getUserAgentRegexes.html) 147 | 148 | Compile browserslist query to [regexes for each browser](#regex-info-object). 149 | 150 | ### [getUserAgentRegex(options)](https://browserslist.github.io/browserslist-useragent-regexp/functions/getUserAgentRegex.html) 151 | 152 | Compile browserslist query to one regex. 153 | 154 | > [Description of all methods you can find in Documentation.](https://browserslist.github.io/browserslist-useragent-regexp/index.html) 155 | 156 | #### Options 157 | 158 | | Option | Type | Default | Description | 159 | |--------|------|---------|-------------| 160 | | browsers | `string \| string[]` | — | Manually provide a browserslist query (or an array of queries). Specifying this overrides the browserslist configuration specified in your project. | 161 | | ignorePatch | `boolean` | `true` | Ignore differences in patch browser numbers. | 162 | | ignoreMinor | `boolean` | `false` | Ignore differences in minor browser versions. | 163 | | allowHigherVersions | `boolean` | `false` | For all the browsers in the browserslist query, return a match if the useragent version is equal to or higher than the one specified in browserslist. | 164 | | allowZeroSubversions | `boolean` | `false` | Ignore match of patch or patch and minor, if they are 0. | 165 | 166 | Any of the [`browserslist` API options](https://github.com/browserslist/browserslist#js-api) may also be provided. 167 | 168 | #### Regex info object 169 | 170 | | Property | Type | Description | 171 | |----------|------|-------------| 172 | | family | `string` | Browser family. | 173 | | requestVersions | `[number, number, number][]` | Versions provided by browserslist. | 174 | | regex | `RegExp` | Regex to match useragent with family and versions. | 175 | | sourceRegex | `RegExp` | Original useragent regex, without versions. | 176 | | version | `[number, number, number] \| null` | Useragent version of regex. | 177 | | minVersion | `[number, number, number] \| null` | Useragent min version of regex. | 178 | | maxVersion | `[number, number, number] \| null` | Useragent max version of regex. | 179 | 180 | ## Other 181 | 182 | - [Supported browsers](https://github.com/browserslist/browserslist-useragent#supported-browsers) 183 | - [Notes](https://github.com/browserslist/browserslist-useragent#notes) 184 | - [When querying for modern browsers](https://github.com/browserslist/browserslist-useragent#when-querying-for-modern-browsers) 185 | -------------------------------------------------------------------------------- /examples/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # EXAMPLES 2 | 3 | ## Supported browsers 4 | 5 | 1) Create `.browserslistrc` config, for example like this: 6 | 7 | ``` 8 | last 2 versions 9 | not dead 10 | ``` 11 | 12 | 2) Add script to `package.json`: 13 | 14 | ```json 15 | { 16 | "scripts": { 17 | "supportedBrowsers": "echo \"export default $(browserslist-useragent-regexp --allowHigherVersions);\" > supportedBrowsers.js" 18 | } 19 | } 20 | ``` 21 | 22 | 3) Run this script, to compile regex: 23 | 24 | ```bash 25 | npm run supportedBrowsers 26 | # or 27 | yarn supportedBrowsers 28 | ``` 29 | 30 | 4) Import regex from created file: 31 | 32 | ```js 33 | import supportedBrowsers from './supportedBrowsers.js'; 34 | 35 | if (supportedBrowsers.test(navigator.userAgent)) { 36 | alert('Your browser is supported.'); 37 | } 38 | ``` 39 | 40 | ## Unsupported browsers 41 | 42 | 1) Create `.browserslistrc` config, for example with [environments](https://github.com/browserslist/browserslist#environments), like this: 43 | 44 | ``` 45 | [production] 46 | last 2 versions 47 | not dead 48 | 49 | [unsupported] 50 | dead 51 | ``` 52 | 53 | 2) Add script to `package.json`: 54 | 55 | ```json 56 | { 57 | "scripts": { 58 | "supportedBrowsers": "echo \"export default $(BROWSERSLIST_ENV=unsupported browserslist-useragent-regexp);\" > supportedBrowsers.js" 59 | } 60 | } 61 | ``` 62 | 63 | 3) Run this script, to compile regex: 64 | 65 | ```bash 66 | npm run supportedBrowsers 67 | # or 68 | yarn supportedBrowsers 69 | ``` 70 | 71 | 4) Import regex from created file: 72 | 73 | ```js 74 | import unsupportedBrowsers from './unsupportedBrowsers.js'; 75 | 76 | if (unsupportedBrowsers.test(navigator.userAgent)) { 77 | alert('Your browser is unsupported.'); 78 | } 79 | ``` 80 | -------------------------------------------------------------------------------- /examples/buildDemo.js: -------------------------------------------------------------------------------- 1 | import { 2 | getUserAgentRegexes, 3 | getUserAgentRegex 4 | } from '../dist/index.js' 5 | 6 | function versionsToString(versions) { 7 | return versions.map(_ => ( 8 | _[0] === Infinity 9 | ? 'all' 10 | : _.join('.') 11 | )).join(' ') 12 | } 13 | 14 | function renderStyles() { 15 | return `` 64 | } 65 | 66 | function renderScript() { 67 | const modernBrowsers = getUserAgentRegex({ 68 | browsers: 'last 3 years and not dead', 69 | allowHigherVersions: true 70 | }) 71 | const actualBrowsers = getUserAgentRegex({ 72 | browsers: 'last 6 years and not dead', 73 | allowHigherVersions: true 74 | }) 75 | 76 | return `` 111 | } 112 | 113 | function renderHtml(body) { 114 | return ` 115 | 116 | 117 | 118 | 119 | 120 | 121 | DEMO 122 | 136 | ${renderScript()} 137 | ${renderStyles()} 138 | 139 | 140 | ${body} 141 | 142 | ` 143 | } 144 | 145 | function renderUserAgentRegex({ 146 | family, 147 | sourceRegex, 148 | regex, 149 | requestVersions, 150 | matchedVersions, 151 | version, 152 | minVersion, 153 | maxVersion 154 | }, query) { 155 | const regexString = regex.toString() 156 | const sourceRegexString = sourceRegex.toString() 157 | const requestVersionsString = versionsToString(requestVersions) 158 | const matchedVersionsString = versionsToString(matchedVersions) 159 | let regexBrowsersVersion = '' 160 | 161 | if (minVersion) { 162 | regexBrowsersVersion = minVersion.filter(isFinite).join('.') 163 | } else { 164 | regexBrowsersVersion = '...' 165 | } 166 | 167 | regexBrowsersVersion += ' - ' 168 | 169 | if (maxVersion) { 170 | regexBrowsersVersion += maxVersion.filter(isFinite).join('.') 171 | } else { 172 | regexBrowsersVersion += '...' 173 | } 174 | 175 | return ` 176 | 177 | 178 | 179 | Family: 180 | ${family} 181 | 182 | 183 | Versions:${requestVersionsString} 184 | 185 | 186 | Matched versions:${matchedVersionsString} 187 | 188 | 189 | Source regex:
${sourceRegexString}
190 | 191 | 192 | ${version ? 'Source regex fixed browser version' : 'Source regex browsers versions'}:${version ? version.join('.') : regexBrowsersVersion} 193 | 194 | 195 | Versioned regex:
${regexString}
196 | ` 197 | } 198 | 199 | function renderQuery(query) { 200 | const result = getUserAgentRegexes({ 201 | browsers: query, 202 | allowHigherVersions: true 203 | }) 204 | 205 | return ` 206 | 207 | 208 | 209 | 210 | 211 |

212 |
${query}
213 |

214 | 215 | 216 | 217 | 218 | ${result.map(_ => renderUserAgentRegex(_, query)).join('\n')} 219 | ` 220 | } 221 | 222 | function render(queries) { 223 | return renderHtml(`

browserslist-useragent-regexp demo

224 | 225 | 226 | 227 | 228 | 229 | 230 | 235 | 236 |
Useragent:
Options: 231 |
{
232 |   allowHigherVersions: true
233 | }
234 |
237 | 238 | ${queries.map(_ => renderQuery(_)).join('\n')} 239 |
`) 240 | } 241 | 242 | console.log(render(['dead', 'defaults'])) 243 | -------------------------------------------------------------------------------- /examples/demojs/index.actual.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function elevateElements(elements) { 4 | var firstElement = elements[0] 5 | var root = elements[0].parentElement 6 | var firstRootElement = root.firstElementChild 7 | 8 | if (firstElement === firstRootElement) { 9 | return 10 | } 11 | 12 | var fragment = document.createDocumentFragment() 13 | 14 | elements.forEach(function (element) { 15 | fragment.append(element) 16 | }) 17 | 18 | root.insertBefore(fragment, firstRootElement) 19 | } 20 | 21 | document.getElementById('useragent').innerText = navigator.userAgent 22 | 23 | Array.from(document.querySelectorAll('[data-query]')).forEach(function (input) { 24 | var query = input.dataset.query 25 | var some = false 26 | 27 | Array.from(document.querySelectorAll('[data-for-query="' + query + '"]')).forEach(function (input) { 28 | var regex = input.dataset.regex 29 | var family = input.dataset.family 30 | var checked = new RegExp(regex).test(navigator.userAgent) 31 | 32 | input.checked = checked 33 | some = some || checked 34 | 35 | if (checked) { 36 | elevateElements( 37 | document.querySelectorAll('[data-group-family="' + query + ' ' + family + '"]') 38 | ) 39 | } 40 | }) 41 | 42 | input.checked = some 43 | 44 | if (some) { 45 | elevateElements( 46 | document.querySelectorAll('[data-group-query="' + query + '"]') 47 | ) 48 | } 49 | }) 50 | -------------------------------------------------------------------------------- /examples/demojs/index.modern.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function elevateElements(elements) { 4 | const firstElement = elements[0] 5 | const root = elements[0].parentElement 6 | const firstRootElement = root.firstElementChild 7 | 8 | if (firstElement === firstRootElement) { 9 | return 10 | } 11 | 12 | const fragment = document.createDocumentFragment() 13 | 14 | elements.forEach((element) => { 15 | fragment.append(element) 16 | }) 17 | 18 | root.insertBefore(fragment, firstRootElement) 19 | } 20 | 21 | document.getElementById('useragent').innerText = navigator.userAgent 22 | 23 | document.querySelectorAll('[data-query]').forEach((input) => { 24 | const { query } = input.dataset 25 | let some = false 26 | 27 | document.querySelectorAll(`[data-for-query='${query}']`).forEach((input) => { 28 | const { regex, family } = input.dataset 29 | const checked = new RegExp(regex).test(navigator.userAgent) 30 | 31 | input.checked = checked 32 | some = some || checked 33 | 34 | if (checked) { 35 | elevateElements( 36 | document.querySelectorAll(`[data-group-family='${query} ${family}']`) 37 | ) 38 | } 39 | }) 40 | 41 | input.checked = some 42 | 43 | if (some) { 44 | elevateElements( 45 | document.querySelectorAll(`[data-group-query='${query}']`) 46 | ) 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /examples/demojs/index.old.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function forEach(elements, handler) { 4 | for (var i = 0, len = elements.length; i < len; i++) { 5 | handler(elements[i]); 6 | } 7 | } 8 | 9 | function findByAttribute(attribute, value) { 10 | var hasValue = typeof value !== 'undefined'; 11 | 12 | if (typeof document.querySelectorAll === 'function') { 13 | return document.querySelectorAll( 14 | hasValue 15 | ? '[' + attribute + '=' + value + ']' 16 | : '[' + attribute + ']' 17 | ); 18 | } 19 | 20 | var result = []; 21 | 22 | forEach(document.all, function (element) { 23 | if (!hasValue && element.hasAttribute(attribute) 24 | || hasValue && element.getAttribute(attribute) === value 25 | ) { 26 | result.push(element); 27 | } 28 | }); 29 | 30 | return result; 31 | } 32 | 33 | function elevateElements(elements) { 34 | var firstElement = elements[0] 35 | var root = elements[0].parentElement 36 | var firstRootElement = root.children[0] 37 | 38 | if (firstElement === firstRootElement) { 39 | return 40 | } 41 | 42 | var fragment = document.createDocumentFragment() 43 | 44 | forEach(elements, function (element) { 45 | fragment.appendChild(element) 46 | }) 47 | 48 | root.insertBefore(fragment, firstRootElement) 49 | } 50 | 51 | document.getElementById('useragent').innerText = navigator.userAgent; 52 | 53 | forEach(findByAttribute('data-query'), function (input) { 54 | var query = input.getAttribute('data-query'); 55 | var some = false; 56 | 57 | forEach(findByAttribute('data-for-query', query), function (input) { 58 | var regex = input.getAttribute('data-regex') 59 | var family = input.getAttribute('data-family') 60 | var checked = new RegExp(regex).test(navigator.userAgent); 61 | 62 | input.checked = checked; 63 | some = some || checked; 64 | 65 | if (checked) { 66 | elevateElements( 67 | findByAttribute('data-group-family', query + ' ' + family) 68 | ) 69 | } 70 | }); 71 | 72 | input.checked = some; 73 | 74 | if (some) { 75 | elevateElements( 76 | findByAttribute('data-group-query', query) 77 | ) 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browserslist-useragent-regexp", 3 | "type": "module", 4 | "version": "4.1.3", 5 | "description": "A utility to compile browserslist query to a RegExp to test browser useragent.", 6 | "author": "dangreen", 7 | "license": "MIT", 8 | "funding": [ 9 | "https://opencollective.com/browserslist-useragent-regexp", 10 | "https://ko-fi.com/dangreen" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/browserslist/browserslist-useragent-regexp" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/browserslist/browserslist-useragent-regexp/issues" 18 | }, 19 | "keywords": [ 20 | "browserslist", 21 | "useragent", 22 | "regexp" 23 | ], 24 | "engines": { 25 | "node": ">=14.0.0" 26 | }, 27 | "bin": { 28 | "browserslist-useragent-regexp": "./dist/cli.js", 29 | "bluare": "./dist/cli.js" 30 | }, 31 | "exports": "./src/index.ts", 32 | "publishConfig": { 33 | "types": "./dist/index.d.ts", 34 | "exports": { 35 | "types": "./dist/index.d.ts", 36 | "import": "./dist/index.js" 37 | }, 38 | "directory": "package" 39 | }, 40 | "files": [ 41 | "dist" 42 | ], 43 | "scripts": { 44 | "clear:package": "del ./package", 45 | "clear": "del ./package ./dist ./coverage", 46 | "prepublishOnly": "run test build clear:package clean-publish", 47 | "postpublish": "pnpm clear:package", 48 | "emitDeclarations": "tsc --emitDeclarationOnly", 49 | "build": "run -p [ rollup -c ] emitDeclarations", 50 | "lint": "eslint './*{js,ts}' './src/**/*.{js,ts}' './examples/**/*.{js,ts}'", 51 | "test:unit": "vitest run --coverage", 52 | "test:unit:watch": "vitest watch", 53 | "test:types": "tsc --noEmit", 54 | "test:size": "size-limit", 55 | "test": "run -p lint test:unit", 56 | "build:demo": "node examples/buildDemo > ./docs/demo.html && cp -R examples/demojs/ docs/demojs/", 57 | "build:docs": "typedoc ./src --out ./docs --excludeExternals && touch docs/.nojekyll", 58 | "commit": "cz", 59 | "bumpVersion": "standard-version", 60 | "createGithubRelease": "simple-github-release", 61 | "release": "run bumpVersion [ git push origin master --tags ] createGithubRelease", 62 | "release:beta": "run [ bumpVersion --prerelease beta ] [ git push origin master --tags ] [ createGithubRelease --prerelease ]", 63 | "updateGitHooks": "simple-git-hooks" 64 | }, 65 | "peerDependencies": { 66 | "browserslist": ">=4.0.0" 67 | }, 68 | "dependencies": { 69 | "argue-cli": "^2.1.0", 70 | "easy-table": "^1.2.0", 71 | "picocolors": "^1.0.0", 72 | "regexp-tree": "^0.1.24", 73 | "ua-regexes-lite": "^1.2.1" 74 | }, 75 | "devDependencies": { 76 | "@commitlint/cli": "^18.4.3", 77 | "@commitlint/config-conventional": "^18.4.3", 78 | "@commitlint/cz-commitlint": "^18.4.3", 79 | "@rollup/plugin-node-resolve": "^15.0.1", 80 | "@size-limit/file": "^11.0.0", 81 | "@swc/core": "^1.3.20", 82 | "@swc/helpers": "^0.5.1", 83 | "@trigen/browserslist-config": "8.0.0-alpha.30", 84 | "@trigen/eslint-config": "8.0.0-alpha.32", 85 | "@trigen/scripts": "8.0.0-alpha.30", 86 | "@types/node": "^20.10.1", 87 | "@vitest/coverage-v8": "^1.0.0", 88 | "browserslist": "^4.22.2", 89 | "clean-publish": "^4.0.1", 90 | "commitizen": "^4.2.5", 91 | "del-cli": "^5.0.0", 92 | "eslint": "^8.28.0", 93 | "nano-staged": "^0.8.0", 94 | "rollup": "^4.6.1", 95 | "rollup-plugin-add-shebang": "^0.3.1", 96 | "rollup-plugin-swc3": "^0.11.0", 97 | "simple-git-hooks": "^2.8.1", 98 | "simple-github-release": "^1.0.0", 99 | "size-limit": "^11.0.0", 100 | "standard-version": "^9.5.0", 101 | "typedoc": "^0.25.1", 102 | "typescript": "^5.1.3", 103 | "vite": "^5.0.4", 104 | "vitest": "^1.0.0" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { swc } from 'rollup-plugin-swc3' 2 | import { nodeResolve } from '@rollup/plugin-node-resolve' 3 | import nodeEsm from '@trigen/browserslist-config/node-esm' 4 | import shebang from 'rollup-plugin-add-shebang' 5 | import pkg from './package.json' assert { type: 'json' } 6 | 7 | const extensions = ['.js', '.ts'] 8 | const external = _ => /node_modules/.test(_) && !/@swc\/helpers/.test(_) 9 | const plugins = targets => [ 10 | nodeResolve({ 11 | extensions 12 | }), 13 | swc({ 14 | tsconfig: false, 15 | jsc: { 16 | parser: { 17 | syntax: 'typescript' 18 | }, 19 | externalHelpers: true 20 | }, 21 | env: { 22 | targets 23 | }, 24 | module: { 25 | type: 'es6' 26 | }, 27 | sourceMaps: true 28 | }) 29 | ] 30 | 31 | export default [ 32 | { 33 | input: pkg.exports, 34 | plugins: plugins(nodeEsm.join(', ')), 35 | external, 36 | output: { 37 | file: pkg.publishConfig.exports.import, 38 | format: 'es', 39 | sourcemap: true 40 | } 41 | }, 42 | { 43 | input: 'src/cli.ts', 44 | plugins: [...plugins(nodeEsm.join(', ')), shebang()], 45 | external: _ => !_.endsWith('src/cli.ts'), 46 | output: { 47 | file: 'dist/cli.js', 48 | format: 'es', 49 | sourcemap: true 50 | } 51 | } 52 | ] 53 | -------------------------------------------------------------------------------- /src/browsers/browserslist.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { parseBrowsersList } from './browserslist.js' 3 | 4 | describe('Browsers', () => { 5 | describe('browserslist', () => { 6 | describe('parseBrowsersList', () => { 7 | it('should correct parse browsers list', () => { 8 | const browsersList = [ 9 | 'ie 10', 10 | 'chrome 11.12', 11 | 'opera 13.14.15', 12 | 'ios_saf 14.2-14.4', 13 | 'android 4.4.2-4.4.4' 14 | ] 15 | const browsers = [ 16 | { 17 | family: 'ie', 18 | version: [ 19 | 10, 20 | 0, 21 | 0 22 | ] 23 | }, 24 | { 25 | family: 'chrome', 26 | version: [ 27 | 11, 28 | 12, 29 | 0 30 | ] 31 | }, 32 | { 33 | family: 'opera', 34 | version: [ 35 | 13, 36 | 14, 37 | 15 38 | ] 39 | }, 40 | { 41 | family: 'ios_saf', 42 | version: [ 43 | 14, 44 | 2, 45 | 0 46 | ] 47 | }, 48 | { 49 | family: 'ios_saf', 50 | version: [ 51 | 14, 52 | 3, 53 | 0 54 | ] 55 | }, 56 | { 57 | family: 'ios_saf', 58 | version: [ 59 | 14, 60 | 4, 61 | 0 62 | ] 63 | }, 64 | { 65 | family: 'android', 66 | version: [ 67 | 4, 68 | 4, 69 | 2 70 | ] 71 | }, 72 | { 73 | family: 'android', 74 | version: [ 75 | 4, 76 | 4, 77 | 3 78 | ] 79 | }, 80 | { 81 | family: 'android', 82 | version: [ 83 | 4, 84 | 4, 85 | 4 86 | ] 87 | } 88 | ] 89 | 90 | expect( 91 | parseBrowsersList(browsersList) 92 | ).toEqual( 93 | browsers 94 | ) 95 | }) 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/browsers/browserslist.ts: -------------------------------------------------------------------------------- 1 | import browserslist from 'browserslist' 2 | import { 3 | semverify, 4 | rangeSemver 5 | } from '../semver/index.js' 6 | import type { 7 | Browser, 8 | BrowserslistRequest 9 | } from './types.js' 10 | 11 | /** 12 | * Browsers strings to info objects. 13 | * @param browsersList - Browsers strings with family and version. 14 | * @returns Browser info objects. 15 | */ 16 | export function parseBrowsersList(browsersList: string[]) { 17 | return browsersList.reduce((browsers, browser) => { 18 | const [family, versionString, versionStringTo] = browser.split(/ |-/) 19 | const version = semverify(versionString) 20 | const versions = !version 21 | ? [] 22 | : versionStringTo 23 | ? rangeSemver(version, semverify(versionStringTo)) 24 | : [version] 25 | 26 | return versions.reduce((browsers, semver) => { 27 | if (semver) { 28 | browsers.push({ 29 | family, 30 | version: semver 31 | }) 32 | } 33 | 34 | return browsers 35 | }, browsers) 36 | }, []) 37 | } 38 | 39 | /** 40 | * Request browsers list. 41 | * @param options - Options to get browsers list. 42 | * @returns Browser info objects. 43 | */ 44 | export function getBrowsersList(options: BrowserslistRequest = {}) { 45 | const { 46 | browsers, 47 | ...browserslistOptions 48 | } = options 49 | const browsersList = browserslist(browsers, browserslistOptions) 50 | const parsedBrowsers = parseBrowsersList(browsersList) 51 | 52 | return parsedBrowsers 53 | } 54 | -------------------------------------------------------------------------------- /src/browsers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js' 2 | export * from './utils.js' 3 | export * from './browserslist.js' 4 | export * from './optimize.js' 5 | -------------------------------------------------------------------------------- /src/browsers/optimize.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import type { Browser } from './types.js' 3 | import type { Semver } from '../semver/types.js' 4 | import { 5 | mergeBrowserVersions, 6 | versionsListToRanges 7 | } from './optimize.js' 8 | 9 | describe('Browsers', () => { 10 | describe('optimize', () => { 11 | describe('mergeBrowserVersions', () => { 12 | it('should merge browsers versions', () => { 13 | const browsers: Browser[] = [ 14 | { 15 | family: 'firefox', 16 | version: [ 17 | 10, 18 | 0, 19 | 0 20 | ] 21 | }, 22 | { 23 | family: 'firefox', 24 | version: [ 25 | 11, 26 | 0, 27 | 0 28 | ] 29 | }, 30 | { 31 | family: 'chrome', 32 | version: [ 33 | 11, 34 | 12, 35 | 0 36 | ] 37 | } 38 | ] 39 | const mergedBrowsers = mergeBrowserVersions(browsers) 40 | 41 | expect( 42 | mergedBrowsers.get('firefox')?.length 43 | ).toBe( 44 | 2 45 | ) 46 | 47 | expect( 48 | mergedBrowsers.get('chrome')?.length 49 | ).toBe( 50 | 1 51 | ) 52 | }) 53 | }) 54 | 55 | describe('versionsListToRanges', () => { 56 | it('should collapse ranges', () => { 57 | const versions: Semver[] = [ 58 | [ 59 | 11, 60 | 0, 61 | 0 62 | ], 63 | [ 64 | 12, 65 | 0, 66 | 0 67 | ], 68 | [ 69 | 13, 70 | 0, 71 | 0 72 | ], 73 | [ 74 | 14, 75 | 1, 76 | 1 77 | ], 78 | [ 79 | 15, 80 | 1, 81 | 0 82 | ], 83 | [ 84 | 15, 85 | 2, 86 | 0 87 | ], 88 | [ 89 | 15, 90 | 3, 91 | 1 92 | ], 93 | [ 94 | 15, 95 | 3, 96 | 2 97 | ], 98 | [ 99 | 15, 100 | 3, 101 | 3 102 | ] 103 | ] 104 | const ranges = [ 105 | [ 106 | [11, 13], 107 | 0, 108 | 0 109 | ], 110 | [ 111 | 14, 112 | 1, 113 | 1 114 | ], 115 | [ 116 | 15, 117 | [1, 2], 118 | 0 119 | ], 120 | [ 121 | 15, 122 | 3, 123 | [1, 3] 124 | ] 125 | ] 126 | 127 | expect( 128 | versionsListToRanges(versions) 129 | ).toEqual( 130 | ranges 131 | ) 132 | 133 | expect( 134 | versionsListToRanges([ 135 | [ 136 | 10, 137 | 0, 138 | 0 139 | ] 140 | ]) 141 | ).toEqual([ 142 | [ 143 | 10, 144 | 0, 145 | 0 146 | ] 147 | ]) 148 | 149 | expect( 150 | versionsListToRanges([ 151 | [ 152 | 10, 153 | 0, 154 | 0 155 | ], 156 | [ 157 | 14, 158 | 0, 159 | 0 160 | ] 161 | ]) 162 | ).toEqual([ 163 | [ 164 | 10, 165 | 0, 166 | 0 167 | ], 168 | [ 169 | 14, 170 | 0, 171 | 0 172 | ] 173 | ]) 174 | 175 | expect( 176 | versionsListToRanges([ 177 | [ 178 | 10, 179 | 0, 180 | 0 181 | ], 182 | [ 183 | 14, 184 | 1, 185 | 0 186 | ] 187 | ]) 188 | ).toEqual([ 189 | [ 190 | 10, 191 | 0, 192 | 0 193 | ], 194 | [ 195 | 14, 196 | 1, 197 | 0 198 | ] 199 | ]) 200 | 201 | expect( 202 | versionsListToRanges([ 203 | [ 204 | 4, 205 | 0, 206 | 0 207 | ], 208 | [ 209 | 10, 210 | 1, 211 | 0 212 | ], 213 | [ 214 | 11, 215 | 1, 216 | 0 217 | ] 218 | ]) 219 | ).toEqual([ 220 | [ 221 | 4, 222 | 0, 223 | 0 224 | ], 225 | [ 226 | [10, 11], 227 | 1, 228 | 0 229 | ] 230 | ]) 231 | 232 | expect( 233 | versionsListToRanges([ 234 | [ 235 | 10, 236 | 1, 237 | 0 238 | ], 239 | [ 240 | 11, 241 | 1, 242 | 0 243 | ], 244 | [ 245 | 15, 246 | 0, 247 | 0 248 | ], 249 | [ 250 | 20, 251 | 0, 252 | 1 253 | ], 254 | [ 255 | 21, 256 | 0, 257 | 1 258 | ] 259 | ]) 260 | ).toEqual([ 261 | [ 262 | [10, 11], 263 | 1, 264 | 0 265 | ], 266 | [ 267 | 15, 268 | 0, 269 | 0 270 | ], 271 | [ 272 | [20, 21], 273 | 0, 274 | 1 275 | ] 276 | ]) 277 | 278 | expect( 279 | versionsListToRanges([ 280 | [ 281 | 15, 282 | 1, 283 | 0 284 | ], 285 | [ 286 | 15, 287 | 2, 288 | 1 289 | ], 290 | [ 291 | 15, 292 | 3, 293 | 2 294 | ] 295 | ]) 296 | ).toEqual([ 297 | [ 298 | 15, 299 | 1, 300 | 0 301 | ], 302 | [ 303 | 15, 304 | 2, 305 | 1 306 | ], 307 | [ 308 | 15, 309 | 3, 310 | 2 311 | ] 312 | ]) 313 | 314 | expect( 315 | versionsListToRanges([ 316 | [ 317 | 15, 318 | 1, 319 | 0 320 | ], 321 | [ 322 | 15, 323 | 2, 324 | 0 325 | ] 326 | ]) 327 | ).toEqual([ 328 | [ 329 | 15, 330 | [1, 2], 331 | 0 332 | ] 333 | ]) 334 | 335 | expect( 336 | versionsListToRanges([ 337 | [ 338 | 4, 339 | 0, 340 | 0 341 | ], 342 | [ 343 | 5, 344 | 0, 345 | 0 346 | ], 347 | [ 348 | 5, 349 | 1, 350 | 0 351 | ] 352 | ]) 353 | ).toEqual([ 354 | [ 355 | [4, 5], 356 | 0, 357 | 0 358 | ], 359 | [ 360 | 5, 361 | 1, 362 | 0 363 | ] 364 | ]) 365 | 366 | expect( 367 | versionsListToRanges([ 368 | [ 369 | 4, 370 | 1, 371 | 0 372 | ], 373 | [ 374 | 4, 375 | 1, 376 | 1 377 | ], 378 | [ 379 | 4, 380 | 2, 381 | 0 382 | ] 383 | ]) 384 | ).toEqual([ 385 | [ 386 | 4, 387 | 1, 388 | [0, 1] 389 | ], 390 | [ 391 | 4, 392 | 2, 393 | 0 394 | ] 395 | ]) 396 | 397 | expect( 398 | versionsListToRanges([ 399 | [ 400 | 4, 401 | 1, 402 | 0 403 | ], 404 | [ 405 | 4, 406 | 1, 407 | 1 408 | ], 409 | [ 410 | 4, 411 | 1, 412 | 3 413 | ] 414 | ]) 415 | ).toEqual([ 416 | [ 417 | 4, 418 | 1, 419 | [0, 1] 420 | ], 421 | [ 422 | 4, 423 | 1, 424 | 3 425 | ] 426 | ]) 427 | 428 | expect( 429 | versionsListToRanges([ 430 | [ 431 | 4, 432 | 1, 433 | 0 434 | ], 435 | [ 436 | 4, 437 | 1, 438 | 1 439 | ], 440 | [ 441 | 4, 442 | 2, 443 | 1 444 | ] 445 | ]) 446 | ).toEqual([ 447 | [ 448 | 4, 449 | 1, 450 | [0, 1] 451 | ], 452 | [ 453 | 4, 454 | 2, 455 | 1 456 | ] 457 | ]) 458 | 459 | expect( 460 | versionsListToRanges([ 461 | [ 462 | 4, 463 | 1, 464 | 0 465 | ], 466 | [ 467 | 4, 468 | 1, 469 | 1 470 | ], 471 | [ 472 | 4, 473 | 2, 474 | 2 475 | ] 476 | ]) 477 | ).toEqual([ 478 | [ 479 | 4, 480 | 1, 481 | [0, 1] 482 | ], 483 | [ 484 | 4, 485 | 2, 486 | 2 487 | ] 488 | ]) 489 | }) 490 | 491 | it('should do not touch single versions', () => { 492 | expect( 493 | versionsListToRanges([ 494 | [ 495 | 10, 496 | 0, 497 | 0 498 | ] 499 | ]) 500 | ).toEqual([ 501 | [ 502 | 10, 503 | 0, 504 | 0 505 | ] 506 | ]) 507 | 508 | expect( 509 | versionsListToRanges([ 510 | [ 511 | 10, 512 | 0, 513 | 0 514 | ], 515 | [ 516 | 14, 517 | 0, 518 | 0 519 | ] 520 | ]) 521 | ).toEqual([ 522 | [ 523 | 10, 524 | 0, 525 | 0 526 | ], 527 | [ 528 | 14, 529 | 0, 530 | 0 531 | ] 532 | ]) 533 | }) 534 | }) 535 | }) 536 | }) 537 | -------------------------------------------------------------------------------- /src/browsers/optimize.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Semver, 3 | RangedSemver 4 | } from '../semver/index.js' 5 | import { SemverPart } from '../semver/index.js' 6 | import { compareArrays } from '../utils/index.js' 7 | import type { 8 | Browser, 9 | BrowsersVersions 10 | } from './types.js' 11 | import { numbersToRanges } from './utils.js' 12 | 13 | /** 14 | * Merge browser info object to map with versions. 15 | * @param browsers - Browser info object to merge. 16 | * @returns Merged browsers map. 17 | */ 18 | export function mergeBrowserVersions(browsers: Browser[]) { 19 | const merge: BrowsersVersions = new Map() 20 | 21 | browsers.forEach(({ 22 | family, 23 | version 24 | }) => { 25 | const versions = merge.get(family) 26 | 27 | if (versions) { 28 | const strVersion = version.join('.') 29 | 30 | if (versions.every(_ => _.join('.') !== strVersion)) { 31 | versions.push(version) 32 | } 33 | 34 | return 35 | } 36 | 37 | merge.set(family, [version]) 38 | }) 39 | 40 | merge.forEach((versions) => { 41 | versions.sort((a, b) => { 42 | for (const i in a) { 43 | if (a[i] !== b[i]) { 44 | return a[i] - b[i] 45 | } 46 | } 47 | 48 | return 0 49 | }) 50 | }) 51 | 52 | return merge 53 | } 54 | 55 | /** 56 | * Versions to ranged versions. 57 | * @param versions - Semver versions list. 58 | * @returns Ranged versions list. 59 | */ 60 | export function versionsListToRanges(versions: Semver[]) { 61 | if (versions.length < 2) { 62 | return versions 63 | } 64 | 65 | const max = versions.length + 1 66 | const ranges: RangedSemver[] = [] 67 | let prev: number[] = null 68 | let current: number[] = versions[0] 69 | let major: number | number[] = [current[SemverPart.Major]] 70 | let minor: number | number[] = [current[SemverPart.Minor]] 71 | let patch: number | number[] = [current[SemverPart.Patch]] 72 | let part: SemverPart = null 73 | 74 | for (let i = 1; i < max; i++) { 75 | prev = versions[i - 1] 76 | current = versions[i] || [] 77 | 78 | for (let p = SemverPart.Major; p <= SemverPart.Patch; p++) { 79 | if ((p === part || part === null) 80 | && prev[p] + 1 === current[p] 81 | && compareArrays(prev, current, p + 1) 82 | ) { 83 | part = p 84 | 85 | if (p === SemverPart.Major) { 86 | (major as number[]).push(current[SemverPart.Major]) 87 | } else { 88 | major = current[SemverPart.Major] 89 | } 90 | 91 | if (p === SemverPart.Minor) { 92 | (minor as number[]).push(current[SemverPart.Minor]) 93 | } else { 94 | minor = current[SemverPart.Minor] 95 | } 96 | 97 | if (p === SemverPart.Patch) { 98 | (patch as number[]).push(current[SemverPart.Patch]) 99 | } else { 100 | patch = current[SemverPart.Patch] 101 | } 102 | 103 | break 104 | } 105 | 106 | if (part === p || prev[p] !== current[p]) { 107 | ranges.push([ 108 | numbersToRanges(major), 109 | numbersToRanges(minor), 110 | numbersToRanges(patch) 111 | ]) 112 | major = [current[SemverPart.Major]] 113 | minor = [current[SemverPart.Minor]] 114 | patch = [current[SemverPart.Patch]] 115 | part = null 116 | break 117 | } 118 | } 119 | } 120 | 121 | return ranges 122 | } 123 | -------------------------------------------------------------------------------- /src/browsers/types.ts: -------------------------------------------------------------------------------- 1 | import type browserslist from 'browserslist' 2 | import type { Options } from 'browserslist' 3 | import type { 4 | Semver, 5 | RangedSemver 6 | } from '../semver/types.js' 7 | 8 | export interface Browser { 9 | family: string 10 | version: Semver 11 | } 12 | 13 | export interface BrowserslistRequest extends Options { 14 | browsers?: Parameters[0] 15 | } 16 | 17 | export type BrowsersVersions = Map 18 | 19 | export type RangedBrowsersVersions = Map 20 | -------------------------------------------------------------------------------- /src/browsers/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { numbersToRanges } from './utils.js' 3 | 4 | describe('Browsers', () => { 5 | describe('utils', () => { 6 | describe('numbersToRanges', () => { 7 | it('should get first and last elements form array', () => { 8 | expect( 9 | numbersToRanges([ 10 | 5, 11 | 6, 12 | 7 13 | ]) 14 | ).toEqual( 15 | [5, 7] 16 | ) 17 | 18 | expect( 19 | numbersToRanges([5, 6]) 20 | ).toEqual( 21 | [5, 6] 22 | ) 23 | }) 24 | 25 | it('should get first element form one-element-array', () => { 26 | expect( 27 | numbersToRanges([8]) 28 | ).toBe( 29 | 8 30 | ) 31 | }) 32 | 33 | it('should return number', () => { 34 | expect( 35 | numbersToRanges(10) 36 | ).toBe( 37 | 10 38 | ) 39 | }) 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/browsers/utils.ts: -------------------------------------------------------------------------------- 1 | import type { SemverRange } from '../semver/index.js' 2 | 3 | /** 4 | * Array of numbers to array of first and last elements. 5 | * @param numbers - Array of numbers. 6 | * @returns Number or two numbers. 7 | */ 8 | export function numbersToRanges(numbers: number | number[]): SemverRange { 9 | if (typeof numbers === 'number') { 10 | return numbers 11 | } 12 | 13 | if (numbers.length === 1) { 14 | return numbers[0] 15 | } 16 | 17 | return [numbers[0], numbers[numbers.length - 1]] 18 | } 19 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { 3 | argv, 4 | read, 5 | end, 6 | alias, 7 | option, 8 | readOptions 9 | } from 'argue-cli' 10 | import colors from 'picocolors' 11 | import Table from 'easy-table' 12 | import type { Semver } from './index.js' 13 | import { 14 | getUserAgentRegex, 15 | getBrowsersList, 16 | mergeBrowserVersions, 17 | getRegexesForBrowsers, 18 | applyVersionsToRegexes, 19 | defaultOptions, 20 | compileRegexes, 21 | compileRegex 22 | } from './index.js' 23 | 24 | function versionsToString(versions: Semver[]) { 25 | return versions.map(_ => ( 26 | _[0] === Infinity 27 | ? 'all' 28 | : _.join('.') 29 | )).join(' ') 30 | } 31 | 32 | const { 33 | help, 34 | verbose, 35 | ...regexOptions 36 | } = readOptions( 37 | option(alias('help', 'h'), Boolean), 38 | option(alias('verbose', 'v'), Boolean), 39 | option('ignorePatch', Boolean), 40 | option('ignoreMinor', Boolean), 41 | option('allowHigherVersions', Boolean), 42 | option('allowZeroSubversions', Boolean) 43 | ) 44 | 45 | if (help) { 46 | end() 47 | 48 | const optionsTable = new Table() 49 | 50 | optionsTable.cell('Option', 'query') 51 | optionsTable.cell( 52 | 'Description', 53 | 'Manually provide a browserslist query.' 54 | + ' Specifying this overrides the browserslist configuration specified in your project.' 55 | ) 56 | optionsTable.newRow() 57 | 58 | optionsTable.cell('Option', '--help, -h') 59 | optionsTable.cell('Description', 'Print this message.') 60 | optionsTable.newRow() 61 | 62 | optionsTable.cell('Option', '--verbose, -v') 63 | optionsTable.cell('Description', 'Print additional info about regexes.') 64 | optionsTable.newRow() 65 | 66 | optionsTable.cell('Option', '--ignorePatch') 67 | optionsTable.cell('Description', 'Ignore differences in patch browser numbers.') 68 | optionsTable.cell('Default', 'true') 69 | optionsTable.newRow() 70 | 71 | optionsTable.cell('Option', '--ignoreMinor') 72 | optionsTable.cell('Description', 'Ignore differences in minor browser versions.') 73 | optionsTable.cell('Default', 'false') 74 | optionsTable.newRow() 75 | 76 | optionsTable.cell('Option', '--allowHigherVersions') 77 | optionsTable.cell( 78 | 'Description', 79 | 'For all the browsers in the browserslist query,' 80 | + ' return a match if the user agent version is equal to or higher than the one specified in browserslist.' 81 | ) 82 | optionsTable.cell('Default', 'false') 83 | optionsTable.newRow() 84 | 85 | optionsTable.cell('Option', '--allowZeroSubversions') 86 | optionsTable.cell('Description', 'Ignore match of patch or patch and minor, if they are 0.') 87 | optionsTable.cell('Default', 'false') 88 | optionsTable.newRow() 89 | 90 | console.log(`\nbrowserslist-useragent-regexp [query] [...options]\n\n${optionsTable.toString()}`) 91 | process.exit(0) 92 | } 93 | 94 | const query = argv.length 95 | ? read() 96 | : undefined 97 | const options = { 98 | browsers: query, 99 | ...defaultOptions, 100 | ...regexOptions 101 | } 102 | 103 | end() 104 | 105 | if (verbose) { 106 | const browsersList = getBrowsersList(options) 107 | const mergedBrowsers = mergeBrowserVersions(browsersList) 108 | 109 | console.log( 110 | colors.blue('\n> Browserslist\n') 111 | ) 112 | 113 | const browsersTable = new Table() 114 | 115 | mergedBrowsers.forEach((versions, browser) => { 116 | browsersTable.cell('Browser', colors.yellow(browser)) 117 | 118 | versions.forEach((version, i) => { 119 | if (version[0] === Infinity) { 120 | browsersTable.cell(`Version ${i}`, 'all') 121 | } else { 122 | browsersTable.cell(`Version ${i}`, version.join('.')) 123 | } 124 | }) 125 | 126 | browsersTable.newRow() 127 | }) 128 | 129 | console.log(browsersTable.print()) 130 | 131 | const sourceRegexes = getRegexesForBrowsers(mergedBrowsers, options) 132 | const versionedRegexes = applyVersionsToRegexes(sourceRegexes, options) 133 | const regexes = compileRegexes(versionedRegexes) 134 | const regexesTable = new Table() 135 | 136 | console.log( 137 | colors.blue('\n> Regexes\n') 138 | ) 139 | 140 | regexes.forEach(({ 141 | family, 142 | requestVersions, 143 | matchedVersions, 144 | sourceRegex, 145 | version, 146 | minVersion, 147 | maxVersion, 148 | regex 149 | }, i) => { 150 | if (i > 0) { 151 | regexesTable.cell('', '') 152 | regexesTable.newRow() 153 | } 154 | 155 | const requestVersionsString = versionsToString(requestVersions) 156 | const matchedVersionsString = versionsToString(matchedVersions) 157 | 158 | regexesTable.cell('Name', colors.yellow('Family:')) 159 | regexesTable.cell('Value', family) 160 | regexesTable.newRow() 161 | 162 | regexesTable.cell('Name', colors.yellow('Versions:')) 163 | regexesTable.cell('Value', requestVersionsString) 164 | regexesTable.newRow() 165 | 166 | regexesTable.cell('Name', colors.yellow('Matched versions:')) 167 | regexesTable.cell('Value', matchedVersionsString) 168 | regexesTable.newRow() 169 | 170 | regexesTable.cell('Name', colors.yellow('Source regex:')) 171 | regexesTable.cell('Value', sourceRegex) 172 | regexesTable.newRow() 173 | 174 | if (version) { 175 | regexesTable.cell('Name', colors.yellow('Source regex fixed browser version:')) 176 | regexesTable.cell('Value', version.join('.')) 177 | regexesTable.newRow() 178 | } else { 179 | let regexBrowsersVersion = '' 180 | 181 | if (minVersion) { 182 | regexBrowsersVersion = minVersion.filter(isFinite).join('.') 183 | } else { 184 | regexBrowsersVersion = '...' 185 | } 186 | 187 | regexBrowsersVersion += ' - ' 188 | 189 | if (maxVersion) { 190 | regexBrowsersVersion += maxVersion.filter(isFinite).join('.') 191 | } else { 192 | regexBrowsersVersion += '...' 193 | } 194 | 195 | regexesTable.cell('Name', colors.yellow('Source regex browsers versions:')) 196 | regexesTable.cell('Value', regexBrowsersVersion) 197 | regexesTable.newRow() 198 | } 199 | 200 | regexesTable.cell('Name', colors.yellow('Versioned regex:')) 201 | regexesTable.cell('Value', regex) 202 | regexesTable.newRow() 203 | }) 204 | 205 | console.log(`${regexesTable.print()}\n`) 206 | 207 | const regex = compileRegex(regexes) 208 | 209 | console.log(regex) 210 | process.exit(0) 211 | } 212 | 213 | console.log( 214 | getUserAgentRegex(options) 215 | ) 216 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browsers/index.js' 2 | export * from './numbers/index.js' 3 | export * from './regex/index.js' 4 | export * from './semver/index.js' 5 | export * from './useragent/index.js' 6 | export * from './useragentRegex/index.js' 7 | export * from './utils/index.js' 8 | export * from './versions/index.js' 9 | -------------------------------------------------------------------------------- /src/numbers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './range.js' 2 | export * from './ray.js' 3 | export * from './segment.js' 4 | export * from './utils.js' 5 | -------------------------------------------------------------------------------- /src/numbers/range.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { toString } from '../regex/index.js' 3 | import { rangeToRegex } from './range.js' 4 | 5 | describe('Numbers', () => { 6 | describe('range', () => { 7 | describe('rangeToRegex', () => { 8 | it('should return number pattern for Infinity (\'all\') version', () => { 9 | expect( 10 | toString(rangeToRegex(Infinity)) 11 | ).toBe( 12 | '\\d+' 13 | ) 14 | }) 15 | 16 | it('should return ray pattern', () => { 17 | expect( 18 | toString(rangeToRegex(6)) 19 | ).toBe( 20 | '([6-9]|\\d{2,})' 21 | ) 22 | }) 23 | 24 | it('should return segment pattern', () => { 25 | expect( 26 | toString(rangeToRegex(6, 8)) 27 | ).toBe( 28 | '[6-8]' 29 | ) 30 | }) 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/numbers/range.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DisjunctionCapturingGroupNode, 3 | NumberPatternNode 4 | } from '../regex/index.js' 5 | import { rayToNumberPatterns } from './ray.js' 6 | import { segmentToNumberPatterns } from './segment.js' 7 | 8 | /** 9 | * Get regex for given numeric range. 10 | * @param from - Range start. 11 | * @param to - Range end. 12 | * @returns Range pattern. 13 | */ 14 | export function rangeToRegex(from: number, to = Infinity) { 15 | if (from === Infinity) { 16 | return NumberPatternNode() 17 | } 18 | 19 | const numberPatterns = to === Infinity 20 | ? rayToNumberPatterns(from) 21 | : segmentToNumberPatterns(from, to) 22 | const regex = DisjunctionCapturingGroupNode(numberPatterns) 23 | 24 | return regex 25 | } 26 | -------------------------------------------------------------------------------- /src/numbers/ray.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { toString } from '../regex/index.js' 3 | import { 4 | rayRangeDigitPattern, 5 | rayToNumberPatterns 6 | } from './ray.js' 7 | 8 | describe('Numbers', () => { 9 | describe('ray', () => { 10 | describe('rayRangeNumberPattern', () => { 11 | it('should return digit pattern', () => { 12 | expect( 13 | toString(rayRangeDigitPattern(0, true)) 14 | ).toBe( 15 | '\\d' 16 | ) 17 | }) 18 | 19 | it('should return max digit', () => { 20 | expect( 21 | toString(rayRangeDigitPattern(9, true)) 22 | ).toBe( 23 | '9' 24 | ) 25 | }) 26 | 27 | it('should return digits range', () => { 28 | expect( 29 | toString(rayRangeDigitPattern(1, true)) 30 | ).toBe( 31 | '[1-9]' 32 | ) 33 | }) 34 | 35 | it('should start from next digit', () => { 36 | expect( 37 | toString(rayRangeDigitPattern(1, false)) 38 | ).toBe( 39 | '[2-9]' 40 | ) 41 | }) 42 | 43 | it('should not return more than 9', () => { 44 | expect( 45 | toString(rayRangeDigitPattern(9, false)) 46 | ).toBe( 47 | '' 48 | ) 49 | }) 50 | }) 51 | 52 | describe('rayToNumberPatterns', () => { 53 | it('should return correct ray pattern', () => { 54 | expect( 55 | rayToNumberPatterns(0).map(toString) 56 | ).toEqual(['\\d+']) 57 | 58 | expect( 59 | rayToNumberPatterns(1).map(toString) 60 | ).toEqual(['[1-9]', '\\d{2,}']) 61 | 62 | expect( 63 | rayToNumberPatterns(9).map(toString) 64 | ).toEqual(['9', '\\d{2,}']) 65 | 66 | expect( 67 | rayToNumberPatterns(10).map(toString) 68 | ).toEqual(['[1-9]\\d', '\\d{3,}']) 69 | 70 | expect( 71 | rayToNumberPatterns(11).map(toString) 72 | ).toEqual([ 73 | '1[1-9]', 74 | '[2-9]\\d', 75 | '\\d{3,}' 76 | ]) 77 | 78 | expect( 79 | rayToNumberPatterns(19).map(toString) 80 | ).toEqual([ 81 | '19', 82 | '[2-9]\\d', 83 | '\\d{3,}' 84 | ]) 85 | 86 | expect( 87 | rayToNumberPatterns(20).map(toString) 88 | ).toEqual(['[2-9]\\d', '\\d{3,}']) 89 | 90 | expect( 91 | rayToNumberPatterns(21).map(toString) 92 | ).toEqual([ 93 | '2[1-9]', 94 | '[3-9]\\d', 95 | '\\d{3,}' 96 | ]) 97 | 98 | expect( 99 | rayToNumberPatterns(29).map(toString) 100 | ).toEqual([ 101 | '29', 102 | '[3-9]\\d', 103 | '\\d{3,}' 104 | ]) 105 | 106 | expect( 107 | rayToNumberPatterns(99).map(toString) 108 | ).toEqual(['99', '\\d{3,}']) 109 | 110 | expect( 111 | rayToNumberPatterns(100).map(toString) 112 | ).toEqual(['[1-9]\\d\\d', '\\d{4,}']) 113 | 114 | expect( 115 | rayToNumberPatterns(101).map(toString) 116 | ).toEqual([ 117 | '10[1-9]', 118 | '1[1-9]\\d', 119 | '[2-9]\\d\\d', 120 | '\\d{4,}' 121 | ]) 122 | 123 | expect( 124 | rayToNumberPatterns(111).map(toString) 125 | ).toEqual([ 126 | '11[1-9]', 127 | '1[2-9]\\d', 128 | '[2-9]\\d\\d', 129 | '\\d{4,}' 130 | ]) 131 | 132 | expect( 133 | rayToNumberPatterns(199).map(toString) 134 | ).toEqual([ 135 | '199', 136 | '[2-9]\\d\\d', 137 | '\\d{4,}' 138 | ]) 139 | 140 | expect( 141 | rayToNumberPatterns(200).map(toString) 142 | ).toEqual(['[2-9]\\d\\d', '\\d{4,}']) 143 | 144 | expect( 145 | rayToNumberPatterns(299).map(toString) 146 | ).toEqual([ 147 | '299', 148 | '[3-9]\\d\\d', 149 | '\\d{4,}' 150 | ]) 151 | 152 | expect( 153 | rayToNumberPatterns(999).map(toString) 154 | ).toEqual(['999', '\\d{4,}']) 155 | 156 | expect( 157 | rayToNumberPatterns(1000).map(toString) 158 | ).toEqual(['[1-9]\\d\\d\\d', '\\d{5,}']) 159 | 160 | expect( 161 | rayToNumberPatterns(56).map(toString) 162 | ).toEqual([ 163 | '5[6-9]', 164 | '[6-9]\\d', 165 | '\\d{3,}' 166 | ]) 167 | }) 168 | }) 169 | }) 170 | }) 171 | -------------------------------------------------------------------------------- /src/numbers/ray.ts: -------------------------------------------------------------------------------- 1 | import type { Expression } from 'regexp-tree/ast' 2 | import { 3 | SimpleCharNode, 4 | CharacterClassNode, 5 | ClassRangeNode, 6 | RangeQuantifierNode, 7 | AlternativeNode, 8 | DigitPatternNode, 9 | NumberPatternNode 10 | } from '../regex/index.js' 11 | import { numberToDigits } from './utils.js' 12 | 13 | /** 14 | * Get digit pattern. 15 | * @param digit - Ray start. 16 | * @param includes - Include start digit or use next. 17 | * @returns Digit pattern. 18 | */ 19 | export function rayRangeDigitPattern(digit: number, includes: boolean) { 20 | const rangeStart = digit + Number(!includes) 21 | 22 | if (rangeStart === 0) { 23 | return DigitPatternNode() 24 | } 25 | 26 | if (rangeStart === 9) { 27 | return SimpleCharNode('9') 28 | } 29 | 30 | if (rangeStart > 9) { 31 | return null 32 | } 33 | 34 | return CharacterClassNode( 35 | ClassRangeNode( 36 | SimpleCharNode(rangeStart), 37 | SimpleCharNode('9') 38 | ) 39 | ) 40 | } 41 | 42 | /** 43 | * Create numeric ray pattern. 44 | * @param from - Start from this number. 45 | * @returns Numeric ray pattern parts. 46 | */ 47 | export function rayToNumberPatterns(from: number) { 48 | if (from === 0) { 49 | return [NumberPatternNode()] 50 | } 51 | 52 | const digits = numberToDigits(from) 53 | const digitsCount = digits.length 54 | const other = NumberPatternNode( 55 | RangeQuantifierNode(digitsCount + 1) 56 | ) 57 | const zeros = digitsCount - 1 58 | 59 | if (from / Math.pow(10, zeros) === digits[0]) { 60 | return [ 61 | AlternativeNode( 62 | rayRangeDigitPattern(digits[0], true), 63 | Array.from({ 64 | length: zeros 65 | }, DigitPatternNode) 66 | ), 67 | other 68 | ] 69 | } 70 | 71 | const raysNumberPatterns = digits.reduce((topNodes, _, i) => { 72 | const ri = digitsCount - i - 1 73 | const d = i === 0 74 | let prev: Expression = SimpleCharNode('') 75 | const nodes = digits.reduce((nodes, digit, j) => { 76 | if (j < ri) { 77 | nodes.push(SimpleCharNode(digit)) 78 | } else 79 | if (prev) { 80 | if (j > ri) { 81 | nodes.push(DigitPatternNode()) 82 | } else { 83 | prev = rayRangeDigitPattern(digit, d) 84 | 85 | if (prev) { 86 | nodes.push(prev) 87 | } else { 88 | return [] 89 | } 90 | } 91 | } 92 | 93 | return nodes 94 | }, []) 95 | 96 | if (nodes.length) { 97 | topNodes.push(nodes) 98 | } 99 | 100 | return topNodes 101 | }, []) 102 | const numberPatterns: Expression[] = raysNumberPatterns.map(_ => AlternativeNode(_)) 103 | 104 | numberPatterns.push(other) 105 | 106 | return numberPatterns 107 | } 108 | -------------------------------------------------------------------------------- /src/numbers/segment.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { toString } from '../regex/index.js' 3 | import { 4 | segmentRangeNumberPattern, 5 | splitToDecadeRanges, 6 | splitCommonDiff, 7 | segmentToNumberPatterns 8 | } from './segment.js' 9 | 10 | describe('Numbers', () => { 11 | describe('segment', () => { 12 | describe('segmentRangeNumberPattern', () => { 13 | it('should return digit pattern', () => { 14 | expect( 15 | toString(segmentRangeNumberPattern(0, 9)) 16 | ).toBe( 17 | '\\d' 18 | ) 19 | }) 20 | 21 | it('should return digit', () => { 22 | expect( 23 | toString(segmentRangeNumberPattern(7, 7)) 24 | ).toBe( 25 | '7' 26 | ) 27 | }) 28 | 29 | it('should return digit enum', () => { 30 | expect( 31 | toString(segmentRangeNumberPattern(7, 8)) 32 | ).toBe( 33 | '[78]' 34 | ) 35 | }) 36 | 37 | it('should return digit range', () => { 38 | expect( 39 | toString(segmentRangeNumberPattern(1, 8)) 40 | ).toBe( 41 | '[1-8]' 42 | ) 43 | }) 44 | 45 | it('should not return invalid range', () => { 46 | expect( 47 | toString(segmentRangeNumberPattern(9, 6)) 48 | ).toBe( 49 | '' 50 | ) 51 | }) 52 | 53 | it('should add zeros', () => { 54 | expect( 55 | toString(segmentRangeNumberPattern(3, 4, 3)) 56 | ).toBe( 57 | '000[34]' 58 | ) 59 | }) 60 | }) 61 | 62 | describe('splitToDecadeRanges', () => { 63 | it('should split to decades', () => { 64 | expect( 65 | splitToDecadeRanges(0, 9) 66 | ).toEqual([[0, 9]]) 67 | 68 | expect( 69 | splitToDecadeRanges(0, 10) 70 | ).toEqual([[0, 9], [10, 10]]) 71 | 72 | expect( 73 | splitToDecadeRanges(0, 99) 74 | ).toEqual([[0, 9], [10, 99]]) 75 | 76 | expect( 77 | splitToDecadeRanges(0, 199) 78 | ).toEqual([ 79 | [0, 9], 80 | [10, 99], 81 | [100, 199] 82 | ]) 83 | }) 84 | }) 85 | 86 | describe('splitCommonDiff', () => { 87 | it('should not get common and diff', () => { 88 | expect( 89 | splitCommonDiff([1, 2], [3]) 90 | ).toEqual( 91 | null 92 | ) 93 | 94 | expect( 95 | splitCommonDiff([1], [3]) 96 | ).toEqual( 97 | null 98 | ) 99 | }) 100 | 101 | it('should get common and diff', () => { 102 | expect( 103 | splitCommonDiff([1, 2], [1, 3]) 104 | ).toEqual([ 105 | '1', 106 | 2, 107 | 3 108 | ]) 109 | 110 | expect( 111 | splitCommonDiff([ 112 | 1, 113 | 6, 114 | 7, 115 | 1 116 | ], [ 117 | 1, 118 | 6, 119 | 1, 120 | 2 121 | ]) 122 | ).toEqual([ 123 | '16', 124 | 71, 125 | 12 126 | ]) 127 | }) 128 | 129 | it('should work correctly, if there are common digits after first difference', () => { 130 | expect( 131 | splitCommonDiff([1, 0, 3], [1, 2, 3]) 132 | ).toEqual(['1', 3, 23]) 133 | 134 | expect( 135 | splitCommonDiff([1, 2, 5, 2], [1, 4, 5, 3]) 136 | ).toEqual(['1', 252, 453]) 137 | }) 138 | }) 139 | 140 | describe('segmentToNumberPatterns', () => { 141 | it('should return digit range', () => { 142 | expect( 143 | segmentToNumberPatterns(0, 7).map(toString) 144 | ).toEqual( 145 | ['[0-7]'] 146 | ) 147 | }) 148 | 149 | it('should return digit pattern', () => { 150 | expect( 151 | segmentToNumberPatterns(7, 7).map(toString) 152 | ).toEqual( 153 | ['7'] 154 | ) 155 | }) 156 | 157 | it('should return digit enum', () => { 158 | expect( 159 | segmentToNumberPatterns(7, 8).map(toString) 160 | ).toEqual( 161 | ['[78]'] 162 | ) 163 | }) 164 | 165 | it('should correct handle range', () => { 166 | expect( 167 | segmentToNumberPatterns(11, 13).map(toString) 168 | ).toEqual( 169 | ['1[1-3]'] 170 | ) 171 | 172 | expect( 173 | segmentToNumberPatterns(32, 65).map(toString) 174 | ).toEqual( 175 | [ 176 | '3[2-9]', 177 | '[45]\\d', 178 | '6[0-5]' 179 | ] 180 | ) 181 | 182 | expect( 183 | segmentToNumberPatterns(32, 99).map(toString) 184 | ).toEqual( 185 | [ 186 | '3[2-9]', 187 | '[4-9]\\d' 188 | ] 189 | ) 190 | }) 191 | 192 | it('should correct handle big range', () => { 193 | expect( 194 | segmentToNumberPatterns(32, 256).map(toString) 195 | ).toEqual([ 196 | '3[2-9]', 197 | '[4-9]\\d', 198 | '1[0-9]\\d', 199 | '25[0-6]', 200 | '2[0-4]\\d' 201 | ]) 202 | }) 203 | }) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /src/numbers/segment.ts: -------------------------------------------------------------------------------- 1 | import type { Expression } from 'regexp-tree/ast' 2 | import { concat } from '../utils/index.js' 3 | import { 4 | AlternativeNode, 5 | SimpleCharNode, 6 | CharacterClassNode, 7 | ClassRangeNode, 8 | DisjunctionCapturingGroupNode, 9 | DigitPatternNode, 10 | optimizeSegmentNumberPatterns 11 | } from '../regex/index.js' 12 | import { numberToDigits } from './utils.js' 13 | 14 | /** 15 | * Get digit pattern. 16 | * @param from - Segment start. 17 | * @param to - Segment end. 18 | * @param zeros - Zeros to add as prefix. 19 | * @returns Digit pattern. 20 | */ 21 | export function segmentRangeNumberPattern(from: number, to: number, zeros?: number) { 22 | if (to < from) { 23 | return null 24 | } 25 | 26 | const fromNode = SimpleCharNode(from) 27 | const toNode = SimpleCharNode(to) 28 | const zerosPrefix = typeof zeros === 'number' && zeros > 0 29 | ? Array.from({ 30 | length: zeros 31 | }, () => SimpleCharNode(0)) 32 | : [] 33 | const addPrefix = zerosPrefix.length 34 | ? (node: Expression) => AlternativeNode(zerosPrefix, node) 35 | : (node: Expression) => node 36 | 37 | if (from === to) { 38 | return addPrefix(fromNode) 39 | } 40 | 41 | if (from === 0 && to === 9) { 42 | return addPrefix(DigitPatternNode()) 43 | } 44 | 45 | if (to - from === 1) { 46 | return addPrefix(CharacterClassNode( 47 | fromNode, 48 | toNode 49 | )) 50 | } 51 | 52 | return addPrefix(CharacterClassNode( 53 | ClassRangeNode(fromNode, toNode) 54 | )) 55 | } 56 | 57 | /** 58 | * Split segment range to decade ranges. 59 | * @param from - Segment start. 60 | * @param to - Segment end. 61 | * @returns Ranges. 62 | */ 63 | export function splitToDecadeRanges(from: number, to: number) { 64 | const ranges: [number, number][] = [] 65 | let num = from 66 | let decade = 1 67 | 68 | do { 69 | decade *= 10 70 | 71 | if (num < decade) { 72 | ranges.push([num, Math.min(decade - 1, to)]) 73 | num = decade 74 | } 75 | } while (decade <= to) 76 | 77 | return ranges 78 | } 79 | 80 | /** 81 | * Get common and diffs of two numbers (arrays of digits). 82 | * @param a - Digits. 83 | * @param b - Other digits. 84 | * @returns Common part and diffs. 85 | */ 86 | export function splitCommonDiff(a: number[], b: number[]): [string, number, number] { 87 | const len = a.length 88 | 89 | if (len !== b.length || a[0] !== b[0]) { 90 | return null 91 | } 92 | 93 | let common = a[0].toString() 94 | let currA = 0 95 | let currB = 0 96 | let diffA = '' 97 | let diffB = '' 98 | 99 | for (let i = 1; i < len; i++) { 100 | currA = a[i] 101 | currB = b[i] 102 | 103 | if (currA === currB && diffA === '' && diffB === '') { 104 | common += currA 105 | } else { 106 | diffA += currA 107 | diffB += currB 108 | } 109 | } 110 | 111 | return [ 112 | common, 113 | parseInt(diffA, 10), 114 | parseInt(diffB, 10) 115 | ] 116 | } 117 | 118 | /** 119 | * Get segment patterns. 120 | * @param from - Segment start. 121 | * @param to - Segment end. 122 | * @param digitsInNumber - How many digits should be en number. Will be filled by zeros. 123 | * @returns Segment patterns. 124 | */ 125 | export function segmentToNumberPatterns(from: number, to: number, digitsInNumber = 0): Expression[] { 126 | const fromDigits = numberToDigits(from) 127 | const digitsCount = fromDigits.length 128 | 129 | if (from < 10 && to < 10 || from === to) { 130 | const zeros = digitsInNumber - digitsCount 131 | 132 | return [segmentRangeNumberPattern(from, to, zeros)] 133 | } 134 | 135 | const toDigits = numberToDigits(to) 136 | 137 | if (digitsCount !== toDigits.length) { 138 | const decadeRanges = splitToDecadeRanges(from, to) 139 | const parts = concat( 140 | decadeRanges.map(([from, to]) => segmentToNumberPatterns(from, to, digitsInNumber)) 141 | ) 142 | 143 | return parts 144 | } 145 | 146 | const commonStart = splitCommonDiff(fromDigits, toDigits) 147 | 148 | if (Array.isArray(commonStart)) { 149 | const [ 150 | common, 151 | from, 152 | to 153 | ] = commonStart 154 | const digitsInNumber = digitsCount - common.length 155 | const diffParts = segmentToNumberPatterns(from, to, digitsInNumber) 156 | 157 | return [ 158 | AlternativeNode( 159 | Array.from(common, SimpleCharNode), 160 | DisjunctionCapturingGroupNode(diffParts) 161 | ) 162 | ] 163 | } 164 | 165 | const range = Array.from({ 166 | length: digitsCount - 1 167 | }) 168 | const middleSegment = segmentRangeNumberPattern( 169 | fromDigits[0] + 1, 170 | toDigits[0] - 1 171 | ) 172 | const parts = [ 173 | ...range.map((_, i) => { 174 | const ri = digitsCount - i - 1 175 | const d = Number(i > 0) 176 | 177 | return AlternativeNode( 178 | fromDigits.map((digit, j) => { 179 | if (j < ri) { 180 | return SimpleCharNode(digit) 181 | } 182 | 183 | if (j > ri) { 184 | return segmentRangeNumberPattern(0, 9) 185 | } 186 | 187 | return segmentRangeNumberPattern(digit + d, 9) 188 | }) 189 | ) 190 | }), 191 | // but output more readable 192 | ...middleSegment 193 | ? [ 194 | AlternativeNode( 195 | middleSegment, 196 | Array.from({ 197 | length: digitsCount - 1 198 | }, () => DigitPatternNode()) 199 | ) 200 | ] 201 | : [], 202 | ...range.map((_, i) => { 203 | const ri = digitsCount - i - 1 204 | const d = Number(i > 0) 205 | 206 | return AlternativeNode( 207 | toDigits.map((digit, j) => { 208 | if (j < ri) { 209 | return SimpleCharNode(digit) 210 | } 211 | 212 | if (j > ri) { 213 | return segmentRangeNumberPattern(0, 9) 214 | } 215 | 216 | return segmentRangeNumberPattern(0, digit - d) 217 | }) 218 | ) 219 | }) 220 | ] 221 | 222 | return optimizeSegmentNumberPatterns(parts) 223 | } 224 | -------------------------------------------------------------------------------- /src/numbers/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transform number to digits array. 3 | * @param num - Target number. 4 | * @returns Digits array. 5 | */ 6 | export function numberToDigits(num: string | number) { 7 | return Array.from(num.toString(), Number) 8 | } 9 | -------------------------------------------------------------------------------- /src/regex/index.ts: -------------------------------------------------------------------------------- 1 | export * from './nodes.js' 2 | export * from './regex.js' 3 | export * from './utils.js' 4 | export * from './optimize.js' 5 | -------------------------------------------------------------------------------- /src/regex/nodes.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AstRegExp, 3 | Alternative, 4 | Expression, 5 | Char, 6 | SimpleChar, 7 | SpecialChar, 8 | ClassRange, 9 | CharacterClass, 10 | Quantifier, 11 | SimpleQuantifier, 12 | RangeQuantifier, 13 | CapturingGroup, 14 | Repetition, 15 | Disjunction 16 | } from 'regexp-tree/ast' 17 | import { concat } from '../utils/index.js' 18 | 19 | export function AstRegExpNode(body: Expression): AstRegExp { 20 | return { 21 | type: 'RegExp', 22 | body, 23 | flags: '' 24 | } 25 | } 26 | 27 | export function AlternativeNode( 28 | ...expressions: (Expression | Expression[])[] 29 | ): Alternative | Expression { 30 | const exps = concat(expressions).filter(Boolean) 31 | 32 | if (exps.length === 1) { 33 | return exps[0] 34 | } 35 | 36 | return { 37 | type: 'Alternative', 38 | expressions: exps 39 | } 40 | } 41 | 42 | export function SimpleCharNode(value: string | number): SimpleChar { 43 | return { 44 | type: 'Char', 45 | kind: 'simple', 46 | value: String(value), 47 | codePoint: NaN 48 | } 49 | } 50 | 51 | export function MetaCharNode(value: string): SpecialChar { 52 | return { 53 | type: 'Char', 54 | kind: 'meta', 55 | value, 56 | codePoint: NaN 57 | } 58 | } 59 | 60 | export function ClassRangeNode(from: Char, to: Char): ClassRange { 61 | return { 62 | type: 'ClassRange', 63 | from, 64 | to 65 | } 66 | } 67 | 68 | export function CharacterClassNode( 69 | ...expressions: (Char | ClassRange | (Char | ClassRange)[])[] 70 | ): CharacterClass { 71 | return { 72 | type: 'CharacterClass', 73 | expressions: concat(expressions).filter(Boolean) 74 | } 75 | } 76 | 77 | export function SimpleQuantifierNode(kind: SimpleQuantifier['kind']): SimpleQuantifier { 78 | return { 79 | type: 'Quantifier', 80 | kind, 81 | greedy: true 82 | } 83 | } 84 | 85 | export function RangeQuantifierNode(from: number, to?: number): RangeQuantifier { 86 | return { 87 | type: 'Quantifier', 88 | kind: 'Range', 89 | from, 90 | to, 91 | greedy: true 92 | } 93 | } 94 | 95 | export function CapturingGroupNode(expression: Expression): CapturingGroup { 96 | return { 97 | type: 'Group', 98 | capturing: true, 99 | expression, 100 | number: null 101 | } 102 | } 103 | 104 | export function RepetitionNode(expression: Expression, quantifier: Quantifier): Repetition { 105 | return { 106 | type: 'Repetition', 107 | expression, 108 | quantifier 109 | } 110 | } 111 | 112 | export function DisjunctionNode(...expressions: (Expression | Expression[])[]): Disjunction | Expression { 113 | const exprs = concat(expressions).filter(Boolean) 114 | 115 | if (exprs.length === 1) { 116 | return exprs[0] 117 | } 118 | 119 | const disjunction: Disjunction = { 120 | type: 'Disjunction', 121 | left: null, 122 | right: exprs.pop() 123 | } 124 | 125 | exprs.reduceRight((disjunction, expr, i) => { 126 | if (i === 0) { 127 | disjunction.left = expr 128 | 129 | return disjunction 130 | } 131 | 132 | disjunction.left = { 133 | type: 'Disjunction', 134 | left: null, 135 | right: expr 136 | } 137 | 138 | return disjunction.left 139 | }, disjunction) 140 | 141 | return disjunction 142 | } 143 | 144 | export function DisjunctionCapturingGroupNode(...expressions: (Expression | Expression[])[]) { 145 | const expr = DisjunctionNode(...expressions) 146 | 147 | if (expr.type === 'Disjunction') { 148 | return CapturingGroupNode(expr) 149 | } 150 | 151 | return expr 152 | } 153 | 154 | export function DigitPatternNode() { 155 | return MetaCharNode('\\d') 156 | } 157 | 158 | export function NumberPatternNode( 159 | quantifier: Quantifier = SimpleQuantifierNode('+') 160 | ) { 161 | const numberPattern = RepetitionNode( 162 | DigitPatternNode(), 163 | quantifier 164 | ) 165 | 166 | return numberPattern 167 | } 168 | 169 | export function NumberCharsNode(value: number) { 170 | return AlternativeNode( 171 | Array.from(String(value), SimpleCharNode) 172 | ) 173 | } 174 | -------------------------------------------------------------------------------- /src/regex/optimize.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { toString } from './regex.js' 3 | import { optimizeRegex } from './optimize.js' 4 | 5 | describe('Regex', () => { 6 | describe('optimizeRegex', () => { 7 | it('should remove braces', () => { 8 | expect( 9 | toString(optimizeRegex('(Family)foo(NotFamily)(bar)')) 10 | ).toBe( 11 | '/FamilyfooNotFamilybar/' 12 | ) 13 | 14 | expect( 15 | toString(optimizeRegex('(?:Family)')) 16 | ).toBe( 17 | '/Family/' 18 | ) 19 | 20 | expect( 21 | toString(optimizeRegex('(Family)foo(NotFamily|bar)')) 22 | ).toBe( 23 | '/Familyfoo(NotFamily|bar)/' 24 | ) 25 | 26 | expect( 27 | toString(optimizeRegex('(Family)(foo)?(?=NotFamily)')) 28 | ).toBe( 29 | '/Family(foo)?(?=NotFamily)/' 30 | ) 31 | }) 32 | 33 | it('should remove top braces', () => { 34 | expect( 35 | toString(optimizeRegex('/(chrome|safari)/')) 36 | ).toBe( 37 | '/chrome|safari/' 38 | ) 39 | 40 | expect( 41 | toString(optimizeRegex('/(chrome|safari) post/')) 42 | ).toBe( 43 | '/(chrome|safari) post/' 44 | ) 45 | }) 46 | 47 | it('should make groups uncapturable', () => { 48 | expect( 49 | toString(optimizeRegex('(?:Family)+')) 50 | ).toBe( 51 | '/(Family)+/' 52 | ) 53 | }) 54 | 55 | it('should merge disjunctions', () => { 56 | expect( 57 | toString(optimizeRegex('/chrome|(safari|firefox)|opera/')) 58 | ).toBe( 59 | '/chrome|safari|firefox|opera/' 60 | ) 61 | 62 | expect( 63 | toString(optimizeRegex('/(chrome|(safari|firefox)|opera)/')) 64 | ).toBe( 65 | '/chrome|safari|firefox|opera/' 66 | ) 67 | 68 | expect( 69 | toString(optimizeRegex('/(chrome|(safari|(firefox|edge))|opera)/')) 70 | ).toBe( 71 | '/chrome|safari|firefox|edge|opera/' 72 | ) 73 | }) 74 | 75 | it('should remove unnecessary escapes in ranges', () => { 76 | expect( 77 | toString(optimizeRegex('[\\.\\[]')) 78 | ).toBe( 79 | '/[.[]/' 80 | ) 81 | }) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /src/regex/optimize.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AstRegExp, 3 | AstNode, 4 | Expression 5 | } from 'regexp-tree/ast' 6 | import RegexpTree from 'regexp-tree' 7 | import { 8 | isCharNode, 9 | isDigitRangeNode, 10 | parseRegex, 11 | toString 12 | } from './regex.js' 13 | 14 | /** 15 | * Optimize regex. 16 | * @param regex - Regex to optimize. 17 | * @returns Optimized regex string. 18 | */ 19 | export function optimizeRegex(regex: string | RegExp | AstRegExp): AstRegExp 20 | export function optimizeRegex(regex: T): T 21 | 22 | export function optimizeRegex(regex: string | RegExp | AstNode) { 23 | // Optimization requires filled codePoints 24 | const regexAst = RegexpTree.optimize(parseRegex(toString(regex))).getAST() 25 | 26 | RegexpTree.traverse(regexAst, { 27 | Group(nodePath) { 28 | const { 29 | parent, 30 | node 31 | } = nodePath 32 | const { expression } = node 33 | 34 | node.capturing = true 35 | 36 | if (parent.type === 'RegExp' 37 | || expression.type !== 'Disjunction' && parent.type !== 'Repetition' 38 | || expression.type === 'Disjunction' && parent.type === 'Disjunction' 39 | ) { 40 | nodePath.replace(nodePath.node.expression) 41 | } 42 | } 43 | }) 44 | 45 | return regexAst 46 | } 47 | 48 | /** 49 | * Merge digits patterns if possible. 50 | * @param a 51 | * @param b 52 | * @returns Merged node. 53 | */ 54 | export function mergeDigits(a: Expression, b: Expression) { 55 | if (isCharNode(a) && isCharNode(b) && a.value === b.value) { 56 | return b 57 | } 58 | 59 | if ( 60 | isCharNode(a, /\d/) && isDigitRangeNode(b) 61 | && Number(b.expressions[0].from.value) - Number(a.value) === 1 62 | ) { 63 | return { 64 | ...b, 65 | expressions: [ 66 | { 67 | ...b.expressions[0], 68 | from: a 69 | } 70 | ] 71 | } 72 | } 73 | 74 | if ( 75 | isDigitRangeNode(a) && isCharNode(b, /\d/) 76 | && Number(b.value) - Number(a.expressions[0].to.value) === 1 77 | ) { 78 | return { 79 | ...a, 80 | expressions: [ 81 | { 82 | ...a.expressions[0], 83 | to: b 84 | } 85 | ] 86 | } 87 | } 88 | 89 | return null 90 | } 91 | 92 | /** 93 | * Optimize segment number patterns. 94 | * @param patterns 95 | * @returns Optimized segment number patterns. 96 | */ 97 | export function optimizeSegmentNumberPatterns(patterns: Expression[]) { 98 | return patterns.reduce((patterns, node) => { 99 | const prevNode = patterns[patterns.length - 1] 100 | 101 | if (prevNode 102 | && node.type === 'Alternative' && prevNode.type === 'Alternative' 103 | && node.expressions.length === prevNode.expressions.length 104 | ) { 105 | const merged = prevNode.expressions.reduceRight((exps, exp, i) => { 106 | if (!exps) { 107 | return exps 108 | } 109 | 110 | const merged = mergeDigits(exp, node.expressions[i]) 111 | 112 | if (merged) { 113 | exps.unshift(merged) 114 | } else { 115 | return null 116 | } 117 | 118 | return exps 119 | }, []) 120 | 121 | if (merged) { 122 | node.expressions = merged 123 | patterns.pop() 124 | } 125 | } 126 | 127 | patterns.push(node) 128 | 129 | return patterns 130 | }, []) 131 | } 132 | -------------------------------------------------------------------------------- /src/regex/regex.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AstRegExp, 3 | AstNode, 4 | Expression, 5 | Char, 6 | CharacterClass, 7 | ClassRange 8 | } from 'regexp-tree/ast' 9 | import RegexpTree from 'regexp-tree' 10 | 11 | /** 12 | * Check node whether is number pattern. 13 | * @param node - AST node to check. 14 | * @returns Is number pattern or not. 15 | */ 16 | export function isNumberPatternNode(node: AstNode) { 17 | if (node.type === 'Group' && node.expression.type === 'Repetition') { 18 | const { 19 | expression, 20 | quantifier 21 | } = node.expression 22 | 23 | return expression.type === 'Char' && expression.value === '\\d' 24 | && quantifier.kind === '+' && quantifier.greedy 25 | } 26 | 27 | return false 28 | } 29 | 30 | /** 31 | * Check node whether is char node. 32 | * @param node - AST node to check. 33 | * @param value - Value to compare. 34 | * @returns Is char node or not. 35 | */ 36 | export function isCharNode(node: AstNode, value?: string | number | RegExp): node is Char { 37 | if (node && node.type === 'Char') { 38 | return typeof value === 'undefined' 39 | || value instanceof RegExp && value.test(node.value) 40 | || String(value) === node.value 41 | } 42 | 43 | return false 44 | } 45 | 46 | /** 47 | * Check node whether is digit range. 48 | * @param node - AST node to check. 49 | * @returns Is digit range or not. 50 | */ 51 | export function isDigitRangeNode(node: AstNode): node is CharacterClass & { expressions: [ClassRange] } { 52 | if (node.type === 'CharacterClass' && node.expressions.length === 1) { 53 | const [expression] = node.expressions 54 | 55 | return expression.type === 'ClassRange' 56 | && isCharNode(expression.from, /\d/) 57 | && isCharNode(expression.to, /\d/) 58 | } 59 | 60 | return false 61 | } 62 | 63 | /** 64 | * Check node whether is expression. 65 | * @param node - AST node to check. 66 | * @returns Is expression node or not. 67 | */ 68 | export function isExpressionNode(node: AstNode): node is Expression { 69 | return node.type !== 'RegExp' && node.type !== 'ClassRange' && node.type !== 'Quantifier' 70 | } 71 | 72 | /** 73 | * Parse regex from string or regex. 74 | * @param regex - Target regex or string. 75 | * @returns Parsed regex. 76 | */ 77 | export function parseRegex(regex: string | RegExp | AstRegExp): AstRegExp 78 | export function parseRegex(regex: string | RegExp | AstNode): AstNode 79 | export function parseRegex(regex: T): T 80 | 81 | export function parseRegex(regex: string | RegExp | AstNode) { 82 | return typeof regex === 'string' 83 | ? RegexpTree.parse(regex.replace(/^([^/])/, '/$1').replace(/([^/])$/, '$1/')) 84 | : regex instanceof RegExp 85 | ? RegexpTree.parse(regex) 86 | : regex 87 | } 88 | 89 | /** 90 | * Get regex from string or AST. 91 | * @param src - String or AST. 92 | * @returns RegExp. 93 | */ 94 | export function toRegex(src: string | AstRegExp) { 95 | return typeof src === 'string' 96 | ? new RegExp(src) 97 | : new RegExp(RegexpTree.generate(src.body), src.flags) 98 | } 99 | 100 | /** 101 | * Get string from regex or AST. 102 | * @param src - RegExp or AST. 103 | * @returns String. 104 | */ 105 | export function toString(src: string | RegExp | AstNode | null) { 106 | return typeof src === 'string' 107 | ? src 108 | : src instanceof RegExp 109 | ? src.toString() 110 | : RegexpTree.generate(src) 111 | } 112 | -------------------------------------------------------------------------------- /src/regex/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpecificTraversalHandlers, 3 | TraversalCallbacks, 4 | TraversalCallback, 5 | TraversalHandlers 6 | } from 'regexp-tree' 7 | 8 | export interface Visitors extends SpecificTraversalHandlers { 9 | every?: TraversalCallback | TraversalCallbacks 10 | } 11 | 12 | const classes = [ 13 | 'RegExp', 14 | 'Disjunction', 15 | 'Alternative', 16 | 'Assertion', 17 | 'Char', 18 | 'CharacterClass', 19 | 'ClassRange', 20 | 'Backreference', 21 | 'Group', 22 | 'Repetition', 23 | 'Quantifier' 24 | ] as const 25 | 26 | /** 27 | * Create traversal visitors. 28 | * @param visitors 29 | * @returns Traversal handlers. 30 | */ 31 | export function visitors(visitors: Visitors): TraversalHandlers { 32 | const { every } = visitors 33 | 34 | if (!every) { 35 | return visitors 36 | } 37 | 38 | if (typeof every === 'function') { 39 | return { 40 | // eslint-disable-next-line @typescript-eslint/naming-convention 41 | '*': every, 42 | ...visitors 43 | } 44 | } 45 | 46 | return classes.reduce>((newVisitors, className) => { 47 | const visitor = visitors[className] as TraversalCallback | TraversalCallbacks 48 | const visitorPre = visitor 49 | ? 'pre' in visitor 50 | ? visitor.pre 51 | : visitor as TraversalCallback 52 | : null 53 | const visitorPost = visitor 54 | ? 'post' in visitor 55 | ? visitor.post 56 | : null 57 | : null 58 | 59 | newVisitors[className] = { 60 | pre(nodePath) { 61 | if (every.pre(nodePath) !== false && visitorPre) { 62 | return visitorPre(nodePath) 63 | } 64 | 65 | return true 66 | }, 67 | post(nodePath) { 68 | if (every.post(nodePath) !== false && visitorPost) { 69 | return visitorPost(nodePath) 70 | } 71 | 72 | return true 73 | } 74 | } 75 | 76 | return newVisitors 77 | }, {}) 78 | } 79 | -------------------------------------------------------------------------------- /src/semver/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js' 2 | export * from './semver.js' 3 | -------------------------------------------------------------------------------- /src/semver/semver.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import type { Semver } from './types.js' 3 | import { 4 | semverify, 5 | compareSemvers, 6 | getRequiredSemverPartsCount, 7 | rangeSemver 8 | } from './semver.js' 9 | 10 | describe('Semver', () => { 11 | describe('semverify', () => { 12 | it('should semverify strings', () => { 13 | expect( 14 | semverify('10') 15 | ).toEqual( 16 | [ 17 | 10, 18 | 0, 19 | 0 20 | ] 21 | ) 22 | 23 | expect( 24 | semverify('11.1') 25 | ).toEqual( 26 | [ 27 | 11, 28 | 1, 29 | 0 30 | ] 31 | ) 32 | 33 | expect( 34 | semverify('13.0.1') 35 | ).toEqual( 36 | [ 37 | 13, 38 | 0, 39 | 1 40 | ] 41 | ) 42 | }) 43 | 44 | it('should semverify array', () => { 45 | expect( 46 | semverify([10]) 47 | ).toEqual( 48 | [ 49 | 10, 50 | 0, 51 | 0 52 | ] 53 | ) 54 | 55 | expect( 56 | semverify(['11', '1']) 57 | ).toEqual( 58 | [ 59 | 11, 60 | 1, 61 | 0 62 | ] 63 | ) 64 | 65 | expect( 66 | semverify([ 67 | 13, 68 | '0', 69 | '1' 70 | ]) 71 | ).toEqual( 72 | [ 73 | 13, 74 | 0, 75 | 1 76 | ] 77 | ) 78 | }) 79 | 80 | it('should return Infinity for \'all\' version', () => { 81 | expect( 82 | semverify('all') 83 | ).toEqual( 84 | [ 85 | Infinity, 86 | 0, 87 | 0 88 | ] 89 | ) 90 | 91 | expect( 92 | semverify(['all']) 93 | ).toEqual( 94 | [ 95 | Infinity, 96 | 0, 97 | 0 98 | ] 99 | ) 100 | }) 101 | 102 | it('should return null for non-number versions', () => { 103 | expect( 104 | semverify('TP') 105 | ).toBeNull() 106 | 107 | expect( 108 | semverify(['TP']) 109 | ).toBeNull() 110 | }) 111 | }) 112 | 113 | describe('compareSemvers', () => { 114 | it('should handle Infinity (\'all\') version', () => { 115 | expect( 116 | compareSemvers( 117 | [ 118 | 10, 119 | 1, 120 | 1 121 | ], 122 | [ 123 | Infinity, 124 | 0, 125 | 0 126 | ], 127 | {} 128 | ) 129 | ).toBe( 130 | true 131 | ) 132 | }) 133 | 134 | describe('allowHigherVersions: false', () => { 135 | const options = { 136 | ignoreMinor: false, 137 | ignorePatch: false, 138 | allowHigherVersions: false 139 | } 140 | 141 | it('should compare all parts', () => { 142 | expect( 143 | compareSemvers( 144 | [ 145 | 10, 146 | 1, 147 | 1 148 | ], 149 | [ 150 | 10, 151 | 1, 152 | 1 153 | ], 154 | options 155 | ) 156 | ).toBe( 157 | true 158 | ) 159 | 160 | expect( 161 | compareSemvers( 162 | [ 163 | 10, 164 | 1, 165 | 2 166 | ], 167 | [ 168 | 10, 169 | 1, 170 | 1 171 | ], 172 | options 173 | ) 174 | ).toBe( 175 | false 176 | ) 177 | 178 | expect( 179 | compareSemvers( 180 | [ 181 | 10, 182 | 1, 183 | 1 184 | ], 185 | [ 186 | 10, 187 | 2, 188 | 1 189 | ], 190 | options 191 | ) 192 | ).toBe( 193 | false 194 | ) 195 | }) 196 | 197 | it('should compare ignoring patch', () => { 198 | const ignorePatchOptions = { 199 | ...options, 200 | ignorePatch: true 201 | } 202 | 203 | expect( 204 | compareSemvers( 205 | [ 206 | 10, 207 | 1, 208 | 1 209 | ], 210 | [ 211 | 10, 212 | 1, 213 | 1 214 | ], 215 | ignorePatchOptions 216 | ) 217 | ).toBe( 218 | true 219 | ) 220 | 221 | expect( 222 | compareSemvers( 223 | [ 224 | 10, 225 | 1, 226 | 0 227 | ], 228 | [ 229 | 10, 230 | 1, 231 | 1 232 | ], 233 | ignorePatchOptions 234 | ) 235 | ).toBe( 236 | true 237 | ) 238 | 239 | expect( 240 | compareSemvers( 241 | [ 242 | 10, 243 | 1, 244 | 2 245 | ], 246 | [ 247 | 10, 248 | 2, 249 | 1 250 | ], 251 | ignorePatchOptions 252 | ) 253 | ).toBe( 254 | false 255 | ) 256 | 257 | expect( 258 | compareSemvers( 259 | [ 260 | 10, 261 | 1, 262 | 2 263 | ], 264 | [ 265 | 12, 266 | 2, 267 | 1 268 | ], 269 | ignorePatchOptions 270 | ) 271 | ).toBe( 272 | false 273 | ) 274 | }) 275 | 276 | it('should compare ignoring minor', () => { 277 | const ignoreMinorOptions = { 278 | ...options, 279 | ignoreMinor: true 280 | } 281 | 282 | expect( 283 | compareSemvers( 284 | [ 285 | 10, 286 | 1, 287 | 1 288 | ], 289 | [ 290 | 10, 291 | 1, 292 | 1 293 | ], 294 | ignoreMinorOptions 295 | ) 296 | ).toBe( 297 | true 298 | ) 299 | 300 | expect( 301 | compareSemvers( 302 | [ 303 | 10, 304 | 1, 305 | 0 306 | ], 307 | [ 308 | 10, 309 | 2, 310 | 1 311 | ], 312 | ignoreMinorOptions 313 | ) 314 | ).toBe( 315 | true 316 | ) 317 | 318 | expect( 319 | compareSemvers( 320 | [ 321 | 10, 322 | 1, 323 | 2 324 | ], 325 | [ 326 | 11, 327 | 2, 328 | 1 329 | ], 330 | ignoreMinorOptions 331 | ) 332 | ).toBe( 333 | false 334 | ) 335 | }) 336 | }) 337 | 338 | describe('allowHigherVersions: true', () => { 339 | const options = { 340 | ignoreMinor: false, 341 | ignorePatch: false, 342 | allowHigherVersions: true 343 | } 344 | 345 | it('should compare all parts', () => { 346 | expect( 347 | compareSemvers( 348 | [ 349 | 10, 350 | 1, 351 | 1 352 | ], 353 | [ 354 | 10, 355 | 1, 356 | 1 357 | ], 358 | options 359 | ) 360 | ).toBe( 361 | true 362 | ) 363 | 364 | expect( 365 | compareSemvers( 366 | [ 367 | 10, 368 | 2, 369 | 3 370 | ], 371 | [ 372 | 10, 373 | 1, 374 | 1 375 | ], 376 | options 377 | ) 378 | ).toBe( 379 | true 380 | ) 381 | 382 | expect( 383 | compareSemvers( 384 | [ 385 | 10, 386 | 1, 387 | 2 388 | ], 389 | [ 390 | 10, 391 | 1, 392 | 1 393 | ], 394 | options 395 | ) 396 | ).toBe( 397 | true 398 | ) 399 | 400 | expect( 401 | compareSemvers( 402 | [ 403 | 9, 404 | 1, 405 | 1 406 | ], 407 | [ 408 | 10, 409 | 2, 410 | 1 411 | ], 412 | options 413 | ) 414 | ).toBe( 415 | false 416 | ) 417 | 418 | expect( 419 | compareSemvers( 420 | [ 421 | 10, 422 | 1, 423 | 0 424 | ], 425 | [ 426 | 10, 427 | 1, 428 | 1 429 | ], 430 | options 431 | ) 432 | ).toBe( 433 | false 434 | ) 435 | }) 436 | 437 | it('should compare ignoring patch', () => { 438 | const ignorePatchOptions = { 439 | ...options, 440 | ignorePatch: true 441 | } 442 | 443 | expect( 444 | compareSemvers( 445 | [ 446 | 10, 447 | 1, 448 | 1 449 | ], 450 | [ 451 | 10, 452 | 1, 453 | 1 454 | ], 455 | ignorePatchOptions 456 | ) 457 | ).toBe( 458 | true 459 | ) 460 | 461 | expect( 462 | compareSemvers( 463 | [ 464 | 10, 465 | 0, 466 | 2 467 | ], 468 | [ 469 | 10, 470 | 2, 471 | 1 472 | ], 473 | ignorePatchOptions 474 | ) 475 | ).toBe( 476 | false 477 | ) 478 | 479 | expect( 480 | compareSemvers( 481 | [ 482 | 9, 483 | 1, 484 | 2 485 | ], 486 | [ 487 | 12, 488 | 2, 489 | 1 490 | ], 491 | ignorePatchOptions 492 | ) 493 | ).toBe( 494 | false 495 | ) 496 | }) 497 | 498 | it('should compare ignoring minor', () => { 499 | const ignoreMinorOptions = { 500 | ...options, 501 | ignoreMinor: true 502 | } 503 | 504 | expect( 505 | compareSemvers( 506 | [ 507 | 10, 508 | 1, 509 | 1 510 | ], 511 | [ 512 | 10, 513 | 1, 514 | 1 515 | ], 516 | ignoreMinorOptions 517 | ) 518 | ).toBe( 519 | true 520 | ) 521 | 522 | expect( 523 | compareSemvers( 524 | [ 525 | 10, 526 | 1, 527 | 0 528 | ], 529 | [ 530 | 10, 531 | 2, 532 | 1 533 | ], 534 | ignoreMinorOptions 535 | ) 536 | ).toBe( 537 | true 538 | ) 539 | 540 | expect( 541 | compareSemvers( 542 | [ 543 | 9, 544 | 1, 545 | 2 546 | ], 547 | [ 548 | 11, 549 | 2, 550 | 1 551 | ], 552 | ignoreMinorOptions 553 | ) 554 | ).toBe( 555 | false 556 | ) 557 | }) 558 | }) 559 | }) 560 | 561 | describe('getRequiredSemverPartsCount', () => { 562 | describe('allowZeroSubversions: false', () => { 563 | const options = { 564 | ignoreMinor: false, 565 | ignorePatch: false, 566 | allowZeroSubversions: false 567 | } 568 | const version: Semver = [ 569 | 1, 570 | 2, 571 | 3 572 | ] 573 | 574 | it('should require all parts', () => { 575 | expect( 576 | getRequiredSemverPartsCount(version, options) 577 | ).toBe( 578 | 3 579 | ) 580 | }) 581 | 582 | it('should require major and minor parts', () => { 583 | const ignorePatchOptions = { 584 | ...options, 585 | ignorePatch: true 586 | } 587 | 588 | expect( 589 | getRequiredSemverPartsCount(version, ignorePatchOptions) 590 | ).toBe( 591 | 2 592 | ) 593 | }) 594 | 595 | it('should require major part', () => { 596 | const ignoreMinorOptions = { 597 | ...options, 598 | ignoreMinor: true 599 | } 600 | 601 | expect( 602 | getRequiredSemverPartsCount(version, ignoreMinorOptions) 603 | ).toBe( 604 | 1 605 | ) 606 | }) 607 | }) 608 | 609 | describe('allowZeroSubversions: true', () => { 610 | const options = { 611 | ignoreMinor: false, 612 | ignorePatch: false, 613 | allowZeroSubversions: true 614 | } 615 | const version: Semver = [ 616 | 1, 617 | 2, 618 | 3 619 | ] 620 | const zeroVersion: Semver = [ 621 | 1, 622 | 1, 623 | 0 624 | ] 625 | const zerosVersion: Semver = [ 626 | 1, 627 | 0, 628 | 0 629 | ] 630 | 631 | it('should require all parts', () => { 632 | expect( 633 | getRequiredSemverPartsCount(version, options) 634 | ).toBe( 635 | 3 636 | ) 637 | 638 | expect( 639 | getRequiredSemverPartsCount(zeroVersion, options) 640 | ).toBe( 641 | 2 642 | ) 643 | 644 | expect( 645 | getRequiredSemverPartsCount(zerosVersion, options) 646 | ).toBe( 647 | 1 648 | ) 649 | }) 650 | 651 | it('should require major and minor parts', () => { 652 | const ignorePatchOptions = { 653 | ...options, 654 | ignorePatch: true 655 | } 656 | 657 | expect( 658 | getRequiredSemverPartsCount(version, ignorePatchOptions) 659 | ).toBe( 660 | 2 661 | ) 662 | 663 | expect( 664 | getRequiredSemverPartsCount(zeroVersion, ignorePatchOptions) 665 | ).toBe( 666 | 2 667 | ) 668 | 669 | expect( 670 | getRequiredSemverPartsCount(zerosVersion, ignorePatchOptions) 671 | ).toBe( 672 | 1 673 | ) 674 | }) 675 | 676 | it('should require major part', () => { 677 | const ignoreMinorOptions = { 678 | ...options, 679 | ignoreMinor: true 680 | } 681 | 682 | expect( 683 | getRequiredSemverPartsCount(version, ignoreMinorOptions) 684 | ).toBe( 685 | 1 686 | ) 687 | 688 | expect( 689 | getRequiredSemverPartsCount(zeroVersion, ignoreMinorOptions) 690 | ).toBe( 691 | 1 692 | ) 693 | 694 | expect( 695 | getRequiredSemverPartsCount(zerosVersion, ignoreMinorOptions) 696 | ).toBe( 697 | 1 698 | ) 699 | }) 700 | }) 701 | }) 702 | 703 | describe('rangeSemver', () => { 704 | it('should range patch', () => { 705 | expect( 706 | rangeSemver( 707 | [4, 4, 3], 708 | [4, 4, 5] 709 | ) 710 | ).toEqual([ 711 | [4, 4, 3], 712 | [4, 4, 4], 713 | [4, 4, 5] 714 | ]) 715 | }) 716 | 717 | it('should range minor', () => { 718 | expect( 719 | rangeSemver( 720 | [15, 4, 0], 721 | [15, 6, 0] 722 | ) 723 | ).toEqual([ 724 | [15, 4, 0], 725 | [15, 5, 0], 726 | [15, 6, 0] 727 | ]) 728 | }) 729 | 730 | it('should range minor', () => { 731 | expect( 732 | rangeSemver( 733 | [100, 0, 0], 734 | [102, 0, 0] 735 | ) 736 | ).toEqual([ 737 | [100, 0, 0], 738 | [101, 0, 0], 739 | [102, 0, 0] 740 | ]) 741 | }) 742 | }) 743 | }) 744 | -------------------------------------------------------------------------------- /src/semver/semver.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SemverLike, 3 | Semver, 4 | RangedSemver, 5 | SemverCompareOptions 6 | } from './types.js' 7 | 8 | /** 9 | * Get semver from string or array. 10 | * @param version - Target to convert. 11 | * @returns Array with semver parts. 12 | */ 13 | export function semverify(version: SemverLike): Semver | null { 14 | const versionParts = Array.isArray(version) 15 | ? version 16 | : version.toString().split('.') 17 | 18 | if (versionParts[0] === 'all') { 19 | return [ 20 | Infinity, 21 | 0, 22 | 0 23 | ] 24 | } 25 | 26 | let versionPart: number | string = null 27 | let semverPart: number = null 28 | const semver: Semver = [ 29 | 0, 30 | 0, 31 | 0 32 | ] 33 | 34 | for (let i = 0; i < 3; i++) { 35 | versionPart = versionParts[i] 36 | 37 | if (typeof versionPart === 'undefined') { 38 | continue 39 | } 40 | 41 | semverPart = typeof versionPart === 'number' 42 | ? versionPart 43 | : parseInt(versionPart, 10) 44 | 45 | if (isNaN(semverPart)) { 46 | return null 47 | } 48 | 49 | semver[i] = semverPart 50 | } 51 | 52 | return semver 53 | } 54 | 55 | /** 56 | * Get semver range. 57 | * @param from 58 | * @param to 59 | * @returns Semver range. 60 | */ 61 | export function rangeSemver(from: Semver, to: Semver) { 62 | let partIndex = 0 63 | const range: Semver[] = [] 64 | 65 | for (let i = 2; i >= 0; i--) { 66 | if (from[i] !== to[i]) { 67 | partIndex = i 68 | break 69 | } 70 | } 71 | 72 | for (let i = from[partIndex], max = to[partIndex]; i <= max; i++) { 73 | range.push( 74 | from.map((v, j) => (j === partIndex ? i : v)) as Semver 75 | ) 76 | } 77 | 78 | return range 79 | } 80 | 81 | /** 82 | * Compare semvers. 83 | * @param a - Semver to compare. 84 | * @param b - Semver to compare with. 85 | * @param options - Compare options. 86 | * @returns Equals or not. 87 | */ 88 | export function compareSemvers(a: Semver, b: Semver, options: SemverCompareOptions) { 89 | const [ 90 | major, 91 | minor, 92 | patch 93 | ] = a 94 | const [ 95 | majorBase, 96 | minorBase, 97 | patchBase 98 | ] = b 99 | const { 100 | ignoreMinor, 101 | ignorePatch, 102 | allowHigherVersions 103 | } = options 104 | 105 | if (majorBase === Infinity) { 106 | return true 107 | } 108 | 109 | const compareMinor = !ignoreMinor 110 | const comparePatch = compareMinor && !ignorePatch 111 | 112 | if (allowHigherVersions) { 113 | if ( 114 | comparePatch && patch < patchBase 115 | || compareMinor && minor < minorBase 116 | ) { 117 | return false 118 | } 119 | 120 | return major >= majorBase 121 | } 122 | 123 | if ( 124 | comparePatch && patch !== patchBase 125 | || compareMinor && minor !== minorBase 126 | ) { 127 | return false 128 | } 129 | 130 | return major === majorBase 131 | } 132 | 133 | /** 134 | * Get required semver parts count. 135 | * @param version - Semver parts or ranges. 136 | * @param options - Semver compare options. 137 | * @returns Required semver parts count. 138 | */ 139 | export function getRequiredSemverPartsCount(version: Semver | RangedSemver, options: SemverCompareOptions) { 140 | const { 141 | ignoreMinor, 142 | ignorePatch, 143 | allowZeroSubversions 144 | } = options 145 | let shouldRepeatCount = ignoreMinor 146 | ? 1 147 | : ignorePatch 148 | ? 2 149 | : 3 150 | 151 | if (allowZeroSubversions) { 152 | for (let i = shouldRepeatCount - 1; i > 0; i--) { 153 | if (version[i] !== 0 || shouldRepeatCount === 1) { 154 | break 155 | } 156 | 157 | shouldRepeatCount-- 158 | } 159 | } 160 | 161 | return shouldRepeatCount 162 | } 163 | -------------------------------------------------------------------------------- /src/semver/types.ts: -------------------------------------------------------------------------------- 1 | export interface SemverCompareOptions { 2 | ignoreMinor?: boolean 3 | ignorePatch?: boolean 4 | allowZeroSubversions?: boolean 5 | allowHigherVersions?: boolean 6 | } 7 | 8 | export type Semver = [ 9 | number, 10 | number, 11 | number 12 | ] 13 | 14 | export type SemverRange = number | [number, number] 15 | 16 | export type RangedSemver = [ 17 | SemverRange, 18 | SemverRange, 19 | SemverRange 20 | ] 21 | 22 | export type SemverLike = string | (number | string)[] 23 | 24 | export enum SemverPart { 25 | Major = 0, 26 | Minor, 27 | Patch 28 | } 29 | -------------------------------------------------------------------------------- /src/useragent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js' 2 | export * from './utils.js' 3 | export * from './useragent.js' 4 | -------------------------------------------------------------------------------- /src/useragent/types.ts: -------------------------------------------------------------------------------- 1 | import type { UserAgentRegex } from 'ua-regexes-lite' 2 | import type { AstRegExp } from 'regexp-tree/ast' 3 | import type { Semver } from '../semver/types.js' 4 | 5 | export interface BrowserRegex extends UserAgentRegex { 6 | requestVersions: Semver[] 7 | matchedVersions: Semver[] 8 | } 9 | 10 | export interface BrowserVersionedRegex extends BrowserRegex { 11 | sourceRegex: RegExp 12 | regexAst: AstRegExp 13 | } 14 | -------------------------------------------------------------------------------- /src/useragent/useragent.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import type { UserAgentRegex } from 'ua-regexes-lite' 3 | import type { BrowsersVersions } from '../browsers/types.js' 4 | import { getRegexesForBrowsers } from './useragent.js' 5 | 6 | describe('UserAgent', () => { 7 | describe('getRegexesForBrowsers', () => { 8 | it('should find regexes by family name', () => { 9 | const browsers: BrowsersVersions = new Map([['chrome', [[98, 0, 0], [99, 0, 0], [100, 0, 0]]]]) 10 | const regexes = [ 11 | { 12 | regex: /chrome/, 13 | family: 'chrome' 14 | }, 15 | { 16 | regex: /firefox/, 17 | family: 'firefox' 18 | } 19 | ] 20 | 21 | expect(getRegexesForBrowsers(browsers, {}, regexes)).toEqual([ 22 | { 23 | regex: /chrome/, 24 | family: 'chrome', 25 | requestVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]], 26 | matchedVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]] 27 | } 28 | ]) 29 | }) 30 | 31 | it('should find regexes by version', () => { 32 | const browsers: BrowsersVersions = new Map([['chrome', [[98, 0, 0], [99, 0, 0], [100, 0, 0]]]]) 33 | const regexes: UserAgentRegex[] = [ 34 | { 35 | regex: /chrome 97/, 36 | family: 'chrome', 37 | version: [97, 0, 0] 38 | }, 39 | { 40 | regex: /chrome 99/, 41 | family: 'chrome', 42 | version: [99, 0, 0] 43 | }, 44 | { 45 | regex: /chrome 101/, 46 | family: 'chrome', 47 | version: [101, 0, 0] 48 | } 49 | ] 50 | 51 | expect(getRegexesForBrowsers(browsers, {}, regexes)).toEqual([ 52 | { 53 | regex: /chrome 99/, 54 | family: 'chrome', 55 | version: [99, 0, 0], 56 | minVersion: [99, 0, 0], 57 | maxVersion: [99, 0, 0], 58 | requestVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]], 59 | matchedVersions: [[99, 0, 0]] 60 | } 61 | ]) 62 | }) 63 | 64 | it('should find regexes by version ranges', () => { 65 | const browsers: BrowsersVersions = new Map([['chrome', [[98, 0, 0], [99, 0, 0], [100, 0, 0]]]]) 66 | const regexes: UserAgentRegex[] = [ 67 | { 68 | regex: /chrome x/, 69 | family: 'chrome', 70 | maxVersion: [9, 0, 0] 71 | }, 72 | { 73 | regex: /chrome xx/, 74 | family: 'chrome', 75 | minVersion: [10, 0, 0], 76 | maxVersion: [99, 0, 0] 77 | }, 78 | { 79 | regex: /chrome xxx/, 80 | family: 'chrome', 81 | minVersion: [100, 0, 0] 82 | } 83 | ] 84 | 85 | expect(getRegexesForBrowsers(browsers, {}, regexes)).toEqual([ 86 | { 87 | regex: /chrome xx/, 88 | family: 'chrome', 89 | minVersion: [10, 0, 0], 90 | maxVersion: [99, 0, 0], 91 | requestVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]], 92 | matchedVersions: [[98, 0, 0], [99, 0, 0]] 93 | }, 94 | { 95 | regex: /chrome xxx/, 96 | family: 'chrome', 97 | minVersion: [100, 0, 0], 98 | requestVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]], 99 | matchedVersions: [[100, 0, 0]] 100 | } 101 | ]) 102 | }) 103 | 104 | it('should apply patches', () => { 105 | const browsers: BrowsersVersions = new Map([['chrome', [[98, 0, 0], [99, 0, 0], [100, 0, 0]]]]) 106 | const regexes: UserAgentRegex[] = [ 107 | { 108 | regex: /chrome/, 109 | family: 'chrome' 110 | }, 111 | { 112 | regex: /chrome patch/, 113 | family: 'chrome', 114 | maxVersion: [98, 0, 0] 115 | } 116 | ] 117 | 118 | expect(getRegexesForBrowsers(browsers, {}, regexes)).toEqual([ 119 | { 120 | regex: /chrome patch/, 121 | family: 'chrome', 122 | requestVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]], 123 | matchedVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]] 124 | } 125 | ]) 126 | }) 127 | 128 | it('should not apply patches', () => { 129 | const browsers: BrowsersVersions = new Map([['chrome', [[99, 0, 0], [100, 0, 0]]]]) 130 | const regexes: UserAgentRegex[] = [ 131 | { 132 | regex: /chrome/, 133 | family: 'chrome' 134 | }, 135 | { 136 | regex: /chrome patch/, 137 | family: 'chrome', 138 | maxVersion: [98, 0, 0] 139 | } 140 | ] 141 | 142 | expect(getRegexesForBrowsers(browsers, {}, regexes)).toEqual([ 143 | { 144 | regex: /chrome/, 145 | family: 'chrome', 146 | requestVersions: [[99, 0, 0], [100, 0, 0]], 147 | matchedVersions: [[99, 0, 0], [100, 0, 0]] 148 | } 149 | ]) 150 | }) 151 | 152 | it('should not apply patches for different family', () => { 153 | const browsers: BrowsersVersions = new Map([['chrome', [[98, 0, 0], [99, 0, 0], [100, 0, 0]]]]) 154 | const regexes: UserAgentRegex[] = [ 155 | { 156 | regex: /firefox/, 157 | family: 'firefox' 158 | }, 159 | { 160 | regex: /chrome patch/, 161 | family: 'chrome', 162 | maxVersion: [98, 0, 0] 163 | } 164 | ] 165 | 166 | expect(getRegexesForBrowsers(browsers, {}, regexes)).toEqual([ 167 | { 168 | regex: /chrome patch/, 169 | family: 'chrome', 170 | maxVersion: [98, 0, 0], 171 | requestVersions: [[98, 0, 0], [99, 0, 0], [100, 0, 0]], 172 | matchedVersions: [[98, 0, 0]] 173 | } 174 | ]) 175 | }) 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /src/useragent/useragent.ts: -------------------------------------------------------------------------------- 1 | import { regexes } from 'ua-regexes-lite' 2 | import type { SemverCompareOptions } from '../semver/index.js' 3 | import type { BrowsersVersions } from '../browsers/types.js' 4 | import type { BrowserRegex } from './types.js' 5 | import { findMatchedVersions } from './utils.js' 6 | 7 | /** 8 | * Get useragent regexes for given browsers. 9 | * @param browsers - Browsers. 10 | * @param options - Semver compare options. 11 | * @param targetRegexes - Override default regexes. 12 | * @returns User agent regexes. 13 | */ 14 | export function getRegexesForBrowsers(browsers: BrowsersVersions, options: SemverCompareOptions, targetRegexes = regexes) { 15 | const result: BrowserRegex[] = [] 16 | let prevFamily = '' 17 | let prevRegexIsGlobal = false 18 | 19 | targetRegexes.forEach((regex) => { 20 | const requestVersions = browsers.get(regex.family) 21 | 22 | if (!requestVersions) { 23 | return 24 | } 25 | 26 | let { 27 | version, 28 | minVersion, 29 | maxVersion 30 | } = regex 31 | 32 | if (version) { 33 | minVersion = version 34 | maxVersion = version 35 | } 36 | 37 | let matchedVersions = findMatchedVersions(minVersion, maxVersion, requestVersions, options) 38 | 39 | if (matchedVersions.length) { 40 | // regex contains global patch 41 | if (prevFamily === regex.family && prevRegexIsGlobal) { 42 | version = undefined 43 | minVersion = undefined 44 | maxVersion = undefined 45 | matchedVersions = requestVersions 46 | result.pop() 47 | } 48 | 49 | result.push({ 50 | ...regex, 51 | version, 52 | minVersion, 53 | maxVersion, 54 | requestVersions, 55 | matchedVersions 56 | }) 57 | } 58 | 59 | prevRegexIsGlobal = !version && !minVersion && !maxVersion 60 | prevFamily = regex.family 61 | }) 62 | 63 | return result 64 | } 65 | -------------------------------------------------------------------------------- /src/useragent/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import type { Semver } from '../semver/types.js' 3 | import { findMatchedVersions } from './utils.js' 4 | 5 | describe('UserAgent', () => { 6 | describe('utils', () => { 7 | describe('someSemverMatched', () => { 8 | const versions: Semver[] = [ 9 | [ 10 | 10, 11 | 0, 12 | 0 13 | ], 14 | [ 15 | 11, 16 | 0, 17 | 0 18 | ], 19 | [ 20 | 12, 21 | 0, 22 | 0 23 | ] 24 | ] 25 | 26 | it('should correct match semver', () => { 27 | expect( 28 | findMatchedVersions( 29 | [ 30 | 9, 31 | 0, 32 | 0 33 | ], 34 | null, 35 | versions, 36 | {} 37 | ) 38 | ).toEqual( 39 | versions 40 | ) 41 | 42 | expect( 43 | findMatchedVersions( 44 | null, 45 | [ 46 | 11, 47 | 0, 48 | 0 49 | ], 50 | versions, 51 | {} 52 | ) 53 | ).toEqual( 54 | [ 55 | [ 56 | 10, 57 | 0, 58 | 0 59 | ], 60 | [ 61 | 11, 62 | 0, 63 | 0 64 | ] 65 | ] 66 | ) 67 | 68 | expect( 69 | findMatchedVersions( 70 | [ 71 | 10, 72 | 0, 73 | 0 74 | ], 75 | [ 76 | 16, 77 | 0, 78 | 0 79 | ], 80 | versions, 81 | {} 82 | ) 83 | ).toEqual( 84 | versions 85 | ) 86 | 87 | expect( 88 | findMatchedVersions( 89 | [ 90 | 16, 91 | 0, 92 | 0 93 | ], 94 | null, 95 | versions, 96 | {} 97 | ) 98 | ).toEqual( 99 | [] 100 | ) 101 | 102 | expect( 103 | findMatchedVersions( 104 | null, 105 | [ 106 | 9, 107 | 0, 108 | 0 109 | ], 110 | versions, 111 | {} 112 | ) 113 | ).toEqual( 114 | [] 115 | ) 116 | }) 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/useragent/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Semver, 3 | SemverCompareOptions 4 | } from '../semver/index.js' 5 | import { compareSemvers } from '../semver/index.js' 6 | 7 | /** 8 | * Find matched versions. 9 | * @param minVersion - Semver version. 10 | * @param maxVersion - Semver version. 11 | * @param bases - Base semver versions. 12 | * @param options - Semver compare options. 13 | * @returns Matched versions. 14 | */ 15 | export function findMatchedVersions( 16 | minVersion: Semver | null, 17 | maxVersion: Semver | null, 18 | bases: Semver[], 19 | options: SemverCompareOptions 20 | ) { 21 | const compareOptions = { 22 | ...options, 23 | allowHigherVersions: true 24 | } 25 | const minComparator = (ver: Semver) => compareSemvers(ver, minVersion, compareOptions) 26 | const maxComparator = (ver: Semver) => compareSemvers(maxVersion, ver, compareOptions) 27 | const comparator = minVersion && maxVersion 28 | ? (ver: Semver) => minComparator(ver) && maxComparator(ver) 29 | : minVersion 30 | ? minComparator 31 | : maxVersion 32 | ? maxComparator 33 | : () => true 34 | 35 | return bases.filter(comparator) 36 | } 37 | -------------------------------------------------------------------------------- /src/useragentRegex/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js' 2 | export * from './utils.js' 3 | export * from './useragentRegex.js' 4 | -------------------------------------------------------------------------------- /src/useragentRegex/types.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserslistRequest } from '../browsers/types.js' 2 | import type { SemverCompareOptions } from '../semver/types.js' 3 | 4 | export type UserAgentRegexOptions = BrowserslistRequest & SemverCompareOptions 5 | -------------------------------------------------------------------------------- /src/useragentRegex/useragentRegex.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest' 2 | import { useragents } from '../../test/useragents.js' 3 | import { 4 | getUserAgentRegex, 5 | getUserAgentRegexes 6 | } from './useragentRegex.js' 7 | 8 | function* getUserAgents() { 9 | const regexesCache = new Map() 10 | const getRegex = (query: string, allowHigherVersions = true) => { 11 | const key = `${query}:${allowHigherVersions}` 12 | let regex = regexesCache.get(key) 13 | 14 | if (!regex) { 15 | regex = getUserAgentRegex({ 16 | browsers: query, 17 | allowHigherVersions, 18 | allowZeroSubversions: true 19 | }) 20 | regexesCache.set(key, regex) 21 | } 22 | 23 | return regex 24 | } 25 | 26 | for (const useragent of useragents) { 27 | if (useragent.yes) { 28 | for (const query of useragent.yes) { 29 | yield { 30 | ua: useragent.ua, 31 | regex: getRegex(query, useragent.allowHigherVersions), 32 | query, 33 | allowHigherVersions: useragent.allowHigherVersions, 34 | should: true 35 | } 36 | } 37 | } 38 | 39 | if (useragent.no) { 40 | for (const query of useragent.no) { 41 | yield { 42 | ua: useragent.ua, 43 | regex: getRegex(query, useragent.allowHigherVersions), 44 | allowHigherVersions: useragent.allowHigherVersions, 45 | query, 46 | should: false 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | interface UserAgentTest { 54 | ua: string 55 | regex: RegExp 56 | query: string 57 | allowHigherVersions?: boolean 58 | should: boolean 59 | } 60 | 61 | function inspect({ query, ua, should, allowHigherVersions }: UserAgentTest) { 62 | const info = getUserAgentRegexes({ 63 | browsers: query, 64 | allowHigherVersions, 65 | allowZeroSubversions: true 66 | }) 67 | const message = `${should ? 'Should' : 'Should not'} matches: 68 | 69 | Useragent: ${ua} 70 | Query: ${query} 71 | ${info.map(_ => ` 72 | Source Regex: ${String(_.sourceRegex)} 73 | Regex: ${String(_.regex)}`).join('')} 74 | ` 75 | 76 | throw new Error(message) 77 | } 78 | 79 | describe('UserAgentRegex', () => { 80 | it('should create correct regexes', () => { 81 | const userAgents = getUserAgents() 82 | let res: boolean 83 | 84 | for (const ua of userAgents) { 85 | res = ua.regex.test(ua.ua) 86 | 87 | if (res !== ua.should) { 88 | inspect(ua) 89 | } 90 | } 91 | }) 92 | }, 5 * 60 * 1000) 93 | -------------------------------------------------------------------------------- /src/useragentRegex/useragentRegex.ts: -------------------------------------------------------------------------------- 1 | import type { SemverCompareOptions } from '../semver/index.js' 2 | import { getRegexesForBrowsers } from '../useragent/index.js' 3 | import { 4 | getBrowsersList, 5 | mergeBrowserVersions 6 | } from '../browsers/index.js' 7 | import { applyVersionsToRegexes } from '../versions/index.js' 8 | import type { UserAgentRegexOptions } from './types.js' 9 | import { 10 | compileRegexes, 11 | compileRegex 12 | } from './utils.js' 13 | 14 | export const defaultOptions = { 15 | ignoreMinor: false, 16 | ignorePatch: true, 17 | allowZeroSubversions: false, 18 | allowHigherVersions: false 19 | } as const satisfies Required 20 | 21 | /** 22 | * Get source regexes objects from browserslist query. 23 | * @param options - Browserslist and semver compare options. 24 | * @returns Source regexes objects. 25 | */ 26 | export function getPreUserAgentRegexes(options: UserAgentRegexOptions = {}) { 27 | const finalOptions = { 28 | ...defaultOptions, 29 | ...options 30 | } 31 | const browsersList = getBrowsersList(finalOptions) 32 | const mergedBrowsers = mergeBrowserVersions(browsersList) 33 | const sourceRegexes = getRegexesForBrowsers(mergedBrowsers, finalOptions) 34 | const versionedRegexes = applyVersionsToRegexes(sourceRegexes, finalOptions) 35 | 36 | return versionedRegexes 37 | } 38 | 39 | /** 40 | * Compile browserslist query to regexes. 41 | * @param options - Browserslist and semver compare options. 42 | * @returns Objects with info about compiled regexes. 43 | */ 44 | export function getUserAgentRegexes(options: UserAgentRegexOptions = {}) { 45 | return compileRegexes( 46 | getPreUserAgentRegexes(options) 47 | ) 48 | } 49 | 50 | /** 51 | * Compile browserslist query to regex. 52 | * @param options - Browserslist and semver compare options. 53 | * @returns Compiled regex. 54 | */ 55 | export function getUserAgentRegex(options: UserAgentRegexOptions = {}) { 56 | return compileRegex( 57 | getPreUserAgentRegexes(options) 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/useragentRegex/utils.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserVersionedRegex } from '../useragent/types.js' 2 | import { 3 | optimizeRegex, 4 | toRegex, 5 | CapturingGroupNode, 6 | AstRegExpNode, 7 | DisjunctionCapturingGroupNode 8 | } from '../regex/index.js' 9 | 10 | /** 11 | * Compile regexes. 12 | * @param regexes - Objects with info about compiled regexes. 13 | * @returns Objects with info about compiled regexes. 14 | */ 15 | export function compileRegexes(regexes: BrowserVersionedRegex[]) { 16 | return regexes.map(({ 17 | regexAst, 18 | ...regex 19 | }) => { 20 | const optimizedRegexAst = optimizeRegex(regexAst) 21 | 22 | return { 23 | ...regex, 24 | regexAst: optimizedRegexAst, 25 | regex: toRegex(optimizedRegexAst) 26 | } 27 | }) 28 | } 29 | 30 | /** 31 | * Compile regex. 32 | * @param regexes - Objects with info about compiled regexes. 33 | * @returns Compiled common regex. 34 | */ 35 | export function compileRegex(regexes: BrowserVersionedRegex[]) { 36 | const partsRegexes = regexes.map( 37 | ({ regexAst }) => CapturingGroupNode(regexAst.body) 38 | ) 39 | const regexAst = optimizeRegex( 40 | AstRegExpNode( 41 | DisjunctionCapturingGroupNode(partsRegexes) 42 | ) 43 | ) 44 | 45 | return toRegex(regexAst) 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compare two arrays. 3 | * @param a - Array to compare. 4 | * @param b - Array to compare. 5 | * @param from - Index to start compare from. 6 | * @returns Equals or not. 7 | */ 8 | export function compareArrays(a: unknown[], b: unknown[], from = 0) { 9 | const len = a.length 10 | 11 | for (let i = from; i < len; i++) { 12 | if (a[i] !== b[i]) { 13 | return false 14 | } 15 | } 16 | 17 | return true 18 | } 19 | 20 | /** 21 | * Clone simple object. 22 | * @param value 23 | * @returns Object clone. 24 | */ 25 | export function clone(value: T): T { 26 | if (value === null || typeof value !== 'object') { 27 | return value 28 | } 29 | 30 | /* eslint-disable */ 31 | const copy = Array.isArray(value) 32 | ? [] 33 | : {} 34 | let i 35 | 36 | for (i in value) { 37 | copy[i] = clone(value[i]) 38 | } 39 | /* eslint-enable */ 40 | 41 | return copy as T 42 | } 43 | 44 | /** 45 | * Concat arrays. 46 | * @param items 47 | * @returns Concatinated arrays. 48 | */ 49 | export function concat(items: (T | T[])[]) { 50 | return ([] as T[]).concat(...items) 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { compareArrays } from './index.js' 3 | 4 | describe('Utils', () => { 5 | describe('compareArrays', () => { 6 | it('should correct compare arrays', () => { 7 | expect( 8 | compareArrays([], []) 9 | ).toBe( 10 | true 11 | ) 12 | 13 | expect( 14 | compareArrays([ 15 | 1, 16 | 2, 17 | 3 18 | ], [ 19 | 1, 20 | 2, 21 | 3 22 | ]) 23 | ).toBe( 24 | true 25 | ) 26 | 27 | expect( 28 | compareArrays([ 29 | 3, 30 | 2, 31 | 3 32 | ], [ 33 | 1, 34 | 2, 35 | 3 36 | ]) 37 | ).toBe( 38 | false 39 | ) 40 | 41 | expect( 42 | compareArrays([ 43 | 3, 44 | 2, 45 | 3 46 | ], [ 47 | 1, 48 | 2, 49 | 3 50 | ], 1) 51 | ).toBe( 52 | true 53 | ) 54 | 55 | expect( 56 | compareArrays([ 57 | 3, 58 | 2, 59 | 3 60 | ], [ 61 | 1, 62 | 2, 63 | 3 64 | ], 2) 65 | ).toBe( 66 | true 67 | ) 68 | 69 | expect( 70 | compareArrays([ 71 | 3, 72 | 2, 73 | 3 74 | ], [ 75 | 1, 76 | 2, 77 | 3 78 | ], 3) 79 | ).toBe( 80 | true 81 | ) 82 | 83 | expect( 84 | compareArrays([3, 1], [1, 2], 3) 85 | ).toBe( 86 | true 87 | ) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /src/versions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './versions.js' 2 | export * from './utils.js' 3 | -------------------------------------------------------------------------------- /src/versions/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { 3 | AlternativeNode, 4 | SimpleCharNode, 5 | toString 6 | } from '../regex/index.js' 7 | import { 8 | getNumberPatternsCount, 9 | replaceNumberPatterns, 10 | getNumberPatternsPart, 11 | rangedSemverToRegex 12 | } from './utils.js' 13 | 14 | describe('Versions', () => { 15 | describe('utils', () => { 16 | describe('getNumberPatternsCount', () => { 17 | it('should find correct number patterns count', () => { 18 | expect( 19 | getNumberPatternsCount('__(\\d+)__') 20 | ).toBe( 21 | 1 22 | ) 23 | 24 | expect( 25 | getNumberPatternsCount(/__(\d+)__/) 26 | ).toBe( 27 | 1 28 | ) 29 | 30 | expect( 31 | getNumberPatternsCount('__(\\d+)__(\\d+)') 32 | ).toBe( 33 | 2 34 | ) 35 | 36 | expect( 37 | getNumberPatternsCount(/__(\d+)__(\d+)/) 38 | ).toBe( 39 | 2 40 | ) 41 | 42 | expect( 43 | getNumberPatternsCount('__') 44 | ).toBe( 45 | 0 46 | ) 47 | 48 | expect( 49 | getNumberPatternsCount(/__/) 50 | ).toBe( 51 | 0 52 | ) 53 | }) 54 | }) 55 | 56 | describe('replaceNumberPatterns', () => { 57 | const numbers = [ 58 | SimpleCharNode('1'), 59 | SimpleCharNode('2'), 60 | SimpleCharNode('3') 61 | ] 62 | 63 | it('should replace in RegExp', () => { 64 | expect( 65 | toString(replaceNumberPatterns(/__(\d+)__/, numbers)) 66 | ).toBe( 67 | '/__1__/' 68 | ) 69 | 70 | expect( 71 | toString(replaceNumberPatterns(/__(\d+)__(\d+)/, numbers)) 72 | ).toBe( 73 | '/__1__2/' 74 | ) 75 | 76 | expect( 77 | toString(replaceNumberPatterns(/__(\d+)__(\d+)__(\d+)/, numbers)) 78 | ).toBe( 79 | '/__1__2__3/' 80 | ) 81 | 82 | expect( 83 | toString(replaceNumberPatterns(/__/, numbers)) 84 | ).toBe( 85 | '/__/' 86 | ) 87 | }) 88 | 89 | it('should replace in string', () => { 90 | expect( 91 | toString(replaceNumberPatterns('__(\\d+)__', numbers)) 92 | ).toBe( 93 | '/__1__/' 94 | ) 95 | 96 | expect( 97 | toString(replaceNumberPatterns('__(\\d+)__(\\d+)', numbers)) 98 | ).toBe( 99 | '/__1__2/' 100 | ) 101 | 102 | expect( 103 | toString(replaceNumberPatterns('__(\\d+)__(\\d+)__(\\d+)', numbers)) 104 | ).toBe( 105 | '/__1__2__3/' 106 | ) 107 | 108 | expect( 109 | toString(replaceNumberPatterns('__', numbers)) 110 | ).toBe( 111 | '/__/' 112 | ) 113 | }) 114 | 115 | it('should replace only given count', () => { 116 | expect( 117 | toString(replaceNumberPatterns('__(\\d+)__(\\d+)__(\\d+)', numbers, 3)) 118 | ).toBe( 119 | '/__1__2__3/' 120 | ) 121 | 122 | expect( 123 | toString(replaceNumberPatterns('__(\\d+)__(\\d+)__(\\d+)', numbers, 2)) 124 | ).toBe( 125 | '/__1__2__(\\d+)/' 126 | ) 127 | 128 | expect( 129 | toString(replaceNumberPatterns('__(\\d+)__(\\d+)__(\\d+)', numbers, 1)) 130 | ).toBe( 131 | '/__1__(\\d+)__(\\d+)/' 132 | ) 133 | 134 | expect( 135 | toString(replaceNumberPatterns('__(\\d+)__(\\d+)__(\\d+)', numbers, 0)) 136 | ).toBe( 137 | '/__(\\d+)__(\\d+)__(\\d+)/' 138 | ) 139 | }) 140 | }) 141 | 142 | describe('getNumberPatternsPart', () => { 143 | it('should work with RegExp', () => { 144 | expect( 145 | toString(AlternativeNode(getNumberPatternsPart(/Chrome(\d+)/))) 146 | ).toBe( 147 | '(\\d+)' 148 | ) 149 | }) 150 | 151 | it('should work with string', () => { 152 | expect( 153 | toString(AlternativeNode(getNumberPatternsPart('Chrome(\\d+)'))) 154 | ).toBe( 155 | '(\\d+)' 156 | ) 157 | 158 | expect( 159 | toString(AlternativeNode(getNumberPatternsPart('Chrome\\/(\\d+)\\.(\\d+)'))) 160 | ).toBe( 161 | '(\\d+)\\.(\\d+)' 162 | ) 163 | }) 164 | 165 | it('should ignore square braces', () => { 166 | expect( 167 | toString(AlternativeNode(getNumberPatternsPart('[(\\d+)](\\d+)'))) 168 | ).toBe( 169 | '(\\d+)' 170 | ) 171 | }) 172 | 173 | it('should handle escaped braces', () => { 174 | expect( 175 | toString(AlternativeNode(getNumberPatternsPart('\\(\\d+\\) (not a number) (\\d+)'))) 176 | ).toBe( 177 | '(\\d+)' 178 | ) 179 | }) 180 | 181 | it('should handle nested groups', () => { 182 | expect( 183 | toString(AlternativeNode(getNumberPatternsPart('Chrome (Canary(\\d+)) (\\d+)(test)'))) 184 | ).toBe( 185 | '(Canary(\\d+)) (\\d+)' 186 | ) 187 | 188 | expect( 189 | toString(AlternativeNode(getNumberPatternsPart('Browser (a (b (\\d+) (\\d+)))'))) 190 | ).toBe( 191 | '(\\d+) (\\d+)' 192 | ) 193 | }) 194 | 195 | it('should handle only given number patterns count', () => { 196 | expect( 197 | toString(AlternativeNode(getNumberPatternsPart('(\\d+)1(\\d+)2(\\d+)3', 2))) 198 | ).toBe( 199 | '(\\d+)1(\\d+)' 200 | ) 201 | 202 | expect( 203 | toString(AlternativeNode(getNumberPatternsPart('(\\d+)1(\\d+)2(\\d+)3', 1))) 204 | ).toBe( 205 | '(\\d+)' 206 | ) 207 | 208 | expect( 209 | toString(AlternativeNode(getNumberPatternsPart('Chrome (Canary(\\d+)) (\\d+)', 1))) 210 | ).toBe( 211 | '(\\d+)' 212 | ) 213 | }) 214 | 215 | it('should not capture usless part if passed patterns count is more', () => { 216 | expect( 217 | toString(AlternativeNode(getNumberPatternsPart('pre (\\d+) post', 2))) 218 | ).toBe( 219 | '(\\d+)' 220 | ) 221 | }) 222 | }) 223 | 224 | describe('rangedSemverToRegex', () => { 225 | describe('disallow higher versions', () => { 226 | const options = { 227 | ignoreMinor: false, 228 | ignorePatch: false, 229 | allowHigherVersions: false 230 | } 231 | 232 | it('should return only numbers', () => { 233 | expect( 234 | rangedSemverToRegex( 235 | [ 236 | 11, 237 | 12, 238 | 0 239 | ], 240 | options 241 | ).map(_ => _.map(toString)) 242 | ).toEqual([ 243 | [ 244 | '11', 245 | '12', 246 | '0' 247 | ] 248 | ]) 249 | }) 250 | 251 | it('should return only numbers patterns', () => { 252 | expect( 253 | rangedSemverToRegex( 254 | [ 255 | Infinity, 256 | 0, 257 | 0 258 | ], 259 | options 260 | ).map(_ => _.map(toString)) 261 | ).toEqual([ 262 | [ 263 | '\\d+', 264 | '\\d+', 265 | '\\d+' 266 | ] 267 | ]) 268 | }) 269 | 270 | it('should return number pattern at patch', () => { 271 | const ignorePatchOptions = { 272 | ...options, 273 | ignorePatch: true 274 | } 275 | 276 | expect( 277 | rangedSemverToRegex( 278 | [ 279 | 11, 280 | 12, 281 | 0 282 | ], 283 | ignorePatchOptions 284 | ).map(_ => _.map(toString)) 285 | ).toEqual([ 286 | [ 287 | '11', 288 | '12', 289 | '\\d+' 290 | ] 291 | ]) 292 | }) 293 | 294 | it('should return number patterns at minor and patch', () => { 295 | const ignoreMinorOptions = { 296 | ...options, 297 | ignoreMinor: true 298 | } 299 | 300 | expect( 301 | rangedSemverToRegex( 302 | [ 303 | 11, 304 | 12, 305 | 0 306 | ], 307 | ignoreMinorOptions 308 | ).map(_ => _.map(toString)) 309 | ).toEqual([ 310 | [ 311 | '11', 312 | '\\d+', 313 | '\\d+' 314 | ] 315 | ]) 316 | }) 317 | 318 | it('should return ranged major', () => { 319 | expect( 320 | rangedSemverToRegex( 321 | [ 322 | [11, 13], 323 | 12, 324 | 0 325 | ], 326 | options 327 | ).map(_ => _.map(toString)) 328 | ).toEqual([ 329 | [ 330 | '1[1-3]', 331 | '12', 332 | '0' 333 | ] 334 | ]) 335 | expect( 336 | rangedSemverToRegex( 337 | [ 338 | 11, 339 | [12, 15], 340 | 2 341 | ], 342 | options 343 | ).map(_ => _.map(toString)) 344 | ).toEqual([ 345 | [ 346 | '11', 347 | '1[2-5]', 348 | '2' 349 | ] 350 | ]) 351 | expect( 352 | rangedSemverToRegex( 353 | [ 354 | 11, 355 | 12, 356 | [2, 5] 357 | ], 358 | options 359 | ).map(_ => _.map(toString)) 360 | ).toEqual([ 361 | [ 362 | '11', 363 | '12', 364 | '[2-5]' 365 | ] 366 | ]) 367 | }) 368 | }) 369 | 370 | describe('allow higher versions', () => { 371 | const allowHigherOptions = { 372 | allowHigherVersions: true 373 | } 374 | 375 | it('should return ranged major ray', () => { 376 | expect( 377 | rangedSemverToRegex( 378 | [ 379 | [11, 13], 380 | 12, 381 | 1 382 | ], 383 | allowHigherOptions 384 | ).map(_ => _.map(toString)) 385 | ).toEqual([ 386 | [ 387 | '11', 388 | '12', 389 | '([1-9]|\\d{2,})' 390 | ], 391 | [ 392 | '11', 393 | '(1[3-9]|[2-9]\\d|\\d{3,})', 394 | '\\d+' 395 | ], 396 | [ 397 | '(1[2-9]|[2-9]\\d|\\d{3,})', 398 | '\\d+', 399 | '\\d+' 400 | ] 401 | ]) 402 | }) 403 | 404 | it('should collapse zero tails', () => { 405 | expect( 406 | rangedSemverToRegex( 407 | [ 408 | [11, 13], 409 | 12, 410 | 0 411 | ], 412 | allowHigherOptions 413 | ).map(_ => _.map(toString)) 414 | ).toEqual([ 415 | [ 416 | '11', 417 | '(1[2-9]|[2-9]\\d|\\d{3,})', 418 | '\\d+' 419 | ], 420 | [ 421 | '(1[2-9]|[2-9]\\d|\\d{3,})', 422 | '\\d+', 423 | '\\d+' 424 | ] 425 | ]) 426 | expect( 427 | rangedSemverToRegex( 428 | [ 429 | [11, 13], 430 | 0, 431 | 0 432 | ], 433 | allowHigherOptions 434 | ).map(_ => _.map(toString)) 435 | ).toEqual([ 436 | [ 437 | '(1[1-9]|[2-9]\\d|\\d{3,})', 438 | '\\d+', 439 | '\\d+' 440 | ] 441 | ]) 442 | }) 443 | 444 | it('should respect ignore options', () => { 445 | expect( 446 | rangedSemverToRegex( 447 | [ 448 | [11, 13], 449 | 12, 450 | 11 451 | ], 452 | { 453 | allowHigherVersions: true, 454 | ignorePatch: true 455 | } 456 | ).map(_ => _.map(toString)) 457 | ).toEqual([ 458 | [ 459 | '11', 460 | '(1[2-9]|[2-9]\\d|\\d{3,})', 461 | '\\d+' 462 | ], 463 | [ 464 | '(1[2-9]|[2-9]\\d|\\d{3,})', 465 | '\\d+', 466 | '\\d+' 467 | ] 468 | ]) 469 | expect( 470 | rangedSemverToRegex( 471 | [ 472 | [11, 13], 473 | 1, 474 | 2 475 | ], 476 | { 477 | allowHigherVersions: true, 478 | ignoreMinor: true 479 | } 480 | ).map(_ => _.map(toString)) 481 | ).toEqual([ 482 | [ 483 | '(1[1-9]|[2-9]\\d|\\d{3,})', 484 | '\\d+', 485 | '\\d+' 486 | ] 487 | ]) 488 | }) 489 | }) 490 | }) 491 | }) 492 | }) 493 | -------------------------------------------------------------------------------- /src/versions/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AstRegExp, 3 | AstNode, 4 | Expression 5 | } from 'regexp-tree/ast' 6 | import type { NodePath } from 'regexp-tree' 7 | import RegexpTree from 'regexp-tree' 8 | import type { 9 | SemverRange, 10 | RangedSemver, 11 | SemverCompareOptions 12 | } from '../semver/index.js' 13 | import { 14 | parseRegex, 15 | isNumberPatternNode, 16 | isExpressionNode, 17 | visitors, 18 | NumberPatternNode, 19 | NumberCharsNode 20 | } from '../regex/index.js' 21 | import { rangeToRegex } from '../numbers/index.js' 22 | 23 | /** 24 | * Get number patterns count from the regex. 25 | * @param regex - Target regex. 26 | * @returns Number patterns count. 27 | */ 28 | export function getNumberPatternsCount(regex: string | RegExp | AstNode) { 29 | const regexAst = parseRegex(regex) 30 | let count = 0 31 | 32 | RegexpTree.traverse(regexAst, { 33 | Group(nodePath: NodePath) { 34 | if (isNumberPatternNode(nodePath.node)) { 35 | count++ 36 | } 37 | } 38 | }) 39 | 40 | return count 41 | } 42 | 43 | /** 44 | * Replace number patterns. 45 | * @param regex - Target regex. 46 | * @param numbers - Number patterns to paste. 47 | * @param numberPatternsCount - Number patterns count to replace. 48 | * @returns Regex with replaced number patterns. 49 | */ 50 | export function replaceNumberPatterns( 51 | regex: string | RegExp | AstRegExp, 52 | numbers: Expression[], 53 | numberPatternsCount?: number 54 | ): AstRegExp 55 | export function replaceNumberPatterns( 56 | regex: T, 57 | numbers: Expression[], 58 | numberPatternsCount?: number 59 | ): T 60 | 61 | export function replaceNumberPatterns( 62 | regex: string | RegExp | AstNode, 63 | numbers: Expression[], 64 | numberPatternsCount?: number 65 | ) { 66 | let regexAst = parseRegex(regex) 67 | const numbersToReplace = typeof numberPatternsCount === 'number' 68 | && numberPatternsCount < numbers.length 69 | ? numbers.slice(0, numberPatternsCount) 70 | : numbers.slice() 71 | 72 | RegexpTree.traverse(regexAst, visitors({ 73 | every() { 74 | return Boolean(numbersToReplace.length) 75 | }, 76 | Group(nodePath: NodePath) { 77 | if (isNumberPatternNode(nodePath.node) && numbersToReplace.length) { 78 | if (regexAst === nodePath.node) { 79 | regexAst = numbersToReplace.shift() 80 | } else { 81 | nodePath.replace(numbersToReplace.shift()) 82 | } 83 | 84 | return false 85 | } 86 | 87 | return true 88 | } 89 | })) 90 | 91 | return regexAst 92 | } 93 | 94 | /** 95 | * Get from regex part with number patterns. 96 | * @param regex - Target regex. 97 | * @param numberPatternsCount - Number patterns to extract. 98 | * @returns Regex part with number patterns. 99 | */ 100 | export function getNumberPatternsPart(regex: string | RegExp | AstNode, numberPatternsCount?: number): Expression[] { 101 | const regexAst = parseRegex(regex) 102 | const maxNumbersCount = Math.min( 103 | getNumberPatternsCount(regexAst), 104 | numberPatternsCount || Infinity 105 | ) 106 | const expressions: Expression[] = [] 107 | let numbersCounter = 0 108 | let containsNumberPattern = false 109 | 110 | RegexpTree.traverse(regexAst, visitors({ 111 | every: { 112 | pre({ node, parent }) { 113 | if (node === regexAst) { 114 | return true 115 | } 116 | 117 | if (!isExpressionNode(node)) { 118 | return false 119 | } 120 | 121 | if (parent === regexAst) { 122 | containsNumberPattern = false 123 | } 124 | 125 | return numbersCounter < maxNumbersCount 126 | }, 127 | post({ node, parent }) { 128 | if (node !== regexAst && parent === regexAst 129 | && isExpressionNode(node) 130 | && (containsNumberPattern || numbersCounter > 0 && numbersCounter < maxNumbersCount) 131 | ) { 132 | expressions.push(node) 133 | } 134 | } 135 | }, 136 | Group(nodePath: NodePath) { 137 | if (isNumberPatternNode(nodePath.node) && numbersCounter < maxNumbersCount) { 138 | containsNumberPattern = true 139 | numbersCounter++ 140 | 141 | return false 142 | } 143 | 144 | return true 145 | } 146 | })) 147 | 148 | if (expressions.length === 1 && !isNumberPatternNode(expressions[0])) { 149 | return getNumberPatternsPart(expressions[0], maxNumbersCount) 150 | } 151 | 152 | return expressions 153 | } 154 | 155 | /** 156 | * Ranged semver to regex patterns. 157 | * @param rangedVersion - Ranged semver. 158 | * @param options - Semver compare options. 159 | * @returns Array of regex pattern. 160 | */ 161 | export function rangedSemverToRegex(rangedVersion: RangedSemver, options: SemverCompareOptions) { 162 | const { 163 | ignoreMinor, 164 | ignorePatch, 165 | allowHigherVersions 166 | } = options 167 | const ignoreIndex = rangedVersion[0] === Infinity 168 | ? 0 169 | : ignoreMinor 170 | ? 1 171 | : ignorePatch 172 | ? 2 173 | : 3 174 | 175 | if (allowHigherVersions) { 176 | const numberPatterns: Expression[][] = [] 177 | let prevWasZero = true 178 | let d = 0 179 | let start = 0 180 | const createMapper = (i: number) => (range: SemverRange, j: number) => { 181 | if (j >= ignoreIndex) { 182 | return NumberPatternNode() 183 | } 184 | 185 | start = Array.isArray(range) 186 | ? range[0] 187 | : range 188 | 189 | if (j < i) { 190 | return NumberCharsNode(start) 191 | } 192 | 193 | if (j > i) { 194 | return NumberPatternNode() 195 | } 196 | 197 | return rangeToRegex(start + d) 198 | } 199 | 200 | for (let i = ignoreIndex - 1; i >= 0; i--) { 201 | if (prevWasZero && !rangedVersion[i]) { 202 | continue 203 | } 204 | 205 | prevWasZero = false 206 | numberPatterns.push(rangedVersion.map(createMapper(i))) 207 | d = 1 208 | } 209 | 210 | return numberPatterns 211 | } 212 | 213 | const numberPatterns = rangedVersion.map((range, i) => { 214 | if (i >= ignoreIndex) { 215 | return NumberPatternNode() 216 | } 217 | 218 | if (Array.isArray(range)) { 219 | return rangeToRegex( 220 | range[0], 221 | range[1] 222 | ) 223 | } 224 | 225 | return NumberCharsNode(range) 226 | }) 227 | 228 | return [numberPatterns] 229 | } 230 | -------------------------------------------------------------------------------- /src/versions/versions.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import type { BrowserRegex } from '../useragent/types.js' 3 | import { 4 | toString, 5 | toRegex 6 | } from '../regex/index.js' 7 | import { 8 | applyVersionsToRegex, 9 | applyVersionsToRegexes 10 | } from './versions.js' 11 | 12 | describe('Versions', () => { 13 | describe('versions', () => { 14 | describe('applyVersionsToRegex', () => { 15 | it('should work with RegExp', () => { 16 | expect( 17 | toString(applyVersionsToRegex( 18 | /Chrome v(\d+) (\d+) (\d+)/, 19 | [ 20 | [ 21 | [10, 11], 22 | 0, 23 | 0 24 | ], 25 | [ 26 | 64, 27 | 0, 28 | 0 29 | ] 30 | ], 31 | {} 32 | )) 33 | ).toBe( 34 | '/Chrome v(1[01] 0 0|64 0 0)/' 35 | ) 36 | }) 37 | 38 | it('should work with string', () => { 39 | expect( 40 | toString(applyVersionsToRegex( 41 | 'Chrome v(\\d+) (\\d+) (\\d+)?', 42 | [ 43 | [ 44 | [10, 11], 45 | 0, 46 | 0 47 | ], 48 | [ 49 | 64, 50 | 0, 51 | 0 52 | ] 53 | ], 54 | {} 55 | )) 56 | ).toBe( 57 | '/Chrome v(1[01] 0 0?|64 0 0?)/' 58 | ) 59 | }) 60 | 61 | it('should apply semver compare options', () => { 62 | expect( 63 | toString(applyVersionsToRegex( 64 | 'Chrome v(\\d+)', 65 | [ 66 | [ 67 | [10, 11], 68 | 0, 69 | 0 70 | ], 71 | [ 72 | 64, 73 | 2, 74 | 2 75 | ], 76 | [ 77 | 64, 78 | 0, 79 | 0 80 | ] 81 | ], 82 | { 83 | ignorePatch: true, 84 | allowZeroSubversions: true, 85 | allowHigherVersions: true 86 | } 87 | )) 88 | ).toBe( 89 | '/Chrome v([1-9]\\d|\\d{3,})/' 90 | ) 91 | }) 92 | 93 | it('should apply semver to match higher version ', () => { 94 | expect( 95 | toString(applyVersionsToRegex( 96 | 'Chrome v(\\d+) (\\d+)', 97 | [ 98 | [ 99 | 8, 100 | 2, 101 | 0 102 | ] 103 | ], 104 | { 105 | ignorePatch: true, 106 | allowZeroSubversions: true, 107 | allowHigherVersions: true 108 | } 109 | )) 110 | ).toBe( 111 | '/Chrome v(8 ([2-9]|\\d{2,})|(9|\\d{2,}) \\d+)/' 112 | ) 113 | }) 114 | }) 115 | 116 | describe('applyVersionsToRegexes', () => { 117 | const regexes: BrowserRegex[] = [ 118 | { 119 | family: 'chrome', 120 | regex: /Chrome (\d+) (\d+) (\d+)/, 121 | requestVersions: [[64, 0, 0], [73, 0, 0]], 122 | matchedVersions: [[64, 0, 0], [73, 0, 0]] 123 | }, 124 | { 125 | family: 'firefox', 126 | regex: /FF/, 127 | requestVersions: [[1, 2, 3]], 128 | matchedVersions: [[1, 2, 3]] 129 | }, 130 | { 131 | family: 'ie', 132 | regex: /lol serious?/, 133 | requestVersions: [[5, 0, 0]], 134 | version: [5, 0, 0], 135 | matchedVersions: [[5, 0, 0]] 136 | } 137 | ] 138 | 139 | it('should return versioned RegExp objects with info', () => { 140 | expect( 141 | applyVersionsToRegexes( 142 | regexes, 143 | { 144 | allowZeroSubversions: true, 145 | allowHigherVersions: true 146 | } 147 | ).map(({ regexAst, ...regex }) => ({ 148 | ...regex, 149 | regex: toRegex(regexAst) 150 | })) 151 | ).toEqual([ 152 | { 153 | ...regexes[0], 154 | sourceRegex: regexes[0].regex, 155 | regex: /Chrome (6[4-9]|[7-9]\d|\d{3,}) (\d+) (\d+)/ 156 | }, 157 | { 158 | ...regexes[1], 159 | sourceRegex: regexes[1].regex, 160 | regex: /FF/ 161 | }, 162 | { 163 | ...regexes[2], 164 | sourceRegex: regexes[2].regex, 165 | regex: /lol serious?/ 166 | } 167 | ]) 168 | }) 169 | }) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /src/versions/versions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AstRegExp, 3 | AstNode 4 | } from 'regexp-tree/ast' 5 | import RegexpTree from 'regexp-tree' 6 | import type { 7 | BrowserRegex, 8 | BrowserVersionedRegex 9 | } from '../useragent/types.js' 10 | import { clone } from '../utils/index.js' 11 | import type { 12 | RangedSemver, 13 | SemverCompareOptions 14 | } from '../semver/index.js' 15 | import { getRequiredSemverPartsCount } from '../semver/index.js' 16 | import { versionsListToRanges } from '../browsers/index.js' 17 | import { 18 | parseRegex, 19 | AlternativeNode, 20 | DisjunctionCapturingGroupNode, 21 | visitors 22 | } from '../regex/index.js' 23 | import { 24 | getNumberPatternsPart, 25 | replaceNumberPatterns, 26 | rangedSemverToRegex 27 | } from './utils.js' 28 | 29 | /** 30 | * Apply ranged sevmers to the regex. 31 | * @param regex - Target regex. 32 | * @param versions - Ranged semvers. 33 | * @param options - Semver compare options. 34 | * @returns Regex with given versions. 35 | */ 36 | export function applyVersionsToRegex( 37 | regex: string | RegExp | AstRegExp, 38 | versions: RangedSemver[], 39 | options: SemverCompareOptions 40 | ): AstRegExp 41 | export function applyVersionsToRegex( 42 | regex: T, 43 | versions: RangedSemver[], 44 | options: SemverCompareOptions 45 | ): T 46 | 47 | export function applyVersionsToRegex( 48 | regex: string | RegExp | AstNode, 49 | versions: RangedSemver[], 50 | options: SemverCompareOptions 51 | ) { 52 | const { allowHigherVersions } = options 53 | const regexAst = parseRegex(regex) 54 | const finalVersions = allowHigherVersions && versions.length 55 | ? [versions[0]] 56 | : versions 57 | const maxRequiredPartsCount = finalVersions.reduce( 58 | (maxRequiredPartsCount, version) => Math.max( 59 | maxRequiredPartsCount, 60 | getRequiredSemverPartsCount(version, options) 61 | ), 62 | 1 63 | ) 64 | const numberPatternsPart = getNumberPatternsPart(regexAst, maxRequiredPartsCount) 65 | const versionsPart = DisjunctionCapturingGroupNode( 66 | ...finalVersions.map( 67 | version => rangedSemverToRegex(version, options) 68 | .map(parts => replaceNumberPatterns( 69 | AlternativeNode(clone(numberPatternsPart)), 70 | parts, 71 | maxRequiredPartsCount 72 | )) 73 | ) 74 | ) 75 | 76 | RegexpTree.traverse(regexAst, visitors({ 77 | every(nodePath) { 78 | if (!numberPatternsPart.length) { 79 | return false 80 | } 81 | 82 | if (nodePath.node === numberPatternsPart[0]) { 83 | if (numberPatternsPart.length === 1) { 84 | nodePath.replace(versionsPart) 85 | } else { 86 | nodePath.remove() 87 | } 88 | 89 | numberPatternsPart.shift() 90 | } 91 | 92 | return true 93 | } 94 | })) 95 | 96 | return regexAst 97 | } 98 | 99 | /** 100 | * Apply browser versions to info objects. 101 | * @param browserRegexes - Objects with requested browser version and regex. 102 | * @param options - Semver compare options. 103 | * @returns Objects with requested browser version and regex special for this version. 104 | */ 105 | export function applyVersionsToRegexes( 106 | browserRegexes: BrowserRegex[], 107 | options: SemverCompareOptions 108 | ): BrowserVersionedRegex[] { 109 | return browserRegexes.map(({ 110 | regex: sourceRegex, 111 | version, 112 | maxVersion, 113 | matchedVersions, 114 | ...other 115 | }) => { 116 | let regexAst = parseRegex(sourceRegex) 117 | 118 | if (!version) { 119 | regexAst = applyVersionsToRegex( 120 | regexAst, 121 | versionsListToRanges(matchedVersions), 122 | { 123 | ...options, 124 | allowHigherVersions: !maxVersion && options.allowHigherVersions 125 | } 126 | ) 127 | } 128 | 129 | return { 130 | regex: null, 131 | sourceRegex, 132 | regexAst, 133 | version, 134 | maxVersion, 135 | matchedVersions, 136 | ...other 137 | } 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [ 4 | "../src/**/*.spec.ts", 5 | "." 6 | ], 7 | "exclude": [] 8 | } 9 | -------------------------------------------------------------------------------- /test/useragents.ts: -------------------------------------------------------------------------------- 1 | export const useragents = [ 2 | /** 3 | * IE 6 4 | */ 5 | { 6 | ua: 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)', 7 | yes: ['ie 6', 'ie >= 5', 'ie 6-7'] 8 | }, 9 | /** 10 | * IE 7 11 | */ 12 | { 13 | ua: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)', 14 | yes: ['ie 7', 'ie >= 7', 'ie 6-7'] 15 | }, 16 | /** 17 | * IE 8 18 | */ 19 | { 20 | ua: 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)', 21 | yes: ['ie 8', 'ie >= 8', 'ie 8-9'] 22 | }, 23 | /** 24 | * IE 8 Compatability Mode 25 | */ 26 | { 27 | ua: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)', 28 | yes: ['ie 8', 'ie >= 8', 'ie 8-9'] 29 | }, 30 | /** 31 | * IE 9 32 | */ 33 | { 34 | ua: 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', 35 | yes: ['ie 9', 'ie >= 9', 'ie 8-9'] 36 | }, 37 | /** 38 | * IE 9 Compatability Mode 39 | */ 40 | { 41 | ua: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET4.0C; .NET4.0E)', 42 | yes: ['ie 9', 'ie >= 9', 'ie 8-9'] 43 | }, 44 | /** 45 | * IE 10 46 | */ 47 | { 48 | ua: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)', 49 | yes: ['ie 10', 'ie >= 10', 'ie 10-11'] 50 | }, 51 | { 52 | ua: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Win64; x64; Trident/6.0)', 53 | yes: ['ie 10', 'ie >= 10', 'ie 10-11'] 54 | }, 55 | /** 56 | * IE 10 Compatability Mode 57 | */ 58 | { 59 | ua: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/6.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET4.0C; .NET4.0E)', 60 | yes: ['ie 10', 'ie >= 10', 'ie 10-11'] 61 | }, 62 | /** 63 | * IE 11 64 | */ 65 | { 66 | ua: 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; Trident/7.0; rv:11.0) like Gecko', 67 | yes: ['ie 11', 'ie >= 11', 'ie 10-11'] 68 | }, 69 | /** 70 | * IE 11 Compatability Mode 71 | */ 72 | { 73 | ua: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729)', 74 | yes: ['ie 11', 'ie >= 11', 'ie 10-11'] 75 | }, 76 | /** 77 | * Edge on EdgeHTML 78 | */ 79 | { 80 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063', 81 | yes: ['edge 15', 'edge >= 15', 'edge 15-18'], 82 | no: ['chrome >= 50'] 83 | }, 84 | { 85 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18363', 86 | yes: ['edge 18', 'edge >= 18', 'edge 15-18'], 87 | no: ['chrome >= 50'] 88 | }, 89 | /** 90 | * Edge on Chromium 91 | */ 92 | { 93 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36 Edg/80.0.361.62', 94 | yes: ['edge 80', 'edge >= 80', 'chrome 80'] 95 | }, 96 | { 97 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 Edg/105.0.1343.25', 98 | yes: ['edge 105', 'edge >= 105', 'chrome 105'] 99 | }, 100 | /** 101 | * Edge bug #1530 102 | */ 103 | { 104 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edge/120.0', 105 | allowHigherVersions: false, 106 | yes: ['edge 120'], 107 | no: ['edge 12'] 108 | }, 109 | /** 110 | * Firefox Desktop 111 | */ 112 | { 113 | ua: 'Mozilla/5.0 (Windows NT 5.2; rv:42.0) Gecko/20100101 Firefox/42.0', 114 | yes: ['firefox >= 40'] 115 | }, 116 | /** 117 | * Firefox Desktop bug #1530 118 | */ 119 | { 120 | ua: 'Mozilla/5.0 (Windows NT 5.2; rv:42.0) Gecko/20100101 Firefox/120.0', 121 | allowHigherVersions: false, 122 | yes: ['firefox 120'], 123 | no: ['firefox 12'] 124 | }, 125 | /** 126 | * Chrome Desktop 127 | */ 128 | { 129 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.92 Safari/537.36', 130 | yes: ['chrome >= 60'] 131 | }, 132 | /** 133 | * Chrome Desktop bug #1530 134 | */ 135 | { 136 | ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', 137 | allowHigherVersions: false, 138 | yes: ['chrome 120'], 139 | no: ['chrome 12'] 140 | }, 141 | /** 142 | * Safari Desktop 143 | */ 144 | { 145 | ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15', 146 | yes: ['safari 15'] 147 | }, 148 | /** 149 | * Safari iPad Desktop-like 150 | */ 151 | { 152 | ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15', 153 | yes: ['safari 14'] 154 | }, 155 | { 156 | // Weird case with comma 157 | ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6,2 Safari/605.1.15', 158 | yes: ['safari 15'] 159 | }, 160 | /** 161 | * Opera Desktop on Presto 162 | */ 163 | { 164 | ua: 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11', 165 | yes: ['opera 11'] 166 | }, 167 | { 168 | ua: 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8) Presto/2.12.388 Version/12.15', 169 | yes: ['opera 12'] 170 | }, 171 | /** 172 | * Opera Desktop on Chromium 173 | */ 174 | { 175 | ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.95 Safari/537.36 OPR/15.0.1147.153', 176 | yes: ['opera 15'] 177 | }, 178 | { 179 | ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 OPR/91.0.4516.16', 180 | yes: ['opera 91', 'chrome 105'] 181 | }, 182 | /** 183 | * iOS iPhone Safari 184 | */ 185 | { 186 | ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1', 187 | yes: ['ios_saf 15'] 188 | }, 189 | /** 190 | * iOS iPhone App (WKWebView) 191 | */ 192 | { 193 | ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148', 194 | yes: ['ios_saf 15'] 195 | }, 196 | /** 197 | * Opera Mini 198 | */ 199 | { 200 | ua: 'Opera/9.80 (Android; Opera Mini/12.0.1987/37.7327; U; pl) Presto/2.12.423 Version/12.16', 201 | yes: ['op_mini all'] 202 | }, 203 | /** 204 | * Android 205 | */ 206 | { 207 | ua: 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; SCH-I535 Build/KOT49H) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', 208 | yes: ['android 4'] 209 | }, 210 | /** 211 | * Opera Mobile 212 | */ 213 | { 214 | ua: 'Opera/9.80 (S60; SymbOS; Opera Mobi/SYB-1107071606; U; en) Presto/2.8.149 Version/11.10', 215 | yes: ['op_mob 11'] 216 | }, 217 | { 218 | ua: 'Opera/12.02 (Android 4.1; Linux; Opera Mobi/ADR-1111101157; U; en-US) Presto/2.9.201 Version/12.02', 219 | yes: ['op_mob 12'] 220 | }, 221 | /** 222 | * IE Mobile 223 | */ 224 | { 225 | ua: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 920)', 226 | yes: ['ie_mob >= 1'] 227 | }, 228 | { 229 | ua: 'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 635) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537', 230 | yes: ['ie_mob >= 1'], 231 | no: ['android 4', 'ios 7'] 232 | } 233 | ] 234 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type Checking */ 4 | "strict": false, /* @todo Enable */ 5 | "strictBindCallApply": true, 6 | "noFallthroughCasesInSwitch": true, 7 | "noImplicitOverride": true, 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | /* Modules */ 12 | "module": "NodeNext", 13 | "moduleResolution": "NodeNext", 14 | "resolveJsonModule": true, 15 | /* Emit */ 16 | "declaration": true, 17 | "declarationMap": true, 18 | "inlineSourceMap": true, 19 | "outDir": "dist", 20 | /* Interop Constraints */ 21 | "allowSyntheticDefaultImports": true, 22 | "isolatedModules": true, 23 | "verbatimModuleSyntax": true, 24 | /* Language and Environment */ 25 | "lib": [ 26 | "esnext" 27 | ], 28 | "target": "ESNext", 29 | /* Completeness */ 30 | "skipLibCheck": true 31 | }, 32 | "include": [ 33 | "src" 34 | ], 35 | "exclude": [ 36 | "**/*.spec.ts" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | include: ['src/**/*'], 7 | reporter: ['lcovonly', 'text'] 8 | } 9 | } 10 | }) 11 | --------------------------------------------------------------------------------