Skip to content
Merged
16 changes: 13 additions & 3 deletions web/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { notFound } from "next/navigation";
import { Nav } from "@/components/nav";
import { Footer } from "@/components/footer";
import { UsageCounting } from "@/components/usage-counting";
import { BUILD_FACTS } from "@/lib/facts";
import { localeDirection, locales, type Locale } from "@/lib/i18n/config";
import { isValidLocale, localeDirection, locales } from "@/lib/i18n/config";
import { getChrome, getHome } from "@/lib/i18n/dictionaries";
import { serializeJsonLd } from "@/lib/json-ld";
import { buildPageMetadata } from "@/lib/page-meta";
import { buildSiteJsonLd } from "@/lib/site-schema";
import { metadata as notFoundMetadata } from "./not-found";
import "../globals.css";

// Shannon Sans is the one face, as in the GPUI app (`set_theme`). The pinned
Expand Down Expand Up @@ -57,6 +59,10 @@ export function generateStaticParams() {

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
// The layout answers not-found for this; without the guard the home page's
// title, canonical, and hreflang stream into that 404 (`/wp-login.php`).
// Throwing here instead leaves the page with no title at all.
if (!isValidLocale(locale)) return notFoundMetadata;
const home = getHome(locale);
return buildPageMetadata({
path: "/",
Expand All @@ -74,6 +80,10 @@ export default async function LocaleLayout({
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Middleware leaves dotted paths alone, so `/wp-login.php` reaches this
// segment with that "locale". Without this it rendered the home page with
// HTTP 200 and `<html lang="wp-login.php">`.
if (!isValidLocale(locale)) notFound();
const chrome = getChrome(locale);
// RTL locales (e.g. ar) set the document direction from the canonical
// registry so the browser handles bidirectional layout from the root.
Expand Down Expand Up @@ -106,9 +116,9 @@ export default async function LocaleLayout({
<a href="#main-content" className="skip-link">
{chrome.skipToContent}
</a>
<Nav locale={locale as Locale} />
<Nav locale={locale} />
<main id="main-content">{children}</main>
<Footer locale={locale as Locale} />
<Footer locale={locale} />
{/* Aggregate usage counting, on by default — see lib/telemetry. The
choice lives on the privacy page; every opt-out stays off. */}
<UsageCounting appVersion={BUILD_FACTS.version ?? "0.0.0"} />
Expand Down
6 changes: 5 additions & 1 deletion web/app/api/product-telemetry/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import { MAX_ENVELOPE_BYTES, validateEnvelope } from "@/lib/telemetry/product-us
* holds it.
*/

export const runtime = "edge";
// No `runtime = "edge"`: @opennextjs/cloudflare does not support the edge
// runtime. Its `migrate` command says to remove the declaration, and its
// server bundle replaces Next's edge runtime with an empty shim. How the
// deployed route behaved while it was declared has not been checked. The
// default runtime already has fetch, Request, and TextDecoder.

export const CANONICAL_INGEST_URL = "https://telemetry.codewhale.net/v1/telemetry";
const FORWARD_TIMEOUT_MS = 1500;
Expand Down
23 changes: 23 additions & 0 deletions web/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defaultLocale } from "@/lib/i18n/config";
import { NotFoundRoute } from "@/components/route-state";
import LocaleLayout from "./[locale]/layout";

export { metadata } from "./[locale]/not-found";

/**
* Root not-found boundary. `[locale]/layout.tsx` calls `notFound()` for a first
* segment that is not a locale (`/wp-login.php`); a layout's own not-found
* boundary cannot catch what the layout throws, so the error lands here. The
* root layout renders no document, so this wraps the not-found state in the
* default locale's shell — nav, footer, and dictionary copy, like
* `/en/nonexistent` — instead of the framework's bare page.
*/
export default function RootNotFound() {
return (
<LocaleLayout params={Promise.resolve({ locale: defaultLocale })}>
<div className="route-state">
<NotFoundRoute />
</div>
</LocaleLayout>
);
}
4 changes: 2 additions & 2 deletions web/lib/content/usage-counting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const USAGE_COUNTING_COPY = {
footerLink: { en: "Usage data", zh: "使用数据" },
heading: { en: "Usage counting on this site", zh: "本站的使用统计" },
summary: {
en: "This site counts page views, documentation views, install-command copies, and downloads as plain totals. Counting is on by default. Only those totals leave your browser — no page addresses, referrers, account, or content — sent to Codewhale's own endpoint, which may pass them to PostHog. A random install id, unrelated to you, rotates every 90 days.",
zh: "本站会把页面浏览、文档浏览、安装命令复制和下载次数作为纯总数统计,默认开启。离开浏览器的只有这些总数——没有页面地址、来源、账户或内容——发送到 Codewhale 自己的端点,该端点可能把总数交给 PostHog 处理。与你无关的随机安装 id 每 90 天轮换一次。",
en: "This site counts page views, documentation views, install-command copies, sign-in and sign-up link clicks, and error pages shown as plain totals. Counting is on by default. Only those totals leave your browser — no page addresses, referrers, account, or content — sent to Codewhale's own endpoint, which may pass them to PostHog. A random install id, unrelated to you, rotates every 90 days.",
zh: "本站会把页面浏览、文档浏览、安装命令复制、登录和注册链接点击以及错误页面显示次数作为纯总数统计,默认开启。离开浏览器的只有这些总数——没有页面地址、来源、账户或内容——发送到 Codewhale 自己的端点,该端点可能把总数交给 PostHog 处理。与你无关的随机安装 id 每 90 天轮换一次。",
},
choice: {
en: "Turning it off is kept in this browser and clears any queued counts and the install id. Nothing here records that you accepted anything.",
Expand Down
84 changes: 81 additions & 3 deletions web/lib/facts-drift.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deriveFactsFromRemote } from "./facts-drift";
import { PROVIDER_LABEL_MAP } from "../scripts/facts-lib.mjs";
import { isRepoFacts } from "./facts";
import { deriveFactsFromRemote, PROVIDER_LABELS, runFactsDrift } from "./facts-drift";

const REVISION = "b".repeat(40);

function response(body: string, status = 200): Response {
return new Response(body, { status });
}

function installGitHubFixture(toolCountSource: string | null): void {
const VALID_GENERATED_FACTS =
'export const FACTS: RepoFacts = {"toolCount":73,"models":[]};';

function installGitHubFixture(
toolCountSource: string | null,
releaseHtmlUrl = "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.0",
sourceOverrides: Record<string, string> = {},
): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
Expand All @@ -25,7 +34,7 @@ function installGitHubFixture(toolCountSource: string | null): void {
JSON.stringify({
tag_name: "v0.9.0",
published_at: "2026-07-16T20:05:39Z",
html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.0",
html_url: releaseHtmlUrl,
}),
);
}
Expand All @@ -43,6 +52,7 @@ function installGitHubFixture(toolCountSource: string | null): void {
`,
"npm/codewhale/package.json": JSON.stringify({ engines: { node: ">=18" } }),
LICENSE: "MIT License\n",
...sourceOverrides,
};
if (rawPath === "web/lib/facts.generated.ts") {
return toolCountSource === null ? response("not found", 404) : response(toolCountSource);
Expand Down Expand Up @@ -105,4 +115,72 @@ describe("deriveFactsFromRemote", () => {

await expect(deriveFactsFromRemote()).resolves.toBeNull();
});

it("stores a canonical release URL when GitHub answers with the repo's other casing", async () => {
installGitHubFixture(
VALID_GENERATED_FACTS,
"https://github.com/Hmbown/Codewhale/releases/tag/v0.9.0",
);

const facts = await deriveFactsFromRemote();

expect(facts?.latestPublishedRelease?.url).toBe(
"https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.0",
);
expect(isRepoFacts(facts)).toBe(true);
});
});

describe("runFactsDrift", () => {
it("writes a KV snapshot that getFacts() accepts", async () => {
installGitHubFixture(
VALID_GENERATED_FACTS,
"https://github.com/Hmbown/Codewhale/releases/tag/v0.9.0",
);
const store = new Map<string, string>();
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => {
store.set(key, value);
},
};

const result = await runFactsDrift({ CURATED_KV: kv });

expect(result.ok).toBe(true);
expect(isRepoFacts(JSON.parse(store.get("facts:current") ?? "null"))).toBe(true);
});

// The scheduled handler discards the result, so the log line is the only
// signal that the cron stopped refreshing KV.
it("warns and writes nothing when the derived facts fail validation", async () => {
installGitHubFixture(VALID_GENERATED_FACTS, undefined, {
// A non-string engines.node survives derivation but fails isRepoFacts.
"npm/codewhale/package.json": JSON.stringify({ engines: { node: 18 } }),
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const store = new Map<string, string>();
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => {
store.set(key, value);
},
};

const result = await runFactsDrift({ CURATED_KV: kv });

expect(result).toEqual({ ok: false, reason: "remote facts failed validation" });
expect(store.size).toBe(0);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("[facts-drift] remote facts failed validation"),
);
warn.mockRestore();
});
});

describe("PROVIDER_LABELS", () => {
it("matches the build-time PROVIDER_LABEL_MAP, so cron snapshots keep every provider", () => {
expect(Object.keys(PROVIDER_LABEL_MAP).length).toBeGreaterThan(0);
expect(PROVIDER_LABELS).toEqual(PROVIDER_LABEL_MAP);
});
});
Loading
Loading