├── .editorconfig ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ ├── config.ts ├── data │ ├── chrome.ts │ ├── firefox.ts │ ├── generic.ts │ ├── normalization.ts │ └── url-match-patterns-compat.ts ├── errorMessages.ts ├── index.ts ├── multiplePatterns.ts ├── tsconfig.json └── utils │ └── testUtils.ts ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── config.ts ├── constants.ts ├── getDummyUrl.ts ├── getExampleUrls.ts ├── getHostRegex.ts ├── getPatternSegments.ts ├── index.ts ├── matchPattern.ts ├── toMatcherOrError.ts ├── types.ts └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = tab 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | unsafe-perm = true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | README.md 4 | index.html 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "strict", 5 | "insertPragma": false, 6 | "bracketSameLine": false, 7 | "jsxSingleQuote": true, 8 | "proseWrap": "preserve", 9 | "quoteProps": "as-needed", 10 | "requirePragma": false, 11 | "semi": false, 12 | "singleQuote": true, 13 | "tabWidth": 4, 14 | "useTabs": true, 15 | "trailingComma": "all", 16 | "vueIndentScriptAndStyle": false, 17 | "printWidth": 80 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lionel Rowe 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 | # browser-extension-url-match 2 | 3 | Robust, configurable URL pattern matching, conforming to the algorithm used by [Chrome](https://developer.chrome.com/docs/extensions/mv3/match_patterns/) and [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) browser extensions. 4 | 5 | * Native ESM import (browser, Deno): `import { matchPattern } from 'https://esm.sh/browser-extension-url-match@1.0.0'` 6 | * [NPM module](https://www.npmjs.com/package/browser-extension-url-match) (Node): `npm i browser-extension-url-match` 7 | 8 | This library uses the native [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor and [`Array#flatMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap). Polyfills may be required if you need to support Internet Explorer or Node.js <= 11.X. 9 | 10 | A [live demo](https://clearlylocal.github.io/browser-extension-url-match/) is available. 11 | 12 | ## Usage 13 | 14 | ### Basic usage with `matchPattern` 15 | 16 | The `matchPattern` function takes a pattern or array of patterns as input and returns a valid or invalid `Matcher` object: 17 | * If all input patterns are valid, `matcher.valid` will be `true`. 18 | * If one or more input patterns are invalid, `matcher.valid` will be `false`, and `matcher.error` will contain a diagnostic error object. 19 | 20 | Calling `matcher.assertValid` asserts that the matcher is valid and throws an error at runtime if it isn’t. 21 | 22 | By default, matchers use Chrome presets with strict URL matching. 23 | 24 | ```ts 25 | import { matchPattern } from 'browser-extension-url-match' 26 | 27 | const matcher = matchPattern('https://example.com/foo/*').assertValid() 28 | 29 | matcher.match('https://example.com/foo/bar') // ⇒ true 30 | matcher.match('https://example.com/bar/baz') // ⇒ false 31 | 32 | const matcher2 = matchPattern([ 33 | 'https://example.com/foo/*', 34 | 'https://example.com/bar/*', 35 | ]).assertValid() 36 | 37 | matcher2.match('https://example.com/foo/bar') // ⇒ true 38 | matcher2.match('https://example.com/bar/baz') // ⇒ true 39 | 40 | const matcher3 = matchPattern('').assertValid() 41 | 42 | matcher3.match('https://example.com/foo/bar') // ⇒ true 43 | 44 | const invalidMatcher = matchPattern('htp://example.com/*') 45 | 46 | invalidMatcher.valid // ⇒ false 47 | invalidMatcher.error // ⇒ TypeError: Scheme "htp" is not supported 48 | invalidMatcher.assertValid() // throws TypeError at runtime 49 | ``` 50 | 51 | ### Working with user input 52 | 53 | If the input patterns are hard coded, calling `assertValid` is a way of telling the TypeScript compiler that they’re assumed to be valid. However, if patterns are supplied from user input or other sources with unknown integrity, it’s usually better to check the `valid` property, which allows TypeScript to correctly infer the type: 54 | 55 | ```ts 56 | const matcherInput = form.querySelector('input#matcher')! 57 | const checkBtn = form.querySelector('button#check')! 58 | 59 | checkBtn.addEventListener('click', () => { 60 | const matcher = matchPattern(matcherInput.value) 61 | 62 | // type narrowing via ternary operator 63 | matcherInput.setCustomValidity(matcher.valid ? '' : matcher.error.message) 64 | matcherInput.reportValidity() 65 | 66 | // type narrowing via `if ... else` 67 | if (matcher.valid) { 68 | const url = prompt('Enter URL to match against') 69 | alert(matcher.match(url ?? '') ? 'Matched!' : 'Unmatched') 70 | } else { 71 | console.error(matcher.error.message) 72 | } 73 | }) 74 | ``` 75 | 76 | ### Configuration options 77 | 78 | You can customize `matchPattern` by supplying options in the second argument. 79 | 80 | ```ts 81 | import { matchPattern } from 'browser-extension-url-match' 82 | 83 | const options = { 84 | supportedSchemes: ['http', 'https', 'ftp', 'ftps'], 85 | } 86 | 87 | matchPattern('ftps://*/*', options) 88 | .assertValid() 89 | .match('ftps://example.com/foo/bar') 90 | // ⇒ true 91 | ``` 92 | 93 | The available configuration options are as follows: 94 | 95 | #### `strict` 96 | 97 | If set to `false`, the specified path segment is ignored and is always treated as `/*`. This corresponds to the behavior when specifying [host permissions](https://developer.chrome.com/docs/extensions/mv3/declare_permissions/). 98 | 99 | **Default:** `true`. 100 | 101 | #### `supportedSchemes` 102 | 103 | An array of schemes to allow in the pattern. Available schemes are `http`, `https`, `ws`, `wss`, `ftp`, `ftps`, and `file`. 104 | 105 | `data` and `urn` are not currently supported, due to limited implementation and unclear semantics. 106 | 107 | **Default:** `['http', 'https', 'file', 'ftp']` 108 | 109 | #### `schemeStarMatchesWs` 110 | 111 | If `true`, `*` in the scheme will match `ws` and `wss` as well as `http` and `https`, which is the default behavior in Firefox. 112 | 113 | **Default:** `false` 114 | 115 | ### Chrome and Firefox presets 116 | 117 | Presets are available to provide defaults based on what Chrome and Firefox support. 118 | 119 | ```ts 120 | import { matchPattern, presets } from 'browser-extension-url-match' 121 | 122 | const matcher = matchPattern('*://example.com/', presets.firefox) 123 | 124 | matcher.assertValid().match('ws://example.com') // ⇒ true 125 | ``` 126 | 127 | You can also combine presets with custom options: 128 | 129 | ```ts 130 | const options = { 131 | ...presets.firefox, 132 | strict: false, 133 | } 134 | 135 | matchPattern('wss://example.com/', options) 136 | .assertValid() 137 | .match('wss://example.com/foo/bar') 138 | // ⇒ true 139 | ``` 140 | 141 | ### Generating examples 142 | 143 | You can also generate an array of examples matching URL strings from a valid `Matcher` object. 144 | 145 | ```ts 146 | matchPattern('https://*.example.com/*').assertValid().examples 147 | // ⇒ [ 148 | // 'https://example.com/', 149 | // 'https://example.com/foo', 150 | // 'https://example.com/bar/baz/', 151 | // 'https://www.example.com/', 152 | // 'https://www.example.com/foo', 153 | // 'https://www.example.com/bar/baz/', 154 | // 'https://foo.bar.example.com/', 155 | // 'https://foo.bar.example.com/foo', 156 | // 'https://foo.bar.example.com/bar/baz/', 157 | // ] 158 | ``` 159 | -------------------------------------------------------------------------------- /__tests__/config.ts: -------------------------------------------------------------------------------- 1 | import { matchPattern, presets } from '../src' 2 | 3 | describe('config', () => { 4 | describe('defaults', () => { 5 | it('schemeStarMatchesWs = false', () => { 6 | expect( 7 | matchPattern('*://a.com/').assertValid().match('http://a.com'), 8 | ).toBe(true) 9 | expect( 10 | matchPattern('*://a.com/').assertValid().match('ws://a.com'), 11 | ).toBe(false) 12 | }) 13 | 14 | it('supportedSchemes = chrome defaults', () => { 15 | expect( 16 | matchPattern('').assertValid().match('http://a.com'), 17 | ).toBe(true) 18 | expect( 19 | matchPattern('').assertValid().match('ws://a.com'), 20 | ).toBe(false) 21 | 22 | expect(matchPattern('https://example.com/').valid).toBe(true) 23 | expect(matchPattern('wss://example.com/').valid).toBe(false) 24 | }) 25 | 26 | it('strict = true', () => { 27 | expect( 28 | matchPattern('https://a.com/b') 29 | .assertValid() 30 | .match('https://a.com'), 31 | ).toBe(false) 32 | }) 33 | }) 34 | 35 | describe('defaults (explicitly supply empty options object)', () => { 36 | it('schemeStarMatchesWs = false', () => { 37 | expect( 38 | matchPattern('*://a.com/', {}) 39 | .assertValid() 40 | .match('http://a.com'), 41 | ).toBe(true) 42 | expect( 43 | matchPattern('*://a.com/', {}) 44 | .assertValid() 45 | .match('ws://a.com'), 46 | ).toBe(false) 47 | }) 48 | 49 | it('supportedSchemes = chrome defaults', () => { 50 | expect( 51 | matchPattern('', {}) 52 | .assertValid() 53 | .match('http://a.com'), 54 | ).toBe(true) 55 | expect( 56 | matchPattern('', {}) 57 | .assertValid() 58 | .match('ws://a.com'), 59 | ).toBe(false) 60 | 61 | expect(matchPattern('https://example.com/', {}).valid).toBe(true) 62 | expect(matchPattern('wss://example.com/', {}).valid).toBe(false) 63 | }) 64 | 65 | it('strict = true', () => { 66 | expect( 67 | matchPattern('https://a.com/b', {}) 68 | .assertValid() 69 | .match('https://a.com'), 70 | ).toBe(false) 71 | }) 72 | }) 73 | 74 | describe('firefox defaults', () => { 75 | it('schemeStarMatchesWs = true', () => { 76 | expect( 77 | matchPattern('*://a.com/', presets.firefox) 78 | .assertValid() 79 | .match('http://a.com'), 80 | ).toBe(true) 81 | expect( 82 | matchPattern('*://a.com/', presets.firefox) 83 | .assertValid() 84 | .match('ws://a.com'), 85 | ).toBe(true) 86 | }) 87 | 88 | it('supportedSchemes = firefox defaults', () => { 89 | expect( 90 | matchPattern('', presets.firefox) 91 | .assertValid() 92 | .match('http://a.com'), 93 | ).toBe(true) 94 | expect( 95 | matchPattern('', presets.firefox) 96 | .assertValid() 97 | .match('ws://a.com'), 98 | ).toBe(true) 99 | 100 | expect( 101 | matchPattern('https://example.com/', presets.firefox).valid, 102 | ).toBe(true) 103 | expect( 104 | matchPattern('wss://example.com/', presets.firefox).valid, 105 | ).toBe(true) 106 | }) 107 | }) 108 | 109 | describe('chrome defaults', () => { 110 | it('schemeStarMatchesWs = false', () => { 111 | expect( 112 | matchPattern('*://a.com/', presets.chrome) 113 | .assertValid() 114 | .match('http://a.com'), 115 | ).toBe(true) 116 | expect( 117 | matchPattern('*://a.com/', presets.chrome) 118 | .assertValid() 119 | .match('ws://a.com'), 120 | ).toBe(false) 121 | }) 122 | 123 | it('supportedSchemes = chrome defaults', () => { 124 | expect( 125 | matchPattern('', presets.chrome) 126 | .assertValid() 127 | .match('http://a.com'), 128 | ).toBe(true) 129 | expect( 130 | matchPattern('', presets.chrome) 131 | .assertValid() 132 | .match('ws://a.com'), 133 | ).toBe(false) 134 | 135 | expect( 136 | matchPattern('https://example.com/', presets.chrome).valid, 137 | ).toBe(true) 138 | expect( 139 | matchPattern('wss://example.czom/', presets.chrome).valid, 140 | ).toBe(false) 141 | }) 142 | }) 143 | 144 | describe('strict mode', () => { 145 | it('strict = false', () => { 146 | const matcher = matchPattern('https://a.com/b', { strict: false }) 147 | 148 | expect(matcher.assertValid().match('https://a.com/b')).toBe(true) 149 | 150 | expect(matcher.assertValid().match('https://a.com')).toBe(true) 151 | expect(matcher.assertValid().match('https://a.com/c')).toBe(true) 152 | expect(matcher.assertValid().match('https://a.com/c/d/e/f')).toBe( 153 | true, 154 | ) 155 | expect(matcher.assertValid().match('https://a.com/b/c/d/e/f')).toBe( 156 | true, 157 | ) 158 | }) 159 | 160 | it('strict = true', () => { 161 | const matcher = matchPattern('https://a.com/b', { strict: true }) 162 | 163 | expect(matcher.assertValid().match('https://a.com/b')).toBe(true) 164 | 165 | expect(matcher.assertValid().match('https://a.com')).toBe(false) 166 | expect(matcher.assertValid().match('https://a.com/c')).toBe(false) 167 | expect(matcher.assertValid().match('https://a.com/c/d/e/f')).toBe( 168 | false, 169 | ) 170 | expect(matcher.assertValid().match('https://a.com/b/c/d/e/f')).toBe( 171 | false, 172 | ) 173 | }) 174 | }) 175 | }) 176 | -------------------------------------------------------------------------------- /__tests__/data/chrome.ts: -------------------------------------------------------------------------------- 1 | // https://developer.chrome.com/docs/extensions/mv3/match_patterns/ 2 | 3 | export const wellFormed = [ 4 | { 5 | pattern: 'http://*/*', 6 | accept: ['http://www.google.com/', 'http://example.org/foo/bar.html'], 7 | reject: [], 8 | }, 9 | { 10 | pattern: 'http://*/foo*', 11 | accept: [ 12 | 'http://example.com/foo/bar.html', 13 | 'http://www.google.com/foo', 14 | ], 15 | reject: [], 16 | }, 17 | { 18 | pattern: 'https://*.google.com/foo*bar', 19 | accept: [ 20 | 'https://www.google.com/foo/baz/bar', 21 | 'https://docs.google.com/foobar', 22 | ], 23 | reject: [], 24 | }, 25 | { 26 | pattern: 'http://localhost/*', 27 | accept: [ 28 | 'http://localhost/', 29 | 'http://localhost:8080/', 30 | 'http://localhost/xyz', 31 | 'http://localhost:8080/xyz', 32 | ], 33 | reject: [], 34 | }, 35 | { 36 | pattern: 'https://localhost/a/b/c', 37 | accept: [ 38 | 'https://localhost/a/b/c', 39 | 'https://localhost:8080/a/b/c', 40 | 'https://localhost/a/b/c', 41 | 'https://localhost:8080/a/b/c', 42 | ], 43 | reject: [], 44 | }, 45 | { 46 | pattern: 'file:///foo*', 47 | accept: ['file:///foo/bar.html', 'file:///foo'], 48 | reject: [], 49 | }, 50 | { 51 | pattern: 'http://127.0.0.1/*', 52 | accept: ['http://127.0.0.1/', 'http://127.0.0.1/foo/bar.html'], 53 | reject: [], 54 | }, 55 | { 56 | pattern: '*://mail.google.com/*', 57 | accept: [ 58 | 'http://mail.google.com/foo/baz/bar', 59 | 'https://mail.google.com/foobar', 60 | ], 61 | reject: [], 62 | }, 63 | { 64 | pattern: '', 65 | accept: ['http://example.org/foo/bar.html', 'file:///bar/baz.html'], 66 | reject: [], 67 | }, 68 | ] 69 | 70 | export const malformed = [ 71 | 'http://www.google.com', 72 | 'http://*foo/bar', 73 | 'http://foo.*.bar/baz', 74 | 'http:/bar', 75 | 'foo://*', 76 | // chrome doesn't support `data:` scheme 77 | 'data:text/html;base64,PGh0bWw+', 78 | // chrome supports `urn:` scheme, but library intentionally rejects due to unclear syntax/semantics in the spec 79 | 'urn:uuid:54723bea-c94e-480e-80c8-a69846c3f582', 80 | ] 81 | -------------------------------------------------------------------------------- /__tests__/data/firefox.ts: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns 2 | 3 | export const wellFormed = [ 4 | { 5 | pattern: '', 6 | accept: [ 7 | 'http://example.org/', 8 | 'https://a.org/some/path/', 9 | 'ws://sockets.somewhere.org/', 10 | 'wss://ws.example.com/stuff/', 11 | 'ftp://files.somewhere.org/', 12 | ], 13 | reject: ['resource://a/b/c/'], 14 | }, 15 | { 16 | pattern: '*://*/*', 17 | accept: [ 18 | 'http://example.org/', 19 | 'https://a.org/some/path/', 20 | 'ws://sockets.somewhere.org/', 21 | 'wss://ws.example.com/stuff/', 22 | ], 23 | reject: [ 24 | 'ftp://ftp.example.org/', 25 | 'ftps://ftp.example.org/', 26 | 'file:///a/', 27 | ], 28 | }, 29 | { 30 | pattern: '*://*.mozilla.org/*', 31 | accept: [ 32 | 'http://mozilla.org/', 33 | 'https://mozilla.org/', 34 | 'http://a.mozilla.org/', 35 | 'http://a.b.mozilla.org/', 36 | 'https://b.mozilla.org/path/', 37 | 'ws://ws.mozilla.org/', 38 | 'wss://secure.mozilla.org/something', 39 | ], 40 | reject: [ 41 | 'ftp://mozilla.org/', 42 | 'http://mozilla.com/', 43 | 'http://firefox.org/', 44 | ], 45 | }, 46 | { 47 | pattern: '*://mozilla.org/', 48 | accept: [ 49 | 'http://mozilla.org/', 50 | 'https://mozilla.org/', 51 | 'ws://mozilla.org/', 52 | 'wss://mozilla.org/', 53 | ], 54 | reject: [ 55 | 'ftp://mozilla.org/', 56 | 'http://a.mozilla.org/', 57 | 'http://mozilla.org/a', 58 | ], 59 | }, 60 | { 61 | pattern: 'ftp://mozilla.org/', 62 | accept: ['ftp://mozilla.org'], 63 | reject: [ 64 | 'http://mozilla.org/', 65 | 'ftp://sub.mozilla.org/', 66 | 'ftp://mozilla.org/path', 67 | ], 68 | }, 69 | { 70 | pattern: 'https://*/path', 71 | accept: [ 72 | 'https://mozilla.org/path', 73 | 'https://a.mozilla.org/path', 74 | 'https://something.com/path', 75 | ], 76 | reject: [ 77 | 'http://mozilla.org/path', 78 | 'https://mozilla.org/path/', 79 | 'https://mozilla.org/a', 80 | 'https://mozilla.org/', 81 | 'https://mozilla.org/path?foo=1', 82 | ], 83 | }, 84 | { 85 | pattern: 'https://*/path/', 86 | accept: ['https://mozilla.org/path/', 'https://a.mozilla.org/path/'], 87 | reject: [ 88 | 'http://mozilla.org/path/', 89 | 'https://mozilla.org/path', 90 | 'https://mozilla.org/a', 91 | 'https://mozilla.org/', 92 | 'https://mozilla.org/path/?foo=1', 93 | 'https://something.com/path', 94 | ], 95 | }, 96 | { 97 | pattern: 'https://mozilla.org/*', 98 | accept: [ 99 | 'https://mozilla.org/', 100 | 'https://mozilla.org/path', 101 | 'https://mozilla.org/another', 102 | 'https://mozilla.org/path/to/doc', 103 | 'https://mozilla.org/path/to/doc?foo=1', 104 | ], 105 | reject: ['http://mozilla.org/path', 'https://mozilla.com/path'], 106 | }, 107 | { 108 | pattern: 'https://mozilla.org/a/b/c/', 109 | accept: [ 110 | 'https://mozilla.org/a/b/c/', 111 | 'https://mozilla.org/a/b/c/#section1', 112 | ], 113 | reject: [], 114 | }, 115 | { 116 | pattern: 'https://mozilla.org/*/b/*/', 117 | accept: [ 118 | 'https://mozilla.org/a/b/c/', 119 | 'https://mozilla.org/d/b/f/', 120 | 'https://mozilla.org/a/b/c/d/', 121 | 'https://mozilla.org/a/b/c/d/#section1', 122 | 'https://mozilla.org/a/b/c/d/?foo=/', 123 | 'https://mozilla.org/a?foo=21314&bar=/b/&extra=c/', 124 | ], 125 | reject: [ 126 | 'https://mozilla.org/b/*/', 127 | 'https://mozilla.org/a/b/', 128 | 'https://mozilla.org/a/b/c/d/?foo=bar', 129 | ], 130 | }, 131 | { 132 | pattern: 'file:///blah/*', 133 | accept: ['file:///blah/', 'file:///blah/bleh'], 134 | reject: ['file:///bleh/'], 135 | }, 136 | ] 137 | 138 | export const malformed = [ 139 | 'resource://path/', // Unsupported scheme. 140 | 'https://mozilla.org', // No path. 141 | 'https://mozilla.*.org/', // "*" in host must be at the start. 142 | 'https://*zilla.org/', // "*" in host must be the only character or be followed by ".". 143 | 'http*://mozilla.org/', // "*" in scheme must be the only character. 144 | 'https://mozilla.org:80/', // Host must not include a port number. 145 | '*://*', // Empty path: this should be "*://*/*". 146 | 'file://*', // Empty path: this should be "file:///*". 147 | // FF doesn't support `urn:` scheme 148 | 'urn:uuid:54723bea-c94e-480e-80c8-a69846c3f582', 149 | // FF supports `data:` scheme, but library intentionally rejects due to unclear syntax/semantics in the spec 150 | 'data:text/html;base64,PGh0bWw+', 151 | ] 152 | -------------------------------------------------------------------------------- /__tests__/data/generic.ts: -------------------------------------------------------------------------------- 1 | export const wellFormed = [ 2 | // https://github.com/nickclaw/url-match-patterns/issues/2 3 | { 4 | pattern: 'https://*.ft.com/lol', 5 | accept: [ 6 | 'https://a.ft.com/lol', 7 | 'https://a.b.c.ft.com/lol', 8 | 'https://ft.com/lol', 9 | ], 10 | reject: [ 11 | 'https://www.microsoft.com/lol', 12 | 'https://a.microsoft.com/lol', 13 | ], 14 | }, 15 | { 16 | pattern: '*://google.com/?', 17 | accept: ['https://google.com/?'], 18 | reject: ['https://google.com/a?', 'https://google.com/?a=1'], 19 | }, 20 | ] 21 | 22 | export const malformed = [ 23 | '', 24 | '*:/www.ab.com/', 25 | 'htp://www.ab.com/', 26 | 'https://www.ab.com', 27 | 'https://www.a[]b.com/', 28 | 'https://www.a()b.com/', 29 | 'https://www.a\0b.com/', 30 | 'https://*.*.www.ab.com/', 31 | 'https://www.*.ab.com/', 32 | 'https://*www.ab.com/', 33 | 34 | // https://github.com/clearlylocal/browser-extension-url-match/issues/3 35 | 'https://example.com#', 36 | 'https://example.com/#', 37 | 'https://example.com/#foo', 38 | 'https://example.com#bar', 39 | 40 | 'https://example.com#/foo', 41 | 'https://example.com/#/bar', 42 | ] 43 | -------------------------------------------------------------------------------- /__tests__/data/normalization.ts: -------------------------------------------------------------------------------- 1 | export const wellFormed = [ 2 | { pattern: '*://*/fo^', accept: ['http://example.com/fo%5e'], reject: [] }, 3 | { 4 | pattern: '*://*/fo|a)', 5 | accept: ['http://example.com/fo%7ca)'], 6 | reject: [], 7 | }, 8 | { 9 | pattern: '*://*/[fo])', 10 | accept: ['http://example.com/%5bfo%5d)'], 11 | reject: [], 12 | }, 13 | { 14 | pattern: '*://*/fo{1,2}', 15 | accept: ['http://a.co/fo%7b1,2%7d'], 16 | reject: [], 17 | }, 18 | 19 | { pattern: '*://*/fo%5e', accept: ['http://example.com/fo^'], reject: [] }, 20 | { 21 | pattern: '*://*/fo%7ca)', 22 | accept: ['http://example.com/fo|a)'], 23 | reject: [], 24 | }, 25 | { 26 | pattern: '*://*/%5bfo%5d)', 27 | accept: ['http://example.com/[fo])'], 28 | reject: [], 29 | }, 30 | { 31 | pattern: '*://*/fo%7b1,2%7d', 32 | accept: ['http://a.co/fo{1,2}'], 33 | reject: [], 34 | }, 35 | { 36 | pattern: '*://*/fo%7b1,2%7d', 37 | accept: ['http://a.co/fo{1,2}'], 38 | reject: [], 39 | }, 40 | { 41 | pattern: 'https://exÆmple.com/*', 42 | accept: ['https://xn--exmple-qua.com'], 43 | reject: [], 44 | }, 45 | { 46 | pattern: 'https://ex%C3%A6mple.com/*', 47 | accept: ['https://xn--exmple-qua.com'], 48 | reject: [], 49 | }, 50 | { 51 | pattern: 'https://xn--exmple-qua.com/*', 52 | accept: ['https://exÆmple.com', 'https://ex%C3%A6mple.com'], 53 | reject: [], 54 | }, 55 | 56 | { 57 | pattern: 'https://*.exÆmple.com/*', 58 | accept: ['https://foo.xn--exmple-qua.com'], 59 | reject: [], 60 | }, 61 | { 62 | pattern: 'https://*.ex%C3%A6mple.com/*', 63 | accept: ['https://foo.xn--exmple-qua.com'], 64 | reject: [], 65 | }, 66 | { 67 | pattern: 'https://*.xn--exmple-qua.com/*', 68 | accept: ['https://foo.exÆmple.com', 'https://bar.baz.ex%C3%A6mple.com'], 69 | reject: [], 70 | }, 71 | ] 72 | 73 | export const malformed = [] 74 | -------------------------------------------------------------------------------- /__tests__/data/url-match-patterns-compat.ts: -------------------------------------------------------------------------------- 1 | // Ensure compatibility with https://github.com/nickclaw/url-match-patterns 2 | 3 | // (The MIT License) 4 | 5 | // Copyright(c) 2017 Nicholas Clawson 6 | 7 | // Permission is hereby granted, free of charge, to any person obtaining 8 | // a copy of this software and associated documentation files(the 9 | // 'Software'), to deal in the Software without restriction, including 10 | // without limitation the rights to use, copy, modify, merge, publish, 11 | // distribute, sublicense, and / or sell copies of the Software, and to 12 | // permit persons to whom the Software is furnished to do so, subject to 13 | // the following conditions: 14 | 15 | // The above copyright notice and this permission notice shall be 16 | // included in all copies or substantial portions of the Software. 17 | 18 | // THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 19 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 21 | // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 23 | // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 24 | // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | export const wellFormed = [ 27 | // https://github.com/nickclaw/url-match-patterns/blob/master/test/regression.js 28 | 29 | { pattern: '*://*/fo?o', accept: [], reject: ['http://example.com/fo'] }, 30 | { pattern: '*://*/f.o', accept: [], reject: ['http://example.com/foo'] }, 31 | { pattern: '*://*/fo+', accept: [], reject: ['http://example.com/foo'] }, 32 | { 33 | pattern: '*://*/fo{1,2}', 34 | accept: [], 35 | reject: ['http://example.com/foo'], 36 | }, 37 | { pattern: '*://*/fo^', accept: ['http://example.com/fo^'], reject: [] }, 38 | { pattern: '*://*/fo$', accept: ['http://example.com/fo$'], reject: [] }, 39 | { pattern: '*://*/fo(', accept: ['http://example.com/fo('], reject: [] }, 40 | { pattern: '*://*/fo)', accept: ['http://example.com/fo)'], reject: [] }, 41 | { 42 | pattern: '*://*/fo|a)', 43 | accept: ['http://example.com/fo|a)'], 44 | reject: [], 45 | }, 46 | { 47 | pattern: '*://*/[fo])', 48 | accept: ['http://example.com/[fo])'], 49 | reject: [], 50 | }, 51 | 52 | // https://github.com/nickclaw/url-match-patterns/blob/master/test/fixtures/chrome-examples.js 53 | { 54 | pattern: 'http://*/*', 55 | accept: ['http://www.google.com/', 'http://example.org/foo/bar.html'], 56 | reject: [], 57 | }, 58 | { 59 | pattern: 'http://*/foo*', 60 | accept: [ 61 | 'http://example.com/foo/bar.html', 62 | 'http://www.google.com/foo', 63 | ], 64 | reject: [], 65 | }, 66 | { 67 | pattern: 'https://*.google.com/foo*bar', 68 | accept: [ 69 | // the example here was wrong 70 | // 'http://www.google.com/foo/baz/bar', 71 | // 'http://docs.google.com/foobar', 72 | 'https://www.google.com/foo/baz/bar', 73 | 'https://docs.google.com/foobar', 74 | ], 75 | reject: [], 76 | }, 77 | { 78 | pattern: 'http://example.org/foo/bar.html', 79 | accept: ['http://example.org/foo/bar.html'], 80 | reject: [], 81 | }, 82 | { 83 | pattern: 'file:///foo*', 84 | accept: ['file:///foo/bar.html', 'file:///foo'], 85 | reject: [], 86 | }, 87 | { 88 | pattern: 'http://127.0.0.1/*', 89 | accept: ['http://127.0.0.1/', 'http://127.0.0.1/foo/bar.html'], 90 | reject: [], 91 | }, 92 | { 93 | pattern: '*://mail.google.com/*', 94 | accept: [ 95 | 'http://mail.google.com/foo/baz/bar', 96 | 'https://mail.google.com/foobar', 97 | ], 98 | reject: [], 99 | }, 100 | { 101 | pattern: '', 102 | accept: ['http://example.org/foo/bar.html', 'file:///bar/baz.html'], 103 | reject: [], 104 | }, 105 | 106 | // https://github.com/nickclaw/url-match-patterns/blob/master/test/fixtures/firefox-examples.js 107 | 108 | { 109 | pattern: '', 110 | accept: [ 111 | 'http://example.org/', 112 | 'ftp://files.somewhere.org/', 113 | 'https://a.org/some/path/', 114 | ], 115 | reject: ['resource://a/b/c/'], 116 | }, 117 | { 118 | pattern: '*://*.mozilla.org/*', 119 | accept: [ 120 | 'http://mozilla.org/', 121 | 'https://mozilla.org/', 122 | 'http://a.mozilla.org/', 123 | 'http://a.b.mozilla.org/', 124 | 'https://b.mozilla.org/path/', 125 | ], 126 | reject: [ 127 | 'ftp://mozilla.org/', 128 | 'http://mozilla.com/', 129 | 'http://firefox.org/', 130 | ], 131 | }, 132 | { 133 | pattern: '*://mozilla.org/', 134 | accept: ['http://mozilla.org/', 'https://mozilla.org/'], 135 | reject: [ 136 | 'ftp://mozilla.org/', 137 | 'http://a.mozilla.org/', 138 | 'http://mozilla.org/a', 139 | ], 140 | }, 141 | { 142 | pattern: 'ftp://mozilla.org/', 143 | accept: ['ftp://mozilla.org'], 144 | reject: [ 145 | 'http://mozilla.org/', 146 | 'ftp://sub.mozilla.org/', 147 | 'ftp://mozilla.org/path', 148 | ], 149 | }, 150 | { 151 | pattern: 'https://*/path', 152 | accept: [ 153 | 'https://mozilla.org/path', 154 | 'https://a.mozilla.org/path', 155 | 'https://something.com/path', 156 | ], 157 | reject: [ 158 | 'http://mozilla.org/path', 159 | 'https://mozilla.org/path/', 160 | 'https://mozilla.org/a', 161 | 'https://mozilla.org/', 162 | ], 163 | }, 164 | { 165 | pattern: 'https://*/path/', 166 | accept: [ 167 | 'https://mozilla.org/path/', 168 | 'https://a.mozilla.org/path/', 169 | 'https://something.com/path/', 170 | ], 171 | reject: [ 172 | 'http://mozilla.org/path/', 173 | 'https://mozilla.org/path', 174 | 'https://mozilla.org/a', 175 | 'https://mozilla.org/', 176 | ], 177 | }, 178 | { 179 | pattern: 'https://mozilla.org/*', 180 | accept: [ 181 | 'https://mozilla.org/', 182 | 'https://mozilla.org/path', 183 | 'https://mozilla.org/another', 184 | 'https://mozilla.org/path/to/doc', 185 | ], 186 | reject: ['http://mozilla.org/path', 'https://mozilla.com/path'], 187 | }, 188 | { 189 | pattern: 'https://mozilla.org/a/b/c/', 190 | accept: ['https://mozilla.org/a/b/c/'], 191 | reject: [], 192 | }, 193 | { 194 | pattern: 'https://mozilla.org/*/b/*/', 195 | accept: [ 196 | 'https://mozilla.org/a/b/c/', 197 | 'https://mozilla.org/d/b/f/', 198 | 'https://mozilla.org/a/b/c/d/', 199 | ], 200 | reject: ['https://mozilla.org/b/*/', 'https://mozilla.org/a/b/'], 201 | }, 202 | { 203 | pattern: 'file:///blah/*', 204 | accept: ['file:///blah/', 'file:///blah/bleh'], 205 | reject: ['file:///bleh/'], 206 | }, 207 | ] 208 | 209 | export const malformed = [ 210 | // https://github.com/nickclaw/url-match-patterns/blob/master/test/fixtures/firefox-examples.js 211 | 212 | 'resource://path/', 213 | 'https://mozilla.org', 214 | 'https://mozilla.*.org/', 215 | 'https://*zilla.org/', 216 | 'http*://mozilla.org/', 217 | 'file://*', 218 | ] 219 | -------------------------------------------------------------------------------- /__tests__/errorMessages.ts: -------------------------------------------------------------------------------- 1 | import { matchPattern, presets } from '../src' 2 | 3 | describe('error messages', () => { 4 | it('invalid pattern (bare URL origin with no trailing slash)', () => { 5 | expect(matchPattern('https://example.com').error?.message).toMatch( 6 | 'Pattern "https://example.com" does not contain a path', 7 | ) 8 | }) 9 | 10 | it('generic invalid pattern', () => { 11 | expect(matchPattern('_').error?.message).toMatch( 12 | 'Pattern "_" is invalid', 13 | ) 14 | }) 15 | 16 | it('invalid pattern one of many', () => { 17 | expect( 18 | matchPattern(['https://example.com/foo/*', 'https://example.com']) 19 | .error?.message, 20 | ).toMatch('Pattern "https://example.com" does not contain a path') 21 | }) 22 | 23 | it('unsupported scheme', () => { 24 | expect(matchPattern('htp://example.com/*').error?.message).toMatch( 25 | 'Scheme "htp" is not supported', 26 | ) 27 | }) 28 | 29 | it('urn:', () => { 30 | expect(matchPattern('urn:abc').error?.message).toMatch( 31 | 'not currently support', 32 | ) 33 | }) 34 | 35 | it('data: (FF)', () => { 36 | expect( 37 | matchPattern('data:abc', presets.firefox).error?.message, 38 | ).toMatch('not currently support') 39 | }) 40 | 41 | it('cannot be used to construct a valid URL', () => { 42 | expect(matchPattern('http://\0/*').error?.message).toMatch( 43 | 'cannot be used to construct a valid URL', 44 | ) 45 | }) 46 | 47 | it('contains port number', () => { 48 | expect(matchPattern('*://a.com:8080/').error?.message).toMatch( 49 | 'port number', 50 | ) 51 | }) 52 | 53 | it('invalid characters', () => { 54 | expect(matchPattern('*://a_b.co/').error?.message).toMatch( 55 | 'Host "a_b.co" contains invalid characters', 56 | ) 57 | }) 58 | 59 | it('malformed partial-wildcard host', () => { 60 | expect(matchPattern('*://*.*.a.co/').error?.message).toMatch( 61 | 'can contain only one wildcard at the start', 62 | ) 63 | 64 | expect(matchPattern('*://a.*.b.co/').error?.message).toMatch( 65 | 'can contain only one wildcard at the start', 66 | ) 67 | }) 68 | 69 | it('missing host', () => { 70 | expect(matchPattern('*:///a').error?.message).toMatch( 71 | 'Host is optional only if the scheme is "file"', 72 | ) 73 | 74 | expect(matchPattern('http:///a').error?.message).toMatch( 75 | 'Host is optional only if the scheme is "file"', 76 | ) 77 | 78 | expect(matchPattern('file:///a').error).toBeUndefined() 79 | }) 80 | 81 | it('contains hash', () => { 82 | expect(matchPattern('https://example.com#').error?.message).toMatch( 83 | 'cannot contain a hash', 84 | ) 85 | expect(matchPattern('https://example.com#').error?.message).toMatch( 86 | '"#"', 87 | ) 88 | 89 | expect(matchPattern('https://example.com#bar').error?.message).toMatch( 90 | 'cannot contain a hash', 91 | ) 92 | expect(matchPattern('https://example.com#bar').error?.message).toMatch( 93 | '"#bar"', 94 | ) 95 | 96 | expect(matchPattern('https://example.com/#').error?.message).toMatch( 97 | 'cannot contain a hash', 98 | ) 99 | expect(matchPattern('https://example.com/#').error?.message).toMatch( 100 | '"#"', 101 | ) 102 | 103 | expect(matchPattern('https://example.com/#foo').error?.message).toMatch( 104 | 'cannot contain a hash', 105 | ) 106 | expect(matchPattern('https://example.com/#foo').error?.message).toMatch( 107 | '"#foo"', 108 | ) 109 | 110 | expect(matchPattern('https://example.com#/foo').error?.message).toMatch( 111 | 'cannot contain a hash', 112 | ) 113 | expect(matchPattern('https://example.com#/foo').error?.message).toMatch( 114 | '"#/foo"', 115 | ) 116 | 117 | expect( 118 | matchPattern('https://example.com/#/bar').error?.message, 119 | ).toMatch('cannot contain a hash') 120 | expect( 121 | matchPattern('https://example.com/#/bar').error?.message, 122 | ).toMatch('"#/bar"') 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import { matchPattern, presets } from '../src' 2 | import * as chrome from './data/chrome' 3 | import * as firefox from './data/firefox' 4 | import * as generic from './data/generic' 5 | import * as urlMatchPatternsCompat from './data/url-match-patterns-compat' 6 | import * as normalization from './data/normalization' 7 | import { assertInvalid } from './utils/testUtils' 8 | 9 | type WellFormed = { 10 | pattern: string 11 | accept: string[] 12 | reject: string[] 13 | } 14 | 15 | const tests: Record< 16 | string, 17 | { 18 | wellFormed: WellFormed[] 19 | malformed: string[] 20 | } 21 | > = { 22 | chrome, 23 | firefox, 24 | generic, 25 | urlMatchPatternsCompat, 26 | normalization, 27 | } 28 | 29 | for (const [k, v] of Object.entries(tests)) { 30 | describe(k, () => { 31 | const preset = presets[k as keyof typeof presets] ?? presets.chrome 32 | 33 | describe('well-formed', () => { 34 | for (const { pattern, accept, reject } of v.wellFormed) { 35 | describe(pattern, () => { 36 | const matcher = matchPattern(pattern, preset).assertValid() 37 | 38 | it('is valid', () => { 39 | expect(matcher.valid).toBe(true) 40 | 41 | expect((matcher as any).error).toBeUndefined() 42 | }) 43 | 44 | it('unwraps successfully', () => { 45 | expect(() => matcher.assertValid()).not.toThrow() 46 | }) 47 | 48 | it('has no error', () => { 49 | expect((matcher as any).error).toBeUndefined() 50 | }) 51 | 52 | it('has at least 1 example', () => { 53 | expect(matcher.examples.length).toBeGreaterThanOrEqual( 54 | 1, 55 | ) 56 | }) 57 | 58 | accept.forEach((x) => { 59 | it(`matches ${x}`, () => { 60 | expect(matcher.match(x)).toBe(true) 61 | }) 62 | }) 63 | 64 | reject.forEach((x) => { 65 | it(`doesn't match ${x}`, () => { 66 | expect(matcher.match(x)).toBe(false) 67 | }) 68 | }) 69 | }) 70 | } 71 | }) 72 | 73 | describe('malformed', () => { 74 | for (const pattern of v.malformed) { 75 | describe(pattern, () => { 76 | const matcher = matchPattern(pattern, preset) 77 | 78 | assertInvalid(matcher) 79 | 80 | it('is invalid', () => { 81 | expect(matcher.valid).toBe(false) 82 | }) 83 | 84 | it('throws on assertValid', () => { 85 | expect(() => matcher.assertValid()).toThrow(TypeError) 86 | }) 87 | 88 | it('has error', () => { 89 | expect(matcher.error).toBeInstanceOf(TypeError) 90 | }) 91 | 92 | it('has no `match` method', () => { 93 | expect((matcher as any).match).toBeUndefined() 94 | }) 95 | 96 | it('has no examples', () => { 97 | expect((matcher as any).examples).toBeUndefined() 98 | }) 99 | }) 100 | } 101 | }) 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /__tests__/multiplePatterns.ts: -------------------------------------------------------------------------------- 1 | import { matchPattern } from '../src' 2 | import { assertInvalid } from './utils/testUtils' 3 | 4 | describe('matchAny', () => { 5 | it('valid', () => { 6 | const pattern1 = 'https://example.com/foo/*' 7 | const pattern2 = 'https://example.com/foo2/*' 8 | 9 | let matcher = matchPattern([pattern1, pattern2]) 10 | 11 | expect(() => matcher.assertValid()).not.toThrow() 12 | 13 | matcher = matcher.assertValid() 14 | 15 | expect(matcher.match('https://example.com/foo/bar')).toBe(true) 16 | expect(matcher.match('https://example.com/foo2/bar')).toBe(true) 17 | expect(matcher.match('https://example.com/bar/baz')).toBe(false) 18 | 19 | expect(matcher.examples.length).toBeGreaterThanOrEqual(2) 20 | 21 | expect(matcher.examples.length).toBe( 22 | matchPattern(pattern1).assertValid().examples.length + 23 | matchPattern(pattern2).assertValid().examples.length, 24 | ) 25 | 26 | expect(matcher.patterns).toStrictEqual([pattern1, pattern2]) 27 | }) 28 | 29 | it('invalid', () => { 30 | const matcher = matchPattern([ 31 | 'https://invalid-pattern.com', 32 | 'https://example.com/foo/*', 33 | 'https://example.com/foo2/*', 34 | ]) 35 | 36 | assertInvalid(matcher) 37 | 38 | expect(() => matcher.assertValid()).toThrow(TypeError) 39 | expect(() => matcher.assertValid()).toThrow( 40 | 'Pattern "https://invalid-pattern.com" does not contain a path', 41 | ) 42 | 43 | expect(matcher.valid).toBe(false) 44 | expect(matcher.error).toBeInstanceOf(TypeError) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | "target": "ES2017", 5 | "module": "ESNext", 6 | "lib": ["DOM", "ES2019"], 7 | "allowJs": true, 8 | "declaration": true, 9 | "downlevelIteration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "moduleResolution": "node", 15 | "esModuleInterop": true 16 | }, 17 | "include": ["."] 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/utils/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { InvalidMatcher, MatcherOrInvalid } from '../../src/types' 2 | 3 | export function assertInvalid( 4 | matcher: MatcherOrInvalid, 5 | ): asserts matcher is InvalidMatcher { 6 | if (matcher.valid) { 7 | throw new TypeError('Expected malformed to be invalid') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | browser-extension-url-match demo 6 | 10 | 51 | 52 | 53 | 88 | 89 | 90 |
91 |

