feat(web): surface Computer Use and fresh published releases [skip ci]

Give Computer Use and release history primary navigation, and show terminal and
Computer Use releases on the homepage and changelog. Read bounded, validated
published metadata for the two fixed repositories in parallel; exclude drafts,
prereleases and noncanonical release URLs. Keep download qualification on the
Computer Use setup surface and retain known releases when updates fail.

Refresh visible clients every five minutes and on return, using the original
check time so prefetched pages can refresh promptly. Provide independent RSS
and release-note links without requiring a website rebuild. Preserve newer
known terminal release metadata in the existing facts cron. Trim the changelog
introduction and export the two local translation catalogs.

Verified: scoped ESLint; TypeScript --noEmit --incremental false; 12 existing
Vitest suites, 132 tests passed; locale schema and local GT parity passed;
git diff --check; local releases API HTTP 200, both current releases, 36ms;
both canonical RSS feeds HTTP 200; responsive desktop/mobile, Chinese dark
mode, direct Computer Use navigation, actual download setup and release links.

Full protected web workflow and production publication follow the shared
source/docs metadata landing. This is not Engine release qualification.
This commit is contained in:
CodeWhale Bot committed 2026-11-06 21:34:10 -08:00
1 parent 088d8ca18d
commit 543b5b76db
18 files changed
+325 -96

No files matched your search

