diff --git a/README.md b/README.md index 59eff58..b8d7dfd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,86 @@ -# Nope — 見たくないもの見せません +# Nope — Hide what you don't want to see + +**日本語は下にあります。** [日本語セクションへ](#nope--見たくないもの見せません) + +![Nope cat-ear mascot with the kitepon.dev endorsement](assets/brand/nope-github-hero.png) + +## In 5 seconds + +Nope is a Chrome extension that hides sources and words you do not want to see, on the supported search and list pages. You choose those sources or keywords yourself, instead of leaving the page layout to the site. + +The UI follows the browser language: Japanese for `ja*`, English otherwise (`docs/adr/0001-ui-locale.md`). + +## Current distribution + +**v2.0.1 is live on the Chrome Web Store.** Install from the [Chrome Web Store](https://chromewebstore.google.com/detail/bodffbgmcokkhlibiehhelefknmbiaaf). The public source and this README are available now. + +Developers who want to inspect the source should use **Develop and test** below. Load unpacked is a developer check, not the install path for people using the product. + +## How to use it + +1. On a supported search or list page, block a source you do not want to see. +2. Choose whether blocked cards become a placeholder or disappear and collapse. +3. Open the Nope popup from the Chrome toolbar to manage sources by site, Yahoo News keywords, and the display mode. + +Keyword blocking is only on Yahoo News and Yahoo! JAPAN. Other surfaces block by source. + +## Before and after + +Before (the source is unblocked): + +![AliExpress search results showing product cards](docs/evidence/r5-smoke-restored.png) + +After (the same search page, with that source replaced by the Nope placeholder): + +![AliExpress search results with the target source replaced by the Nope placeholder](docs/evidence/r5-smoke-blocked.png) + +Only cards whose source can be identified are hidden. Cards that the site DOM no longer exposes, or whose source cannot be resolved, stay visible. + +## Seven service groups, eight surfaces -![Nopeの猫マスコットとkitepon.dev endorsement](assets/brand/nope-github-hero.png) +| Service group | Surfaces | What you can block | +|---|---|---| +| AliExpress | Search results, product page | Store | +| Rakuten Ichiba | Search results | Shop | +| Yahoo! Shopping | Search results | Store | +| Yahoo Auctions | Search results | Seller | +| Amazon.co.jp | Search results | Marketplace seller | +| YouTube | Search results, Home | Channel | +| Yahoo News group | Yahoo News, Yahoo! JAPAN | Publisher, keyword | + +Nope is a free Chrome extension. It does not cover every website or every surface on the sites above. + +## Privacy, permissions, limits + +- The only API permission is `storage`. Each service is declared in `content_scripts[].matches` so the feature can run on that surface. +- Blocked sources, display mode, and Yahoo News keywords are stored in `chrome.storage.sync`. Source-resolution cache is stored in `chrome.storage.local`. +- The extension does not send browsing data or block settings to developer analytics, tracking, or a backend. When a source must be resolved, it sends a public identifier only to the site you are already viewing. +- AliExpress JSONP responses run in that site's `MAIN` world. The Chrome Web Store remote-code declaration is therefore "yes". + +Read the [privacy policy](docs/store/privacy.md). Report surface bugs in [Issues](https://github.com/kitepon/Nope/issues). Japanese and English reports are accepted. There is no promised reply window. + +## Develop and test + +```sh +node --test test/ +node scripts/pack.mjs +``` + +`scripts/pack.mjs` rebuilds a ZIP and a stable unpacked tree in `dist/` with the manifest, runtime source, popup, icons, locale catalogs, and mascot images. Developers can load that unpacked tree in Chrome. + +Store-listing copy is in [docs/store](docs/store/). Per-site adapters are in [src/adapters](src/adapters/). Locale selection is in [docs/adr/0001-ui-locale.md](docs/adr/0001-ui-locale.md). + +## License + +[MIT License](LICENSE) + +--- + +# Nope — 見たくないもの見せません ## 5秒でわかる -Nopeは、見たくない発信元や言葉を、対応する検索結果・一覧から隠す日本語UIのChrome拡張です。Webサービス側に委ねていた表示を、発信元またはキーワード単位で自分で選び直せます。 +Nopeは、見たくない発信元や言葉を、対応する検索結果・一覧から隠すChrome拡張です。UIはブラウザ言語に従います(`ja*`は日本語、それ以外は英語)。Webサービス側に委ねていた表示を、発信元またはキーワード単位で自分で選び直せます。 ## 現在の配布状態 @@ -44,7 +120,7 @@ Nopeは、見たくない発信元や言葉を、対応する検索結果・一 | YouTube | 検索結果 | チャンネル | | Yahooニュース群 | Yahoo!ニュース・Yahoo! JAPAN | 出版社・キーワード | -NopeはChrome向け、日本語UI、無料の拡張です。すべてのWebサイトやすべての表示面には対応しません。 +NopeはChrome向け、無料の拡張です。すべてのWebサイトやすべての表示面には対応しません。 ## プライバシー・権限・制限 @@ -53,7 +129,7 @@ NopeはChrome向け、日本語UI、無料の拡張です。すべてのWebサ - 開発者のanalytics、tracking、backendへ閲覧情報やブロック設定を送信しません。発信元解決が必要な時だけ、閲覧中の対象サービス自身へ公開識別子を送信します。 - AliExpressのJSONP応答は同サイトの`MAIN` worldで実行します。このためChrome Web Storeのremote code申告は「はい」です。 -詳しくは[プライバシーポリシー](docs/store/privacy.md)を読んでください。対応面の不具合は[Issue](https://github.com/kitepon/Nope/issues)で報告できます。日本語で受け付け、返信期限や英語supportは約束しません。 +詳しくは[プライバシーポリシー](docs/store/privacy.md)を読んでください。対応面の不具合は[Issue](https://github.com/kitepon/Nope/issues)で報告できます。日本語と英語で受け付けます。返信期限は約束しません。 ## 開発・テスト @@ -62,9 +138,9 @@ node --test test/ node scripts/pack.mjs ``` -`scripts/pack.mjs`は、manifest、実行時source、popup、icons、マスコット画像だけを含むZIPとstable unpacked面を`dist/`へ再生成します。配布物はChromeのLoad unpackedで開発者が確認できます。 +`scripts/pack.mjs`は、manifest、実行時source、popup、icons、locale catalog、マスコット画像だけを含むZIPとstable unpacked面を`dist/`へ再生成します。配布物はChromeのLoad unpackedで開発者が確認できます。 -設計・Store申告の詳細は[docs/store](docs/store/)を、対応サイトごとの実装は[src/adapters](src/adapters/)を参照してください。 +設計・Store申告の詳細は[docs/store](docs/store/)を、対応サイトごとの実装は[src/adapters](src/adapters/)を、locale契約は[docs/adr/0001-ui-locale.md](docs/adr/0001-ui-locale.md)を参照してください。 ## ライセンス diff --git a/SECURITY.md b/SECURITY.md index cbd904e..d41bbea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,15 @@ # Security policy +If you think Nope has a vulnerability, is using a permission it should not, or is sending browsing data somewhere it should not, do not put details in a public Issue. Use GitHub private vulnerability reporting to the repository owner when that feature is available. + +If private reporting is not available, file an Issue with the smallest summary needed to reproduce the problem. Do not include cookies, auth tokens, browsing history, block lists, URL query strings, or full-page screenshots. + +English and Japanese reports are accepted. There is no promised response or fix window. + +--- + Nopeの脆弱性、意図しない権限利用、閲覧情報の送信が疑われる事象は、公開Issueに詳細を書かず、repository ownerへGitHubのprivate vulnerability reportingが利用可能な場合はそちらから報告してください。 private reportingが利用できない場合は、再現に必要な最小限の概要だけをIssueで知らせてください。Cookie、認証トークン、閲覧履歴、ブロック対象、URLのquery、画面全体のスクリーンショットは掲載しないでください。 -日本語での報告を受け付けます。返信期限や修正期限は約束しません。 +日本語と英語での報告を受け付けます。返信期限や修正期限は約束しません。 diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..9e5d465 --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,14 @@ +{ + "extName": { + "message": "Nope — Hide what you don't want to see", + "description": "Extension name in Chrome and the Web Store" + }, + "extDescription": { + "message": "Hide content from sources and keywords you choose, on the supported search and list pages.", + "description": "Extension description in Chrome and the Web Store" + }, + "extTitle": { + "message": "Nope", + "description": "Toolbar button tooltip" + } +} diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json new file mode 100644 index 0000000..0778a03 --- /dev/null +++ b/_locales/ja/messages.json @@ -0,0 +1,14 @@ +{ + "extName": { + "message": "Nope — 見たくないもの見せません", + "description": "Chrome とストアに出す拡張名" + }, + "extDescription": { + "message": "指定した発信元やキーワードをもとに、検索結果や一覧から見たくないコンテンツを隠します。", + "description": "Chrome とストアに出す拡張の説明" + }, + "extTitle": { + "message": "Nope", + "description": "ツールバーボタンのツールチップ" + } +} diff --git a/assets/store/README.md b/assets/store/README.md index e8ead2e..f55c7bc 100644 --- a/assets/store/README.md +++ b/assets/store/README.md @@ -11,7 +11,13 @@ Chrome Web Storeへそのまま入力する完成素材。2026-08-15のChrome We | `screenshot-03-manage.png` | 1280×800 / 8-bit RGB | 実extension popupの表示モード、発信元、キーワード管理 | `b5a0ec4c274b6a2f1eaa4c61d0f5589a4adc20c5b4040a7a9142c9262e1ad070` | | `screenshot-04-display-modes.png` | 1280×800 / 8-bit RGB | 同一検索面のplaceholder / collapse実画面比較 | `79072d638e233b92a8be0ea420e1fded5771956f4af400500d762f92978f40b3` | | `screenshot-05-supported-and-private.png` | 1280×800 / 8-bit RGB | 7サービス群・8対応面、Nope専用account、開発者serverへの送信境界 | `8ff8e687bc187885a8901455acda28139c8d1cb7b06bc9433e63d69675f496da` | +| `en/screenshot-01-placeholder.png` | 1280×800 / 8-bit RGB | English caption overlay on the same placeholder capture | generated 2026-08-24 | +| `en/screenshot-02-block-source.png` | 1280×800 / 8-bit RGB | English caption overlay on the same product-page capture | generated 2026-08-24 | +| `en/screenshot-03-manage.png` | 1280×800 / 8-bit RGB | English caption overlay and English popup chrome | generated 2026-08-24 | +| `en/screenshot-04-display-modes.png` | 1280×800 / 8-bit RGB | English caption overlay on the same display-mode comparison | generated 2026-08-24 | +| `en/screenshot-05-supported-and-private.png` | 1280×800 / 8-bit RGB | English supported-surfaces and privacy boundary card | generated 2026-08-24 | | `small-promo-440x280.png` | 440×280 / 8-bit RGB | Nope product identityを主役にしたsmall promo | `67a177a591e67b4e2b3b74d6d65302826b0c1a6e96c614b239a3ca7cd301a722` | +| `en/small-promo-440x280.png` | 440×280 / 8-bit RGB | Same identity, English slogan | generated 2026-08-24 | | `store-icon-128.png` | 128×128 / 8-bit RGBA | 現行iconを96×96で中央配置、四辺16px透明padding | `97fb9cb09d14f28c4512bea706dfc63206c6c7ac03f805e2b4af930e78c9ee08` | ## 制作境界 @@ -22,4 +28,4 @@ Chrome Web Storeへそのまま入力する完成素材。2026-08-15のChrome We - 5枚目のprivacy表現は「Nope専用account不要」「開発者serverへ送信しない」とし、発信元解決で閲覧中のサービス自身へ通信する事実を同じ画面に残した。「通信なし」にはしていない。 - product identityはNope mascotと禁止記号を先に置いた。mascot内の`kitepon.dev`は承認済みmaster endorsementであり、新しい合成logoは作っていない。 -raw captureはRootSitePromotionの`scripts/capture-nope-store-raw.mjs`、Store用書出しは同`render-nope-store-assets.mjs`で再現する。v2.0.1の完成素材はChrome Web Storeで一般公開済み。このREADMEの更新だけではライブCWSへの再提出にならない。marquee(Featured / ワイド)は作成しない。 +raw captureはRootSitePromotionの`scripts/capture-nope-store-raw.mjs`、Store用書出しは同`render-nope-store-assets.mjs`で再現する。英語captionと英語popup/promoは`scripts/render-en-store-assets.py`で、同じraw captureと現行ブランド色から書き出す。AliExpressのホストUIは2026-08-15の日本語ページのまま。v2.0.1の完成素材はChrome Web Storeで一般公開済み。このREADMEの更新だけではライブCWSへの再提出にならない。marquee(ワイド掲載用)は作成しない。 diff --git a/assets/store/en/screenshot-01-placeholder.png b/assets/store/en/screenshot-01-placeholder.png new file mode 100644 index 0000000..c1837f0 Binary files /dev/null and b/assets/store/en/screenshot-01-placeholder.png differ diff --git a/assets/store/en/screenshot-02-block-source.png b/assets/store/en/screenshot-02-block-source.png new file mode 100644 index 0000000..cdd7f77 Binary files /dev/null and b/assets/store/en/screenshot-02-block-source.png differ diff --git a/assets/store/en/screenshot-03-manage.png b/assets/store/en/screenshot-03-manage.png new file mode 100644 index 0000000..3b63149 Binary files /dev/null and b/assets/store/en/screenshot-03-manage.png differ diff --git a/assets/store/en/screenshot-04-display-modes.png b/assets/store/en/screenshot-04-display-modes.png new file mode 100644 index 0000000..8325a86 Binary files /dev/null and b/assets/store/en/screenshot-04-display-modes.png differ diff --git a/assets/store/en/screenshot-05-supported-and-private.png b/assets/store/en/screenshot-05-supported-and-private.png new file mode 100644 index 0000000..e48d502 Binary files /dev/null and b/assets/store/en/screenshot-05-supported-and-private.png differ diff --git a/assets/store/en/small-promo-440x280.png b/assets/store/en/small-promo-440x280.png new file mode 100644 index 0000000..2b17295 Binary files /dev/null and b/assets/store/en/small-promo-440x280.png differ diff --git a/docs/adr/0001-ui-locale.md b/docs/adr/0001-ui-locale.md new file mode 100644 index 0000000..30c6697 --- /dev/null +++ b/docs/adr/0001-ui-locale.md @@ -0,0 +1,28 @@ +# ADR 0001 — UI locale selection + +Date: 2026-08-24 (JST) + +## Status + +Accepted for the English-first catalog. Later locales add a catalog; they do not change this selection rule unless a later ADR replaces it. + +## Context + +The shipped UI was Japanese-only. `default_locale` / `_locales` had been added once and removed as YAGNI (`docs/evidence/t1-manifest.md`). Real installs are not Japan-only. There is no options page, onboarding flow, or context menu. User-visible strings live in the popup, injected page controls, toasts, error badges, and the manifest name / description. + +## Decision + +1. **Shipped catalogs**: `en` (default) and `ja`. +2. **Selection source**: Chrome UI language via `chrome.i18n.getUILanguage()` when the extension API exists; otherwise `navigator.language`. +3. **Matching**: `ja` and `ja-*` (including `ja_JP`) select Japanese. Any other tag, including empty or missing, selects English. +4. **Manifest**: `default_locale` is `en`. Name, description, and toolbar title use `__MSG_*__` so Chrome can show the matching catalog on `chrome://extensions` and the Web Store. +5. **Runtime**: `src/i18n.js` (`CB_I18N`) is the lookup used by popup and content scripts. This repo has no build step, so the full UI catalog lives in that file. `_locales/*/messages.json` holds the three manifest keys and must stay identical to `CB_I18N.MESSAGES`. +6. **Later locales**: add `_locales//` and a `CB_I18N.MESSAGES` catalog. Until a catalog ships, unmatched languages stay on English. +7. **Out of scope**: no Featured / marquee / wide listing treatment; no install-count or user-number claims; no live Chrome Web Store scrape or submit from agents. + +## Consequences + +- Japanese UI remains for `ja*` browsers. +- English is the fallback and the manifest default. +- Tests pin `selectLocale` and require every Japanese key to have a non-empty English string. +- Popup `lang` follows the selected catalog. Dates in the popup use `ja-JP` or `en`. diff --git a/docs/store/listing.md b/docs/store/listing.md index ebc8f84..850926c 100644 --- a/docs/store/listing.md +++ b/docs/store/listing.md @@ -1,38 +1,49 @@ -# Chrome Web Store 掲載情報(listing) +# Chrome Web Store listing copy -Chrome Web Storeデベロッパーダッシュボードの「Store listing」「Privacy practices」へ転記するv2.0.1の入力正本。v2.0.1は一般公開済み(https://chromewebstore.google.com/detail/bodffbgmcokkhlibiehhelefknmbiaaf)。Dashboardへの保存・再提出は別の操作gateで行う。この文書の更新だけではライブCWSへ再提出しない。 +Source text for the Chrome Web Store developer dashboard **Store listing** and **Privacy practices** tabs. v2.0.1は一般公開済み(https://chromewebstore.google.com/detail/bodffbgmcokkhlibiehhelefknmbiaaf)。Dashboard save / resubmit is a separate gate. Updating this document does not resubmit the live store. This agent does not scrape or change the live store. -事実確認元: `manifest.json`(permissions・content_scripts)、`src/storage.js`、`src/mtop.js`、`src/mtop-main-relay.js`、`src/content-item.js`、`popup/popup.html`、`docs/evidence/store-v2.0.1/`、RootSitePromotionの`docs/nope-product-contract.md`とChrome Web Store公式listing要件(2026-08-15時点)。 +Default listing locale is **English**. Keep **Japanese** as an additional locale. Do not apply a store-highlight treatment, and do not add a marquee or wide promotional image. + +Fact sources: `manifest.json`, `src/storage.js`, `src/mtop.js`, `src/mtop-main-relay.js`, `src/content-item.js`, `popup/popup.html`, `docs/adr/0001-ui-locale.md`, `docs/evidence/store-v2.0.1/`, and Chrome Web Store listing rules (checked 2026-08-15; locale contract added 2026-08-24 JST). --- -## 製品名・URL +## Product name and URLs -- **製品名**: Nope — 見たくないもの見せません +- **Product name (English, default)**: Nope — Hide what you don't want to see +- **Product name (Japanese)**: Nope — 見たくないもの見せません - **Homepage URL**: https://kitepon.dev/products/nope/ - **Support URL**: https://github.com/kitepon/Nope/issues - **Privacy policy URL**: https://github.com/kitepon/Nope/blob/main/docs/store/privacy.md --- -## Single purpose(単一目的の宣言) +## Single purpose -> ユーザーが指定した発信元またはキーワードに基づいて、閲覧中の Web ページから不要なコンテンツを非表示にする。 +> Hide unwanted content on the pages you are viewing, using sources or keywords you choose. + +**English (paste into the dashboard):** -**日本語(ダッシュボード入力用、そのまま):** +Hide unwanted content on the pages you are viewing, using sources or keywords you choose. + +**Japanese (paste into the Japanese locale):** ユーザーが指定した発信元またはキーワードに基づいて、閲覧中の Web ページから不要なコンテンツを非表示にします。 -**この宣言の根拠と実装の現状:** +This sentence summarizes the current implementation: seven service groups, eight surfaces, plus keyword blocking on the Yahoo News group. It is not a future promise. -v2.0.1では7サービス群・8対応面の発信元ブロックと、Yahooニュース群のキーワードブロックを実装済み(`docs/roadmap-block-targets.md`、`docs/evidence/nope-v2-terminal-audit.md`参照)。宣言文は将来像ではなく、現在の実装をそのまま要約している。 +The implementation spans several pages and the popup. Each piece is only a means to that one purpose. -拡張の実装は複数の対応ページとポップアップにまたがるが、すべて上記1目的のための手段でしかない。 +- `src/content-search.js` (search results): hides matching cards +- `src/content-name.js` (news lists): hides matching cards by display name +- `src/content-item.js` (product pages): the control that adds or removes a store from the block list +- `popup/`: review, add, and remove sources and keywords, and choose the display mode -- `src/content-search.js`(検索結果ページ): 非表示そのものを実行する中核機能 -- `src/content-name.js`(ニュース一覧): 表示名で発信元を判定し、非表示を実行する中核機能 -- `src/content-item.js`(商品ページ): 「このストアをブロック」ボタン=非表示対象(ブロックリスト)への追加・解除の入力手段 -- `popup/`(拡張アイコンクリック時): ブロック対象とキーワードの一覧表示・追加・削除・キャッシュクリア=非表示対象を管理する手段 +--- + +## 日本語の単一目的(ダッシュボード入力用) + +> ユーザーが指定した発信元またはキーワードに基づいて、閲覧中の Web ページから不要なコンテンツを非表示にする。 --- @@ -41,17 +52,18 @@ v2.0.1では7サービス群・8対応面の発信元ブロックと、Yahooニ Chrome Web Store の権限一覧には `manifest.json` の `permissions` と `content_scripts.matches` の両方がホストアクセスとして表示される。実際の `manifest.json`(v2.0.1時点)は次の通り: ```json +"default_locale": "en", "permissions": ["storage"], "content_scripts": [ { "matches": ["*://*.aliexpress.com/*"], "js": ["src/mtop-main-relay.js"], "world": "MAIN", "run_at": "document_start" }, - { "matches": ["*://*.aliexpress.com/*"], "js": ["src/md5.js", "src/storage.js", "src/mtop.js", "src/content-item.js", "src/content-search.js", "src/content-aliexpress-init.js"], "run_at": "document_idle" }, - { "matches": ["*://search.rakuten.co.jp/*"], "js": ["src/storage.js", "src/content-search.js", "src/adapters/rakuten.js"], "run_at": "document_idle" }, - { "matches": ["*://shopping.yahoo.co.jp/*"], "js": ["src/storage.js", "src/content-search.js", "src/adapters/yahoo_shopping.js"], "run_at": "document_idle" }, - { "matches": ["*://www.youtube.com/*"], "js": ["src/storage.js", "src/content-search.js", "src/adapters/youtube.js"], "run_at": "document_idle" }, - { "matches": ["*://auctions.yahoo.co.jp/*"], "js": ["src/storage.js", "src/content-search.js", "src/adapters/yahoo_auction.js"], "run_at": "document_idle" }, - { "matches": ["*://www.amazon.co.jp/*"], "js": ["src/storage.js", "src/content-search.js", "src/adapters/amazon.js"], "run_at": "document_idle" }, - { "matches": ["*://news.yahoo.co.jp/*"], "js": ["src/storage.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_news.js"], "run_at": "document_idle" }, - { "matches": ["*://www.yahoo.co.jp/*"], "js": ["src/storage.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_japan.js"], "run_at": "document_idle" } + { "matches": ["*://*.aliexpress.com/*"], "js": ["src/md5.js", "src/storage.js", "src/i18n.js", "src/mtop.js", "src/content-item.js", "src/content-search.js", "src/content-aliexpress-init.js"], "run_at": "document_idle" }, + { "matches": ["*://search.rakuten.co.jp/*"], "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/rakuten.js"], "run_at": "document_idle" }, + { "matches": ["*://shopping.yahoo.co.jp/*"], "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/yahoo_shopping.js"], "run_at": "document_idle" }, + { "matches": ["*://www.youtube.com/*"], "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/youtube.js"], "run_at": "document_idle" }, + { "matches": ["*://auctions.yahoo.co.jp/*"], "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/yahoo_auction.js"], "run_at": "document_idle" }, + { "matches": ["*://www.amazon.co.jp/*"], "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/amazon.js"], "run_at": "document_idle" }, + { "matches": ["*://news.yahoo.co.jp/*"], "js": ["src/storage.js", "src/i18n.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_news.js"], "run_at": "document_idle" }, + { "matches": ["*://www.yahoo.co.jp/*"], "js": ["src/storage.js", "src/i18n.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_japan.js"], "run_at": "document_idle" } ] ``` @@ -135,13 +147,38 @@ Amazon.co.jp の検索結果ページのみで実行される。 --- -## Description(説明文) +## Description + +### Short description — English (132 characters or fewer) -### 短い説明(Short description、132文字以内) +> Hide sources and keywords you choose on supported search and list pages. Seven service groups, eight surfaces. + +### Detailed description — English + +> A Chrome extension that hides content from sources and keywords you choose, on the pages you are viewing. +> +> What it does +> - Block shops, channels, and publishers on the supported search and list pages +> - Add a source from the on-page block control +> - On Yahoo News and Yahoo! JAPAN, also block articles that contain a keyword you add +> - Review and remove blocked sources and keywords in the toolbar popup +> - Show blocked cards as a placeholder, or hide them and collapse the list +> +> Data +> - Page URLs, titles, and source identifiers are processed in the browser to decide what to hide +> - On AliExpress, a site-issued token is used only to sign product-to-store lookups +> - The extension does not send user data to developer servers or unrelated third parties +> - The block list and settings are stored with chrome.storage +> - Source-resolution requests go only to the site you are already viewing +> +> Coverage +> Seven service groups and eight surfaces. Surfaces, limits, and current behavior are on the product page and in the public source. + +### 短い説明 — 日本語(132文字以内) > 指定した発信元やキーワードのコンテンツを閲覧中のWebページから非表示にします。7サービス群・8対応面に対応。 -### 詳細説明(Detailed description、実入力) +### 詳細説明 — 日本語(実入力) > 閲覧中のWebページから、指定した発信元やキーワードのコンテンツを非表示にするChrome拡張機能です。 > @@ -169,7 +206,9 @@ Amazon.co.jp の検索結果ページのみで実行される。 ## Category / Language(カテゴリ・言語) - **Category**: Tools(ショッピング・動画・ニュースを横断する汎用コンテンツフィルターであり、特定の買い物体験だけを主機能としないため。Chrome Web Store の現行カテゴリ定義では「他カテゴリに収まらないツール」に該当) -- **Language**: 日本語(ja)のみ。`popup/popup.html` は `lang="ja"`、拡張内メッセージもすべて日本語。多言語対応は未実装のため、英語等での申請はしない +- **Language (default listing)**: English (`en`). Manifest `default_locale` is `en`. The UI follows the browser language (`ja*` → Japanese, otherwise English). See `docs/adr/0001-ui-locale.md`. +- **Additional listing locale**: Japanese (`ja`). Keep the Japanese name, descriptions, and screenshots. +- Do not apply a store-highlight treatment. Do not upload a marquee or wide promotional image. - **Pricing**: 料金なし(アプリ内購入なし) - **Visibility**: 公開 - **Regions**: すべての地域 @@ -190,14 +229,26 @@ v2.0.1をChrome for Testing 152.0.7977.42へLoad unpackedし、実在するAliEx | 4 | 同じ検索面におけるplaceholderとcollapseの表示差 | `assets/store/screenshot-04-display-modes.png` | | 5 | 7サービス群・8対応面、Nope専用account不要、開発者serverへ送信しない境界 | `assets/store/screenshot-05-supported-and-private.png` | +### English listing screenshots + +English caption overlays use the same 2026-08-15 raw captures. In-page host UI may still be Japanese because those captures were taken on `ja.aliexpress.com`. Recapture of live pages is out of scope here (AliExpress blocks automated browsers). + +| # | Scene | File | +|---|---|---| +| 1 | Blocked store cards replaced by the Nope mascot | `assets/store/en/screenshot-01-placeholder.png` | +| 2 | Block-this-store control on a real product page | `assets/store/en/screenshot-02-block-source.png` | +| 3 | English popup chrome (same blocked store as the 2026-08-15 capture; illustrated, not a new live session) | `assets/store/en/screenshot-03-manage.png` | +| 4 | Placeholder versus collapse on the same search page | `assets/store/en/screenshot-04-display-modes.png` | +| 5 | Seven service groups, eight surfaces, no Nope account, no developer server | `assets/store/en/screenshot-05-supported-and-private.png` | + 5枚とも1280×800・8-bit RGB・アルファなしのPNG。旧名、QA用余白、debug表示、個人情報、account情報を含まない。 ### Store icon / promotional image - **Store icon(128×128)**: `assets/store/store-icon-128.png` - 現行Nope iconを96×96で中央配置し、四辺へ16pxの透明paddingを確保 -- **Small promo tile(440×280)**: `assets/store/small-promo-440x280.png` - - 現行Nope product identityを先に置き、短い日本語価値だけを表示 -- **Marquee(1400×560)**: 作成しない。任意素材であり、Featured / ワイド掲載用には使わない。 +- **Small promo tile(440×280), Japanese listing**: `assets/store/small-promo-440x280.png` +- **Small promo tile(440×280), English listing**: `assets/store/en/small-promo-440x280.png` +- **Marquee**: Do not create. Optional, and not a required field before publish. Do not add a wide promotional image. v2.0.1のlistingはChrome Web Storeで一般公開済み。画像のDashboard保存、審査取消、再申請、publishはこのlisting契約の更新だけでは実施しない。 diff --git a/docs/store/privacy.md b/docs/store/privacy.md index dbe2d12..1842c08 100644 --- a/docs/store/privacy.md +++ b/docs/store/privacy.md @@ -1,6 +1,34 @@ +# Nope privacy policy + +Last updated: 2026-08-24 (JST) + +Public URL (Chrome Web Store): https://github.com/kitepon/Nope/blob/main/docs/store/privacy.md + +This page is the single privacy-policy URL for both the English and Japanese store listings. The Japanese text follows the English text. + +Nope — Hide what you don't want to see ("the extension") processes the page content and URLs of supported sites in the browser so it can decide what to hide. On AliExpress it briefly reads the token part of that site's `_m_h5_tk` cookie, only to sign a product-to-store lookup. The developer does not collect or receive this data. There is no analytics, advertising, or tracking traffic, and no developer server. + +The placeholder and popup footer include a fixed link to `kitepon.dev` so the operator is visible. The link uses only `utm_source=nope`, `utm_medium=chrome_extension`, `utm_campaign=nope-brand-link`, and a fixed `utm_content` for placement. It does not include the page you were viewing, block targets, keywords, the current URL, query, or fragment. + +Data the extension handles in order to provide the feature, using Chrome Web Store categories: + +- **Authentication information**: the token part of AliExpress `_m_h5_tk`, used only to sign the product-to-store request, not stored by the extension, sent only to AliExpress over HTTPS. +- **Web history**: the URL of the supported page you are viewing and URLs inside list cards, used only to identify the surface and the source. The extension does not build, store, or send a browsing-history list. +- **Website content**: titles, links, and source identifiers on product, video, and article cards, used only to decide what to hide and what to show. + +It does not handle names, addresses, email, health, financial, payment, personal communications, or location data. It does not record clicks, mouse position, scroll, or keystrokes. + +Stored data stays in `chrome.storage` on the user's device (and Chrome sync destinations). There is no developer backend. See the Japanese table below for keys and sync scope. + +Network requests for source resolution go only to the site you are already viewing (AliExpress, Yahoo Auctions, Amazon.co.jp, YouTube). Other supported sites use the visible DOM only. + +The UI language follows the browser (`ja*` → Japanese, otherwise English). That choice does not change what data is processed. + +--- + # Nope — 見たくないもの見せません プライバシーポリシー -最終更新日: 2026-08-15 +最終更新日: 2026-08-24 公開 URL(Chrome Web Store 申告用): https://github.com/kitepon/Nope/blob/main/docs/store/privacy.md @@ -89,3 +117,4 @@ Chrome Web Storeの分類に沿うと、本拡張が機能提供のために取 - 2026-08-11: YouTube(yt-home-search)へhandle→チャンネルID解決の通信を追加(ブロック/解除操作時のみ) - 2026-08-12: Chrome Web Store公式定義に合わせ、端末内処理を含むデータ取り扱い(認証情報・ウェブ履歴・ウェブサイト内容)を明示 - 2026-08-15: v2.0.1の固定campaignによる`kitepon.dev` root linkを追記。extension内analyticsを追加しない境界を明記 +- 2026-08-24: 英語本文を同じURLへ追加。UI locale契約(`ja*`は日本語、それ以外は英語)を追記。利用者数は記載しない diff --git a/docs/store/submission-checklist.md b/docs/store/submission-checklist.md index 52eb0ba..bd1633d 100644 --- a/docs/store/submission-checklist.md +++ b/docs/store/submission-checklist.md @@ -25,7 +25,9 @@ v2.0.1はChrome Web Storeで一般公開済み。公開URL: https://chromewebsto - [ ] Single purpose description - [ ] Category: Tools -- [ ] Language: 日本語(ja) +- [ ] Language: English (en) as the default listing locale, plus Japanese (ja) +- [ ] English short / detailed description from `docs/store/listing.md` +- [ ] English screenshots from `assets/store/en/` if the English locale is being filled - [ ] Short description(132文字以内。現在実装の価値と7サービス群・8対応面を過不足なく記載) - [ ] Detailed description - [ ] Screenshots(1280×800、`docs/store/listing.md`の5枚を表の順序でアップロード) diff --git a/manifest.json b/manifest.json index 08daf67..d517cd0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,9 @@ { "manifest_version": 3, - "name": "Nope — 見たくないもの見せません", + "default_locale": "en", + "name": "__MSG_extName__", "version": "2.0.1", - "description": "指定した発信元やキーワードをもとに、検索結果や一覧から見たくないコンテンツを隠します。", + "description": "__MSG_extDescription__", "permissions": ["storage"], "icons": { "16": "icons/icon16.png", @@ -11,7 +12,7 @@ }, "action": { "default_popup": "popup/popup.html", - "default_title": "Nope", + "default_title": "__MSG_extTitle__", "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", @@ -42,42 +43,42 @@ }, { "matches": ["*://*.aliexpress.com/*"], - "js": ["src/md5.js", "src/storage.js", "src/mtop.js", "src/content-item.js", "src/content-search.js", "src/content-aliexpress-init.js"], + "js": ["src/md5.js", "src/storage.js", "src/i18n.js", "src/mtop.js", "src/content-item.js", "src/content-search.js", "src/content-aliexpress-init.js"], "run_at": "document_idle" }, { "matches": ["*://search.rakuten.co.jp/*"], - "js": ["src/storage.js", "src/content-search.js", "src/adapters/rakuten.js"], + "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/rakuten.js"], "run_at": "document_idle" }, { "matches": ["*://shopping.yahoo.co.jp/*"], - "js": ["src/storage.js", "src/content-search.js", "src/adapters/yahoo_shopping.js"], + "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/yahoo_shopping.js"], "run_at": "document_idle" }, { "matches": ["*://www.youtube.com/*"], - "js": ["src/storage.js", "src/content-search.js", "src/adapters/youtube.js"], + "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/youtube.js"], "run_at": "document_idle" }, { "matches": ["*://auctions.yahoo.co.jp/*"], - "js": ["src/storage.js", "src/content-search.js", "src/adapters/yahoo_auction.js"], + "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/yahoo_auction.js"], "run_at": "document_idle" }, { "matches": ["*://www.amazon.co.jp/*"], - "js": ["src/storage.js", "src/content-search.js", "src/adapters/amazon.js"], + "js": ["src/storage.js", "src/i18n.js", "src/content-search.js", "src/adapters/amazon.js"], "run_at": "document_idle" }, { "matches": ["*://news.yahoo.co.jp/*"], - "js": ["src/storage.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_news.js"], + "js": ["src/storage.js", "src/i18n.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_news.js"], "run_at": "document_idle" }, { "matches": ["*://www.yahoo.co.jp/*"], - "js": ["src/storage.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_japan.js"], + "js": ["src/storage.js", "src/i18n.js", "src/keyword-filter.js", "src/content-name.js", "src/adapters/yahoo_japan.js"], "run_at": "document_idle" } ] diff --git a/popup/popup.css b/popup/popup.css index d1ec63d..e6902b3 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -13,7 +13,7 @@ body { width: 320px; margin: 0; padding: 12px; - font-family: "Hiragino Sans", "Yu Gothic", Meiryo, sans-serif; + font-family: "Hiragino Sans", "Yu Gothic", Meiryo, system-ui, sans-serif; font-size: 13px; background: var(--paper); color: var(--ink); diff --git a/popup/popup.html b/popup/popup.html index 2ba6f86..1a47223 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -1,5 +1,5 @@ - + Nope @@ -9,9 +9,9 @@

