mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
d9015df5b8
commit
332f60beef
9 files changed
+70
-29
No files matched your search
@@ -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_<server>_<tool></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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user