fix(web): keep admin POSTs across the host move and truthful locale coverage

Audit 0.10.1 js-ops, web (W01), Refs #6561:

- W01-07: the www -> apex canonical-host redirect is 308, not 301, so a
  client never replays an admin login/post form as a body-less GET. 308
  is still a permanent move for crawlers.
- W01-02: FAQ English answers render for every non-Chinese route locale;
  their five internal links now follow that locale instead of /en.
- W01-03: /computer-use coverage in the content-locale registry is
  derived from the COMPUTER_USE dictionary (hasComputerUseTranslation),
  so canonical, hreflang and sitemap list its 18 translated locales
  instead of canonicalizing them to English.
- W01-04: /docs/guide leaves the registry override. Its fr/de/ca/hi/tr/
  it/pl/ar dictionaries translate only the overview and headings; the
  getting-started steps that are the body ship in en/zh, so those
  variants canonicalize to English like other partial pages.
- W01-m1: Tailwind scans lib/i18n/dictionaries, where the Arabic and
  Hindi guide's leading-loose lives.
- W01-m2: the default caption track follows the page locale when it has
  captions, else English.

Validation (web, npm ci, vitest 4.1.11):
- vitest run middleware, faq-schema, media-manifest, docs-ia, page-meta,
  lib/i18n, public-surface-contract, public-copy: Test Files 14 passed,
  Tests 142 passed.
- tsc --noEmit -p web: exit 0. eslint on touched files: exit 0.
- Negative controls: middleware.ts at HEAD -> "keeps an admin form POST"
  fails (expected 301 to be 308). tailwind.config.ts at HEAD -> built
  utilities contain leading-loose 0 times (1 with the fix).
- Sitemap count 99 -> 107 (+16 computer-use, -8 docs/guide) asserted in
  docs-ia.test.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 00:36:40 -07:00
1 parent d9015df5b8
commit 332f60beef
9 files changed
+70 -29

No files matched your search

