diff --git a/README.md b/README.md index 9acecd0..9d08c02 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ npm run build -w @techword/browser `chrome://extensions` → デベロッパーモード ON → 「パッケージ化されていない拡張機能を読み込む」 で `packages/browser/dist` を選択。任意のページで用語にホバーします。 +ホバー内の `☆` を押すと用語をお気に入りに追加でき、もう一度押すと解除できます。 +Chrome のツールバーにある TechTerm のアイコンを押すとサイドパネルが開き、 +お気に入りの確認、個別削除、全削除ができます。お気に入りは Chrome の同期ストレージに保存されます。 + ## 辞書を増やす `packages/core/src/data/terms.json` に追記します。 @@ -73,3 +77,16 @@ VS Code 側は設定 `techword.userTerms` からも同じ形式で追加・上 (最大 4 トークン、長い一致から順に)。 - **漢字・ひらがなは切り出さない**。形態素解析なしでは境界が決まらないため、 日本語の別名はカタカナか英字で `match` に書く運用です。 + +### 表示する用語の設定 + +拡張機能アイコンからサイドパネルを開き、「表示する用語」でカテゴリを選択できます。 +初期状態では全カテゴリが有効です。チェックを外すと、そのカテゴリのハイライトと +ホバー説明が開いているページでも無効になります。お気に入りは削除されません。 +設定はChromeの同期ストレージに保存されます。 + +通常のページ更新では、追加・変更されたテキストだけを再検索します。 +削除したテキストのハイライトは除去し、初回表示とカテゴリ設定の変更時だけページ全体を検索します。 + +`npm run build` のあとに `npm test` を実行すると、辞書に加えて、差分更新・カテゴリ設定・ +サイドパネルの保存失敗時の動作も検証します。 diff --git a/package-lock.json b/package-lock.json index 9e02975..b559b6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "TechTerm", - "version": "0.1.1", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "TechTerm", - "version": "0.1.1", + "version": "0.2.1", "workspaces": [ "packages/core", "packages/vscode", @@ -19,6 +19,135 @@ "typescript": "^5.9.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -452,6 +581,41 @@ "resolved": "packages/core", "link": true }, + "node_modules/@types/chrome": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.9.tgz", + "integrity": "sha512-6rXrDPkkWv4D2Q23HqT+iED4voODU5CpOSc2VFgKyecSFItuEsIBvz9CY6jUQ3dd+Q7zo1bnCE9pKRMtkYut5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -482,6 +646,16 @@ "node": ">=6.5" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/archiver": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", @@ -740,6 +914,72 @@ "node": ">=18" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -818,6 +1058,60 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -846,6 +1140,13 @@ "dev": true, "license": "ISC" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -866,6 +1167,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -912,6 +1253,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -928,6 +1276,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -938,6 +1293,26 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -955,6 +1330,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -988,6 +1373,13 @@ "url": "https://github.com/sponsors/yqnn" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1009,6 +1401,26 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/streamx": { "version": "2.28.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", @@ -1031,6 +1443,13 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-stream": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", @@ -1068,6 +1487,52 @@ "b4a": "^1.6.4" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1096,6 +1561,106 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zip-stream": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", @@ -1113,18 +1678,22 @@ }, "packages/browser": { "name": "@techword/browser", - "version": "0.1.1", + "version": "0.2.1", "dependencies": { "@techword/core": "*" + }, + "devDependencies": { + "@types/chrome": "^0.2.9", + "jsdom": "^26.1.0" } }, "packages/core": { "name": "@techword/core", - "version": "0.1.1" + "version": "0.2.1" }, "packages/vscode": { "name": "techword-vscode", - "version": "0.1.1", + "version": "0.2.1", "dependencies": { "@techword/core": "*" }, diff --git a/package.json b/package.json index 8d95e00..5625ccd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "npm run build -w @techword/core && npm run build -w techword-vscode && npm run build -w @techword/browser", "watch:core": "npm run watch -w @techword/core", - "test": "npm test -w @techword/core" + "test": "npm test -w @techword/core && npm test -w @techword/browser" }, "devDependencies": { "@types/node": "^22.20.1", diff --git a/packages/browser/manifest.json b/packages/browser/manifest.json index d450d81..a003eb5 100644 --- a/packages/browser/manifest.json +++ b/packages/browser/manifest.json @@ -2,7 +2,18 @@ "manifest_version": 3, "name": "TechTerm", "version": "0.2.1", + "minimum_chrome_version": "114", "description": "プログラミング用語にホバーすると、意味を簡潔に表示します。", + "permissions": ["storage", "sidePanel"], + "action": { + "default_title": "TechTermのお気に入りを開く" + }, + "background": { + "service_worker": "background.js" + }, + "side_panel": { + "default_path": "sidepanel.html" + }, "content_scripts": [ { "matches": [ diff --git a/packages/browser/package.json b/packages/browser/package.json index 562184e..10c1347 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -4,10 +4,15 @@ "private": true, "typescript": "^6.0.3", "scripts": { - "build": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 && cp manifest.json dist/manifest.json", - "watch": "esbuild src/content.ts --bundle --outfile=dist/content.js --format=iife --target=chrome110 --watch" + "build": "tsc -p tsconfig.json --noEmit && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 && cp manifest.json sidepanel.html sidepanel.css dist/", + "watch": "mkdir -p dist && cp manifest.json sidepanel.html sidepanel.css dist/ && esbuild src/content.ts src/background.ts src/sidepanel.ts --bundle --outdir=dist --format=iife --target=chrome114 --watch", + "test": "node --test tests/*.test.cjs" }, "dependencies": { "@techword/core": "*" + }, + "devDependencies": { + "@types/chrome": "^0.2.9", + "jsdom": "^26.1.0" } } diff --git a/packages/browser/sidepanel.css b/packages/browser/sidepanel.css new file mode 100644 index 0000000..bb59fa5 --- /dev/null +++ b/packages/browser/sidepanel.css @@ -0,0 +1,147 @@ +:root { + color-scheme: light dark; + font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif; + background: Canvas; + color: CanvasText; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +main { + padding: 18px 16px 28px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 20px; + line-height: 1.3; +} + +.count { + margin-top: 3px; + color: GrayText; + font-size: 12px; +} + +button { + border: 1px solid color-mix(in srgb, CanvasText 24%, transparent); + border-radius: 7px; + background: ButtonFace; + color: ButtonText; + cursor: pointer; + font: inherit; +} + +button:hover:not(:disabled) { + background: color-mix(in srgb, ButtonFace 80%, CanvasText 20%); +} + +button:focus-visible, +a:focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; +} + +button:disabled { + cursor: default; + opacity: 0.5; +} + +.clear-favorites { + flex: none; + padding: 7px 10px; + font-size: 12px; +} + +.favorites { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.favorite-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); + border-radius: 9px; +} + +.favorite-body { + flex: 1; + min-width: 0; +} + +.favorite-body h2 { + font-size: 15px; + line-height: 1.4; +} + +.favorite-body p { + margin-top: 4px; + color: color-mix(in srgb, CanvasText 76%, transparent); + font-size: 13px; + line-height: 1.6; +} + +.favorite-body a { + display: inline-block; + margin-top: 6px; + color: LinkText; + font-size: 12px; +} + +.remove-favorite { + flex: none; + padding: 6px 8px; + color: #c62828; + font-size: 12px; +} + +.empty { + padding: 30px 8px; + color: GrayText; + text-align: center; + font-size: 13px; +} + +.error { + margin-bottom: 12px; + color: #c62828; + font-size: 13px; +} + +[hidden] { display: none !important; } +.term-settings { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid color-mix(in srgb, CanvasText 18%, transparent); } +.term-settings summary { padding: 6px 0; cursor: pointer; } +.term-settings summary:focus-visible { outline: 2px solid Highlight; outline-offset: 4px; border-radius: 4px; } +.term-settings h2 { display: inline; font-size: 18px; } +.settings-description, .settings-note, .settings-status { margin-top: 8px; font-size: 12px; line-height: 1.6; color: GrayText; } +.term-categories { display: grid; gap: 4px; margin-top: 12px; } +.category-row { display: flex; align-items: center; gap: 10px; padding: 9px 6px; font-size: 13px; cursor: pointer; } +.category-row:hover { background: color-mix(in srgb, CanvasText 6%, transparent); border-radius: 6px; } +.category-row input { width: 17px; height: 17px; margin: 0; flex: none; accent-color: #4272d7; } +.category-row input:focus-visible { outline: 2px solid Highlight; outline-offset: 3px; } +.category-count { margin-left: auto; color: GrayText; white-space: nowrap; } diff --git a/packages/browser/sidepanel.html b/packages/browser/sidepanel.html new file mode 100644 index 0000000..0473d00 --- /dev/null +++ b/packages/browser/sidepanel.html @@ -0,0 +1,35 @@ + + + + + + TechTerm + + + +
+
+

