Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down Expand Up @@ -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
# 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/
42 changes: 41 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,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
Expand All @@ -87,6 +88,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:
Expand Down Expand Up @@ -598,6 +625,19 @@ 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)`)是 **grid 窄欄**
(`minmax(0, 1fr) max-content`):部首列維持一行不換行,星號/異體字/
複製由上而下疊成 44px 單欄靠右;≤3 字短標題(`.entry-compact-title`,
見 `DictionaryPage.tsx` 可見字數判斷)改為橫向一簇,避免短標題旁留空。
`.entry-copy-status` 保留 8rem 預留(行動版 out-of-flow 向標題區左延,
不撐寬窄欄)——R8 要求量到它,**不可**拿掉。守門測試在
`tests/e2e/dictionary.spec.ts`(`mobile: action row …`、
`mobile: extra action buttons …`、`mobile: enlarged long title …`,以及
`copy button activates with Space …` 的零位移契約)與
`tests/e2e/visual-invariants.spec.ts` 的 R8(320/390px:預留盒幾何+無水平溢出)。
- **URL 前綴文法**(`'`=t、`:`=h、`~`=c、`@`/`=`/`=*` 家族、`/<數字>` idx):
唯一定義在 `src/utils/dictionary-route.ts` 的 `classifyRoute`(頁面/head 分類)
與 `stripLangPrefix`(語言前綴,API 端加 `{'!': 't'}` legacy 別名)。
Expand Down
6 changes: 5 additions & 1 deletion tests/e2e/dark-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,11 @@ test.describe("mobile dark-mode search results & controls (490×1376, real mobil

const HAKKA_PATH = "/%3A%E5%AD%97"; // :字
const CN_XREF_PATH = "/~%E4%B8%8A%E8%A8%B4"; // ~上訴 (seeded lang-c fixture)
const CN_API_PATTERN = /\/api\/~[^?]*\.json/;
// Match only the primary fixture request. The rendered cross-reference labels
// trigger follow-up `/api/~*.json` fetches; intercepting those too leaves
// route.fetch() callbacks in flight when the test page closes and turns normal
// teardown into "Target page, context or browser has been closed" failures.
const CN_API_PATTERN = /\/api\/~%E4%B8%8A%E8%A8%B4\.json(?:\?.*)?$/i;

/** Intercept the ~上訴 JSON API to inject a synthetic `alt` field — no seeded
* lang-c fixture carries `A`/`alt` (簡體字), the source field
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/dev-proxy-public-bypass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @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 fs from "node:fs";
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);

// Encoded separators are decoded only after new URL(), so these cases
// exercise the explicit pre-normalization traversal guard. Without it,
// normalize() erases `..` and the encoded-slash requests map to the real
// public/manifest.json. Encoded backslash covers Windows path semantics.
expect(servedFromPublicDir("/api/%2e%2e%2fmanifest.json")).toBe(false);
expect(servedFromPublicDir("/assets/%2e%2e%2fmanifest.json")).toBe(false);
expect(servedFromPublicDir("/assets/%2e%2e%5cmanifest.json")).toBe(false);
});

it("keeps real traversal targets as positive controls", () => {
// Prove both targets used above exist. package.json checks attempted
// escape from public/; manifest.json catches traversal that normalizes
// back onto a real public file and could otherwise trigger the bypass.
expect(fs.statSync(path.resolve(REPO_ROOT, "package.json")).isFile()).toBe(true);
expect(fs.statSync(path.resolve(REPO_ROOT, "public", "manifest.json")).isFile()).toBe(true);
expect(servedFromPublicDir("/package.json")).toBe(false);
});
});
84 changes: 82 additions & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ 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";
import { tryDecodeURIComponent } from "./src/utils/dictionary-route";
import { STROKE_JSON_BASE_URL } from "./src/utils/media-cdn";

interface LocalStaticMount {
Expand Down Expand Up @@ -87,6 +88,80 @@ 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");

/**
* 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;
let pathname: string;
try {
pathname = new URL(requestUrl, "http://localhost").pathname;
} catch {
return false;
}
const decoded = tryDecodeURIComponent(pathname);
if (decoded === null) return false;

// Reject traversal before normalization. Encoded separators survive the
// WHATWG URL parser (for example `%2f`), then decoding can turn
// `/api/%2e%2e%2fmanifest.json` into `/api/../manifest.json`. Normalizing
// first would erase the `..` evidence and could incorrectly classify the
// request as public/manifest.json.
if (decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..")) return false;

const relative = path.posix.normalize(decoded).replace(/^\/+/, "");
if (relative.length === 0 || relative.startsWith("..")) return false;
const resolved = path.resolve(publicDir, relative);
if (path.relative(publicDir, resolved).startsWith("..")) return false;
try {
return fs.statSync(resolved).isFile();
} catch {
return false;
}
}

function workerProxyConfig(origin: string): Record<string, ProxyOptions> {
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",
Expand Down Expand Up @@ -171,6 +246,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
Expand Down Expand Up @@ -312,9 +389,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(() => [
Expand Down