From f63846bdbf85ff9222a46a5f7b659dc11d2c75b8 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Mon, 17 Aug 2026 08:26:25 +0800 Subject: [PATCH 01/12] fix(layout): keep title controls on the radical's line while they fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile heading controls dropped to their own row unconditionally: the icon row was pinned to `grid-row: 3` and `.radical` was squeezed into a `max-width: 4.5rem` column, so /萌 at 375px burned four stacked rows (艸 / +8 / = 12 / pencil, then copy·異體字·star) with obvious free space next to the headword. Root blocker was `.entry-copy-status { min-width: 8rem }`: a permanent inline reservation for the copy feedback. 8rem plus three 44px targets can never fit beside a title at phone widths, so the icon row could not share a line no matter how much room the title left. - `.entry-heading` (≤767px) is now ONE wrapping flex line: title `flex: 1 1 auto; min-width: 0`, `justify-content: flex-end`, `order` for visual sequence. Flex line-breaking uses max-content sizes, so the radical box and the icon row stay on the title's line whenever they fit and wrap to their own right-flush line only when the title needs that width. Measured: 375px /萌 → 2 rows (heading 193px → 145px); 560px → all one line; 375px /一鼓作氣 → title line 1, controls share line 2; 1280px unchanged. - `.entry-copy-status` becomes an absolutely positioned pill under the icon row, visibility driven by `data-state="idle|ok|error"`. The node is never unmounted (an aria-live region must exist before its content changes), and out-of-flow satisfies the zero-layout-shift contract more directly than the reservation did. Background uses --moe-surface-alt; --moe-surface equals the entry card in dark mode and made the pill invisible. - `.radical` is nowrap with a 0.15em column gap, so the 部首/筆畫 badge is one line again, and its stroke-order pencil joins the toolbar at a 44px touch target (legacy sized it 32x30). - `row-gap: 0.5rem` gives clearance for ruby tone marks, which paint below h1.title's own box and otherwise graze a wrapped control line. Tests updated to the new contract: `mobile: controls share the radical's line when the heading still has room` (560px) is the guard for the reported behaviour; the 333px test now asserts controls clear the title and the icon row stays right-flush; the enlarged-64px-title test asserts both groups clear the title rather than stacking; visual-invariants R8 (320/390px) asserts the status region is out of flow, that nothing is reserved left of the first icon, and that the longest failure message stays inside the viewport. Also in this commit, because it is the same in-flight narrow-heading work this checkout carried uncommitted and my layout is verified on top of it (see 5b1a7de1 on origin/fix/narrow-heading-wrap): the title ruby box in index.css (e2e blocks r2-assets styles.css, without which length-3 yin+diao cannot share a heading) and the moe-android diao centering. Separately, plain `vp dev` gains a dev proxy for the paths the Worker owns (`/api`, `/assets` → VITE_DEV_API_ORIGIN, default www.moedict.tw, with `public/` winning via bypass). Without it those paths hit Vite's SPA fallback and returned index.html, which surfaced as `取得 ASSET_BASE_URL 失敗: SyntaxError: JSON.parse` and `MIME 型態「text/html」不是「text/css」`. AGENTS.md documents both dev modes and the heading-layout invariant. Verified: chromium e2e 259 passed, webkit-romanization 15, unit 2125, integration 141, tsc -b --noEmit clean, vp lint clean, dark-contrast audit 0 unexpected findings. --- AGENTS.md | 45 +++++- src/index.css | 207 ++++++++++++++++++++++++---- src/pages/DictionaryPage.tsx | 12 ++ tests/e2e/dictionary.spec.ts | 121 ++++++++++++++-- tests/e2e/visual-invariants.spec.ts | 63 +++++++-- vite.config.ts | 52 ++++++- 6 files changed, 440 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a80e24ab..58b5fc50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,8 @@ memory/MEMORY.md # 跨 session 的架構筆記(與本檔互補) ```bash vp install # 依 devEngines.packageManager 安裝相依 -vp run dev # predev 重建索引,再啟動 Vite + Miniflare +vp run dev # predev 重建索引,再啟動 Vite(無 Worker,見下) +bun run dev:remote # 同上但改跑真 Worker + 遠端 R2(需 wrangler 登入) vp run build # prebuild 重建索引、tsc -b,再執行 vp build vp check # Oxfmt + type-aware Oxlint + TS diagnostics vp run typecheck # canonical tsc -b --noEmit project build @@ -83,6 +84,32 @@ vp run test:coverage # 三層 coverage 合併至 coverage/combined/ `vp run test:unit` / `vp run test:integration`。不要用裸 `bun test`——它不讀 `vite.config.ts` 的 happy-dom、setup、alias 與 project 設定。 +### 本機 dev 的兩種模式(`vite.config.ts` 的 `plugins` 三元式) + +`command === "serve"` 時,只有 `VITE_CLOUDFLARE_REMOTE_DEV=1` 才會掛 +`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`**, + 瀏覽器就會出現 `取得 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 + + `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`),否則以 + `Failed to start the remote proxy session` 開不起來。三個 R2 綁定都是 + `remote: true`,所以沒有「本機空桶」模式可用;要離線跑真 Worker 只有 + `serve:e2e` 那條(curated fixtures)。 + ## 部署(零停機兩階段 rollout,這是規範不是建議) **沒有裸 `wrangler deploy` 這條路。** 標準指令一律走安全 orchestrator: @@ -594,6 +621,22 @@ moedict-data(MOE 原始 dump)→ moedict-process(pack 產生器) 依 legacy `view.ls:132-158`):由 `src/components/TitlePronunciation.tsx` 單點持有(ruby 以 children slot 傳入),順序由 `tests/unit/title-pronunciation.test.tsx` 鎖定。改順序=改該元件+測試。 +- **詞條標題列的控制項版型**(`.entry-heading` / `.entry-control-stack` / + `.radical` / `.entry-actions`,`src/index.css`):桌機(≥768px)維持 + `float: right` 的兩列堆疊(部首+筆順一列、複製/異體字/星號一列); + 行動版(`@media (max-width: 767px)`)是**單一 wrapping flex 列** + (title `flex: 1 1 auto` + `justify-content: flex-end` + `order` 排序), + 部首盒與圖示列**只有在標題真的需要那段寬度時才換行下移**,寬一點的手機 + /橫向就會排在同一列。**不要**用 `grid-row` 之類把圖示列釘死在下一列。 + 行動版 4 個控制項(含 `.radical` 裡的筆順鉛筆)都是 44px 觸控目標。 + `.entry-copy-status`(`role="status"` aria-live 區域)是**絕對定位**的 + 浮出提示,靠 `data-state="idle|ok|error"` 控制可見度(節點永不 unmount, + 否則 AT 不會播報)——**絕不可**改回行內 `min-width` 預留,那正是圖示列 + 在手機上永遠擠不進標題那一列的原因。守門測試在 + `tests/e2e/dictionary.spec.ts`(三個 `mobile: controls …` 版型案例,以及 + `copy button activates with Space …` 的零位移契約)與 + `tests/e2e/visual-invariants.spec.ts` 的 R8(320/390px:狀態列 out-of-flow、 + 圖示列左緣不被預留、無水平溢出)。 - **URL 前綴文法**(`'`=t、`:`=h、`~`=c、`@`/`=`/`=*` 家族、`/<數字>` idx): 唯一定義在 `src/utils/dictionary-route.ts` 的 `classifyRoute`(頁面/head 分類) 與 `stripLangPrefix`(語言前綴,API 端加 `{'!': 't'}` legacy 別名)。 diff --git a/src/index.css b/src/index.css index 742adca4..7dcb21a9 100644 --- a/src/index.css +++ b/src/index.css @@ -797,7 +797,10 @@ html[data-theme="dark"] .result .entry-actions .iconic-color.star:hover { /* 星號收藏/複製解釋/異體字字典連結:單一動作列,取代原先各自散落的絕對 定位星號、逐條複製按鈕、標題旁的異體字連結圖示(UI 調整:見對應 issue)。 - idx===0 時渲染一次;flex-wrap 讓窄螢幕自然換行,不需要另外的手機專屬版型。*/ + idx===0 時渲染一次。寬螢幕(≥768px)把 radical + actions 浮在標題右側、 + 上下兩列;窄螢幕改成單一 wrapping flex 列:部首/筆順與圖示列只有在標題 + 真的需要那段寬度時才換行下移(狀態訊息改為絕對定位的浮出提示,不再預留 + 8rem 行內寬度把標題擠成一窄條)。*/ /* Float the controls in a BFC so the title keeps its legacy full-width block/ruby geometry while the heading reserves the taller control stack. */ .result .entry-heading { @@ -822,7 +825,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; @@ -875,41 +879,64 @@ 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) { + /* 44px touch targets for every control in the heading toolbar, including + the stroke-animation pencil (legacy sizes it 32x30 via + `.iconic-circle.stroke`) now that it reads as part of the same row. */ .entry-actions .entry-copy-button, .entry-actions .variants-link, - .entry-actions .star { + .entry-actions .star, + .result .entry-control-stack .radical a.iconic-circle.stroke { min-width: 44px; min-height: 44px; display: inline-flex; @@ -917,6 +944,70 @@ html[data-theme="dark"] .result .entry-actions .iconic-color.star:hover { justify-content: center; padding: 7px; } + + /* Mobile heading = ONE wrapping flex line, not fixed grid rows. + + Items in visual order: title, radical box, action icons. Flex line + breaking uses each item's max-content size, so the controls stay on the + title's line whenever they actually fit, and drop to their own + right-aligned line only when the title needs that width -- which is the + whole point (previously `.entry-actions` was pinned to `grid-row: 3`, so + it dropped a row unconditionally, and `.radical` was squeezed into a + 4.5rem column that wrapped the 部首/筆畫 badge over three lines). + + h1.title grows into the leftover space so `justify-content: flex-end` + only ever right-aligns a controls-only line; on a shared line the title + keeps its left edge and the controls stay flush right. + `display: contents` on the stack keeps DictionaryPage's DOM intact + (Hakka `h1.title` nextElementSibling stays `.bopomofo`) while letting + the radical box and the icon row wrap independently. */ + .result .entry-heading { + display: flex; + flex-flow: row wrap; + justify-content: flex-end; + align-items: flex-start; + column-gap: 0.5rem; + /* 0.5rem, not 0.25rem: the title's ruby annotations (`ru[annotation]` + tone marks) paint below h1.title's own box, so a wrapped control line + needs clearance or a large title's tone mark grazes the icon row. */ + row-gap: 0.5rem; + } + .result .entry-control-stack { + float: none; + display: contents; + min-width: 0; + margin-left: 0; + } + .result .entry-heading > h1.title { + order: 0; + flex: 1 1 auto; + min-width: 0; + } + .result .entry-control-stack .radical { + order: 1; + flex: 0 0 auto; + display: flex; + flex-wrap: nowrap; + justify-content: flex-end; + align-items: center; + max-width: none; + white-space: nowrap; + /* Flex items drop their own leading whitespace, so the legacy markup's + " = 12" would render as "+8= 12" at gap: 0. */ + column-gap: 0.15em; + row-gap: 0; + } + .result .entry-actions { + order: 2; + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + .result .entry-heading > .bopomofo, + .result .entry-heading > .cn-specific { + order: 3; + flex: 1 1 100%; + } } @media (prefers-reduced-motion: reduce) { .entry-actions button:active, @@ -1050,7 +1141,11 @@ html.moe-capacitor .result .entry .title { html.moe-android .result .entry .title { --title-zhuyin-x-shift: 0.012em; - --title-zhuyin-y-shift: -0.055em; + /* 0: `top: calc(50% + var(--title-zhuyin-y-shift))` only applies once + zhuyin is `position: absolute` (this file, and styles.css in prod). + -0.055em consumed the 3.5px Android yin-center budget at 59.7px + title size once that top started applying. */ + --title-zhuyin-y-shift: 0em; } html.moe-ios .result .entry .title { @@ -1127,6 +1222,34 @@ body[data-ruby-pref="none"] hruby.rightangle ru[annotation] > .romanization-sele display: none !important; } +/* Title ruby box (legacy `hruby ru[zhuyin]` in styles.css). Needed in + index.css because e2e blocks r2-assets.test.local/styles.css — without + this, length-3 yin+diao is five 1em inline cells (~300px) and cannot + share a heading with the radical chip. When styles.css loads, its + `width: 1.55em !important` and `right: .05em !important` match; the + zhuyin-pref rules below still win `top`/`transform` via `!important`. */ +.result .entry .title hruby ru[zhuyin] { + display: inline-block; + position: relative; + width: 1.55em; +} +.result .entry .title hruby ru[zhuyin] zhuyin { + position: absolute; + display: inline-block; + transform: scale(0.4); + right: 0.05em; + top: 0; + height: 2.7em; + width: 0.8em; + line-height: 0.9; + white-space: pre-wrap; + word-break: break-all; +} +.result .entry .title hruby ru[zhuyin] diao { + position: absolute; + display: inline-block; +} + .result .entry .title hruby.rightangle ru[zhuyin] zhuyin, .result .entry .title hruby.rightangle ru[zhuyin] yin, .result .entry .title hruby.rightangle ru[zhuyin] diao { @@ -1160,6 +1283,34 @@ html.moe-capacitor .result .entry .title hruby.rightangle ru[zhuyin] diao { width: 0.7em; } +/* Android e2e has no styles.css, so zhuyin is our 2.7em transform CB. + Checked-final `top` (issue #301) is written for ru as CB and would + miss ru-center by >10px. Scope to moe-android so #301's injected + styles.css path keeps the length-specific tops. */ +html.moe-android + body[data-ruby-pref="zhuyin"] + .result + .entry + .title + hruby.rightangle + ru[zhuyin] + diao { + top: 50% !important; + margin-top: 0 !important; + transform: translateY(-50%) !important; +} + +html.moe-android + body[data-ruby-pref="zhuyin"] + .result + .entry + .title + hruby.rightangle + ru[zhuyin][length="2"] + diao { + top: calc(50% - 0.475em) !important; +} + body[data-ruby-pref="zhuyin"] .result .entry .title hruby.rightangle ru[zhuyin] zhuyin, body[data-ruby-pref="zhuyin"] .result .entry .title hruby.rightangle ru[zhuyin][length="1"] zhuyin, body[data-ruby-pref="zhuyin"] .result .entry .title hruby.rightangle ru[zhuyin][length="2"] zhuyin, diff --git a/src/pages/DictionaryPage.tsx b/src/pages/DictionaryPage.tsx index 13b215ca..a1d3bc7f 100644 --- a/src/pages/DictionaryPage.tsx +++ b/src/pages/DictionaryPage.tsx @@ -1082,6 +1082,18 @@ export function DictionaryPage({ word, lang, idx: targetDefIdx }: DictionaryPage
{ + // The mobile heading is a wrapping flex line (src/index.css + // `@media (max-width: 767px) .result .entry-heading`), NOT fixed grid + // rows: the icon row only drops below the 部首/筆畫 box when the title + // actually needs that width. 560px is inside the mobile breakpoint and + // wide enough for title + radical + icons on one line. + await page.setViewportSize({ width: 560, height: 812 }); + await page.goto("/%E8%90%8C"); + await waitForEntryHydration(page, "萌"); + const heading = page.locator(".entry-heading").first(); + const radical = page.locator(".radical").first(); + const actions = page.locator(".entry-actions").first(); + const title = page.locator("h1.title").first(); + const [headingBox, radicalBox, actionsBox, titleBox] = await Promise.all([ + heading.boundingBox(), + radical.boundingBox(), + actions.boundingBox(), + title.boundingBox(), + ]); + expect(headingBox && radicalBox && actionsBox && titleBox).toBeTruthy(); + if (!headingBox || !radicalBox || !actionsBox || !titleBox) return; + + // Same flex line as the radical box — this is the whole point. + expect(Math.abs(actionsBox.y - radicalBox.y)).toBeLessThanOrEqual(2); + // Radical box sits between the title and the icons, all flush right. + expect(radicalBox.x).toBeGreaterThanOrEqual(titleBox.x + titleBox.width - 2); + expect(actionsBox.x).toBeGreaterThanOrEqual(radicalBox.x + radicalBox.width - 2); + expect( + Math.abs(actionsBox.x + actionsBox.width - (headingBox.x + headingBox.width)), + ).toBeLessThanOrEqual(2); + // 44px touch targets survive the shared line. + for (const selector of [".entry-copy-button", "a.variants-link", ".star"]) { + const box = await actions.locator(selector).boundingBox(); + expect(box?.width).toBe(44); + expect(box?.height).toBe(44); + } + }); + test("mobile: controls wrap below a long title instead of stealing heading width", async ({ + page, + }) => { + await page.setViewportSize({ width: 333, height: 700 }); + // 萌芽 is in the seeded 萌 pack bucket; 韓非子 is a production headword + // that the curated e2e fixtures do not load (falls through to 字卡). + await page.goto("/%E8%90%8C%E8%8A%BD"); + await waitForEntryHydration(page, "萌芽"); + const titleLocator = page.locator("h1.title").first(); + await expect(titleLocator).toBeVisible(); + const radical = page.locator(".radical").first(); + const actions = page.locator(".entry-actions").first(); + const heading = page.locator(".entry-heading").first(); + const [titleBox, radicalBox, actionsBox, headingBox] = await Promise.all([ + titleLocator.boundingBox(), + radical.boundingBox(), + actions.boundingBox(), + heading.boundingBox(), + ]); + expect(titleBox && radicalBox && actionsBox && headingBox).toBeTruthy(); + if (!titleBox || !radicalBox || !actionsBox || !headingBox) return; + // Too narrow to share: both controls wrap onto their own line(s) BELOW + // the title, never squeezing it into a column. + expect(radicalBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height - 2); + expect(actionsBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height - 2); + // The icon row — the rightmost group on whichever line it lands on — is + // always flush with the heading's right edge; the radical box either + // shares that line immediately to its left, or is right-flush on its own. + expect( + Math.abs(actionsBox.x + actionsBox.width - (headingBox.x + headingBox.width)), + ).toBeLessThanOrEqual(2); + if (Math.abs(radicalBox.y - actionsBox.y) <= 2) { + expect(radicalBox.x + radicalBox.width).toBeLessThanOrEqual(actionsBox.x + 2); + } else { + expect( + Math.abs(radicalBox.x + radicalBox.width - (headingBox.x + headingBox.width)), + ).toBeLessThanOrEqual(2); + } + expect(titleBox.width).toBeGreaterThan(headingBox.width * 0.7); + expect(radicalBox.width).toBeLessThan(80); + }); test("mobile: enlarged long title reserves controls and preserves Hakka adjacency", async ({ page, }) => { @@ -2180,19 +2260,28 @@ test.describe("entry copy-explanation action (RESCOPE #258, single action-row bu element.setAttribute("style", "font-size: 64px; line-height: 1.2"); }); const heading = page.locator(".entry-heading").first(); - const stack = page.locator(".entry-control-stack").first(); + const radical = page.locator(".radical").first(); + const actions = page.locator(".entry-actions").first(); const titleLocator = page.locator("h1.title").first(); const firstItem = page.locator(".entry-item").first(); - const [headingBox, stackBox, titleBox, itemBox] = await Promise.all([ + const [headingBox, radicalBox, actionsBox, titleBox, itemBox] = await Promise.all([ heading.boundingBox(), - stack.boundingBox(), + radical.boundingBox(), + actions.boundingBox(), titleLocator.boundingBox(), firstItem.boundingBox(), ]); - expect(headingBox && stackBox && titleBox && itemBox).toBeTruthy(); - if (!headingBox || !stackBox || !titleBox || !itemBox) return; - expect(stackBox.x).toBeGreaterThanOrEqual(0); - expect(stackBox.x + stackBox.width).toBeLessThanOrEqual(375 + 1); + expect(headingBox && radicalBox && actionsBox && titleBox && itemBox).toBeTruthy(); + if (!headingBox || !radicalBox || !actionsBox || !titleBox || !itemBox) return; + expect(radicalBox.x).toBeGreaterThanOrEqual(0); + expect(radicalBox.x + radicalBox.width).toBeLessThanOrEqual(375 + 1); + expect(actionsBox.x).toBeGreaterThanOrEqual(0); + expect(actionsBox.x + actionsBox.width).toBeLessThanOrEqual(375 + 1); + // A 64px title consumes the whole flex line, so both control groups wrap + // below it (they may share that wrapped line with each other — the + // invariant is that they clear the title, not that they stack). + expect(actionsBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height - 2); + expect(radicalBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height - 2); const titleRects = await titleLocator.evaluate((title) => { const range = document.createRange(); const walker = document.createTreeWalker(title, NodeFilter.SHOW_TEXT); @@ -2214,16 +2303,20 @@ test.describe("entry copy-explanation action (RESCOPE #258, single action-row bu return rects; }); const tolerance = 1; + const separatedFrom = ( + rect: { left: number; right: number; top: number; bottom: number }, + box: { x: number; y: number; width: number; height: number }, + ) => + rect.right <= box.x + tolerance || + rect.left >= box.x + box.width - tolerance || + rect.bottom <= box.y + tolerance || + rect.top >= box.y + box.height - tolerance; for (const rect of titleRects) { - const separated = - rect.right <= stackBox.x + tolerance || - rect.left >= stackBox.x + stackBox.width - tolerance || - rect.bottom <= stackBox.y + tolerance || - rect.top >= stackBox.y + stackBox.height - tolerance; - expect(separated).toBe(true); + expect(separatedFrom(rect, radicalBox)).toBe(true); + expect(separatedFrom(rect, actionsBox)).toBe(true); } expect(itemBox.y).toBeGreaterThanOrEqual( - Math.max(titleBox.y + titleBox.height, stackBox.y + stackBox.height) - 1, + Math.max(titleBox.y + titleBox.height, actionsBox.y + actionsBox.height) - 1, ); await page.goto("/%3A%E5%AD%97"); await expect(page.locator("h1.title").first()).toContainText("字", { timeout: 15_000 }); diff --git a/tests/e2e/visual-invariants.spec.ts b/tests/e2e/visual-invariants.spec.ts index 3e90d7df..4549b0a0 100644 --- a/tests/e2e/visual-invariants.spec.ts +++ b/tests/e2e/visual-invariants.spec.ts @@ -378,9 +378,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 +397,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 +434,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 +454,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/vite.config.ts b/vite.config.ts index f6d5d311..040541b0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { defineConfig, type Plugin, lazyPlugins } from "vite-plus"; +import { defineConfig, type Plugin, type ProxyOptions, lazyPlugins } from "vite-plus"; import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; @@ -87,6 +87,49 @@ async function proxyStrokeJson(cp: string, res: import("http").ServerResponse): } } +/** + * Dev-only proxy for every path the Cloudflare Worker owns in production. + * + * WHY: `vp dev` (without VITE_CLOUDFLARE_REMOTE_DEV=1) runs plain Vite with no + * Worker at all, so `/api/*` and `/assets/*` used to fall through to Vite's SPA + * fallback and return `index.html` — the browser then reported + * `JSON.parse: unexpected character` for /api/config and refused + * `/assets/styles.css` because its MIME type was `text/html`. + * + * Forwarding those prefixes to a live Worker origin (default: production + * www.moedict.tw, override with VITE_DEV_API_ORIGIN) keeps plain `vp dev` + * fully functional without Cloudflare credentials, mirroring the existing + * `/lookup/trs` dev proxy. Files that genuinely live in `public/` + * (e.g. /assets/images/icon.png) are served locally and never proxied. + */ +const WORKER_PROXY_PREFIXES = ["/api", "/assets"] as const; +const publicDir = path.resolve(projectRoot, "public"); + +function servedFromPublicDir(requestUrl: string | undefined): boolean { + if (!requestUrl) return false; + const pathname = new URL(requestUrl, "http://localhost").pathname; + const relative = path.posix.normalize(decodeURIComponent(pathname)).replace(/^\/+/, ""); + if (relative.length === 0 || relative.startsWith("..") || relative.includes("\0")) return false; + const resolved = path.resolve(publicDir, relative); + if (path.relative(publicDir, resolved).startsWith("..")) return false; + return fs.existsSync(resolved) && fs.statSync(resolved).isFile(); +} + +function workerProxyConfig(origin: string): Record { + return Object.fromEntries( + WORKER_PROXY_PREFIXES.map((prefix): [string, ProxyOptions] => [ + prefix, + { + target: origin, + changeOrigin: true, + // `public/` wins over the remote Worker: returning the request URL + // tells Vite's proxy to skip this request and continue the chain. + bypass: (req) => (servedFromPublicDir(req.url) ? req.url : undefined), + }, + ]), + ); +} + function localDataAssetsPlugin(): Plugin { return { name: "moedict-local-data-assets", @@ -171,6 +214,8 @@ function getDictionaryDataVersion(): string { // https://vite.dev/config/ export default defineConfig(({ command }) => { const remoteDev = process.env.VITE_CLOUDFLARE_REMOTE_DEV === "1"; + // Live Worker origin backing plain `vp dev` (no Cloudflare credentials needed). + const devWorkerOrigin = process.env.VITE_DEV_API_ORIGIN ?? "https://www.moedict.tw"; // Emit source maps only when explicitly building for coverage — the // coverage merge script (scripts/merge-coverage.mjs) reads them to map // bundled Chromium V8 coverage back to src/**/*.ts. Regular `npm run @@ -312,9 +357,12 @@ export default defineConfig(({ command }) => { server: { proxy: { "/lookup/trs": { - target: "https://www.moedict.tw", + target: devWorkerOrigin, changeOrigin: true, }, + // Only when the Cloudflare plugin is not mounted (plain `vp dev`); + // with VITE_CLOUDFLARE_REMOTE_DEV=1 the real Worker serves these. + ...(command === "serve" && !remoteDev ? workerProxyConfig(devWorkerOrigin) : {}), }, }, plugins: lazyPlugins(() => [ From fec3ec93630c5052905864fa2492045a02d9a640 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Mon, 17 Aug 2026 11:50:13 +0800 Subject: [PATCH 02/12] chore: ignore root-level font build output, drop dead docs negations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two long-lived sources of `git status` noise in the shared checkout, both now resolved by deleting the artefacts and closing the gap that let them appear: - 76MB of `fonts/TW-Kai-shard-{0..7}.ttf` at the repo root — the output of `scripts/build-tw-kai-shards.mjs --out-dir=fonts`, deterministic and checksum-verified against `data/assets/fonts/tw-kai-shards.manifest.json`, with the served copies already in R2 (`r2-assets.moedict.tw/fonts/...`). The existing rule stated the intent ("Never commit untracked font binaries into git history") but only matched `data/assets/fonts/`, so the build dir was a commit candidate for six days. Now `/fonts/` is ignored too. - `docs/issue-triage/` plus `docs/{materialize,verify}-open-issues-dashboard.mjs` — a 2026-07 triage workbench for the g0v/moedict-webkit backlog. That backlog is down to one open issue (#219), `open-issues-dashboard.data.js` was a timestamped snapshot of the 39-issue state, and the verifier hardcodes `EXPECTED_LIVE_OPEN = 39` in seven assertions, so committing it would have landed a permanently failing check. Deleted, and the `!docs/issue-triage/**` negations added for it in cc0ec62c are removed rather than left pointing at nothing. --- .gitignore | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 906baba9..2fb4cd52 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,13 @@ logs *.log -# Local documentation artifacts (except committed issue-triage outputs and scripts) +# Local documentation artifacts. Only docs/superpowers/ (design + recovery +# runbooks) and hand-written docs/*.mjs tooling are tracked; the 2026-07 +# docs/issue-triage/ workbench was discarded once the g0v/moedict-webkit +# backlog it triaged was closed, so its negations are gone too. docs/* !docs/ !docs/*.mjs -!docs/issue-triage/ -!docs/issue-triage/** !docs/superpowers/ !docs/superpowers/** npm-debug.log* @@ -95,4 +96,8 @@ tests/e2e/**/*-firefox-win32.png # TW-Kai R2-hosted Worker fallback font shards — hosted in R2 moedict-assets bucket # (fonts/TW-Kai-shard-*.ttf). Never commit untracked font binaries into git history. -data/assets/fonts/TW-Kai-shard-*.ttf \ No newline at end of file +# Both landing spots are covered: data/assets/fonts/ (the --overwrite target that +# scripts/build-tw-kai-shards.mjs guards) and a repo-root build dir, which is what +# `--out-dir=fonts` produces (76MB of regenerable, manifest-checksummed TTFs). +data/assets/fonts/TW-Kai-shard-*.ttf +/fonts/ \ No newline at end of file From 3f7a56f103b09a5dec857e1aa6e0bfe5fdff4728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E9=B3=B3?= Date: Mon, 17 Aug 2026 14:27:50 +0800 Subject: [PATCH 03/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- vite.config.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 040541b0..7a9c955e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -108,11 +108,21 @@ const publicDir = path.resolve(projectRoot, "public"); function servedFromPublicDir(requestUrl: string | undefined): boolean { if (!requestUrl) return false; const pathname = new URL(requestUrl, "http://localhost").pathname; - const relative = path.posix.normalize(decodeURIComponent(pathname)).replace(/^\/+/, ""); + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return false; + } + const relative = path.posix.normalize(decoded).replace(/^\/+/, ""); if (relative.length === 0 || relative.startsWith("..") || relative.includes("\0")) return false; const resolved = path.resolve(publicDir, relative); if (path.relative(publicDir, resolved).startsWith("..")) return false; - return fs.existsSync(resolved) && fs.statSync(resolved).isFile(); + try { + return fs.statSync(resolved).isFile(); + } catch { + return false; + } } function workerProxyConfig(origin: string): Record { From f7772abb8cd51081828b40a2d4b3e0126a82d275 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Mon, 17 Aug 2026 14:35:10 +0800 Subject: [PATCH 04/12] fix(dev): decode the public-dir bypass path through tryDecodeURIComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 3f7a56f1 (Copilot Autofix), keeping its `statSync` try/catch and narrowing the remaining gaps in the same helper: - AGENTS.md makes `tryDecodeURIComponent` (src/utils/dictionary-route.ts) the single decode path for request paths — "禁止裸呼 decodeURIComponent" — so the local try/catch is replaced by that call rather than duplicating the rule at a second site. Same behaviour, one definition. - `new URL(req.url, …)` is guarded too: it is the other throw site in a hook where an exception fails the request instead of the lookup. - Exported so the contract is testable. tests/unit/dev-proxy-public-bypass.test.ts pins what the review was actually about: `/assets/%`, `/assets/%zz`, `/api/%E0%A4%A` and `/%C0%80` answer false instead of throwing; a real `public/` file (and a query-stringed one) wins over the proxy; a directory does not; `..`, `%2e%2e` and NUL-byte paths refuse, with a positive control proving the traversal target exists on disk so those assertions fail closed for the right reason. The test runs under `// @vitest-environment node` because vite.config.ts derives `projectRoot` from `import.meta.url`, which is not a file: URL in the project's happy-dom unit environment. Smoke-verified against a live `vp dev`: `/assets/%` and `/assets/%zz` return 400 from the upstream Worker, `/api/%` returns 200 JSON, `/assets/images/icon.png` is still served from public/, and the dev server stays up with no URIError. --- tests/unit/dev-proxy-public-bypass.test.ts | 66 ++++++++++++++++++++++ vite.config.ts | 22 ++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/unit/dev-proxy-public-bypass.test.ts diff --git a/tests/unit/dev-proxy-public-bypass.test.ts b/tests/unit/dev-proxy-public-bypass.test.ts new file mode 100644 index 00000000..887e736a --- /dev/null +++ b/tests/unit/dev-proxy-public-bypass.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +// (vite.config.ts resolves `projectRoot` from `import.meta.url`, which is not +// a file: URL under the project-wide happy-dom environment.) +/** + * `servedFromPublicDir` decides whether plain `vp run dev` serves a request + * locally instead of proxying it to the live Worker (vite.config.ts + * `workerProxyConfig`). It runs inside Vite's proxy `bypass` hook, so a throw + * here fails the request rather than the file lookup — every failure mode must + * answer "not a public file". + * + * Guards the review finding on PR #170: a bare `decodeURIComponent` on a + * request path raises URIError for `/assets/%`, and the repo rule + * (src/utils/dictionary-route.ts) is that request paths decode only through + * `tryDecodeURIComponent`. + */ + +import { describe, expect, it } from "vite-plus/test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { servedFromPublicDir } from "../../vite.config"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +describe("dev proxy public/ bypass", () => { + it("serves a real file under public/ locally", () => { + // Committed fixture: public/assets/images/icon.png is why the /assets + // proxy needs a bypass at all. + expect(servedFromPublicDir("/assets/images/icon.png")).toBe(true); + expect(servedFromPublicDir("/assets/images/icon.png?v=1")).toBe(true); + expect(servedFromPublicDir("/manifest.json")).toBe(true); + }); + + it("proxies anything that is not a file in public/", () => { + expect(servedFromPublicDir("/assets/styles.css")).toBe(false); + expect(servedFromPublicDir("/api/%E8%90%8C.json")).toBe(false); + // A directory is not a file, so it must not shadow the Worker. + expect(servedFromPublicDir("/assets/images")).toBe(false); + expect(servedFromPublicDir("/assets/images/")).toBe(false); + expect(servedFromPublicDir(undefined)).toBe(false); + expect(servedFromPublicDir("")).toBe(false); + }); + + it("answers false instead of throwing on malformed percent-encoding", () => { + // Bare decodeURIComponent raises URIError on each of these. + for (const url of ["/assets/%", "/assets/%zz", "/api/%E0%A4%A", "/%C0%80"]) { + expect(servedFromPublicDir(url)).toBe(false); + } + }); + + it("refuses traversal and NUL bytes rather than resolving outside public/", () => { + // package.json exists at the repo root but is not public/ content. + expect(servedFromPublicDir("/../package.json")).toBe(false); + expect(servedFromPublicDir("/assets/../../package.json")).toBe(false); + expect(servedFromPublicDir("/%2e%2e/package.json")).toBe(false); + expect(servedFromPublicDir("/assets/icon%00.png")).toBe(false); + }); + + it("keeps the repo-root file it must never serve", () => { + // Positive control for the traversal assertions above: the file the + // traversal attempts point at really does exist on disk, so those + // assertions fail closed for the right reason. + expect(path.resolve(REPO_ROOT, "package.json").endsWith("package.json")).toBe(true); + expect(servedFromPublicDir("/package.json")).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 7a9c955e..c059ff7d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import { defineConfig, type Plugin, type ProxyOptions, lazyPlugins } from "vite- 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"; interface LocalStaticMount { @@ -105,15 +106,28 @@ async function proxyStrokeJson(cp: string, res: import("http").ServerResponse): const WORKER_PROXY_PREFIXES = ["/api", "/assets"] as const; const publicDir = path.resolve(projectRoot, "public"); -function servedFromPublicDir(requestUrl: string | undefined): boolean { +/** + * True when the request maps to a real file under `public/`, which must win + * over the remote Worker. + * + * Exported for tests. Every failure mode answers "not a public file" rather + * than throwing: this runs inside Vite's proxy `bypass` hook, so an exception + * fails the request instead of the lookup. Percent-decoding goes through + * `tryDecodeURIComponent` because AGENTS.md makes that the single decode path + * for request paths (a bare `decodeURIComponent` raises URIError on + * `/assets/%`), and one `statSync` in a try/catch replaces existsSync+statSync, + * which had a TOCTOU window and could still throw (EACCES, ELOOP). + */ +export function servedFromPublicDir(requestUrl: string | undefined): boolean { if (!requestUrl) return false; - const pathname = new URL(requestUrl, "http://localhost").pathname; - let decoded: string; + let pathname: string; try { - decoded = decodeURIComponent(pathname); + pathname = new URL(requestUrl, "http://localhost").pathname; } catch { return false; } + const decoded = tryDecodeURIComponent(pathname); + if (decoded === null) return false; const relative = path.posix.normalize(decoded).replace(/^\/+/, ""); if (relative.length === 0 || relative.startsWith("..") || relative.includes("\0")) return false; const resolved = path.resolve(publicDir, relative); From 0c3b18f5a57ac4bd7dcc2735cca245c046680b22 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Tue, 18 Aug 2026 12:25:38 +0800 Subject: [PATCH 05/12] fix(dark): entry-card links follow the themed text colour, not the blue accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a dictionary page the auto-linked headwords, citations and 又音 are most of the visible text — 135 anchors on /萌 alone. Legacy styles.css paints them `#000`, slightly stronger than the `#333` body text, and signals clickability on hover (`background: #ddd`) rather than by colour. Dark mode routed the same anchors through `--moe-link` (#7fd0ff), so every one of them turned blue where light mode has black, and the entry read as a wall of links. They now take `--moe-text`, the white the rest of the card already uses, which reproduces the light-mode relationship: 135/135 anchors go rgb(127, 208, 255) → rgb(230, 227, 223) in dark and stay rgb(0, 0, 0) in light. Contrast improves from 10.4:1 to 12.9:1 on the #1c1c1c card. `--moe-link` is untouched and still paints genuinely external links — the About page and the sidebar search hover row — where blue is the intended affordance and about-contrast.spec.ts / dark-mode.spec.ts already pin it. Guard tests in dark-mode.spec.ts (real legacy cascade routed in) assert the per-theme colours, that no descendant of `.result` computes to the link blue, and that the headword anchor still tracks the title: it inherits via `.result .entry .title .single-char-stroke-trigger a { color: inherit }`, so it reads #333 in light mode rather than the legacy #000 — measured, not assumed. Verified: chromium e2e 260 passed, dark-mode + about-contrast 54 passed, unit 2149, typecheck clean, vp check 0 errors, dark-contrast audit 0 unexpected findings. --- src/index.css | 9 ++++- tests/e2e/dark-mode.spec.ts | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/index.css b/src/index.css index 7dcb21a9..553326bc 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 態文字設為 diff --git a/tests/e2e/dark-mode.spec.ts b/tests/e2e/dark-mode.spec.ts index eed8ee85..a408682e 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" }); From 5481292195b175b1df90f90005b655104433744a Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Sun, 23 Aug 2026 05:01:02 +0800 Subject: [PATCH 06/12] =?UTF-8?q?Fix=20prod-shipment=20regressions:=20hete?= =?UTF-8?q?ronym=20antonyms,=20=E5=90=8C=E7=BE=A9/=E5=8F=8D=E7=BE=A9=20lin?= =?UTF-8?q?ks,=20legacy=20hash=20deep=20links,=20404=20fallback,=20local?= =?UTF-8?q?=20dev=20dictionary=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R1: render t-dict heteronym-level 反義詞 (was declared in type, never rendered) - R2: re-hyperlink a-dict 同義詞/反義詞 via DOMParser anchor whitelist instead of untag() - R3: boot-time applyLegacyHashRewrite() maps legacy #a=萌 / #'月暗暝 / #dict/… hashes to path routes - R4: non-entry unknown paths return HTTP 404 (shell body + no-store kept); tri-state probeDictionaryEntry fails open for entries - L1: vite dev serves /api/.json + index/xref/search-index from local data via filesystem DICTIONARY stand-in reusing prod handlers - toolchain: DOM lib in tsconfig.node.json + CacheStorage.default augmentation replaces ad-hoc globals d.ts (resolves vp check duplicates) Gates: typecheck clean; unit 2166/2166 (+17); integration 145/145 (+4); vp run build clean; vp check 0 errors (cherry picked from commit 2a14051e96adc4be2de3d4535d0fd286e54e3d3e) Conflict resolution: vite.config.ts keeps both dev tiers. serveLocalDictionaryApi (data/dictionary, registered via configureServer) answers first; the existing workerProxyConfig/VITE_DEV_API_ORIGIN proxy remains the fallback tier for routes with no local data (/api/config, /api/cns/*, /assets/*). --- src/api/handleDictionaryAPI.ts | 71 ++++-- src/main.tsx | 18 +- src/pages/DictionaryPage.tsx | 63 +++++- src/utils/dictionary-route.ts | 51 +++++ src/utils/legacy-hash-shim.ts | 23 ++ tests/integration/api-legacy-assets.test.ts | 19 ++ tests/integration/smoke.test.ts | 15 ++ tests/unit/dict-xref-links.test.tsx | 229 ++++++++++++++++++++ tests/unit/dictionary-route.test.ts | 85 +++++++- tests/unit/worker-dispatch-edges.test.ts | 28 ++- tsconfig.node.json | 5 +- vite.config.ts | 132 ++++++++++- worker/index.ts | 48 +++- workers-cache-augment.d.ts | 24 ++ 14 files changed, 754 insertions(+), 57 deletions(-) create mode 100644 src/utils/legacy-hash-shim.ts create mode 100644 tests/unit/dict-xref-links.test.tsx create mode 100644 workers-cache-augment.d.ts 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/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 a1d3bc7f..c57a80f5 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 : []; @@ -1412,17 +1442,25 @@ export function DictionaryPage({ word, lang, idx: targetDefIdx }: DictionaryPage {toStringArray(def.synonyms).length > 0 && (
似 - - {untag(toStringArray(def.synonyms).join("、").replace(/,/g, "、"))} - +
)} {toStringArray(def.antonyms).length > 0 && (
反 - - {untag(toStringArray(def.antonyms).join("、").replace(/,/g, "、"))} - +
)} {afterParallel && ( @@ -1456,6 +1494,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/utils/dictionary-route.ts b/src/utils/dictionary-route.ts index ea53ed13..364a7f8b 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 的別名 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/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..0d3f4dc3 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", () => { @@ -461,4 +462,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/worker-dispatch-edges.test.ts b/tests/unit/worker-dispatch-edges.test.ts index b1bc3ba0..c1aa40b2 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,33 @@ 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(200); + expect(res.status).toBe(404); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain(" { + 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(200); + expect(res.status).toBe(404); + expect(await res.text()).toContain(" { + it("lang=c 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("/~%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 c059ff7d..43ee71c2 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"); @@ -154,12 +157,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(); @@ -177,6 +301,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 {}; From 2c8c4fe96a542759cd6ddaa17085881b74abae2c Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Sun, 23 Aug 2026 05:35:47 +0800 Subject: [PATCH 07/12] docs(agents): dev API now resolves local data first, proxy second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked serveLocalDictionaryApi tier serves entry/index/xref/ search-index JSON from data/dictionary before the VITE_DEV_API_ORIGIN proxy, so the old "字典內容來自正式站,不是本機 data/dictionary" caveat is no longer true. Order verified in the installed Vite bundle: configureServer hooks run (node_modules/vite/dist/vite/node/chunks/node.js) before middlewares.use(proxyMiddleware(...)), so plugin middleware precedes server.proxy. Kept explicit: dev still runs no Worker, so HTML shell, 404 fallback, edge-cache headers and /api/config remain dev:remote / serve:e2e territory. --- AGENTS.md | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58b5fc50..a5918300 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,19 +90,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`),否則以 From 3faf67d731702d85143850704f8404c6181e9460 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Sun, 23 Aug 2026 05:37:26 +0800 Subject: [PATCH 08/12] docs(agents): record the R4 entry-404 contract next to the shell fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker/index.ts now answers 404 for entry-shaped bare word paths whose probeDictionaryEntry() returns a definitive false, so the neighbouring "HTML shell ... 絕不回 404" line needed scoping: asset unavailability still never produces 404; only a proven "no such headword" does. Records the tri-state fail-open rule (true/null keep 200), the bodyless-404 case when the shell itself is unavailable, and that deploy/rollback smoke routes are unaffected. --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a5918300..df481f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,8 +170,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`、`@部首`、`=分類`、`'*清單`、壞編碼)根本不進這條判斷。 + 唯一「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);不存在也不需要任何手動 From f1a90d9fdc266bdb771b05d2d52711e3e2bae5c7 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Sun, 23 Aug 2026 05:53:34 +0800 Subject: [PATCH 09/12] fix(routes): /privacy is a page kind, not a headword; align e2e with R4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked R4 rule 404s any `entry` path whose probeDictionaryEntry() is a definitive miss. classifyRoute only exempted about/about.html, so /privacy — a real App.tsx route rendering — classified as entry("privacy"), probed as a miss, and a live page started answering 404. Fixed in the grammar's single source of truth rather than with a second route list in the Worker: classifyRoute gains a `privacy` kind, and the TS exhaustive switches in resolveHeadByPath, resolveMiddlePointTarget and MiddlePoint are completed (privacy now has its own head instead of a "privacy" headword lookup). Also aligns the e2e suite the cherry-picked commit never ran. A full `vp run test:e2e` at the cherry-pick was 11 failed / 249 passed; every failure was caused by the commit (verified: all 11 pass at 0c3b18f5). Ten were the intended R4 contract change reaching specs that still asserted the old status — paths with no combined entry (/萌黃, /不存在的詞, /totallymade-up-path-1234, /foo'bar) now answer 404 with the SPA shell body intact, which is exactly the 字圖 CharacterImageView surface those specs measure. Status expectations updated; every other assertion left alone. The two candidate sweeps accept 200 or 404 because their samples mix real entries with 字圖 fallbacks and they measure ruby geometry, not status. Gates: typecheck clean; unit 2177/2177; integration 145/145; e2e 262 passed 0 failed (was 249/11); vp check 0 errors; vp run build clean. --- AGENTS.md | 9 +++++- src/MiddlePoint.tsx | 2 ++ src/ssr/head.ts | 4 +++ src/utils/dictionary-route.ts | 6 ++++ src/utils/middle-point-target.ts | 3 ++ .../e2e/charimg-caption-dark-contrast.spec.ts | 4 ++- tests/e2e/dictionary.spec.ts | 29 ++++++++++++++----- tests/e2e/navigation.spec.ts | 5 ++-- tests/e2e/visual-invariants.spec.ts | 4 ++- tests/unit/dictionary-route.test.ts | 16 ++++++++++ tests/unit/middle-point-resolver.test.ts | 4 +++ tests/unit/ssr-head.test.ts | 7 +++++ tests/unit/worker-dispatch-edges.test.ts | 19 ++++++++++++ 13 files changed, 100 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df481f2f..46249a9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ release manifest/digest 與剛剛實際發布到 R2 的那份不一致。完 會讓爬蟲/CDN 把捏造的 shell 當內容)。只有 `false` 會改狀態:`true` (命中,含 NFKC 相容 fallback)維持 200,`null`(shard 讀取/解析失敗, 資料層答不出來)刻意 fail open 也維持 200;非 entry 路徑 - (`/`、`/about`、`@部首`、`=分類`、`'*清單`、壞編碼)根本不進這條判斷。 + (`/`、`/about`、`/privacy`、`@部首`、`=分類`、`'*清單`、壞編碼)根本不進這條判斷。 唯一「shell 取不到又確定查無此詞」的組合會回**無 body 的 404** (`Cache-Control: no-store`),而不是 recovery 503。deploy/rollback smoke 打的 `/`、`/api/config`、`/api/<詞>.json` 都不是 entry shell 路徑,不受影響。 @@ -674,6 +674,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/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/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 364a7f8b..83a15d75 100644 --- a/src/utils/dictionary-route.ts +++ b/src/utils/dictionary-route.ts @@ -166,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 } @@ -189,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/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/dictionary.spec.ts b/tests/e2e/dictionary.spec.ts index b8cd9a2d..a9da3cba 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(); }); }); @@ -2492,7 +2505,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 @@ -2554,7 +2569,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 4549b0a0..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"); diff --git a/tests/unit/dictionary-route.test.ts b/tests/unit/dictionary-route.test.ts index 0d3f4dc3..c88c87cd 100644 --- a/tests/unit/dictionary-route.test.ts +++ b/tests/unit/dictionary-route.test.ts @@ -214,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: "" }); 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 c1aa40b2..9176bb87 100644 --- a/tests/unit/worker-dispatch-edges.test.ts +++ b/tests/unit/worker-dispatch-edges.test.ts @@ -510,6 +510,25 @@ describe("dispatch — HTML shell metadata injection with dictionary lookup", () 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(" { + const fetcher = shellFetcher(); + const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] }); + const res = await dispatch(req("/about"), env); + expect(res.status).toBe(200); + }); + 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"] }); From 30b0ff0075ef6fdec2a5798eb35055b42fa4a366 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Fri, 16 Oct 2026 20:49:32 +0800 Subject: [PATCH 10/12] chore: sync bun.lock with vitest 4.1.11 from #171 --- bun.lock | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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=="], From 8e2de72ee1e0d06f254aa3196c1a4b3556cf5b9b Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Fri, 16 Oct 2026 20:54:31 +0800 Subject: [PATCH 11/12] fix(layout): keep the stroke pencil at legacy size in the narrow rail The 44px pencil target came from the wrapping-flex heading (f63846bd), which #173's narrow rail superseded; in the rail it grew the radical line to 63px and rendered the pencil as an oversized block at 333px. --- src/index.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/index.css b/src/index.css index a7c23000..a7041662 100644 --- a/src/index.css +++ b/src/index.css @@ -937,13 +937,9 @@ html[data-theme="dark"] .result .entry-actions .iconic-color.star:hover { background: transparent; } @media (max-width: 767px) { - /* 44px touch targets for every control in the heading toolbar, including - the stroke-animation pencil (legacy sizes it 32x30 via - `.iconic-circle.stroke`) now that it reads as part of the same row. */ .entry-actions .entry-copy-button, .entry-actions .variants-link, - .entry-actions .star, - .result .entry-control-stack .radical a.iconic-circle.stroke { + .entry-actions .star { min-width: 44px; min-height: 44px; display: inline-flex; From 3ca43556e756ab276f6e614858961984435f9203 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Fri, 16 Oct 2026 20:54:31 +0800 Subject: [PATCH 12/12] test(e2e): wait for the entry and legacy stylesheet in console-load-errors Shell readiness returned before the entry rendered and before AssetLoader injected styles.css, so the title and stylesheet assertions raced the page. --- tests/e2e/console-load-errors.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 —