browser-extension-url-match demo

92 | 93 |

94 | matchPattern and presets are also 95 | available in the browser console. 96 |

97 | 98 |
99 |
100 | 104 |
105 |
106 | 110 |
111 |
112 | 113 |
114 |
115 | 116 |

Results

117 |
118 |
119 | 120 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts?$': 'ts-jest', 4 | }, 5 | modulePathIgnorePatterns: ['data', 'utils'], 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-extension-url-match", 3 | "version": "1.2.0", 4 | "description": "Browser extension URL pattern matching", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "jest --watchAll --verbose=false", 8 | "build": "tsc && babel --plugins @babel/plugin-transform-modules-commonjs dist/*.js -d dist", 9 | "prepare": "jest && rm -rf dist && npm run build" 10 | }, 11 | "types": "dist/index.d.ts", 12 | "author": "https://github.com/lionel-rowe", 13 | "license": "MIT", 14 | "repository": { 15 | "url": "https://github.com/lionel-rowe/browser-extension-url-match" 16 | }, 17 | "devDependencies": { 18 | "@babel/cli": "^7.12.10", 19 | "@babel/core": "^7.12.10", 20 | "@babel/plugin-transform-modules-commonjs": "^7.12.1", 21 | "@types/jest": "^26.0.20", 22 | "@types/node": "^14.14.22", 23 | "jest": "^26.6.3", 24 | "ts-jest": "^26.4.4", 25 | "typescript": "^4.1.3" 26 | }, 27 | "files": [ 28 | "dist/", 29 | "README.md", 30 | "LICENSE" 31 | ], 32 | "dependencies": { 33 | "fancy-regex": "^0.5.4" 34 | }, 35 | "keywords": [ 36 | "URL", 37 | "URI", 38 | "pattern", 39 | "match", 40 | "Chrome", 41 | "Firefox", 42 | "extension", 43 | "WebExtensions" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { MatchPatternOptions } from './types' 2 | 3 | export const presets: Record< 4 | 'chrome' | 'firefox', 5 | Pick< 6 | Required, 7 | 'supportedSchemes' | 'schemeStarMatchesWs' 8 | > 9 | > = { 10 | chrome: { 11 | supportedSchemes: [ 12 | 'http', 13 | 'https', 14 | 'file', 15 | 'ftp', 16 | // 'urn', 17 | ], 18 | schemeStarMatchesWs: false, 19 | }, 20 | firefox: { 21 | supportedSchemes: [ 22 | 'http', 23 | 'https', 24 | 'ws', 25 | 'wss', 26 | 'ftp', 27 | 'file', 28 | // 'ftps', 29 | // 'data', 30 | ], 31 | schemeStarMatchesWs: true, 32 | }, 33 | } 34 | 35 | export const defaultOptions: Required = { 36 | ...presets.chrome, 37 | strict: true, 38 | } 39 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ALL_URLS = '' 2 | -------------------------------------------------------------------------------- /src/getDummyUrl.ts: -------------------------------------------------------------------------------- 1 | import { regex } from 'fancy-regex' 2 | import { PatternSegments } from './types' 3 | 4 | type DummyUrlOptions = Partial<{ 5 | strict: boolean 6 | defaultScheme: string 7 | subdomain: string 8 | pathAndQueryReplacer: string 9 | rootDomain: string 10 | }> 11 | 12 | const DELIMS = /^|$|[/?=&\-]/ 13 | 14 | export function getDummyUrl( 15 | patternSegments: PatternSegments, 16 | replacements: DummyUrlOptions = {}, 17 | ) { 18 | const { rawHost, rawPathAndQuery } = patternSegments 19 | const { 20 | defaultScheme = 'https', 21 | subdomain = '', 22 | pathAndQueryReplacer = '', 23 | rootDomain = 'example.com', 24 | strict = true, 25 | } = replacements 26 | 27 | let host 28 | 29 | const scheme = 30 | patternSegments.scheme === '*' ? defaultScheme : patternSegments.scheme 31 | 32 | if (scheme === 'file') { 33 | host = '' 34 | } else if (rawHost === '*') { 35 | host = [subdomain, rootDomain].filter(Boolean).join('.') 36 | } else { 37 | host = rawHost.replace(/^\*./, subdomain ? `${subdomain}.` : '') 38 | } 39 | 40 | const pathAndQuery = (strict ? rawPathAndQuery : '/*') 41 | // start with hyphen-delimited 42 | .replace(/\*/g, `-${pathAndQueryReplacer}-`) 43 | // remove consecutive hyphens and hyphens adjacent to delimiters 44 | .replace(regex('g')`-+(${DELIMS})`, '$1') 45 | .replace(regex('g')`(${DELIMS})-+`, '$1') 46 | // remove consecutive slashes 47 | .replace(/\/+/g, '/') 48 | 49 | try { 50 | return new URL(`${scheme}://${host}${pathAndQuery}`) 51 | } catch (_e) { 52 | return null 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/getExampleUrls.ts: -------------------------------------------------------------------------------- 1 | import { getDummyUrl } from './getDummyUrl' 2 | import { getPatternSegments } from './getPatternSegments' 3 | import { MatchPatternOptions } from './types' 4 | 5 | export function getExampleUrls( 6 | pattern: string, 7 | options: Required, 8 | ) { 9 | const patternSegments = getPatternSegments(pattern)! 10 | const { supportedSchemes, strict } = options 11 | 12 | const subdomains = ['', 'www', 'foo.bar'] 13 | const rootDomains = ['example.com'] 14 | const pathAndQueryReplacers = ['', 'foo', '/bar/baz/'] 15 | 16 | const all = supportedSchemes.flatMap((defaultScheme) => 17 | subdomains.flatMap((subdomain) => 18 | rootDomains.flatMap((rootDomain) => 19 | pathAndQueryReplacers.flatMap((pathAndQueryReplacer) => 20 | getDummyUrl(patternSegments, { 21 | defaultScheme, 22 | subdomain, 23 | rootDomain, 24 | pathAndQueryReplacer, 25 | strict, 26 | }), 27 | ), 28 | ), 29 | ), 30 | ) 31 | 32 | return [...new Set(all.filter(Boolean).map((url) => url!.href))] 33 | } 34 | -------------------------------------------------------------------------------- /src/getHostRegex.ts: -------------------------------------------------------------------------------- 1 | import { regex } from 'fancy-regex' 2 | import { getDummyUrl } from './getDummyUrl' 3 | import { PatternSegments } from './types' 4 | 5 | export function getHostRegex(patternSegments: PatternSegments) { 6 | const { pattern, scheme, rawHost } = patternSegments 7 | 8 | if (!rawHost && scheme !== 'file') { 9 | return new TypeError('Host is optional only if the scheme is "file".') 10 | } 11 | 12 | const isStarHost = rawHost.includes('*') 13 | 14 | if (isStarHost) { 15 | const segments = rawHost.split('*.') 16 | 17 | if ( 18 | rawHost.length > 1 && 19 | (segments.length !== 2 || segments[0] || !segments[1]) 20 | ) { 21 | return new TypeError( 22 | 'Host can contain only one wildcard at the start, in the form "*."', 23 | ) 24 | } 25 | } 26 | 27 | const dummyUrl = getDummyUrl(patternSegments, { 28 | subdomain: '', 29 | }) 30 | 31 | if (!dummyUrl) { 32 | return new TypeError( 33 | `Pattern "${pattern}" cannot be used to construct a valid URL.`, 34 | ) 35 | } 36 | 37 | const dummyHost = dummyUrl.host 38 | 39 | if (/:\d+$/.test(dummyHost)) { 40 | return new TypeError( 41 | `Host "${rawHost}" cannot include a port number. All ports are matched by default.`, 42 | ) 43 | } 44 | 45 | if (/[^.a-z0-9\-]/.test(dummyHost)) { 46 | return new TypeError(`Host "${rawHost}" contains invalid characters.`) 47 | } 48 | 49 | const host = isStarHost ? '*.' + dummyHost : dummyHost 50 | 51 | if (rawHost === '*') { 52 | return /.+/ 53 | } else if (host.startsWith('*.')) { 54 | return regex()` 55 | ^ 56 | (?:[^.]+\.)* # any number of dot-terminated segments 57 | ${host.slice(2)} # rest after leading *. 58 | $ 59 | ` 60 | } else { 61 | return regex()`^${host}$` 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/getPatternSegments.ts: -------------------------------------------------------------------------------- 1 | import { regex } from 'fancy-regex' 2 | import { PatternSegments } from './types' 3 | import { ALL_URLS } from './constants' 4 | 5 | const patternRegex = regex()` 6 | ^ 7 | (\*|\w+) # scheme 8 | :// 9 | ( 10 | \* | # Any host 11 | [^/\#]* # Only the given host (optional only if scheme is file) 12 | ) 13 | (/[^\r\n\#]*) # path 14 | $ 15 | ` 16 | 17 | export function getPatternSegments(pattern: string): PatternSegments | null { 18 | if (pattern === ALL_URLS) { 19 | return { 20 | pattern, 21 | scheme: '*', 22 | rawHost: '*', 23 | rawPathAndQuery: '/*', 24 | } 25 | } 26 | 27 | const m = pattern.match(patternRegex) 28 | 29 | if (!m) return null 30 | 31 | const [, /* fullMatch */ scheme, rawHost, rawPathAndQuery] = m 32 | 33 | return { 34 | pattern, 35 | scheme, 36 | rawHost, 37 | rawPathAndQuery, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { matchPattern } from './matchPattern' 2 | export { presets } from './config' 3 | -------------------------------------------------------------------------------- /src/matchPattern.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions } from './config' 2 | import { getExampleUrls } from './getExampleUrls' 3 | import { toMatchFnOrError } from './toMatcherOrError' 4 | import { 5 | MatchPatternOptions, 6 | MatchFn, 7 | MatcherOrInvalid, 8 | Matcher, 9 | InvalidMatcher, 10 | } from './types' 11 | 12 | function assertValid(this: MatcherOrInvalid): Matcher { 13 | if (!this.valid) { 14 | throw new TypeError(this.error.message) 15 | } 16 | 17 | return this 18 | } 19 | 20 | function _matchPattern(options: MatchPatternOptions) { 21 | return (pattern: string): MatcherOrInvalid => { 22 | const combinedOptions = { 23 | ...defaultOptions, 24 | ...options, 25 | } 26 | 27 | const val = toMatchFnOrError(pattern, combinedOptions) 28 | 29 | return val instanceof Error 30 | ? { 31 | valid: false, 32 | error: val, 33 | assertValid, 34 | } 35 | : { 36 | valid: true, 37 | match: val, 38 | get examples() { 39 | return ( 40 | getExampleUrls(pattern, combinedOptions) 41 | // sanity check - examples should all match 42 | .filter((url) => (val as MatchFn)(url)) 43 | // prevent example list from getting too long 44 | .slice(0, 100) 45 | ) 46 | }, 47 | patterns: [pattern], 48 | config: combinedOptions, 49 | assertValid, 50 | } 51 | } 52 | } 53 | 54 | function allValid( 55 | matchers: readonly MatcherOrInvalid[], 56 | ): matchers is readonly Matcher[] { 57 | return matchers.every((m) => m.valid) 58 | } 59 | 60 | export function matchPattern( 61 | pattern: string[] | string, 62 | options: Partial = {}, 63 | ): MatcherOrInvalid { 64 | const patterns = 65 | typeof pattern === 'string' ? [pattern] : [...new Set(pattern)] 66 | 67 | if (patterns.length === 1) return _matchPattern(options)(patterns[0]) 68 | 69 | const matchers: readonly MatcherOrInvalid[] = patterns.map( 70 | _matchPattern(options), 71 | ) 72 | 73 | if (allValid(matchers)) { 74 | return { 75 | valid: true, 76 | get examples() { 77 | return [ 78 | ...new Set( 79 | matchers.flatMap((m) => (m as Matcher).examples), 80 | ), 81 | ] 82 | }, 83 | match: (url: string | URL) => matchers.some((m) => m.match(url)), 84 | patterns, 85 | config: options, 86 | assertValid, 87 | } 88 | } else { 89 | const invalid = matchers.find((m) => !m.valid) as InvalidMatcher 90 | 91 | return { 92 | valid: false, 93 | error: invalid.error, 94 | assertValid, 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/toMatcherOrError.ts: -------------------------------------------------------------------------------- 1 | import { regex, RegexFragment, regexEscape } from 'fancy-regex' 2 | import { ALL_URLS } from './constants' 3 | import { getHostRegex } from './getHostRegex' 4 | import { getPatternSegments } from './getPatternSegments' 5 | import { MatchPatternOptions } from './types' 6 | import { createMatchFn, normalizeUrlFragment } from './utils' 7 | 8 | export function toMatchFnOrError( 9 | pattern: string, 10 | options: Required, 11 | ) { 12 | const { supportedSchemes, schemeStarMatchesWs, strict } = options 13 | 14 | if (pattern === ALL_URLS) { 15 | return createMatchFn((url) => { 16 | return regex()` 17 | ^ 18 | (?:${supportedSchemes}) 19 | : 20 | $ 21 | `.test(url.protocol) 22 | }) 23 | } 24 | 25 | const unsupportedScheme = pattern.match(/^(urn|data):/)?.[1] 26 | 27 | if (unsupportedScheme) { 28 | return new TypeError( 29 | `browser-extension-url-match does not currently support scheme "${unsupportedScheme}"`, 30 | ) 31 | } 32 | 33 | const patternSegments = getPatternSegments(pattern) 34 | 35 | if (!patternSegments) { 36 | try { 37 | const url = new URL(pattern) 38 | 39 | if (url.hash || url.href.endsWith('#')) { 40 | return new TypeError( 41 | `Pattern cannot contain a hash: "${pattern}" contains hash "${url.hash || '#'}"`, 42 | ) 43 | } 44 | 45 | if (!pattern.slice(url.origin.length).startsWith('/')) { 46 | return new TypeError( 47 | `Pattern "${pattern}" does not contain a path. Use "${pattern}/*" to match any paths with that origin or "${pattern}/" to match that URL alone`, 48 | ) 49 | } 50 | } catch { 51 | /* fall back to generic err */ 52 | } 53 | 54 | return new TypeError(`Pattern "${pattern}" is invalid`) 55 | } 56 | 57 | const { scheme, rawPathAndQuery } = patternSegments 58 | 59 | /* Scheme */ 60 | if ( 61 | scheme !== '*' && 62 | !supportedSchemes.includes(scheme as (typeof supportedSchemes)[number]) 63 | ) { 64 | return new TypeError(`Scheme "${scheme}" is not supported`) 65 | } 66 | 67 | const schemeRegex = regex()`${ 68 | scheme === '*' 69 | ? new RegexFragment( 70 | ['https?', schemeStarMatchesWs && 'wss?'] 71 | .filter(Boolean) 72 | .join('|'), 73 | ) 74 | : scheme 75 | }:` 76 | 77 | /* Host */ 78 | const hostRegex = getHostRegex(patternSegments) 79 | 80 | if (hostRegex instanceof Error) { 81 | return hostRegex 82 | } 83 | 84 | /* Path and query string */ 85 | // Non-strict used for host permissions. 86 | // "The path must be present in a host permission, but is always treated as /*." 87 | // See https://developer.chrome.com/docs/extensions/mv3/match_patterns/ 88 | const pathAndQuery = strict ? normalizeUrlFragment(rawPathAndQuery) : '/*' 89 | 90 | if (pathAndQuery instanceof Error) { 91 | return pathAndQuery 92 | } 93 | 94 | const pathAndQueryRegex = regex()`^${new RegexFragment( 95 | pathAndQuery 96 | .split('*') 97 | .map((x) => regexEscape(x)) 98 | .join('.*'), 99 | )}$` 100 | 101 | return createMatchFn((url) => { 102 | // respect zero-search-string 103 | const pathAndQuery = 104 | url.pathname + (url.href.endsWith('?') ? '?' : url.search) 105 | 106 | return ( 107 | schemeRegex.test(url.protocol) && 108 | // test against `url.hostname`, not `url.host`, as port is ignored 109 | hostRegex.test(url.hostname) && 110 | pathAndQueryRegex.test(pathAndQuery) 111 | ) 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type MatchFn = (url: string | URL) => boolean 2 | 3 | type Assertable = { 4 | /** 5 | * Return the valid matcher or throw if invalid 6 | * 7 | * @throws {TypeError} 8 | */ 9 | assertValid: () => Matcher 10 | } 11 | 12 | export type Matcher = Readonly< 13 | Assertable & { 14 | valid: true 15 | match: MatchFn 16 | patterns: string[] 17 | examples: string[] 18 | config: MatchPatternOptions 19 | error?: undefined 20 | } 21 | > 22 | 23 | export type InvalidMatcher = Readonly< 24 | Assertable & { 25 | valid: false 26 | error: Error 27 | } 28 | > 29 | 30 | export type MatcherOrInvalid = Matcher | InvalidMatcher 31 | 32 | export type MatchPatternOptions = { 33 | supportedSchemes?: ( 34 | | 'http' 35 | | 'https' 36 | | 'ws' 37 | | 'wss' 38 | | 'ftp' 39 | | 'ftps' 40 | | 'file' 41 | )[] 42 | // | 'data' 43 | // | 'urn' 44 | schemeStarMatchesWs?: boolean 45 | strict?: boolean 46 | } 47 | 48 | export type PatternSegments = { 49 | pattern: string 50 | scheme: string 51 | rawHost: string 52 | rawPathAndQuery: string 53 | } 54 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { MatchFn } from './types' 2 | 3 | export const normalizeUrlFragment = (urlFragent: string) => { 4 | try { 5 | return encodeURI(decodeURI(urlFragent)) 6 | } catch (e) { 7 | return e as Error 8 | } 9 | } 10 | 11 | export function createMatchFn(fn: (url: URL) => boolean): MatchFn { 12 | return (url: string | URL) => { 13 | let normalizedUrl: URL 14 | 15 | try { 16 | const urlStr = url instanceof URL ? url.href : url 17 | 18 | normalizedUrl = new URL(urlStr) 19 | 20 | const normalizedPathname = normalizeUrlFragment( 21 | normalizedUrl.pathname, 22 | ) 23 | const normalizedSearch = normalizeUrlFragment(normalizedUrl.search) 24 | 25 | if ( 26 | normalizedPathname instanceof Error || 27 | normalizedSearch instanceof Error 28 | ) { 29 | return false 30 | } 31 | 32 | normalizedUrl.pathname = normalizedPathname 33 | 34 | if (!normalizedUrl.href.endsWith('?')) { 35 | // avoid nuking zero-search-string 36 | normalizedUrl.search = normalizedSearch 37 | } 38 | } catch (_e) { 39 | return false 40 | } 41 | 42 | return fn(normalizedUrl) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | "target": "ES2017", 5 | "module": "ESNext", 6 | "lib": ["DOM", "ES2019"], 7 | "allowJs": true, 8 | "declaration": true, 9 | "downlevelIteration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "moduleResolution": "node", 15 | "esModuleInterop": true, 16 | "outDir": "./dist" 17 | }, 18 | "include": ["src/**/*.ts"] 19 | } 20 | --------------------------------------------------------------------------------