diff --git a/README.md b/README.md
index d20c30b08..8e72d15f7 100644
--- a/README.md
+++ b/README.md
@@ -44,14 +44,23 @@ This extension is available in the below search engines.
To publish a ruleset as a subscription, place a ruleset file encoded in UTF-8 on a suitable HTTP(S) server, and publish the URL. Here is an example hosted on GitHub:
https://raw.githubusercontent.com/iorate/ublacklist-example-subscription/master/uBlacklist.txt
-In uBlacklist >=6.6.0 for _Chrome_, subscription links are available. To add a subscription with `name` and `url`, the following URL can be used as a shortcut to the options page:
+You can prepend YAML frontmatter to your ruleset. It is recommended that you set the `name` variable.
```
-https://iorate.github.io/ublacklist/subscribe?name={urlEncode(name)}&url={urlEncode(url)}
+---
+name: Your ruleset name
+---
+*://*.example.com/*
+```
+
+In uBlacklist >=6.6.0 for _Chrome_, subscription links are available. To add a subscription with `url`, the following URL can be used as a shortcut to the options page:
+
+```
+https://iorate.github.io/ublacklist/subscribe?url={urlEncode(url)}
```
For the above example:
-https://iorate.github.io/ublacklist/subscribe?name=Example&url=https%3A%2F%2Fraw.githubusercontent.com%2Fiorate%2Fublacklist-example-subscription%2Fmaster%2FuBlacklist.txt
+https://iorate.github.io/ublacklist/subscribe?url=https%3A%2F%2Fraw.githubusercontent.com%2Fiorate%2Fublacklist-example-subscription%2Fmaster%2FuBlacklist.txt
## For developers
diff --git a/biome.json b/biome.json
index 3b073dc9a..c16e51e92 100644
--- a/biome.json
+++ b/biome.json
@@ -1,7 +1,7 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
- "ignore": ["**/package.json"]
+ "ignore": ["**/package.json", "**/parser.js"]
},
"vcs": {
"enabled": true,
diff --git a/package.json b/package.json
index aa69abae7..fd1bb0fc9 100644
--- a/package.json
+++ b/package.json
@@ -3,15 +3,20 @@
"version": "0.0.0",
"dependencies": {
"@codemirror/commands": "^6.5.0",
+ "@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1",
+ "@codemirror/lint": "^6.7.0",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.26.3",
+ "@lezer/common": "^1.2.1",
"@lezer/highlight": "^1.2.0",
+ "@lezer/lr": "^1.4.0",
"@mdi/svg": "^7.4.47",
"colord": "^2.9.3",
"dayjs": "^1.11.11",
"goober": "2.1.10",
"is-mobile": "^4.0.0",
+ "js-yaml": "^4.1.0",
"punycode": "^2.3.1",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
@@ -21,7 +26,9 @@
},
"devDependencies": {
"@biomejs/biome": "^1.7.3",
+ "@lezer/generator": "^1.7.0",
"@types/fs-extra": "^11.0.4",
+ "@types/js-yaml": "^4.0.9",
"@types/license-checker": "^25.0.6",
"@types/node": "^20.12.8",
"@types/punycode": "^2.1.4",
@@ -56,8 +63,8 @@
"fix": "pnpm run /^fix:/",
"fix:biome": "biome check --apply .",
"fix:prettier": "prettier --write .",
+ "generate-ruleset-parser": "lezer-generator src/scripts/ruleset/ruleset.grammar -o src/scripts/ruleset/parser.js --noTerms",
"generate-third-party-notices": "tsx scripts/generate-third-party-notices.ts",
- "postinstall": "pnpm generate-third-party-notices",
"test": "tsx scripts/test.ts"
},
"type": "module",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ceb8545c4..740080552 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,18 +11,30 @@ importers:
'@codemirror/commands':
specifier: ^6.5.0
version: 6.5.0
+ '@codemirror/lang-yaml':
+ specifier: ^6.1.1
+ version: 6.1.1(@codemirror/view@6.26.3)
'@codemirror/language':
specifier: ^6.10.1
version: 6.10.1
+ '@codemirror/lint':
+ specifier: ^6.7.0
+ version: 6.7.0
'@codemirror/state':
specifier: ^6.4.1
version: 6.4.1
'@codemirror/view':
specifier: ^6.26.3
version: 6.26.3
+ '@lezer/common':
+ specifier: ^1.2.1
+ version: 1.2.1
'@lezer/highlight':
specifier: ^1.2.0
version: 1.2.0
+ '@lezer/lr':
+ specifier: ^1.4.0
+ version: 1.4.0
'@mdi/svg':
specifier: ^7.4.47
version: 7.4.47
@@ -38,6 +50,9 @@ importers:
is-mobile:
specifier: ^4.0.0
version: 4.0.0
+ js-yaml:
+ specifier: ^4.1.0
+ version: 4.1.0
punycode:
specifier: ^2.3.1
version: 2.3.1
@@ -60,9 +75,15 @@ importers:
'@biomejs/biome':
specifier: ^1.7.3
version: 1.7.3
+ '@lezer/generator':
+ specifier: ^1.7.0
+ version: 1.7.0
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
+ '@types/js-yaml':
+ specifier: ^4.0.9
+ version: 4.0.9
'@types/license-checker':
specifier: ^25.0.6
version: 25.0.6
@@ -946,12 +967,26 @@ packages:
cpu: [x64]
os: [win32]
+ '@codemirror/autocomplete@6.16.0':
+ resolution: {integrity: sha512-P/LeCTtZHRTCU4xQsa89vSKWecYv1ZqwzOd5topheGRf+qtacFgBeIMQi3eL8Kt/BUNvxUWkx+5qP2jlGoARrg==}
+ peerDependencies:
+ '@codemirror/language': ^6.0.0
+ '@codemirror/state': ^6.0.0
+ '@codemirror/view': ^6.0.0
+ '@lezer/common': ^1.0.0
+
'@codemirror/commands@6.5.0':
resolution: {integrity: sha512-rK+sj4fCAN/QfcY9BEzYMgp4wwL/q5aj/VfNSoH1RWPF9XS/dUwBkvlL3hpWgEjOqlpdN1uLC9UkjJ4tmyjJYg==}
+ '@codemirror/lang-yaml@6.1.1':
+ resolution: {integrity: sha512-HV2NzbK9bbVnjWxwObuZh5FuPCowx51mEfoFT9y3y+M37fA3+pbxx4I7uePuygFzDsAmCTwQSc/kXh/flab4uw==}
+
'@codemirror/language@6.10.1':
resolution: {integrity: sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==}
+ '@codemirror/lint@6.7.0':
+ resolution: {integrity: sha512-LTLOL2nT41ADNSCCCCw8Q/UmdAFzB23OUYSjsHTdsVaH0XEo+orhuqbDNWzrzodm14w6FOxqxpmy4LF8Lixqjw==}
+
'@codemirror/state@6.4.1':
resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==}
@@ -1318,12 +1353,19 @@ packages:
'@lezer/common@1.2.1':
resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==}
+ '@lezer/generator@1.7.0':
+ resolution: {integrity: sha512-IJ16tx3biLKlCXUzcK4v8S10AVa2BSM2rB12rtAL6f1hL2TS/HQQlGCoWRvanlL2J4mCYEEIv9uG7n4kVMkVDA==}
+ hasBin: true
+
'@lezer/highlight@1.2.0':
resolution: {integrity: sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==}
'@lezer/lr@1.4.0':
resolution: {integrity: sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==}
+ '@lezer/yaml@1.0.3':
+ resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==}
+
'@mdi/svg@7.4.47':
resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==}
@@ -1632,6 +1674,9 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
+ '@types/js-yaml@4.0.9':
+ resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -2431,6 +2476,9 @@ packages:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'}
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -7172,6 +7220,13 @@ snapshots:
'@biomejs/cli-win32-x64@1.7.3':
optional: true
+ '@codemirror/autocomplete@6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)(@lezer/common@1.2.1)':
+ dependencies:
+ '@codemirror/language': 6.10.1
+ '@codemirror/state': 6.4.1
+ '@codemirror/view': 6.26.3
+ '@lezer/common': 1.2.1
+
'@codemirror/commands@6.5.0':
dependencies:
'@codemirror/language': 6.10.1
@@ -7179,6 +7234,17 @@ snapshots:
'@codemirror/view': 6.26.3
'@lezer/common': 1.2.1
+ '@codemirror/lang-yaml@6.1.1(@codemirror/view@6.26.3)':
+ dependencies:
+ '@codemirror/autocomplete': 6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)(@lezer/common@1.2.1)
+ '@codemirror/language': 6.10.1
+ '@codemirror/state': 6.4.1
+ '@lezer/common': 1.2.1
+ '@lezer/highlight': 1.2.0
+ '@lezer/yaml': 1.0.3
+ transitivePeerDependencies:
+ - '@codemirror/view'
+
'@codemirror/language@6.10.1':
dependencies:
'@codemirror/state': 6.4.1
@@ -7188,6 +7254,12 @@ snapshots:
'@lezer/lr': 1.4.0
style-mod: 4.1.2
+ '@codemirror/lint@6.7.0':
+ dependencies:
+ '@codemirror/state': 6.4.1
+ '@codemirror/view': 6.26.3
+ crelt: 1.0.6
+
'@codemirror/state@6.4.1': {}
'@codemirror/view@6.26.3':
@@ -7987,6 +8059,11 @@ snapshots:
'@lezer/common@1.2.1': {}
+ '@lezer/generator@1.7.0':
+ dependencies:
+ '@lezer/common': 1.2.1
+ '@lezer/lr': 1.4.0
+
'@lezer/highlight@1.2.0':
dependencies:
'@lezer/common': 1.2.1
@@ -7995,6 +8072,12 @@ snapshots:
dependencies:
'@lezer/common': 1.2.1
+ '@lezer/yaml@1.0.3':
+ dependencies:
+ '@lezer/common': 1.2.1
+ '@lezer/highlight': 1.2.0
+ '@lezer/lr': 1.4.0
+
'@mdi/svg@7.4.47': {}
'@mdx-js/mdx@3.0.1':
@@ -8404,6 +8487,8 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
+ '@types/js-yaml@4.0.9': {}
+
'@types/json-schema@7.0.15': {}
'@types/jsonfile@6.1.4':
@@ -9330,6 +9415,8 @@ snapshots:
crc-32: 1.2.2
readable-stream: 3.6.2
+ crelt@1.0.6: {}
+
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
diff --git a/scripts/build.ts b/scripts/build.ts
index c873e30c8..782afb8b7 100644
--- a/scripts/build.ts
+++ b/scripts/build.ts
@@ -170,7 +170,7 @@ async function createBuildScripts(
bundle: true,
define: defineProcessEnv(context),
entryPoints: sources.map((file) => path.join(srcDir, file)),
- format: "esm",
+ format: "iife",
jsx: "automatic",
jsxDev: debug,
// https://github.com/evanw/esbuild/issues/3418
diff --git a/src/_locales/en/messages.json.ts b/src/_locales/en/messages.json.ts
index 674e8d88b..5f1d9095c 100644
--- a/src/_locales/en/messages.json.ts
+++ b/src/_locales/en/messages.json.ts
@@ -344,12 +344,20 @@ export default exportAsMessages({
// The title of the add-subscription dialog.
options_addSubscriptionDialog_title: "Add a subscription",
+ // UNUSED
// The label for the name input on the add-subscription dialog.
options_addSubscriptionDialog_nameLabel: "Name",
// The label for the URL input on the add-subscription dialog.
options_addSubscriptionDialog_urlLabel: "URL",
+ // The label for the alternative name input on the add-subscription dialog.
+ options_addSubscriptionDialog_altNameLabel: "Alternative name (optional)",
+
+ // The helper text for the alternative name input on the add-subscription dialog.
+ options_addSubscriptionDialog_altNameDescription:
+ "The alternative name used when a downloaded ruleset does not contain a name.",
+
// The text of the add button on the add-subscription dialog.
options_addSubscriptionDialog_addButton: "Add",
diff --git a/src/_locales/ja/messages.json.ts b/src/_locales/ja/messages.json.ts
index 02f269c1f..459f865f0 100644
--- a/src/_locales/ja/messages.json.ts
+++ b/src/_locales/ja/messages.json.ts
@@ -126,6 +126,9 @@ export default exportAsMessages({
options_addSubscriptionDialog_title: "購読を追加する",
options_addSubscriptionDialog_nameLabel: "名前",
options_addSubscriptionDialog_urlLabel: "URL",
+ options_addSubscriptionDialog_altNameLabel: "代替の名前 (オプション)",
+ options_addSubscriptionDialog_altNameDescription:
+ "ダウンロードしたルールセットに名前が含まれないときに、代わりに使用される名前です。",
options_addSubscriptionDialog_addButton: "追加",
options_showSubscriptionDialog_blacklistLabel: "ルールのリスト",
options_updateInterval: "更新の間隔",
diff --git a/src/common/locales.ts b/src/common/locales.ts
index da0186f72..fc588ebc2 100644
--- a/src/common/locales.ts
+++ b/src/common/locales.ts
@@ -108,6 +108,8 @@ export type MessageName =
| "options_addSubscriptionDialog_title"
| "options_addSubscriptionDialog_nameLabel"
| "options_addSubscriptionDialog_urlLabel"
+ | "options_addSubscriptionDialog_altNameLabel"
+ | "options_addSubscriptionDialog_altNameDescription"
| "options_addSubscriptionDialog_addButton"
| "options_showSubscriptionDialog_blacklistLabel"
| "options_updateInterval"
diff --git a/src/common/match-pattern.test.ts b/src/common/match-pattern.test.ts
new file mode 100644
index 000000000..326271cab
--- /dev/null
+++ b/src/common/match-pattern.test.ts
@@ -0,0 +1,204 @@
+import assert from "node:assert";
+import { test } from "node:test";
+import { MatchPatternMap } from "./match-pattern.ts";
+
+function get(map: MatchPatternMap, url: string) {
+ return map.get(url).sort();
+}
+
+test("MatchPatternMap", async (t) => {
+ // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
+ await t.test("MDN Examples", () => {
+ const map = new MatchPatternMap();
+ map.set("", 0);
+ map.set("*://*/*", 1);
+ map.set("*://*.mozilla.org/*", 2);
+ map.set("*://mozilla.org/", 3);
+ assert.throws(() => map.set("ftp://mozilla.org/", 4));
+ map.set("https://*/path", 5);
+ map.set("https://*/path/", 6);
+ map.set("https://mozilla.org/*", 7);
+ map.set("https://mozilla.org/a/b/c/", 8);
+ map.set("https://mozilla.org/*/b/*/", 9);
+ assert.throws(() => map.set("file:///blah/*", 10));
+ //
+ assert.deepStrictEqual(get(map, "http://example.org/"), [0, 1]);
+ assert.deepStrictEqual(get(map, "https://a.org/some/path/"), [0, 1]);
+ assert.deepStrictEqual(get(map, "ws://sockets.somewhere.org/"), []);
+ assert.deepStrictEqual(get(map, "wss://ws.example.com/stuff/"), []);
+ assert.deepStrictEqual(get(map, "ftp://files.somewhere.org/"), []);
+ assert.deepStrictEqual(get(map, "resource://a/b/c/"), []);
+ assert.deepStrictEqual(get(map, "ftps://files.somewhere.org/"), []);
+ // *://*/*
+ assert.deepStrictEqual(get(map, "http://example.org/"), [0, 1]);
+ assert.deepStrictEqual(get(map, "https://a.org/some/path/"), [0, 1]);
+ assert.deepStrictEqual(get(map, "ws://sockets.somewhere.org/"), []);
+ assert.deepStrictEqual(get(map, "wss://ws.example.com/stuff/"), []);
+ assert.deepStrictEqual(get(map, "ftp://ftp.example.org/"), []);
+ assert.deepStrictEqual(get(map, "file:///a/"), []);
+ // *://*.mozilla.org/*
+ assert.deepStrictEqual(get(map, "http://mozilla.org/"), [0, 1, 2, 3]);
+ assert.deepStrictEqual(get(map, "https://mozilla.org/"), [0, 1, 2, 3, 7]);
+ assert.deepStrictEqual(get(map, "http://a.mozilla.org/"), [0, 1, 2]);
+ assert.deepStrictEqual(get(map, "http://a.b.mozilla.org/"), [0, 1, 2]);
+ assert.deepStrictEqual(
+ get(map, "https://b.mozilla.org/path/"),
+ [0, 1, 2, 6],
+ );
+ assert.deepStrictEqual(get(map, "ws://ws.mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "wss://secure.mozilla.org/something"), []);
+ assert.deepStrictEqual(get(map, "ftp://mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "http://mozilla.com/"), [0, 1]);
+ assert.deepStrictEqual(get(map, "http://firefox.org/"), [0, 1]);
+ // *://mozilla.org/
+ assert.deepStrictEqual(get(map, "http://mozilla.org/"), [0, 1, 2, 3]);
+ assert.deepStrictEqual(get(map, "https://mozilla.org/"), [0, 1, 2, 3, 7]);
+ assert.deepStrictEqual(get(map, "ws://mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "wss://mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "ftp://mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "http://a.mozilla.org/"), [0, 1, 2]);
+ assert.deepStrictEqual(get(map, "http://mozilla.org/a"), [0, 1, 2]);
+ // ftp://mozilla.org/
+ assert.deepStrictEqual(get(map, "ftp://mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "http://mozilla.org/"), [0, 1, 2, 3]);
+ assert.deepStrictEqual(get(map, "ftp://sub.mozilla.org/"), []);
+ assert.deepStrictEqual(get(map, "ftp://mozilla.org/path"), []);
+ // https://*/path
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path"),
+ [0, 1, 2, 5, 7],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://a.mozilla.org/path"),
+ [0, 1, 2, 5],
+ );
+ assert.deepStrictEqual(get(map, "https://something.com/path"), [0, 1, 5]);
+ assert.deepStrictEqual(get(map, "http://mozilla.org/path"), [0, 1, 2]);
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path/"),
+ [0, 1, 2, 6, 7],
+ );
+ assert.deepStrictEqual(get(map, "https://mozilla.org/a"), [0, 1, 2, 7]);
+ assert.deepStrictEqual(get(map, "https://mozilla.org/"), [0, 1, 2, 3, 7]);
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path?foo=1"),
+ [0, 1, 2, 7],
+ );
+ // https://*/path/
+ assert.deepStrictEqual(get(map, "http://mozilla.org/path/"), [0, 1, 2]);
+ assert.deepStrictEqual(
+ get(map, "https://a.mozilla.org/path/"),
+ [0, 1, 2, 6],
+ );
+ assert.deepStrictEqual(get(map, "https://something.com/path/"), [0, 1, 6]);
+ assert.deepStrictEqual(get(map, "http://mozilla.org/path/"), [0, 1, 2]);
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path"),
+ [0, 1, 2, 5, 7],
+ );
+ assert.deepStrictEqual(get(map, "https://mozilla.org/a"), [0, 1, 2, 7]);
+ assert.deepStrictEqual(get(map, "https://mozilla.org/"), [0, 1, 2, 3, 7]);
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path?foo=1"),
+ [0, 1, 2, 7],
+ );
+ // https://mozilla.org/*
+ assert.deepStrictEqual(get(map, "https://mozilla.org/"), [0, 1, 2, 3, 7]);
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path"),
+ [0, 1, 2, 5, 7],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/another"),
+ [0, 1, 2, 7],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path/to/doc"),
+ [0, 1, 2, 7],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/path/to/doc?foo=1"),
+ [0, 1, 2, 7],
+ );
+ // https://mozilla.org/a/b/c/
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/"),
+ [0, 1, 2, 7, 8, 9],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/#section1"),
+ [0, 1, 2, 7, 8, 9],
+ );
+ // https://mozilla.org/*/b/*/
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/"),
+ [0, 1, 2, 7, 8, 9],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/d/b/f/"),
+ [0, 1, 2, 7, 9],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/d/"),
+ [0, 1, 2, 7, 9],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/d/#section1"),
+ [0, 1, 2, 7, 9],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/d/?foo=/"),
+ [0, 1, 2, 7, 9],
+ );
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a?foo=21314&bar=/b/&extra=c/"),
+ [0, 1, 2, 7, 9],
+ );
+ assert.deepStrictEqual(get(map, "https://mozilla.org/b/*/"), [0, 1, 2, 7]);
+ assert.deepStrictEqual(get(map, "https://mozilla.org/a/b/"), [0, 1, 2, 7]);
+ assert.deepStrictEqual(
+ get(map, "https://mozilla.org/a/b/c/d/?foo=bar"),
+ [0, 1, 2, 7],
+ );
+ // file:///blah/*
+ assert.deepStrictEqual(get(map, "file:///blah/"), []);
+ assert.deepStrictEqual(get(map, "file:///blah/bleh"), []);
+ assert.deepStrictEqual(get(map, "file:///bleh/"), []);
+ // Invalid match patterns
+ assert.throws(() => map.set("resource://path/", 11));
+ assert.throws(() => map.set("https://mozilla.org", 12));
+ assert.throws(() => map.set("https://mozilla.*.org/", 13));
+ assert.throws(() => map.set("https://*zilla.org", 14));
+ assert.throws(() => map.set("http*://mozilla.org/", 15));
+ assert.throws(() => map.set("https://mozilla.org:80/", 16));
+ assert.throws(() => map.set("*//*", 17));
+ assert.throws(() => map.set("file://*", 18));
+ });
+
+ await t.test("Serialization", () => {
+ let map = new MatchPatternMap();
+ map.set("", 0);
+ map.set("*://*/*", 1);
+ map.set("*://*.mozilla.org/*", 2);
+ map.set("*://mozilla.org/", 3);
+ map.set("https://*/path", 5);
+ map.set("https://*/path/", 6);
+ map.set("https://mozilla.org/*", 7);
+ map.set("https://mozilla.org/a/b/c/", 8);
+ map.set("https://mozilla.org/*/b/*/", 9);
+ const json = map.toJSON();
+ assert.strictEqual(
+ JSON.stringify(json),
+ '[[0],[[],[[1],[5,"https","/path"],[6,"https","/path/"]],{"org":[[],[],{"mozilla":[[[3,"*","/"],[7,"https"],[8,"https","/a/b/c/"],[9,"https","/*/b/*/"]],[[2]]]}]}]]',
+ );
+ map = new MatchPatternMap(json);
+ assert.deepStrictEqual(get(map, "http://mozilla.org/"), [0, 1, 2, 3]);
+ assert.deepStrictEqual(get(map, "https://mozilla.org/"), [0, 1, 2, 3, 7]);
+ assert.deepStrictEqual(get(map, "http://a.mozilla.org/"), [0, 1, 2]);
+ assert.deepStrictEqual(get(map, "http://a.b.mozilla.org/"), [0, 1, 2]);
+ assert.deepStrictEqual(
+ get(map, "https://b.mozilla.org/path/"),
+ [0, 1, 2, 6],
+ );
+ });
+});
diff --git a/src/common/match-pattern.ts b/src/common/match-pattern.ts
index 90b45222d..b0075eaaa 100644
--- a/src/common/match-pattern.ts
+++ b/src/common/match-pattern.ts
@@ -1,14 +1,162 @@
-export type MatchPatternScheme = "*" | "http" | "https" | "ftp";
-
-export type ParsedMatchPattern = {
- scheme: MatchPatternScheme;
- host: string;
- path: string;
-};
-
-export function parseMatchPattern(input: string): ParsedMatchPattern | null {
- const m = /^(\*|https?|ftp):\/\/(\*|(?:\*\.)?[^/*]+)(\/.*)$/.exec(
- input.trim(),
- );
- return m && { scheme: m[1] as MatchPatternScheme, host: m[2], path: m[3] };
+export type ParsedMatchPattern =
+ | {
+ allURLs: true;
+ }
+ | {
+ allURLs: false;
+ scheme: string;
+ host: string;
+ path: string;
+ };
+
+export function parseMatchPattern(pattern: string): ParsedMatchPattern | null {
+ const execResult = matchPatternRegExp.exec(pattern);
+ if (!execResult) {
+ return null;
+ }
+ const groups = execResult.groups as
+ | { allURLs: string }
+ | { allURLs?: never; scheme: string; host: string; path: string };
+ return groups.allURLs != null
+ ? { allURLs: true }
+ : {
+ allURLs: false,
+ scheme: groups.scheme.toLowerCase(),
+ host: groups.host.toLowerCase(),
+ path: groups.path,
+ };
+}
+
+const matchPatternRegExp = (() => {
+ const allURLs = String.raw`(?)`;
+ const scheme = String.raw`(?\*|[A-Za-z][0-9A-Za-z+.-]*)`;
+ const label = String.raw`(?:[0-9A-Za-z](?:[0-9A-Za-z-]*[0-9A-Za-z])?)`;
+ const host = String.raw`(?(?:\*|${label})(?:\.${label})*)`;
+ const path = String.raw`(?/(?:\*|[0-9A-Za-z._~:/?[\]@!$&'()+,;=-]|%[0-9A-Fa-f]{2})*)`;
+ return new RegExp(String.raw`^(?:${allURLs}|${scheme}://${host}${path})$`);
+})();
+
+export type MatchPatternMapJSON = [allURLs: T[], hostMap: HostMap];
+
+export class MatchPatternMap {
+ static supportedSchemes: string[] = ["http", "https"];
+
+ private allURLs: T[];
+ private hostMap: HostMap;
+
+ constructor(json?: Readonly>) {
+ if (json) {
+ this.allURLs = json[0];
+ this.hostMap = json[1];
+ } else {
+ this.allURLs = [];
+ this.hostMap = [[], []];
+ }
+ }
+
+ toJSON(): MatchPatternMapJSON {
+ return [this.allURLs, this.hostMap];
+ }
+
+ get(url: string): T[] {
+ const { protocol, hostname: host, pathname, search } = new URL(url);
+ const scheme = protocol.slice(0, -1);
+ const path = `${pathname}${search}`;
+ if (!MatchPatternMap.supportedSchemes.includes(scheme)) {
+ return [];
+ }
+ const values: T[] = [...this.allURLs];
+ let node = this.hostMap;
+ for (const label of host.split(".").reverse()) {
+ collectBucket(node[1], scheme, path, values);
+ if (!node[2]?.[label]) {
+ return values;
+ }
+ node = node[2][label];
+ }
+ collectBucket(node[1], scheme, path, values);
+ collectBucket(node[0], scheme, path, values);
+ return values;
+ }
+
+ set(pattern: string, value: T) {
+ const parseResult = parseMatchPattern(pattern);
+ if (!parseResult) {
+ throw new Error(`Invalid match pattern: ${pattern}`);
+ }
+ if (parseResult.allURLs) {
+ this.allURLs.push(value);
+ return;
+ }
+ const { scheme, host, path } = parseResult;
+ if (scheme !== "*" && !MatchPatternMap.supportedSchemes.includes(scheme)) {
+ throw new Error(`Unsupported scheme: ${scheme}`);
+ }
+ const labels = host.split(".").reverse();
+ const anySubdomain = labels[labels.length - 1] === "*";
+ if (anySubdomain) {
+ labels.pop();
+ }
+ let node = this.hostMap;
+ for (const label of labels) {
+ node[2] ||= {};
+ node = node[2][label] ||= [[], []];
+ }
+ node[anySubdomain ? 1 : 0].push(
+ path === "/*"
+ ? scheme === "*"
+ ? [value]
+ : [value, scheme]
+ : [value, scheme, path],
+ );
+ }
+}
+
+type HostMap = [
+ self: HostMapBucket,
+ anySubdomain: HostMapBucket,
+ subdomains?: Record>,
+];
+
+type HostMapBucket = [value: T, scheme?: string, path?: string][];
+
+function collectBucket(
+ bucket: HostMapBucket,
+ scheme: string,
+ path: string,
+ values: T[],
+): void {
+ for (const [value, schemePattern = "*", pathPattern = "/*"] of bucket) {
+ if (testScheme(schemePattern, scheme) && testPath(pathPattern, path)) {
+ values.push(value);
+ }
+ }
+}
+
+function testScheme(schemePattern: string, scheme: string): boolean {
+ return schemePattern === "*"
+ ? scheme === "http" || scheme === "https"
+ : scheme === schemePattern;
+}
+
+function testPath(pathPattern: string, path: string): boolean {
+ if (pathPattern === "/*") {
+ return true;
+ }
+ const [first, ...rest] = pathPattern.split("*");
+ if (rest.length === 0) {
+ return path === first;
+ }
+ if (!path.startsWith(first)) {
+ return false;
+ }
+ let pos = first.length;
+ for (const part of rest.slice(0, -1)) {
+ const partPos = path.indexOf(part, pos);
+ if (partPos === -1) {
+ return false;
+ }
+ pos = partPos + part.length;
+ }
+ return path.slice(pos).endsWith(rest[rest.length - 1]);
}
diff --git a/src/process-env.d.ts b/src/common/process-env.d.ts
similarity index 100%
rename from src/process-env.d.ts
rename to src/common/process-env.d.ts
diff --git a/src/manifest.json.ts b/src/manifest.json.ts
index 68dff99e9..bd50b1477 100644
--- a/src/manifest.json.ts
+++ b/src/manifest.json.ts
@@ -46,7 +46,9 @@ export default {
if (!parsed) {
throw new Error(`Invalid match pattern: ${match}`);
}
- return `${parsed.scheme}://${parsed.host}/*`;
+ return parsed.allURLs
+ ? ""
+ : `${parsed.scheme}://${parsed.host}/*`;
}),
),
],
diff --git a/src/pages/options.html b/src/pages/options.html
index 6463d768f..4e96b155e 100644
--- a/src/pages/options.html
+++ b/src/pages/options.html
@@ -7,6 +7,6 @@
-
+