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 @@
- + diff --git a/src/pages/popup.html b/src/pages/popup.html index 6f4e89120..60919664b 100644 --- a/src/pages/popup.html +++ b/src/pages/popup.html @@ -7,6 +7,6 @@
- + diff --git a/src/pages/watch.html b/src/pages/watch.html index f0a4e671b..83b4c21f3 100644 --- a/src/pages/watch.html +++ b/src/pages/watch.html @@ -4,6 +4,6 @@ - + diff --git a/src/scripts/background/backup-restore.ts b/src/scripts/background/backup-restore.ts index 462970551..cd208c560 100644 --- a/src/scripts/background/backup-restore.ts +++ b/src/scripts/background/backup-restore.ts @@ -5,12 +5,11 @@ import { defaultLocalStorageItems, loadFromLocalStorage, } from "../local-storage.ts"; -import { Ruleset } from "../ruleset.ts"; import type { LocalStorageItemsBackupRestore, Subscriptions, } from "../types.ts"; -import { stringEntries } from "../utilities.ts"; +import { stringEntries, toPlainRuleset } from "../utilities.ts"; import { resetAllInRawStorage } from "./raw-storage.ts"; import { updateAll as updateAllSubscriptions } from "./subscriptions.ts"; @@ -52,7 +51,7 @@ export async function restore( const now = dayjs().toISOString(); const blacklist = items.blacklist ?? defaults.blacklist; - const compiledRules = Ruleset.compile(blacklist); + const ruleset = toPlainRuleset(blacklist); const subscriptions: Subscriptions = {}; for (const { name, url, enabled } of items.subscriptions || @@ -60,8 +59,8 @@ export async function restore( subscriptions[nextSubscriptionId] = { name, url, + ruleset: toPlainRuleset(""), blacklist: "", - compiledRules: Ruleset.compile(""), updateResult: false, enabled: enabled ?? true, }; @@ -69,8 +68,8 @@ export async function restore( } return { + ruleset, blacklist, - compiledRules, timestamp: now, blockWholeSite: items.blockWholeSite ?? defaults.blockWholeSite, diff --git a/src/scripts/background/local-storage.ts b/src/scripts/background/local-storage.ts index ce116de90..a44b9d344 100644 --- a/src/scripts/background/local-storage.ts +++ b/src/scripts/background/local-storage.ts @@ -1,13 +1,12 @@ import dayjs from "dayjs"; import { postMessage } from "../messages.ts"; -import { Ruleset } from "../ruleset.ts"; import type { LocalStorageItemsSavable, SaveSource, Subscription, SubscriptionId, } from "../types.ts"; -import { numberKeys } from "../utilities.ts"; +import { numberKeys, toPlainRuleset } from "../utilities.ts"; import { type RawStorageItems, modifyInRawStorage, @@ -33,7 +32,7 @@ const localStorageSections: readonly LocalStorageSection[] = [ { beforeSave(items, dirtyFlagsUpdate, now) { if (items.blacklist != null) { - items.compiledRules = Ruleset.compile(items.blacklist); + items.ruleset = toPlainRuleset(items.blacklist); items.timestamp = now.toISOString(); dirtyFlagsUpdate.blocklist = true; } @@ -93,7 +92,8 @@ export async function save( export async function compileRules(): Promise { return modifyInRawStorage(["blacklist"], ({ blacklist }) => ({ - compiledRules: Ruleset.compile(blacklist), + ruleset: toPlainRuleset(blacklist), + compiledRules: false, })); } diff --git a/src/scripts/background/subscriptions.ts b/src/scripts/background/subscriptions.ts index b9579b5b0..933c0426d 100644 --- a/src/scripts/background/subscriptions.ts +++ b/src/scripts/background/subscriptions.ts @@ -1,12 +1,12 @@ import { browser } from "../browser.ts"; import { postMessage } from "../messages.ts"; -import { Ruleset } from "../ruleset.ts"; import type { SubscriptionId } from "../types.ts"; import { HTTPError, errorResult, numberKeys, successResult, + toPlainRuleset, } from "../utilities.ts"; import { loadFromRawStorage, modifyInRawStorage } from "./raw-storage.ts"; @@ -32,7 +32,9 @@ async function tryLock( export function update(id: SubscriptionId): Promise { return tryLock(id, async () => { const { - subscriptions: { [id]: subscription }, + subscriptions: { + [id]: { compiledRules, ...subscription }, + }, } = await loadFromRawStorage(["subscriptions"]); if (!subscription || !(subscription.enabled ?? true)) { return; @@ -43,8 +45,9 @@ export function update(id: SubscriptionId): Promise { try { const response = await fetch(subscription.url); if (response.ok) { - subscription.blacklist = await response.text(); - subscription.compiledRules = Ruleset.compile(subscription.blacklist); + const source = await response.text(); + subscription.ruleset = toPlainRuleset(source); + subscription.blacklist = source; subscription.updateResult = successResult(); } else { subscription.updateResult = errorResult( diff --git a/src/scripts/background/sync.ts b/src/scripts/background/sync.ts index 3f7d6f7cf..f5086621c 100644 --- a/src/scripts/background/sync.ts +++ b/src/scripts/background/sync.ts @@ -2,7 +2,6 @@ import dayjs from "dayjs"; import { z } from "zod"; import { browser } from "../browser.ts"; import { postMessage } from "../messages.ts"; -import { Ruleset } from "../ruleset.ts"; import type { Result, Subscriptions } from "../types.ts"; import { Mutex, @@ -10,6 +9,7 @@ import { numberKeys, parseJSON, successResult, + toPlainRuleset, } from "../utilities.ts"; import { syncFile } from "./clouds.ts"; import { @@ -78,11 +78,11 @@ const syncSections: readonly SyncSection[] = [ }; }, afterDownload(cloudItems, cloudContent, cloudModifiedTime) { - const blacklist = cloudContent; + const source = cloudContent; return { ...cloudItems, - blacklist, - compiledRules: Ruleset.compile(blacklist), + ruleset: toPlainRuleset(source), + blacklist: source, timestamp: cloudModifiedTime.toISOString(), }; }, @@ -91,8 +91,7 @@ const syncSections: readonly SyncSection[] = [ cloudItems.timestamp != null && dayjs(cloudItems.timestamp).isBefore(latestLocalItems.timestamp) ) { - const { blacklist, compiledRules, timestamp, ...newCloudItems } = - cloudItems; + const { ruleset, blacklist, timestamp, ...newCloudItems } = cloudItems; return newCloudItems; } return { ...cloudItems }; diff --git a/src/scripts/block-dialog.tsx b/src/scripts/block-dialog.tsx index 89490eeff..8715bd1da 100644 --- a/src/scripts/block-dialog.tsx +++ b/src/scripts/block-dialog.tsx @@ -29,6 +29,7 @@ import { useClassName, usePrevious } from "./components/utilities.ts"; import type { InteractiveRuleset } from "./interactive-ruleset.ts"; import { translate } from "./locales.ts"; import { PathDepth } from "./path-depth.ts"; +import type { LinkProps } from "./ruleset/ruleset.ts"; import type { DialogTheme } from "./types.ts"; import { makeAltURL, svgToDataURL } from "./utilities.ts"; @@ -36,11 +37,10 @@ type BlockDialogContentProps = { blockWholeSite: boolean; close: () => void; enablePathDepth: boolean; + entryProps: LinkProps; open: boolean; openOptionsPage: () => Promise; ruleset: InteractiveRuleset; - title: string | null; - url: string; onBlocked: () => void | Promise; }; @@ -48,11 +48,10 @@ const BlockDialogContent: React.FC = ({ blockWholeSite, close, enablePathDepth, + entryProps, open, openOptionsPage, ruleset, - title, - url: entryURL, onBlocked, }) => { const [state, setState] = useState({ @@ -68,9 +67,9 @@ const BlockDialogContent: React.FC = ({ }); const prevOpen = usePrevious(open); if (open && !prevOpen) { - const url = makeAltURL(entryURL); + const url = makeAltURL(entryProps.url); if (url && /^(https?|ftp)$/.test(url.scheme)) { - const patch = ruleset.createPatch({ url, title }, blockWholeSite); + const patch = ruleset.createPatch(entryProps, blockWholeSite); state.disabled = false; state.unblock = patch.unblock; state.host = punycode.toUnicode( @@ -85,7 +84,7 @@ const BlockDialogContent: React.FC = ({ } else { state.disabled = true; state.unblock = false; - state.host = entryURL; + state.host = entryProps.url; state.detailsOpen = false; state.pathDepth = null; state.depth = ""; @@ -156,7 +155,7 @@ const BlockDialogContent: React.FC = ({ id="url" readOnly rows={2} - value={entryURL} + value={entryProps.url} /> )} @@ -217,7 +216,7 @@ const BlockDialogContent: React.FC = ({ readOnly rows={2} spellCheck="false" - value={title ?? ""} + value={entryProps.title ?? ""} /> diff --git a/src/scripts/components/dialog.tsx b/src/scripts/components/dialog.tsx index 9dea571ae..28db59aa3 100644 --- a/src/scripts/components/dialog.tsx +++ b/src/scripts/components/dialog.tsx @@ -194,6 +194,7 @@ export const DialogTitle = React.forwardRef< fontSize: "1.125em", fontWeight: "normal", margin: 0, + overflowWrap: "break-word", }), [], ); diff --git a/src/scripts/components/editor.tsx b/src/scripts/components/editor.tsx index 5bab3fdb4..f358b844e 100644 --- a/src/scripts/components/editor.tsx +++ b/src/scripts/components/editor.tsx @@ -1,10 +1,10 @@ import { history, historyKeymap, standardKeymap } from "@codemirror/commands"; import { HighlightStyle, - type Language, - language, + type LanguageSupport, syntaxHighlighting, } from "@codemirror/language"; +import { lintGutter } from "@codemirror/lint"; import { Compartment, EditorState, Transaction } from "@codemirror/state"; import { EditorView, @@ -18,11 +18,58 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { FOCUS_END_CLASS, FOCUS_START_CLASS } from "./constants.ts"; import { useTheme } from "./theme.tsx"; +type ColorScheme = { + background: string; + foreground: string; + selectionBackground: string; + lineNumberForeground: string; + activeLineNumberForeground: string; + comment: string; + name: string; + literal: string; + string: string; + keyword: string; + operator: string; + meta: string; +}; + +// [jellybeans.vim](https://github.com/nanotech/jellybeans.vim) +const darkColorScheme: ColorScheme = { + background: "#202124", + foreground: "#e8e8d3", + selectionBackground: "#2e2e2e", + lineNumberForeground: "#858585", + activeLineNumberForeground: "#fabb6e", + comment: "#888888", + name: "#fabb6e", + literal: "#cf6a4c", + string: "#99ad6a", + keyword: "#8197bf", + operator: "#ffe2a9", + meta: "#8fbfdc", +}; + +// [hybrid.vim](https://github.com/w0ng/vim-hybrid) +const lightColorScheme: ColorScheme = { + background: "#f8f9fa", + foreground: "#000", + selectionBackground: "#bcbcbc", + lineNumberForeground: "#bcbcbc", + activeLineNumberForeground: "#5f5f00", + comment: "#5f5f5f", + name: "#875f00", + literal: "#5f0000", + string: "#005f00", + keyword: "#00005f", + operator: "#8abeb7", + meta: "#005f5f", +}; + export type EditorProps = { focusStart?: boolean; focusEnd?: boolean; height?: string; - language?: Language; + language?: LanguageSupport; readOnly?: boolean; resizable?: boolean; value?: string; @@ -58,6 +105,7 @@ export const Editor: React.FC = ({ extensions: [ keymap.of([...standardKeymap, ...historyKeymap]), history(), + lintGutter(), lineNumbers(), highlightActiveLineGutter(), highlightSpecialChars(), @@ -91,26 +139,20 @@ export const Editor: React.FC = ({ const theme = useTheme(); useLayoutEffect(() => { + const colorScheme = + theme.name === "dark" ? darkColorScheme : lightColorScheme; view.current?.dispatch({ effects: highlightStyleCompartment.current.reconfigure( syntaxHighlighting( HighlightStyle.define([ - { - tag: t.annotation, - color: theme.editor.annotation, - }, - { - tag: t.regexp, - color: theme.editor.regexp, - }, - { - tag: t.comment, - color: theme.editor.comment, - }, - { - tag: t.invalid, - color: theme.editor.comment, - }, + { tag: t.comment, color: colorScheme.comment }, + { tag: t.name, color: colorScheme.name }, + { tag: t.literal, color: colorScheme.literal }, + { tag: t.string, color: colorScheme.string }, + { tag: t.regexp, color: colorScheme.string }, + { tag: t.keyword, color: colorScheme.keyword }, + { tag: t.operator, color: colorScheme.operator }, + { tag: t.meta, color: colorScheme.meta }, ]) as Highlighter, ), ), @@ -119,9 +161,7 @@ export const Editor: React.FC = ({ useLayoutEffect(() => { view.current?.dispatch({ - effects: languageCompartment.current.reconfigure( - lang ? language.of(lang) : [], - ), + effects: languageCompartment.current.reconfigure(lang || []), }); }, [lang]); @@ -134,14 +174,16 @@ export const Editor: React.FC = ({ }, [readOnly]); useLayoutEffect(() => { + const colorScheme = + theme.name === "dark" ? darkColorScheme : lightColorScheme; view.current?.dispatch({ effects: themeCompartment.current.reconfigure( EditorView.theme( { "&": { - backgroundColor: theme.editor.background, + backgroundColor: colorScheme.background, border: `1px solid ${theme.editor.border}`, - color: theme.editor.text, + color: colorScheme.foreground, height, overflow: "hidden", resize: resizable ? "vertical" : "none", @@ -156,21 +198,21 @@ export const Editor: React.FC = ({ overflow: "auto", }, ".cm-gutters": { - backgroundColor: theme.editor.background, + backgroundColor: "transparent", border: "none", - color: theme.editor.lineNumber, + color: colorScheme.lineNumberForeground, }, ".cm-activeLineGutter": { backgroundColor: "transparent", }, "&.cm-focused .cm-activeLineGutter": { - color: theme.editor.activeLineNumber, + color: colorScheme.activeLineNumberForeground, }, ".cm-lineNumbers .cm-gutterElement": { padding: "0 8px", }, ".cm-content ::selection": { - backgroundColor: theme.editor.selectionBackground, + backgroundColor: colorScheme.selectionBackground, }, }, { dark: theme.name === "dark" }, diff --git a/src/scripts/components/table.tsx b/src/scripts/components/table.tsx index f0dbc86e2..26b3e4911 100644 --- a/src/scripts/components/table.tsx +++ b/src/scripts/components/table.tsx @@ -37,28 +37,27 @@ export const TableHeaderRow = React.forwardRef< }); export type TableHeaderCellProps = JSX.IntrinsicElements["th"] & { - breakAll?: boolean; width?: string; }; export const TableHeaderCell = React.forwardRef< HTMLTableCellElement, TableHeaderCellProps ->(function TableHeaderCell({ breakAll, width = "auto", ...props }, ref) { +>(function TableHeaderCell({ width = "auto", ...props }, ref) { const className = useClassName( (theme) => ({ color: theme.text.secondary, fontWeight: "normal", + overflowWrap: "break-word", padding: "0.75em 0", textAlign: "start", verticalAlign: "middle", width, - wordBreak: breakAll ? "break-all" : "normal", "&:not(:first-child)": { paddingLeft: "0.75em", }, }), - [breakAll, width], + [width], ); return ; }); @@ -80,23 +79,21 @@ export const TableRow = React.forwardRef( }, ); -export type TableCellProps = { - breakAll?: boolean; -} & JSX.IntrinsicElements["td"]; +export type TableCellProps = JSX.IntrinsicElements["td"]; export const TableCell = React.forwardRef( - function TableCell({ breakAll, ...props }, ref) { + function TableCell(props, ref) { const className = useClassName( (theme) => ({ borderTop: `solid 1px ${theme.separator}`, + overflowWrap: "break-word", padding: "0.75em 0", verticalAlign: "middle", - wordBreak: breakAll ? "break-all" : "normal", "&:not(:first-child)": { paddingLeft: "0.75em", }, }), - [breakAll], + [], ); return ; }, diff --git a/src/scripts/components/theme.tsx b/src/scripts/components/theme.tsx index 7a1a157fd..58f086e24 100644 --- a/src/scripts/components/theme.tsx +++ b/src/scripts/components/theme.tsx @@ -36,14 +36,6 @@ export type Theme = { }; editor: { border: string; - background: string; - text: string; - lineNumber: string; - activeLineNumber: string; - selectionBackground: string; - annotation: string; - regexp: string; - comment: string; }; focus: { circle: string; @@ -126,17 +118,8 @@ export const darkTheme: Readonly = { dialog: { background: "rgb(41, 42, 45)", }, - // [hybrid.vim](https://github.com/w0ng/vim-hybrid) editor: { border: "rgb(95, 99, 104)", - background: "rgb(29, 31, 33)", - text: "rgb(197, 200, 198)", - lineNumber: "rgb(55, 59, 65)", - activeLineNumber: "rgb(240, 198, 116)", - selectionBackground: "rgb(55, 59, 65)", - annotation: "rgb(129, 162, 190)", - regexp: "rgb(181, 189, 104)", - comment: "rgb(112, 120, 128)", }, focus: { shadow: "rgba(138, 180, 248, 0.5)", @@ -218,17 +201,8 @@ export const lightTheme: Readonly = { dialog: { background: "white", }, - // [hybrid.vim](https://github.com/w0ng/vim-hybrid) editor: { border: "rgb(218, 220, 224)", - background: "rgb(255, 255, 255)", - text: "rgb(0, 0, 0)", - lineNumber: "rgb(210, 210, 210)", - activeLineNumber: "rgb(106, 106, 0)", - selectionBackground: "rgb(210, 210, 210)", - annotation: "rgb(0, 0, 106)", - regexp: "rgb(0, 106, 0)", - comment: "rgb(106, 106, 106)", }, focus: { shadow: "rgba(26, 115, 232, 0.4)", diff --git a/src/scripts/content-script.tsx b/src/scripts/content-script.tsx index 020c2aabe..5dfedb5aa 100644 --- a/src/scripts/content-script.tsx +++ b/src/scripts/content-script.tsx @@ -1,22 +1,24 @@ import { useLayoutEffect, useMemo } from "react"; import { type Root, createRoot } from "react-dom/client"; +import { MatchPatternMap } from "../common/match-pattern.ts"; import { BlockDialog } from "./block-dialog.tsx"; import { browser } from "./browser.ts"; import { InteractiveRuleset } from "./interactive-ruleset.ts"; import { loadFromLocalStorage, saveToLocalStorage } from "./local-storage.ts"; import { translate } from "./locales.ts"; import { sendMessage } from "./messages.ts"; -import { Ruleset } from "./ruleset.ts"; +import type { LinkProps } from "./ruleset/ruleset.ts"; import { SEARCH_ENGINES } from "./search-engines.ts"; import { css, glob } from "./styles.ts"; import type { DialogTheme, + SearchEngine, SerpControl, SerpEntry, SerpHandler, SerpHandlerResult, } from "./types.ts"; -import { AltURL, MatchPattern } from "./utilities.ts"; +import { fromPlainRuleset } from "./utilities.ts"; const Button: React.FC<{ children: React.ReactNode; onClick: () => void }> = ({ children, @@ -168,8 +170,8 @@ class ContentScript { onSerpStart(): void { void (async () => { const options = await loadFromLocalStorage([ + "ruleset", "blacklist", - "compiledRules", "subscriptions", "skipBlockDialog", "hideControl", @@ -184,16 +186,11 @@ class ContentScript { this.options = { ruleset: new InteractiveRuleset( - options.blacklist, - options.compiledRules !== false - ? options.compiledRules - : Ruleset.compile(options.blacklist), + fromPlainRuleset(options.ruleset || null, options.blacklist), Object.values(options.subscriptions) .filter((subscription) => subscription.enabled ?? true) - .map( - (subscription) => - subscription.compiledRules ?? - Ruleset.compile(subscription.blacklist), + .map(({ ruleset, blacklist }) => + fromPlainRuleset(ruleset || null, blacklist), ), ), skipBlockDialog: options.skipBlockDialog, @@ -266,8 +263,8 @@ class ContentScript { if (!this.options) { return; } - entry.state = this.options.ruleset.test(entry.props); - if (entry.state === 0) { + entry.state = this.options.ruleset.query(entry.props); + if (entry.state?.type === "block") { const scopeState = this.scopeStates[entry.scope] ?? { blockedEntryCount: 0, showBlockedEntries: false, @@ -327,13 +324,13 @@ class ContentScript { renderEntry(entry: SerpEntry): void { delete entry.root.dataset.ubBlocked; delete entry.root.dataset.ubHighlight; - if (entry.state === 0) { + if (entry.state?.type === "block") { entry.root.dataset.ubBlocked = this.scopeStates[entry.scope] ?.showBlockedEntries ? "visible" : "hidden"; - } else if (entry.state >= 2) { - entry.root.dataset.ubHighlight = String(entry.state - 1); + } else if (entry.state?.type === "highlight") { + entry.root.dataset.ubHighlight = String(entry.state.colorNumber); } entry.actionRoot.classList.toggle( "ub-hidden", @@ -342,7 +339,7 @@ class ContentScript { entry.actionRoot.lang = browser.i18n.getUILanguage(); this.render( { if (!this.options || !this.blockDialogRoot) { return; @@ -359,10 +356,7 @@ class ContentScript { ); this.rejudgeAllEntries(); } else { - this.renderBlockDialog( - entry.props.url.toString(), - entry.props.title, - ); + this.renderBlockDialog(entry.props); } }} {...(entry.onActionRender ? { onRender: entry.onActionRender } : {})} @@ -371,22 +365,21 @@ class ContentScript { ); } - renderBlockDialog(url: string, title: string | null, open = true) { + renderBlockDialog(entryProps: LinkProps, open = true) { if (!this.options || !this.blockDialogRoot) { return; } this.render( this.renderBlockDialog(url, title, false)} + close={() => this.renderBlockDialog(entryProps, false)} enablePathDepth={this.options.enablePathDepth} + entryProps={entryProps} open={open} openOptionsPage={() => sendMessage("open-options-page")} ruleset={this.options.ruleset} target={this.blockDialogRoot} theme={this.options.dialogTheme ?? this.serpHandler.getDialogTheme()} - title={title} - url={url} onBlocked={() => { if (!this.options) { return; @@ -418,14 +411,15 @@ function main() { } document.documentElement.dataset.ubActive = "1"; - const url = new AltURL(window.location.href); - const serpHandler = Object.values(SEARCH_ENGINES) - .find(({ contentScripts }) => - contentScripts - .flatMap(({ matches }) => matches) - .some((match) => new MatchPattern(match).test(url)), - ) - ?.getSerpHandler(); + const map = new MatchPatternMap(); + for (const searchEngine of Object.values(SEARCH_ENGINES)) { + for (const { matches } of searchEngine.contentScripts) { + for (const match of matches) { + map.set(match, searchEngine); + } + } + } + const serpHandler = map.get(window.location.href)[0]?.getSerpHandler(); if (serpHandler) { if (serpHandler.delay) { window.setTimeout( diff --git a/src/scripts/interactive-ruleset.test.ts b/src/scripts/interactive-ruleset.test.ts index 0b8a281a3..ed4dc5fb3 100644 --- a/src/scripts/interactive-ruleset.test.ts +++ b/src/scripts/interactive-ruleset.test.ts @@ -1,268 +1,336 @@ import assert from "node:assert"; -import { describe, test } from "node:test"; +import { test } from "node:test"; import { InteractiveRuleset } from "./interactive-ruleset.ts"; -import { Ruleset } from "./ruleset.ts"; -import type { SerpEntryProps } from "./types.ts"; -import { AltURL, r } from "./utilities.ts"; +import { Ruleset } from "./ruleset/ruleset.ts"; -function makeInteractiveRuleset( +function createInteractiveRuleset( user: string, subscriptions: readonly string[] = [], ): InteractiveRuleset { return new InteractiveRuleset( - user, - Ruleset.compile(user), - subscriptions.map((subscription) => Ruleset.compile(subscription)), + new Ruleset(user), + subscriptions.map((subscription) => new Ruleset(subscription)), ); } -function makeProps(url: string, title?: string): SerpEntryProps { - return { - url: new AltURL(url), - title: title ?? null, - }; -} - -describe("psl", () => { - test("patch", () => { - const rs1 = makeInteractiveRuleset(""); - const p11 = rs1.createPatch( - makeProps("https://www.library.city.chuo.tokyo.jp"), - true, - ); - assert.strictEqual(p11.unblock, false); - assert.strictEqual(p11.rulesToAdd, r`*://*.city.chuo.tokyo.jp/*`); - assert.strictEqual(p11.rulesToRemove, ""); - rs1.applyPatch(); - assert.strictEqual(rs1.toString(), r`*://*.city.chuo.tokyo.jp/*`); - - const rs2 = makeInteractiveRuleset("", [r`*://*.example.com/*`]); - const p21 = rs2.createPatch(makeProps("https://www.example.com/"), true); - assert.strictEqual(p21.unblock, true); - assert.strictEqual(p21.rulesToAdd, r`@*://*.example.com/*`); - assert.strictEqual(p21.rulesToRemove, ""); - rs2.applyPatch(); - assert.strictEqual(rs2.toString(), r`@*://*.example.com/*`); - }); -}); - -describe("title", () => { - test("test", () => { - const rs1 = makeInteractiveRuleset( - r`*://example.com/* +test("InteractiveRuleset", async (t) => { + await t.test("Title", () => { + { + const ruleset = createInteractiveRuleset(`*://example.com/* url/example\.(net|org)/ title/Example/ -@title/allowed/i`, - ); - assert.strictEqual(rs1.test(makeProps("http://example.net", "Net")), 0); - assert.strictEqual( - rs1.test(makeProps("https://example.edu", "Example Domain")), - 0, - ); - assert.strictEqual(rs1.test(makeProps("http://example.com", "Allowed")), 1); - - const rs2 = makeInteractiveRuleset( - r`/example\.net/ +@title/allowed/i`); + assert.deepStrictEqual( + ruleset.query({ url: "http://example.net", title: "Net" }), + { + type: "block", + }, + ); + assert.deepStrictEqual( + ruleset.query({ url: "https://example.edu", title: "Example Domain" }), + { type: "block" }, + ); + assert.deepStrictEqual( + ruleset.query({ url: "http://example.com", title: "Allowed" }), + { type: "unblock" }, + ); + } + { + const ruleset = createInteractiveRuleset(`/example\.net/ u/example\.org/ t/Example/ -@t/allowed/i`, - ); - assert.strictEqual(rs2.test(makeProps("http://example.net", "Net")), 0); - assert.strictEqual( - rs2.test(makeProps("https://example.edu", "Example Domain")), - 0, - ); - assert.strictEqual(rs2.test(makeProps("http://example.com", "Allowed")), 1); +@t/allowed/i`); + assert.deepStrictEqual( + ruleset.query({ url: "http://example.net", title: "Net" }), + { + type: "block", + }, + ); + assert.deepStrictEqual( + ruleset.query({ url: "https://example.edu", title: "Example Domain" }), + { type: "block" }, + ); + assert.deepStrictEqual( + ruleset.query({ url: "http://example.com", title: "Allowed" }), + { type: "unblock" }, + ); + } }); -}); -describe("highlight", () => { - test("test", () => { - const rs1 = makeInteractiveRuleset( - r`*://example.com/* + await t.test("Highlight", () => { + { + const ruleset = createInteractiveRuleset(`*://example.com/* @ *://example.net/* @1*://example.org/* @2 *://example.edu/* -@10/example\.com/`, - ); - assert.strictEqual(rs1.test(makeProps("https://example.com/")), 11); - assert.strictEqual(rs1.test(makeProps("https://example.net/")), 1); - assert.strictEqual(rs1.test(makeProps("https://example.org/")), 2); - assert.strictEqual(rs1.test(makeProps("https://example.edu/")), 3); - assert.strictEqual(rs1.test(makeProps("https://example.co.jp")), -1); - - const rs2 = makeInteractiveRuleset(r` @2 https://*.example.com/* `); - assert.strictEqual( - rs2.test(makeProps("https://subdomain.example.com/")), - 3, - ); - assert.strictEqual(rs2.test(makeProps("https://example.net/")), -1); - - const rs3 = makeInteractiveRuleset( - r`*://example.com/* +@10/example\.com/`); + assert.deepStrictEqual(ruleset.query({ url: "https://example.com/" }), { + type: "highlight", + colorNumber: 10, + }); + assert.deepStrictEqual(ruleset.query({ url: "https://example.net/" }), { + type: "unblock", + }); + assert.deepStrictEqual(ruleset.query({ url: "https://example.org/" }), { + type: "highlight", + colorNumber: 1, + }); + assert.deepStrictEqual(ruleset.query({ url: "https://example.edu/" }), { + type: "highlight", + colorNumber: 2, + }); + assert.strictEqual(ruleset.query({ url: "https://example.co.jp" }), null); + } + { + const ruleset = createInteractiveRuleset( + " @2 https://*.example.com/* ", + ); + assert.deepStrictEqual( + ruleset.query({ url: "https://subdomain.example.com/" }), + { + type: "highlight", + colorNumber: 2, + }, + ); + assert.strictEqual(ruleset.query({ url: "https://example.net/" }), null); + } + { + const ruleset = createInteractiveRuleset( + `*://example.com/* @*://example.net/*`, - [ - r`@100 *://*.example.com/* + [ + `@100 *://*.example.com/* *://example.net/*`, - ], - ); - assert.strictEqual(rs3.test(makeProps("https://example.com/")), 0); - assert.strictEqual( - rs3.test(makeProps("https://subdomain.example.com/")), - 101, - ); - assert.strictEqual(rs3.test(makeProps("https://example.net/")), 1); + ], + ); + assert.deepStrictEqual(ruleset.query({ url: "https://example.com/" }), { + type: "block", + }); + assert.deepStrictEqual( + ruleset.query({ url: "https://subdomain.example.com/" }), + { + type: "highlight", + colorNumber: 100, + }, + ); + assert.deepStrictEqual(ruleset.query({ url: "https://example.net/" }), { + type: "unblock", + }); + } }); - test("patch", () => { - const rs1 = makeInteractiveRuleset(r`@1*://example.com/*`); - const p11 = rs1.createPatch(makeProps("https://example.com/"), false); - assert.strictEqual(p11.unblock, false); - assert.strictEqual(p11.rulesToAdd, r`*://example.com/*`); - assert.strictEqual(p11.rulesToRemove, r`@1*://example.com/*`); - const p12 = rs1.modifyPatch({ - rulesToAdd: r`*://example.com/* + await t.test("Patch", () => { + { + const ruleset = createInteractiveRuleset("@1*://example.com/*"); + { + const patch1 = ruleset.createPatch( + { url: "https://example.com/" }, + false, + ); + assert.strictEqual(patch1.unblock, false); + assert.strictEqual(patch1.rulesToAdd, "*://example.com/*"); + assert.strictEqual(patch1.rulesToRemove, "@1*://example.com/*"); + const patch2 = ruleset.modifyPatch({ + rulesToAdd: `*://example.com/* @2/example/`, - }); - assert.strictEqual(p12, null); - rs1.applyPatch(); - assert.strictEqual(rs1.toString(), r`*://example.com/*`); - - const rs2 = makeInteractiveRuleset(r`*://example.com/*`, [ - r`*://example.com/*`, - ]); - const p21 = rs2.createPatch(makeProps("https://example.com"), false); - assert.strictEqual(p21.unblock, true); - assert.strictEqual(p21.rulesToAdd, r`@*://example.com/*`); - assert.strictEqual(p21.rulesToRemove, r`*://example.com/*`); - const p22 = rs2.modifyPatch({ rulesToAdd: r`@42*://*.example.com/*` }); - assert.notStrictEqual(p22, null); - rs2.applyPatch(); - assert.strictEqual(rs2.toString(), r`@42*://*.example.com/*`); + }); + assert.strictEqual(patch2, null); + ruleset.applyPatch(); + } + assert.strictEqual(ruleset.toString(), "*://example.com/*"); + } + { + const ruleset = createInteractiveRuleset("*://example.com/*", [ + "*://example.com/*", + ]); + { + const patch1 = ruleset.createPatch( + { url: "https://example.com" }, + false, + ); + assert.strictEqual(patch1.unblock, true); + assert.strictEqual(patch1.rulesToAdd, "@*://example.com/*"); + assert.strictEqual(patch1.rulesToRemove, "*://example.com/*"); + const patch2 = ruleset.modifyPatch({ + rulesToAdd: "@42*://*.example.com/*", + }); + assert.strictEqual(patch2?.unblock, true); + assert.strictEqual(patch2?.rulesToAdd, "@42*://*.example.com/*"); + assert.strictEqual(patch2?.rulesToRemove, "*://example.com/*"); + ruleset.applyPatch(); + } + assert.strictEqual(ruleset.toString(), "@42*://*.example.com/*"); + } }); -}); -describe("block and unblock", () => { - const RULESET1 = r`*://*.example.com/* + await t.test("Subscriptions", () => { + const user = String.raw`*://*.example.com/* # Block 'example.net' and 'example.org' /example\.(net|org)/ # But unblock 'example.net' @*://example.net/*`; - const RULESET2 = r`ftp://example.org/*`; - const RULESET3 = r`/^https?:\/\/www\.qinterest\./ -@https://example.edu/path/to/*`; - - test("toString", () => { - const rs1 = makeInteractiveRuleset(RULESET1); - assert.strictEqual(rs1.toString(), RULESET1); - - const rs123 = makeInteractiveRuleset(RULESET1, [RULESET2, RULESET3]); - assert.strictEqual(rs123.toString(), RULESET1); - }); - - test("test", () => { - const rs1 = makeInteractiveRuleset(RULESET1); - assert.strictEqual(rs1.test(makeProps("http://example.com/")), 0); - assert.strictEqual(rs1.test(makeProps("https://www.example.com/path")), 0); - assert.strictEqual(rs1.test(makeProps("ftp://example.net/")), 0); - assert.strictEqual(rs1.test(makeProps("http://example.edu/")), -1); - - const rs123 = makeInteractiveRuleset(RULESET1, [RULESET2, RULESET3]); - assert.strictEqual(rs123.test(makeProps("http://example.com/")), 0); - assert.strictEqual(rs123.test(makeProps("http://example.net/")), 1); - assert.strictEqual(rs123.test(makeProps("https://example.edu/")), -1); - assert.strictEqual( - rs123.test(makeProps("https://example.edu/path/to/example")), - 1, - ); - assert.strictEqual(rs123.test(makeProps("https://www.qinterest.com/")), 0); - }); - - test("patch", () => { - const rs1 = makeInteractiveRuleset(RULESET1); - const p1a = rs1.createPatch(makeProps("https://www.example.edu/"), false); - assert.strictEqual(p1a.unblock, false); - assert.strictEqual(p1a.props.url.toString(), "https://www.example.edu/"); - assert.strictEqual(p1a.rulesToAdd, "*://www.example.edu/*"); - assert.strictEqual(p1a.rulesToRemove, ""); - const p1b = rs1.modifyPatch({ rulesToAdd: "*://example.edu/*" }); - assert.strictEqual(p1b, null); - const p1c = rs1.modifyPatch({ rulesToAdd: "https://*.example.edu/" }); - assert.strictEqual(p1c?.rulesToAdd, "https://*.example.edu/"); - rs1.applyPatch(); - assert.strictEqual( - rs1.toString(), - r`${RULESET1} + const subscriptions = [ + "ftp://example.org/*", + String.raw`/^https?:\/\/www\.qinterest\./ +@https://example.edu/path/to/*`, + ]; + { + const ruleset = createInteractiveRuleset(user); + assert.strictEqual(ruleset.toString(), user); + } + { + const ruleset = createInteractiveRuleset(user, subscriptions); + assert.strictEqual(ruleset.toString(), user); + } + { + const ruleset = createInteractiveRuleset(user); + assert.deepStrictEqual(ruleset.query({ url: "http://example.com/" }), { + type: "block", + }); + assert.deepStrictEqual( + ruleset.query({ url: "https://www.example.com/path" }), + { + type: "block", + }, + ); + assert.strictEqual(ruleset.query({ url: "ftp://example.net/" }), null); + assert.strictEqual(ruleset.query({ url: "http://example.edu/" }), null); + } + { + const ruleset = createInteractiveRuleset(user, subscriptions); + assert.deepStrictEqual(ruleset.query({ url: "http://example.com/" }), { + type: "block", + }); + assert.deepStrictEqual(ruleset.query({ url: "http://example.net/" }), { + type: "unblock", + }); + assert.strictEqual(ruleset.query({ url: "https://example.edu/" }), null); + assert.deepStrictEqual( + ruleset.query({ url: "https://example.edu/path/to/example" }), + { type: "unblock" }, + ); + assert.deepStrictEqual( + ruleset.query({ url: "https://www.qinterest.com/" }), + { + type: "block", + }, + ); + } + { + const ruleset = createInteractiveRuleset(user); + { + const patch1 = ruleset.createPatch( + { url: "https://www.example.edu/" }, + false, + ); + assert.strictEqual(patch1.unblock, false); + assert.strictEqual(patch1.props.url, "https://www.example.edu/"); + assert.strictEqual(patch1.rulesToAdd, "*://www.example.edu/*"); + assert.strictEqual(patch1.rulesToRemove, ""); + const patch2 = ruleset.modifyPatch({ rulesToAdd: "*://example.edu/*" }); + assert.strictEqual(patch2, null); + const patch3 = ruleset.modifyPatch({ + rulesToAdd: "https://*.example.edu/", + }); + assert.strictEqual(patch3?.rulesToAdd, "https://*.example.edu/"); + ruleset.applyPatch(); + } + assert.strictEqual( + ruleset.toString(), + `${user} https://*.example.edu/`, - ); - - const rs123 = makeInteractiveRuleset(RULESET1, [RULESET2, RULESET3]); - const p123a = rs123.createPatch( - makeProps("http://www.example.com/path"), - false, - ); - assert.strictEqual(p123a.unblock, true); - assert.strictEqual( - p123a.props.url.toString(), - "http://www.example.com/path", - ); - assert.strictEqual(p123a.rulesToAdd, ""); - assert.strictEqual(p123a.rulesToRemove, "*://*.example.com/*"); - rs123.applyPatch(); - assert.strictEqual( - rs123.toString(), - r`# Block 'example.net' and 'example.org' + ); + } + { + const ruleset = createInteractiveRuleset(user, subscriptions); + { + const patch = ruleset.createPatch( + { url: "http://www.example.com/path" }, + false, + ); + assert.strictEqual(patch.unblock, true); + assert.strictEqual(patch.props.url, "http://www.example.com/path"); + assert.strictEqual(patch.rulesToAdd, ""); + assert.strictEqual(patch.rulesToRemove, "*://*.example.com/*"); + ruleset.applyPatch(); + } + assert.strictEqual( + ruleset.toString(), + String.raw`# Block 'example.net' and 'example.org' /example\.(net|org)/ # But unblock 'example.net' @*://example.net/*`, - ); - - const p123b = rs123.createPatch(makeProps("https://example.net/"), false); - assert.strictEqual(p123b.unblock, false); - assert.strictEqual(p123b.props.url.toString(), "https://example.net/"); - assert.strictEqual(p123b.rulesToAdd, ""); - assert.strictEqual(p123b.rulesToRemove, "@*://example.net/*"); - const p123c = rs123.modifyPatch({ rulesToAdd: "@/net/" }); - assert.strictEqual(p123c, null); - const p123d = rs123.modifyPatch({ rulesToAdd: "Only comment" }); - assert.strictEqual(p123d?.rulesToAdd, "Only comment"); - rs123.deletePatch(); - assert.throws(() => { - rs123.applyPatch(); - }); - assert.strictEqual( - rs123.toString(), - r`# Block 'example.net' and 'example.org' + ); + { + const patch1 = ruleset.createPatch( + { url: "https://example.net/" }, + false, + ); + assert.strictEqual(patch1.unblock, false); + assert.strictEqual(patch1.props.url, "https://example.net/"); + assert.strictEqual(patch1.rulesToAdd, ""); + assert.strictEqual(patch1.rulesToRemove, "@*://example.net/*"); + const patch2 = ruleset.modifyPatch({ rulesToAdd: "@/net/" }); + assert.strictEqual(patch2, null); + const patch3 = ruleset.modifyPatch({ rulesToAdd: "Only comment" }); + assert.strictEqual(patch3?.rulesToAdd, "Only comment"); + ruleset.deletePatch(); + assert.throws(() => { + ruleset.applyPatch(); + }); + } + assert.strictEqual( + ruleset.toString(), + String.raw`# Block 'example.net' and 'example.org' /example\.(net|org)/ # But unblock 'example.net' @*://example.net/*`, - ); - - const p123e = rs123.createPatch( - makeProps("ftp://example.org/dir/file"), - false, - ); - assert.strictEqual(p123e.unblock, true); - assert.strictEqual( - p123e.props.url.toString(), - "ftp://example.org/dir/file", - ); - assert.strictEqual(p123e.rulesToAdd, "@ftp://example.org/*"); - assert.strictEqual(p123e.rulesToRemove, r`/example\.(net|org)/`); - rs123.applyPatch(); - assert.strictEqual( - rs123.toString(), - r`# Block 'example.net' and 'example.org' -# But unblock 'example.net' -@*://example.net/* -@ftp://example.org/*`, - ); - - rs123.createPatch(makeProps("http://www.example.edu/foo/bar"), false); - const p123f = rs123.modifyPatch({ - rulesToAdd: r`*://www.example.edu/* + ); + { + const patch1 = ruleset.createPatch( + { url: "http://www.example.edu/foo/bar" }, + false, + ); + assert.strictEqual(patch1.unblock, false); + assert.strictEqual(patch1.rulesToAdd, "*://www.example.edu/*"); + assert.strictEqual(patch1.rulesToRemove, ""); + const patch2 = ruleset.modifyPatch({ + rulesToAdd: `*://www.example.edu/* @/edu/`, - }); - assert.strictEqual(p123f, null); + }); + assert.strictEqual(patch2, null); + } + } + }); + + await t.test("PSL", () => { + { + const ruleset = createInteractiveRuleset(""); + { + const patch = ruleset.createPatch( + { url: "https://www.library.city.chuo.tokyo.jp" }, + true, + ); + assert.strictEqual(patch.unblock, false); + assert.strictEqual(patch.rulesToAdd, "*://*.city.chuo.tokyo.jp/*"); + assert.strictEqual(patch.rulesToRemove, ""); + ruleset.applyPatch(); + } + assert.strictEqual(ruleset.toString(), "*://*.city.chuo.tokyo.jp/*"); + } + { + const ruleset = createInteractiveRuleset("", ["*://*.example.com/*"]); + { + const patch = ruleset.createPatch( + { url: "https://www.example.com/" }, + true, + ); + assert.strictEqual(patch.unblock, true); + assert.strictEqual(patch.rulesToAdd, "@*://*.example.com/*"); + assert.strictEqual(patch.rulesToRemove, ""); + ruleset.applyPatch(); + } + assert.strictEqual(ruleset.toString(), "@*://*.example.com/*"); + } }); }); diff --git a/src/scripts/interactive-ruleset.ts b/src/scripts/interactive-ruleset.ts index 6973bfa9e..00deece8e 100644 --- a/src/scripts/interactive-ruleset.ts +++ b/src/scripts/interactive-ruleset.ts @@ -1,232 +1,241 @@ import * as tldts from "tldts"; -import { Ruleset } from "./ruleset.ts"; -import type { SerpEntryProps } from "./types.ts"; -import { type AltURL, lines, unlines } from "./utilities.ts"; +import { + type LinkProps, + Ruleset, + type TestRawResult, +} from "./ruleset/ruleset.ts"; -export type InteractiveRulesetPatch = { - unblock: boolean; - props: SerpEntryProps; - rulesToAdd: string; - rulesToRemove: string; -}; +export type QueryResult = + | { type: "block" } + | { type: "unblock" } + | { type: "highlight"; colorNumber: number }; -type PatchInternal = InteractiveRulesetPatch & { - requireRulesToAdd: boolean; - ruleRemovers: (() => void)[]; -}; +function isBlock(result: QueryResult | null): boolean { + return result?.type === "block"; +} -function unlinesNullable(lines: readonly (string | null)[]): string { - return unlines(lines.filter((line): line is string => line != null)); +function isUnblockOrHighlight(result: QueryResult | null): boolean { + return result?.type === "unblock" || result?.type === "highlight"; } -function suggestMatchPattern( - url: AltURL, - unblock: boolean, - blockWholeSite: boolean, -): string { - const at = unblock ? "@" : ""; - const scheme = - url.scheme === "http" || url.scheme === "https" ? "*" : url.scheme; - let host: string; - if (blockWholeSite) { - const domain = tldts.getDomain(url.host); - host = domain != null ? `*.${domain}` : url.host; - } else { - host = url.host; +function query(ruleset: Ruleset, props: LinkProps): QueryResult | null { + return toQueryResult(testRawWithURLParts(ruleset, props)); +} + +function toQueryResult(testRawResult: TestRawResult): QueryResult | null { + let result: QueryResult | null = null; + for (const { specifier } of testRawResult) { + if (!specifier) { + if (!result) { + result = { type: "block" }; + } + } else if (specifier.type === "negate") { + if (!result || result.type === "block") { + result = { type: "unblock" }; + } + } else if ( + !result || + result.type === "block" || + result.type === "unblock" || + result.colorNumber < specifier.colorNumber + ) { + result = { type: "highlight", colorNumber: specifier.colorNumber }; + } } - const path = "/*"; - return `${at}${scheme}://${host}${path}`; + return result; } +function testRawWithURLParts( + ruleset: Ruleset, + props: LinkProps, +): TestRawResult { + const { protocol, hostname, pathname, search } = new URL(props.url); + return ruleset.testRaw({ + scheme: protocol.slice(0, -1), + host: hostname, + path: `${pathname}${search}`, + ...props, + }); +} + +export type Patch = { + props: LinkProps; + unblock: boolean; + rulesToAdd: string; + rulesToRemove: string; + requireRulesToAdd: boolean; + lineNumbersToRemove: number[]; +}; + export class InteractiveRuleset { - private readonly userRules: (string | null)[]; private readonly userRuleset: Ruleset; private readonly subscriptionRulesets: readonly Ruleset[]; - private patch: PatchInternal | null = null; - - constructor( - userRules: string, - userCompiledRules: string, - subscriptionCompiledRules: readonly string[], - ) { - this.userRules = lines(userRules); - this.userRuleset = new Ruleset(userCompiledRules); - this.subscriptionRulesets = subscriptionCompiledRules.map( - (compiled) => new Ruleset(compiled), - ); + private patch: Patch | null = null; + + constructor(userRuleset: Ruleset, subscriptionRulesets: readonly Ruleset[]) { + this.userRuleset = userRuleset; + this.subscriptionRulesets = subscriptionRulesets; } toString(): string { - return unlinesNullable(this.userRules); + return this.userRuleset.toString(); } - // 0: block - // 1: unblock - // 2: highlight-1 - // 3: highlight-2 - // ... - // -1: none of the above - test(props: Readonly): number { - const userResults = this.userRuleset.test(props); - if (userResults >= 0) { - return userResults; + query(props: Readonly): QueryResult | null { + const userResult = query(this.userRuleset, props); + if (userResult) { + return userResult; } - return Math.max( - -1, - ...this.subscriptionRulesets.map((ruleset) => ruleset.test(props)), + return toQueryResult( + this.subscriptionRulesets.flatMap((ruleset) => + testRawWithURLParts(ruleset, props), + ), ); } // Create a patch to block an unblocked URL or unblock a blocked URL. // If a patch is already created, it will be deleted. - createPatch( - props: SerpEntryProps, - blockWholeSite: boolean, - ): InteractiveRulesetPatch { - const patch = { props } as PatchInternal; - const userResults = this.userRuleset.exec(props); - if (userResults.some(([, value]) => value >= 1)) { + createPatch(props: LinkProps, blockWholeSite: boolean): Patch { + let unblock: boolean; + let rulesToAdd: string; + let rulesToRemove: string; + let requireRulesToAdd: boolean; + let lineNumbersToRemove: number[]; + const userResults = testRawWithURLParts(this.userRuleset, props); + if (userResults.some(({ specifier }) => specifier)) { // The URL is unblocked by a user rule. Block it. - patch.unblock = false; - if (userResults.some(([, value]) => value === 0)) { + unblock = false; + if (userResults.some(({ specifier }) => !specifier)) { // No need to add a user rule to block it. - patch.requireRulesToAdd = false; - patch.rulesToAdd = ""; + requireRulesToAdd = false; + rulesToAdd = ""; } else if ( - this.subscriptionRulesets.some((ruleset) => ruleset.test(props) >= 1) + this.subscriptionRulesets.some((ruleset) => + isUnblockOrHighlight(query(ruleset, props)), + ) ) { // Add a user rule to block it. - patch.requireRulesToAdd = true; - patch.rulesToAdd = suggestMatchPattern( - props.url, - false, - blockWholeSite, - ); + requireRulesToAdd = true; + rulesToAdd = suggestMatchPattern(props.url, false, blockWholeSite); } else if ( - this.subscriptionRulesets.some((ruleset) => ruleset.test(props) === 0) + this.subscriptionRulesets.some((ruleset) => + isBlock(query(ruleset, props)), + ) ) { // No need to add a user rule to block it. - patch.requireRulesToAdd = false; - patch.rulesToAdd = ""; + requireRulesToAdd = false; + rulesToAdd = ""; } else { // Add a user rule to block it. - patch.requireRulesToAdd = true; - patch.rulesToAdd = suggestMatchPattern( - props.url, - false, - blockWholeSite, - ); + requireRulesToAdd = true; + rulesToAdd = suggestMatchPattern(props.url, false, blockWholeSite); } - patch.rulesToRemove = unlinesNullable( - userResults.flatMap(([index, value]) => - value >= 1 ? [this.userRules[index]] : [], - ), + // Remove user rules unblocking it. + rulesToRemove = userResults + .flatMap(({ lineNumber, specifier }) => + specifier ? [this.userRuleset.get(lineNumber)] : [], + ) + .join("\n"); + lineNumbersToRemove = userResults.flatMap(({ lineNumber, specifier }) => + specifier ? [lineNumber] : [], ); - patch.ruleRemovers = userResults.flatMap(([index, value, remove]) => - value >= 1 - ? [ - () => { - this.userRules[index] = null; - }, - remove, - ] - : [], - ); - } else if (userResults.some(([, value]) => value === 0)) { + } else if (userResults.some(({ specifier }) => !specifier)) { // The URL is blocked by a user rule. Unblock it. - patch.unblock = true; + unblock = true; if ( - this.subscriptionRulesets.some((ruleset) => ruleset.test(props) >= 1) + this.subscriptionRulesets.some((ruleset) => + isUnblockOrHighlight(query(ruleset, props)), + ) ) { // No need to add a user rule to unblock it. - patch.requireRulesToAdd = false; - patch.rulesToAdd = ""; + requireRulesToAdd = false; + rulesToAdd = ""; } else if ( - this.subscriptionRulesets.some((ruleset) => ruleset.test(props) === 0) + this.subscriptionRulesets.some((ruleset) => + isBlock(query(ruleset, props)), + ) ) { // Add a user rule to unblock it. - patch.requireRulesToAdd = true; - patch.rulesToAdd = suggestMatchPattern(props.url, true, blockWholeSite); + requireRulesToAdd = true; + rulesToAdd = suggestMatchPattern(props.url, true, blockWholeSite); } else { // No need to add a user rule to unblock it. - patch.requireRulesToAdd = false; - patch.rulesToAdd = ""; + requireRulesToAdd = false; + rulesToAdd = ""; } - patch.rulesToRemove = unlinesNullable( - userResults.flatMap(([index, value]) => - value === 0 ? [this.userRules[index]] : [], - ), - ); - patch.ruleRemovers = userResults.flatMap(([index, value, remove]) => - value === 0 - ? [ - () => { - this.userRules[index] = null; - }, - remove, - ] - : [], + // Remove user rules blocking it. + rulesToRemove = userResults + .flatMap(({ lineNumber, specifier }) => + !specifier ? [this.userRuleset.get(lineNumber)] : [], + ) + .join("\n"); + lineNumbersToRemove = userResults.flatMap(({ lineNumber, specifier }) => + !specifier ? [lineNumber] : [], ); } else if ( - this.subscriptionRulesets.some((ruleset) => ruleset.test(props) >= 1) + this.subscriptionRulesets.some((ruleset) => + isUnblockOrHighlight(query(ruleset, props)), + ) ) { // The URL is unblocked by a subscription rule. Block it. // Add a user rule to block it. - patch.unblock = false; - patch.requireRulesToAdd = true; - patch.rulesToAdd = suggestMatchPattern(props.url, false, blockWholeSite); - patch.rulesToRemove = ""; - patch.ruleRemovers = []; + unblock = false; + requireRulesToAdd = true; + rulesToAdd = suggestMatchPattern(props.url, false, blockWholeSite); + rulesToRemove = ""; + lineNumbersToRemove = []; } else if ( - this.subscriptionRulesets.some((ruleset) => ruleset.test(props) === 0) + this.subscriptionRulesets.some((ruleset) => + isBlock(query(ruleset, props)), + ) ) { // The URL is blocked by a subscription rule. Unblock it. // Add a user rule to unblock it. - patch.unblock = true; - patch.requireRulesToAdd = true; - patch.rulesToAdd = suggestMatchPattern(props.url, true, blockWholeSite); - patch.rulesToRemove = ""; - patch.ruleRemovers = []; + unblock = true; + requireRulesToAdd = true; + rulesToAdd = suggestMatchPattern(props.url, true, blockWholeSite); + rulesToRemove = ""; + lineNumbersToRemove = []; } else { // The URL is neither blocked nor unblocked. Block it. // Add a user rule to block it. - patch.unblock = false; - patch.requireRulesToAdd = true; - patch.rulesToAdd = suggestMatchPattern(props.url, false, blockWholeSite); - patch.rulesToRemove = ""; - patch.ruleRemovers = []; + unblock = false; + requireRulesToAdd = true; + rulesToAdd = suggestMatchPattern(props.url, false, blockWholeSite); + rulesToRemove = ""; + lineNumbersToRemove = []; } - this.patch = patch; - return patch; + this.patch = { + props, + unblock, + rulesToAdd, + rulesToRemove, + requireRulesToAdd, + lineNumbersToRemove, + }; + return this.patch; } // Modify a created patch. // Only 'rulesToAdd' can be modified. - modifyPatch( - patch: Readonly>, - ): InteractiveRulesetPatch | null { + modifyPatch({ rulesToAdd }: { readonly rulesToAdd: string }): Patch | null { if (!this.patch) { throw new Error("Patch not created"); } - const rulesetToAdd = new Ruleset(Ruleset.compile(patch.rulesToAdd)); - let rulesAddable!: boolean; - if (this.patch.unblock) { - if (this.patch.requireRulesToAdd) { - rulesAddable = rulesetToAdd.test(this.patch.props) >= 1; - } else { - rulesAddable = rulesetToAdd.test(this.patch.props) !== 0; - } - } else { - if (this.patch.requireRulesToAdd) { - rulesAddable = rulesetToAdd.test(this.patch.props) === 0; - } else { - rulesAddable = rulesetToAdd.test(this.patch.props) < 1; - } - } + const rulesetToAdd = new Ruleset(""); + rulesetToAdd.extend(rulesToAdd); + const resultToAdd = query(rulesetToAdd, this.patch.props); + const rulesAddable = this.patch.unblock + ? this.patch.requireRulesToAdd + ? isUnblockOrHighlight(resultToAdd) + : !isBlock(resultToAdd) + : this.patch.requireRulesToAdd + ? isBlock(resultToAdd) + : !isUnblockOrHighlight(resultToAdd); if (!rulesAddable) { return null; } - this.patch.rulesToAdd = patch.rulesToAdd; + this.patch.rulesToAdd = rulesToAdd; return this.patch; } @@ -234,14 +243,10 @@ export class InteractiveRuleset { if (!this.patch) { throw new Error("Patch not created"); } - - for (const removeRule of this.patch.ruleRemovers) { - removeRule(); + this.userRuleset.extend(this.patch.rulesToAdd); + for (const lineNumber of this.patch.lineNumbersToRemove) { + this.userRuleset.delete(lineNumber); } - - this.userRules.push(...lines(this.patch.rulesToAdd)); - this.userRuleset.add(this.patch.rulesToAdd); - this.patch = null; } @@ -249,3 +254,18 @@ export class InteractiveRuleset { this.patch = null; } } + +function suggestMatchPattern( + url: string, + unblock: boolean, + blockWholeSite: boolean, +): string { + let host = new URL(url).hostname; + if (blockWholeSite) { + const domain = tldts.getDomain(host); + if (domain != null) { + host = `*.${domain}`; + } + } + return `${unblock ? "@" : ""}*://${host}/*`; +} diff --git a/src/scripts/local-storage.ts b/src/scripts/local-storage.ts index e54e80f76..c521613a2 100644 --- a/src/scripts/local-storage.ts +++ b/src/scripts/local-storage.ts @@ -8,6 +8,7 @@ import type { } from "./types.ts"; export const defaultLocalStorageItems: Readonly = { + ruleset: false, blacklist: "", compiledRules: false, skipBlockDialog: false, diff --git a/src/scripts/options/ruleset-editor.tsx b/src/scripts/options/ruleset-editor.tsx index 9965ea7a1..c750ef561 100644 --- a/src/scripts/options/ruleset-editor.tsx +++ b/src/scripts/options/ruleset-editor.tsx @@ -1,59 +1,8 @@ -import { StreamLanguage } from "@codemirror/language"; import { Editor, type EditorProps } from "../components/editor.tsx"; -import { RE_LINE } from "../ruleset.ts"; - -const rulesetLanguage = StreamLanguage.define<{ - tokens: (readonly [number, string | null])[]; -}>({ - token(stream, state) { - if (stream.sol()) { - const groups = RE_LINE.exec(stream.string)?.groups; - if (!groups) { - stream.skipToEnd(); - return "invalid"; - } - state.tokens = []; - if (groups.spaceBeforeRuleOrComment) { - state.tokens.push([groups.spaceBeforeRuleOrComment.length, null]); - } - if (groups.highlight) { - state.tokens.push([groups.highlight.length, "annotation"]); - } - if (groups.spaceAfterHighlight) { - state.tokens.push([groups.spaceAfterHighlight.length, null]); - } - if (groups.matchPattern) { - state.tokens.push([groups.matchPattern.length, null]); - } - if (groups.regularExpression) { - state.tokens.push([groups.regularExpression.length, "regexp"]); - } - if (groups.spaceAfterRule) { - state.tokens.push([groups.spaceAfterRule.length, null]); - } - if (groups.comment) { - state.tokens.push([groups.comment.length, "lineComment"]); - } - } - const token = state.tokens.shift(); - if (!token) { - // Something went wrong... - stream.skipToEnd(); - return "invalid"; - } - stream.pos += token[0]; - return token[1]; - }, - startState() { - return { tokens: [] }; - }, - copyState(state) { - return { tokens: [...state.tokens] }; - }, -}); +import { ruleset } from "../ruleset/lang.ts"; export type RulesetEditorProps = Omit; export const RulesetEditor: React.FC = (props) => ( - + ); diff --git a/src/scripts/options/subscription-section.tsx b/src/scripts/options/subscription-section.tsx index 7937c844b..df519f8de 100644 --- a/src/scripts/options/subscription-section.tsx +++ b/src/scripts/options/subscription-section.tsx @@ -1,5 +1,6 @@ import dayjs from "dayjs"; import { useEffect, useState } from "react"; +import { MatchPatternMap } from "../../common/match-pattern.ts"; import { browser } from "../browser.ts"; import { Button } from "../components/button.tsx"; import { CheckBox } from "../components/checkbox.tsx"; @@ -45,7 +46,6 @@ import { addMessageListeners, sendMessage } from "../messages.ts"; import type { Subscription, SubscriptionId, Subscriptions } from "../types.ts"; import { AltURL, - MatchPattern, isErrorResult, numberEntries, numberKeys, @@ -55,6 +55,13 @@ import { useOptionsContext } from "./options-context.tsx"; import { RulesetEditor } from "./ruleset-editor.tsx"; import { SetIntervalItem } from "./set-interval-item.tsx"; +function getName(subscription: Readonly): string { + const name = subscription.ruleset?.metadata.name; + return typeof name === "string" + ? name + : subscription.name || subscription.url; +} + const PERMISSION_PASSLIST = [ "*://*.githubusercontent.com/*", // A third-party CDN service supporting GitHub, GitLab and BitBucket @@ -63,12 +70,15 @@ const PERMISSION_PASSLIST = [ async function requestPermission(urls: readonly string[]): Promise { const origins: string[] = []; - const passlist = PERMISSION_PASSLIST.map((pass) => new MatchPattern(pass)); + const map = new MatchPatternMap(); + for (const pass of PERMISSION_PASSLIST) { + map.set(pass, null); + } for (const url of urls) { - const u = new AltURL(url); - if (passlist.some((pass) => pass.test(u))) { + if (map.get(url).length) { continue; } + const u = new AltURL(url); origins.push(`${u.scheme}://${u.host}/*`); } // Don't call `permissions.request` when unnecessary. re #110 @@ -83,9 +93,6 @@ const AddSubscriptionDialog: React.FC< } & DialogProps > = ({ close, open, initialName, initialURL, setSubscriptions }) => { const [state, setState] = useState(() => ({ - name: initialName, - // required - nameValid: initialName !== "", url: initialURL, urlValid: (() => { // pattern="https?:.*" @@ -101,15 +108,15 @@ const AddSubscriptionDialog: React.FC< } return true; })(), + name: initialName, })); const prevOpen = usePrevious(open); if (open && prevOpen === false) { - state.name = ""; - state.nameValid = false; state.url = ""; state.urlValid = false; + state.name = ""; } - const ok = state.nameValid && state.urlValid; + const ok = state.urlValid; return ( - - {translate("options_addSubscriptionDialog_nameLabel")} + + {translate("options_addSubscriptionDialog_urlLabel")} - {open && ( - { - const { - value: name, - validity: { valid: nameValid }, - } = e.currentTarget; - setState((s) => ({ ...s, name, nameValid })); - }} - /> - )} + { + const { + value: url, + validity: { valid: urlValid }, + } = e.currentTarget; + setState((s) => ({ ...s, url, urlValid })); + }} + /> - - {translate("options_addSubscriptionDialog_urlLabel")} + + {translate("options_addSubscriptionDialog_altNameLabel")} + + {translate("options_addSubscriptionDialog_altNameDescription")} + - {open && ( - { - const { - value: url, - validity: { valid: urlValid }, - } = e.currentTarget; - setState((s) => ({ ...s, url, urlValid })); - }} - /> - )} + + setState((s) => { + const name = e.currentTarget.value; + return { ...s, name }; + }) + } + /> @@ -230,7 +235,7 @@ const ShowSubscriptionDialog: React.FC< > - {subscription?.name ?? ""} + {subscription ? getName(subscription) : ""} @@ -286,12 +291,14 @@ const ManageSubscription: React.FC<{ subscription, updating, }) => { + const checkboxId = `enableSubscription${id}`; return ( { const enabled = e.currentTarget.checked; await sendMessage("enable-subscription", id, enabled); @@ -305,7 +312,11 @@ const ManageSubscription: React.FC<{ }} /> - {subscription.name} + + + {getName(subscription)} + + {updating ? ( translate("options_subscriptionUpdateRunning") @@ -441,9 +452,7 @@ export const ManageSubscriptions: React.FC<{ {numberEntries(subscriptions) - .sort(([id1, { name: name1 }], [id2, { name: name2 }]) => - name1 < name2 ? -1 : name1 > name2 ? 1 : id1 - id2, - ) + .sort(([id1], [id2]) => id1 - id2) .map(([id, subscription]) => ( { await sendMessage("open-options-page"); @@ -152,14 +152,20 @@ const Popup: React.FC = () => { if (tabId == null || url == null) { return; } - const altURL = makeAltURL(url); - const match = - altURL && - Object.values(SEARCH_ENGINES) - .flatMap(({ contentScripts }) => - contentScripts.flatMap(({ matches }) => matches), - ) - .find((match) => new MatchPattern(match).test(altURL)); + const map = new MatchPatternMap(); + for (const { contentScripts } of Object.values(SEARCH_ENGINES)) { + for (const { matches } of contentScripts) { + for (const match of matches) { + map.set(match, match); + } + } + } + let match = null; + try { + match = map.get(url)[0] ?? null; + } catch { + // Invalid URL + } if (match != null) { const active = process.env.BROWSER === "chrome" @@ -184,23 +190,18 @@ const Popup: React.FC = () => { }); } else { const options = await loadFromLocalStorage([ + "ruleset", "blacklist", - "compiledRules", "subscriptions", "enablePathDepth", "blockWholeSite", ]); const ruleset = new InteractiveRuleset( - options.blacklist, - options.compiledRules !== false - ? options.compiledRules - : Ruleset.compile(options.blacklist), + fromPlainRuleset(options.ruleset || null, options.blacklist), Object.values(options.subscriptions) .filter((subscription) => subscription.enabled ?? true) - .map( - (subscription) => - subscription.compiledRules ?? - Ruleset.compile(subscription.blacklist), + .map(({ ruleset, blacklist }) => + fromPlainRuleset(ruleset || null, blacklist), ), ); setState({ @@ -210,9 +211,11 @@ const Popup: React.FC = () => { close: () => window.close(), enablePathDepth: options.enablePathDepth, openOptionsPage, + entryProps: { + url, + ...(title != null ? { title } : {}), + }, ruleset, - title, - url, onBlocked: () => saveToLocalStorage({ blacklist: ruleset.toString() }, "popup"), }, diff --git a/src/scripts/ruleset.test.ts b/src/scripts/ruleset.test.ts deleted file mode 100644 index 33a535d52..000000000 --- a/src/scripts/ruleset.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import assert from "node:assert"; -import { describe, test } from "node:test"; -import util from "node:util"; -import { Ruleset } from "./ruleset.ts"; -import type { SerpEntryProps } from "./types.ts"; -import { AltURL, r } from "./utilities.ts"; - -function makeProps( - url: string | { readonly url: string; readonly title?: string }, -): SerpEntryProps { - return typeof url === "string" - ? { url: new AltURL(url), title: null } - : { url: new AltURL(url.url), title: url.title ?? null }; -} - -function testTest( - rules: string, - table: readonly (readonly [ - url: string | { url: string; title?: string }, - result: number, - ])[], -): void { - describe(util.inspect(rules), () => { - const ruleset = new Ruleset(Ruleset.compile(rules)); - for (const [url, result] of table) { - test(util.inspect(url), () => { - assert.strictEqual(ruleset.test(makeProps(url)), result); - }); - } - }); -} - -describe("Match patterns", () => { - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns - testTest(r`*://*/*`, [ - ["http://example.org/", 0], - ["https://a.org/some/path", 0], - ["ftp://ftp.example.org/", -1], - ["file:///a/", -1], - ]); - testTest(r`*://*.mozilla.org/*`, [ - ["http://mozilla.org/", 0], - ["https://mozilla.org/", 0], - ["http://a.mozilla.org", 0], - ["https://a.b.mozilla.org", 0], - ["https://b.mozilla.org/path", 0], - ["ftp://mozilla.org/", -1], - ["http://mozilla.com/", -1], - ["http://firefox.org/", -1], - ]); - testTest(r`*://mozilla.org/`, [ - ["http://mozilla.org/", 0], - ["https://mozilla.org/", 0], - ["ftp://mozilla.org/", -1], - ["http://a.mozilla.org", -1], - ["http://mozilla.org/a", -1], - ]); - testTest(r`ftp://mozilla.org/`, [ - ["ftp://mozilla.org/", 0], - ["http://mozilla.org/", -1], - ["ftp://sub.mozilla.org/", -1], - ["ftp://mozilla.org/path", -1], - ]); - testTest(r`https://*/path`, [ - ["https://mozilla.org/path", 0], - ["https://a.mozilla.org/path", 0], - ["https://something.com/path", 0], - ["http://mozilla.org/path", -1], - ["https://mozilla.org/path/", -1], - ["https://mozilla.org/a", -1], - ["https://mozilla.org/", -1], - ["https://mozilla.org/path?foo=1", -1], - ]); - testTest(r`https://*/path/`, [ - ["https://mozilla.org/path/", 0], - ["https://a.mozilla.org/path/", 0], - ["https://something.com/path/", 0], - ["http://mozilla.org/path/", -1], - ["https://mozilla.org/path", -1], - ["https://mozilla.org/a", -1], - ["https://mozilla.org/", -1], - ["https://mozilla.org/path/?foo=1", -1], - ]); - testTest(r`https://mozilla.org/*`, [ - ["https://mozilla.org/", 0], - ["https://mozilla.org/path", 0], - ["https://mozilla.org/another", 0], - ["https://mozilla.org/path/to/doc", 0], - ["https://mozilla.org/path/to/doc?foo=1", 0], - ["http://mozilla.org/path", -1], - ["https://mozilla.com/path", -1], - ]); - testTest(r`https://mozilla.org/a/b/c/`, [ - ["https://mozilla.org/a/b/c/", 0], - ["https://mozilla.org/a/b/c/#section1", 0], - ]); - testTest(r`https://mozilla.org/*/b/*/`, [ - ["https://mozilla.org/a/b/c/", 0], - ["https://mozilla.org/d/b/f/", 0], - ["https://mozilla.org/a/b/c/d/", 0], - ["https://mozilla.org/a/b/c/d/#section1", 0], - ["https://mozilla.org/a/b/c/d?foo=/", 0], - ["https://mozilla.org/a?foo=21314&bar=/b/&extra=c/", 0], - ["https://mozilla.org/b/*/", -1], - ["https://mozilla.org/a/b/", -1], - ["https://mozilla.org/a/b/c/d/?foo=bar", -1], - ]); - // Invalid match patterns - testTest(r`https://mozilla.org`, [["https://mozilla/org/", -1]]); - testTest(r`https://mozilla.*.org/`, [ - ["https://mozilla.org/", -1], - ["https://mozilla.a.org/", -1], - ]); - testTest(r`https://*zilla.org/`, [["https://mozilla.org/", -1]]); - testTest(r`http*://mozilla.org/`, [["https://mozilla.org/", -1]]); - testTest(r`https://mozilla.org:80/`, [["https://mozilla.org:80/", -1]]); - // Schemes and hosts are case-insensitive - testTest(r`HTTPS://*.EXAMPLE.com/PATH/*`, [ - ["https://example.com/PATH/", 0], - ["HTTPS://WWW.EXAMPLE.COM/PATH/TO/DIR", 0], - ["https://example.com/path/", -1], - ]); -}); - -describe("Regular expressions", () => { - testTest(r`/example\.(net|org)/`, [ - ["https://example.net/", 0], - ["https://example.org/", 0], - ["http://example.com/?query=example.net", 0], - ["ftp://example.net/", 0], - ["http://example.com/", -1], - ]); - testTest(r`url/example\.(net|org)/`, [ - ["https://example.net/", 0], - ["http://example.com/", -1], - ]); - testTest(r`title/Example Domain/`, [ - [{ url: "http://example.com/", title: "Example Domain" }, 0], - [{ url: "http://example.com/", title: "This Is An Example Domain" }, 0], - [{ url: "http://example.com/" }, -1], - [{ url: "http://example.com/", title: "example domain" }, -1], - ]); - testTest(r`t/example domain/i`, [ - [{ url: "http://example.com/", title: "Example Domain" }, 0], - [{ url: "http://example.com/", title: "example domain" }, 0], - [{ url: "http://example.com/" }, -1], - [{ url: "http://example.com/", title: "example-domain" }, -1], - ]); - // https://iorate.github.io/ublacklist/advanced-features#regular-expressions - testTest(r`/^https:\/\/www\.qinterest\./`, [ - ["https://www.qinterest.com/", 0], - ["https://www.qinterest.jp/hoge", 0], - ["http://www.qinterest.com/", -1], - ["https://www.rinterest.com/", -1], - ]); - testTest(r`/^https?:\/\/([^/.]+\.)*?xn--/`, [ - ["http://xn--fsq.xn--zckzah/", 0], // http://例.テスト/ - ["http://example.test/", -1], - ]); - testTest(r`^https?:\/\/example\.com\/`, [["https://example.com/", -1]]); - testTest(r`/^https?://example\.com//`, [["https://example.com/", -1]]); -}); - -function testExecAndTest( - rules: string, - table: readonly (readonly [ - props: string | { readonly url: string; readonly title?: string }, - execResult: readonly (readonly [line: number, value: number])[], - testResult: number, - ])[], -): void { - describe(util.inspect(rules), () => { - const ruleset = new Ruleset(Ruleset.compile(rules)); - for (const [url, execResult, testResult] of table) { - test(util.inspect(url), () => { - const props = makeProps(url); - assert.deepStrictEqual( - ruleset - .exec(props) - .map(([line, value]) => [line, value] as const) - .sort(([l1], [l2]) => l1 - l2), - [...execResult].sort(([l1], [l2]) => l1 - l2), - ); - assert.strictEqual(ruleset.test(props), testResult); - }); - } - }); -} - -describe("Unblocking and highlighting rules", () => { - testTest(r`@*://example.com/*`, [ - ["https://example.com/", 1], - ["https://example.net/", -1], - ]); - testTest(r`@1 /example\.net/`, [ - ["http://www.example.net/", 2], - ["http://www.example.com/", -1], - ]); - testTest(r`@10t/bar/i`, [ - [{ url: "http://example.com/", title: "FOO BAR BAZ" }, 11], - [{ url: "http://example.com/foo/bar/baz/", title: "QUX QUUX" }, -1], - ]); - // Invalid highlighting rules - testTest(r`@ 1 /example\.net/`, [["http://www.example.net/", -1]]); -}); - -describe("Multiple rules", () => { - // Multiple match patterns - testExecAndTest( - r`*://example.com/* -@https://example.com/* -@1*://www.example.com/* -@2*://*.example.com/* -@3*://example.com/path -@4http://a.b.example.com/* -@5http://*.b.example.com/*/b/*/ -*://example.com/*`, - [ - [ - "https://example.com/", - [ - [0, 0], - [1, 1], - [3, 3], - [7, 0], - ], - 3, - ], - [ - "http://www.example.com/path", - [ - [2, 2], - [3, 3], - ], - 3, - ], - [ - "http://example.com/path", - [ - [0, 0], - [3, 3], - [4, 4], - [7, 0], - ], - 4, - ], - [ - "http://a.b.example.com/a/b/c/", - [ - [3, 3], - [5, 5], - [6, 6], - ], - 6, - ], - ["https://example.net/a/b/c/", [], -1], - ], - ); - // Multiple regular expressions - testExecAndTest( - r`@3 /example\.com/ -@2 u/example\.net/ -@1 url/www\.example\.com/ -@ t/example/ -title/domain/i`, - [ - [ - { url: "https://www.example.com", title: "Example Domain" }, - [ - [0, 4], - [2, 2], - [4, 0], - ], - 4, - ], - [ - { url: "ftp://ftp.example.net", title: "ftp example" }, - [ - [1, 3], - [3, 1], - ], - 3, - ], - ], - ); - // Empty, comment and invalid rules - testExecAndTest( - r` *://*.example.com/*bar* -t/quux$/ - -# Invalid rule -example\.(net|org) - -@2 /^HTTP:\/\//i -@https://example.com/* - -# IPv4 address -/^https?:\/\/(\d{1,3}\.){3}\d{1,3}\//`, - [ - [ - { url: "https://example.com/foobar" }, - [ - [0, 0], - [7, 1], - ], - 1, - ], - [ - { url: "http://www.example.com/hogefuga", title: "qux quux" }, - [ - [1, 0], - [6, 3], - ], - 3, - ], - [ - { url: "https://127.0.0.1/hoge/fuga/", title: "qux quux" }, - [ - [1, 0], - [10, 0], - ], - 0, - ], - [{ url: "ftp://127.0.0.1/", title: "quux qux" }, [], -1], - ], - ); -}); - -describe("Add and remove rules", () => { - const ruleset = new Ruleset( - Ruleset.compile( - r`*://example.com/* -@https://example.net/* - @1 /example\.edu/ -*://*.net/*`, - ), - ); - - const props1 = { url: new AltURL("https://example.net/path"), title: null }; - assert.strictEqual(ruleset.test(props1), 1); - ruleset.add(r`title/example/i -@2*://*.example.net/path*`); - assert.strictEqual(ruleset.test(props1), 3); - for (const [, value, remove] of ruleset.exec(props1)) { - if (value > 0) { - remove(); - } - } - assert.strictEqual(ruleset.test(props1), 0); - - const props2 = { - url: new AltURL("https://example.com/"), - title: "**EXAMPLE**", - }; - assert.strictEqual(ruleset.test(props2), 0); - for (const [, value, remove] of ruleset.exec(props2)) { - if (!value) { - remove(); - } - } - assert.strictEqual(ruleset.test(props2), -1); - - ruleset.add(r`@*://*/*`); - assert.strictEqual(ruleset.test(props1), 1); - assert.strictEqual(ruleset.test(props2), 1); -}); diff --git a/src/scripts/ruleset.ts b/src/scripts/ruleset.ts deleted file mode 100644 index 5d70e266b..000000000 --- a/src/scripts/ruleset.ts +++ /dev/null @@ -1,395 +0,0 @@ -import type { SerpEntryProps } from "./types.ts"; -import { type MatchPatternScheme, lines, r } from "./utilities.ts"; - -export type RegularExpressionProp = "url" | "title"; - -export type ParsedRule = - | { - type: "mp"; - scheme: MatchPatternScheme; - host: string; - path: string; - value: number; - } - | { - type: "re"; - prop: RegularExpressionProp; - pattern: string; - flags: string; - value: number; - }; - -export const RE_LINE = (() => { - const spaceBeforeRuleOrComment = r`(?\s+)`; - - const color = r`(?0|[1-9]\d*)`; - const highlight = r`(?@${color}?)`; - const spaceAfterHighlight = r`(?\s+)`; - - const scheme = r`(?\*|[Hh][Tt][Tt][Pp][Ss]?|[Ff][Tt][Pp])`; - const label = r`(?:[0-9A-Za-z](?:[-0-9A-Za-z]*[0-9A-Za-z])?)`; - const host = r`(?(?:\*|${label})(?:\.${label})*)`; - const path = r`(?/(?:\*|[-0-9A-Za-z._~:/?[\]@!$&'()+,;=]|%[0-9A-Fa-f]{2})*)`; - const matchPattern = r`(?${scheme}://${host}${path})`; - - const prop = r`(?u(?:rl)?|t(?:itle)?)`; - const backslashSequence = r`(?:\\.)`; - const class_ = r`(?:\[(?:[^\]\\]|${backslashSequence})*])`; - const firstChar = r`(?:[^*\\/[]|${backslashSequence}|${class_})`; - const char = r`(?:[^\\/[]|${backslashSequence}|${class_})`; - const pattern = r`(?${firstChar}${char}*)`; - const flags = r`(?iu?|ui?)`; - const regularExpression = r`(?${prop}?/${pattern}/${flags}?)`; - - const rule = r`(?(${highlight}${spaceAfterHighlight}?)?(?:${matchPattern}|${regularExpression}))`; - const spaceAfterRule = r`(?\s+)`; - - const comment = r`(?#.*)`; - - const line = r`^${spaceBeforeRuleOrComment}?(?:${rule}${spaceAfterRule}?)?${comment}?$`; - - return new RegExp(line); -})(); - -export function parseRule(input: string): ParsedRule | null { - const groups = RE_LINE.exec(input)?.groups; - if (!groups?.rule) { - return null; - } - // *://example.com/* -> 0 (block) - // @*://example.com/* -> 1 (unblock) - // @1*://example.com/* -> 2 (highlight-1) - // @2*://example.com/* -> 3 (highlight-2) - // ... - const value = groups.highlight - ? groups.color - ? Number(groups.color) + 1 - : 1 - : 0; - return groups.matchPattern - ? { - type: "mp", - scheme: groups.scheme.toLowerCase() as MatchPatternScheme, - host: groups.host.toLowerCase(), - path: groups.path, - value, - } - : { - type: "re", - prop: groups.prop === "t" || groups.prop === "title" ? "title" : "url", - pattern: groups.pattern, - flags: groups.flags || "", - value, - }; -} -// #endregion rule - -type CompiledMatchPatternISPV = - | number - | [ - index: number, - scheme: string, - path: true extends RT ? string | RegExp : string, - value: number, - ]; - -type CompiledMatchPattern = { - [key: string]: - | (true extends RT - ? CompiledMatchPatternISPV | null - : CompiledMatchPatternISPV)[] - | CompiledMatchPattern; -}; - -type CompiledRegularExpressionIPFV = [ - index: number, - pattern: true extends RT ? string | RegExp : string, - flags: string, - value: number, -]; - -type CompiledRegularExpression = Record< - RegularExpressionProp, - (true extends RT - ? CompiledRegularExpressionIPFV | null - : CompiledRegularExpressionIPFV)[] ->; - -export type CompiledRules = { - length: number; - mp: CompiledMatchPattern; - re: CompiledRegularExpression; -}; - -function compileMatchPattern( - mp: CompiledMatchPattern, - index: number, - scheme: string, - host: string, - path: string, - value: number, -): void { - // NOTE: `current['*']` and `current['']` are always of type `CompiledMatchPatternISPV[]`. - const labels = host.split(".").reverse(); - let current = mp; - for (const label of labels.slice(0, -1)) { - if (!label || label === "*") { - return; - } - const next = current[label]; - if (!next) { - current = current[label] = {}; - } else if (Array.isArray(next)) { - current = current[label] = { "": next }; - } else { - current = next; - } - } - { - const label = labels[labels.length - 1]; - if (!label) { - return; - } - const next = current[label]; - const ispv = - scheme === "*" && path === "/*" && !value - ? index - : ([index, scheme, path, value] as CompiledMatchPatternISPV); - if (!next) { - current[label] = [ispv]; - } else if (Array.isArray(next)) { - current[label] = [...next, ispv]; - } else { - next[""] = [ - ...((next[""] as CompiledMatchPatternISPV[] | undefined) || []), - ispv, - ]; - } - } -} - -function compileRegularExpression( - re: CompiledRegularExpression, - index: number, - prop: RegularExpressionProp, - pattern: string, - flags: string, - value: number, -): void { - try { - new RegExp(pattern, flags); - } catch { - return; - } - const ipfv = [ - index, - pattern, - flags, - value, - ] as CompiledRegularExpressionIPFV; - re[prop].push(ipfv); -} - -function compileRuleset( - result: CompiledRules, - rules: readonly string[], -): void { - for (const rule of rules) { - const parsed = parseRule(rule); - if (!parsed) { - // pass - } else if (parsed.type === "mp") { - compileMatchPattern( - result.mp, - result.length, - parsed.scheme, - parsed.host, - parsed.path, - parsed.value, - ); - } else { - compileRegularExpression( - result.re, - result.length, - parsed.prop, - parsed.pattern, - parsed.flags, - parsed.value, - ); - } - ++result.length; - } -} - -export type RulesetResults = [ - index: number, - value: number, - remove: () => void, -][]; - -function execMatchPatternISPVArray( - results: RulesetResults, - ispvs: (CompiledMatchPatternISPV | null)[], - scheme: string, - path: string, -): void { - ispvs.forEach((ispv, i) => { - if (ispv == null) { - return; - } - if (typeof ispv === "number") { - if (scheme === "http" || scheme === "https") { - results.push([ - ispv, - 0, - () => { - ispvs[i] = null; - }, - ]); - } - } else { - if (typeof ispv[2] === "string") { - ispv[2] = new RegExp( - `^${ispv[2] - .replace(/[$^\\.+?()[\]{}|]/g, "\\$&") - .replace(/\*/g, ".*")}$`, - ); - } - if ( - (ispv[1] === "*" - ? scheme === "http" || scheme === "https" - : scheme === ispv[1]) && - ispv[2].test(path) - ) { - results.push([ - ispv[0], - ispv[3], - () => { - ispvs[i] = null; - }, - ]); - } - } - }); -} - -function execMatchPattern( - results: RulesetResults, - mp: CompiledMatchPattern, - scheme: string, - host: string, - path: string, -): void { - // NOTE: `current['*']` and `current['']` are always of type `CompiledMatchPatternISPV[]`. - const labels = host.split(".").reverse(); - let current = mp; - for (const label of labels.slice(0, -1)) { - let next = current["*"]; - if (Array.isArray(next)) { - execMatchPatternISPVArray(results, next, scheme, path); - } - next = current[label]; - if (!next || Array.isArray(next)) { - return; - } - current = next; - } - { - const label = labels[labels.length - 1]; - let next = current["*"]; - if (Array.isArray(next)) { - execMatchPatternISPVArray(results, next, scheme, path); - } - next = current[label]; - if (!next) { - return; - } - if (Array.isArray(next)) { - execMatchPatternISPVArray(results, next, scheme, path); - } else { - current = next; - next = current[""]; - if (Array.isArray(next)) { - execMatchPatternISPVArray(results, next, scheme, path); - } - next = current["*"]; - if (Array.isArray(next)) { - execMatchPatternISPVArray(results, next, scheme, path); - } - } - } -} - -function execRegularExpressionIPFVArray( - results: RulesetResults, - ipfvs: (CompiledRegularExpressionIPFV | null)[], - prop: string, -): void { - ipfvs.forEach((ipfv, i) => { - if (ipfv == null) { - return; - } - if (typeof ipfv[1] === "string") { - ipfv[1] = new RegExp(ipfv[1], ipfv[2]); - } - if (ipfv[1].test(prop)) { - results.push([ - ipfv[0], - ipfv[3], - () => { - ipfvs[i] = null; - }, - ]); - } - }); -} - -export class Ruleset { - static compile(rules: string): string { - const compiledRules: CompiledRules = { - length: 0, - mp: {}, - re: { url: [], title: [] }, - }; - compileRuleset(compiledRules, lines(rules)); - return JSON.stringify(compiledRules); - } - - private readonly compiled: CompiledRules; - - constructor(compiled: string) { - this.compiled = JSON.parse(compiled) as CompiledRules; - } - - add(rules: string): void { - compileRuleset(this.compiled, lines(rules)); - } - - exec(props: Readonly): RulesetResults { - const results: RulesetResults = []; - execMatchPattern( - results, - this.compiled.mp, - props.url.scheme, - props.url.host, - props.url.path, - ); - execRegularExpressionIPFVArray( - results, - this.compiled.re.url, - props.url.toString(), - ); - if (props.title != null) { - execRegularExpressionIPFVArray( - results, - this.compiled.re.title, - props.title, - ); - } - return results; - } - - test(props: Readonly): number { - return Math.max(-1, ...this.exec(props).map(([, value]) => value)); - } -} diff --git a/src/scripts/ruleset/lang.ts b/src/scripts/ruleset/lang.ts new file mode 100644 index 000000000..c8ad2e84b --- /dev/null +++ b/src/scripts/ruleset/lang.ts @@ -0,0 +1,73 @@ +import { yamlFrontmatter } from "@codemirror/lang-yaml"; +import { LRLanguage, LanguageSupport, syntaxTree } from "@codemirror/language"; +import { type Diagnostic, linter } from "@codemirror/lint"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { parser as rulesetParser } from "./parser.js"; +import { parseRegExp, parseString } from "./utils.ts"; + +const rulesetLanguage = LRLanguage.define({ + name: "ruleset", + parser: rulesetParser.configure({ + props: [ + styleTags({ + Comment: t.lineComment, + "@ AtInteger @if": t.modifier, + Identifier: t.variableName, + "StringMatchOperator CaseSensitivity RegExpMatchOperator": + t.compareOperator, + String: t.string, + RegExp: t.regexp, + "( )": t.paren, + '"!" & |': t.logicOperator, + }), + ], + }), + languageData: { + commentTokens: { line: "#" }, + }, +}); + +function getMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return "Unknown error"; +} + +const rulesetLinter = linter((view) => { + const diagnostics: Diagnostic[] = []; + syntaxTree(view.state) + .cursor() + .iterate((node) => { + const pushError = (message: string) => { + diagnostics.push({ + from: node.from, + to: node.to, + severity: "error", + message, + }); + }; + if (node.type.isError) { + pushError("Syntax error"); + } else if (node.name === "String") { + try { + parseString(view.state.sliceDoc(node.from, node.to)); + } catch (error) { + pushError(getMessage(error)); + } + } else if (node.name === "RegExp") { + try { + parseRegExp(view.state.sliceDoc(node.from, node.to)); + } catch (error) { + pushError(getMessage(error)); + } + } + }); + return diagnostics; +}); + +export function ruleset(): LanguageSupport { + return yamlFrontmatter({ + content: new LanguageSupport(rulesetLanguage, rulesetLinter), + }); +} diff --git a/src/scripts/ruleset/parser.js b/src/scripts/ruleset/parser.js new file mode 100644 index 000000000..7f822515c --- /dev/null +++ b/src/scripts/ruleset/parser.js @@ -0,0 +1,21 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +const spec_Identifier = {__proto__:null,HTTP:67, HTTp:67, HTtP:67, HTtp:67, HtTP:67, HtTp:67, HttP:67, Http:67, hTTP:67, hTTp:67, hTtP:67, hTtp:67, htTP:67, htTp:67, httP:67, http:67, HTTPS:67, HTTpS:67, HTtPS:67, HTtpS:67, HtTPS:67, HtTpS:67, HttPS:67, HttpS:67, hTTPS:67, hTTpS:67, hTtPS:67, hTtpS:67, htTPS:67, htTpS:67, httPS:67, httpS:67, HTTPs:67, HTTps:67, HTtPs:67, HTtps:67, HtTPs:67, HtTps:67, HttPs:67, Https:67, hTTPs:67, hTTps:67, hTtPs:67, hTtps:67, htTPs:67, htTps:67, httPs:67, https:67} +export const parser = LRParser.deserialize({ + version: 14, + states: "&fQwQPOOOOQO'#C`'#C`OOQO'#Cb'#CbOOQO'#Cd'#CdO!OOPO'#CdO!TQQO'#CiOOQO'#Ci'#CiO!`QPO'#CoO!`QPO'#CqO!nQPO'#C_OOQO'#DR'#DRO!|QPO'#C_O`QPO'#C_O#XQPO'#CwQ#cQPOOQ#cQPOOO#hOSO,59OO#mQPO,59TOOQO,59T,59TO#rQPO,59TO#wQPO,59ZOOQO,59],59]O!`QPO,59_O!`QPO,59aO$SQPO'#CfOOQO,58y,58yO$XQPO,58yO$gQPO,58yOOQO,59c,59cOOQO-E6u-E6uO$rOSO1G.jO$wQQO1G.oOOQO1G.o1G.oOOQO1G.u1G.uOOQO1G.y1G.yO%]QPO1G.{O!`QPO,59QOOQO1G.e1G.eOOQO7+$U7+$UOOQO7+$Z7+$ZO%nQPO1G.lOOQO7+$W7+$W", + stateData: "&O~OnOSPOS~OTPOVQOXTO[VObUOfWOoROpSOqSO~Ov]O~PYOr`O~O^aOacObbO~OXTO[VObUOfWO~OhfOjgOlRXvRX~OZhOlRXvRX~OlkXvkX~PYOv]O~OsnO~O_oO~ObpO~OdqOhfOjgO~O[tO~OhfOjgOlRavRa~OZhOlRavRa~OtvO~O`wOh]ij]il]iv]id]i~OhfOjiiliiviidii~OdyOhfOjgO~OVZTZ~", + goto: "#XvPPPw}P}P!RP!YPP!`PPPPP!`P!`P!`P!`P!jPPPPPPPPP!qQ_ORl]T[O]SZO]Rk[QiZRukaYOVW[]fgtS^O_Rm^SXO]QdVQeWQj[QrfQsgRxt", + nodeNames: "⚠ Comment Ruleset Rule NegateSpecifier @ HighlightSpecifier AtInteger MatchPattern Identifier IfSpecifier @if ( MatchExpression StringMatchOperator String CaseSensitivity RegExpMatchOperator RegExp ParenthesizedExpression ) NegateExpression ! AndExpression & OrExpression |", + maxTerm: 38, + nodeProps: [ + ["group", -5,13,19,21,23,25,"Expression"] + ], + skippedNodes: [0,1], + repeatNodeCount: 1, + tokenData: ">e~RlXY!yYZ#X]^!ypq!yqr#^rs#cst$jtu%Rvw%xwx%}xy'Pyz'Uz{'Z!P!Q(i!Q!['t![!]:_!^!_:p!_!`;}!b!c<[!c!k=S!k!l=m!l!}=S#Q#R>Y#R#S%g#T#]=S#]#^=m#^#o=S#p#q>`~#ORn~XY!y]^!ypq!y~#^Ov~~#cOf~~#fWOY#cZr#crs$Os#O#c#O#P$T#P;'S#c;'S;=`$d<%lO#c~$TO_~~$WSOY#cZ;'S#c;'S;=`$d<%lO#c~$gP;=`<%l#c~$oSP~OY$jZ;'S$j;'S;=`${<%lO$j~%OP;=`<%l$jR%US!_!`%b!c!}%g#R#S%g#T#o%gQ%gO^QP%lSXP!Q![%g!c!}%g#R#S%g#T#o%g~%}Oh~~&QWOY%}Zw%}wx$Ox#O%}#O#P&j#P;'S%};'S;=`&y<%lO%}~&mSOY%}Z;'S%};'S;=`&y<%lO%}~&|P;=`<%l%}~'UO[~~'ZOd~V'bQpPsS!O!P'h!_!`%bS'kR!Q!['t!c!}'t#T#o'tS'yTsS}!O(Y!O!P'h!Q!['t!c!}'t#T#o'tS(]S}!O(Y!Q!['t!c!}'t#T#o'tV(nutSOY+RZq+Rqr-Urt+Rtu-Uuv/nvw-Uwx-Uxy-Uyz-Uz{-U{|-U|}-U}!O-U!O!P-U!P!Q3z!Q![-U![!]-U!]!^-U!^!_+R!_!`-U!`!a+R!a!b-U!b!c-U!c!}-U!}#O6W#O#P,o#P#Q-U#Q#R+R#R#S-U#S#T+R#T#o-U#o#r+R#r#s-U#s;'S+R;'S;=`-O<%lO+RR+UXOY+RZ!P+R!P!Q+q!Q!}+R!}#O,S#O#P,o#P;'S+R;'S;=`-O<%lO+RR+vSbR#]#^+q#a#b+q#g#h+q#i#j+qR,VUOY,SZ#O,S#P#Q+R#Q;'S,S;'S;=`,i<%lO,SR,lP;=`<%l,SR,rSOY+RZ;'S+R;'S;=`-O<%lO+RR-RP;=`<%l+RV-ZutSOY+RZq+Rqr-Urt+Rtu-Uuv/nvw-Uwx-Uxy-Uyz-Uz{-U{|-U|}-U}!O-U!O!P-U!P!Q1l!Q![-U![!]-U!]!^-U!^!_+R!_!`-U!`!a+R!a!b-U!b!c-U!c!}-U!}#O6W#O#P,o#P#Q-U#Q#R+R#R#S-U#S#T+R#T#o-U#o#r+R#r#s-U#s;'S+R;'S;=`-O<%lO+RV/q^OY+RZ!P+R!P!Q+q!Q![0m![!c+R!c!i0m!i!}+R!}#O,S#O#P,o#P#T+R#T#Z0m#Z;'S+R;'S;=`-O<%lO+RV0p^OY+RZ!P+R!P!Q+q!Q![-U![!c+R!c!i-U!i!}+R!}#O,S#O#P,o#P#T+R#T#Z-U#Z;'S+R;'S;=`-O<%lO+RV1sqbRtSqr3ztu3zuv5nvw3zwx3zxy3zyz3zz{3z{|3z|}3z}!O3z!O!P3z!P!Q3z!Q![3z![!]3z!]!^3z!_!`3z!a!b3z!b!c3z!c!}3z!}#O3z#P#Q3z#R#S3z#T#]3z#]#^1l#^#a3z#a#b1l#b#g3z#g#h1l#h#i3z#i#j1l#j#o3z#r#s3zS4PitSqr3ztu3zuv5nvw3zwx3zxy3zyz3zz{3z{|3z|}3z}!O3z!O!P3z!P!Q3z!Q![3z![!]3z!]!^3z!_!`3z!a!b3z!b!c3z!c!}3z!}#O3z#P#Q3z#R#S3z#T#o3z#r#s3zS5qR!Q![5z!c!i5z#T#Z5zS5}R!Q![3z!c!i3z#T#Z3zV6]ttSOY,SZq,Sqr6Wrt,Stu6Wuv8mvw6Wwx6Wxy6Wyz6Wz{6W{|6W|}6W}!O6W!O!P6W!P!Q6W!Q![6W![!]6W!]!^6W!^!_,S!_!`6W!`!a,S!a!b6W!b!c6W!c!}6W!}#O6W#P#Q-U#Q#R,S#R#S6W#S#T,S#T#o6W#o#r,S#r#s6W#s;'S,S;'S;=`,i<%lO,SV8p[OY,SZ!Q,S!Q![9f![!c,S!c!i9f!i#O,S#P#Q+R#Q#T,S#T#Z9f#Z;'S,S;'S;=`,i<%lO,SV9i[OY,SZ!Q,S!Q![6W![!c,S!c!i6W!i#O,S#P#Q+R#Q#T,S#T#Z6W#Z;'S,S;'S;=`,i<%lO,S~:bP!P!Q:e~:hP!P!Q:k~:pOr~~:sP#T#U:v~:yP#`#a:|~;PP#`#a;S~;VP#R#S;Y~;]P#i#j;`~;cP#f#g;f~;iP#`#a;l~;oP#g#h;r~;uP!`!a;x~;}Oo~~]P!_!`%b~>eOj~", + tokenizers: [0, 1, 2], + topRules: {"Ruleset":[0,2]}, + specialized: [{term: 9, get: (value) => spec_Identifier[value] || -1}], + tokenPrec: 225 +}) diff --git a/src/scripts/ruleset/ruleset.grammar b/src/scripts/ruleset/ruleset.grammar new file mode 100644 index 000000000..76613b871 --- /dev/null +++ b/src/scripts/ruleset/ruleset.grammar @@ -0,0 +1,78 @@ +@precedence { + negate @left + and @left + or @left +} + +@skip { space | Comment } + +@top Ruleset { Rule? ("\n" Rule?)* } + +Rule { (NegateSpecifier | HighlightSpecifier)? ((MatchPattern IfSpecifier?) | expression) } + +NegateSpecifier { "@" } +HighlightSpecifier { AtInteger } +IfSpecifier { "@if" "(" expression ")" } + +@skip {} { + MatchPattern { + "" + | ( "*" + | @extend< + Identifier, + "HTTP" | "HTTp" | "HTtP" | "HTtp" | "HtTP" | "HtTp" | "HttP" | "Http" | "hTTP" | "hTTp" | "hTtP" | "hTtp" | "htTP" | "htTp" | "httP" | "http" + | "HTTPS" | "HTTpS" | "HTtPS" | "HTtpS" | "HtTPS" | "HtTpS" | "HttPS" | "HttpS" | "hTTPS" | "hTTpS" | "hTtPS" | "hTtpS" | "htTPS" | "htTpS" | "httPS" | "httpS" | "HTTPs" | "HTTps" | "HTtPs" | "HTtps" | "HtTPs" | "HtTps" | "HttPs" | "Https" | "hTTPs" | "hTTps" | "hTtPs" | "hTtps" | "htTPs" | "htTps" | "httPs" | "https" + > + ) + "://" + matchPatternHost + matchPatternPath + } +} + +expression[@isGroup=Expression] { + MatchExpression { + Identifier StringMatchOperator String CaseSensitivity? + | (Identifier RegExpMatchOperator?)? RegExp + } + | ParenthesizedExpression { "(" expression ")" } + | NegateExpression { "!" !negate expression } + | AndExpression { expression !and "&" expression } + | OrExpression { expression !or "|" expression } +} + +@tokens { + space { $[ \t\r]+ } + Comment { "#" ![\n]* } + + "@" + AtInteger { "@" ("0" | $[1-9] @digit*) } + "@if" + @precedence { AtInteger, "@if", "@" } + + matchPatternHost { ("*" | matchPatternLabel) ("." matchPatternLabel)* } + matchPatternLabel { (@digit | @asciiLetter) ((@digit | @asciiLetter | "-")* (@digit | @asciiLetter))? } + matchPatternPath { "/" matchPatternPathChar* } + matchPatternPathChar { "*" | @digit | @asciiLetter | $[._~:/?[\]@!$&'()+,;=-] | "%" hex hex } + hex { @digit | $[A-Fa-f] } + + Identifier { "$"? (@asciiLetter | "_") (@digit | @asciiLetter | "_")* } + + StringMatchOperator { $[^$*]? "=" } + CaseSensitivity { $[Ii] } + RegExpMatchOperator { "=~" } + + String { "'" (stringEscape | stringContentSingle)* "'" | '"' (stringEscape | stringContentDouble)* '"' } + stringEscape { "\\" ![\n] } + stringContentSingle { !['\\\n] } + stringContentDouble { !["\\\n] } + + RegExp { "/" regExpPattern "/" regExpFlags? } + regExpPattern { (regExpEscape | "[" regExpClassContent* "]" | regExpContent)+ } + regExpEscape { "\\" ![\n] } + regExpClassContent { ![\]\\\n] } + regExpContent { ![/\\[\n] } + regExpFlags { $[imsu]+ } + + "(" ")" "!" "&" "|" +} diff --git a/src/scripts/ruleset/ruleset.test.ts b/src/scripts/ruleset/ruleset.test.ts new file mode 100644 index 000000000..2ad80cbc8 --- /dev/null +++ b/src/scripts/ruleset/ruleset.test.ts @@ -0,0 +1,790 @@ +import assert from "node:assert"; +import { test } from "node:test"; +import { type LinkProps, Ruleset, type TestRawResult } from "./ruleset.ts"; + +function testRaw(ruleset: Ruleset, props: LinkProps): TestRawResult { + return ruleset + .testRaw(props) + .sort(({ lineNumber: a }, { lineNumber: b }) => a - b); +} + +test("Ruleset", async (t) => { + await t.test("Match patterns", () => { + { + const ruleset = new Ruleset("*://*/*"); + assert.ok(ruleset.test({ url: "https://example.com/" })); + assert.ok(ruleset.test({ url: "https://a.org/some/path" })); + assert.ok(!ruleset.test({ url: "ftp://ftp.example.org/" })); + assert.ok(!ruleset.test({ url: "file:///a/" })); + } + { + const ruleset = new Ruleset("*://*.mozilla.org/*"); + assert.ok(ruleset.test({ url: "http://mozilla.org/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/" })); + assert.ok(ruleset.test({ url: "http://a.mozilla.org" })); + assert.ok(ruleset.test({ url: "https://a.b.mozilla.org" })); + assert.ok(ruleset.test({ url: "https://b.mozilla.org/path" })); + assert.ok(!ruleset.test({ url: "ftp://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "http://mozilla.com/" })); + assert.ok(!ruleset.test({ url: "http://firefox.org/" })); + } + { + const ruleset = new Ruleset("*://mozilla.org/"); + assert.ok(ruleset.test({ url: "http://mozilla.org/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "ftp://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "http://a.mozilla.org" })); + assert.ok(!ruleset.test({ url: "http://mozilla.org/a" })); + } + { + const ruleset = new Ruleset("ftp://mozilla.org"); // not supported + assert.ok(!ruleset.test({ url: "ftp://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "http://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "ftp://sub.mozilla.org/" })); + assert.ok(!ruleset.test({ url: "ftp://mozilla.org/path" })); + } + { + const ruleset = new Ruleset("https://*/path"); + assert.ok(ruleset.test({ url: "https://mozilla.org/path" })); + assert.ok(ruleset.test({ url: "https://a.mozilla.org/path" })); + assert.ok(ruleset.test({ url: "https://something.com/path" })); + assert.ok(!ruleset.test({ url: "http://mozilla.org/path" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/path/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/a" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/path?foo=1" })); + } + { + const ruleset = new Ruleset("https://*/path/"); + assert.ok(ruleset.test({ url: "https://mozilla.org/path/" })); + assert.ok(ruleset.test({ url: "https://a.mozilla.org/path/" })); + assert.ok(ruleset.test({ url: "https://something.com/path/" })); + assert.ok(!ruleset.test({ url: "http://mozilla.org/path/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/path" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/a" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/path/?foo=1" })); + } + { + const ruleset = new Ruleset("https://mozilla.org/*"); + assert.ok(ruleset.test({ url: "https://mozilla.org/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/path" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/another" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/path/to/doc" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/path/to/doc?foo=1" })); + assert.ok(!ruleset.test({ url: "http://mozilla.org/path" })); + assert.ok(!ruleset.test({ url: "https://mozilla.com/path" })); + } + { + const ruleset = new Ruleset("https://mozilla.org/a/b/c/"); + assert.ok(ruleset.test({ url: "https://mozilla.org/a/b/c/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/a/b/c/#section1" })); + } + { + const ruleset = new Ruleset("https://mozilla.org/*/b/*/"); + assert.ok(ruleset.test({ url: "https://mozilla.org/a/b/c/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/d/b/f/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/a/b/c/d/" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/a/b/c/d/#section1" })); + assert.ok(ruleset.test({ url: "https://mozilla.org/a/b/c/d?foo=/" })); + assert.ok( + ruleset.test({ + url: "https://mozilla.org/a?foo=21314&bar=/b/&extra=c/", + }), + ); + assert.ok(!ruleset.test({ url: "https://mozilla.org/b/*/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/a/b/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.org/a/b/c/d/?foo=bar" })); + } + // Invalid match patterns + { + const ruleset = new Ruleset("https://mozilla.org"); + assert.ok(!ruleset.test({ url: "https://mozilla.org/" })); + } + { + const ruleset = new Ruleset("https://mozilla.*.org/"); + assert.ok(!ruleset.test({ url: "https://mozilla.org/" })); + assert.ok(!ruleset.test({ url: "https://mozilla.a.org/" })); + } + { + const ruleset = new Ruleset("https://*zilla.org/"); + assert.ok(!ruleset.test({ url: "https://mozilla.org/" })); + } + { + const ruleset = new Ruleset("http*://mozilla.org/"); + assert.ok(!ruleset.test({ url: "https://mozilla.org/" })); + } + { + const ruleset = new Ruleset("https://mozilla.org:80/"); + assert.ok(!ruleset.test({ url: "https://mozilla.org:80/" })); + } + // Schemes and hosts are case-insensitive + { + const ruleset = new Ruleset("HTTPS://*.EXAMPLE.com/PATH/*"); + assert.ok(ruleset.test({ url: "https://example.com/PATH/" })); + assert.ok(ruleset.test({ url: "HTTPS://WWW.EXAMPLE.COM/PATH/TO/DIR" })); + assert.ok(!ruleset.test({ url: "https://example.com/path/" })); + } + }); + + await t.test("Regular expressions (legacy)", () => { + { + const ruleset = new Ruleset(String.raw`/example\.(net|org)/`); + assert.ok(ruleset.test({ url: "https://example.net/" })); + assert.ok(ruleset.test({ url: "https://example.org/" })); + assert.ok(ruleset.test({ url: "http://example.com/?query=example.net" })); + assert.ok(!ruleset.test({ url: "ftp://example.net/" })); + assert.ok(!ruleset.test({ url: "http://example.com/" })); + } + { + const ruleset = new Ruleset(String.raw`url/example\.(net|org)/`); + assert.ok(ruleset.test({ url: "https://example.net/" })); + assert.ok(!ruleset.test({ url: "https://example.com/" })); + } + { + const ruleset = new Ruleset("title/Example Domain/"); + assert.ok( + ruleset.test({ url: "http://example.com", title: "Example Domain" }), + ); + assert.ok( + ruleset.test({ + url: "http://example.com", + title: "This Is An Example Domain", + }), + ); + assert.ok(!ruleset.test({ url: "http://example.com" })); + assert.ok( + !ruleset.test({ url: "http://example.com", title: "example domain" }), + ); + } + { + const ruleset = new Ruleset("t/example domain/i"); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok(!ruleset.test({ url: "http://example.com/" })); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "example-domain" }), + ); + } + // https://iorate.github.io/ublacklist/advanced-features#regular-expressions + { + const ruleset = new Ruleset(String.raw`/https:\/\/www\.qinterest\./`); + assert.ok(ruleset.test({ url: "https://www.qinterest.com/" })); + assert.ok(ruleset.test({ url: "https://www.qinterest.jp/hoge" })); + assert.ok(!ruleset.test({ url: "http://www.qinterest.com/" })); + assert.ok(!ruleset.test({ url: "https://www.rinterest.com/" })); + } + { + const ruleset = new Ruleset(String.raw`/https?:\/\/([^/.]+\.)*?xn--/`); + assert.ok(ruleset.test({ url: "http://xn--fsq.xn--zckzah/" })); // http://例.テスト/ + assert.ok(!ruleset.test({ url: "http://example.test/" })); + } + }); + + await t.test("Simple expressions", () => { + { + const ruleset = new Ruleset('title="Example Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset('title = "Example Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title="example domain"i'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title = "example domain" I'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title^="Example"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok(!ruleset.test({ url: "http://example.com/", title: "Domain" })); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset('title ^= "Example"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title^="Example"i'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + } + { + const ruleset = new Ruleset('title ^= "Example" I'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + } + { + const ruleset = new Ruleset('title$="Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "Example" }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset('title $= "Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('$domain$="Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", $domain: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title$="domain"i'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title $= "domain" I'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title*="ple Dom"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset('title *= "ple Dom"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title*="PLE DOM"i'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('title *= "PLE DOM" I'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset("title=~/example/i"); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "Test Domain" }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset("title =~ /example/i"); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + // String literals + { + const ruleset = new Ruleset( + String.raw`title="foo bar \xA9 \u00A9 \u{2F804} \0 \b \f \n \r \t \v \a"`, + ); + assert.ok( + ruleset.test({ + url: "http://example.com/", + title: "foo bar \xA9 \u00A9 \u{2F804} \0 \b \f \n \r \t \v a", + }), + ); + } + { + const ruleset = new Ruleset(String.raw`title="foo bar \00"`); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "foo bar \x000" }), + ); + } + { + const ruleset = new Ruleset(String.raw`title="foo bar \xA"`); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "foo bar \\xA" }), + ); + } + }); + + await t.test("Complex expressions", () => { + { + const ruleset = new Ruleset('(title="Example Domain")'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "example domain", + }), + ); + } + { + const ruleset = new Ruleset('( title = "Example Domain" )'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + { + const ruleset = new Ruleset('!title="Example Domain"'); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset('url*="example"&title="Example Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + !ruleset.test({ + url: "http://something.com/", + title: "Example Domain", + }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset('url*="example"|title="Example Domain"'); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + ruleset.test({ + url: "http://something.com/", + title: "Example Domain", + }), + ); + assert.ok( + ruleset.test({ + url: "http://example.com/", + snippet: "Example Domain", + }), + ); + assert.ok( + !ruleset.test({ + url: "http://something.com/", + snippet: "Example Domain", + }), + ); + } + // Precedence + { + const ruleset = new Ruleset( + 'url *= "example" | title ^= "Example" & title $= "Domain"', + ); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "example domain" }), + ); + assert.ok( + ruleset.test({ + url: "http://something.com/", + title: "Example Domain", + }), + ); + assert.ok( + !ruleset.test({ + url: "http://something.com/", + snippet: "Example", + }), + ); + } + // More complex expressions + { + const ruleset = new Ruleset( + `a="1" & b^="2" | !(c$="3" & d*="4") | !!e=~/5/ & f=~/6/`, + ); + assert.ok(ruleset.test({ url: "http://example.com/", a: "1", b: "20" })); + assert.ok(ruleset.test({ url: "http://example.com/", a: "1", b: "3" })); + assert.ok( + !ruleset.test({ url: "http://example.com/", a: "1", c: "3", d: "4" }), + ); + assert.ok( + ruleset.test({ + url: "http://example.com/", + a: "1", + b: "3", + c: "3", + d: "123567", + }), + ); + assert.ok( + ruleset.test({ + url: "http://example.com/", + a: "1", + b: "3", + c: "3", + d: "4", + e: "551", + f: "169", + }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.com/", + a: "1", + b: "3", + c: "3", + d: "4", + e: "551", + f: "777", + }), + ); + } + }); + + await t.test("Negate and highlight specifiers", () => { + { + const ruleset = new Ruleset("@*://example.com/*"); + assert.deepStrictEqual( + testRaw(ruleset, { url: "https://example.com/" }), + [{ lineNumber: 1, specifier: { type: "negate" } }], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "https://example.net/" }), + [], + ); + } + { + const ruleset = new Ruleset(String.raw`@1 /example\.net/`); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://www.example.net/" }), + [{ lineNumber: 1, specifier: { type: "highlight", colorNumber: 1 } }], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://www.example.com/" }), + [], + ); + } + { + const ruleset = new Ruleset("@10t/bar/i"); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://example.com/", title: "FOO BAR BAZ" }), + [{ lineNumber: 1, specifier: { type: "highlight", colorNumber: 10 } }], + ); + assert.deepStrictEqual( + testRaw(ruleset, { + url: "http://example.com/foo/bar/baz/", + title: "QUX QUUX", + }), + [], + ); + } + // Invalid highlight specifier + { + const ruleset = new Ruleset(String.raw`@ 1 /example\.net/`); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://www.example.net/" }), + [], + ); + } + }); + + await t.test("If specifier", () => { + { + const ruleset = new Ruleset("*://example.com/* @if(title=~/example/i)"); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ url: "http://example.org/", title: "Example Domain" }), + ); + assert.ok( + !ruleset.test({ + url: "http://example.org/", + snippet: "Example Domain", + }), + ); + } + { + const ruleset = new Ruleset( + "*://example.com/* @if( (title =~ /example/i) )", + ); + assert.ok( + ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + // Space is required before if specifier + { + const ruleset = new Ruleset("*://example.com/*@if(title=~/example/i)"); + assert.ok( + !ruleset.test({ url: "http://example.com/", title: "Example Domain" }), + ); + } + }); + + await t.test("Multiple rules", () => { + { + const ruleset = new Ruleset(`*://example.com/* +@https://example.com/* +@1*://www.example.com/* +@2*://*.example.com/* +@3*://example.com/path +@4http://a.b.example.com/* +@5http://*.b.example.com/*/b/*/ +*://example.com/*`); + assert.deepStrictEqual( + testRaw(ruleset, { url: "https://example.com/" }), + [ + { lineNumber: 1, specifier: null }, + { lineNumber: 2, specifier: { type: "negate" } }, + { lineNumber: 4, specifier: { type: "highlight", colorNumber: 2 } }, + { lineNumber: 8, specifier: null }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://www.example.com/path" }), + [ + { lineNumber: 3, specifier: { type: "highlight", colorNumber: 1 } }, + { lineNumber: 4, specifier: { type: "highlight", colorNumber: 2 } }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://example.com/path" }), + [ + { lineNumber: 1, specifier: null }, + { lineNumber: 4, specifier: { type: "highlight", colorNumber: 2 } }, + { lineNumber: 5, specifier: { type: "highlight", colorNumber: 3 } }, + { lineNumber: 8, specifier: null }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "http://a.b.example.com/a/b/c/" }), + [ + { lineNumber: 4, specifier: { type: "highlight", colorNumber: 2 } }, + { lineNumber: 6, specifier: { type: "highlight", colorNumber: 4 } }, + { lineNumber: 7, specifier: { type: "highlight", colorNumber: 5 } }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "https://example.net/a/b/c/" }), + [], + ); + } + { + const ruleset = new Ruleset(String.raw`@3 /example\.com/ +@2 u/example\.net/ +@1 url/www\.example\.com/ +@ t/example/ +title/domain/i`); + assert.deepStrictEqual( + testRaw(ruleset, { + url: "https://www.example.com", + title: "Example Domain", + }), + [ + { lineNumber: 1, specifier: { type: "highlight", colorNumber: 3 } }, + { lineNumber: 3, specifier: { type: "highlight", colorNumber: 1 } }, + { lineNumber: 5, specifier: null }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { + url: "ftp://ftp.example.net", + title: "ftp example", + }), + [], + ); + } + { + const ruleset = new Ruleset(String.raw` *://*.example.com/*bar* +t/quux$/ + +# Invalid rule +example\.(net|org) + +@2 /^HTTP:\/\//i +@https://example.com/* + +# IPv4 address +/^https?:\/\/(\d{1,3}\.){3}\d{1,3}\//`); + assert.deepStrictEqual( + testRaw(ruleset, { url: "https://example.com/foobar" }), + [ + { lineNumber: 1, specifier: null }, + { lineNumber: 8, specifier: { type: "negate" } }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { + url: "http://www.example.com/hogefuga", + title: "qux quux", + }), + [ + { lineNumber: 2, specifier: null }, + { lineNumber: 7, specifier: { type: "highlight", colorNumber: 2 } }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { + url: "https://127.0.0.1/hoge/fuga/", + title: "qux quux", + }), + [ + { lineNumber: 2, specifier: null }, + { lineNumber: 11, specifier: null }, + ], + ); + assert.deepStrictEqual( + testRaw(ruleset, { url: "ftp://127.0.0.1/", title: "quux qux" }), + [], + ); + } + }); + + await t.test("Extension and deletion", () => { + const ruleset = new Ruleset(""); + const props1 = { url: "https://example.net/path" }; + + ruleset.extend(""); + assert.deepStrictEqual(testRaw(ruleset, props1), []); + + ruleset.extend(`*://example.com/* +@https://example.net/* + @1 /example\.edu/ +*://*.net/*`); + assert.deepStrictEqual(testRaw(ruleset, props1), [ + { lineNumber: 2, specifier: { type: "negate" } }, + { lineNumber: 4, specifier: null }, + ]); + + ruleset.extend(""); + assert.deepStrictEqual(testRaw(ruleset, props1), [ + { lineNumber: 2, specifier: { type: "negate" } }, + { lineNumber: 4, specifier: null }, + ]); + + ruleset.extend(`title/example/i +@2*://*.example.net/path*`); + assert.deepStrictEqual(testRaw(ruleset, props1), [ + { lineNumber: 2, specifier: { type: "negate" } }, + { lineNumber: 4, specifier: null }, + { lineNumber: 6, specifier: { type: "highlight", colorNumber: 2 } }, + ]); + + for (const { lineNumber, specifier } of ruleset.testRaw(props1)) { + if (specifier && specifier.type === "highlight") { + ruleset.delete(lineNumber); + } + } + assert.deepStrictEqual(testRaw(ruleset, props1), [ + { lineNumber: 2, specifier: { type: "negate" } }, + { lineNumber: 4, specifier: null }, + ]); + + const props2 = { + url: "https://example.com/", + title: "**EXAMPLE**", + }; + assert.deepStrictEqual(testRaw(ruleset, props2), [ + { lineNumber: 1, specifier: null }, + { lineNumber: 5, specifier: null }, + ]); + + for (const { lineNumber, specifier } of ruleset.testRaw(props2)) { + if (!specifier) { + ruleset.delete(lineNumber); + } + } + assert.deepStrictEqual(testRaw(ruleset, props2), []); + + ruleset.extend("@*://*/*"); + assert.deepStrictEqual(testRaw(ruleset, props1), [ + { lineNumber: 2, specifier: { type: "negate" } }, + { lineNumber: 4, specifier: null }, + { lineNumber: 7, specifier: { type: "negate" } }, + ]); + assert.deepStrictEqual(testRaw(ruleset, props2), [ + { lineNumber: 7, specifier: { type: "negate" } }, + ]); + }); +}); diff --git a/src/scripts/ruleset/ruleset.ts b/src/scripts/ruleset/ruleset.ts new file mode 100644 index 000000000..6988bc8c0 --- /dev/null +++ b/src/scripts/ruleset/ruleset.ts @@ -0,0 +1,407 @@ +import { DocInput } from "@codemirror/language"; +import { Text } from "@codemirror/state"; +import type { SyntaxNode } from "@lezer/common"; +import yaml from "js-yaml"; +import { z } from "zod"; +import { + MatchPatternMap, + type MatchPatternMapJSON, +} from "../../common/match-pattern.ts"; +import { ruleset } from "./lang.ts"; +import { parser } from "./parser.js"; +import { parseRegExp, parseString } from "./utils.ts"; + +export type RulesetJSON = { + source: string[]; + metadata: Record; + rules: MatchPatternMapJSON; +}; + +export type LinkProps = { + url: string; + [prop: string]: string | undefined; +}; + +export type TestRawResult = { + lineNumber: number; + specifier: Specifier | null; +}[]; + +export type Specifier = + | { type: "negate" } + | { type: "highlight"; colorNumber: number }; + +export class Ruleset implements Iterable { + private source: Text; + private readonly metadata: Record; + private readonly rules: MatchPatternMap; + private readonly deletedLineNumbers: Set = new Set(); + + constructor(input: string | RulesetJSON) { + if (typeof input === "string") { + this.source = Text.of(input.split("\n")); + this.metadata = {}; + this.rules = new MatchPatternMap(); + const tree = ruleset().language.parser.parse(new DocInput(this.source)); + const frontMatterNode = tree.topNode.getChild("Frontmatter"); + if (frontMatterNode) { + // biome-ignore lint/style/noNonNullAssertion: "Frontmatter" always has "Stream" + const streamNode = frontMatterNode.getChild("Stream")!; + const stream = this.source.sliceString(streamNode.from, streamNode.to); + try { + this.metadata = z + .record(z.string(), z.unknown()) + .parse(yaml.load(stream, { schema: yaml.JSON_SCHEMA })); + } catch { + // `YAMLException` or `ZodError` is thrown + } + } + collectRuleset( + // biome-ignore lint/style/noNonNullAssertion: "Document" always has "Ruleset" + tree.topNode.getChild("Ruleset")!, + this.source, + this.rules, + ); + } else { + this.source = Text.of(input.source); + this.metadata = input.metadata; + this.rules = new MatchPatternMap(input.rules); + } + } + + extend(input: string) { + if (!input.length) { + return; + } + if (!this.source.length) { + this.source = Text.of(input.split("\n")); + const tree = parser.parse(new DocInput(this.source)); + collectRuleset(tree.topNode, this.source, this.rules); + return; + } + const from = this.source.length + 1; + this.source = this.source.append(Text.of(["", ...input.split("\n")])); + const to = this.source.length; + const tree = parser.parse(new DocInput(this.source), undefined, [ + { from: 0, to: 0 }, + { from, to }, + ]); + collectRuleset(tree.topNode, this.source, this.rules); + } + + toString(): string { + return [...this].join("\n"); + } + + toJSON(): RulesetJSON { + if (this.deletedLineNumbers.size) { + return new Ruleset(this.toString()).toJSON(); + } + return { + source: this.source.toJSON(), + metadata: this.metadata, + rules: this.rules.toJSON(), + }; + } + + get length(): number { + return this.source.lines - this.deletedLineNumbers.size; + } + + *[Symbol.iterator](): Generator { + for (let n = 1; n <= this.source.lines; ++n) { + if (this.deletedLineNumbers.has(n)) { + continue; + } + yield this.source.line(n).text; + } + } + + get(n: number): string | null { + if (n < 1 || n > this.source.lines || this.deletedLineNumbers.has(n)) { + return null; + } + return this.source.line(n).text; + } + + delete(n: number) { + if (!Number.isInteger(n) || n < 1 || n > this.source.lines) { + return; + } + this.deletedLineNumbers.add(n); + } + + test(props: Readonly): boolean { + const result = this.testRaw(props); + return result.length !== 0 && result.every(({ specifier }) => !specifier); + } + + testRaw(props: Readonly): TestRawResult { + const result: TestRawResult = []; + for (const [lineNumber, value = 1, expression = null] of this.rules.get( + props.url, + )) { + if ( + !this.deletedLineNumbers.has(lineNumber) && + (!expression || execExpression(expression, props)) + ) { + const specifier: Specifier | null = + value === 1 + ? null + : value === 0 + ? { type: "negate" } + : { type: "highlight", colorNumber: -value }; + result.push({ lineNumber, specifier }); + } + } + return result; + } +} + +type Rule = [ + lineNumber: number, + value?: number, + expression?: Expression | null, +]; + +type Expression = + | ["=", string, string] + | ["=i", string, string] + | ["^=", string, string] + | ["^=i", string, string] + | ["$=", string, string] + | ["$=i", string, string] + | ["*=", string, string] + | ["*=i", string, string] + | ["=~", string, PlainRegExp] + | ["!", Expression] + | ["&", Expression, Expression] + | ["|", Expression, Expression]; + +const regExpSymbol = Symbol("RegExp"); + +type PlainRegExp = [pattern: string, flags?: string] & { + [regExpSymbol]?: RegExp; +}; + +function collectRuleset( + rulesetNode: SyntaxNode, + source: Text, + rules: MatchPatternMap, +) { + for (const ruleNode of rulesetNode.getChildren("Rule")) { + if (hasError(ruleNode)) { + continue; + } + const lineNumber = getLineNumber(ruleNode, source); + const value = getValue(ruleNode, source); + const matchPattern = getMatchPattern(ruleNode, source); + let expression: Expression | null; + try { + expression = getExpression(ruleNode, source); + } catch { + // An invalid string literal or regular expression + continue; + } + rules.set( + matchPattern ?? "", + expression + ? [lineNumber, value, expression] + : value !== 1 + ? [lineNumber, value] + : [lineNumber], + ); + } +} + +function hasError(ruleNode: SyntaxNode): boolean { + const cursor = ruleNode.cursor(); + do { + if (cursor.type.isError) { + return true; + } + } while (cursor.next() && cursor.to <= ruleNode.to); + return false; +} + +function getLineNumber(ruleNode: SyntaxNode, source: Text): number { + return source.lineAt(ruleNode.from).number; +} + +function getValue(ruleNode: SyntaxNode, source: Text): number { + if (ruleNode.getChild("NegateSpecifier")) { + return 0; + } + const highlightSpecifierNode = ruleNode.getChild("HighlightSpecifier"); + if (highlightSpecifierNode) { + return -Number( + source.sliceString( + highlightSpecifierNode.from + 1, + highlightSpecifierNode.to, + ), + ); + } + return 1; +} + +function getMatchPattern(ruleNode: SyntaxNode, source: Text): string | null { + const matchPatternNode = ruleNode.getChild("MatchPattern"); + if (matchPatternNode) { + return source.sliceString(matchPatternNode.from, matchPatternNode.to); + } + return null; +} + +function getExpression(ruleNode: SyntaxNode, source: Text): Expression | null { + const ifSpecifierNode = ruleNode.getChild("IfSpecifier"); + if (ifSpecifierNode) { + // biome-ignore lint/style/noNonNullAssertion: "IfSpecifier" always has "Expression" + return collectExpression(ifSpecifierNode.getChild("Expression")!, source); + } + const expressionNode = ruleNode.getChild("Expression"); + if (expressionNode) { + return collectExpression(expressionNode, source); + } + return null; +} + +function collectExpression( + expressionNode: SyntaxNode, + source: Text, +): Expression { + if (expressionNode.name === "MatchExpression") { + const identifierNode = expressionNode.getChild("Identifier"); + let identifier = + identifierNode && + source.sliceString(identifierNode.from, identifierNode.to); + identifier = + identifier == null || identifier === "u" + ? "url" + : identifier === "t" + ? "title" + : identifier; + const stringMatchOperatorNode = expressionNode.getChild( + "StringMatchOperator", + ); + if (stringMatchOperatorNode) { + const operator = source.sliceString( + stringMatchOperatorNode.from, + stringMatchOperatorNode.to, + ) as "=" | "^=" | "$=" | "*="; + // biome-ignore lint/style/noNonNullAssertion: "StringMatchOperator" is always followed by "String" + const stringNode = expressionNode.getChild("String")!; + const string = parseString( + source.sliceString(stringNode.from, stringNode.to), + ); + const caseInsensitive = + expressionNode.getChild("CaseSensitivity") != null; + return [caseInsensitive ? `${operator}i` : operator, identifier, string]; + } + // biome-ignore lint/style/noNonNullAssertion: If "StringMatchOperator" is not present, "RegExp" is present + const regExpNode = expressionNode.getChild("RegExp")!; + const { pattern, flags } = parseRegExp( + source.sliceString(regExpNode.from, regExpNode.to), + ); + return ["=~", identifier, flags ? [pattern, flags] : [pattern]]; + } + if (expressionNode.name === "ParenthesizedExpression") { + // biome-ignore lint/style/noNonNullAssertion: "ParenthesizedExpression" always has "Expression" + return collectExpression(expressionNode.getChild("Expression")!, source); + } + if (expressionNode.name === "NegateExpression") { + return [ + "!", + // biome-ignore lint/style/noNonNullAssertion: "NegateExpression" always has "Expression" + collectExpression(expressionNode.getChild("Expression")!, source), + ]; + } + if (expressionNode.name === "AndExpression") { + const [leftNode, rightNode] = expressionNode.getChildren("Expression"); + return [ + "&", + collectExpression(leftNode, source), + collectExpression(rightNode, source), + ]; + } + { + // "OrExpression" + const [leftNode, rightNode] = expressionNode.getChildren("Expression"); + return [ + "|", + collectExpression(leftNode, source), + collectExpression(rightNode, source), + ]; + } +} + +function execExpression( + expression: Expression, + props: Readonly, +): boolean { + if (expression[0] === "=") { + const prop = props[expression[1]]; + return prop != null && prop === expression[2]; + } + if (expression[0] === "=i") { + const prop = props[expression[1]]; + return prop != null && prop.toLowerCase() === expression[2].toLowerCase(); + } + if (expression[0] === "^=") { + const prop = props[expression[1]]; + // biome-ignore lint/complexity/useOptionalChain: Return a boolean value + return prop != null && prop.startsWith(expression[2]); + } + if (expression[0] === "^=i") { + const prop = props[expression[1]]; + return ( + // biome-ignore lint/complexity/useOptionalChain: Return a boolean value + prop != null && prop.toLowerCase().startsWith(expression[2].toLowerCase()) + ); + } + if (expression[0] === "$=") { + const prop = props[expression[1]]; + // biome-ignore lint/complexity/useOptionalChain: Return a boolean value + return prop != null && prop.endsWith(expression[2]); + } + if (expression[0] === "$=i") { + const prop = props[expression[1]]; + return ( + // biome-ignore lint/complexity/useOptionalChain: Return a boolean value + prop != null && prop.toLowerCase().endsWith(expression[2].toLowerCase()) + ); + } + if (expression[0] === "*=") { + const prop = props[expression[1]]; + // biome-ignore lint/complexity/useOptionalChain: Return a boolean value + return prop != null && prop.includes(expression[2]); + } + if (expression[0] === "*=i") { + const prop = props[expression[1]]; + return ( + // biome-ignore lint/complexity/useOptionalChain: Return a boolean value + prop != null && prop.toLowerCase().includes(expression[2].toLowerCase()) + ); + } + if (expression[0] === "=~") { + const prop = props[expression[1]]; + return prop != null && plainRegExpTest(expression[2], prop); + } + if (expression[0] === "!") { + return !execExpression(expression[1], props); + } + if (expression[0] === "&") { + return ( + execExpression(expression[1], props) && + execExpression(expression[2], props) + ); + } + // "|" + return ( + execExpression(expression[1], props) || execExpression(expression[2], props) + ); +} + +function plainRegExpTest(regExp: PlainRegExp, string: string): boolean { + regExp[regExpSymbol] ||= new RegExp(regExp[0], regExp[1] ?? ""); + return regExp[regExpSymbol].test(string); +} diff --git a/src/scripts/ruleset/utils.ts b/src/scripts/ruleset/utils.ts new file mode 100644 index 000000000..3c5202ad3 --- /dev/null +++ b/src/scripts/ruleset/utils.ts @@ -0,0 +1,48 @@ +export function parseString(string: string): string { + return string + .slice(1, -1) + .replaceAll( + /\\(?:0[0-9]?|u(?:[0-9A-Fa-f]{4}|\{(?:10[0-9A-Fa-f]{4}|0[0-9A-Fa-f]{5}|[0-9A-Fa-f]{1,5})\})?|x(?:[0-9A-Fa-f]{2})?|.)/g, + (s: string): string => { + // biome-ignore lint/style/noNonNullAssertion: The regular expression guarantees that `s` has at least two characters + const c = s[1]!; + if (c === "0") { + if (s.length > 2) { + throw new SyntaxError("Deprecated octal escape sequence"); + } + // "\0" + return "\0"; + } + if (c === "u") { + if (s.length === 2) { + throw new SyntaxError("Invalid Unicode escape sequence"); + } + if (s[2] === "{") { + // "\u{2F804}" + return String.fromCodePoint(Number.parseInt(s.slice(3, -1), 16)); + } + // "\u00A9" + return String.fromCodePoint(Number.parseInt(s.slice(2), 16)); + } + if (c === "x") { + if (s.length === 2) { + throw new SyntaxError("Invalid hexadecimal escape sequence"); + } + // "\xA9" + return String.fromCodePoint(Number.parseInt(s.slice(2), 16)); + } + return { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" }[c] ?? c; + }, + ); +} + +export function parseRegExp(regExp: string): { + pattern: string; + flags: string; +} { + const patternEnd = regExp.lastIndexOf("/"); + const pattern = regExp.slice(1, patternEnd); + const flags = regExp.slice(patternEnd + 1); + new RegExp(pattern, flags); + return { pattern, flags }; +} diff --git a/src/scripts/search-engines/bing-desktop.ts b/src/scripts/search-engines/bing-desktop.ts index b9cc8dd20..76af0e7a7 100644 --- a/src/scripts/search-engines/bing-desktop.ts +++ b/src/scripts/search-engines/bing-desktop.ts @@ -70,6 +70,10 @@ const serpHandlers: Readonly> = { }, }, ], + pageProps: { + $site: "bing", + $category: "web", + }, }), "/images/search": handleSerp({ globalStyle: { @@ -177,6 +181,10 @@ const serpHandlers: Readonly> = { innerTargets: ".dg_b, .infsd, .lnkw", }, ], + pageProps: { + $site: "bing", + $category: "images", + }, }), "/videos/search": handleSerp({ globalStyle: { @@ -241,6 +249,10 @@ const serpHandlers: Readonly> = { innerTargets: "#vm_res, .dg_u", }, ], + pageProps: { + $site: "bing", + $category: "videos", + }, }), "/news/search": handleSerp({ globalStyle, @@ -276,6 +288,10 @@ const serpHandlers: Readonly> = { innerTargets: ".source", }, ], + pageProps: { + $site: "bing", + $category: "news", + }, }), }; diff --git a/src/scripts/search-engines/bing-mobile.ts b/src/scripts/search-engines/bing-mobile.ts index f9e8d5b8d..df7fa6aac 100644 --- a/src/scripts/search-engines/bing-mobile.ts +++ b/src/scripts/search-engines/bing-mobile.ts @@ -68,6 +68,10 @@ const serpHandlers: Readonly> = { }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "bing", + $category: "web", + }, }), "/images/search": handleSerp({ globalStyle: applyGlobalStyle({ @@ -132,6 +136,10 @@ const serpHandlers: Readonly> = { }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "bing", + $category: "images", + }, }), "/videos/search": handleSerp({ globalStyle: applyGlobalStyle({ @@ -182,6 +190,10 @@ const serpHandlers: Readonly> = { }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "bing", + $category: "videos", + }, }), "/news/search": handleSerp({ globalStyle: applyGlobalStyle({ @@ -228,6 +240,10 @@ const serpHandlers: Readonly> = { }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "bing", + $category: "news", + }, }), }; diff --git a/src/scripts/search-engines/brave.ts b/src/scripts/search-engines/brave.ts index 98a570f26..bd1cba5eb 100644 --- a/src/scripts/search-engines/brave.ts +++ b/src/scripts/search-engines/brave.ts @@ -39,6 +39,9 @@ function getSerpHandler(): SerpHandler { actionStyle: { fontSize: "var(--text-sm-2)", }, + props: { + $category: "web", + }, }, // Images { @@ -55,6 +58,9 @@ function getSerpHandler(): SerpHandler { actionStyle: { fontSize: "var(--text-sm-2)", }, + props: { + $category: "images", + }, }, // Videos { @@ -65,6 +71,9 @@ function getSerpHandler(): SerpHandler { actionStyle: { fontSize: "var(--text-sm-2)", }, + props: { + $category: "videos", + }, }, // News { @@ -75,9 +84,15 @@ function getSerpHandler(): SerpHandler { actionStyle: { fontSize: "var(--text-sm-2)", }, + props: { + $category: "news", + }, }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "brave", + }, }); } diff --git a/src/scripts/search-engines/duckduckgo.ts b/src/scripts/search-engines/duckduckgo.ts index 7611f5fc1..c73131112 100644 --- a/src/scripts/search-engines/duckduckgo.ts +++ b/src/scripts/search-engines/duckduckgo.ts @@ -140,6 +140,9 @@ const serpHandler = handleSerp({ order: 3, }, actionButtonStyle: "result__a", + props: { + $category: "web", + }, }, // Images { @@ -152,6 +155,9 @@ const serpHandler = handleSerp({ lineHeight: "1.5", }, actionButtonStyle: "result__a", + props: { + $category: "images", + }, }, // Videos { @@ -165,6 +171,9 @@ const serpHandler = handleSerp({ margin: "0.4em 0 -0.4em", }, actionButtonStyle: "result__a", + props: { + $category: "videos", + }, }, // News { @@ -184,6 +193,9 @@ const serpHandler = handleSerp({ }, }, actionButtonStyle: "result__a", + props: { + $category: "news", + }, }, // News Cards on the main page { @@ -217,6 +229,9 @@ const serpHandler = handleSerp({ }), ); }, + props: { + $category: "web", + }, }, ], pagerHandlers: [ @@ -226,6 +241,9 @@ const serpHandler = handleSerp({ }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "duckduckgo", + }, }); const htmlSerpHandler = handleSerp({ @@ -263,6 +281,10 @@ const htmlSerpHandler = handleSerp({ }, }, ], + pageProps: { + $site: "duckduckgo", + $category: "web", + }, }); const liteSerpHandler = handleSerp({ @@ -324,6 +346,10 @@ const liteSerpHandler = handleSerp({ }, }, ], + pageProps: { + $site: "duckduckgo", + $category: "web", + }, }); export const duckduckgo: Readonly = { diff --git a/src/scripts/search-engines/ecosia.ts b/src/scripts/search-engines/ecosia.ts index 46224af90..870650a1f 100644 --- a/src/scripts/search-engines/ecosia.ts +++ b/src/scripts/search-engines/ecosia.ts @@ -42,6 +42,10 @@ function getSerpHandler(): SerpHandler { }, }, ], + pageProps: { + $site: "ecosia", + $category: "web", + }, }); } diff --git a/src/scripts/search-engines/google-desktop.ts b/src/scripts/search-engines/google-desktop.ts index 72254c0c8..215d8fdb6 100644 --- a/src/scripts/search-engines/google-desktop.ts +++ b/src/scripts/search-engines/google-desktop.ts @@ -593,6 +593,10 @@ const desktopSerpHandlers: Record = { innerTargets: ".I48dHb, .GHMsie, .ZHugbd", }, ], + pageProps: { + $site: "google", + $category: "web", + }, }), // Books bks: handleSerp({ @@ -608,6 +612,10 @@ const desktopSerpHandlers: Record = { actionStyle: desktopRegularActionStyle, }, ], + pageProps: { + $site: "google", + $category: "books", + }, }), // Images "udm=2": handleSerp({ @@ -639,6 +647,10 @@ const desktopSerpHandlers: Record = { }, }, ], + pageProps: { + $site: "google", + $category: "images", + }, }), isch: handleSerp({ globalStyle: desktopGlobalStyle, @@ -681,6 +693,10 @@ const desktopSerpHandlers: Record = { }, }, ], + pageProps: { + $site: "google", + $category: "images", + }, }), // News nws: handleSerp({ @@ -739,6 +755,10 @@ const desktopSerpHandlers: Record = { innerTargets: ".SoaBEf, .JJZKK, .ZE0LJd, .S1FAPd", }, ], + pageProps: { + $site: "google", + $category: "news", + }, }), // Videos vid: handleSerp({ @@ -760,6 +780,10 @@ const desktopSerpHandlers: Record = { innerTargets: ".g", }, ], + pageProps: { + $site: "google", + $category: "videos", + }, }), }; diff --git a/src/scripts/search-engines/google-mobile.ts b/src/scripts/search-engines/google-mobile.ts index eb0cd2b40..72b0d2a0c 100644 --- a/src/scripts/search-engines/google-mobile.ts +++ b/src/scripts/search-engines/google-mobile.ts @@ -281,6 +281,10 @@ const mobileSerpHandlers: Record = { innerTargets: ".xpd, .tRkSqb", }, ], + pageProps: { + $site: "google", + $category: "web", + }, }), // Books bks: handleSerp({ @@ -301,6 +305,10 @@ const mobileSerpHandlers: Record = { actionStyle: mobileRegularActionStyle, }, ], + pageProps: { + $site: "google", + $category: "books", + }, }), // Images isch: handleSerp({ @@ -371,6 +379,10 @@ const mobileSerpHandlers: Record = { }, }, ], + pageProps: { + $site: "google", + $category: "images", + }, }), "udm=2": handleSerp({ globalStyle: mobileGlobalStyle, @@ -510,6 +522,10 @@ const mobileSerpHandlers: Record = { }, }, ], + pageProps: { + $site: "google", + $category: "news", + }, }), // Videos vid: handleSerp({ @@ -572,6 +588,10 @@ const mobileSerpHandlers: Record = { innerTargets: ".mnr-c", }, ], + pageProps: { + $site: "google", + $category: "videos", + }, }), }; diff --git a/src/scripts/search-engines/helpers.ts b/src/scripts/search-engines/helpers.ts index ce27ef25d..ac4c49c5b 100644 --- a/src/scripts/search-engines/helpers.ts +++ b/src/scripts/search-engines/helpers.ts @@ -130,6 +130,9 @@ export type EntryHandler = { | ((target: HTMLElement) => HTMLElement | null); actionStyle?: string | CSSAttribute | ((actionRoot: HTMLElement) => void); actionButtonStyle?: string | CSSAttribute | ((button: HTMLElement) => void); + props?: + | Record + | ((root: HTMLElement) => Record); }; export type PagerHandler = { @@ -158,10 +161,12 @@ export function handleSerpElement({ controlHandlers, entryHandlers, pagerHandlers = [], + pageProps = {}, }: { controlHandlers: ControlHandler[]; entryHandlers: EntryHandler[]; pagerHandlers?: PagerHandler[]; + pageProps?: Record<`$${string}`, string>; }): (element: HTMLElement) => SerpHandlerResult { const entryRoots = new WeakSet(); const onSerpElement = (element: HTMLElement): SerpHandlerResult => { @@ -214,6 +219,7 @@ export function handleSerpElement({ actionPosition = "beforeend", actionStyle, actionButtonStyle, + props = {}, } of entryHandlers) { if ( !(typeof target === "string" @@ -290,10 +296,12 @@ export function handleSerpElement({ ? handleRender(entryActionRoot, actionButtonStyle) : null, props: { - url: entryAltURL, - title: entryTitle, + ...pageProps, + url: entryAltURL.toString(), + ...(entryTitle != null ? { title: entryTitle } : {}), + ...(typeof props === "object" ? props : props(entryRoot)), }, - state: -1, + state: null, }); entryRoots.add(entryRoot); } @@ -330,6 +338,7 @@ export function handleSerp({ getDialogTheme = () => "light", observeRemoval = false, delay = 0, + pageProps = {}, }: { globalStyle: CSSAttribute | ((colors: SerpColors) => void); targets?: () => HTMLElement[]; @@ -339,6 +348,7 @@ export function handleSerp({ getDialogTheme?: () => DialogTheme; observeRemoval?: boolean; delay?: number; + pageProps?: Record<`$${string}`, string>; }): SerpHandler { if (!targets) { const selectors: string[] = []; @@ -355,6 +365,7 @@ export function handleSerp({ controlHandlers, entryHandlers, pagerHandlers, + pageProps: pageProps, }); return { onSerpStart: handleSerpStart({ targets, onSerpElement }), diff --git a/src/scripts/search-engines/kagi.ts b/src/scripts/search-engines/kagi.ts index c3cb9b664..a2c697f0a 100644 --- a/src/scripts/search-engines/kagi.ts +++ b/src/scripts/search-engines/kagi.ts @@ -31,6 +31,12 @@ function getSerpHandler(): SerpHandler { title: "._ext_t", actionTarget: "._ext_a", actionStyle: "_ub_act_btn_box", + props: () => { + const subdirectory = new URL(location.href).pathname.split("/")[1]; + return { + $category: subdirectory === "search" ? "web" : subdirectory, + }; + }, }, ], pagerHandlers: [ @@ -40,6 +46,9 @@ function getSerpHandler(): SerpHandler { }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "kagi", + }, }); } diff --git a/src/scripts/search-engines/searx.ts b/src/scripts/search-engines/searx.ts index 90492b6a2..204856c68 100644 --- a/src/scripts/search-engines/searx.ts +++ b/src/scripts/search-engines/searx.ts @@ -74,6 +74,9 @@ function getSerpHandler(): SerpHandler { }, fontSize: "1rem", }, + props: { + $category: "web", + }, }, // Images { @@ -87,6 +90,9 @@ function getSerpHandler(): SerpHandler { position: "absolute", padding: "3.1rem 0 0 0", }, + props: { + $category: "images", + }, }, // Videos { @@ -101,6 +107,9 @@ function getSerpHandler(): SerpHandler { }, fontSize: "1rem", }, + props: { + $category: "videos", + }, }, // Maps { @@ -115,6 +124,9 @@ function getSerpHandler(): SerpHandler { }, fontSize: "1rem", }, + props: { + $category: "map", + }, }, // Files { @@ -129,9 +141,15 @@ function getSerpHandler(): SerpHandler { }, fontSize: "1rem", }, + props: { + $category: "files", + }, }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "searx", + }, }); } diff --git a/src/scripts/search-engines/startpage.ts b/src/scripts/search-engines/startpage.ts index f56f4bbca..93e6a2659 100644 --- a/src/scripts/search-engines/startpage.ts +++ b/src/scripts/search-engines/startpage.ts @@ -43,6 +43,9 @@ function getSerpHandler(): SerpHandler { display: "block", marginTop: "4px", }, + props: { + $category: "web", + }, }, // News { @@ -55,6 +58,9 @@ function getSerpHandler(): SerpHandler { fontSize: "16px", marginTop: "4px", }, + props: { + $category: "news", + }, }, // Videos { @@ -71,10 +77,16 @@ function getSerpHandler(): SerpHandler { marginTop: "4px", }); }, + props: { + $category: "videos", + }, }, ], getDialogTheme: () => hasDarkBackground(document.documentElement) ? "dark" : "light", + pageProps: { + $site: "startpage", + }, }); } diff --git a/src/scripts/search-engines/yahoo-japan-desktop.ts b/src/scripts/search-engines/yahoo-japan-desktop.ts index b7e22c66c..5db8aa16c 100644 --- a/src/scripts/search-engines/yahoo-japan-desktop.ts +++ b/src/scripts/search-engines/yahoo-japan-desktop.ts @@ -35,6 +35,10 @@ const webHandler = handleSerp({ }, }, ], + pageProps: { + $site: "yahooJapan", + $category: "web", + }, }); const handlers: Readonly> = { diff --git a/src/scripts/search-engines/yahoo-japan-mobile.ts b/src/scripts/search-engines/yahoo-japan-mobile.ts index a65f01bd7..43adeee44 100644 --- a/src/scripts/search-engines/yahoo-japan-mobile.ts +++ b/src/scripts/search-engines/yahoo-japan-mobile.ts @@ -30,6 +30,10 @@ const webHandler = handleSerp({ }, }, ], + pageProps: { + $site: "yahooJapan", + $category: "web", + }, }); const handlers: Readonly> = { diff --git a/src/scripts/search-engines/yandex.ts b/src/scripts/search-engines/yandex.ts index c9cc98cf9..d2b26a014 100644 --- a/src/scripts/search-engines/yandex.ts +++ b/src/scripts/search-engines/yandex.ts @@ -42,6 +42,10 @@ const serpHandler: SerpHandler = handleSerp({ }, ], getDialogTheme: getDialogThemeFromBody(), + pageProps: { + $site: "yandex", + $category: "web", + }, }); export const yandex: Readonly = { diff --git a/src/scripts/types.ts b/src/scripts/types.ts index 3675a51df..9de43cdd4 100644 --- a/src/scripts/types.ts +++ b/src/scripts/types.ts @@ -1,7 +1,8 @@ import type dayjs from "dayjs"; import type { MessageName0 } from "../common/locales.ts"; import type { SearchEngine as _SearchEngine } from "../common/search-engines.ts"; -import type { AltURL } from "./utilities.ts"; +import type { QueryResult } from "./interactive-ruleset.ts"; +import type { LinkProps } from "./ruleset/ruleset.ts"; export type { MessageName, @@ -75,8 +76,11 @@ export type CloudToken = { // #endregion Clouds // #region LocalStorage +export type PlainRuleset = { metadata: Record; rules: string }; + export type LocalStorageItems = { - // blocklist + // ruleset + ruleset: PlainRuleset | false; blacklist: string; compiledRules: string | false; @@ -115,7 +119,7 @@ export type LocalStorageItemsFor< export type LocalStorageItemsSavable = Omit< LocalStorageItems, - "compiledRules" | "syncCloudId" | "syncResult" | "subscriptions" + "ruleset" | "compiledRules" | "syncCloudId" | "syncResult" | "subscriptions" >; export type SaveSource = "content-script" | "popup" | "options" | "background"; @@ -150,18 +154,13 @@ export type SerpControl = { onRender: (() => void) | null; }; -export type SerpEntryProps = { - url: AltURL; - title: string | null; -}; - export type SerpEntry = { scope: string; root: HTMLElement; actionRoot: HTMLElement; onActionRender: (() => void) | null; - props: SerpEntryProps; - state: number; + props: LinkProps; + state: QueryResult | null; }; export type SerpHandlerResult = { @@ -197,6 +196,7 @@ export type SubscriptionId = number; export type Subscription = { name: string; url: string; + ruleset?: PlainRuleset; blacklist: string; compiledRules?: string; updateResult: Result | false | null; diff --git a/src/scripts/utilities.ts b/src/scripts/utilities.ts index 4823c7043..d57bd70db 100644 --- a/src/scripts/utilities.ts +++ b/src/scripts/utilities.ts @@ -1,6 +1,11 @@ import dayjs from "dayjs"; -import { parseMatchPattern } from "../common/match-pattern.ts"; -import type { ErrorResult, Result, SuccessResult } from "./types.ts"; +import { Ruleset, type RulesetJSON } from "./ruleset/ruleset.ts"; +import type { + ErrorResult, + PlainRuleset, + Result, + SuccessResult, +} from "./types.ts"; // #region AltURL export class AltURL { @@ -48,109 +53,6 @@ export class UnexpectedResponse extends Error { } // #endregion Error -// #region MatchPattern -export { - type MatchPatternScheme, - parseMatchPattern, -} from "../common/match-pattern.ts"; - -type SchemePattern = { type: "any" } | { type: "exact"; exact: string }; - -type HostPattern = - | { type: "any" } - | { type: "domain"; domain: string } - | { type: "exact"; exact: string }; - -type PathPattern = - | { type: "any" } - | { type: "prefix"; prefix: string } - | { type: "exact"; exact: string } - | { type: "regExp"; regExp: RegExp }; - -export class MatchPattern { - private readonly schemePattern: SchemePattern; - private readonly hostPattern: HostPattern; - private readonly pathPattern: PathPattern; - - constructor(mp: string) { - const parsed = parseMatchPattern(mp); - if (!parsed) { - throw new Error("Invalid match pattern"); - } - const { scheme, host, path } = parsed; - if (scheme === "*") { - this.schemePattern = { type: "any" }; - } else { - this.schemePattern = { type: "exact", exact: scheme }; - } - if (host === "*") { - this.hostPattern = { type: "any" }; - } else if (host.startsWith("*.")) { - this.hostPattern = { type: "domain", domain: host.slice(2) }; - } else { - this.hostPattern = { type: "exact", exact: host }; - } - if (path === "/*") { - this.pathPattern = { type: "any" }; - } else { - const wildcardIndex = path.indexOf("*"); - if (wildcardIndex === path.length - 1) { - this.pathPattern = { type: "prefix", prefix: path.slice(0, -1) }; - } else if (wildcardIndex === -1) { - this.pathPattern = { type: "exact", exact: path }; - } else { - this.pathPattern = { - type: "regExp", - regExp: new RegExp( - `^${path - .replace(/[$^\\.+?()[\]{}|]/g, "\\$&") - .replace(/\*/g, ".*?")}$`, - ), - }; - } - } - } - - test(url: AltURL): boolean { - if (this.hostPattern.type === "domain") { - if ( - url.host !== this.hostPattern.domain && - !url.host.endsWith(`.${this.hostPattern.domain}`) - ) { - return false; - } - } else if (this.hostPattern.type === "exact") { - if (url.host !== this.hostPattern.exact) { - return false; - } - } - if (this.schemePattern.type === "any") { - if (url.scheme !== "http" && url.scheme !== "https") { - return false; - } - } else { - if (url.scheme !== this.schemePattern.exact) { - return false; - } - } - if (this.pathPattern.type === "prefix") { - if (!url.path.startsWith(this.pathPattern.prefix)) { - return false; - } - } else if (this.pathPattern.type === "exact") { - if (url.path !== this.pathPattern.exact) { - return false; - } - } else if (this.pathPattern.type === "regExp") { - if (!this.pathPattern.regExp.test(url.path)) { - return false; - } - } - return true; - } -} -// #endregion MatchPattern - // #region Mutex export class Mutex { private queue: (() => Promise)[] = []; @@ -238,12 +140,6 @@ export function numberEntries( export function lines(s: string): string[] { return s ? s.split("\n") : []; } - -export function unlines(ss: string[]): string { - return ss.join("\n"); -} - -export const r = String.raw.bind(String); // #endregion string export function downloadTextFile( @@ -292,3 +188,23 @@ export function parseJSON(text: string): string | undefined { return undefined; } } + +export function fromPlainRuleset( + ruleset: PlainRuleset | null, + source: string, +): Ruleset { + return new Ruleset( + ruleset + ? { + source: source.split("\n"), + metadata: ruleset.metadata, + rules: JSON.parse(ruleset.rules) as RulesetJSON["rules"], + } + : source, + ); +} + +export function toPlainRuleset(source: string): PlainRuleset { + const { metadata, rules } = new Ruleset(source).toJSON(); + return { metadata, rules: JSON.stringify(rules) }; +} diff --git a/src/scripts/watch.ts b/src/scripts/watch.ts index a71a313e7..0e4941cdd 100644 --- a/src/scripts/watch.ts +++ b/src/scripts/watch.ts @@ -30,4 +30,4 @@ async function main() { }; } -await main(); +void main(); diff --git a/src/third-party-notices.txt b/src/third-party-notices.txt index 89003462b..c1fd988d7 100644 --- a/src/third-party-notices.txt +++ b/src/third-party-notices.txt @@ -23,6 +23,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +@codemirror/lang-yaml + +MIT License + +Copyright (C) 2024 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + @codemirror/language MIT License @@ -48,6 +73,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +@codemirror/lint + +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + @codemirror/state MIT License @@ -98,6 +148,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +@lezer/common + +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + @lezer/highlight MIT License @@ -123,6 +198,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +@lezer/lr + +MIT License + +Copyright (C) 2018 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + @mdi/svg Pictogrammers Free License @@ -247,6 +347,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +js-yaml + +(The MIT License) + +Copyright (C) 2011-2015 by Vitaly Puzrin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + punycode Copyright Mathias Bynens diff --git a/tsconfig.json b/tsconfig.json index 4e73840a2..315185f4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "allowImportingTsExtensions": true, + "allowJs": true, "exactOptionalPropertyTypes": true, "jsx": "react-jsx", "module": "ESNext", @@ -11,5 +12,5 @@ "target": "ESNext", "verbatimModuleSyntax": true }, - "exclude": ["website"] + "exclude": ["dist", "website"] } diff --git a/website/docs/advanced-features.md b/website/docs/advanced-features.md index db1d8ab51..37538ba8a 100644 --- a/website/docs/advanced-features.md +++ b/website/docs/advanced-features.md @@ -9,7 +9,7 @@ You can edit rules to block sites in the options page, as well as in the "Block ![rule editor](/img/advanced-features/rules-1.png) -You can write rules by [match patterns](#match-patterns) or [regular expressions](#regular-expressions). +You can write rules by [match patterns](#match-patterns) or [expressions](#expressions). ### Match patterns {#match-patterns} @@ -25,16 +25,93 @@ Here are examples of **valid** match patterns. Here are examples of **invalid** match patterns. -| Invalid pattern | Reason | -| ---------------------- | --------------------------------------------------------------------------------- | -| `*://www.qinterest.*/` | `*` is not at the start. Use [regular expressions](#regular-expressions) instead. | -| `` | Not supported. | +| Invalid pattern | Reason | +| ---------------------- | ------------------------ | +| `*://www.qinterest.*/` | `*` is not at the start. | -### Regular expressions {#regular-expressions} +### Expressions {#expressions} -You can write more flexible rules by [regular expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). +You can write rules by expressions. -Note that regular expression rules shall be regular expression **literals** in JavaScript, surrounded by `/` (e.g. `/example\.(net|org)/`). +#### Variables {#variables} + +Currently, `url` and `title` are available in expressions. + +``` +# Search results which URLs include "example" +url *= "example" + +# Search results which titles start with "Something" +title ^= "Something" +``` + +Parts of URL are also available: `scheme`, `host` and `path`. + +``` +# Search results which schemes are HTTP +scheme="http" + +# Search results which hosts end with ".example.com" +host $= ".example.com" + +# Search results which paths include "blah", case-insensitive +path*="blah"i +``` + +In addition, properties of search engine result pages are available. `$site` and `$category` are available for now. + +``` +# Block YouTube on Google Search +$site="google" & host="youtube.com" + +# Block Amazon.com on image search +$category = "images" & host = "www.amazon.com" +``` + +#### String matchers {#string-matchers} + +String matchers resemble [CSS attribute selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors). + +``` +# Titles which are exactly "Example Domain" +title = "Example Domain" + +# Titles which start with "Example" +title ^= "Example" + +# Titles which end with "Domain" +title $= "Domain" + +# Titles which include "ple Dom" +title *= "ple Dom" +``` + +To perform case-insensitive comparison, use the `i` operator. + +``` +# Titles which end with "domain", case-insensitive +title $= "domain" i +``` + +#### Regular expressions {#regular-expressions} + +You can write more flexible expressions by [regular expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). + +``` +# URLs which include "example.net" or "example.org" +url =~ /example\.(net|org)/ + +# "=~" can be omitted +url/example\.(net|org)/ + +# "url" can be omitted +/example\.(net|org)/ + +# Titles which include "example domain", case-insensitive +title =~ /example domain/i +``` + +Note that regular expressions shall be regular expression **literals** in JavaScript, surrounded by `/` (e.g. `/example\.(net|org)/`). Here are examples of **valid** regular expressions. @@ -50,11 +127,29 @@ Here are examples of **invalid** regular expressions. | `^https?:\/\/example\.com\/` | Not surrounded by `/`. | | `/^https?://example\.com//` | Inner `/` are not escaped. | -### Regular expressions for page titles {#regular-expressions-for-page-titles} +#### Logical operators {#logical-operators} -To block sites with specific titles, use regular expressions preceded by `title`. +Logical "not" (`!`), "and" (`&`), and "or" (`|`) are supported. -For example, `title/example domain/i` blocks sites which titles include "example domain" in a case-insensitive manner. +``` +# Block schemes other than HTTPS +!scheme="https" + +# Block Amazon.com on image search +$category = "images" & host = "www.amazon.com" + +# Titles including "example" or "domain", case-insensitive +title *= "example" i | title *= "domain" i +``` + +### Use expressions with match patterns {#use-expressions-with-match-patterns} + +You can append `@if(expression)` to match patterns. + +``` +# Block Amazon.com on image search +*://*.amazon.com/* @if($category="images") +``` ### Unblock rules {#unblock-rules} @@ -162,7 +257,7 @@ The ruleset is saved in the `/Apps/uBlacklist/` folder on your Dropbox. The fold ## Subscription {#subscription} -You can subscribe to public rulesets. +You can subscribe to public rulesets. A list of known public subscription lists can be found on the [Subscriptions](/subscriptions) page. To add a subscription, click the "Add subscription" button and enter the name and URL. You will be required to permit access to the origin of the URL. @@ -172,12 +267,19 @@ You can show, update or remove a subscription. ![manage subscription](/img/advanced-features/subscription-2.png) -### Publish a subscription {#publish-a-subscription} +### Publish a subscription {#publish-subscription} 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. -It is a good idea to host your subscription on GitHub. Make sure that you publish the **raw** URL (e.g. https://raw.githubusercontent.com/iorate/ublacklist-example-subscription/master/uBlacklist.txt). +You can prepend YAML frontmatter to your ruleset. It is recommended that you set the `name` variable. -A list of known public subscription lists can be found on the [Subscriptions](/subscriptions) page. +``` +--- +name: Your ruleset name +--- +*://*.example.com/* +``` + +It is a good idea to host your subscription on GitHub. Make sure that you publish the **raw** URL (e.g. https://raw.githubusercontent.com/iorate/ublacklist-example-subscription/master/uBlacklist.txt). ![raw url](/img/advanced-features/subscription-3.png) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 598035f8d..5515681e2 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -78,6 +78,9 @@ const config: Config = { theme: prismThemes.github, darkTheme: prismThemes.dracula, }, + tableOfContents: { + maxHeadingLevel: 4, + }, } satisfies Preset.ThemeConfig, }; diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-features.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-features.md index 28b2506c9..93538d0da 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-features.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-features.md @@ -9,7 +9,7 @@ sidebar_position: 2 ![ルールエディター](/img/advanced-features/rules-1.png) -[マッチパターン](#match-patterns)または[正規表現](#regular-expressions)でルールを書くことができます。 +[マッチパターン](#match-patterns)または[式](#expressions)でルールを書くことができます。 ### マッチパターン {#match-patterns} @@ -25,16 +25,93 @@ sidebar_position: 2 **無効な**マッチパターンの例です。 -| 無効なパターン | 理由 | -| ---------------------- | ------------------------------------------------------------------------------------------ | -| `*://www.qinterest.*/` | `*` が先頭以外に置かれています。代わりに[正規表現](#regular-expressions)を使ってください。 | -| `` | サポートされていません。 | +| 無効なパターン | 理由 | +| ---------------------- | -------------------------------- | +| `*://www.qinterest.*/` | `*` が先頭以外に置かれています。 | -### 正規表現 {#regular-expressions} +### 式 {#expressions} -[正規表現](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_Expressions)を使ってより柔軟にルールを書くことができます。 +式でルールを書くことができます。 -正規表現ルールは、`/` で囲まれた、JavaScript の正規表現**リテラル**の形でなければなりません (例: `/example\.(net|org)/`)。 +#### 変数 {#variables} + +現在、`url` と `title` が式の中で使用できます。 + +``` +# URL が "example" を含む検索結果 +url *= "example" + +# タイトルが "Something" で始まる検索結果 +title ^= "Something" +``` + +URL の一部である `scheme`、`host`、`path` も使用できます。 + +``` +# スキームが HTTP の検索結果 +scheme="http" + +# ホスト名が ".example.com" で終わる検索結果 +host $= ".example.com" + +# パスが "blah" を含む検索結果 (大文字小文字を区別しない) +path*="blah"i +``` + +さらに、検索結果ページ自体のプロパティを使用できます。今のところ、`$site` と `$category` が使用できます。 + +``` +# Google 検索で YouTube をブロックする +$site="google" & host="youtube.com" + +# 画像検索で Amazon.com をブロックする +$category = "images" & host = "www.amazon.com" +``` + +#### 文字列マッチ {#string-matchers} + +文字列マッチは [CSS の属性セレクター](https://developer.mozilla.org/ja/docs/Web/CSS/Attribute_selectors) に似ています。 + +``` +# タイトルが "Example Domain" と一致 +title = "Example Domain" + +# タイトルが "Example" から始まる +title ^= "Example" + +# タイトルが "Domain" で終わる +title $= "Domain" + +# タイトルが "ple Dom" を含む +title *= "ple Dom" +``` + +大文字小文字を区別しない比較を行うには、`i` を追加します。 + +``` +# タイトルが "domain" で終わる (大文字小文字を区別しない) +title $= "domain" i +``` + +#### 正規表現 {#regular-expressions} + +[正規表現](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_Expressions)を使ってより柔軟に式を書くことができます。 + +``` +# URL が "example.net" または "example.org" を含む +url =~ /example\.(net|org)/ + +# "=~" は省略できます +url/example\.(net|org)/ + +# "url" も省略できます +/example\.(net|org)/ + +# タイトルが "example domain" を含む (大文字小文字を区別しない) +title =~ /example domain/i +``` + +正規表現は、`/` で囲まれた、JavaScript の正規表現**リテラル**の形でなければなりません (例: `/example\.(net|org)/`)。 **有効な**正規表現の例です。 @@ -50,11 +127,29 @@ sidebar_position: 2 | `^https?:\/\/example\.com\/` | `/` で囲まれていません。 | | `/^https?://example\.com//` | 正規表現内の `/` がエスケープされていません。 | -### 正規表現 (タイトルでブロック) {#regular-expressions-for-page-titles} +#### 論理演算子 {#logical-operators} + +論理否定 (`!`)、論理積 (`&`)、論理和 (`|`) が使用できます。 + +``` +# HTTPS 以外のスキームをブロック +!scheme="https" + +# 画像検索で Amazon.com をブロック +$category = "images" & host = "www.amazon.com" + +# タイトルが "example" または "domain" を含む (大文字小文字を区別しない) +title *= "example" i | title *= "domain" i +``` -特定のタイトルを持つサイトをブロックするには、正規表現ルールの前に `title` を追加します。 +### 式をマッチパターンと一緒に使う{#use-expressions-with-match-patterns} -例えば、ルール `title/example domain/i` は、タイトルに "example domain" を含む (大文字小文字を区別しない) サイトをブロックします。 +マッチパターンの後に `@if(式)` を続けることがで来ます。 + +``` +# 画像検索で Amazon.com をブロックする +*://*.amazon.com/* @if($category="images") +``` ### ブロック解除ルール {#unblock-rules} @@ -172,10 +267,19 @@ Firefox またはその派生ブラウザでは、`https://www.googleapis.com` ![購読のメニュー](/img/advanced-features/subscription-2.png) -### 購読を公開する {#publish-a-subscription} +### 購読を公開する {#publish-subscription} ルールセットを購読として公開するには、UTF-8 でエンコードしたルールセットファイルを適切な HTTP(S) サーバーに配置し、URL を公開します。 +ルールセットには YAML frontmatter を書くことができます。`name` 変数を設定することが推奨されます。 + +``` +--- +name: あなたのルールセットの名前 +--- +*://*.example.com/* +``` + 購読を GitHub で公開するのはよい考えです。**Raw** URL (例えば https://raw.githubusercontent.com/iorate/ublacklist-example-subscription/master/uBlacklist.txt) を公開してください。 ![raw url](/img/advanced-features/subscription-3.png)