Nope

- ブロック済みの表示 - - + Blocked item display + +
@@ -23,25 +23,26 @@

Nope

KEYWORD BLOCK

- - + +

- + + diff --git a/popup/popup.js b/popup/popup.js index 040c3ea..eba61e9 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -6,21 +6,15 @@ 'use strict'; const CB_POPUP = (() => { - /** サイトキー → 表示名のマップ。 */ - const SITE_LABELS = { - aliexpress: 'AliExpress', - rakuten: '楽天市場', - yahoo_shopping: 'Yahoo!ショッピング', - yahoo_auctions: 'ヤフオク', - amazon: 'Amazon', - youtube: 'YouTube', - yahoo_news: 'Yahoo ニュース', - yahoo_japan: 'Yahoo! JAPAN', - }; + /** @param {string} siteKey @returns {string} */ + function siteLabel(siteKey) { + const label = CB_I18N.t(`site_${siteKey}`); + return label === `site_${siteKey}` ? siteKey : label; + } /** @param {number} timestamp @returns {string} */ function formatDate(timestamp) { - return new Date(timestamp).toLocaleString('ja-JP'); + return new Date(timestamp).toLocaleString(CB_I18N.dateLocale()); } /** addedAt降順。 @param {Record} entries */ @@ -40,9 +34,8 @@ const CB_POPUP = (() => { const header = document.createElement('div'); header.className = 'cb-site-header'; - const siteLabel = SITE_LABELS[siteKey] ?? siteKey; const count = Object.keys(entries).length; - header.textContent = siteLabel; + header.textContent = siteLabel(siteKey); const countSpan = document.createElement('span'); countSpan.className = 'cb-site-count'; countSpan.textContent = `(${count})`; @@ -77,14 +70,14 @@ const CB_POPUP = (() => { if (info.nameOnly) { const warning = document.createElement('span'); warning.className = 'cb-name-warning'; - warning.textContent = '⚠ 表示名で判定:改名・同名の別発信元に注意'; - warning.title = '表示名でブロックしているため、発信元が改名すると解除され、同名の別発信元も誤ってブロックする可能性があります'; + warning.textContent = CB_I18N.t('nameOnlyWarning'); + warning.title = CB_I18N.t('nameOnlyWarningTitle'); li.append(warning); } const removeBtn = document.createElement('button'); removeBtn.type = 'button'; - removeBtn.textContent = '削除'; + removeBtn.textContent = CB_I18N.t('remove'); removeBtn.addEventListener('click', () => onRemove(sourceId)); li.append(removeBtn); return li; @@ -106,7 +99,7 @@ const CB_POPUP = (() => { if (activeSites.length === 0) { const empty = document.createElement('p'); empty.className = 'cb-empty'; - empty.textContent = 'ブロック中の発信元はありません'; + empty.textContent = CB_I18N.t('emptyBlocked'); containerEl.append(empty); return; } @@ -133,7 +126,7 @@ const CB_POPUP = (() => { if (keywords.length === 0) { const empty = document.createElement('li'); empty.className = 'cb-keyword-empty'; - empty.textContent = 'キーワードは登録されていません'; + empty.textContent = CB_I18N.t('emptyKeywords'); listEl.append(empty); return; } @@ -149,7 +142,7 @@ const CB_POPUP = (() => { const removeBtn = document.createElement('button'); removeBtn.type = 'button'; - removeBtn.textContent = '削除'; + removeBtn.textContent = CB_I18N.t('remove'); removeBtn.addEventListener('click', async () => { await CB_STORAGE.removeBlockedKeyword(siteKey, keyword); await renderKeywordList(listEl, siteKey); @@ -171,6 +164,9 @@ const CB_POPUP = (() => { } function init() { + CB_I18N.applyDocument(document); + document.title = CB_I18N.t('extTitle'); + const blockedListEl = document.getElementById('blocked-list'); const keywordSiteEl = document.getElementById('keyword-site'); const keywordFormEl = document.getElementById('keyword-form'); @@ -189,7 +185,7 @@ const CB_POPUP = (() => { const site = currentKeywordSite(); const keywords = await CB_STORAGE.getBlockedKeywords(site); if (keywords.includes(keyword)) { - keywordStatusEl.textContent = 'すでに登録済みです'; + keywordStatusEl.textContent = CB_I18N.t('keywordAlreadyAdded'); return; } await CB_STORAGE.addBlockedKeyword(site, keyword); @@ -213,7 +209,7 @@ const CB_POPUP = (() => { } return { - SITE_LABELS, + siteLabel, formatDate, sortEntries, renderSiteGroup, diff --git a/scripts/pack.mjs b/scripts/pack.mjs index e3547ca..a8bedfb 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -1,6 +1,6 @@ // 配布用 ZIP と、README記載の「開発者モードでの読み込み(Load unpacked)」用の // 展開済みディレクトリ(stable unpacked面)を生成するスクリプト。 -// 同梱するのは manifest.json / src/ / popup/ / icons/ / 実行時に使うマスコット2画像のみ。 +// 同梱するのは manifest.json / _locales/ / src/ / popup/ / icons/ / 実行時に使うマスコット2画像のみ。 // assets/mascot-source.png(2048x2048、開発用の原本)は実行時に参照されないため、 // 配布物に含めない。ディレクトリ丸ごとではなく個別ファイルとして指定することで除外している。 // @@ -30,6 +30,7 @@ const repoRoot = resolve(__dirname, ".."); const INCLUDE_ENTRIES = [ "manifest.json", + "_locales", "src", "popup", "icons", diff --git a/scripts/render-en-store-assets.py b/scripts/render-en-store-assets.py new file mode 100644 index 0000000..a98f1ca --- /dev/null +++ b/scripts/render-en-store-assets.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Write English Chrome Web Store visuals from the existing branded captures. + +Does not invent product results. Host-site chrome on AliExpress captures stays +as photographed. Only Nope-authored caption panels, the popup illustration, +the supported-surfaces card, and the small promo slogan are rewritten. +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "assets" / "store" +OUT = SRC / "en" + +PAPER = (245, 240, 227) +PAPER_CARD = (249, 246, 239) +INK = (23, 27, 47) +INK_SOFT = (17, 27, 53) +ORANGE = (239, 141, 50) +ORANGE_DEEP = (168, 68, 0) +RED = (214, 54, 50) +WHITE = (255, 254, 249) +NAVY = (17, 27, 53) +COBALT = (33, 73, 170) + +FONT_REG = "/usr/share/fonts/truetype/macos/Inter-Regular.ttf" +FONT_MED = "/usr/share/fonts/truetype/macos/Inter-Medium.ttf" +FONT_SEMI = "/usr/share/fonts/truetype/macos/Inter-SemiBold.ttf" +FONT_BOLD = "/usr/share/fonts/truetype/macos/Inter-Bold.ttf" + + +def font(path, size): + return ImageFont.truetype(path, size) + + +def rounded(draw, box, radius, fill, outline=None, width=1): + draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width) + + +def draw_text(draw, xy, text, face, fill, anchor="lt"): + draw.text(xy, text, font=face, fill=fill, anchor=anchor) + + +def caption_card(draw, box, kicker, title, lines=None): + rounded(draw, box, 18, PAPER_CARD, outline=(233, 226, 210), width=2) + x0, y0, x1, y1 = box + draw_text(draw, (x0 + 22, y0 + 18), kicker, font(FONT_SEMI, 16), ORANGE_DEEP) + draw_text(draw, (x0 + 22, y0 + 48), title, font(FONT_BOLD, 28), INK) + if lines: + y = y0 + 92 + for line in lines: + draw_text(draw, (x0 + 22, y), line, font(FONT_REG, 16), INK_SOFT) + y += 24 + + +def save_rgb(image, path): + path.parent.mkdir(parents=True, exist_ok=True) + image.convert("RGB").save(path, "PNG") + print("wrote", path) + + +def render_screenshot_01(): + im = Image.open(SRC / "screenshot-01-placeholder.png").convert("RGB") + draw = ImageDraw.Draw(im) + rounded(draw, (28, 596, 470, 772), 18, PAPER_CARD, outline=(233, 226, 210), width=2) + draw_text(draw, (50, 616), "NOPE · DEFAULT MODE", font(FONT_SEMI, 15), ORANGE_DEEP) + draw_text(draw, (50, 646), "Replace unwanted stores", font(FONT_BOLD, 26), INK) + draw_text(draw, (50, 682), "with the mascot", font(FONT_BOLD, 26), INK) + save_rgb(im, OUT / "screenshot-01-placeholder.png") + + +def render_screenshot_02(): + im = Image.open(SRC / "screenshot-02-block-source.png").convert("RGB") + draw = ImageDraw.Draw(im) + rounded(draw, (28, 28, 430, 168), 18, PAPER_CARD, outline=(233, 226, 210), width=2) + draw_text(draw, (50, 48), "NOPE · ONE CLICK", font(FONT_SEMI, 15), ORANGE_DEEP) + draw_text(draw, (50, 80), "Block a store from", font(FONT_BOLD, 26), INK) + draw_text(draw, (50, 114), "the product page", font(FONT_BOLD, 26), INK) + + rounded(draw, (780, 596, 1252, 772), 18, PAPER_CARD, outline=(233, 226, 210), width=2) + draw_text(draw, (802, 618), "Control added to the live page", font(FONT_SEMI, 18), INK) + draw_text(draw, (802, 656), "The injected action uses the browser", font(FONT_REG, 16), INK_SOFT) + draw_text(draw, (802, 682), "language. This capture was taken on the", font(FONT_REG, 16), INK_SOFT) + draw_text(draw, (802, 708), "Japanese AliExpress page.", font(FONT_REG, 16), INK_SOFT) + save_rgb(im, OUT / "screenshot-02-block-source.png") + + +def render_screenshot_03(): + im = Image.new("RGB", (1280, 800), PAPER) + draw = ImageDraw.Draw(im) + + # popup card + rounded(draw, (72, 70, 430, 730), 16, WHITE, outline=(239, 229, 210), width=2) + draw_text(draw, (96, 94), "Nope", font(FONT_BOLD, 22), INK) + rounded(draw, (96, 136, 406, 230), 8, WHITE, outline=(251, 229, 210), width=1) + draw_text(draw, (110, 148), "Blocked item display", font(FONT_SEMI, 12), ORANGE_DEEP) + draw_text(draw, (110, 176), "● Replace with placeholder", font(FONT_REG, 14), INK) + draw_text(draw, (110, 200), "○ Hide and collapse", font(FONT_REG, 14), INK) + + draw_text(draw, (96, 252), "BLOCKED", font(FONT_SEMI, 11), ORANGE_DEEP) + rounded(draw, (96, 274, 406, 318), 6, (251, 229, 210)) + draw_text(draw, (110, 288), "AliExpress (1)", font(FONT_SEMI, 13), ORANGE_DEEP) + rounded(draw, (96, 318, 406, 368), 0, WHITE) + draw_text(draw, (110, 336), "Bestselling Makeup Store", font(FONT_REG, 13), INK) + rounded(draw, (318, 330, 394, 354), 4, WHITE, outline=ORANGE_DEEP, width=1) + draw_text(draw, (356, 342), "Remove", font(FONT_REG, 11), ORANGE_DEEP, anchor="mm") + + draw_text(draw, (96, 392), "KEYWORD BLOCK", font(FONT_SEMI, 11), ORANGE_DEEP) + rounded(draw, (96, 414, 406, 446), 6, WHITE, outline=(251, 229, 210), width=1) + draw_text(draw, (110, 430), "Yahoo News", font(FONT_REG, 13), INK) + rounded(draw, (96, 458, 318, 490), 6, WHITE, outline=(251, 229, 210), width=1) + draw_text(draw, (110, 474), "Keyword", font(FONT_REG, 13), (160, 160, 160)) + rounded(draw, (328, 458, 406, 490), 6, (198, 83, 0)) + draw_text(draw, (367, 474), "Add", font(FONT_SEMI, 13), WHITE, anchor="mm") + draw_text(draw, (110, 516), "generative AI", font(FONT_REG, 13), INK) + rounded(draw, (318, 504, 394, 528), 4, WHITE, outline=ORANGE_DEEP, width=1) + draw_text(draw, (356, 516), "Remove", font(FONT_REG, 11), ORANGE_DEEP, anchor="mm") + + rounded(draw, (96, 560, 230, 592), 6, WHITE, outline=ORANGE_DEEP, width=1) + draw_text(draw, (163, 576), "Clear cache", font(FONT_REG, 12), ORANGE_DEEP, anchor="mm") + draw_text(draw, (406, 700), "kitepon.dev", font(FONT_SEMI, 12), COBALT, anchor="rb") + + draw_text(draw, (520, 180), "NOPE · MANAGE", font(FONT_SEMI, 16), ORANGE_DEEP) + draw_text(draw, (520, 216), "Manage blocks", font(FONT_BOLD, 40), INK) + draw_text(draw, (520, 268), "in one popup", font(FONT_BOLD, 40), INK) + + rows = [ + ("Display mode", "Placeholder / collapse"), + ("Sources", "Review and remove by site"), + ("Keywords", "Add and remove"), + ] + y = 360 + for label, value in rows: + rounded(draw, (520, y, 1180, y + 72), 10, WHITE) + draw_text(draw, (544, y + 36), label, font(FONT_SEMI, 16), ORANGE_DEEP, anchor="lm") + draw_text(draw, (780, y + 36), value, font(FONT_REG, 18), INK, anchor="lm") + y += 88 + save_rgb(im, OUT / "screenshot-03-manage.png") + + +def render_screenshot_04(): + im = Image.open(SRC / "screenshot-04-display-modes.png").convert("RGB") + draw = ImageDraw.Draw(im) + rounded(draw, (20, 16, 820, 168), 0, PAPER) + draw_text(draw, (40, 36), "NOPE · DISPLAY MODE", font(FONT_SEMI, 15), ORANGE_DEEP) + draw_text(draw, (40, 68), "Switch the view whenever you want", font(FONT_BOLD, 28), INK) + + # Original navy comparison headers occupy y≈175–266. + rounded(draw, (28, 175, 628, 266), 0, NAVY) + draw_text(draw, (52, 196), "Replace", font(FONT_SEMI, 22), ORANGE) + draw_text(draw, (52, 230), "See where the blocked cards were", font(FONT_REG, 16), WHITE) + + rounded(draw, (652, 175, 1252, 266), 0, NAVY) + draw_text(draw, (676, 196), "Hide and collapse", font(FONT_SEMI, 22), ORANGE) + draw_text(draw, (676, 230), "Remove them and pack the list", font(FONT_REG, 16), WHITE) + save_rgb(im, OUT / "screenshot-04-display-modes.png") + + +def render_screenshot_05(): + im = Image.new("RGB", (1280, 800), PAPER) + draw = ImageDraw.Draw(im) + rounded(draw, (0, 0, 360, 800), 0, NAVY) + icon = Image.open(SRC / "store-icon-128.png").convert("RGBA").resize((160, 160)) + im.paste(icon, (100, 72), icon) + draw = ImageDraw.Draw(im) + draw_text(draw, (40, 268), "SUPPORTED SURFACES", font(FONT_SEMI, 14), (168, 196, 230)) + draw_text(draw, (40, 304), "7 service", font(FONT_BOLD, 36), WHITE) + draw_text(draw, (40, 350), "groups", font(FONT_BOLD, 36), WHITE) + draw_text(draw, (40, 410), "8 surfaces", font(FONT_BOLD, 36), ORANGE) + + chips = [ + (400, 80, 640, 150, "AliExpress"), + (660, 80, 900, 150, "Rakuten Ichiba"), + (920, 80, 1240, 150, "Yahoo! Shopping"), + (400, 170, 640, 240, "Yahoo Auctions"), + (660, 170, 900, 240, "Amazon.co.jp"), + (920, 170, 1240, 240, "YouTube"), + (400, 260, 1240, 330, "Yahoo News / Yahoo! JAPAN"), + ] + for x0, y0, x1, y1, label in chips: + rounded(draw, (x0, y0, x1, y1), 14, WHITE) + draw_text(draw, ((x0 + x1) / 2, (y0 + y1) / 2), label, font(FONT_SEMI, 18), INK, anchor="mm") + + rounded(draw, (400, 400, 800, 520), 16, RED) + draw_text(draw, (600, 444), "No Nope account", font(FONT_BOLD, 22), WHITE, anchor="mm") + draw_text(draw, (600, 478), "required", font(FONT_BOLD, 22), WHITE, anchor="mm") + rounded(draw, (840, 400, 1240, 520), 16, COBALT) + draw_text(draw, (1040, 444), "Not sent to a", font(FONT_BOLD, 22), WHITE, anchor="mm") + draw_text(draw, (1040, 478), "developer server", font(FONT_BOLD, 22), WHITE, anchor="mm") + + draw_text( + draw, + (400, 580), + "Source-resolution requests stay on the site you are already viewing.", + font(FONT_REG, 16), + (90, 90, 100), + ) + save_rgb(im, OUT / "screenshot-05-supported-and-private.png") + + +def render_small_promo(): + im = Image.open(SRC / "small-promo-440x280.png").convert("RGB") + draw = ImageDraw.Draw(im) + # Cover the Japanese slogan on the red panel without touching the mascot. + rounded(draw, (248, 70, 440, 230), 0, RED) + draw_text(draw, (268, 96), "Nope", font(FONT_BOLD, 42), WHITE) + draw_text(draw, (268, 150), "Hide what you", font(FONT_SEMI, 16), WHITE) + draw_text(draw, (268, 174), "don't want", font(FONT_SEMI, 16), WHITE) + draw_text(draw, (268, 198), "to see", font(FONT_SEMI, 16), WHITE) + save_rgb(im, OUT / "small-promo-440x280.png") + + +def main(): + render_screenshot_01() + render_screenshot_02() + render_screenshot_03() + render_screenshot_04() + render_screenshot_05() + render_small_promo() + + +if __name__ == "__main__": + main() diff --git a/src/content-item.js b/src/content-item.js index 6f22d4e..6e1db89 100644 --- a/src/content-item.js +++ b/src/content-item.js @@ -55,17 +55,17 @@ const CB_ITEM = (() => { async function refresh() { const blocked = await CB_STORAGE.getBlockedSources('aliexpress'); - button.textContent = blocked[storeId] ? 'ブロック解除' : '🚫 このストアをブロック'; + button.textContent = blocked[storeId] ? CB_I18N.t('unblock') : CB_I18N.t('blockThisStore'); } button.addEventListener('click', async () => { const blocked = await CB_STORAGE.getBlockedSources('aliexpress'); if (blocked[storeId]) { await CB_STORAGE.removeBlockedSource('aliexpress', storeId); - showToast(`${name} のブロックを解除しました`); + showToast(CB_I18N.t('unblockedToast', name)); } else { await CB_STORAGE.addBlockedSource('aliexpress', storeId, name); - showToast(`${name} をブロックしました`); + showToast(CB_I18N.t('blockedToast', name)); } await refresh(); }); diff --git a/src/content-name.js b/src/content-name.js index 558ed62..ade4a7f 100644 --- a/src/content-name.js +++ b/src/content-name.js @@ -39,7 +39,7 @@ const CB_NAME = (() => { link.target = '_blank'; link.rel = 'noopener'; link.title = 'kitepon.dev'; - link.setAttribute('aria-label', 'kitepon.dev を開く'); + link.setAttribute('aria-label', CB_I18N.t('openBrand')); Object.assign(link.style, { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: '0', cursor: 'pointer', borderRadius: '8px', flexShrink: '1', @@ -140,15 +140,15 @@ const CB_NAME = (() => { if (reasonValue) { const reasonEl = document.createElement('p'); reasonEl.textContent = reasonType === 'keyword' - ? `キーワード:「${reasonValue}」` - : `発信元:${reasonValue}`; + ? CB_I18N.t('keywordReason', reasonValue) + : CB_I18N.t('sourceReason', reasonValue); Object.assign(reasonEl.style, { color: COLOR_INK, margin: '2px 0 0', fontSize: '12px' }); content.appendChild(reasonEl); } const unblockBtn = document.createElement('button'); unblockBtn.type = 'button'; - unblockBtn.textContent = reasonType === 'keyword' ? 'キーワード解除' : '発信元ブロック解除'; + unblockBtn.textContent = reasonType === 'keyword' ? CB_I18N.t('unblockKeyword') : CB_I18N.t('unblockSource'); Object.assign(unblockBtn.style, { marginTop: '4px', border: `1px solid ${COLOR_ORANGE}`, color: COLOR_ORANGE_DEEP, backgroundColor: 'transparent', borderRadius: '4px', padding: '4px 10px', cursor: 'pointer', @@ -164,7 +164,7 @@ const CB_NAME = (() => { await onUnblock(); } catch (error) { console.warn(`content-name: ${reasonType}ブロック解除に失敗しました value=${reasonValue}`, error); - unblockBtn.textContent = '解除に失敗しました'; + unblockBtn.textContent = CB_I18N.t('unblockFailed'); unblockBtn.disabled = false; } }); @@ -262,8 +262,8 @@ const CB_NAME = (() => { button = doc.createElement('button'); button.type = 'button'; button.className = SOURCE_BUTTON_CLASS; - button.title = `${sourceName} のブロックを切り替える`; - button.setAttribute('aria-label', `${sourceName} のブロックを切り替える`); + button.title = CB_I18N.t('toggleBlock', sourceName); + button.setAttribute('aria-label', CB_I18N.t('toggleBlock', sourceName)); Object.assign(button.style, { position: 'absolute', top: '6px', right: '6px', zIndex: '2147483646', cursor: 'pointer', border: `1px solid ${COLOR_ORANGE}`, background: COLOR_WHITE, @@ -289,16 +289,16 @@ const CB_NAME = (() => { const current = await storage.getBlockedSources(siteKey); if (current[sourceName]) { await storage.removeBlockedSource(siteKey, sourceName); - showToast(`${sourceName} のブロックを解除しました`); + showToast(CB_I18N.t('unblockedToast', sourceName)); } else { await storage.addBlockedSource(siteKey, sourceName, sourceName, true); - showToast(`${sourceName} をブロックしました`); + showToast(CB_I18N.t('blockedToast', sourceName)); } blockedSources = await storage.getBlockedSources(siteKey); for (const knownCard of processedCards) applyCardVisibility(knownCard); } catch (error) { console.warn(`content-name: 発信元ブロック操作に失敗しました siteKey=${siteKey} sourceName=${sourceName}`, error); - showToast(`${sourceName} のブロック操作に失敗しました`); + showToast(CB_I18N.t('blockFailedToast', sourceName)); } finally { button.disabled = false; } @@ -317,7 +317,7 @@ const CB_NAME = (() => { } const button = ensureSourceButton(card, sourceName, wrapper); button.style.display = ''; - button.textContent = isSourceBlocked(sourceName) ? 'ブロック解除' : '🚫 発信元をブロック'; + button.textContent = isSourceBlocked(sourceName) ? CB_I18N.t('unblock') : CB_I18N.t('blockThisSource'); } function buildOptions(reason) { @@ -328,11 +328,11 @@ const CB_NAME = (() => { if (reason.type === 'keyword') { await storage.removeBlockedKeyword(siteKey, reason.value); blockedKeywords = await storage.getBlockedKeywords(siteKey); - showToast(`キーワード「${reason.value}」を解除しました`); + showToast(CB_I18N.t('unblockedKeywordToast', reason.value)); } else { await storage.removeBlockedSource(siteKey, reason.value); blockedSources = await storage.getBlockedSources(siteKey); - showToast(`${reason.value} のブロックを解除しました`); + showToast(CB_I18N.t('unblockedToast', reason.value)); } for (const knownCard of processedCards) applyCardVisibility(knownCard); }, diff --git a/src/content-search.js b/src/content-search.js index 8470080..554939b 100644 --- a/src/content-search.js +++ b/src/content-search.js @@ -64,7 +64,7 @@ const CB_SEARCH = (() => { link.target = '_blank'; link.rel = 'noopener'; link.title = 'kitepon.dev'; - link.setAttribute('aria-label', 'kitepon.dev を開く'); + link.setAttribute('aria-label', CB_I18N.t('openBrand')); Object.assign(link.style, { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: '0', cursor: 'pointer', borderRadius: '8px', @@ -185,7 +185,7 @@ const CB_SEARCH = (() => { const unblockBtn = document.createElement('button'); unblockBtn.type = 'button'; - unblockBtn.textContent = 'ブロック解除'; + unblockBtn.textContent = CB_I18N.t('unblock'); Object.assign(unblockBtn.style, { marginTop: '8px', border: `1px solid ${COLOR_ORANGE}`, color: COLOR_ORANGE_DEEP, backgroundColor: 'transparent', borderRadius: '4px', padding: '4px 10px', cursor: 'pointer', @@ -237,8 +237,8 @@ const CB_SEARCH = (() => { button.type = 'button'; button.className = REGISTER_BUTTON_CLASS; const label = sourceName || sourceId; - button.title = `${label} のブロックを切り替える`; - if (button.setAttribute) button.setAttribute('aria-label', `${label} のブロックを切り替える`); + button.title = CB_I18N.t('toggleBlock', label); + if (button.setAttribute) button.setAttribute('aria-label', CB_I18N.t('toggleBlock', label)); Object.assign(button.style, { position: 'absolute', top: '6px', right: '6px', zIndex: '2147483646', cursor: 'pointer', border: `1px solid ${COLOR_ORANGE}`, background: COLOR_WHITE, @@ -305,8 +305,8 @@ const CB_SEARCH = (() => { badge = doc.createElement('span'); badge.className = RESOLUTION_ERROR_CLASS; - badge.textContent = '⚠ 識別子解決に失敗'; - badge.title = `この${entityLabel || 'チャンネル'}のブロック操作は利用できません(識別子の解決に失敗しました。ページを再読み込みすると再試行します)`; + badge.textContent = CB_I18N.t('resolutionFailed'); + badge.title = CB_I18N.t('resolutionFailedTitle', CB_I18N.entity(entityLabel)); Object.assign(badge.style, { position: 'absolute', top: '6px', right: '6px', zIndex: '2147483646', border: '1px solid #b3261e', background: COLOR_WHITE, color: '#b3261e', @@ -330,7 +330,7 @@ const CB_SEARCH = (() => { function applyRegisterButton(deps) { const { doc, buttonByCard, errorBadgeByCard, card, wrapper, resolveAnchor, - sourceId, sourceName, siteKey, storage, blocked, entityLabel = 'チャンネル', resolutionFailed, + sourceId, sourceName, siteKey, storage, blocked, entityLabel = 'channel', resolutionFailed, resolveBeforeToggle, onResolutionFailed, onToggled, } = deps; // アダプタがカード種別に応じたアンカーを自分で判定する(例: YouTubeは検索#dismissible/ @@ -359,7 +359,7 @@ const CB_SEARCH = (() => { // このブロックには常にblocked=falseの時しか到達しないが、CB_NAMEとの実装対称性のため // 分岐を残す——2026-08-11実Chrome smokeでtextContent未設定=空欄ボタンの欠陥が出たため、 // 「作る時に一度だけ」ではなく「出す時に毎回」設定する形にした)。 - button.textContent = blocked ? 'ブロック解除' : `🚫 この${entityLabel}をブロック`; + button.textContent = blocked ? CB_I18N.t('unblock') : CB_I18N.t('blockThisEntity', CB_I18N.entity(entityLabel)); } // ---- floating button方式(bell裁定[107]): resolver.register.mode === 'floating' のアダプタだけ使う ---- @@ -483,8 +483,8 @@ const CB_SEARCH = (() => { if (!target) { console.warn('content-search: クリック地点の対象カードを再同定できませんでした。誤登録を防ぐため操作を中止します'); if (button) { - button.textContent = '⚠ 対象を確認できません'; - button.title = '対象カードを確認できなかったため、ブロック操作を中止しました'; + button.textContent = CB_I18N.t('targetUnconfirmed'); + button.title = CB_I18N.t('targetUnconfirmedTitle'); button.disabled = true; } return; @@ -496,8 +496,8 @@ const CB_SEARCH = (() => { ctx = await ctx.refreshBeforeToggle(); } catch (err) { console.warn('content-search: クリック直前の対象カード再検証に失敗しました。誤登録を防ぐため操作を中止します', err); - button.textContent = '⚠ 対象を確認できません'; - button.title = '対象カードを再確認できなかったため、ブロック操作を中止しました'; + button.textContent = CB_I18N.t('targetUnconfirmed'); + button.title = CB_I18N.t('targetRecheckFailedTitle'); button.disabled = true; return; } @@ -572,16 +572,16 @@ const CB_SEARCH = (() => { positionFloatingButtonOverCard(doc, card); button.style.display = ''; if (ctx.resolutionFailed) { - const label = '識別子解決に失敗しました'; - button.textContent = '⚠ 識別子解決に失敗'; - button.title = `この${ctx.entityLabel || 'チャンネル'}のブロック操作は利用できません(識別子の解決に失敗しました。ページを再読み込みすると再試行します)`; + const label = CB_I18N.t('identifierFailedAria'); + button.textContent = CB_I18N.t('resolutionFailed'); + button.title = CB_I18N.t('resolutionFailedTitle', CB_I18N.entity(ctx.entityLabel)); if (button.setAttribute) button.setAttribute('aria-label', label); button.disabled = true; } else { const label = ctx.sourceName || ctx.sourceId; - button.textContent = ctx.blocked ? 'ブロック解除' : `🚫 この${ctx.entityLabel || 'チャンネル'}をブロック`; - button.title = `${label} のブロックを切り替える`; - if (button.setAttribute) button.setAttribute('aria-label', `${label} のブロックを切り替える`); + button.textContent = ctx.blocked ? CB_I18N.t('unblock') : CB_I18N.t('blockThisEntity', CB_I18N.entity(ctx.entityLabel)); + button.title = CB_I18N.t('toggleBlock', label); + if (button.setAttribute) button.setAttribute('aria-label', CB_I18N.t('toggleBlock', label)); button.disabled = false; } } diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..9ea7312 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,224 @@ +// Nope — UI locale helper. +// Catalogs live here so content scripts and popup share one lookup without a build step. +// Browser language comes from chrome.i18n.getUILanguage() when the extension API exists, +// otherwise navigator.language. Shipped locales: en (default) and ja. +// Contract: docs/adr/0001-ui-locale.md + +'use strict'; + +const CB_I18N = (() => { + const DEFAULT_LOCALE = 'en'; + const SUPPORTED = Object.freeze(['en', 'ja']); + + const ENTITY_KEYS = Object.freeze({ + store: 'entityStore', + shop: 'entityShop', + seller: 'entitySeller', + channel: 'entityChannel', + source: 'entitySource', + ストア: 'entityStore', + ショップ: 'entityShop', + 出品者: 'entitySeller', + チャンネル: 'entityChannel', + 発信元: 'entitySource', + }); + + const MESSAGES = { + en: { + extName: 'Nope — Hide what you don\'t want to see', + extDescription: 'Hide content from sources and keywords you choose, on the supported search and list pages.', + extTitle: 'Nope', + displayModeLegend: 'Blocked item display', + displayModePlaceholder: 'Replace with placeholder', + displayModeCollapse: 'Hide and collapse', + site_aliexpress: 'AliExpress', + site_rakuten: 'Rakuten Ichiba', + site_yahoo_shopping: 'Yahoo! Shopping', + site_yahoo_auctions: 'Yahoo Auctions', + site_amazon: 'Amazon', + site_youtube: 'YouTube', + site_yahoo_news: 'Yahoo News', + site_yahoo_japan: 'Yahoo! JAPAN', + keywordPlaceholder: 'Keyword', + addKeyword: 'Add', + clearCache: 'Clear cache', + remove: 'Remove', + nameOnlyWarning: 'Matched by display name. Renames and other sources with the same name can miss or over-block', + nameOnlyWarningTitle: 'This block uses the display name. A rename removes the block, and another source with the same name may be blocked by mistake', + emptyBlocked: 'No blocked sources', + emptyKeywords: 'No keywords yet', + keywordAlreadyAdded: 'Already added', + entityStore: 'store', + entityShop: 'shop', + entitySeller: 'seller', + entityChannel: 'channel', + entitySource: 'source', + unblock: 'Unblock', + blockThisEntity: '🚫 Block this $1', + blockThisStore: '🚫 Block this store', + blockThisSource: '🚫 Block this source', + toggleBlock: 'Toggle block for $1', + blockedToast: 'Blocked $1', + unblockedToast: 'Unblocked $1', + blockFailedToast: 'Could not change the block for $1', + unblockedKeywordToast: 'Removed keyword “$1”', + unblockFailed: 'Could not unblock', + openBrand: 'Open kitepon.dev', + keywordReason: 'Keyword: “$1”', + sourceReason: 'Source: $1', + unblockKeyword: 'Unblock keyword', + unblockSource: 'Unblock source', + resolutionFailed: 'Could not resolve identifier', + resolutionFailedTitle: 'Block is unavailable for this $1 (identifier lookup failed. Reload the page to retry.)', + targetUnconfirmed: 'Could not confirm the target', + targetUnconfirmedTitle: 'Block cancelled because the target card could not be confirmed', + targetRecheckFailedTitle: 'Block cancelled because the target card could not be rechecked', + identifierFailedAria: 'Identifier lookup failed', + }, + ja: { + extName: 'Nope — 見たくないもの見せません', + extDescription: '指定した発信元やキーワードをもとに、検索結果や一覧から見たくないコンテンツを隠します。', + extTitle: 'Nope', + displayModeLegend: 'ブロック済みの表示', + displayModePlaceholder: 'ブロック表示に置き換え', + displayModeCollapse: '非表示にして詰める', + site_aliexpress: 'AliExpress', + site_rakuten: '楽天市場', + site_yahoo_shopping: 'Yahoo!ショッピング', + site_yahoo_auctions: 'ヤフオク', + site_amazon: 'Amazon', + site_youtube: 'YouTube', + site_yahoo_news: 'Yahoo ニュース', + site_yahoo_japan: 'Yahoo! JAPAN', + keywordPlaceholder: 'キーワード', + addKeyword: '追加', + clearCache: 'キャッシュクリア', + remove: '削除', + nameOnlyWarning: '⚠ 表示名で判定:改名・同名の別発信元に注意', + nameOnlyWarningTitle: '表示名でブロックしているため、発信元が改名すると解除され、同名の別発信元も誤ってブロックする可能性があります', + emptyBlocked: 'ブロック中の発信元はありません', + emptyKeywords: 'キーワードは登録されていません', + keywordAlreadyAdded: 'すでに登録済みです', + entityStore: 'ストア', + entityShop: 'ショップ', + entitySeller: '出品者', + entityChannel: 'チャンネル', + entitySource: '発信元', + unblock: 'ブロック解除', + blockThisEntity: '🚫 この$1をブロック', + blockThisStore: '🚫 このストアをブロック', + blockThisSource: '🚫 発信元をブロック', + toggleBlock: '$1 のブロックを切り替える', + blockedToast: '$1 をブロックしました', + unblockedToast: '$1 のブロックを解除しました', + blockFailedToast: '$1 のブロック操作に失敗しました', + unblockedKeywordToast: 'キーワード「$1」を解除しました', + unblockFailed: '解除に失敗しました', + openBrand: 'kitepon.dev を開く', + keywordReason: 'キーワード:「$1」', + sourceReason: '発信元:$1', + unblockKeyword: 'キーワード解除', + unblockSource: '発信元ブロック解除', + resolutionFailed: '⚠ 識別子解決に失敗', + resolutionFailedTitle: 'この$1のブロック操作は利用できません(識別子の解決に失敗しました。ページを再読み込みすると再試行します)', + targetUnconfirmed: '⚠ 対象を確認できません', + targetUnconfirmedTitle: '対象カードを確認できなかったため、ブロック操作を中止しました', + targetRecheckFailedTitle: '対象カードを再確認できなかったため、ブロック操作を中止しました', + identifierFailedAria: '識別子解決に失敗しました', + }, + }; + + function normalizeLanguageTag(lang) { + if (lang == null) return ''; + return String(lang).trim().replace(/_/g, '-').toLowerCase(); + } + + /** + * ja and ja-* select Japanese. Any other tag, including empty, selects English + * until another catalog ships. + * @param {string|null|undefined} lang + * @returns {'en'|'ja'} + */ + function selectLocale(lang) { + const tag = normalizeLanguageTag(lang); + if (tag === 'ja' || tag.startsWith('ja-')) return 'ja'; + return DEFAULT_LOCALE; + } + + function detectLanguage() { + if (typeof chrome !== 'undefined' && chrome.i18n && typeof chrome.i18n.getUILanguage === 'function') { + try { + const ui = chrome.i18n.getUILanguage(); + if (ui) return ui; + } catch (_err) { + // chrome.i18n can exist as a stub in tests without getUILanguage. + } + } + if (typeof navigator !== 'undefined' && navigator.language) return navigator.language; + return DEFAULT_LOCALE; + } + + let currentLocale = selectLocale(detectLanguage()); + + function setLocale(lang) { + currentLocale = selectLocale(lang); + return currentLocale; + } + + function format(template, substitutions) { + if (template == null) return ''; + if (substitutions == null || substitutions.length === 0) return String(template); + return String(template).replace(/\$(\d+)/g, (_, n) => { + const value = substitutions[Number(n) - 1]; + return value == null ? '' : String(value); + }); + } + + function t(key, ...substitutions) { + const catalog = MESSAGES[currentLocale] || MESSAGES[DEFAULT_LOCALE]; + const template = (catalog && catalog[key]) || MESSAGES[DEFAULT_LOCALE][key] || key; + return format(template, substitutions); + } + + function entity(entityLabel) { + const key = ENTITY_KEYS[entityLabel] || ENTITY_KEYS.channel; + return t(key); + } + + function localeTag() { + return currentLocale === 'ja' ? 'ja' : 'en'; + } + + function dateLocale() { + return currentLocale === 'ja' ? 'ja-JP' : 'en'; + } + + function applyDocument(doc) { + const root = doc && doc.documentElement; + if (root) root.lang = localeTag(); + if (!doc || typeof doc.querySelectorAll !== 'function') return localeTag(); + for (const el of doc.querySelectorAll('[data-i18n]')) { + el.textContent = t(el.getAttribute('data-i18n')); + } + for (const el of doc.querySelectorAll('[data-i18n-placeholder]')) { + el.setAttribute('placeholder', t(el.getAttribute('data-i18n-placeholder'))); + } + return localeTag(); + } + + return { + DEFAULT_LOCALE, + SUPPORTED, + MESSAGES, + ENTITY_KEYS, + selectLocale, + detectLanguage, + setLocale, + t, + entity, + getLocale: () => currentLocale, + localeTag, + dateLocale, + applyDocument, + }; +})(); diff --git a/test/brand-contract.test.mjs b/test/brand-contract.test.mjs index 2ce9219..38d0997 100644 --- a/test/brand-contract.test.mjs +++ b/test/brand-contract.test.mjs @@ -18,12 +18,15 @@ test('導入後の2リンクは固定campaignでkitepon.dev rootへ戻る', () = assert.doesNotMatch(name, /const BRAND_URL = 'https:\/\/kitepon\.dev\/';/); }); -test('READMEはStore公開済みを示し、日本語の利用者入口を持つ', () => { +test('READMEはStore公開済みを示し、英語を先に置いて日本語入口も残す', () => { const readme = readFileSync(path.join(repoRoot, 'README.md'), 'utf8'); - for (const heading of ['5秒でわかる', '現在の配布状態', '30秒の使い方', '対応する7サービス群・8対応面', 'プライバシー・権限・制限']) { - assert.match(readme, new RegExp(`## ${heading}`)); - } + const englishHeadings = ['In 5 seconds', 'Current distribution', 'How to use it', 'Seven service groups', 'Privacy, permissions, limits']; + const japaneseHeadings = ['5秒でわかる', '現在の配布状態', '30秒の使い方', '対応する7サービス群・8対応面', 'プライバシー・権限・制限']; + for (const heading of englishHeadings) assert.match(readme, new RegExp(`## ${heading}`)); + for (const heading of japaneseHeadings) assert.match(readme, new RegExp(`## ${heading}`)); + assert.ok(readme.indexOf('## In 5 seconds') < readme.indexOf('## 5秒でわかる')); assert.match(readme, /2\.0\.1はChrome Web Storeで公開済み/); + assert.match(readme, /v2\.0\.1 is live on the Chrome Web Store/); assert.match(readme, /https:\/\/chromewebstore\.google\.com\/detail\/bodffbgmcokkhlibiehhelefknmbiaaf/); assert.match(readme, /Load unpackedは開発者向けの確認手順/); assert.match(readme, /docs\/evidence\/r5-smoke-restored\.png/); @@ -31,4 +34,5 @@ test('READMEはStore公開済みを示し、日本語の利用者入口を持つ assert.doesNotMatch(readme, /審査中/); assert.doesNotMatch(readme, /再審査/); assert.doesNotMatch(readme, /installできると見せるリンクを置きません/); + assert.doesNotMatch(readme, /\b\d{2,}%\b/); }); diff --git a/test/content-item.test.mjs b/test/content-item.test.mjs index 25a255b..92a751f 100644 --- a/test/content-item.test.mjs +++ b/test/content-item.test.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import vm from 'node:vm'; +import { loadI18n } from './helpers/vm-i18n.mjs'; const SRC = path.join(import.meta.dirname, '..', 'src', 'content-item.js'); @@ -48,6 +49,7 @@ function loadContentItem(storage) { setTimeout: () => {}, CB_STORAGE: storage ?? { getBlockedSources: async () => ({}) }, }); + loadI18n(context, 'ja'); vm.runInContext(readFileSync(SRC, 'utf8'), context); return vm.runInContext('CB_ITEM', context); } diff --git a/test/content-name.test.mjs b/test/content-name.test.mjs index fafc2a3..358b6b8 100644 --- a/test/content-name.test.mjs +++ b/test/content-name.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import vm from 'node:vm'; +import { loadI18n } from './helpers/vm-i18n.mjs'; const SRC = path.join(import.meta.dirname, '..', 'src', 'content-name.js'); const BRAND_URL = 'https://kitepon.dev/?utm_source=nope&utm_medium=chrome_extension&utm_campaign=nope-brand-link&utm_content=blocked-placeholder'; @@ -62,6 +63,7 @@ function loadContentName(cards, storage, consoleImpl = console) { CB_STORAGE: storage, CB_KEYWORD_FILTER: { matchesAny: () => false }, }); + loadI18n(context, 'ja'); vm.runInContext(readFileSync(SRC, 'utf8'), context); return { contentName: vm.runInContext('CB_NAME', context), document }; } diff --git a/test/content-search.test.mjs b/test/content-search.test.mjs index 475494a..780d128 100644 --- a/test/content-search.test.mjs +++ b/test/content-search.test.mjs @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import vm from 'node:vm'; +import { loadI18n } from './helpers/vm-i18n.mjs'; const SRC = path.join(import.meta.dirname, '..', 'src', 'content-search.js'); const ALIEXPRESS_INIT_SRC = path.join(import.meta.dirname, '..', 'src', 'content-aliexpress-init.js'); @@ -86,6 +87,7 @@ function loadContentSearch({ includeMtop = true, consoleImpl = console } = {}) { globals.CB_MTOP = { resolveStoreId: async () => { throw new Error('not stubbed'); } }; } const context = vm.createContext(globals); + loadI18n(context, 'ja'); vm.runInContext(readFileSync(SRC, 'utf8'), context); return vm.runInContext('CB_SEARCH', context); } @@ -108,6 +110,7 @@ test('content-search.jsは共通エンジンの読み込みだけでは自動起 }, CB_MTOP: { resolveStoreId: async () => null }, }); + loadI18n(context, 'ja'); vm.runInContext(readFileSync(SRC, 'utf8'), context); @@ -202,11 +205,11 @@ test('manifestはヤフオク・AmazonのPattern C読み込み順と画像公開 const expectedEntries = [ { match: '*://auctions.yahoo.co.jp/*', - scripts: ['src/storage.js', 'src/content-search.js', 'src/adapters/yahoo_auction.js'], + scripts: ['src/storage.js', 'src/i18n.js', 'src/content-search.js', 'src/adapters/yahoo_auction.js'], }, { match: '*://www.amazon.co.jp/*', - scripts: ['src/storage.js', 'src/content-search.js', 'src/adapters/amazon.js'], + scripts: ['src/storage.js', 'src/i18n.js', 'src/content-search.js', 'src/adapters/amazon.js'], }, ]; const accessibleMatches = manifest.web_accessible_resources.flatMap((item) => item.matches || []); diff --git a/test/helpers/vm-i18n.mjs b/test/helpers/vm-i18n.mjs new file mode 100644 index 0000000..1586aee --- /dev/null +++ b/test/helpers/vm-i18n.mjs @@ -0,0 +1,14 @@ +// Load src/i18n.js into a vm context and pin a locale. +// Existing UI tests keep Japanese assertions by defaulting to ja. +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +export const I18N_SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'i18n.js'); + +export function loadI18n(context, locale = 'ja') { + vm.runInContext(readFileSync(I18N_SRC, 'utf8'), context); + if (locale) vm.runInContext(`CB_I18N.setLocale(${JSON.stringify(locale)})`, context); + return vm.runInContext('CB_I18N', context); +} diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs new file mode 100644 index 0000000..b58f819 --- /dev/null +++ b/test/i18n.test.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { I18N_SRC, loadI18n } from './helpers/vm-i18n.mjs'; + +const repoRoot = path.resolve(import.meta.dirname, '..'); +const HAS_CJK = /[\u3040-\u30ff\u4e00-\u9fff]/; + +function loadWith(globals) { + const context = vm.createContext(globals); + vm.runInContext(readFileSync(I18N_SRC, 'utf8'), context); + return vm.runInContext('CB_I18N', context); +} + +test('selectLocale maps ja* to Japanese and everything else to English', () => { + const i18n = loadWith({}); + assert.equal(i18n.selectLocale('ja'), 'ja'); + assert.equal(i18n.selectLocale('ja-JP'), 'ja'); + assert.equal(i18n.selectLocale('ja_JP'), 'ja'); + assert.equal(i18n.selectLocale('ja-JP-mac'), 'ja'); + assert.equal(i18n.selectLocale('en'), 'en'); + assert.equal(i18n.selectLocale('en-US'), 'en'); + assert.equal(i18n.selectLocale('fr'), 'en'); + assert.equal(i18n.selectLocale('zh-CN'), 'en'); + assert.equal(i18n.selectLocale(''), 'en'); + assert.equal(i18n.selectLocale(undefined), 'en'); + assert.equal(i18n.selectLocale(null), 'en'); +}); + +test('detectLanguage prefers chrome.i18n.getUILanguage over navigator.language', () => { + const i18n = loadWith({ + chrome: { i18n: { getUILanguage: () => 'ja-JP' } }, + navigator: { language: 'en-US' }, + }); + assert.equal(i18n.detectLanguage(), 'ja-JP'); + assert.equal(i18n.getLocale(), 'ja'); +}); + +test('detectLanguage falls back to navigator.language then English', () => { + const fromNavigator = loadWith({ navigator: { language: 'fr-FR' } }); + assert.equal(fromNavigator.detectLanguage(), 'fr-FR'); + assert.equal(fromNavigator.getLocale(), 'en'); + + const fromMissing = loadWith({}); + assert.equal(fromMissing.detectLanguage(), 'en'); + assert.equal(fromMissing.getLocale(), 'en'); +}); + +test('English is the default catalog and Japanese stays complete', () => { + const i18n = loadWith({}); + assert.equal(i18n.DEFAULT_LOCALE, 'en'); + assert.deepEqual([...i18n.SUPPORTED], ['en', 'ja']); + assert.deepEqual( + Object.keys(i18n.MESSAGES.en).sort(), + Object.keys(i18n.MESSAGES.ja).sort(), + ); + assert.ok(Object.keys(i18n.MESSAGES.en).length > 20); +}); + +test('every English UI string exists and is not leftover Japanese', () => { + const i18n = loadWith({}); + const required = [ + 'extName', 'extDescription', 'extTitle', + 'displayModeLegend', 'displayModePlaceholder', 'displayModeCollapse', + 'emptyBlocked', 'emptyKeywords', 'keywordAlreadyAdded', + 'blockThisStore', 'blockThisSource', 'blockThisEntity', + 'unblock', 'blockedToast', 'unblockedToast', + 'resolutionFailed', 'targetUnconfirmed', + ]; + for (const key of required) { + const value = i18n.MESSAGES.en[key]; + assert.equal(typeof value, 'string', key); + assert.ok(value.length > 0, key); + assert.equal(HAS_CJK.test(value), false, `${key} still contains Japanese: ${value}`); + } + for (const [key, value] of Object.entries(i18n.MESSAGES.en)) { + assert.equal(typeof value, 'string', key); + assert.ok(value.length > 0, key); + } +}); + +test('t() substitutes placeholders in the selected locale', () => { + const i18n = loadWith({}); + i18n.setLocale('en'); + assert.equal(i18n.t('blockedToast', 'Shop A'), 'Blocked Shop A'); + assert.equal(i18n.t('blockThisEntity', 'shop'), '🚫 Block this shop'); + i18n.setLocale('ja'); + assert.equal(i18n.t('blockedToast', 'Shop A'), 'Shop A をブロックしました'); + assert.equal(i18n.t('blockThisEntity', 'ショップ'), '🚫 このショップをブロック'); +}); + +test('entity() maps adapter labels and English keys', () => { + const i18n = loadWith({}); + i18n.setLocale('en'); + assert.equal(i18n.entity('ショップ'), 'shop'); + assert.equal(i18n.entity('出品者'), 'seller'); + assert.equal(i18n.entity('チャンネル'), 'channel'); + assert.equal(i18n.entity('shop'), 'shop'); + i18n.setLocale('ja'); + assert.equal(i18n.entity('shop'), 'ショップ'); + assert.equal(i18n.entity(undefined), 'チャンネル'); +}); + +test('_locales catalogs match runtime extName, extDescription, and extTitle', () => { + const i18n = loadWith({}); + for (const locale of ['en', 'ja']) { + const chromeMessages = JSON.parse( + readFileSync(path.join(repoRoot, '_locales', locale, 'messages.json'), 'utf8'), + ); + for (const key of ['extName', 'extDescription', 'extTitle']) { + assert.equal(chromeMessages[key].message, i18n.MESSAGES[locale][key], `${locale} ${key}`); + } + } +}); + +test('manifest uses chrome.i18n placeholders and default_locale en', () => { + const manifest = JSON.parse(readFileSync(path.join(repoRoot, 'manifest.json'), 'utf8')); + assert.equal(manifest.default_locale, 'en'); + assert.equal(manifest.name, '__MSG_extName__'); + assert.equal(manifest.description, '__MSG_extDescription__'); + assert.equal(manifest.action.default_title, '__MSG_extTitle__'); + for (const entry of manifest.content_scripts) { + if (entry.world === 'MAIN') continue; + assert.ok(entry.js.includes('src/i18n.js'), `${entry.matches} is missing src/i18n.js`); + const i18nIndex = entry.js.indexOf('src/i18n.js'); + const firstUserScript = entry.js.findIndex((file) => ( + file.startsWith('src/content-') || file.startsWith('src/adapters/') + )); + if (firstUserScript >= 0) { + assert.ok(i18nIndex < firstUserScript, `${entry.matches} loads i18n after UI scripts`); + } + } +}); + +test('popup HTML is locale-neutral and filled by data-i18n keys', () => { + const html = readFileSync(path.join(repoRoot, 'popup', 'popup.html'), 'utf8'); + assert.match(html, /src="\.\.\/src\/i18n\.js"/); + assert.match(html, /data-i18n="displayModeLegend"/); + assert.match(html, /data-i18n="addKeyword"/); + assert.match(html, /data-i18n-placeholder="keywordPlaceholder"/); + assert.doesNotMatch(html, /ブロック済みの表示/); + assert.doesNotMatch(html, /キャッシュクリア/); +}); + +test('loadI18n helper can pin Japanese for existing UI tests', () => { + const context = vm.createContext({}); + const i18n = loadI18n(context, 'ja'); + assert.equal(i18n.getLocale(), 'ja'); + assert.equal(i18n.t('unblock'), 'ブロック解除'); +}); diff --git a/test/pack.test.mjs b/test/pack.test.mjs index 562de1c..54098a6 100644 --- a/test/pack.test.mjs +++ b/test/pack.test.mjs @@ -97,6 +97,10 @@ test('pack.mjs: 通常・hover両方のマスコット画像を配布物へ同 '撤去済みの別ロゴ資産がZIPに含まれている'); assert.ok(!zipEntries.some((entry) => entry.startsWith('assets/store/')), 'Chrome Web Store掲載素材がextension runtime ZIPへ混入している'); + assert.ok(zipEntries.includes('_locales/en/messages.json'), + '英語catalogがZIPに含まれていない'); + assert.ok(zipEntries.includes('_locales/ja/messages.json'), + '日本語catalogがZIPに含まれていない'); assert.equal(existsSync(path.join(unpackedPath, 'assets', 'kitepon-dev-primary.png')), false, '撤去済みの別ロゴ資産がunpacked面に含まれている'); } finally { diff --git a/test/popup.test.mjs b/test/popup.test.mjs index bedb06c..f0be5fd 100644 --- a/test/popup.test.mjs +++ b/test/popup.test.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import vm from 'node:vm'; +import { loadI18n } from './helpers/vm-i18n.mjs'; const SRC = path.join(import.meta.dirname, '..', 'popup', 'popup.js'); @@ -66,6 +67,8 @@ function makeFakeStorage(initialBlocked = {}, initialKeywords = {}) { function loadPopup(storage) { const context = vm.createContext({ document: { + documentElement: { lang: 'en' }, + title: '', getElementById: () => makeFakeListEl(), createElement: () => makeFakeElement(), querySelectorAll: () => [], @@ -76,6 +79,7 @@ function loadPopup(storage) { getDisplayMode: async () => 'placeholder', }, }); + loadI18n(context, 'ja'); vm.runInContext(readFileSync(SRC, 'utf8'), context); return vm.runInContext('CB_POPUP', context); } @@ -98,12 +102,12 @@ test('formatDateは文字列を返す', () => { assert.ok(formatted.length > 0); }); -test('SITE_LABELSにaliexpressとyoutube等の主要サイトが含まれる', () => { +test('siteLabelは主要サイトの日本語表示名を返す', () => { const popup = loadPopup(); - assert.equal(popup.SITE_LABELS.aliexpress, 'AliExpress'); - assert.equal(popup.SITE_LABELS.youtube, 'YouTube'); - assert.equal(popup.SITE_LABELS.yahoo_news, 'Yahoo ニュース'); - assert.equal(popup.SITE_LABELS.yahoo_japan, 'Yahoo! JAPAN'); + assert.equal(popup.siteLabel('aliexpress'), 'AliExpress'); + assert.equal(popup.siteLabel('youtube'), 'YouTube'); + assert.equal(popup.siteLabel('yahoo_news'), 'Yahoo ニュース'); + assert.equal(popup.siteLabel('yahoo_japan'), 'Yahoo! JAPAN'); }); test('renderBlockedListはブロック中の発信元がない時に空メッセージを出す', async () => { diff --git a/test/store-assets.test.mjs b/test/store-assets.test.mjs index bb35683..6d5ee3d 100644 --- a/test/store-assets.test.mjs +++ b/test/store-assets.test.mjs @@ -34,24 +34,28 @@ function pngHeader(buffer) { test('Store screenshots are five full-size 8-bit RGB PNGs', async () => { for (const filename of screenshots) { - const header = pngHeader(await readFile(`assets/store/${filename}`)); - assert.deepEqual(header, { - width: 1280, - height: 800, - bitDepth: 8, - colorType: 2, - }, filename); + for (const directory of ['assets/store', 'assets/store/en']) { + const header = pngHeader(await readFile(`${directory}/${filename}`)); + assert.deepEqual(header, { + width: 1280, + height: 800, + bitDepth: 8, + colorType: 2, + }, `${directory}/${filename}`); + } } }); test('Store icon and small promo match the dashboard dimensions', async () => { - const promo = pngHeader(await readFile('assets/store/small-promo-440x280.png')); - assert.deepEqual(promo, { - width: 440, - height: 280, - bitDepth: 8, - colorType: 2, - }); + for (const promoPath of ['assets/store/small-promo-440x280.png', 'assets/store/en/small-promo-440x280.png']) { + const promo = pngHeader(await readFile(promoPath)); + assert.deepEqual(promo, { + width: 440, + height: 280, + bitDepth: 8, + colorType: 2, + }, promoPath); + } const icon = pngHeader(await readFile('assets/store/store-icon-128.png')); assert.deepEqual(icon, { @@ -76,7 +80,13 @@ test('listing references only the current five screenshots and current Store ass const listing = await readFile('docs/store/listing.md', 'utf8'); for (const filename of screenshots) assert.match(listing, new RegExp(filename.replaceAll('.', '\\.'))); assert.match(listing, /assets\/store\/small-promo-440x280\.png/); + assert.match(listing, /assets\/store\/en\/small-promo-440x280\.png/); assert.match(listing, /assets\/store\/store-icon-128\.png/); + for (const filename of screenshots) { + assert.match(listing, new RegExp(`assets/store/en/${filename.replaceAll('.', '\\.')}`)); + } + assert.doesNotMatch(listing, /Featured/); + assert.match(listing, /Do not create/); for (const oldAsset of ['ac3-placeholder.png', 'ac2-popup.png', 'ac5-collapse.png', 'ac3-unblock.png']) { assert.doesNotMatch(listing, new RegExp(oldAsset.replaceAll('.', '\\.'))); @@ -105,6 +115,8 @@ test('listing, privacy, and submission checklist share the v2.0.1 data boundary' } assert.match(listing, /Remote code declaration[\s\S]*「はい、リモートコードを使用しています」/); + assert.match(listing, /Hide unwanted content on the pages you are viewing/); + assert.match(listing, /Language \(default listing\).*: English/); assert.match(listing, /https:\/\/kitepon\.dev\/products\/nope\//); assert.match(listing, /https:\/\/github\.com\/kitepon\/Nope\/issues/); assert.match(listing, /一般公開済み/); @@ -115,6 +127,7 @@ test('listing, privacy, and submission checklist share the v2.0.1 data boundary' assert.match(checklist, /一般公開済み/); assert.match(checklist, /https:\/\/chromewebstore\.google\.com\/detail\/bodffbgmcokkhlibiehhelefknmbiaaf/); assert.match(checklist, /再審査中ではない/); + assert.match(checklist, /English \(en\) as the default listing locale/); assert.doesNotMatch(checklist, /Data usage 各項目: すべて「該当なし」/); assert.doesNotMatch(listing, /【対応サイト】/); }); diff --git a/test/youtube-surfaces.test.mjs b/test/youtube-surfaces.test.mjs index fe0eaa3..a1ee9a9 100644 --- a/test/youtube-surfaces.test.mjs +++ b/test/youtube-surfaces.test.mjs @@ -6,6 +6,7 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import vm from 'node:vm'; +import { loadI18n } from './helpers/vm-i18n.mjs'; const CONTENT_SEARCH_SRC = path.join(import.meta.dirname, '..', 'src', 'content-search.js'); const MANIFEST = path.join(import.meta.dirname, '..', 'manifest.json'); @@ -178,6 +179,7 @@ function loadContentSearch({ consoleImpl = console, win = makeFakeWindow() } = { }, }; const context = vm.createContext(globals); + loadI18n(context, 'ja'); vm.runInContext(readFileSync(CONTENT_SEARCH_SRC, 'utf8'), context); return vm.runInContext('CB_SEARCH', context); }