+8 -26
View File
@@ -3,6 +3,8 @@ import { Icon } from "@/components/icon";
import { PageHeader } from "@/components/page-header";
import { Status } from "@/components/status-badge";
import { EmptyState } from "@/components/surface-state";
import { ReleaseUpdates } from "@/components/release-updates";
import { getReleaseUpdates } from "@/lib/release-updates";
import { CHANGELOG, type ChangelogRelease } from "@/lib/changelog.generated";
import { changelogAnchor } from "@/scripts/changelog-lib.mjs";
import { getFacts } from "@/lib/facts";
@@ -11,9 +13,9 @@ import { fill, getChangelog, getChrome } from "@/lib/i18n/dictionaries";
import { REPO_RELEASES_URL, REPO_URL } from "@/lib/i18n/links";
import { buildPageMetadata } from "@/lib/page-meta";
// The two headline facts come from the facts layer, which may be refreshed
// from the KV snapshot after deploy; the notes are build-time from CHANGELOG.md.
export const revalidate = 300;
// Published releases resolve at request time; the notes are build-time
// from CHANGELOG.md and link to the complete published history.
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
@@ -52,7 +54,8 @@ export default async function ChangelogPage({ params }: { params: Promise<{ loca
const t = getChangelog(locale);
const chrome = getChrome(locale);
const facts = await getFacts();
const published = facts.latestPublishedRelease;
const updates = await getReleaseUpdates(facts.latestPublishedRelease);
const published = updates.terminal.release;
const candidate = facts.version;
const candidateIsPublished = published !== null && candidate !== null && published.version === candidate;
@@ -60,28 +63,6 @@ export default async function ChangelogPage({ params }: { params: Promise<{ loca
<>
<PageHeader kicker={t.kicker} title={t.title} lede={t.lead} pose="write">
<dl className="def-rows changelog-facts">
<div className="def-row" data-published={published ? "true" : "false"}>
<dt>{t.publishedLabel}</dt>
<dd>
{published ? (
<>
<Status tone="ready">
{fill(t.publishedValue, {
tag: published.tag,
date: formatDate(published.publishedAt, chrome.dateLocale),
})}
</Status>
<a href={published.url} target="_blank" rel="noreferrer" className="link">
{ext(t.releasePageLink)}
</a>
</>
) : (
<a href={REPO_RELEASES_URL} target="_blank" rel="noreferrer" className="link">
{ext(t.releasesLink)}
</a>
)}
</dd>
</div>
<div className="def-row" data-candidate={candidateIsPublished ? "published" : "unreleased"}>
<dt>{t.candidateLabel}</dt>
<dd>
@@ -101,6 +82,7 @@ export default async function ChangelogPage({ params }: { params: Promise<{ loca
</PageHeader>
<div className="page-body">
<ReleaseUpdates key={`${locale}:${updates.fetchedAt}`} locale={locale} initial={updates} showAllLink={false} />
<div className="page-body-narrow changelog-list">
{CHANGELOG.length === 0 ? (
<EmptyState
+9 -10
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { HeroInstall } from "@/components/hero-install";
import { Icon } from "@/components/icon";
import { Section } from "@/components/page-header";
import { Status } from "@/components/status-badge";
import { ReleaseUpdates } from "@/components/release-updates";
import { NativeTerminalGallery } from "@/components/native-terminal-gallery";
import { WhaleLive } from "@/components/whale-live";
import { getFacts } from "@/lib/facts";
@@ -17,10 +17,11 @@ import { DISCORD_URL } from "@/lib/i18n/links";
import { serializeJsonLd } from "@/lib/json-ld";
import { TERMINAL_SCREENSHOT } from "@/lib/media-manifest";
import { buildSoftwareApplicationJsonLd } from "@/lib/software-application-schema";
import { getReleaseUpdates } from "@/lib/release-updates";
// Revalidate against source-proven runtime facts without giving up static edge
// caching. `getFacts()` rejects legacy or older KV snapshots.
export const revalidate = 300;
// Resolve published releases at request time; source fetches cache for five
// minutes. `getFacts()` rejects legacy or older KV snapshots.
export const dynamic = "force-dynamic";
/**
* The whale-road homepage: the promise, capabilities and reading
@@ -40,8 +41,9 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
const { locale } = await params;
const d = getHome(locale);
const facts = await getFacts();
const updates = await getReleaseUpdates(facts.latestPublishedRelease);
const sourceVersion = facts.version ?? "unknown";
const publishedRelease = facts.latestPublishedRelease;
const publishedRelease = updates.terminal.release;
const sourceIsPublished = publishedRelease?.version === sourceVersion;
const showPromo = Date.now() < Date.parse(PARTNER_PROMO.endsAt);
@@ -111,11 +113,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
data-source-state={sourceIsPublished ? "published release" : "source candidate"}
data-source-state-label={sourceIsPublished ? d.publishedRelease : d.sourceCandidate}
>
<Status tone={publishedRelease ? "ready" : "idle"}>
{publishedRelease
? <a href={publishedRelease.url}>{fill(d.latestRelease, { tag: publishedRelease.tag })}</a>
: d.releaseUnavailable}
</Status>
<Link href={`/${locale}/changelog`} className="section-link">{getChrome(locale).footerChangelog}</Link>
<span>{facts.license ?? "MIT"}</span>
</div>
</div>
@@ -123,6 +121,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
{/* PAPER — models, connections, and the getting-started path. */}
<div className="home-body">
<ReleaseUpdates key={`${locale}:${updates.fetchedAt}`} locale={locale} initial={updates} />
<Section
id="home-capabilities"
className="home-section"
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getFacts } from "@/lib/facts";
import { getReleaseUpdates } from "@/lib/release-updates";
export const dynamic = "force-dynamic";
export async function GET() {
const facts = await getFacts();
const snapshot = await getReleaseUpdates(facts.latestPublishedRelease);
return NextResponse.json(snapshot, {
headers: { "Cache-Control": "public, max-age=0, must-revalidate" },
});
}
+27 -1
View File
@@ -200,6 +200,31 @@
font-variant-numeric: tabular-nums;
}
.release-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
}
.release-card {
display: grid;
gap: var(--space-3);
min-width: 0;
padding: var(--space-5);
border: 1px solid var(--line);
border-inline-start: 3px solid var(--accent);
border-radius: var(--radius-sheet);
background: var(--panel);
}
.release-card h3 { font-size: var(--text-ui); font-weight: 600; color: var(--muted); }
.release-version { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); }
.release-version > a { display: inline-flex; align-items: center; min-height: 2.75rem; font-size: var(--text-title); font-weight: 600; color: var(--accent); }
.release-version time,
.release-cached { font-size: var(--text-ui); color: var(--muted); }
.release-links { display: flex; flex-wrap: wrap; gap: var(--space-5); }
.release-links a { display: inline-flex; align-items: center; min-height: 2.75rem; }
.release-update-error { margin-top: var(--space-4); color: var(--muted); font-size: var(--text-ui); }
.page-body > .release-updates { padding-block-start: 0; margin-block-end: var(--section-gap); }
/* ---------- partner offer ---------- */
.home-promo { max-width: 1000px; }
.home-promo-card {
@@ -309,7 +334,8 @@
.home-whale { justify-content: flex-end; }
.home-whale .whale-live { --whale-size: clamp(11rem, 48vw, 16rem); }
.home-work-patterns,
.home-contribution-grid { grid-template-columns: minmax(0, 1fr); }
.home-contribution-grid,
.release-cards { grid-template-columns: minmax(0, 1fr); }
.home-work-patterns > div + div { padding-top: var(--space-5); border-top: 1px solid var(--line); }
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Section } from "@/components/page-header";
import { Icon } from "@/components/icon";
import { readBoundedBody } from "@/lib/bounded-body";
import { COMPUTER_USE_REPO } from "@/lib/computer-use-release";
import { RELEASE_UPDATES_COPY } from "@/lib/content/release-updates";
import { getChrome, pickText } from "@/lib/i18n/dictionaries";
import { REPO_RELEASES_URL } from "@/lib/i18n/links";
import type { ReleaseUpdatesSnapshot } from "@/lib/release-updates";
const INTERVAL = 5 * 60 * 1000;
const SOURCES = { terminal: REPO_RELEASES_URL, computerUse: `${COMPUTER_USE_REPO}/releases` };
const CHANNELS = ["terminal", "computerUse"] as const;
function validSnapshot(value: unknown): value is ReleaseUpdatesSnapshot {
if (!value || typeof value !== "object") return false;
const snapshot = value as Record<string, unknown>;
if (snapshot.fetchedAt !== null && (typeof snapshot.fetchedAt !== "string" || !Number.isFinite(Date.parse(snapshot.fetchedAt)))) return false;
return CHANNELS.every((channel) => {
const item = snapshot[channel];
if (!item || typeof item !== "object") return false;
const state = item as Record<string, unknown>;
if (!["current", "cached", "unavailable"].includes(String(state.status))) return false;
if (state.release === null) return state.status === "unavailable";
if (state.status === "unavailable") return false;
if (!state.release || typeof state.release !== "object") return false;
const release = state.release as Record<string, unknown>;
return typeof release.tag === "string" && /^v\d+\.\d+\.\d+$/.test(release.tag)
&& release.version === release.tag.slice(1)
&& release.url === `${SOURCES[channel]}/tag/${release.tag}`
&& typeof release.publishedAt === "string" && Number.isFinite(Date.parse(release.publishedAt));
});
}
export function ReleaseUpdates({ locale, initial, showAllLink = true }: { locale: string; initial: ReleaseUpdatesSnapshot; showAllLink?: boolean }) {
const [snapshot, setSnapshot] = useState(initial);
const [failed, setFailed] = useState(false);
const text = (key: keyof typeof RELEASE_UPDATES_COPY) => pickText(RELEASE_UPDATES_COPY[key], locale);
const dateLocale = getChrome(locale).dateLocale;
useEffect(() => {
let active = true;
let pending: AbortController | null = null;
let lastAttempt = CHANNELS.every((channel) => initial[channel].status === "current") ? Math.min(Date.now(), Date.parse(initial.fetchedAt ?? "") || 0) : 0;
const refresh = async () => {
if (document.visibilityState !== "visible" || pending || Date.now() - lastAttempt < INTERVAL) return;
lastAttempt = Date.now();
pending = new AbortController();
const timeout = window.setTimeout(() => pending?.abort(), 15_000);
try {
const response = await fetch("/api/releases", { cache: "no-store", credentials: "omit", signal: pending.signal });
if (!response.ok) throw new Error("Release check unavailable");
const next: unknown = JSON.parse(new TextDecoder().decode(await readBoundedBody(response, 32 * 1024)));
if (!validSnapshot(next)) throw new Error("Invalid release response");
if (active) {
setSnapshot((previous) => {
const resolved = { ...next };
for (const channel of CHANNELS) {
const before = previous[channel].release;
const after = next[channel].release;
if (before && next[channel].status !== "current" && (!after || Date.parse(before.publishedAt) > Date.parse(after.publishedAt))) {
resolved[channel] = { release: before, status: "cached" };
}
}
return resolved;
});
setFailed(false);
}
} catch {
if (active) setFailed(true);
} finally {
window.clearTimeout(timeout);
pending = null;
}
};
void refresh();
const interval = window.setInterval(() => void refresh(), INTERVAL);
const visible = () => void refresh();
document.addEventListener("visibilitychange", visible);
window.addEventListener("focus", visible);
return () => {
active = false;
window.clearInterval(interval);
pending?.abort();
document.removeEventListener("visibilitychange", visible);
window.removeEventListener("focus", visible);
};
}, [initial]);
return (
<Section
id="release-updates"
title={text("title")}
className="home-section release-updates"
scope={text("source")}
link={showAllLink ? <Link href={`/${locale}/changelog`} className="section-link">{text("all")}<Icon name="arrow-right" className="icon icon-flip" /></Link> : undefined}
>
<div className="release-cards" data-checked-at={snapshot.fetchedAt ?? undefined}>
{CHANNELS.map((channel) => {
const { release, status } = snapshot[channel];
return (
<article className="release-card" key={channel} aria-label={text(channel)}>
<h3>{text(channel)}</h3>
<div className="release-version" aria-live="polite">
{release ? <a href={release.url}>{release.tag}</a> : <a href={SOURCES[channel]}>{text("unavailable")}</a>}
{release?.publishedAt ? <time dateTime={release.publishedAt}>{new Date(release.publishedAt).toLocaleDateString(dateLocale, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" })}</time> : null}
{release && (status === "cached" || failed) ? <span className="release-cached">{text("cached")}</span> : null}
</div>
<div className="release-links">
<a href={release?.url ?? SOURCES[channel]} className="section-link">{text("notes")}<Icon name="external" className="icon" /></a>
<a href={`${SOURCES[channel]}.atom`} className="section-link" aria-label={`${text(channel)} · ${text("rss")}`}>{text("rss")}</a>
</div>
</article>
);
})}
</div>
{failed ? <p className="release-update-error" role="status">{text("refreshFailed")}</p> : null}
</Section>
);
}
+2 -2
View File
@@ -2021,10 +2021,10 @@
},
"changelog": {
"metaTitle": "Changelog · Codewhale",
"metaDescription": "Codewhale release record: the latest published release, the unreleased source candidate, and the notes for each version, drawn from CHANGELOG.md in the repository.",
"metaDescription": "Release notes for Codewhale and its Computer Use plugin, with published versions and upcoming changes.",
"kicker": "Release record",
"title": "What changed, and in which version.",
"lead": "Two facts sit at the top of this page: the newest published release, and the version the source tree currently declares. Everything below is the repository's own CHANGELOG.md, section by section.",
"lead": "Published releases and upcoming changes.",
"publishedLabel": "Latest published release",
"publishedValue": "{tag} · published {date}",
"candidateLabel": "Source candidate",
+2 -2
View File
@@ -2021,10 +2021,10 @@
},
"changelog": {
"metaTitle": "更新日志 · Codewhale",
"metaDescription": "Codewhale 发布记录:最新已发布版本、尚未发布的源码候选版,以及每个版本的说明,均取自仓库中的 CHANGELOG.md。",
"metaDescription": "Codewhale 与 Computer Use 插件的发布说明、已发布版本和后续改动。",
"kicker": "发布记录",
"title": "改了什么,在哪个版本。",
"lead": "页面顶部是两个事实:最新发布的版本,以及源码树当前声明的版本。下面的内容逐节来自仓库自己的 CHANGELOG.md。",
"lead": "正式发布与即将推出的改动。",
"publishedLabel": "最新已发布版本",
"publishedValue": "{tag} · 发布于 {date}",
"candidateLabel": "源码候选版",
+14
View File
@@ -0,0 +1,14 @@
import type { LocalizedText } from "./vocabulary";
export const RELEASE_UPDATES_COPY = {
title: { en: "Latest releases", zh: "最新发布" },
source: { en: "Published on GitHub. Updates automatically.", zh: "来自 GitHub 的正式发布,自动更新。" },
terminal: { en: "Codewhale terminal", zh: "Codewhale 终端" },
computerUse: { en: "Computer Use plugin", zh: "Computer Use 插件" },
notes: { en: "Release notes", zh: "发布说明" },
all: { en: "All release notes", zh: "全部发布说明" },
rss: { en: "RSS", zh: "RSS" },
cached: { en: "Last known release", zh: "上次确认的版本" },
unavailable: { en: "Check published releases", zh: "查看已发布版本" },
refreshFailed: { en: "Updates could not be checked. The release links still work.", zh: "暂时无法检查更新,仍可通过链接查看发布。" },
} satisfies Record<string, LocalizedText>;
+2 -2
View File
@@ -217,11 +217,11 @@ describe("navigation parity and accessibility", () => {
const reference = buildNavLinks("en", getChrome("en")).map((l) =>
l.href.replace(/^\/en\//, ""),
);
expect(reference).toEqual(["product", "features", "models", "plugins", "docs"]);
expect(reference).toEqual(["product", "computer-use", "models", "plugins", "docs", "changelog"]);
const moreReference = buildSecondaryNavLinks("en", getChrome("en")).map((l) =>
l.href.replace(/^\/en\//, ""),
);
expect(moreReference).toEqual(["docs/guide", "install", "ratatui", "faq", "community", "contribute"]);
expect(moreReference).toEqual(["features", "docs/guide", "install", "ratatui", "faq", "community", "contribute"]);
for (const locale of locales) {
const links = buildNavLinks(locale, getChrome(locale));
expect(
+2
View File
@@ -34,6 +34,8 @@ function installGitHubFixture(
return response(
JSON.stringify({
tag_name: "v0.9.0",
draft: false,
prerelease: false,
published_at: "2026-07-16T20:05:39Z",
html_url: releaseHtmlUrl,
}),
+8 -44
View File
@@ -16,15 +16,12 @@ import { withGenerationLease, type DraftClaimLockNamespace } from "./draft-claim
import { parseModelCatalog } from "./model-catalog.mjs";
import { parseProviderDescriptors } from "./provider-descriptors.mjs";
import { fetchBoundedText } from "./bounded-body";
import type {
PublishedReleaseFact,
RepoFacts,
} from "./facts.generated";
import type { RepoFacts } from "./facts.generated";
import { FACTS as BUILD_FACTS } from "./facts.generated";
import { isRepoFacts } from "./facts";
import { isPublishedRelease, isRepoFacts, newestPublishedRelease } from "./facts";
import { fetchLatestPublishedRelease } from "./published-release";
const RAW_ROOT = "https://raw.githubusercontent.com/codewhale-hq/CodeWhale";
const RELEASE_TAG_ROOT = "https://github.com/codewhale-hq/CodeWhale/releases/tag";
const KV_KEY = "facts:current";
const LOG_KEY = "facts:drift-log";
@@ -107,44 +104,6 @@ function deriveSandboxBackends(source: string): string[] {
return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
}
async function fetchLatestPublishedRelease(
ghToken?: string,
): Promise<PublishedReleaseFact | null> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "codewhale-web-drift",
"X-GitHub-Api-Version": "2022-11-28",
};
if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
try {
const r = await fetchBoundedText("https://api.github.com/repos/codewhale-hq/CodeWhale/releases/latest", { headers });
if (!r.ok) return null;
const j = JSON.parse(r.text) as {
tag_name?: string;
published_at?: string;
};
if (
!j.tag_name ||
!/^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(j.tag_name) ||
!j.published_at ||
!Number.isFinite(Date.parse(j.published_at))
) {
return null;
}
return {
tag: j.tag_name,
version: j.tag_name.slice(1),
publishedAt: j.published_at,
// Built from the tag, not `html_url`: GitHub answers with the repo's
// canonical casing (`codewhale-hq/Codewhale`), which the exact-URL check in
// isRepoFacts rejects, invalidating the whole KV snapshot.
url: `${RELEASE_TAG_ROOT}/${j.tag_name}`,
};
} catch {
return null;
}
}
function deriveLicense(licText: string): string | null {
const first = licText.split(/\r?\n/).find((l) => l.trim().length > 0);
if (!first) return null;
@@ -327,6 +286,11 @@ async function refreshFacts(kv: KVNamespace, githubToken?: string): Promise<Fact
return { ok: true, changed: false };
}
remote.latestPublishedRelease = newestPublishedRelease(
remote.latestPublishedRelease,
isPublishedRelease(cached.latestPublishedRelease) ? cached.latestPublishedRelease : null,
);
const diffs = diff(cached, remote);
if (diffs.length === 0) {
return { ok: true, changed: false };
+3 -2
View File
@@ -57,10 +57,11 @@ function isModelFact(value: unknown): value is ModelFact {
);
}
function isPublishedRelease(value: unknown): value is PublishedReleaseFact {
export function isPublishedRelease(value: unknown): value is PublishedReleaseFact {
if (!isRecord(value)) return false;
return (
typeof value.tag === "string" &&
/^v\d+\.\d+\.\d+$/.test(value.tag) &&
typeof value.version === "string" &&
value.tag === `v${value.version}` &&
typeof value.publishedAt === "string" &&
@@ -110,7 +111,7 @@ export function isRepoFacts(value: unknown): value is RepoFacts {
);
}
function newestPublishedRelease(
export function newestPublishedRelease(
build: PublishedReleaseFact | null,
cached: PublishedReleaseFact | null,
): PublishedReleaseFact | null {
+2 -2
View File
@@ -8,11 +8,11 @@ import type { ChangelogDict } from "../types";
export const changelog: ChangelogDict = {
metaTitle: "Changelog · Codewhale",
metaDescription:
"Codewhale release record: the latest published release, the unreleased source candidate, and the notes for each version, drawn from CHANGELOG.md in the repository.",
"Release notes for Codewhale and its Computer Use plugin, with published versions and upcoming changes.",
kicker: "Release record",
title: "What changed, and in which version.",
lead:
"Two facts sit at the top of this page: the newest published release, and the version the source tree currently declares. Everything below is the repository's own CHANGELOG.md, section by section.",
"Published releases and upcoming changes.",
publishedLabel: "Latest published release",
publishedValue: "{tag} · published {date}",
candidateLabel: "Source candidate",
+2 -2
View File
@@ -7,11 +7,11 @@ import type { ChangelogDict } from "../types";
export const changelog: ChangelogDict = {
metaTitle: "更新日志 · Codewhale",
metaDescription:
"Codewhale 发布记录:最新已发布版本、尚未发布的源码候选版,以及每个版本的说明,均取自仓库中的 CHANGELOG.md。",
"Codewhale 与 Computer Use 插件的发布说明、已发布版本和后续改动。",
kicker: "发布记录",
title: "改了什么,在哪个版本。",
lead:
"页面顶部是两个事实:最新发布的版本,以及源码树当前声明的版本。下面的内容逐节来自仓库自己的 CHANGELOG.md。",
"正式发布与即将推出的改动。",
publishedLabel: "最新已发布版本",
publishedValue: "{tag} · 发布于 {date}",
candidateLabel: "源码候选版",
+5 -2
View File
@@ -29,7 +29,7 @@ export interface ChromeLink {
}
/**
* The primary nav links — Product / Models / Plugins / Docs — identical
* The primary nav links — Product / Computer Use / Models / Plugins / Docs / Changelog — identical
* in shape for every routed locale. This is the strip a serious product
* shows; discovery and community routes live in `secondaryNavLinks` (the
* compact sheet's second group) and the footer.
@@ -37,16 +37,18 @@ export interface ChromeLink {
export function navLinks(locale: string, chrome: ChromeDict): ChromeLink[] {
return [
{ href: `/${locale}/product`, label: chrome.navProduct },
{ href: `/${locale}/features`, label: chrome.navFeatures },
{ href: `/${locale}/computer-use`, label: "Computer Use" },
{ href: `/${locale}/models`, label: chrome.navModels },
{ href: `/${locale}/plugins`, label: chrome.navPlugins },
{ href: `/${locale}/docs`, label: chrome.navDocs },
{ href: `/${locale}/changelog`, label: chrome.footerChangelog },
];
}
/** The compact sheet's second group: getting started, install, and community. */
export function secondaryNavLinks(locale: string, chrome: ChromeDict): ChromeLink[] {
return [
{ href: `/${locale}/features`, label: chrome.navFeatures },
{ href: `/${locale}/docs/guide`, label: chrome.navStart },
{ href: `/${locale}/install`, label: chrome.navInstall },
{ href: `/${locale}/ratatui`, label: "Ratatui" },
@@ -60,6 +62,7 @@ export function secondaryNavLinks(locale: string, chrome: ChromeDict): ChromeLin
export function footerProductLinks(locale: string, chrome: ChromeDict): ChromeLink[] {
return [
{ href: `/${locale}/product`, label: chrome.navProduct },
{ href: `/${locale}/computer-use`, label: "Computer Use" },
{ href: `/${locale}/features`, label: chrome.navFeatures },
{ href: `/${locale}/docs`, label: chrome.footerDocs },
{ href: `/${locale}/docs/guide`, label: chrome.footerGuide },
+3 -1
View File
@@ -83,7 +83,9 @@ describe("public website copy contracts", () => {
expect(homepage).toContain("facts.latestPublishedRelease");
// The machine-readable source-state attribute stays a literal.
expect(homepage).toContain('data-source-state={sourceIsPublished ? "published release" : "source candidate"}');
expect(homepage).toContain("publishedRelease.url");
expect(homepage).toContain("getReleaseUpdates(facts.latestPublishedRelease)");
expect(homepage).toContain("initial={updates}");
expect(readFileSync(new URL("../components/release-updates.tsx", import.meta.url), "utf8")).toContain("href={release.url}");
// The visible wording moved into the dictionary layer (#4934). Assert the
// rendered contract — the EN reference value plus the page's use of it —
// instead of a raw TSX string, and hold every locale to a real value.
+47
View File
@@ -0,0 +1,47 @@
import { fetchBoundedText } from "./bounded-body";
import type { PublishedReleaseFact } from "./facts";
const REPOSITORIES = {
terminal: "codewhale-hq/CodeWhale",
computerUse: "codewhale-hq/codewhale-cu-plugin",
} as const;
export async function fetchLatestPublishedRelease(
token?: string,
channel: keyof typeof REPOSITORIES = "terminal",
): Promise<PublishedReleaseFact | null> {
if (process.env.NEXT_PHASE === "phase-production-build") return null;
const repo = REPOSITORIES[channel];
try {
const response = await fetchBoundedText(
`https://api.github.com/repos/${repo}/releases/latest`,
{
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "codewhale-web",
"X-GitHub-Api-Version": "2022-11-28",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
next: { revalidate: 300 },
},
{ timeoutMs: 5000, maxBytes: 128 * 1024 },
);
if (!response.ok) return null;
const release = JSON.parse(response.text) as Record<string, unknown> | null;
if (!release || release.draft !== false || release.prerelease !== false
|| typeof release.tag_name !== "string" || !/^v\d+\.\d+\.\d+$/.test(release.tag_name)
|| typeof release.html_url !== "string"
|| typeof release.published_at !== "string"
|| !Number.isFinite(Date.parse(release.published_at))) return null;
const url = `https://github.com/${repo}/releases/tag/${release.tag_name}`;
if (release.html_url.toLowerCase() !== url.toLowerCase()) return null;
return {
tag: release.tag_name,
version: release.tag_name.slice(1),
publishedAt: release.published_at,
url,
};
} catch {
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { BUILD_FACTS, isPublishedRelease, newestPublishedRelease, type PublishedReleaseFact } from "./facts";
import { fetchLatestPublishedRelease } from "./published-release";
export type ReleaseUpdateState = {
release: PublishedReleaseFact | null;
status: "current" | "cached" | "unavailable";
};
export type ReleaseUpdatesSnapshot = {
terminal: ReleaseUpdateState;
computerUse: ReleaseUpdateState;
fetchedAt: string | null;
};
function releaseState(release: PublishedReleaseFact | null, current = false): ReleaseUpdateState {
return {
release: release ? {
tag: release.tag,
version: release.version,
publishedAt: release.publishedAt,
url: release.url,
} : null,
status: release ? (current ? "current" : "cached") : "unavailable",
};
}
export async function getReleaseUpdates(
lastKnownTerminal?: PublishedReleaseFact | null,
): Promise<ReleaseUpdatesSnapshot> {
const buildRelease = isPublishedRelease(BUILD_FACTS.latestPublishedRelease)
? BUILD_FACTS.latestPublishedRelease : null;
if (process.env.NEXT_PHASE === "phase-production-build") {
return {
terminal: releaseState(buildRelease),
computerUse: { release: null, status: "unavailable" },
fetchedAt: null,
};
}
const fallback = newestPublishedRelease(
buildRelease,
isPublishedRelease(lastKnownTerminal) ? lastKnownTerminal : null,
);
const [terminal, computerUse] = await Promise.all([
fetchLatestPublishedRelease(),
fetchLatestPublishedRelease(undefined, "computerUse"),
]);
const latestTerminal = newestPublishedRelease(terminal, fallback);
return {
terminal: releaseState(latestTerminal, terminal !== null && latestTerminal?.tag === terminal.tag),
computerUse: releaseState(computerUse, computerUse !== null),
fetchedAt: new Date().toISOString(),
};
}