diff --git a/AGENTS.md b/AGENTS.md index fa143712..fc83cb6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,19 +94,37 @@ vp run test:coverage # 三層 coverage 合併至 coverage/combined/ `cloudflare()`;預設掛的是 `localDataAssetsPlugin()`,**dev server 裡沒有 Worker**(`worker/index.ts` 完全不執行)。 -- **預設 `vp run dev`(無需 Cloudflare 憑證)**:`/api/*` 與 `/assets/*` 由 - `server.proxy` 轉發到 `VITE_DEV_API_ORIGIN`(預設 `https://www.moedict.tw`, - 與既有的 `/lookup/trs` dev proxy 同一條慣例)。`public/` 底下真的存在的檔案 - (如 `/assets/images/icon.png`)由 `bypass` 攔下走本機,不會被 proxy 蓋掉。 - **沒有這層 proxy 時,這些路徑會落到 Vite 的 SPA fallback 回 `index.html`**, +- **預設 `vp run dev`(無需 Cloudflare 憑證)**:`/api/*` 與 `/assets/*` 走 + **兩層**,順序是 load-bearing——`localDataAssetsPlugin()` 的 middleware 註冊在 + `configureServer` 裡,Vite 在裝 `proxyMiddleware` **之前**就先呼叫這個 hook + (`node_modules/vite/dist/vite/node/chunks/node.js` 裡 + `getSortedPluginHooks("configureServer")` 的迴圈在 + `middlewares.use(proxyMiddleware(...))` 之前),所以本機這層永遠先攔: + 1. **本機 `data/dictionary`(`serveLocalDictionaryApi`)**:詞條 + `/api/<詞>.json`、`@部首`/`=分類`/`raw|uni|pua`、list 路由,以及 + `/api/index/{a,t,h,c}.json`、`/api/search-index/*`、`/api/xref*`, + 透過檔案系統版的 `DICTIONARY` 綁定重用**正式的** `handleDictionaryAPI`/ + `handleListAPI`,回應 shape 與正式站一致(`Cache-Control: no-store`)。 + 找不到對應資料檔就 `next()`,不假造內容。 + 2. **`server.proxy` 轉發到 `VITE_DEV_API_ORIGIN`**(預設 + `https://www.moedict.tw`,與既有的 `/lookup/trs` dev proxy 同一條慣例): + 承接第 1 層不處理的路徑——`/api/config`、`/api/cns/*`、`/api/oembed`、 + `/assets/styles.css` 等舊版資產。`public/` 底下真的存在的檔案 + (如 `/assets/images/icon.png`)由 `bypass` 攔下走本機,不會被 proxy 蓋掉。 + + **兩層都沒有時,這些路徑會落到 Vite 的 SPA fallback 回 `index.html`**, 瀏覽器就會出現 `取得 ASSET_BASE_URL 失敗: SyntaxError: JSON.parse ...` 與 `MIME 型態「text/html」不是「text/css」`——那是「沒有 Worker」的症狀, 不是 AssetLoader 的 bug。 - ⚠️ 字典內容來自**正式站**,不是本機 `data/dictionary`;改資料要驗證請用 - `dev:remote`(讀 R2)或 `vp run build && bun run serve:e2e`(Miniflare + + ⚠️ 改 `data/dictionary` 後詞條 API 會**直接反映本機資料**(不必上傳 R2); + 但 dev server 裡**仍然沒有 Worker**(`worker/index.ts` 完全不執行), + HTML shell、404 fallback、邊緣快取標頭、`/api/config` 等 Worker 層行為 + 一律測不到——要驗證那些請用 `dev:remote`(讀 R2)或 + `vp run build && bun run serve:e2e`(Miniflare + `tests/helpers/fixtures.ts` 播種的本機資料)。 ⚠️ 用 5173/3000/8787 以外的埠時,`r2-assets.moedict.tw` 的字型會被 CORS 擋(allowlist 見 `commands/r2-assets-cors.json`),CSS 仍會套用。 + - **`bun run dev:remote`**:掛 `cloudflare()`,跑真 Worker(Miniflare)+ `wrangler.jsonc` 裡 `remote: true` 的 R2 綁定 → **需要 wrangler 登入** (`~/Library/Preferences/.wrangler/config/default.toml`),否則以 @@ -156,8 +174,20 @@ release manifest/digest 與剛剛實際發布到 R2 的那份不一致。完 - **release ID/R2 fallback**:`release-publish.mjs` 把 `dist/client/**` 上傳到 `releases//`,hashed `assets/**` 另複製一份到全域 `immutable/assets/`。Worker 的 HTML shell 走 - `SITE_ASSETS → R2 releases//index.html → 503 no-store`(絕不回 404); + `SITE_ASSETS → R2 releases//index.html → 503 no-store`(**資產取不到 + 本身絕不回 404**——404 只代表「查無此詞」,見下); 資產走 `SITE_ASSETS → R2 release → R2 immutable → 既有 legacy fallback`。 +- **未命中詞條的 HTTP 狀態(`notFoundShellResponse`,worker/index.ts)**: + `classifyRoute` 判為 `entry` 的裸詞路徑,若 + `probeDictionaryEntry(...) === false`(明確查無此詞)就回 **404**, + body 仍是 SPA shell、`Cache-Control: no-store`(舊站也是硬 404,soft-200 + 會讓爬蟲/CDN 把捏造的 shell 當內容)。只有 `false` 會改狀態:`true` + (命中,含 NFKC 相容 fallback)維持 200,`null`(shard 讀取/解析失敗, + 資料層答不出來)刻意 fail open 也維持 200;非 entry 路徑 + (`/`、`/about`、`/privacy`、`@部首`、`=分類`、`'*清單`、壞編碼)根本不進這條判斷。 + 唯一「shell 取不到又確定查無此詞」的組合會回**無 body 的 404** + (`Cache-Control: no-store`),而不是 recovery 503。deploy/rollback smoke + 打的 `/`、`/api/config`、`/api/<詞>.json` 都不是 entry shell 路徑,不受影響。 - **staging → production gate(自動)**:staging 的 `deploy:staging` 在 final smoke 通過「當下」自動寫入共用的 staging-approval 狀態 (git SHA + client manifest digest);不存在也不需要任何手動 @@ -645,6 +675,13 @@ moedict-data(MOE 原始 dump)→ moedict-process(pack 產生器) client 路由的 `resolveMiddlePointTarget`(`src/utils/middle-point-target.ts`, MiddlePoint 的純對應層)都是它的消費者。**新的 parser 一律消費這兩個 函式,禁止自建 if-chain。** +- **`App.tsx` 的靜態頁面路由必須在 `classifyRoute` 有自己的 kind** + (目前 `about`/`about.html`/`privacy`):漏掉就會被歸成 `entry`, + R4 的 shell 狀態規則會拿它去查詞、查無此詞後讓一個活著的頁面回 404 + (`/privacy` 曾因此中招,見 `tests/unit/worker-dispatch-edges.test.ts` + 的 "keeps /privacy at 200" 迴歸測試)。新增靜態頁面時同步加 kind、 + `resolveHeadByPath` 的 head、`resolveMiddlePointTarget` 的目標與 + `MiddlePoint` 的 ``——這四處都有 TS exhaustive switch 把關。 - **request 路徑的 percent-decode 一律用 `tryDecodeURIComponent`** (`dictionary-route.ts`,回 null 不丟例外)——裸呼 `decodeURIComponent` 遇到 `/api/%` 這類壞編碼會把 URIError 冒成 500(曾在 prod 實測到)。 diff --git a/bun.lock b/bun.lock index 9f913316..960fb7aa 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,7 @@ "v8-to-istanbul": "^9.3.0", "vite": "npm:@voidzero-dev/vite-plus-core@0.2.4", "vite-plus": "0.2.4", - "vitest": "4.1.10", + "vitest": "4.1.11", "wrangler": "^4.110.0", }, }, @@ -42,7 +42,7 @@ "overrides": { "undici": "^7.24.0", "vite": "npm:@voidzero-dev/vite-plus-core@0.2.4", - "vitest": "4.1.10", + "vitest": "4.1.11", }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -1071,7 +1071,7 @@ "vite-plus": ["vite-plus@0.2.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@oxlint/plugins": "=1.68.0", "@vitest/browser": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "@voidzero-dev/vite-plus-core": "0.2.4", "oxfmt": "=0.57.0", "oxlint": "=1.72.0", "oxlint-tsgolint": "=0.24.0", "vitest": "4.1.10" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.2.4", "@voidzero-dev/vite-plus-darwin-x64": "0.2.4", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.2.4", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.2.4", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.2.4", "@voidzero-dev/vite-plus-linux-x64-musl": "0.2.4", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.2.4", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.2.4" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.10", "@vitest/browser-webdriverio": "4.1.10" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint", "vp": "bin/vp", "vpr": "bin/vpr" } }, "sha512-gaBBjOXIq9lLRU44oAYdIr99p+JBLX1kxs+l/6LqGgSXwcVKAdDa1boSrOTELqYCkQQ0fpppXUGWi9o6JDT5zw=="], - "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], "weapon-regex": ["weapon-regex@1.3.6", "", {}, "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA=="], @@ -1175,6 +1175,20 @@ "table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "vitest/@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "vitest/@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "vitest/@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "vitest/@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "vitest/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], diff --git a/src/MiddlePoint.tsx b/src/MiddlePoint.tsx index 1ce039a8..f2a76651 100644 --- a/src/MiddlePoint.tsx +++ b/src/MiddlePoint.tsx @@ -33,6 +33,8 @@ export function MiddlePoint() { return ; case "about": return ; + case "privacy": + return ; case "radical": return ; case "starred": diff --git a/src/api/handleDictionaryAPI.ts b/src/api/handleDictionaryAPI.ts index 3aaf9e18..d8c02223 100644 --- a/src/api/handleDictionaryAPI.ts +++ b/src/api/handleDictionaryAPI.ts @@ -384,14 +384,18 @@ export function bucketOf(text: string, lang: DictionaryLang): string { return String(code % bucketSize); } -async function fillBucket( +type BucketShardRead = + | { kind: "hit"; data: DictionaryEntry } + | { kind: "miss" } + | { kind: "unavailable" }; + +async function readBucketShard( id: string, - bucket: string, lang: DictionaryLang, env: DictionaryEnv, -): Promise<{ data: DictionaryEntry | null; err: boolean }> { +): Promise { try { - const bucketPath = `p${lang}ck/${bucket}.txt`; + const bucketPath = `p${lang}ck/${bucketOf(id, lang)}.txt`; // Memoized: bucket shards are the hottest R2 objects (billing audit // 2026-07) and change only on data re-upload. const responseData = (await readR2JsonCached(env, bucketPath)) as Record< @@ -399,23 +403,23 @@ async function fillBucket( DictionaryEntry > | null; if (!responseData) { - return { data: null, err: true }; + // A real deployment materializes every shard, so an absent shard is + // positive evidence that no headword maps here (probeDictionaryEntry). + return { kind: "miss" }; } - const key = escape(id); - const part = responseData[key]; - + const part = responseData[escape(id)]; if (!part) { - return { data: null, err: true }; + return { kind: "miss" }; } - - return { data: part, err: false }; + return { kind: "hit", data: part }; } catch { - return { data: null, err: true }; + // Read/parse failure is transient, NOT evidence of absence. + return { kind: "unavailable" }; } } /** - * Wraps `fillBucket` with a Unicode-compatibility fallback for entry + * Wraps `readBucketShard` with a Unicode-compatibility fallback for entry * lookups. Many CJK compatibility characters — most notably the entire * Kangxi Radicals block (U+2E80–U+2EFF, U+2F00–U+2FDF, e.g. ⼴ U+2F34) — * carry a canonical NFKC/NFKD compatibility decomposition to their @@ -438,9 +442,9 @@ async function fillBucketWithCompatibilityFallback( lang: DictionaryLang, env: DictionaryEnv, ): Promise<{ data: DictionaryEntry | null; err: boolean; resolvedText: string }> { - const primary = await fillBucket(text, bucketOf(text, lang), lang, env); - if (!primary.err && primary.data) { - return { ...primary, resolvedText: text }; + const primary = await readBucketShard(text, lang, env); + if (primary.kind === "hit") { + return { data: primary.data, err: false, resolvedText: text }; } const normalized = text.normalize("NFKC"); @@ -448,13 +452,44 @@ async function fillBucketWithCompatibilityFallback( return { data: null, err: true, resolvedText: text }; } - const fallback = await fillBucket(normalized, bucketOf(normalized, lang), lang, env); - if (fallback.err || !fallback.data) { + const fallback = await readBucketShard(normalized, lang, env); + if (fallback.kind !== "hit") { return { data: null, err: true, resolvedText: text }; } return { data: fallback.data, err: false, resolvedText: normalized }; } +/** + * Tri-state existence probe for shell-status decisions (R4): does this bare + * word path resolve to a dictionary headword? + * + * true — entry exists (exact key or NFKC compatibility fallback, #214) + * false — definitive miss (shard/key provably absent) + * null — the data layer could not answer (read/parse failure); callers + * MUST fail open instead of treating this as "not found" + * + * Mirrors fillBucketWithCompatibilityFallback's resolution order without its + * cross-reference fan-out, so the Worker can decide an HTML route's HTTP + * status from the same memoized shard reads injectHeadMetadata performs. + */ +export async function probeDictionaryEntry( + text: string, + lang: DictionaryLang, + env: DictionaryEnv, +): Promise { + const primary = await readBucketShard(text, lang, env); + if (primary.kind === "hit") return true; + if (primary.kind === "unavailable") return null; + + const normalized = text.normalize("NFKC"); + if (normalized === text) return false; + + const fallback = await readBucketShard(normalized, lang, env); + if (fallback.kind === "hit") return true; + if (fallback.kind === "unavailable") return null; + return false; +} + async function handleRadicalLookup( request: Request, text: string, diff --git a/src/index.css b/src/index.css index 0d96ed76..a7041662 100644 --- a/src/index.css +++ b/src/index.css @@ -498,8 +498,15 @@ html .result { border-color: var(--moe-border, #d4d3d3); color: var(--moe-text, #333); } +/* 詞條卡內的 `a`(自動連結的字詞、引文、又音…)在 legacy 是 `#000`——比 + 內文 `#333` 更黑,靠 hover(`background:#ddd`)而不是顏色來標示可點。深色 + 模式要維持同一個關係:吃 `--moe-text`(#e6e3df,與卡內其他文字同色), + 不要用 `--moe-link`(#7fd0ff)——那讓詞條頁 135 處自動連結全變藍字, + 在淺色模式是黑字的位置。`--moe-link` 仍保留給真正的外部連結(About 頁、 + 側欄搜尋結果 hover),那裡的藍色是刻意的、且有 about-contrast.spec.ts + 與 dark-mode.spec.ts 各自把關。 */ html .result a { - color: var(--moe-link, #000); + color: var(--moe-text, #000); } /* audit-dark-contrast J:legacy `.result a:hover, .result a:focus` / `.result h1 a:hover` (data/assets/styles.css)把 hover/focus 態文字設為 @@ -825,7 +832,8 @@ html[data-theme="dark"] .result .entry-actions .iconic-color.star:hover { } .result .entry-actions { - position: static; + /* Anchor for the out-of-flow .entry-copy-status pill below. */ + position: relative; display: flex; flex-wrap: wrap; justify-content: flex-end; @@ -878,36 +886,55 @@ html[data-theme="dark"] .result .entry-actions .iconic-color.star:hover { transform: scale(0.94); transition: transform 100ms ease-out; } +/* Copy feedback ("已複製" / "複製失敗,請手動選取文字"). + + OUT OF FLOW BY DESIGN: an absolutely positioned pill under the icon row. + It replaces the previous inline `min-width: 8rem` reservation, which was + the only reason the mobile icon row could never share a line with the + radical box -- 8rem of permanently reserved width plus three 44px targets + never fits beside a title at phone widths. + + Being out of flow satisfies the same two contracts the reservation did, + more directly: + 1. Toggling between the nbsp placeholder and either message cannot move + the icons (dictionary.spec.ts "copy button activates with Space and + keeps status geometry stable") -- an out-of-flow box contributes + nothing to its container's size. + 2. It never grows .entry-control-stack's height, so it cannot push + h1.title's line box down (the RESCOPE Android ruby regression that + ruled out `flex-basis: 100%`). + The node stays mounted and keeps its text at all times -- aria-live + regions must exist before their content changes to be announced -- so + idle state is hidden with opacity, never `display: none`. + `right: 0` keeps it inside `.result` (which legacy styles.css clips with + `overflow-x: hidden`), so it can never cause horizontal overflow. */ .entry-copy-status { + position: absolute; + top: calc(100% + 0.2rem); + right: 0; + z-index: 3; display: inline-flex; align-items: center; - /* Fix for the entry-heading right-alignment regression: this reserved - box must sit BEFORE the visible icons in DOM order (see - DictionaryPage.tsx), not after. .entry-actions is `justify-content: - flex-end`; when this element was the LAST flex child, its own - reserved width occupied the rightmost slot, pushing the visible - copy/book/star icons ~8rem+gap left of the radical-box row above - them. With the reservation first, the icon group is what ends up - flush against the right edge. - justify-content: flex-end (not text-align, which flex containers - ignore) keeps the status text hugging the icons on its right edge - when a message replaces the nbsp placeholder, instead of drifting to - this box's own left edge. - Still reserves a fixed box (wide enough for the longest status - string, "複製失敗,請手動選取文字") so toggling between the nbsp - placeholder and either status message never changes this element's - own width -- that's what keeps the icon group's geometry stable - (dictionary.spec.ts "copy button activates with Space and keeps - status geometry stable"). Do NOT force a full-row wrap (no - flex-basis: 100%) -- that grew .entry-control-stack's height by a - fixed amount on every viewport, including narrow ones where it - pushed h1.title's line box down (RESCOPE Android ruby layout - regression). */ justify-content: flex-end; - min-width: 8rem; - min-height: 1.5rem; + max-width: min(80vw, 24rem); + padding: 0.25rem 0.5rem; + border: 1px solid var(--moe-border, #d4d3d3); + border-radius: 4px; + /* --moe-surface-alt (the established raised-surface token, also used by + the star hover state and tooltips): --moe-surface equals the entry + card's own background in dark mode, which made the pill invisible. */ + background: var(--moe-surface-alt, #f4f4f4); + color: var(--moe-text, #333); + box-shadow: 0 1px 3px rgb(0 0 0 / 18%); white-space: nowrap; font-size: 0.78rem; + pointer-events: none; +} +.entry-copy-status[data-state="idle"] { + opacity: 0; + box-shadow: none; + border-color: transparent; + background: transparent; } @media (max-width: 767px) { .entry-actions .entry-copy-button, diff --git a/src/main.tsx b/src/main.tsx index 04b195c3..c4f09a42 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; import { applyHeadByPath } from "./ssr/head"; -import { resolveLegacyHashRoute } from "./utils/dictionary-route"; +import { applyLegacyHashRewrite } from "./utils/legacy-hash-shim"; import { applyTheme, readThemePref } from "./utils/theme-utils"; /** @@ -30,20 +30,6 @@ function fixInitialURL() { } } -/** - * 修正 g0v/moedict-webkit#131:詞條內文連結仍以 `./#'word` 舊式 hashbang - * 形式產生(見 dictionary-route.ts 的 resolveLegacyHashRoute 註解), - * 開新分頁/複製連結/直接輸入網址都不經過 App 內的 click handler, - * 瀏覽器只會落在 pathname "/" + 殘留 hash。必須在 React Router 掛載前 - * 用 replaceState 修正,否則使用者會看到首頁而非目標詞條。 - */ -function fixLegacyHashRoute() { - const target = resolveLegacyHashRoute(window.location.pathname, window.location.hash); - if (target) { - window.history.replaceState(null, "", target); - } -} - /** * 攔截 history API,確保所有導航操作都使用未編碼的 URL */ @@ -91,7 +77,7 @@ function applyPlatformClasses() { // 在渲染前先修正 URL、套用外觀模式和設置攔截器 applyTheme(readThemePref()); applyPlatformClasses(); -fixLegacyHashRoute(); +applyLegacyHashRewrite(); fixInitialURL(); setupHistoryInterceptor(); applyHeadByPath(window.location.pathname); diff --git a/src/pages/DictionaryPage.tsx b/src/pages/DictionaryPage.tsx index 40c9a165..25e50e5c 100644 --- a/src/pages/DictionaryPage.tsx +++ b/src/pages/DictionaryPage.tsx @@ -57,6 +57,9 @@ interface Heteronym { variants?: string[]; audio_id?: string; synonyms?: string[] | string; + /** 台語條目 heteronym 層的反義詞(對照:同義詞掛在 definitions 內);API + * 回傳時已包成 autolink 的 `詞`,渲染前需 sanitize。 */ + antonyms?: string[] | string; /** TWBLG 文/白/俗/替讀音分類(g0v/moedict-webkit#96、#233),僅 lang='t' 有值; * API 回傳時已包成 autolink 的 `文`,渲染前需 untag。 */ reading?: string; @@ -185,6 +188,30 @@ function untag(input: string): string { return input.replace(/<[^>]*>/g, ""); } +/** 同義詞/反義詞(似/反)欄位只保留 autolink 的 lookup 錨點 ``, + * 點擊由 .result 的 onClick 統一攔截導頁;其餘標籤一律剝掉、只留文字內容, + * 保留的錨點也只留 href 屬性。 */ +function sanitizeLookupHtml(input: string): string { + if (typeof DOMParser === "undefined") return untag(input); + try { + const doc = new DOMParser().parseFromString(`
${input}
`, "text/html"); + const wrap = doc.getElementById("wrap"); + if (!wrap) return untag(input); + for (const el of Array.from(wrap.querySelectorAll("*"))) { + if (el instanceof HTMLAnchorElement && (el.getAttribute("href") || "").startsWith("./#")) { + for (const attr of Array.from(el.attributes)) { + if (attr.name !== "href") el.removeAttribute(attr.name); + } + } else { + el.replaceWith(...Array.from(el.childNodes)); + } + } + return wrap.innerHTML; + } catch { + return untag(input); + } +} + function escapeHtml(input: string): string { return String(input || "") .replace(/&/g, "&") @@ -1022,6 +1049,9 @@ export function DictionaryPage({ word, lang, idx: targetDefIdx }: DictionaryPage : []; const dialectSynonyms = lang === "t" || lang === "h" ? splitCommaSeparatedItems(heteronym.synonyms) : []; + // 台語條目的反義詞直接掛在 heteronym 層(對照:同義詞在 definitions 內), + // API 回傳時已包成 autolink 的 `
詞`。 + const heteronymAntonyms = lang === "t" ? splitCommaSeparatedItems(heteronym.antonyms) : []; // TWBLG 文/白/俗/替讀音分類(g0v/moedict-webkit#96、#233):資料只在 // lang='t' 的 ptck pack 出現,其餘語言的 heteronym 沒有這個欄位。 const definitions = Array.isArray(heteronym.definitions) ? heteronym.definitions : []; @@ -1092,6 +1122,18 @@ export function DictionaryPage({ word, lang, idx: targetDefIdx }: DictionaryPage
0 && (
似 - - {untag(toStringArray(def.synonyms).join("、").replace(/,/g, "、"))} - +
)} {toStringArray(def.antonyms).length > 0 && (
反 - - {untag(toStringArray(def.antonyms).join("、").replace(/,/g, "、"))} - +
)} {afterParallel && ( @@ -1454,6 +1504,19 @@ export function DictionaryPage({ word, lang, idx: targetDefIdx }: DictionaryPage
)} + {heteronymAntonyms.length > 0 && ( +
+ 反 + + {heteronymAntonyms.map((item, antIdx) => ( + + {antIdx > 0 ? "、" : ""} + + + ))} + +
+ )} {xrefsByHeteronymId.length > 0 && heteronym.id && (() => { diff --git a/src/ssr/head.ts b/src/ssr/head.ts index c0051037..dbdb75e6 100644 --- a/src/ssr/head.ts +++ b/src/ssr/head.ts @@ -30,6 +30,8 @@ const DEFAULT_DESCRIPTION = [ const ABOUT_DESCRIPTION = "萌典資料來源、授權與專案協作說明。包含教育部辭典資料、字體來源與平台版本資訊。"; +const PRIVACY_DESCRIPTION = "萌典不蒐集任何個人資料,也不會以任何方式使用您的資料。"; + const SITE_ORIGIN = "https://www.moedict.tw"; const DEFAULT_IMAGE = `${SITE_ORIGIN}/assets/images/icon.png`; const DEFAULT_IMAGE_TYPE = "image/png"; @@ -175,6 +177,8 @@ export function resolveHeadByPath(pathname: string): PageHead { return getDefaultHead(normalizedPath); case "about": return createHead("關於本站 - 萌典", ABOUT_DESCRIPTION, normalizedPath); + case "privacy": + return createHead("隱私權政策 - 萌典", PRIVACY_DESCRIPTION, normalizedPath); case "radical": return getRadicalHead(route.radical, route.lang, normalizedPath); case "starred": diff --git a/src/utils/dictionary-route.ts b/src/utils/dictionary-route.ts index ea53ed13..83a15d75 100644 --- a/src/utils/dictionary-route.ts +++ b/src/utils/dictionary-route.ts @@ -66,6 +66,15 @@ export function tryDecodeURIComponent(input: string): string | null { * `href="#"`、About 頁 `#how-to-use`,這些從不落在 "/" 但仍保守排除 * 空 hash 本身)。呼叫端(main.tsx)在 React Router 掛載前執行一次; * 之後的路由分類全部交回 classifyRoute,這裡不自建前綴 if-chain。 + * + * 接受的舊式雜湊形式(2014 版 moedict.org 對外分享/書籤 URL,R3 回歸): + * `#`、`#'`、`#:term`、`#~term`、`#@字`(部首)——無前綴結構 + * `#a=`/`#t=`…——語言字母 kv 形式 + * `#dict/`/`#dict=`——舊 dict 前綴形式 + * `#a::`…——裸雙冒號形式 + * 其餘雜湊一律不改寫:詞條形狀以保守白名單把關(漢字/注音/帶聲調拉丁字 + * /數字/`.`/`-`/`@`),且裸詞至少要含一個非 ASCII 字元——純 ASCII 的 + * `#foo`、`#how-to-use` 之類同頁錨點直接放行,不會被誤判成詞條。 */ export function resolveLegacyHashRoute(pathname: string, hash: string): string | null { if (pathname !== "/") return null; @@ -73,9 +82,51 @@ export function resolveLegacyHashRoute(pathname: string, hash: string): string | if (!token) return null; const decoded = tryDecodeURIComponent(token); if (!decoded) return null; + + const dictForm = decoded.match(/^dict[=/]([athc])(.*)$/); + if (dictForm) return buildLegacyEntryPath(dictForm[1] as DictionaryLang, dictForm[2]); + + const kvForm = decoded.match(/^([athc])=(.+)$/); + if (kvForm) return buildLegacyEntryPath(kvForm[1] as DictionaryLang, kvForm[2]); + + const nsForm = decoded.match(/^([athc])::(.+)$/); + if (nsForm) return buildLegacyEntryPath(nsForm[1] as DictionaryLang, nsForm[2]); + + const head = decoded[0]; + if (head === "'" || head === ":" || head === "~" || head === "@") { + if (!isLegacyEntryTerm(decoded.slice(1))) return null; + return `/${decoded}`; + } + + if (!isLegacyEntryTerm(decoded) || !/\P{ASCII}/u.test(decoded)) return null; return `/${decoded}`; } +/** + * 詞條字元白名單:漢字(含相容表意文字)、注音(含擴充)、預組聲調拉丁字 + * (臺灣台羅/教羅)、組合附加符號、`·`/`˙`(POJ 輕聲點)、`ⁿ`、數字、 + * `.`(heteronym idx)、`-`(連字符詞目)、`@`(部首)。刻意不含 `=`、`*`、 + * 空白與 CJK 標點——那些不屬於任何真實路徑段。 + */ +const LEGACY_TERM_RE = + /^[\p{Script=Han}\p{Script=Bopomofo}\u00C0-\u024F\p{M}\u02C7-\u02D9\u00B7\u207FA-Za-z0-9.@-]+$/u; + +function isLegacyEntryTerm(term: string): boolean { + return term.length > 0 && LEGACY_TERM_RE.test(term); +} + +/** + * 把「明確標了語言」的舊式形式(`a=`/`dict/t'…`/`h::…`)正規化成現行 + * 路徑:語言字母為準,其餘字元若自帶 `'`/`:`/`~` 行內前綴則剝掉,再由 + * buildDictionaryPathname 的前綴規則補回單一正確前綴。 + */ +function buildLegacyEntryPath(lang: DictionaryLang, remainder: string): string | null { + const { rest } = stripLangPrefix(remainder); + if (!isLegacyEntryTerm(rest)) return null; + const prefix = lang === "t" ? "'" : lang === "h" ? ":" : lang === "c" ? "~" : ""; + return `/${prefix}${rest}`; +} + /** * 語言前綴 → 語言代碼的唯一對照表:`'`=t(臺灣台語)、`:`=h(臺灣客語)、 * `~`=c(兩岸)、無前綴=a(華語)。API 端另接受 legacy `!` 作為 t 的別名 @@ -115,6 +166,7 @@ export function stripLangPrefix( export type ClassifiedRoute = | { kind: "default" } | { kind: "about" } + | { kind: "privacy" } | { kind: "radical"; lang: DictionaryLang; radical: string } | { kind: "starred"; lang: DictionaryLang; entry: string } | { kind: "group"; lang: DictionaryLang; category: string } @@ -138,6 +190,11 @@ export function classifyRoute(pathname: string): ClassifiedRoute { } if (decoded === "about" || decoded === "about.html") return { kind: "about" }; + // `/privacy` is a real client route (App.tsx renders for it), not a + // headword. It must classify as its own kind: the Worker's R4 shell-status rule + // 404s any `entry` path whose probe is a definitive miss, so leaving /privacy as + // entry("privacy") made a live page answer 404. + if (decoded === "privacy") return { kind: "privacy" }; if (decoded === "@") return { kind: "radical", lang: "a", radical: "" }; if (decoded === "~@") return { kind: "radical", lang: "c", radical: "" }; diff --git a/src/utils/legacy-hash-shim.ts b/src/utils/legacy-hash-shim.ts new file mode 100644 index 00000000..3b9b3974 --- /dev/null +++ b/src/utils/legacy-hash-shim.ts @@ -0,0 +1,23 @@ +/** + * Boot-time legacy-hash → path-route shim(R3 回歸)。 + * + * 2014 版 moedict.org 的對外分享/書籤網址是 hashbang 形式(`#萌`、 + * `#a=萌`、`#'月暗暝`、`#dict/a萌`、`#a::萌`…)。雜湊不隨 HTTP 請求送出, + * 這些網址一律落在 pathname "/",React Router 掛載後無人解析殘留 hash, + * 使用者只會看到空的 SPA 首頁。本模組必須在 React Router 初始化前執行 + * 一次:命中舊式形式就以 `history.replaceState` 改寫成對應路徑——不留 + * 瀏覽記錄、hash 一併清除,讓 Router 直接看到乾淨的 pathname。 + * + * 形式判定與路徑對照全部委派 dictionary-route.ts 的 resolveLegacyHashRoute; + * 本檔只負責 DOM 存取(該檔同時被無 DOM lib 的 worker tsconfig 引用)。 + */ + +import { resolveLegacyHashRoute } from "./dictionary-route"; + +export function applyLegacyHashRewrite(): boolean { + if (typeof window === "undefined") return false; + const target = resolveLegacyHashRoute(window.location.pathname, window.location.hash); + if (!target) return false; + window.history.replaceState(null, "", target); + return true; +} diff --git a/src/utils/middle-point-target.ts b/src/utils/middle-point-target.ts index a76ee511..a1b1cde2 100644 --- a/src/utils/middle-point-target.ts +++ b/src/utils/middle-point-target.ts @@ -9,6 +9,7 @@ import { classifyRoute, type DictionaryLang } from "./dictionary-route"; export type MiddlePointTarget = | { page: "home" } | { page: "about" } + | { page: "privacy" } | { page: "radical"; lang: DictionaryLang; radical: string } | { page: "starred"; lang: DictionaryLang; entry?: string } | { page: "list"; lang: DictionaryLang; category: string } @@ -30,6 +31,8 @@ export function resolveMiddlePointTarget(pathname: string): MiddlePointTarget { return { page: "home" }; case "about": return { page: "about" }; + case "privacy": + return { page: "privacy" }; case "radical": { if (route.radical.includes("/")) return { page: "home" }; if (!route.radical) { diff --git a/tests/e2e/charimg-caption-dark-contrast.spec.ts b/tests/e2e/charimg-caption-dark-contrast.spec.ts index 7edf61ac..64f3b118 100644 --- a/tests/e2e/charimg-caption-dark-contrast.spec.ts +++ b/tests/e2e/charimg-caption-dark-contrast.spec.ts @@ -61,7 +61,9 @@ const FALLBACK_PATH = "/%E8%90%8C%E9%BB%83"; async function openCaption(page: Page): Promise { const response = await page.goto(FALLBACK_PATH); - expect(response?.status()).toBe(200); + // No combined 萌黃 entry ⇒ R4 answers 404 with the SPA shell body intact, + // which is precisely the CharacterImageView surface this audit measures. + expect(response?.status()).toBe(404); await page.locator(".charimg-result").waitFor({ state: "visible", timeout: 15_000 }); await page .locator("img.charimg-glyph-segment") diff --git a/tests/e2e/console-load-errors.spec.ts b/tests/e2e/console-load-errors.spec.ts index 5d6ea5ed..ccdad738 100644 --- a/tests/e2e/console-load-errors.spec.ts +++ b/tests/e2e/console-load-errors.spec.ts @@ -216,7 +216,14 @@ test.describe("console load errors — EduKai 404 and BiauKai decode", () => { }); await page.goto("/%E8%90%8C"); - await waitForAppReady(page); + // "shell" readiness returns before the entry renders and before + // AssetLoader injects the legacy stylesheet (after /api/config), so both + // assertions below raced the page. Wait on those two contracts directly. + await waitForAppReady(page, "dictionary"); + await page.waitForFunction( + () => + document.querySelector('link[data-asset-id="styles-css"]')?.sheet != null, + ); await page.evaluate(() => document.fonts.ready); // Sanity: the intercepted legacy styles.css must have actually loaded — diff --git a/tests/e2e/dark-mode.spec.ts b/tests/e2e/dark-mode.spec.ts index 61dc390a..0c06fa68 100644 --- a/tests/e2e/dark-mode.spec.ts +++ b/tests/e2e/dark-mode.spec.ts @@ -111,6 +111,84 @@ test.describe("system prefers-color-scheme: dark (no manual override)", () => { }); }); +// Entry-card anchors (auto-linked headwords, citations, 又音…) are the bulk of +// the visible text on a dictionary page — 135 text nodes on /萌 alone. Legacy +// styles.css paints them `#000`, i.e. slightly stronger than the `#333` body +// text, and marks them clickable on hover (`background: #ddd`) rather than by +// colour. Dark mode must keep that relationship: they follow `--moe-text`, the +// same white the rest of the card uses. Painting them `--moe-link` (#7fd0ff) +// turned every one of them blue where light mode has black. `--moe-link` stays +// in place for genuinely external links (About page, sidebar search hover), +// guarded by about-contrast.spec.ts and the hover test further down this file. +test.describe("entry-card links follow the themed text colour", () => { + const LINK_BLUE = "rgb(127, 208, 255)"; // --moe-link + + async function measureEntryLinks(page: Page) { + await blockCssSubresources(page); + await routeStylesCss(page, readWorkingTreeStylesCss); + const response = await page.goto(ENTRY_PATH); + expect(response?.status()).toBe(200); + await waitForAppReady(page, "dictionary"); + return page.evaluate(() => { + const card = document.querySelector(".result")!; + // Plain anchors only: .xref / .iconic-circle / .part-of-speech carry + // their own brand colours by design. The headword anchor inside + // h1.title is excluded too — `.result .entry .title + // .single-char-stroke-trigger a { color: inherit }` makes it track the + // title, so it reads #333 in light mode rather than the legacy #000. + const plain = Array.from(card.querySelectorAll("a")).filter( + (a) => + (a.textContent ?? "").trim().length > 0 && + !a.getAttribute("class") && + a.closest("h1.title") === null, + ); + const titleAnchor = card.querySelector("h1.title .single-char-stroke-trigger a"); + return { + background: getComputedStyle(card).backgroundColor, + bodyColor: getComputedStyle(card.querySelector(".definition")!).color, + anchorCount: plain.length, + anchorColors: [...new Set(plain.map((a) => getComputedStyle(a).color))], + titleAnchorColor: titleAnchor ? getComputedStyle(titleAnchor).color : null, + blueDescendants: Array.from(card.querySelectorAll("*")).filter( + (el) => getComputedStyle(el).color === "rgb(127, 208, 255)", + ).length, + }; + }); + } + + test("dark: plain anchors are the card's text colour, never the link accent", async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: "dark" }); + const measured = await measureEntryLinks(page); + + expect(measured.anchorCount).toBeGreaterThan(10); + expect(measured.anchorColors).toEqual([measured.bodyColor]); + expect(measured.anchorColors).toEqual(["rgb(230, 227, 223)"]); // --moe-text + expect(measured.anchorColors).not.toContain(LINK_BLUE); + // The inheriting headword anchor lands on the same white. + expect(measured.titleAnchorColor).toBe("rgb(230, 227, 223)"); + expect(measured.blueDescendants).toBe(0); + expect(contrastRatio(measured.anchorColors[0], measured.background)).toBeGreaterThanOrEqual( + 4.5, + ); + }); + + test("light: plain anchors keep the legacy black, unchanged", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + const measured = await measureEntryLinks(page); + + expect(measured.anchorCount).toBeGreaterThan(10); + expect(measured.anchorColors).toEqual(["rgb(0, 0, 0)"]); + expect(measured.bodyColor).toBe("rgb(51, 51, 51)"); + // Inherits the title colour, not the legacy #000 — unchanged by this fix. + expect(measured.titleAnchorColor).toBe("rgb(51, 51, 51)"); + expect(contrastRatio(measured.anchorColors[0], measured.background)).toBeGreaterThanOrEqual( + 4.5, + ); + }); +}); + test.describe("system prefers light, no manual override", () => { test.use({ colorScheme: "light" }); diff --git a/tests/e2e/dictionary.spec.ts b/tests/e2e/dictionary.spec.ts index 3c05228c..1f1dfa42 100644 --- a/tests/e2e/dictionary.spec.ts +++ b/tests/e2e/dictionary.spec.ts @@ -1094,7 +1094,11 @@ test.describe("mobile Android Taigi ruby layout", () => { let checked = 0; for (const sample of TAIGI_TITLE_CANDIDATES) { const response = await page.goto(sample.path); - expect(response?.status()).toBe(200); + // Candidates absent from the seeded fixtures fall back to the 字圖 view, + // which R4 now serves with a 404 status (body unchanged). This sweep is + // about ruby geometry, not status, so both outcomes are acceptable; the + // `h1.title` count check below skips the entries that did not render. + expect([200, 404]).toContain(response?.status()); await waitForAppReady(page, "dictionary"); await page.evaluate(() => document.fonts.ready); if ((await page.locator("h1.title").count()) === 0) { @@ -1208,7 +1212,10 @@ test.describe("Mandarin MOE vertical zhuyin proportions", () => { const checkedTitles = new Set(); for (const sample of MANDARIN_VERTICAL_ZHUYIN_SAMPLES) { const response = await page.goto(sample.path); - expect(response?.status()).toBe(200); + // Samples with no combined entry render `.charimg-result`, which R4 + // serves as 404 (body unchanged) — the wait below already accepts that + // surface, so the status must accept it too. + expect([200, 404]).toContain(response?.status()); await page.waitForLoadState("domcontentloaded"); await page .locator(".result .entry h1.title hruby.rightangle, .charimg-result") @@ -1634,10 +1641,16 @@ test.describe("special routes", () => { }); test.describe("404 / fallback paths", () => { - test("unknown word falls back to SPA (not worker 404)", async ({ page }) => { - // React router catch-all still serves index.html + test("unknown word answers 404 but still serves the SPA shell body", async ({ page }) => { + // R4 (worker/index.ts notFoundShellResponse): an entry-shaped path whose + // probeDictionaryEntry() is a definitive miss gets a real 404 status so + // crawlers/CDNs do not treat the shell as content. The BODY is unchanged — + // React Router still hydrates and renders the per-character 字圖 fallback. const response = await page.goto("/%E4%B8%8D%E5%AD%98%E5%9C%A8%E7%9A%84%E8%A9%9E"); - expect(response?.status()).toBe(200); + expect(response?.status()).toBe(404); + expect(response?.headers()["cache-control"]).toContain("no-store"); + await waitForAppReady(page, "dictionary"); + await expect(page.locator(".charimg-result")).toBeVisible(); }); }); @@ -2459,7 +2472,9 @@ test.describe("charimg-result romanize checkbox (RESCOPE #169)", () => { page, }) => { const response = await page.goto(FALLBACK_PATH); - expect(response?.status()).toBe(200); + // FALLBACK_PATH has no combined entry, so R4 answers 404 with the shell + // body intact; the 字圖 view below is exactly what that body renders. + expect(response?.status()).toBe(404); await page.locator(".charimg-result").waitFor({ state: "visible", timeout: 15_000 }); // Segment rows render after loadSegments() resolves — wait for the first @@ -2521,7 +2536,7 @@ test.describe("charimg-result romanize checkbox (RESCOPE #169)", () => { window.localStorage.setItem("charimg-romanize", "1"); }); const response = await page.goto(FALLBACK_PATH); - expect(response?.status()).toBe(200); + expect(response?.status()).toBe(404); await page.locator(".charimg-result").waitFor({ state: "visible", timeout: 15_000 }); await waitForAppReady(page, "dictionary"); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 9af08f37..ceceeef6 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -30,9 +30,10 @@ test.describe("navigation flows", () => { ); }); - test("404-style unknown route still renders SPA shell", async ({ page }) => { + test("404-style unknown route answers 404 and still renders the SPA shell", async ({ page }) => { const response = await page.goto("/totallymade-up-path-1234"); - expect(response?.status()).toBe(200); + // R4: definitive headword miss ⇒ 404 status, unchanged shell body. + expect(response?.status()).toBe(404); await expect(page).toHaveTitle(/萌典|萌/); }); diff --git a/tests/e2e/visual-invariants.spec.ts b/tests/e2e/visual-invariants.spec.ts index 3e90d7df..a5c882ae 100644 --- a/tests/e2e/visual-invariants.spec.ts +++ b/tests/e2e/visual-invariants.spec.ts @@ -147,7 +147,9 @@ test.describe("R4: percent-encoded route edge cases", () => { page, }) => { const response = await page.goto("/foo%27bar"); - expect(response?.status()).toBe(200); + // Route classification (lang-a entry) is what this asserts; R4 gives the + // missing headword a 404 status while still serving the shell body. + expect(response?.status()).toBe(404); await waitForAppReady(page, "dictionary"); await expect(page.locator(".result")).toBeVisible(); expect(await page.evaluate(() => document.body.className)).toContain("lang-a"); @@ -378,9 +380,9 @@ test.describe("R7: font fallback — navbar-brand stays visible when title fonts // R8: narrow-width stress (390 and 320) on the taigi entry (multi-heteronym, // longest status string). // --------------------------------------------------------------------------- -test.describe("R8: narrow-width stress — entry-copy-status reserved box + no document overflow", () => { +test.describe("R8: narrow-width stress — out-of-flow entry-copy-status + no document overflow", () => { for (const width of [390, 320]) { - test(`${width}px: .entry-copy-status count 1, reserved min-width/min-height, no horizontal overflow`, async ({ + test(`${width}px: .entry-copy-status is out of flow, costs the icon row no width, no horizontal overflow`, async ({ page, }) => { await page.setViewportSize({ width, height: 844 }); @@ -397,18 +399,36 @@ test.describe("R8: narrow-width stress — entry-copy-status reserved box + no d const status = page.locator(".entry-copy-status"); await expect(status).toHaveCount(1); - const dims = await status.evaluate((el) => { + // The status region is an absolutely positioned pill anchored to + // .entry-actions (src/index.css). Being out of flow is what lets the + // icon row share the radical box's line at phone widths — an inline + // reservation (the previous `min-width: 8rem`) never fit beside a + // title. Asserted structurally, plus behaviourally below: the first + // visible icon must start at the row's own left edge — the reserved + // inline box used to sit exactly there and push the icons right. + const layout = await status.evaluate((el) => { const cs = getComputedStyle(el); - const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - return { minWidth: parseFloat(cs.minWidth), minHeight: parseFloat(cs.minHeight), rootPx }; + const row = el.parentElement!; + const rowRect = row.getBoundingClientRect(); + const icons = Array.from( + row.querySelectorAll(".entry-copy-button, a.variants-link, .star"), + ).map((icon) => icon.getBoundingClientRect()); + return { + position: cs.position, + state: el.getAttribute("data-state"), + rowLeft: rowRect.left, + rowRight: rowRect.right, + firstIconLeft: icons.length > 0 ? icons[0].left : Number.NaN, + iconCount: icons.length, + clientWidth: document.documentElement.clientWidth, + }; }); - // src/index.css .entry-copy-status: min-width: 8rem, min-height: 1.5rem - // — resolved against the ROOT font-size at measurement time, which the - // legacy stylesheet loaded above overrides to 62.5% (10px root, not - // the browser-default 16px), so the expected px values are computed - // from the live root font-size rather than hardcoded. - expect(dims.minWidth).toBeGreaterThanOrEqual(8 * dims.rootPx); - expect(dims.minHeight).toBeGreaterThanOrEqual(1.5 * dims.rootPx); + expect(layout.position).toBe("absolute"); + expect(layout.state).toBe("idle"); + expect(layout.iconCount).toBeGreaterThan(0); + // Nothing reserved to the left of the icons. + expect(layout.firstIconLeft - layout.rowLeft).toBeLessThanOrEqual(2); + expect(layout.rowRight).toBeLessThanOrEqual(layout.clientWidth + 1); const overflowBefore = await page.evaluate( () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, @@ -416,9 +436,10 @@ test.describe("R8: narrow-width stress — entry-copy-status reserved box + no d expect(overflowBefore).toBe(true); // Icon-rect stability across the status-text change is already - // covered by dictionary.spec.ts:1929-1965 (boxesBefore/During/After - // toEqual across a real copy click) — not duplicated here. Only the - // reserved-box geometry and document-overflow invariants are new. + // covered by dictionary.spec.ts ("copy button activates with Space and + // keeps status geometry stable") — not duplicated here. What is new at + // narrow widths: the longest status string must stay inside the + // viewport and must not introduce horizontal overflow. const copyButton = page.locator(".entry-copy-button"); if ((await copyButton.count()) > 0) { // Deny clipboard permission (cheap, deterministic) to reach the @@ -435,6 +456,20 @@ test.describe("R8: narrow-width stress — entry-copy-status reserved box + no d await waitForAppReady(page, "dictionary-lang"); await page.getByRole("button", { name: "複製解釋" }).click(); await expect(page.locator(".entry-copy-status")).toHaveText("複製失敗,請手動選取文字"); + await expect(page.locator(".entry-copy-status")).toHaveAttribute("data-state", "error"); + + const shown = await page.locator(".entry-copy-status").evaluate((el) => { + const rect = el.getBoundingClientRect(); + return { + opacity: getComputedStyle(el).opacity, + left: rect.left, + right: rect.right, + clientWidth: document.documentElement.clientWidth, + }; + }); + expect(Number(shown.opacity)).toBeGreaterThan(0.99); + expect(shown.left).toBeGreaterThanOrEqual(-1); + expect(shown.right).toBeLessThanOrEqual(shown.clientWidth + 1); const overflowAfter = await page.evaluate( () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, diff --git a/tests/integration/api-legacy-assets.test.ts b/tests/integration/api-legacy-assets.test.ts index 5e83f79d..1957f6bc 100644 --- a/tests/integration/api-legacy-assets.test.ts +++ b/tests/integration/api-legacy-assets.test.ts @@ -159,6 +159,25 @@ describe("R2 shell fallback (tagged release)", () => { expect(await res.text()).toBe(""); }); + // R4: entry-shaped paths must keep the soft-200 SPA-shell behavior only + // when the word actually resolves; unknown words get a real 404 status + // with the same shell body so client-side fallbacks still hydrate. + it("serves a valid entry path (/萌) as 200 + shell", async () => { + const res = await fetchFromTaggedServer("/%E8%90%8C"); + expect(res.status).toBe(200); + expect(res.headers.get("X-Moedict-Shell-Source")).toBe("r2-release"); + expect(res.headers.get("content-type")).toContain("text/html"); + }); + + it("returns 404 (not soft-200) for an unknown word path like /garbage", async () => { + const res = await fetchFromTaggedServer("/garbage"); + expect(res.status).toBe(404); + // Shell body preserved under the corrected status + no-store. + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(" { // First request to get the ETag const res1 = await fetchFromTaggedServer("/"); diff --git a/tests/integration/smoke.test.ts b/tests/integration/smoke.test.ts index 54276b5e..61b2426c 100644 --- a/tests/integration/smoke.test.ts +++ b/tests/integration/smoke.test.ts @@ -21,3 +21,18 @@ describe("server smoke", () => { expect(res.headers.get("access-control-allow-methods")).toContain("GET"); }); }); + +describe("unknown path statuses (R4)", () => { + it("GET /garbage returns a real 404 instead of a soft-200 SPA shell", async () => { + const res = await fetchFromServer("/garbage"); + expect(res.status).toBe(404); + }); + + it("keeps non-404 shell behavior for a valid entry path (/萌)", async () => { + // Shell availability depends on bindings (this server has neither + // SITE_ASSETS nor a release tag, so the render itself recovers to 503); + // the R4 contract is only that a real headword is never 404'd. + const res = await fetchFromServer("/%E8%90%8C"); + expect(res.status).not.toBe(404); + }); +}); diff --git a/tests/unit/dict-xref-links.test.tsx b/tests/unit/dict-xref-links.test.tsx new file mode 100644 index 00000000..46d292fa --- /dev/null +++ b/tests/unit/dict-xref-links.test.tsx @@ -0,0 +1,229 @@ +/** + * Regression for the audited 似/反 rendering gaps: + * + * R1: 台語條目的反義詞掛在 heteronym 層(API 回傳 + * `"antonyms":"月光環"`),舊版 UI 完全沒有 render + * path——現在應像 legacy 一樣在 sense 區塊之後、華語 xref 之前渲染 .antonyms 反區塊。 + * + * R2: 華語條目 definition 層的 同義詞/反義詞 舊版經 untag() 把 autolink 的 + * `` 錨點剝成純文字;現在應保留錨點(sanitizeLookupHtml 只留 + * `./#` lookup 錨點的 href),點擊由 .result 的 onContentClick 統一攔截導頁。 + */ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { DictionaryPage } from "../../src/pages/DictionaryPage"; + +let container: HTMLDivElement; +let root: Root; + +function RouteProbe(): React.ReactElement { + const location = useLocation(); + return {location.pathname}; +} + +function mockFetch(entry: unknown): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.startsWith("/api/config")) { + return new Response(JSON.stringify({ assetBaseUrl: "/assets" }), { status: 200 }); + } + // xref/cns/stroke 端點先擋掉,避免被下面的通用詞條 regex 吃掉。 + if (/^\/api\/xref\//.test(url)) { + return new Response(JSON.stringify({}), { status: 200 }); + } + if (/^\/api\/cns\//.test(url) || url.includes("/api/stroke-json/")) { + return new Response(null, { status: 404 }); + } + const match = /^\/api\/(.+)\.json$/.exec(url); + if (match) { + return new Response(JSON.stringify(entry), { status: 200 }); + } + throw new Error(`unexpected fetch in test: ${url}`); + }), + ); +} + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + // Suppress React 19 act() warning — known false positive with + // createRoot + useEffect + happy-dom (effects fire after act returns) + const originalConsoleError = console.error.bind(console); + vi.spyOn(console, "error").mockImplementation((msg: unknown, ...rest: unknown[]) => { + if (typeof msg === "string" && msg.includes("not wrapped in act")) return; + originalConsoleError(msg, ...rest); + }); +}); + +afterEach(() => { + root.unmount(); + container.remove(); + vi.unstubAllGlobals(); +}); + +function renderEntry(word: string, lang: "a" | "t"): void { + act(() => { + flushSync(() => { + root.render( + + + + , + ); + }); + }); +} + +describe("heteronym-level antonyms (t-dict 反 block)", () => { + it("renders a .antonyms block with the hyperlinked term", async () => { + mockFetch({ + title: "月暗暝", + heteronyms: [ + { + id: "1", + bopomofo: "ㄍㄜㆷ͘ ㄢ˫ ㄅㄧㄥˊ", + antonyms: "月光環", + definitions: [{ type: "名", def: "測試定義" }], + }, + ], + }); + renderEntry("月暗暝", "t"); + + const block = await vi.waitFor(() => { + const el = container.querySelector(".entry > .antonyms"); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + expect(block.querySelector(".part-of-speech")?.textContent).toBe("反"); + // 舊版(無 render path)連反區塊都不會出現;新版須保留 autolink 錨點。 + const anchor = block.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("./#'月光環'"); + expect(anchor?.textContent).toBe("月光環"); + }); + + it("splits comma-separated antonym terms and strips non-lookup markup", async () => { + mockFetch({ + title: "測試詞", + heteronyms: [ + { + id: "1", + bopomofo: "ㄘㄜˋ", + antonyms: + 'A,B', + definitions: [{ type: "名", def: "測試定義" }], + }, + ], + }); + renderEntry("測試詞", "t"); + + const block = await vi.waitFor(() => { + const el = container.querySelector(".entry > .antonyms"); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + // 兩個詞以 、 分隔,各自由 sanitizeLookupHtml 清過。 + expect(block.textContent).toContain("A、B"); + const html = block.innerHTML; + expect(html).toContain(""); + // 外層非白名單標籤被剝掉,只留文字;外來 http 錨點不保留。 + expect(html).not.toContain(" { + mockFetch({ + title: "測試詞", + heteronyms: [ + { + antonyms: "A", + definitions: [{ type: "名", def: "測試定義" }], + }, + ], + }); + renderEntry("測試詞", "a"); + + // 先等詞條本體渲染完成,再確認 lang='a' 不會出現 heteronym 層反區塊。 + await vi.waitFor(() => { + expect(container.querySelector(".entry > .definition, .entry-item")).not.toBeNull(); + }); + expect(container.querySelector(".entry > .antonyms")).toBeNull(); + }); +}); + +describe("def-level synonyms/antonyms keep lookup anchors (似/反)", () => { + // 每個 fixture 用不同的詞:requestDictionary 的 RESPONSE_CACHE 是模組級的, + // 同 (word, lang) 在測試間會吃到上一個案例的快取回應。 + const aDictWord = "範例詞"; + const aDictEntry = { + title: aDictWord, + heteronyms: [ + { + bopomofo: "ㄘㄜˋ", + definitions: [ + { + type: "名", + def: "測試定義", + synonyms: + "同義詞甲、同義詞乙", + antonyms: "反義詞丙", + }, + ], + }, + ], + }; + + it("preserves anchors in the 似 block instead of untagging them", async () => { + mockFetch(aDictEntry); + renderEntry(aDictWord, "a"); + + const block = await vi.waitFor(() => { + const el = container.querySelector(".entry-item .synonyms"); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + expect(block.querySelector(".part-of-speech")?.textContent).toBe("似"); + // 舊版 untag() 後只剩純文字「同義詞甲、同義詞乙」。 + const anchors = Array.from(block.querySelectorAll("a")); + expect(anchors.map((a) => [a.getAttribute("href"), a.textContent])).toEqual([ + ["./#'同義詞甲'", "同義詞甲"], + ["./#'同義詞乙'", "同義詞乙"], + ]); + }); + + it("preserves anchors in the 反 block instead of untagging them", async () => { + mockFetch(aDictEntry); + renderEntry(aDictWord, "a"); + + const block = await vi.waitFor(() => { + const el = container.querySelector(".entry-item .antonyms"); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + expect(block.querySelector(".part-of-speech")?.textContent).toBe("反"); + const anchor = block.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("./#'反義詞丙'"); + expect(anchor?.textContent).toBe("反義詞丙"); + }); + + it("navigates via the app's SPA link handling when clicked", async () => { + mockFetch(aDictEntry); + renderEntry(aDictWord, "a"); + + const anchor = await vi.waitFor(() => { + const el = container.querySelector(".entry-item .synonyms a"); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + act(() => { + anchor.click(); + }); + expect(document.getElementById("route-probe")?.textContent).toBe("/'同義詞甲'"); + }); +}); diff --git a/tests/unit/dictionary-route.test.ts b/tests/unit/dictionary-route.test.ts index 0346123e..c88c87cd 100644 --- a/tests/unit/dictionary-route.test.ts +++ b/tests/unit/dictionary-route.test.ts @@ -8,7 +8,7 @@ * without a worker → src/oembed → worker import cycle. */ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { buildDefinitionDescription, buildDictionaryPathname, @@ -19,6 +19,7 @@ import { stripTags, tryDecodeURIComponent, } from "../../src/utils/dictionary-route"; +import { applyLegacyHashRewrite } from "../../src/utils/legacy-hash-shim"; describe("stripTags", () => { it("coerces null/undefined to empty string", () => { @@ -213,6 +214,22 @@ describe("classifyRoute", () => { }); }); + describe("privacy", () => { + it("classifies /privacy as its own kind, never as an entry", () => { + expect(classifyRoute("/privacy")).toEqual({ kind: "privacy" }); + }); + it("keeps 'privacy' as a headword when it is not the whole path", () => { + // Only the exact page path is exempt; a lang-prefixed or longer path is + // still an ordinary entry route. + expect(classifyRoute("/'privacy")).toEqual({ kind: "entry", lang: "t", text: "privacy" }); + expect(classifyRoute("/privacy-policy")).toEqual({ + kind: "entry", + lang: "a", + text: "privacy-policy", + }); + }); + }); + describe("radical", () => { it("classifies /@ as empty radical (a)", () => { expect(classifyRoute("/@")).toEqual({ kind: "radical", lang: "a", radical: "" }); @@ -461,4 +478,86 @@ describe("resolveLegacyHashRoute (g0v/moedict-webkit#131)", () => { expect(resolveLegacyHashRoute("/", "#%")).toBeNull(); expect(resolveLegacyHashRoute("/", "#%E8%9")).toBeNull(); }); + // ---- R3 regression: 2014 moedict.org share/bookmark URL forms ---- + + it("maps the #a= share form to the Mandarin entry path", () => { + expect(resolveLegacyHashRoute("/", "#a=萌")).toBe("/萌"); + }); + + it("maps the #= forms for every language, prefixing the term", () => { + expect(resolveLegacyHashRoute("/", "#t=月暗暝")).toBe("/'月暗暝"); + expect(resolveLegacyHashRoute("/", "#h=字")).toBe("/:字"); + expect(resolveLegacyHashRoute("/", "#c=萌")).toBe("/~萌"); + }); + + it("maps the #dict/ form, honoring an inline language prefix", () => { + expect(resolveLegacyHashRoute("/", "#dict/a萌")).toBe("/萌"); + expect(resolveLegacyHashRoute("/", "#dict/t'月暗暝")).toBe("/'月暗暝"); + }); + + it("maps the #dict= variant separator", () => { + expect(resolveLegacyHashRoute("/", "#dict=a萌")).toBe("/萌"); + }); + + it("maps the bare #a:: form", () => { + expect(resolveLegacyHashRoute("/", "#a::萌")).toBe("/萌"); + expect(resolveLegacyHashRoute("/", "#t::月暗暝")).toBe("/'月暗暝"); + }); + + it("decodes percent-encoded structured forms", () => { + expect(resolveLegacyHashRoute("/", "#a=%E8%90%8C")).toBe("/萌"); + }); + + it("passes non-legacy hashes through unchanged (no rewrite target)", () => { + expect(resolveLegacyHashRoute("/", "#foo")).toBeNull(); + expect(resolveLegacyHashRoute("/", "#how-to-use")).toBeNull(); + expect(resolveLegacyHashRoute("/", "#a=")).toBeNull(); + expect(resolveLegacyHashRoute("/", "#dict/a")).toBeNull(); + expect(resolveLegacyHashRoute("/", "#dict/x萌")).toBeNull(); + expect(resolveLegacyHashRoute("/", "#a=萌,萌")).toBeNull(); + }); +}); + +describe("applyLegacyHashRewrite (boot-time shim before React Router mounts)", () => { + afterEach(() => { + window.history.replaceState(null, "", "/"); + }); + + it("rewrites /#a=萌 to /萌 via replaceState and clears the hash", () => { + window.history.replaceState(null, "", "/#a=萌"); + const spy = vi.spyOn(window.history, "replaceState"); + expect(applyLegacyHashRewrite()).toBe(true); + expect(spy).toHaveBeenCalledWith(null, "", "/萌"); + expect(decodeURIComponent(window.location.pathname)).toBe("/萌"); + expect(window.location.hash).toBe(""); + spy.mockRestore(); + }); + + it("rewrites /#'月暗暝 to /'月暗暝", () => { + window.history.replaceState(null, "", "/#'月暗暝"); + const spy = vi.spyOn(window.history, "replaceState"); + expect(applyLegacyHashRewrite()).toBe(true); + expect(spy).toHaveBeenCalledWith(null, "", "/'月暗暝"); + expect(decodeURIComponent(window.location.pathname)).toBe("/'月暗暝"); + expect(window.location.hash).toBe(""); + spy.mockRestore(); + }); + + it("leaves non-legacy hashes untouched", () => { + window.history.replaceState(null, "", "/#how-to-use"); + const spy = vi.spyOn(window.history, "replaceState"); + expect(applyLegacyHashRewrite()).toBe(false); + expect(spy).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe("/"); + expect(window.location.hash).toBe("#how-to-use"); + spy.mockRestore(); + }); + + it('leaves the navbar\'s bare href="#" untouched', () => { + window.history.replaceState(null, "", "/#"); + const spy = vi.spyOn(window.history, "replaceState"); + expect(applyLegacyHashRewrite()).toBe(false); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); }); diff --git a/tests/unit/middle-point-resolver.test.ts b/tests/unit/middle-point-resolver.test.ts index 734973a8..83ec85ec 100644 --- a/tests/unit/middle-point-resolver.test.ts +++ b/tests/unit/middle-point-resolver.test.ts @@ -132,4 +132,8 @@ describe("resolveMiddlePointTarget", () => { it("routes about to the about page", () => { expect(resolveMiddlePointTarget("/about")).toEqual({ page: "about" }); }); + + it("routes privacy to the privacy page", () => { + expect(resolveMiddlePointTarget("/privacy")).toEqual({ page: "privacy" }); + }); }); diff --git a/tests/unit/ssr-head.test.ts b/tests/unit/ssr-head.test.ts index c47ff9ac..8251f817 100644 --- a/tests/unit/ssr-head.test.ts +++ b/tests/unit/ssr-head.test.ts @@ -47,6 +47,13 @@ describe("resolveHeadByPath", () => { it("returns about head for /about.html (legacy alias)", () => { expect(resolveHeadByPath("/about.html").title).toBe("關於本站 - 萌典"); }); + + it("returns privacy head for /privacy (not a 'privacy' headword lookup)", () => { + const head = resolveHeadByPath("/privacy"); + expect(head.title).toBe("隱私權政策 - 萌典"); + expect(head.description).toContain("不蒐集任何個人資料"); + expect(head.ogUrl).toBe("https://www.moedict.tw/privacy"); + }); }); describe("radical routes", () => { diff --git a/tests/unit/worker-dispatch-edges.test.ts b/tests/unit/worker-dispatch-edges.test.ts index b1bc3ba0..9176bb87 100644 --- a/tests/unit/worker-dispatch-edges.test.ts +++ b/tests/unit/worker-dispatch-edges.test.ts @@ -422,7 +422,11 @@ describe("dispatch — HTML shell fetch returns non-OK", () => { fetch: vi.fn(async () => new Response("missing", { status: 404 })), }; const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); - const res = await dispatch(req("/deep/link"), env); + // "/" is a non-entry (default) route — it reaches renderHtmlShellWithFallback + // untouched and surfaces the recovery shell when SITE_ASSETS fails. + // (Entry-shaped word paths now hard-404 on a definitive dictionary miss, + // so they no longer exercise the recovery branch.) + const res = await dispatch(req("/"), env); expect(res.status).toBe(503); expect(res.headers.get("X-Moedict-Shell-Source")).toBe("recovery"); }); @@ -493,27 +497,52 @@ describe("dispatch — HTML shell metadata injection with dictionary lookup", () expect(body).toMatch(/property="og:description" content="[^"]*植物發芽/); }); - it("handles the lang=t prefix `/'食` (parseDictionaryRoute line 45)", async () => { + // R4: with an empty DICTIONARY the probe finds no headword, so these bare + // entry-shaped paths now return 404 — but the shell render (and its + // injectHeadMetadata prefix-parsing branch) still ran first, and the shell + // BODY is preserved under the corrected status. + it("lang=t prefix `/'食` renders the shell, then 404s on the dictionary miss", async () => { const fetcher = shellFetcher(); const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); - // Missing pack is OK — the route just resolves without a rich - // description, but the prefix-parsing branch still runs. const res = await dispatch(req("/%27%E9%A3%9F"), env); + expect(res.status).toBe(404); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(" { + const fetcher = shellFetcher(); + const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); + const res = await dispatch(req("/privacy"), env); expect(res.status).toBe(200); + expect(await res.text()).toContain(" { + it("keeps /about at 200 with an empty DICTIONARY", async () => { const fetcher = shellFetcher(); const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); - const res = await dispatch(req("/%3A%E5%AD%97"), env); + const res = await dispatch(req("/about"), env); expect(res.status).toBe(200); }); - it("handles the lang=c prefix `/~萌` (parseDictionaryRoute line 47)", async () => { + it("lang=h prefix `/:字` renders the shell, then 404s on the dictionary miss", async () => { + const fetcher = shellFetcher(); + const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); + const res = await dispatch(req("/%3A%E5%AD%97"), env); + expect(res.status).toBe(404); + expect(await res.text()).toContain(" { const fetcher = shellFetcher(); const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); const res = await dispatch(req("/~%E8%90%8C"), env); - expect(res.status).toBe(200); + expect(res.status).toBe(404); + expect(await res.text()).toContain(" { diff --git a/tsconfig.node.json b/tsconfig.node.json index 41ed69ed..af4df8c6 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,7 +2,7 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2023", - "lib": ["ES2023"], + "lib": ["ES2023", "DOM"], "module": "ESNext", "skipLibCheck": true, @@ -22,5 +22,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts", "playwright.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts"], + "files": ["workers-cache-augment.d.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 52f67bef..043aaa4e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -9,6 +10,8 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; import { tryDecodeURIComponent } from "./src/utils/dictionary-route"; import { STROKE_JSON_BASE_URL } from "./src/utils/media-cdn"; +import { handleDictionaryAPI } from "./src/api/handleDictionaryAPI"; +import { handleListAPI, isListPath } from "./src/api/handleListAPI"; interface LocalStaticMount { prefix: string; @@ -64,7 +67,7 @@ function contentTypeFor(filePath: string): string { } } -async function proxyStrokeJson(cp: string, res: import("http").ServerResponse): Promise { +async function proxyStrokeJson(cp: string, res: ServerResponse): Promise { if (!/^[0-9a-f]{4,6}\.json$/i.test(cp)) { res.statusCode = 400; res.end("Bad Request"); @@ -162,12 +165,133 @@ function workerProxyConfig(origin: string): Record { ); } +/** + * Filesystem-backed stand-in for the production `env.DICTIONARY` R2 binding. + * Every key the Worker reads (`p{lang}ck/{bucket}.txt` pack shards, + * `{lang}/index.json`, `{lang}/xref*.json`, `{lang}/@{radical}.json`, + * `{lang}/={list}.json`, `search-index/{lang}.json`) maps 1:1 onto a path + * under data/dictionary/, so dev serve can reuse the real handlers + * (handleDictionaryAPI / handleListAPI) and return identical response shapes + * instead of re-implementing the entry conversion pipeline. + * + * One shared instance per server process: r2-json-cache memoizes parsed JSON + * per binding object via WeakMap, so reusing the binding keeps pack shards + * warm across requests exactly like a warm Worker isolate does. + */ +const localDictionaryRoot = path.resolve(projectRoot, "data/dictionary"); +const localDictionaryEnv = { + DICTIONARY: { + async get(key: string): Promise<{ text(): Promise } | null> { + const normalizedKey = path.posix.normalize(key.replace(/^\/+/, "")); + if ( + normalizedKey.length === 0 || + normalizedKey === "." || + normalizedKey.startsWith("..") || + normalizedKey.includes("\0") + ) { + return null; + } + const resolvedPath = path.resolve(localDictionaryRoot, normalizedKey); + if (path.relative(localDictionaryRoot, resolvedPath).startsWith("..")) { + return null; + } + try { + const content = await fs.promises.readFile(resolvedPath, "utf8"); + return { text: () => Promise.resolve(content) }; + } catch { + return null; + } + }, + }, +}; + +/** Mirrors the production Worker's JSON routes onto local files/handlers. */ +const localApiFileRoutes: Array<{ pattern: RegExp; keyTemplate: string }> = [ + // Sidebar 搜尋索引 API → {lang}/index.json + { pattern: /^\/api\/index\/([athc])\.json$/, keyTemplate: "$1/index.json" }, + // 全文檢索索引 API → search-index/{lang}.json + { pattern: /^\/api\/search-index\/([athc])\.json$/, keyTemplate: "search-index/$1.json" }, + // 跨語言 xref 索引 API → {lang}/xref.json + { pattern: /^\/api\/xref\/([athc])\.json$/, keyTemplate: "$1/xref.json" }, + // ID-aware xref sidecar → {lang}/xref-by-id.json + { pattern: /^\/api\/xref-by-id\/([athc])\.json$/, keyTemplate: "$1/xref-by-id.json" }, +]; + +async function pipeWebResponse(response: Response, res: ServerResponse): Promise { + const contentType = response.headers.get("Content-Type") ?? "application/json; charset=utf-8"; + res.statusCode = response.status; + res.setHeader("Content-Type", contentType); + // Dev server never caches; prod Cache-Control/Cache-Tag are meaningless locally. + res.setHeader("Cache-Control", "no-store"); + res.end(response.status === 204 ? undefined : await response.text()); +} + +/** + * Serve /api/*.json from local data/dictionary/ during `vp run dev`, + * dispatching through the same handlers as worker/index.ts: + * - /api/index, /api/search-index, /api/xref, /api/xref-by-id → flat file reads + * - list routes (=成語、'=諺語…) → handleListAPI + * - every other .json route (entries, @radicals, =lists, raw/uni/pua) + * → handleDictionaryAPI + * Returns false when the path is not a locally-served API route or the + * underlying data file is absent — the caller falls through (Vite's SPA + * fallback or the next middleware), matching "don't fake missing data". + */ +async function serveLocalDictionaryApi( + req: IncomingMessage, + res: ServerResponse, +): Promise { + let url: URL; + try { + url = new URL(req.url ?? "", "http://localhost"); + } catch { + return false; + } + const pathname = url.pathname; + + for (const { pattern, keyTemplate } of localApiFileRoutes) { + const match = pathname.match(pattern); + if (!match) continue; + const object = await localDictionaryEnv.DICTIONARY.get(keyTemplate.replace("$1", match[1])); + if (!object) return false; + await pipeWebResponse( + new Response(await object.text(), { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }), + res, + ); + return true; + } + + if (req.method !== "GET" && req.method !== "HEAD") return false; + + const request = new Request(url.href, { method: req.method }); + // Mirror worker/index.ts dispatch order: lists gate first (`.json` optional), + // then the .json catch-all. stroke-json is proxied earlier in this plugin; + // cns/config/cache-purge/oembed have no local data and keep falling through. + if (pathname.startsWith("/api/") && isListPath(pathname)) { + await pipeWebResponse(await handleListAPI(request, url, localDictionaryEnv), res); + return true; + } + if ( + pathname.endsWith(".json") && + !pathname.startsWith("/api/stroke-json/") && + !pathname.startsWith("/api/cns/") + ) { + const response = await handleDictionaryAPI(request, url, localDictionaryEnv); + if (!response) return false; + await pipeWebResponse(response, res); + return true; + } + return false; +} + function localDataAssetsPlugin(): Plugin { return { name: "moedict-local-data-assets", apply: "serve", configureServer(server) { - server.middlewares.use((req, res, next) => { + server.middlewares.use(async (req, res, next) => { const requestUrl = req.url; if (!requestUrl) { next(); @@ -185,6 +309,10 @@ function localDataAssetsPlugin(): Plugin { return; } + if (await serveLocalDictionaryApi(req, res)) { + return; + } + const mount = localStaticMounts.find(({ prefix }) => pathname.startsWith(prefix)); if (!mount) { next(); diff --git a/worker/index.ts b/worker/index.ts index b56efc36..b008f364 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,5 +1,8 @@ -import { handleDictionaryAPI } from "../src/api/handleDictionaryAPI"; -import { lookupDictionaryEntry } from "../src/api/handleDictionaryAPI"; +import { + handleDictionaryAPI, + lookupDictionaryEntry, + probeDictionaryEntry, +} from "../src/api/handleDictionaryAPI"; import { handleListAPI, isListPath } from "../src/api/handleListAPI"; import { handleLookupAPI } from "../src/api/handleLookupAPI"; import { handleStrokeAPI, peekStrokeCorpusDigest } from "../src/api/handleStrokeAPI"; @@ -13,6 +16,7 @@ import type { CachePurger } from "../src/api/cache"; import { fetchDictionaryObjectText, resolveDictionaryPointerState } from "../src/api/r2-json-cache"; import { buildDefinitionDescription, + classifyRoute, parseDictionaryRoute, type DictionaryEntryLike, } from "../src/utils/dictionary-route"; @@ -194,6 +198,26 @@ export function shouldRenderHtmlShell(request: Request, url: URL): boolean { return true; } +/** + * R4 status correctness: a bare word path that resolves to no dictionary + * headword must not soft-200 like a real entry route — crawlers and CDNs + * would treat the fabricated shell as content, and the legacy site + * hard-404'd such paths. Keep serving the SPA shell BODY (so any client-side + * fallback still hydrates on hard reload); correct only the status. + */ +function notFoundShellResponse(request: Request, shellResponse: Response): Response { + if (shellResponse.status !== 200) { + // Shell unavailable (recovery 503 etc.) — plain status-correct 404. + return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } }); + } + const headers = new Headers(shellResponse.headers); + headers.set("Cache-Control", "no-store"); + return new Response(request.method === "HEAD" ? null : shellResponse.body, { + status: 404, + headers, + }); +} + function getAssetsBucket(env: Env): R2Bucket | null { const candidate = env.ASSETS; if (!candidate || typeof candidate !== "object" || !("get" in candidate)) return null; @@ -585,7 +609,25 @@ async function dispatchCore(request: Request, env: Env, ctx?: ExecutionContext): } if (shouldRenderHtmlShell(request, url)) { - return await renderHtmlShellWithFallback(request, env, url.pathname, injectHeadMetadata); + const shellResponse = await renderHtmlShellWithFallback( + request, + env, + url.pathname, + injectHeadMetadata, + ); + // R4: entry-shaped bare word paths that resolve to no headword get a + // real 404 (shell body preserved). Non-entry kinds (/, /about, /@部首, + // /=分類, /'*清單, invalid encoding) and data-layer failures (probe + // null) fail open to the current behavior. + const route = classifyRoute(url.pathname); + if ( + route.kind === "entry" && + route.text && + (await probeDictionaryEntry(route.text, route.lang, env)) === false + ) { + return notFoundShellResponse(request, shellResponse); + } + return shellResponse; } const staticResponse = await serveAssetWithFallback(request, env); diff --git a/workers-cache-augment.d.ts b/workers-cache-augment.d.ts new file mode 100644 index 00000000..c8a638d2 --- /dev/null +++ b/workers-cache-augment.d.ts @@ -0,0 +1,24 @@ +/** + * Cloudflare Workers extends the standard CacheStorage with a default + * edge cache (`caches.default`). vite.config.ts imports src/api handlers + * and worker/index.ts references it; the DOM lib added to + * tsconfig.node.json provides `caches` itself but not this + * Workers-specific property. + * + * Structural subset of the Workers Cache API — enough for the + * match/put call sites in worker/index.ts. Augmentation (not global + * redeclaration) so this file coexists with worker-configuration.d.ts + * in any program without duplicate identifiers. + */ + +declare global { + interface CacheStorage { + readonly default: { + match(request: unknown): Promise; + put(request: unknown, response: Response): Promise; + delete(request: unknown): Promise; + }; + } +} + +export {};