+12 -7
View File
@@ -27,7 +27,12 @@ interface FaqItem {
sources?: string[];
}
const faqEn: FaqItem[] = [
/**
* English answers render for every non-Chinese locale (the page body has no
* other translation yet), so their internal links follow the reader's route
* locale instead of silently moving them to `/en`.
*/
const faqEn = (locale: string): FaqItem[] => [
{
q: "What is Codewhale?",
a: (
@@ -62,7 +67,7 @@ brew install Hmbown/deepseek-tui/codewhale
Run <code className="inline">codewhale</code> to start. First run creates <code className="inline">~/.codewhale/</code> automatically. Legacy <code className="inline">~/.deepseek/</code> is still read as a compatibility fallback.
Android arm64 / Termux is preview support: npm works only when the
selected package version has matching Android assets in its GitHub Release.
See the <Link href="/en/install" className="body-link">full install guide</Link> for China mirrors, Docker, and troubleshooting.
See the <Link href={`/${locale}/install`} className="body-link">full install guide</Link> for China mirrors, Docker, and troubleshooting.
</p>
</>
),
@@ -123,7 +128,7 @@ codewhale doctor # full connectivity check`}
<ul className="list-disc pl-5 space-y-1 text-sm text-ink-soft mb-3">
<li><strong>DeepSeek</strong> — bundled default with a native API route, reasoning streaming, cache metrics, and thinking effort control.</li>
<li><strong>OpenRouter</strong> — unified API for DeepSeek models and other open-model routes.</li>
<li><strong>{FACTS.providers.length - 2} more routes</strong> — including OpenAI-compatible, Anthropic, Mistral AI, OpenAI Codex, xAI, Moonshot/Kimi, Z.ai, MiniMax, StepFun, Volcengine Ark, Baidu Qianfan, Model Studio, NVIDIA NIM, Fireworks AI, Together AI, DeepInfra, SiliconFlow, Novita AI, Hugging Face, Arcee AI, AtlasCloud, and the keyless local endpoints SGLang, vLLM, and Ollama. <Link href="/en/models" className="body-link">The full list is generated from the provider registry</Link>.</li>
<li><strong>{FACTS.providers.length - 2} more routes</strong> — including OpenAI-compatible, Anthropic, Mistral AI, OpenAI Codex, xAI, Moonshot/Kimi, Z.ai, MiniMax, StepFun, Volcengine Ark, Baidu Qianfan, Model Studio, NVIDIA NIM, Fireworks AI, Together AI, DeepInfra, SiliconFlow, Novita AI, Hugging Face, Arcee AI, AtlasCloud, and the keyless local endpoints SGLang, vLLM, and Ollama. <Link href={`/${locale}/models`} className="body-link">The full list is generated from the provider registry</Link>.</li>
</ul>
<p>
Set the corresponding env var (e.g. <code className="inline">OPENROUTER_API_KEY</code>) and your provider in <code className="inline">~/.codewhale/config.toml</code>.
@@ -258,7 +263,7 @@ codewhale --provider openrouter --model deepseek/deepseek-v4-pro
<>
Codewhale is a bidirectional MCP client and server. Define servers in <code className="inline">~/.codewhale/mcp.json</code>.
Tools appear as <code className="inline">mcp_&lt;server&gt;_&lt;tool&gt;</code>. You can also expose Codewhale as an MCP server with <code className="inline">codewhale mcp</code>.
See the <Link href="/en/docs/mcp" className="body-link">docs page</Link> for configuration examples.
See the <Link href={`/${locale}/docs/mcp`} className="body-link">docs page</Link> for configuration examples.
</>
),
sources: ["docs/MCP.md"],
@@ -269,7 +274,7 @@ codewhale --provider openrouter --model deepseek/deepseek-v4-pro
<>
No CLA required. Fork, branch with conventional commits (<code className="inline">feat:</code>, <code className="inline">fix:</code>, etc.), run the local checks, open a PR.
The maintainer reads everything personally. Start with issues labeled <code className="inline">good first issue</code>.
See the <Link href="/en/contribute" className="body-link">contribute page</Link> and <a href="https://github.com/Hmbown/CodeWhale/blob/main/CONTRIBUTING.md" className="body-link">CONTRIBUTING.md</a>.
See the <Link href={`/${locale}/contribute`} className="body-link">contribute page</Link> and <a href="https://github.com/Hmbown/CodeWhale/blob/main/CONTRIBUTING.md" className="body-link">CONTRIBUTING.md</a>.
</>
),
sources: ["CONTRIBUTING.md"],
@@ -317,7 +322,7 @@ registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"`}
</p>
<p className="mb-2">
A CNB mirror is maintained for users who cannot reliably reach GitHub
(<Link href="/en/install" className="body-link">docs/CNB_MIRROR.md</Link>).
(<Link href={`/${locale}/install`} className="body-link">docs/CNB_MIRROR.md</Link>).
Cargo users can use the TUNA mirror for faster downloads in China.
</p>
<p>
@@ -748,7 +753,7 @@ brew update && brew upgrade codewhale`}
export default async function FaqPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = getFaq(locale);
const items = { en: faqEn, zh: faqZh }[pickTextLocale(locale)];
const items = pickTextLocale(locale) === "zh" ? faqZh : faqEn(locale);
const canonicalLocale = canonicalLocaleForPath("/faq", locale);
const jsonLd = buildFaqPageJsonLd({
items,
+4 -1
View File
@@ -60,6 +60,9 @@ export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; loca
const poster = asset.poster!;
const video = asset.video!;
const captions = asset.captions ?? [];
// The track matching the page's language is the default; English only
// when the page's language has no captions.
const defaultCaption = captions.some((track) => track.srclang === locale) ? locale : "en";
return (
<figure
@@ -84,7 +87,7 @@ export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; loca
src={`/${track.src}`}
srcLang={track.srclang}
label={track.label}
default={track.srclang === "en"}
default={track.srclang === defaultCaption}
/>
))}
</video>
+6 -5
View File
@@ -13,7 +13,6 @@ import buildSitemap from "../app/sitemap";
import { DOC_TOPICS, docTopicHref, getTopic } from "./docs-map";
import { docsTopicIsCurrent } from "./docs-navigation";
import { locales } from "./i18n/config";
import { contentLocalesForPath } from "./i18n/content-locales";
import { getChrome, getHome } from "./i18n/dictionaries";
import {
currentNavHref,
@@ -91,9 +90,10 @@ describe("sitemap and hreflang preservation", () => {
});
it("keeps sitemap and hreflang output aligned with real translation coverage", () => {
// 18 home locales + 10 guide locales + English-only install + (en, zh) for every other route
// (including /product, /plugins, and /changelog, whose bodies ship en/zh only).
expect(sitemapEntries).toHaveLength(99);
// 18 home locales + 18 /computer-use locales + English-only install + (en, zh) for every
// other route (including /docs/guide, /product, /plugins, and /changelog, whose bodies
// ship en/zh only).
expect(sitemapEntries).toHaveLength(107);
expect(sitemapEntries.filter(entry => entry.url.endsWith("/install")).map(entry => entry.url))
.toEqual([`${SITE_URL}/en/install`]);
expect(sitemapEntries.some(entry => entry.url.endsWith("/pricing"))).toBe(false);
@@ -105,7 +105,8 @@ describe("sitemap and hreflang preservation", () => {
}
for (const [path, expectedLocales] of [
["/", locales],
["/docs/guide", contentLocalesForPath("/docs/guide")],
["/docs/guide", ["en", "zh"]],
["/computer-use", locales],
["/install", ["en"]],
["/docs", ["en", "zh"]],
] as const) {
+11 -2
View File
@@ -1,10 +1,19 @@
import { defaultLocale, locales } from "./config";
import { hasComputerUseTranslation } from "./dictionaries";
/** Routes whose page-body translation coverage differs from English/Chinese. */
/**
* Routes whose page-body translation coverage differs from English/Chinese.
*
* `/docs/guide` is not listed: its French/German/Catalan/Hindi/Turkish/
* Italian/Polish/Arabic dictionaries translate only the overview and headings,
* while the getting-started steps that make up the body ship in English and
* Chinese, so those variants canonicalize to English like any other partial
* page.
*/
const ROUTE_CONTENT_LOCALES: Readonly<Record<string, readonly string[]>> = {
"/": locales,
"/install": ["en"],
"/docs/guide": ["en", "zh", "fr", "de", "ca", "hi", "tr", "it", "pl", "ar"],
"/computer-use": locales.filter(hasComputerUseTranslation),
};
/** Most first-party page bodies currently ship in English and Chinese. */
+9
View File
@@ -445,6 +445,15 @@ export function getComputerUse(locale: string): ComputerUseDict {
return COMPUTER_USE[locale] ?? enComputerUse;
}
/**
* Locales whose /computer-use page body is translated (the page renders only
* from this dictionary). The content-locale registry derives canonical,
* hreflang and sitemap coverage from it, so they cannot drift apart.
*/
export function hasComputerUseTranslation(locale: string): boolean {
return locale === "en" || Object.hasOwn(COMPUTER_USE, locale);
}
export function getStates(locale: string): StatesDict {
return STATES[locale] ?? enStates;
}
+16 -6
View File
@@ -7,38 +7,48 @@ import { middleware } from "../middleware";
* custom domains, so without a canonical-host redirect the whole site is
* reachable — and indexable — twice.
*/
function request(url: string, host: string, headers: Record<string, string> = {}) {
function request(url: string, host: string, headers: Record<string, string> = {}, method = "GET") {
return new NextRequest(new URL(url), {
method,
headers: new Headers({ host, ...headers }),
});
}
describe("canonical host", () => {
it("301s www to the apex, preserving path and query", () => {
it("permanently redirects www to the apex, preserving path and query", () => {
const res = middleware(
request("https://www.codewhale.net/en/docs/hooks?x=1", "www.codewhale.net"),
);
expect(res.status).toBe(301);
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("https://codewhale.net/en/docs/hooks?x=1");
});
it("redirects assets and API routes too, so a moved document stops pulling from www", () => {
for (const path of ["/_next/static/chunk.js", "/api/curated", "/opengraph-image"]) {
const res = middleware(request(`https://www.codewhale.net${path}`, "www.codewhale.net"));
expect(res.status, path).toBe(301);
expect(res.status, path).toBe(308);
expect(res.headers.get("location"), path).toBe(`https://codewhale.net${path}`);
}
});
it("keeps an admin form POST a POST across the host move", () => {
// A 301 lets clients replay a POST as a body-less GET (#6561 W01-07).
for (const path of ["/api/admin/login", "/api/admin/post"]) {
const res = middleware(request(`https://www.codewhale.net${path}`, "www.codewhale.net", {}, "POST"));
expect(res.status, path).toBe(308);
expect(res.headers.get("location"), path).toBe(`https://codewhale.net${path}`);
}
});
it("leaves the apex host alone", () => {
const res = middleware(request("https://codewhale.net/en", "codewhale.net"));
expect(res.status).not.toBe(301);
expect(res.status).not.toBe(308);
});
it("leaves localhost and preview hosts alone", () => {
for (const host of ["localhost:3000", "codewhale-web.pages.dev"]) {
const res = middleware(request("https://example.test/en", host));
expect(res.status, host).not.toBe(301);
expect(res.status, host).not.toBe(308);
}
});
+5 -6
View File
@@ -71,12 +71,11 @@ describe("page metadata", () => {
it("advertises only genuine page-body translations", () => {
for (const [path, locale, expectedLocales, expectedCanonical] of [
["/", "ja", locales, `${SITE_URL}/ja`],
[
"/docs/guide",
"fr",
["en", "zh", "fr", "de", "ca", "hi", "tr", "it", "pl", "ar"],
`${SITE_URL}/fr/docs/guide`,
],
// The guide's steps (its body) ship en/zh only; a French overview
// dictionary does not make the page a French translation.
["/docs/guide", "fr", ["en", "zh"], `${SITE_URL}/en/docs/guide`],
// /computer-use renders only from its per-locale dictionary.
["/computer-use", "ja", locales, `${SITE_URL}/ja/computer-use`],
[
"/docs/guide",
"ja",
+4 -1
View File
@@ -41,7 +41,10 @@ function canonicalHostRedirect(req: NextRequest): NextResponse | null {
const url = req.nextUrl.clone();
url.host = CANONICAL_HOST;
url.port = "";
return NextResponse.redirect(url, 301);
// 308, not 301: clients may turn a 301'd POST (the admin login and post
// forms) into a GET and drop its body. 308 keeps the method and body and is
// still a permanent move for crawlers.
return NextResponse.redirect(url, 308);
}
/**
+3 -1
View File
@@ -1,7 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
// Dictionaries carry per-locale class names (e.g. the Arabic and Hindi
// docs guide's `leading-loose`); unscanned, those utilities never exist.
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/i18n/dictionaries/**/*.ts"],
theme: {
extend: {
colors: {