表示する用語

+

チェックしたカテゴリの用語を強調表示し、カーソルを合わせると説明を表示します。

+
+

設定を読み込み中…

+ +

カテゴリを無効にしても、お気に入りは残ります。

+
+
+
+

お気に入り

+

0件

+
+ +
+ + +

お気に入りはまだありません。

+ +
+ + + diff --git a/packages/browser/src/background.ts b/packages/browser/src/background.ts new file mode 100644 index 0000000..0d9113c --- /dev/null +++ b/packages/browser/src/background.ts @@ -0,0 +1,3 @@ +chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch((error: unknown) => console.error('TechTerm: サイドパネルを設定できませんでした。', error)); diff --git a/packages/browser/src/category-settings.ts b/packages/browser/src/category-settings.ts new file mode 100644 index 0000000..6397d55 --- /dev/null +++ b/packages/browser/src/category-settings.ts @@ -0,0 +1,54 @@ +import { termCategories, type TermCategory } from '@techword/core'; + +const keyFor = (id: TermCategory): string => `termCategory.${id}`; +export type CategorySettings = Record; +export const defaultCategorySettings = (): CategorySettings => + Object.fromEntries(termCategories.map(({ id }) => [id, true])) as CategorySettings; + +/** カテゴリごとに保存し、別カテゴリの同時更新による上書きを防ぐ。 */ +export async function setCategoryEnabled(id: TermCategory, enabled: boolean): Promise { + await chrome.storage.sync.set({ [keyFor(id)]: enabled }); +} + +/** 監視を先に開始し、初回読み込み中の変更も取りこぼさない。 */ +export function observeCategorySettings( + listener: (settings: CategorySettings) => void, + onError: (error: unknown) => void, +): () => void { + const settings = defaultCategorySettings(); + const changedDuringLoad = new Set(); + let loading = true; + let active = true; + const handleChange = (changes: Record, area: string): void => { + if (area !== 'sync') return; + let changed = false; + for (const { id } of termCategories) { + const update = changes[keyFor(id)]; + if (!update) continue; + if (loading) changedDuringLoad.add(id); + settings[id] = update.newValue !== false; + changed = true; + } + if (changed && !loading) listener({ ...settings }); + }; + chrome.storage.onChanged.addListener(handleChange); + void chrome.storage.sync.get(termCategories.map(({ id }) => keyFor(id))) + .then((values) => { + if (!active) return; + for (const { id } of termCategories) { + if (!changedDuringLoad.has(id)) settings[id] = values[keyFor(id)] !== false; + } + loading = false; + listener({ ...settings }); + }) + .catch((error: unknown) => { + if (!active) return; + loading = false; + listener({ ...settings }); + onError(error); + }); + return () => { + active = false; + chrome.storage.onChanged.removeListener(handleChange); + }; +} diff --git a/packages/browser/src/content.ts b/packages/browser/src/content.ts index a3073a9..752e16e 100644 --- a/packages/browser/src/content.ts +++ b/packages/browser/src/content.ts @@ -1,16 +1,11 @@ import { Dictionary, builtinTerms, type Match } from '@techword/core'; +import { observeCategorySettings } from './category-settings'; +import { IncrementalHighlighter, isSearchableText } from './highlighter'; +import { getFavoriteIds, toggleFavorite, watchFavoriteIds } from './favorites'; -const dictionary = new Dictionary(builtinTerms); - +let dictionary = new Dictionary([]); +let highlighter: IncrementalHighlighter | undefined; installHighlightStyle(); -highlight(); -const observer = new MutationObserver(highlight); -const config = { - childList: true, // 子要素の追加・削除を監視 - subtree: true, // 子孫要素(さらに下の階層)まで含めて監視する場合に true - characterData: true, // テキストノードの変更を監視 -}; -observer.observe(document.body, config); /** ホバーしてから出すまでの待ち時間。すぐ出すとポインタを動かすたびに点滅する。 */ const HOVER_DELAY_MS = 250; @@ -18,19 +13,53 @@ const HOVER_DELAY_MS = 250; const tooltip = createTooltip(); let hoverTimer: number | undefined; let currentId: string | undefined; +let currentFavoriteButton: HTMLButtonElement | undefined; +let favoriteIds = new Set(); +let pointerInsideTooltip = false; + +observeCategorySettings((settings) => { + dictionary = new Dictionary(builtinTerms.filter((entry) => settings[entry.category ?? 'basics'])); + window.clearTimeout(hoverTimer); + hide(); + if (highlighter) highlighter.setDictionary(dictionary); + else highlighter = new IncrementalHighlighter(dictionary); +}, (error) => console.error('TechTerm: 表示設定を読み込めませんでした。', error)); + +void getFavoriteIds() + .then((ids) => { + favoriteIds = new Set(ids); + refreshCurrentFavoriteButton(); + }) + .catch((error: unknown) => console.error('TechTerm: お気に入りを読み込めませんでした。', error)); + +watchFavoriteIds((ids) => { + favoriteIds = new Set(ids); + refreshCurrentFavoriteButton(); +}); document.addEventListener('mousemove', (event) => { window.clearTimeout(hoverTimer); + if (event.composedPath().includes(tooltip.host) || pointerInsideTooltip) return; hoverTimer = window.setTimeout(() => handleHover(event), HOVER_DELAY_MS); }); document.addEventListener('scroll', hide, { passive: true, capture: true }); window.addEventListener('blur', hide); +tooltip.host.addEventListener('pointerenter', () => { + pointerInsideTooltip = true; + window.clearTimeout(hoverTimer); +}); +tooltip.host.addEventListener('pointerleave', () => { + pointerInsideTooltip = false; + hide(); +}); + function handleHover(event: MouseEvent): void { if (tooltip.host.style.display === 'block' && tooltip.host.matches(':hover')) return; // ツールチップ上にマウスがあるときはツールチップを消さないようにする const caret = caretFromPoint(event.clientX, event.clientY); if (!caret || caret.node.nodeType !== Node.TEXT_NODE) return hide(); + if (!isSearchableText(caret.node as Text)) return hide(); const text = caret.node.textContent ?? ''; const hit = dictionary.findAt(text, caret.offset); if (!hit) return hide(); @@ -44,6 +73,8 @@ function handleHover(event: MouseEvent): void { function hide(): void { currentId = undefined; + currentFavoriteButton = undefined; + pointerInsideTooltip = false; tooltip.host.style.display = 'none'; } @@ -68,10 +99,37 @@ function render({ entry }: Match): void { const { panel } = tooltip; panel.replaceChildren(); + const header = document.createElement('div'); + header.className = 'tw-header'; + const head = document.createElement('div'); head.className = 'tw-head'; head.textContent = entry.term; - panel.append(head); + + const favoriteButton = document.createElement('button'); + favoriteButton.type = 'button'; + favoriteButton.className = 'tw-favorite'; + updateFavoriteButton(favoriteButton, entry.id); + favoriteButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + favoriteButton.disabled = true; + + try { + const isFavorite = await toggleFavorite(entry.id); + if (isFavorite) favoriteIds.add(entry.id); + else favoriteIds.delete(entry.id); + updateFavoriteButton(favoriteButton, entry.id); + } catch (error: unknown) { + console.error('TechTerm: お気に入りを更新できませんでした。', error); + } finally { + favoriteButton.disabled = false; + } + }); + currentFavoriteButton = favoriteButton; + + header.append(head, favoriteButton); + panel.append(header); const short = document.createElement('div'); short.className = 'tw-short'; @@ -104,6 +162,7 @@ function render({ entry }: Match): void { /** ページ側の CSS に影響されないよう Shadow DOM に閉じ込める。 */ function createTooltip(): { host: HTMLElement; panel: HTMLElement } { const host = document.createElement('div'); + host.dataset.techtermUi = ''; host.style.cssText = 'position:fixed;z-index:2147483647;display:none;'; const root = host.attachShadow({ mode: 'closed' }); @@ -115,7 +174,16 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } { box-shadow: 0 6px 20px rgba(0,0,0,.35); font: 13px/1.6 -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif; } - .tw-head { font-weight: 700; margin-bottom: 2px; } + .tw-header { display: flex; align-items: center; gap: 10px; margin-bottom: 2px; } + .tw-head { flex: 1; min-width: 0; font-weight: 700; } + .tw-favorite { + display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; + border: 1px solid #596276; border-radius: 7px; background: #2b3241; color: #ffd166; + cursor: pointer; font: 20px/1 sans-serif; + } + .tw-favorite:hover { background: #394256; } + .tw-favorite:focus-visible { outline: 2px solid #7fb2ff; outline-offset: 2px; } + .tw-favorite:disabled { cursor: wait; opacity: .65; } .tw-short { color: #f2f2f2; } .tw-detail { margin-top: 6px; color: #b9c0cf; } .tw-example { @@ -133,6 +201,21 @@ function createTooltip(): { host: HTMLElement; panel: HTMLElement } { return { host, panel }; } +function updateFavoriteButton(button: HTMLButtonElement, id: string): void { + const isFavorite = favoriteIds.has(id); + const label = isFavorite ? 'お気に入りから削除' : 'お気に入りに追加'; + button.textContent = isFavorite ? '★' : '☆'; + button.setAttribute('aria-pressed', String(isFavorite)); + button.setAttribute('aria-label', label); + button.title = label; +} + +function refreshCurrentFavoriteButton(): void { + if (currentId && currentFavoriteButton) { + updateFavoriteButton(currentFavoriteButton, currentId); + } +} + /** Chrome (caretRangeFromPoint) と Firefox (caretPositionFromPoint) の差を吸収する。 */ function caretFromPoint(x: number, y: number): { node: Node; offset: number } | undefined { const doc = document as Document & { @@ -152,33 +235,11 @@ function caretFromPoint(x: number, y: number): { node: Node; offset: number } | return undefined; } -function highlight(): void { - let ranges: Range[] = []; - const walker = document.createTreeWalker( - document.body, // body以下のノードを走査する - NodeFilter.SHOW_TEXT, //テキストノードのみを対象 - ); - - while (walker.nextNode() !== null) { - const nodeTagName = walker.currentNode.parentElement?.tagName; - if (nodeTagName === 'SCRIPT' || nodeTagName === 'STYLE') continue; // 負荷軽減のためにスクリプトとスタイルの中身は無視する - const textNodes = dictionary.findAll(walker.currentNode.textContent ?? ''); - for (const {start, end} of textNodes) { - const range = new Range(); - range.setStart(walker.currentNode, start); - range.setEnd(walker.currentNode, end); - ranges.push(range); - } - } - const highlightRange = new Highlight(...ranges); //スプレッド構文でranges配列を展開し引数として渡す - CSS.highlights.set("tw-highlight", highlightRange); -} - function installHighlightStyle(): void { const style = document.createElement("style"); style.textContent = ` ::highlight(tw-highlight) { - background-color: #ffff00; + background-color: rgba(255, 220, 100, 0.2); }`; document.head.append(style); } diff --git a/packages/browser/src/favorites.ts b/packages/browser/src/favorites.ts new file mode 100644 index 0000000..891451b --- /dev/null +++ b/packages/browser/src/favorites.ts @@ -0,0 +1,56 @@ +export const FAVORITES_STORAGE_KEY = 'favoriteTermIds'; + +type FavoriteIdsListener = (ids: string[]) => void; + +function sanitizeFavoriteIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set(value.filter((id): id is string => typeof id === 'string' && id.length > 0)), + ]; +} + +export async function getFavoriteIds(): Promise { + const result = await chrome.storage.sync.get(FAVORITES_STORAGE_KEY); + return sanitizeFavoriteIds(result[FAVORITES_STORAGE_KEY]); +} + +async function setFavoriteIds(ids: string[]): Promise { + await chrome.storage.sync.set({ + [FAVORITES_STORAGE_KEY]: sanitizeFavoriteIds(ids), + }); +} + +export async function toggleFavorite(id: string): Promise { + const ids = await getFavoriteIds(); + const nextIsFavorite = !ids.includes(id); + const nextIds = nextIsFavorite + ? [...ids, id] + : ids.filter((currentId) => currentId !== id); + + await setFavoriteIds(nextIds); + return nextIsFavorite; +} + +export async function removeFavorite(id: string): Promise { + const ids = await getFavoriteIds(); + await setFavoriteIds(ids.filter((currentId) => currentId !== id)); +} + +export async function clearFavorites(): Promise { + await setFavoriteIds([]); +} + +export function watchFavoriteIds(listener: FavoriteIdsListener): () => void { + const handleChange = ( + changes: Record, + areaName: string, + ): void => { + if (areaName !== 'sync') return; + const change = changes[FAVORITES_STORAGE_KEY]; + if (!change) return; + listener(sanitizeFavoriteIds(change.newValue)); + }; + + chrome.storage.onChanged.addListener(handleChange); + return () => chrome.storage.onChanged.removeListener(handleChange); +} diff --git a/packages/browser/src/highlighter.ts b/packages/browser/src/highlighter.ts new file mode 100644 index 0000000..f7486d3 --- /dev/null +++ b/packages/browser/src/highlighter.ts @@ -0,0 +1,110 @@ +import type { Dictionary } from '@techword/core'; + +/** スクリプト・入力欄・拡張機能自身のUIは検索しない。 */ +export function isSearchableText(node: Text): boolean { + return Boolean(document.body?.contains(node)) && + !node.parentElement?.closest('script, style, textarea, input, [contenteditable]:not([contenteditable="false"]), [data-techterm-ui]'); +} + +/** DOMの変更箇所だけを検索し、変更のないRangeは保持する。 */ +export class IncrementalHighlighter { + private readonly ranges = new Map(); + private readonly highlight = typeof Highlight === 'undefined' ? undefined : new Highlight(); + private readonly pending = new Set(); + private readonly removed = new Set(); + private frame: number | undefined; + private readonly observer: MutationObserver; + + constructor(private dictionary: Dictionary) { + if (this.highlight) CSS.highlights.set('tw-highlight', this.highlight); + this.observer = new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'characterData' || record.type === 'attributes') { + this.pending.add(record.target); + } else { + for (const node of record.removedNodes) this.removed.add(node); + for (const node of record.addedNodes) this.pending.add(node); + } + } + if (this.frame === undefined) this.frame = requestAnimationFrame(() => this.flush()); + }); + this.observer.observe(document.documentElement, { + childList: true, subtree: true, characterData: true, + attributes: true, attributeFilter: ['contenteditable'], + }); + this.rescan(); + } + + setDictionary(dictionary: Dictionary): void { + this.dictionary = dictionary; + // 設定変更時だけ全体を再検索。通常のDOM更新では行わない。 + this.rescan(); + } + + disconnect(): void { + this.observer.disconnect(); + if (this.frame !== undefined) cancelAnimationFrame(this.frame); + this.ranges.clear(); + this.pending.clear(); + this.removed.clear(); + this.highlight?.clear(); + CSS.highlights?.delete('tw-highlight'); + } + + private rescan(): void { + if (this.frame !== undefined) cancelAnimationFrame(this.frame); + this.frame = undefined; + this.pending.clear(); + this.removed.clear(); + this.observer.takeRecords(); + this.ranges.clear(); + this.highlight?.clear(); + if (document.body) this.visit(document.body, (node) => this.update(node)); + } + + private flush(): void { + this.frame = undefined; + // 移動したノードは旧Rangeを消してから、最終的な場所で再検索する。 + for (const root of this.topLevelRoots(this.removed)) this.visit(root, (node) => this.remove(node)); + for (const root of this.topLevelRoots(this.pending)) this.visit(root, (node) => this.update(node)); + this.removed.clear(); + this.pending.clear(); + } + + private topLevelRoots(nodes: Set): Node[] { + return [...nodes].filter((node) => { + for (let parent = node.parentNode; parent; parent = parent.parentNode) { + if (nodes.has(parent)) return false; + } + return true; + }); + } + + private visit(root: Node, action: (node: Text) => void): void { + if (root.nodeType === Node.TEXT_NODE) { + action(root as Text); + return; + } + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) action(walker.currentNode as Text); + } + + private remove(node: Text): void { + for (const range of this.ranges.get(node) ?? []) this.highlight?.delete(range); + this.ranges.delete(node); + } + + private update(node: Text): void { + this.remove(node); + if (!this.highlight || !isSearchableText(node)) return; + const ranges: Range[] = []; + for (const { start, end } of this.dictionary.findAll(node.data)) { + const range = new Range(); + range.setStart(node, start); + range.setEnd(node, end); + ranges.push(range); + this.highlight.add(range); + } + if (ranges.length) this.ranges.set(node, ranges); + } +} diff --git a/packages/browser/src/sidepanel.ts b/packages/browser/src/sidepanel.ts new file mode 100644 index 0000000..c21706e --- /dev/null +++ b/packages/browser/src/sidepanel.ts @@ -0,0 +1,178 @@ +import { defaultCategorySettings, observeCategorySettings, setCategoryEnabled } from './category-settings'; +import { builtinTerms, termCategories, type TermCategory, type TermEntry } from '@techword/core'; +import { + clearFavorites, + getFavoriteIds, + removeFavorite, + watchFavoriteIds, +} from './favorites'; + +const favoriteList = requireElement('favorites'); +const emptyMessage = requireElement('empty'); +const count = requireElement('count'); +const clearButton = requireElement('clear-favorites'); +const errorMessage = requireElement('error'); +const termsById = new Map(builtinTerms.map((entry) => [entry.id, entry])); + +let currentFavoriteIds: string[] = []; + +clearButton.addEventListener('click', async () => { + if (currentFavoriteIds.length === 0) return; + if (!window.confirm('お気に入りをすべて削除しますか?')) return; + + clearButton.disabled = true; + hideError(); + try { + await clearFavorites(); + renderFavorites([]); + } catch (error: unknown) { + showError('お気に入りを削除できませんでした。', error); + clearButton.disabled = false; + } +}); + +watchFavoriteIds(renderFavorites); +void loadFavorites(); + +async function loadFavorites(): Promise { + hideError(); + try { + renderFavorites(await getFavoriteIds()); + } catch (error: unknown) { + renderFavorites([]); + showError('お気に入りを読み込めませんでした。', error); + } +} + +function renderFavorites(ids: string[]): void { + currentFavoriteIds = ids; + const entries = ids + .map((id) => termsById.get(id)) + .filter((entry): entry is TermEntry => entry !== undefined); + + favoriteList.replaceChildren(...entries.map(createFavoriteItem)); + favoriteList.hidden = entries.length === 0; + emptyMessage.hidden = entries.length !== 0; + count.textContent = `${entries.length}件`; + clearButton.disabled = ids.length === 0; +} + +function createFavoriteItem(entry: TermEntry): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'favorite-item'; + + const body = document.createElement('div'); + body.className = 'favorite-body'; + + const term = document.createElement('h2'); + term.textContent = entry.term; + + const description = document.createElement('p'); + description.textContent = entry.short; + + body.append(term, description); + + if (entry.link) { + const link = document.createElement('a'); + link.href = entry.link; + link.target = '_blank'; + link.rel = 'noreferrer noopener'; + link.textContent = '詳しく'; + body.append(link); + } + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'remove-favorite'; + removeButton.textContent = '削除'; + removeButton.setAttribute('aria-label', `${entry.term}をお気に入りから削除`); + removeButton.addEventListener('click', async () => { + removeButton.disabled = true; + hideError(); + try { + await removeFavorite(entry.id); + renderFavorites(currentFavoriteIds.filter((id) => id !== entry.id)); + } catch (error: unknown) { + showError(`${entry.term}を削除できませんでした。`, error); + removeButton.disabled = false; + } + }); + + item.append(body, removeButton); + return item; +} + +function requireElement(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element: #${id}`); + return element as T; +} + +function hideError(): void { + errorMessage.hidden = true; + errorMessage.textContent = ''; +} + +function showError(message: string, error: unknown): void { + console.error(`TechTerm: ${message}`, error); + errorMessage.textContent = message; + errorMessage.hidden = false; +} + +const categoryList = requireElement('term-categories'); +const settingsStatus = requireElement('settings-status'); +const settingsError = requireElement('settings-error'); +let categorySettings = defaultCategorySettings(); +const categoryInputs = new Map(); +const savingCategories = new Set(); + +for (const { id, label } of termCategories) { + const row = document.createElement('label'); + row.className = 'category-row'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.disabled = true; + const name = document.createElement('span'); + name.textContent = label; + const count = document.createElement('span'); + count.className = 'category-count'; + count.textContent = `${builtinTerms.filter((entry) => (entry.category ?? 'basics') === id).length}語`; + row.append(input, name, count); + categoryList.append(row); + categoryInputs.set(id, input); + input.addEventListener('change', async () => { + const enabled = input.checked; + savingCategories.add(id); + input.disabled = true; + settingsError.hidden = true; + settingsStatus.textContent = '保存中…'; + try { + await setCategoryEnabled(id, enabled); + // 確定した表示状態はstorage.onChangedから反映する。 + settingsStatus.textContent = '保存しました。開いているページにも反映されます。'; + } catch (error: unknown) { + input.checked = categorySettings[id]; + settingsStatus.textContent = ''; + settingsError.textContent = '表示設定を保存できませんでした。もう一度お試しください。'; + settingsError.hidden = false; + console.error('TechTerm: 表示設定を保存できませんでした。', error); + } finally { + savingCategories.delete(id); + input.disabled = false; + } + }); +} + +observeCategorySettings((settings) => { + categorySettings = settings; + for (const [id, input] of categoryInputs) { + input.checked = settings[id]; + input.disabled = savingCategories.has(id); + } + const enabledCount = builtinTerms.filter((entry) => settings[entry.category ?? 'basics']).length; + settingsStatus.textContent = `${enabledCount} / ${builtinTerms.length}語が有効`; +}, (error) => { + settingsError.textContent = '表示設定を読み込めませんでした。'; + settingsError.hidden = false; + console.error('TechTerm: 表示設定を読み込めませんでした。', error); +}); diff --git a/packages/browser/tests/browser.test.cjs b/packages/browser/tests/browser.test.cjs new file mode 100644 index 0000000..dc52429 --- /dev/null +++ b/packages/browser/tests/browser.test.cjs @@ -0,0 +1,171 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const path = require('node:path'); +const fs = require('node:fs'); +const { buildSync } = require('esbuild'); +const { JSDOM } = require('jsdom'); +const root = path.resolve(__dirname, '..'); +function bundle(file, name = 'TestModule') { + return buildSync({ entryPoints: [path.join(root, 'src', file)], bundle: true, write: false, format: 'iife', globalName: name }).outputFiles[0].text + `\nglobalThis.${name} = ${name};`; +} +const highlighterCode = bundle('highlighter.ts'); +const settingsCode = bundle('category-settings.ts'); +const contentCode = bundle('content.ts'); +const sidepanelCode = bundle('sidepanel.ts'); +function dom(html = '

JavaScript

') { + const d = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const observers = []; + const NativeObserver = d.window.MutationObserver; + d.window.MutationObserver = class extends NativeObserver { + constructor(callback) { super(callback); observers.push(this); } + }; + const close = d.window.close.bind(d.window); + d.window.close = () => { observers.forEach(observer => observer.disconnect()); close(); }; + d.window.Highlight = class extends Set {}; + d.window.CSS = { highlights: new Map() }; + return d; +} +const settle = async (w) => { await new Promise(resolve => w.requestAnimationFrame(() => w.requestAnimationFrame(resolve))); }; +function storage(w, initial = {}, options = {}) { + const data = { ...initial }; + const listeners = new Set(); + const emit = (changes, area = 'sync') => listeners.forEach(fn => fn(changes, area)); + w.chrome = { storage: { onChanged: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) }, sync: { + get: async () => { + const snapshot = { ...data }; + if (options.beforeRead) await options.beforeRead(emit); + return snapshot; + }, + set: async (values) => { + if (options.failWrite) throw Error('write failed'); + const changes = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, { oldValue: data[k], newValue: v }])); + Object.assign(data, values); emit(changes); + }, + } } }; + return { data, emit }; +} + +test('1,000段落のうち変更・追加されたテキストだけを検索し、削除Rangeを除去する', async () => { + const d = dom('
' + '

JavaScript

'.repeat(1000) + '
'); + const w = d.window; + try { + w.eval(highlighterCode); + const calls = []; + const dictionary = { findAll: text => { calls.push(text); return text ? [{ start: 0, end: text.length }] : []; } }; + const h = new w.TestModule.IncrementalHighlighter(dictionary); + const highlight = w.CSS.highlights.get('tw-highlight'); + assert.equal(calls.length, 1000); + const unchanged = [...highlight][1]; + calls.length = 0; + w.document.querySelector('p').firstChild.data = 'TypeScript'; + await settle(w); + assert.deepEqual(calls, ['TypeScript']); + assert.ok(highlight.has(unchanged)); + calls.length = 0; + const wrapper = w.document.createElement('section'); + wrapper.innerHTML = '

React

Vue

'; + w.document.querySelector('main').append(wrapper); + wrapper.firstChild.firstChild.data = 'Nuxt'; + await settle(w); + assert.deepEqual(calls, ['Nuxt', 'Vue']); + calls.length = 0; + wrapper.remove(); + await settle(w); + assert.equal(highlight.size, 1000); + assert.equal(calls.length, 0); + h.disconnect(); + } finally { w.close(); } +}); + +test('ノード移動・body差し替え・編集領域の切り替えでも古いRangeを残さない', async () => { + const d = dom('

React

'); const w = d.window; + try { + w.eval(highlighterCode); + const h = new w.TestModule.IncrementalHighlighter({ findAll: text => [{ start: 0, end: text.length }] }); + const ranges = w.CSS.highlights.get('tw-highlight'); + w.document.querySelector('aside').append(w.document.querySelector('p')); + await settle(w); assert.equal(ranges.size, 1); + w.document.querySelector('aside').setAttribute('contenteditable', 'true'); + await settle(w); assert.equal(ranges.size, 0); + w.document.querySelector('aside').removeAttribute('contenteditable'); + await settle(w); assert.equal(ranges.size, 1); + const body = w.document.createElement('body'); body.innerHTML = '

Git

'; + w.document.body.replaceWith(body); await settle(w); + assert.equal(ranges.size, 1); assert.equal([...ranges][0].toString(), 'Git'); + h.disconnect(); + } finally { w.close(); } +}); + +test('設定の初回読込中に受信した変更が古い値で上書きされない', async () => { + const d = dom(); const w = d.window; + try { + storage(w, {}, { beforeRead: async emit => emit({ 'termCategory.javascript': { newValue: false } }) }); + w.eval(settingsCode); + let received; + const stop = w.TestModule.observeCategorySettings(value => { received = value; }, assert.fail); + await settle(w); + assert.equal(received.javascript, false); + assert.equal(received.basics, true); + await w.TestModule.setCategoryEnabled('basics', false); + assert.equal(received.javascript, false); assert.equal(received.basics, false); + stop(); + } finally { w.close(); } +}); + +test('カテゴリOFFで表示済みの説明とハイライトが消え、ONで戻る。お気に入りは保持', async () => { + const d = dom('

JavaScript

'); const w = d.window; + try { + const { data } = storage(w, { favoriteTermIds: ['javascript'] }); + const node = w.document.querySelector('p').firstChild; + w.document.caretRangeFromPoint = () => { const r = w.document.createRange(); r.setStart(node, 2); return r; }; + w.Range.prototype.getBoundingClientRect = () => ({ left: 10, bottom: 20, top: 5 }); + w.eval(contentCode); await settle(w); + const hover = async () => { w.document.dispatchEvent(new w.MouseEvent('mousemove', { clientX: 20, clientY: 10 })); await new Promise(r => w.setTimeout(r, 280)); }; + const host = w.document.querySelector('[data-techterm-ui]'); + assert.equal(w.CSS.highlights.get('tw-highlight').size, 1); + await hover(); assert.equal(host.style.display, 'block'); + await w.chrome.storage.sync.set({ 'termCategory.javascript': false }); + assert.equal(host.style.display, 'none'); + assert.equal(w.CSS.highlights.get('tw-highlight').size, 0); + await hover(); assert.equal(host.style.display, 'none'); + await w.chrome.storage.sync.set({ 'termCategory.javascript': true }); + assert.equal(w.CSS.highlights.get('tw-highlight').size, 1); + await hover(); assert.equal(host.style.display, 'block'); + assert.deepEqual(data.favoriteTermIds, ['javascript']); + } finally { w.close(); } +}); + +test('サイドパネルに7カテゴリを表示し、保存と失敗時の復元を行う', async () => { + const d = dom(fs.readFileSync(path.join(root, 'sidepanel.html'), 'utf8')); const w = d.window; + try { + const options = {}; const { data, emit } = storage(w, { 'termCategory.javascript': false }, options); + w.eval(sidepanelCode); await settle(w); + const inputs = [...w.document.querySelectorAll('#term-categories input')]; + assert.equal(inputs.length, 7); assert.equal(inputs[1].checked, false); + inputs[0].click(); await settle(w); + assert.equal(data['termCategory.basics'], false); + assert.equal(data['termCategory.javascript'], false); + const errors = []; w.console.error = (...args) => errors.push(args); + options.failWrite = true; inputs[0].click(); await settle(w); + assert.equal(errors.length, 1); + assert.equal(inputs[0].checked, false); assert.equal(inputs[0].disabled, false); + assert.equal(w.document.querySelector('#settings-error').hidden, false); + emit({ 'termCategory.javascript': { newValue: true } }); + assert.equal(inputs[1].checked, true); + } finally { w.close(); } +}); + +test('全カテゴリOFFの保存状態で起動すると、後から追加した用語も表示しない', async () => { + const d = dom('

JavaScript Git API

'); const w = d.window; + try { + const ids = ['basics', 'javascript', 'frameworks', 'html-css', 'development', 'network-data', 'design-performance']; + storage(w, Object.fromEntries(ids.map(id => ['termCategory.' + id, false]))); + w.eval(contentCode); await settle(w); + assert.equal(w.CSS.highlights.get('tw-highlight').size, 0); + const p = w.document.createElement('p'); p.textContent = 'React'; w.document.body.append(p); + await settle(w); + assert.equal(w.CSS.highlights.get('tw-highlight').size, 0); + await w.chrome.storage.sync.set({ 'termCategory.frameworks': true }); + assert.equal(w.CSS.highlights.get('tw-highlight').size, 1); + } finally { w.close(); } +}); diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json new file mode 100644 index 0000000..1290cee --- /dev/null +++ b/packages/browser/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chrome"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/categories.ts b/packages/core/src/categories.ts new file mode 100644 index 0000000..3d50928 --- /dev/null +++ b/packages/core/src/categories.ts @@ -0,0 +1,12 @@ +/** 表示設定で使う安定したカテゴリIDと表示名。 */ +export const termCategories = [ + { id: 'basics', label: 'プログラミング基礎' }, + { id: 'javascript', label: 'JavaScript・TypeScript' }, + { id: 'frameworks', label: 'React・Vue' }, + { id: 'html-css', label: 'HTML・CSS' }, + { id: 'development', label: 'Git・開発ツール' }, + { id: 'network-data', label: '通信・データベース' }, + { id: 'design-performance', label: '設計・性能' }, +] as const; + +export type TermCategory = (typeof termCategories)[number]['id']; diff --git a/packages/core/src/data/terms.json b/packages/core/src/data/terms.json index 86c7ba5..ed57fdd 100644 --- a/packages/core/src/data/terms.json +++ b/packages/core/src/data/terms.json @@ -1,6 +1,7 @@ [ { "id": "closure", + "category": "javascript", "term": "closure", "match": ["クロージャ", "クロージャー"], "short": "自分が作られた場所の変数を覚えている関数。", @@ -10,6 +11,7 @@ }, { "id": "idempotent", + "category": "design-performance", "term": "idempotent", "match": ["冪等", "べき等", "idempotency"], "short": "何回実行しても結果が同じになる性質。", @@ -17,6 +19,7 @@ }, { "id": "race-condition", + "category": "design-performance", "term": "race condition", "match": ["競合状態", "レースコンディション"], "short": "処理の実行順序次第で結果が変わってしまうバグ。", @@ -24,6 +27,7 @@ }, { "id": "memoization", + "category": "design-performance", "term": "memoization", "match": ["メモ化", "memoize", "memo"], "short": "一度計算した結果を覚えておいて使い回すこと。", @@ -31,6 +35,7 @@ }, { "id": "gc", + "category": "design-performance", "term": "garbage collection", "match": ["GC", "ガベージコレクション", "ガベコレ"], "short": "使われなくなったメモリを自動で回収する仕組み。", @@ -38,6 +43,7 @@ }, { "id": "immutable", + "category": "design-performance", "term": "immutable", "match": ["イミュータブル", "不変", "immutability"], "short": "作った後に中身を書き換えられないデータ。", @@ -45,6 +51,7 @@ }, { "id": "dependency-injection", + "category": "design-performance", "term": "dependency injection", "match": ["DI", "依存性注入"], "short": "使う部品を自分で作らず、外から渡してもらう設計。", @@ -52,6 +59,7 @@ }, { "id": "middleware", + "category": "network-data", "term": "middleware", "match": ["ミドルウェア"], "short": "リクエストと本処理の間に挟まる共通処理。", @@ -59,6 +67,7 @@ }, { "id": "polyfill", + "category": "javascript", "term": "polyfill", "match": ["ポリフィル"], "short": "古い環境に新しい機能を後付けするコード。", @@ -66,6 +75,7 @@ }, { "id": "hoisting", + "category": "javascript", "term": "hoisting", "match": ["巻き上げ", "ホイスティング"], "short": "宣言がスコープの先頭に持ち上げられる挙動。", @@ -74,6 +84,7 @@ }, { "id": "use-state", + "category": "frameworks", "term": "useState", "match": ["ユーズステート"], "short": "React で「変わる値」を持つためのフック。", @@ -84,6 +95,7 @@ }, { "id": "use-effect", + "category": "frameworks", "term": "useEffect", "short": "React で描画のあとに副作用を走らせるフック。", "detail": "通信・購読・DOM 操作など、描画そのものではない処理を置く。第 2 引数の依存配列に入れた値が変わったときだけ再実行される。", @@ -92,6 +104,7 @@ }, { "id": "promise", + "category": "javascript", "term": "Promise", "match": ["プロミス"], "short": "「あとで結果が入る箱」を表すオブジェクト。", @@ -100,6 +113,7 @@ }, { "id": "generic", + "category": "javascript", "term": "generics", "match": ["ジェネリクス", "generic", "型引数"], "short": "型を後から差し込めるようにする仕組み。", @@ -107,6 +121,7 @@ }, { "id": "type-guard", + "category": "javascript", "term": "type guard", "match": ["型ガード"], "short": "実行時のチェックで型を絞り込む書き方。", @@ -115,6 +130,7 @@ }, { "id": "orm", + "category": "network-data", "term": "ORM", "match": ["オーアールエム", "object relational mapping"], "short": "DB のテーブルをコードのオブジェクトとして扱う道具。", @@ -122,6 +138,7 @@ }, { "id": "n-plus-one", + "category": "network-data", "term": "N+1 problem", "match": ["N+1", "エヌプラスワン"], "short": "一覧を出すたびに件数ぶん追加クエリが飛ぶ性能問題。", @@ -129,6 +146,7 @@ }, { "id": "idempotency-key", + "category": "design-performance", "term": "idempotency key", "match": ["冪等キー"], "short": "同じリクエストの二重実行を防ぐための識別子。", @@ -136,6 +154,7 @@ }, { "id": "cors", + "category": "network-data", "term": "CORS", "match": ["オリジン間リソース共有", "cross origin resource sharing"], "short": "別ドメインへのブラウザからの通信を許可する仕組み。", @@ -143,6 +162,7 @@ }, { "id": "jwt", + "category": "network-data", "term": "JWT", "match": ["json web token", "ジョット"], "short": "署名つきで中身を自分で検証できるトークン。", @@ -150,6 +170,7 @@ }, { "id": "migration", + "category": "network-data", "term": "migration", "match": ["マイグレーション", "マイグレ"], "short": "DB のスキーマ変更を手順として記録したファイル。", @@ -157,6 +178,7 @@ }, { "id": "debounce", + "category": "design-performance", "term": "debounce", "match": ["デバウンス"], "short": "連続した呼び出しを、止まってから 1 回にまとめる。", @@ -164,6 +186,7 @@ }, { "id": "throttle", + "category": "design-performance", "term": "throttle", "match": ["スロットル", "スロットリング", "throttling"], "short": "呼び出しの頻度を一定間隔までに制限する。", @@ -171,6 +194,7 @@ }, { "id": "exponential-backoff", + "category": "design-performance", "term": "exponential backoff", "match": ["指数バックオフ", "backoff"], "short": "リトライ間隔を 1秒, 2秒, 4秒…と伸ばしていく方式。", @@ -178,6 +202,7 @@ }, { "id": "single-source-of-truth", + "category": "design-performance", "term": "single source of truth", "match": ["SSOT", "信頼できる唯一の情報源"], "short": "同じ情報の正本を 1 箇所だけに持つ設計方針。", @@ -185,6 +210,7 @@ }, { "id": "feature-flag", + "category": "design-performance", "term": "feature flag", "match": ["フィーチャーフラグ", "feature toggle", "機能フラグ"], "short": "コードを出したまま機能のオン/オフを切り替える仕組み。", @@ -192,6 +218,7 @@ }, { "id": "cache", + "category": "design-performance", "term": "cache", "match": ["キャッシュ", "caching"], "short": "一度取得・計算したデータを保存し、次回から素早く使う仕組み。", @@ -199,6 +226,7 @@ }, { "id": "api", + "category": "network-data", "term": "API", "match": ["エーピーアイ", "application programming interface"], "short": "ソフトウェア同士が機能やデータをやり取りするための窓口。", @@ -206,6 +234,7 @@ }, { "id": "refactoring", + "category": "design-performance", "term": "refactoring", "match": ["リファクタリング", "リファクタ"], "short": "動作を変えずに、コードを読みやすく整理すること。", @@ -213,6 +242,7 @@ }, { "id": "object", + "category": "basics", "term": "object", "match": ["オブジェクト"], "short": "複数のデータや処理をひとまとまりにしたもの。", @@ -222,6 +252,7 @@ }, { "id": "class", + "category": "basics", "term": "class", "match": ["クラス"], "short": "同じ種類のオブジェクトを作るための設計図。", @@ -231,6 +262,7 @@ }, { "id": "instance", + "category": "basics", "term": "instance", "match": ["インスタンス"], "short": "クラスなどの設計をもとに実際に作られたオブジェクト。", @@ -239,6 +271,7 @@ }, { "id": "key", + "category": "basics", "term": "key", "match": ["キー", "property name", "プロパティ名"], "short": "オブジェクトなどから値を取り出すための名前。", @@ -247,6 +280,7 @@ }, { "id": "value", + "category": "basics", "term": "value", "match": ["バリュー", "値"], "short": "変数やキーに対応して保存されているデータ。", @@ -255,6 +289,7 @@ }, { "id": "path", + "category": "development", "term": "path", "match": ["パス", "ファイルパス"], "short": "ファイルやディレクトリの場所を表す文字列。", @@ -263,6 +298,7 @@ }, { "id": "directory", + "category": "development", "term": "directory", "match": ["ディレクトリ", "folder", "フォルダ"], "short": "ファイルや別のディレクトリをまとめて置く場所。", @@ -271,6 +307,7 @@ }, { "id": "root-directory", + "category": "development", "term": "root directory", "match": ["ルートディレクトリ", "root folder", "ルートフォルダ"], "short": "ディレクトリ階層のいちばん上にある場所。", @@ -279,6 +316,7 @@ }, { "id": "current-directory", + "category": "development", "term": "current directory", "match": ["カレントディレクトリ", "working directory", "作業ディレクトリ"], "short": "プログラムやコマンドが現在の基準にしているディレクトリ。", @@ -287,6 +325,7 @@ }, { "id": "javascript", + "category": "javascript", "term": "JavaScript", "match": ["ジャバスクリプト", "JS"], "short": "Web ページなどに動きや処理を加えるプログラミング言語。", @@ -296,6 +335,7 @@ }, { "id": "typescript", + "category": "javascript", "term": "TypeScript", "match": ["タイプスクリプト", "TS"], "short": "JavaScript に型の仕組みを加えたプログラミング言語。", @@ -305,6 +345,7 @@ }, { "id": "nodejs", + "category": "javascript", "term": "Node.js", "match": ["Node", "NodeJS", "ノード", "ノードジェイエス"], "short": "ブラウザの外で JavaScript を実行するための環境。", @@ -314,6 +355,7 @@ }, { "id": "vue", + "category": "frameworks", "term": "Vue.js", "match": ["Vue", "VueJS", "ビュー", "ビュージェイエス"], "short": "Web の画面を部品に分けて作るための JavaScript フレームワーク。", @@ -323,6 +365,7 @@ }, { "id": "nextjs", + "category": "frameworks", "term": "Next.js", "match": ["Next", "NextJS", "ネクスト", "ネクストジェイエス"], "short": "React を使った Web アプリ開発のためのフレームワーク。", @@ -332,6 +375,7 @@ }, { "id": "react", + "category": "frameworks", "term": "React", "match": ["React.js", "ReactJS", "リアクト"], "short": "Web の画面をコンポーネントとして作る JavaScript ライブラリ。", @@ -341,6 +385,7 @@ }, { "id": "python", + "category": "basics", "term": "Python", "match": ["パイソン"], "short": "読みやすく簡潔な文法を持つプログラミング言語。", @@ -350,6 +395,7 @@ }, { "id": "ruby", + "category": "basics", "term": "Ruby", "match": ["ルビー"], "short": "人が読み書きしやすいことを重視したプログラミング言語。", @@ -359,6 +405,7 @@ }, { "id": "html", + "category": "html-css", "term": "HTML", "match": ["エイチティーエムエル", "HyperText Markup Language"], "short": "Web ページの内容と構造を記述するためのマークアップ言語。", @@ -368,6 +415,7 @@ }, { "id": "css", + "category": "html-css", "term": "CSS", "match": ["シーエスエス", "Cascading Style Sheets"], "short": "Web ページの見た目やレイアウトを指定するための言語。", @@ -377,6 +425,7 @@ }, { "id": "vscode", + "category": "development", "term": "Visual Studio Code", "match": ["VS Code", "VSCode", "ブイエスコード"], "short": "プログラムの作成や編集に使うソースコードエディタ。", @@ -386,6 +435,7 @@ }, { "id": "nuxt", + "category": "frameworks", "term": "Nuxt", "match": ["Nuxt.js", "NuxtJS", "ナクスト"], "short": "Vue を使った Web アプリ開発のためのフレームワーク。", @@ -395,6 +445,7 @@ }, { "id": "terminal", + "category": "development", "term": "terminal", "match": ["ターミナル", "command line", "コマンドライン"], "short": "文字でコマンドを入力してコンピュータを操作する画面。", @@ -403,6 +454,7 @@ }, { "id": "github", + "category": "development", "term": "GitHub", "match": ["ギットハブ"], "short": "Git のリポジトリを保存・共有できるWebサービス。", @@ -411,6 +463,7 @@ }, { "id": "git", + "category": "development", "term": "Git", "match": ["ギット", "version control system", "バージョン管理システム"], "short": "ファイルの変更履歴を記録・管理するためのツール。", @@ -420,6 +473,7 @@ }, { "id": "commit", + "category": "development", "term": "commit", "match": ["コミット", "git commit"], "short": "Git でファイルの変更をひとまとまりとして記録すること。", @@ -429,6 +483,7 @@ }, { "id": "pull-request", + "category": "development", "term": "pull request", "match": ["プルリクエスト", "PR", "プルリク"], "short": "ブランチの変更を取り込み、レビューしてもらうための依頼。", @@ -437,6 +492,7 @@ }, { "id": "merge", + "category": "development", "term": "merge", "match": ["マージ", "git merge"], "short": "別のブランチにある変更履歴をひとつに統合すること。", @@ -446,6 +502,7 @@ }, { "id": "git-pull", + "category": "development", "term": "git pull", "match": ["pull", "プル"], "short": "リモートリポジトリの変更を取得し、現在のブランチへ反映する操作。", @@ -455,6 +512,7 @@ }, { "id": "git-push", + "category": "development", "term": "git push", "match": ["push", "プッシュ"], "short": "ローカルのコミットをリモートリポジトリへ送る操作。", @@ -464,6 +522,7 @@ }, { "id": "git-clone", + "category": "development", "term": "git clone", "match": ["clone", "クローン"], "short": "リモートリポジトリを履歴ごと手元へ複製する操作。", @@ -473,6 +532,7 @@ }, { "id": "git-checkout", + "category": "development", "term": "git checkout", "match": ["checkout", "チェックアウト"], "short": "ブランチを切り替えたり、ファイルを指定した状態へ戻したりする操作。", @@ -482,6 +542,7 @@ }, { "id": "git-fetch", + "category": "development", "term": "git fetch", "match": ["fetch", "フェッチ"], "short": "リモートリポジトリの最新情報を手元へ取得する操作。", @@ -491,6 +552,7 @@ }, { "id": "branch", + "category": "development", "term": "branch", "match": ["ブランチ", "git branch"], "short": "Git の変更履歴を枝分かれさせて作業する仕組み。", @@ -500,6 +562,7 @@ }, { "id": "repository", + "category": "development", "term": "repository", "match": ["リポジトリ", "repo", "レポジトリ"], "short": "Git がファイルと変更履歴をまとめて管理する場所。", @@ -509,6 +572,7 @@ }, { "id": "git-init", + "category": "development", "term": "git init", "match": ["init", "イニット", "初期化"], "short": "現在のディレクトリを新しい Git リポジトリにする操作。", @@ -518,6 +582,7 @@ }, { "id": "staging-area", + "category": "development", "term": "staging area", "match": ["stage", "staging", "ステージ", "ステージング", "index", "インデックス"], "short": "次のコミットに含める変更を一時的に置く場所。", @@ -527,6 +592,7 @@ }, { "id": "html-id", + "category": "html-css", "term": "id attribute", "match": ["id", "ID", "アイディー", "id属性"], "short": "HTML 要素にページ内で固有の名前を付ける属性。", @@ -537,6 +603,7 @@ }, { "id": "html-tag", + "category": "html-css", "term": "HTML tag", "match": ["tag", "タグ", "HTMLタグ"], "short": "HTML で要素の種類や範囲を示すための記号。", @@ -547,6 +614,7 @@ }, { "id": "css-margin", + "category": "html-css", "term": "margin", "match": ["マージン", "外側の余白"], "short": "CSS で要素の境界線より外側に作る余白。", @@ -557,6 +625,7 @@ }, { "id": "css-border", + "category": "html-css", "term": "border", "match": ["ボーダー", "境界線"], "short": "CSS で要素の周囲に表示する境界線。", @@ -567,6 +636,7 @@ }, { "id": "css-padding", + "category": "html-css", "term": "padding", "match": ["パディング", "内側の余白"], "short": "CSS で要素の内容と境界線の間に作る余白。", @@ -577,6 +647,7 @@ }, { "id": "css-height", + "category": "html-css", "term": "height", "match": ["ハイト", "高さ"], "short": "CSS で要素の高さを指定するプロパティ。", @@ -587,6 +658,7 @@ }, { "id": "css-width", + "category": "html-css", "term": "width", "match": ["ウィズ", "幅"], "short": "CSS で要素の横幅を指定するプロパティ。", @@ -597,6 +669,7 @@ }, { "id": "box-model", + "category": "html-css", "term": "box model", "match": ["ボックスモデル", "CSS box model"], "short": "HTML 要素を四角い領域として扱う CSS の考え方。", @@ -607,6 +680,7 @@ }, { "id": "responsive-web-design", + "category": "html-css", "term": "responsive web design", "match": ["レスポンシブウェブデザイン", "responsive design", "レスポンシブデザイン", "RWD"], "short": "画面の大きさに応じてレイアウトを調整する設計手法。", @@ -616,6 +690,7 @@ }, { "id": "npm", + "category": "development", "term": "npm", "match": ["エヌピーエム"], "short": "Node.js のパッケージを管理するためのツール。", @@ -625,6 +700,7 @@ }, { "id": "ubuntu", + "category": "development", "term": "Ubuntu", "match": ["ウブンツ"], "short": "使いやすさを重視して作られた Linux ディストリビューション。", @@ -634,10 +710,839 @@ }, { "id": "component", + "category": "frameworks", "term": "component", "match": ["コンポーネント", "UI component", "UIコンポーネント"], "short": "画面を構成する再利用可能な部品。", "detail": "ボタン、入力欄、ヘッダーなどを独立した単位として作り、組み合わせて画面を構築する。見た目や処理をまとめることで保守しやすくなる。", "example": "