diff --git a/.gitignore b/.gitignore index 73c72150..85176b47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ .DS_Store -dist/ +# Anchored to the repository root. Written as a bare `dist/` it matched at any +# depth, and because excluding a directory stops git from descending into it, the +# `!frontend/dist/.keep` exception two lines down could never take effect — so +# the .keep that manifest_embed.go's `go:embed all:frontend/dist` needs was +# silently absent from every clone. +/dist/ release/ frontend/node_modules/ frontend/dist/* diff --git a/frontend/.gitignore b/frontend/.gitignore index 5453c76f..c046ff1a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,5 +1,11 @@ node_modules/ -dist/ +# The bundle is ignored but dist/.keep is not: manifest_embed.go carries a +# `go:embed all:frontend/dist`, which does not compile when the directory is +# absent, so a fresh clone could not `go build` or `go vet` before Vite had run. +# Listed per-entry rather than as `dist/` because excluding the directory itself +# stops git descending into it, which would make the exception unreachable. +dist/* +!dist/.keep coverage/ test-results/ playwright-report/ diff --git a/frontend/dist/.keep b/frontend/dist/.keep new file mode 100644 index 00000000..e69de29b diff --git a/internal/process/process.go b/internal/process/process.go index da52b898..b722193c 100644 --- a/internal/process/process.go +++ b/internal/process/process.go @@ -13,6 +13,7 @@ import ( "os/exec" "sort" "strings" + "sync" "time" ) @@ -32,6 +33,12 @@ type Output struct { Text string `json:"text,omitempty"` } +// OutputListener receives each accepted chunk of a command's output. +// +// Calls are serialised: stdout and stderr are copied by separate goroutines, so +// a listener would otherwise be entered concurrently, and every caller would +// have to synchronise on its own. Implementations may append to a slice or write +// to a channel without locking. type OutputListener func(Output) type StreamingRunner interface { @@ -90,8 +97,9 @@ func (r OSRunner) RunWithOutput(ctx context.Context, argv []string, overrides ma command.Env = mergeEnvironment(r.Env, overrides) stdout := &boundedBuffer{limit: MaxOutputBytes} stderr := &boundedBuffer{limit: MaxOutputBytes} - command.Stdout = &streamWriter{stream: "stdout", buffer: stdout, listener: listener} - command.Stderr = &streamWriter{stream: "stderr", buffer: stderr, listener: listener} + var streamLock sync.Mutex + command.Stdout = &streamWriter{stream: "stdout", buffer: stdout, listener: listener, mu: &streamLock} + command.Stderr = &streamWriter{stream: "stderr", buffer: stderr, listener: listener, mu: &streamLock} err := command.Run() result.Stdout = stdout.String() result.Stderr = stderr.String() @@ -118,9 +126,16 @@ type streamWriter struct { stream string buffer *boundedBuffer listener OutputListener + // Shared by the stdout and stderr writers of one command, so a listener sees + // one chunk at a time. Each writer has its own buffer, but a lock per writer + // would not serialise anything — the two goroutines would take different + // locks and still enter the listener together. + mu *sync.Mutex } func (w *streamWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() before := w.buffer.buffer.Len() n, err := w.buffer.Write(data) accepted := w.buffer.buffer.Len() - before diff --git a/internal/process/process_test.go b/internal/process/process_test.go index ea06832b..7f9d37d3 100644 --- a/internal/process/process_test.go +++ b/internal/process/process_test.go @@ -22,6 +22,16 @@ func TestProcessHelper(t *testing.T) { // kills this process, so the sleep never runs to completion. <-time.After(10 * time.Second) } + // Interleaves both streams so the runner's stdout and stderr copiers are + // active at the same time. Real installs look like this — npm reports progress + // on stderr while printing results on stdout. + if os.Getenv("ONEAGENT_PROCESS_BOTH_STREAMS") == "1" { + for index := 0; index < 50; index++ { + os.Stdout.WriteString("o") + os.Stderr.WriteString("e") + } + os.Exit(0) + } os.Stdout.WriteString(os.Getenv("ONEAGENT_PROCESS_VALUE")) os.Exit(0) } @@ -38,7 +48,16 @@ func helperRunner(t *testing.T) OSRunner { if err != nil { t.Fatal(err) } - runner := New(map[string]string{"ONEAGENT_PROCESS_HELPER": "1"}) + /* These cases re-exec the test binary as the helper process, so under `go test + -cover` the child inherits the coverage instrumentation. Without a place to + write its profile it prints "warning: GOCOVERDIR not set" to stderr, which + lands in the captured output and in the listener — failing assertions about + what the command produced for a reason that has nothing to do with the + runner. Giving the child a scratch directory keeps its stderr its own. */ + runner := New(map[string]string{ + "ONEAGENT_PROCESS_HELPER": "1", + "GOCOVERDIR": t.TempDir(), + }) runner.Lookup = func(command string) (string, bool) { if command == "helper" { return path, true @@ -75,6 +94,36 @@ func TestOSRunnerStreamsOutputWithoutChangingResult(t *testing.T) { } } +// The listener here appends without locking, exactly as the install runtime's +// does (internal/app/install.go redacts and forwards). stdout and stderr are +// copied by separate goroutines, so without serialisation inside the runner this +// is a data race in production, not just in a test — it surfaces whenever a +// command writes to both streams, which npm does on every install. +func TestOSRunnerSerialisesListenerAcrossStreams(t *testing.T) { + runner := helperRunner(t) + outputs := make([]Output, 0) + result, err := runner.RunWithOutput(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ + "ONEAGENT_PROCESS_BOTH_STREAMS": "1", + }, helperTimeout, func(output Output) { outputs = append(outputs, output) }) + if err != nil || result.ExitCode != 0 { + t.Fatalf("interleaved process result = %#v, err=%v", result, err) + } + var stdout, stderr int + for _, output := range outputs { + switch output.Stream { + case "stdout": + stdout += len(output.Text) + case "stderr": + stderr += len(output.Text) + } + } + // Both streams have to reach the listener; asserting only the total would + // pass even if one stream's chunks were being dropped. + if stdout != 50 || stderr != 50 { + t.Fatalf("streamed %d stdout and %d stderr bytes, want 50 of each", stdout, stderr) + } +} + func TestOSRunnerReturnsExitCodeAndCapturesOutput(t *testing.T) { runner := helperRunner(t) result, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 48c9e724..c0f90fe1 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -9,6 +9,13 @@ export default defineConfig({ site, base, output: "static", + // Chinese stays unprefixed so every published URL keeps working; English is + // additive under /en/. + i18n: { + defaultLocale: "zh-CN", + locales: ["zh-CN", "en"], + routing: { prefixDefaultLocale: false }, + }, integrations: [sitemap()], build: { assets: "_assets", diff --git a/site/e2e/site.spec.ts b/site/e2e/site.spec.ts index d5534b0f..e228fac1 100644 --- a/site/e2e/site.spec.ts +++ b/site/e2e/site.spec.ts @@ -1,7 +1,14 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; import axe from "axe-core"; -const criticalPages = ["/", "/downloads/", "/agents/", "/security/"]; +const criticalPages = ["/", "/explore/", "/downloads/", "/agents/", "/security/"]; + +/* Mirrors translatedRoutes in src/i18n/index.ts. It cannot be imported here: + that module reads import.meta.env.BASE_URL, which only exists under Vite, and + Playwright runs this file in plain Node. The englishPages list below is the + guard — every route named here has to have an /en/ page that gets audited, so + the two drifting apart fails rather than going unnoticed. */ +const translatedRoutes = ["", "downloads/", "quickstart/", "explore/", "security/"]; test("critical pages fit the viewport without horizontal scrolling", async ({ page }) => { for (const path of criticalPages) { @@ -14,21 +21,374 @@ test("critical pages fit the viewport without horizontal scrolling", async ({ pa } }); -test("home states the product boundary and current channel", async ({ page }) => { +test("home presents one activation entry without claiming a real scan", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { level: 1 })).toContainText("激活你的 AI 开发环境"); - await expect(page.getByRole("link", { name: "下载 OneAgent" })).toBeVisible(); - await expect(page.getByText("自己的 Key", { exact: true })).toBeVisible(); - await expect(page.locator(".hero-note")).toContainText("GitHub"); + await expect(page.locator(".hero-actions").getByRole("button", { name: "开始激活演示" })).toBeVisible(); + await expect(page.locator(".hero-actions").getByRole("link")).toHaveCount(0); + /* The console used to sit at idle until clicked. It now plays itself once it + scrolls into view, so the successor guarantee is that the page drives the + demo — the button stays as the replay and takeover entry. Scrolled here + explicitly because whether the console starts on screen varies by viewport, + and this test is not the one about the trigger threshold. */ + const console = page.locator("#activation-console"); + await console.scrollIntoViewIfNeeded(); + await expect(console).toHaveAttribute("data-autoplaying", "true"); + await expect(page.getByText("示例环境", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("不访问设备", { exact: true })).toBeVisible(); + await expect(page.locator('input[type="password"], input[name*="key" i]')).toHaveCount(0); + await expect(page.locator(".hero-note")).toContainText("未签名技术预览版"); +}); + +test("activation demo reaches Ready only for a supported managed combination", async ({ page }) => { + const fetches: string[] = []; + page.on("request", (request) => { + if (["fetch", "xhr"].includes(request.resourceType())) fetches.push(request.url()); + }); + await page.goto("/"); + const console = page.locator("#activation-console"); + await page.getByRole("button", { name: "开始激活演示" }).click(); + await expect(console).toHaveAttribute("data-phase", "agent"); + await console.getByRole("button", { name: /Claude Code/ }).click(); + await expect(console).toHaveAttribute("data-phase", "mode"); + await console.getByRole("button", { name: /配置模型服务/ }).click(); + await expect(console).toHaveAttribute("data-phase", "provider"); + await console.getByRole("button", { name: /PPIO/ }).click(); + await console.getByRole("button", { name: "验证示例连接" }).click(); + await expect(console).toHaveAttribute("data-phase", "model"); + await console.getByRole("button", { name: "deepseek/deepseek-v3" }).click(); + await console.getByRole("button", { name: "确认激活" }).click(); + await expect(console).toHaveAttribute("data-phase", "ready"); + await expect(console.getByRole("heading", { name: "示例环境已 Ready" })).toBeVisible(); + await expect(console.getByRole("link", { name: "下载 OneAgent" })).toBeVisible(); + await expect(console.getByRole("link", { name: "打开完整配置目录" })).toBeVisible(); + await page.addScriptTag({ content: axe.source }); + const result = await page.evaluate(async () => { + const axeApi = (window as typeof window & { axe: typeof axe }).axe; + return axeApi.run(document.querySelector("#activation-console")!, { + runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, + }); + }); + const serious = result.violations.filter( + (violation) => violation.impact === "serious" || violation.impact === "critical", + ); + expect(serious, JSON.stringify(serious, null, 2)).toEqual([]); + expect(fetches, "the public demo must not call a local or remote activation API").toEqual([]); +}); + +test("activation demo preserves guide-only and preview-gate boundaries", async ({ page }) => { + await page.goto("/"); + const console = page.locator("#activation-console"); + + await page.getByRole("button", { name: "开始激活演示" }).click(); + await expect(console).toHaveAttribute("data-phase", "agent"); + await console.getByRole("button", { name: /Cursor/ }).click(); + await expect(console).toHaveAttribute("data-phase", "guide-only"); + await expect(console.getByRole("heading", { name: "转入官方设置" })).toBeVisible(); + await expect(console.getByRole("link", { name: "下载 OneAgent" })).toBeHidden(); + + await console.getByRole("button", { name: "重置演示" }).click(); + await page.getByRole("button", { name: "开始激活演示" }).click(); + await expect(console).toHaveAttribute("data-phase", "agent"); + await console.getByRole("button", { name: /Codex/ }).click(); + await console.getByRole("button", { name: /配置模型服务/ }).click(); + await console.getByRole("button", { name: /PPIO/ }).click(); + await console.getByRole("button", { name: "验证示例连接" }).click(); + await expect(console).toHaveAttribute("data-phase", "preview-gate"); + await expect(console.getByRole("heading", { name: "仍需通过发布门禁" })).toBeVisible(); + await expect(console.getByRole("link", { name: "下载 OneAgent" })).toBeHidden(); + await expect(console.getByRole("link", { name: "发行政策" })).toBeVisible(); +}); + +// The custom endpoint is the demo's only typed input, and its whole point is +// that the browser applies the same rules as oneagent/providers.py. A field that +// accepted anything would teach visitors an endpoint works when the app rejects it. +test("activation demo validates a custom endpoint the way the app does", async ({ page }) => { + const fetches: string[] = []; + page.on("request", (request) => { + if (["fetch", "xhr"].includes(request.resourceType())) fetches.push(request.url()); + }); + await page.goto("/"); + const console = page.locator("#activation-console"); + await page.getByRole("button", { name: "开始激活演示" }).click(); + await console.getByRole("button", { name: /Claude Code/ }).click(); + await console.getByRole("button", { name: /配置模型服务/ }).click(); + await console.getByRole("button", { name: /自定义端点/ }).click(); + + const url = console.getByLabel("Base URL"); + const verify = console.getByRole("button", { name: "验证示例连接" }); + await expect(url).toBeVisible(); + await expect(verify).toBeDisabled(); + + for (const [value, message] of [ + ["ftp://api.example.com", "需要以 http:// 或 https:// 开头。"], + ["https://user:secret@api.example.com", "请从地址中去掉用户名或密码。"], + ]) { + await url.fill(value); + await expect(console.locator("[data-custom-hint]")).toHaveText(message); + await expect(url).toHaveAttribute("aria-invalid", "true"); + await expect(verify, `${value} must not be verifiable`).toBeDisabled(); + } + + await url.fill("https://api.example.com/openai"); + await expect(console.locator("[data-custom-hint]")).toHaveText("端点可用"); + await expect(verify).toBeEnabled(); + await verify.click(); + + // A custom endpoint publishes no catalog, so the demo shows the app's real + // recovery — type the id — rather than inventing a discovered list. + await expect(console).toHaveAttribute("data-phase", "model"); + await expect(console.locator("[data-model-grid]")).toBeHidden(); + await console.getByLabel("模型 ID").fill("deepseek/deepseek-v3"); + await console.getByRole("button", { name: "确认激活" }).click(); + await expect(console).toHaveAttribute("data-phase", "ready"); + await expect(console.locator("[data-result-provider]")).toHaveText("api.example.com"); + expect(fetches, "typing an endpoint must not make the page call it").toEqual([]); +}); + +// Reusing an existing account is a real branch in the product's ConfigModePage, +// and it exists precisely because no new credential is introduced — so the demo +// must not walk it through provider and model steps it genuinely skips. +test("activation demo skips provider and model for an existing account", async ({ page }) => { + await page.goto("/"); + const console = page.locator("#activation-console"); + await page.getByRole("button", { name: "开始激活演示" }).click(); + await console.getByRole("button", { name: /Claude Code/ }).click(); + await expect(console).toHaveAttribute("data-phase", "mode"); + await console.getByRole("button", { name: /使用已有账号或配置/ }).click(); + + await expect(console).toHaveAttribute("data-phase", "ready"); + await expect(console.getByRole("heading", { name: "保留现有账号" })).toBeVisible(); + await expect(console.locator(".activation-steps li.is-skipped")).toHaveCount(2); + await expect(console.locator("[data-result-provider]")).toHaveText("已跳过"); + await expect(console.locator("[data-log]")).toContainText("Provider 与模型步骤已跳过"); +}); + +test("activation demo keeps a complete event history under reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + const console = page.locator("#activation-console"); + /* Advancing the interface on its own is motion, so reduced motion means the + console waits to be asked. That is also what keeps the rest of this test + valid: nothing has moved off idle before the first click. */ + await console.scrollIntoViewIfNeeded(); + await expect(console).toHaveAttribute("data-phase", "idle"); + await expect(console).not.toHaveAttribute("data-autoplaying", "true"); + await page.getByRole("button", { name: "开始激活演示" }).click(); + await expect(console).toHaveAttribute("data-phase", "agent"); + await expect(console.locator("[data-log] li")).toHaveCount(2); + await console.getByRole("button", { name: /Claude Code/ }).click(); + await console.getByRole("button", { name: /配置模型服务/ }).click(); + await console.getByRole("button", { name: /PPIO/ }).click(); + await console.getByRole("button", { name: "验证示例连接" }).click(); + await expect(console).toHaveAttribute("data-phase", "model"); + await console.getByRole("button", { name: "deepseek/deepseek-v3" }).click(); + await console.getByRole("button", { name: "确认激活" }).click(); + await expect(console).toHaveAttribute("data-phase", "ready"); + await expect(console.locator("[data-log]")).toContainText("示例协议验证完成"); + const animations = await console.evaluate((element) => + element.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length, + ); + expect(animations).toBe(0); +}); + +/* The landing page has to show what activation looks like to someone who clicks + nothing at all — that is the whole reason autoplay exists. */ +test("activation demo plays itself through to Ready without a click", async ({ page }) => { + const fetches: string[] = []; + page.on("request", (request) => { + if (["fetch", "xhr"].includes(request.resourceType())) fetches.push(request.url()); + }); + await page.goto("/"); + const console = page.locator("#activation-console"); + await console.scrollIntoViewIfNeeded(); + + await expect(console).toHaveAttribute("data-phase", "ready"); + await expect(console.getByRole("heading", { name: "示例环境已 Ready" })).toBeVisible(); + // Reaching the end clears the flag, so the visitor is in charge from here. + await expect(console).not.toHaveAttribute("data-autoplaying", "true"); + await expect(console.locator("[data-log]")).toContainText("示例环境可进入 Ready"); + expect(fetches, "an unattended demo must not call anything either").toEqual([]); }); -test("download center links only to GitHub Releases", async ({ page }) => { +// Autoplay is a demonstration, not a ride: the moment someone reaches for the +// console it belongs to them, and it must not resume behind their back. +test("interacting during autoplay stops it where it stands", async ({ page }) => { + await page.goto("/"); + const console = page.locator("#activation-console"); + await console.scrollIntoViewIfNeeded(); + await expect(console).toHaveAttribute("data-autoplaying", "true"); + + /* Waits for a step that is a decision point. Taking over during `scanning` + would still land on `agent`, because the scan's own timer resolves it the + same way a clicked run does — leaving a spinner up forever would be the + worse behaviour. `agent` is the first step where the demo is genuinely + waiting on a choice, so it is where "stopped" is observable. */ + await expect(console).toHaveAttribute("data-phase", "agent"); + await console.locator("[data-log]").click(); + await expect(console).not.toHaveAttribute("data-autoplaying", "true"); + await page.waitForTimeout(2500); + expect(await console.getAttribute("data-phase"), "autoplay resumed after takeover").toBe("agent"); + + // Taken over, not broken: the trigger still replays from the top. + await page.getByRole("button", { name: "开始激活演示" }).click(); + await expect(console).toHaveAttribute("data-phase", "agent"); + await expect(console).not.toHaveAttribute("data-autoplaying", "true"); +}); + +/* The failure this guards is specific and easy to reintroduce: every step calls + focusPanel, and if autoplay keeps doing that it steals focus from whatever the + visitor is reading or tabbing through — on a page they never interacted with. */ +test("autoplay never takes keyboard focus", async ({ page }) => { + await page.goto("/"); + const console = page.locator("#activation-console"); + await console.scrollIntoViewIfNeeded(); + + const insideConsole = () => + page.evaluate(() => Boolean(document.querySelector("#activation-console")?.contains(document.activeElement))); + for (let sample = 0; sample < 12; sample += 1) { + expect(await insideConsole(), "autoplay moved focus into the console").toBe(false); + await page.waitForTimeout(250); + } + await expect(console).toHaveAttribute("data-phase", "ready"); +}); + +test("Explorer restores shareable state and returns focus after the drawer closes", async ({ page }) => { + await page.goto("/explore/?agent=claude-code&provider=ppio&platform=macos&protocol=anthropic"); + const explorer = page.locator("compatibility-explorer"); + const drawer = explorer.locator("[data-explorer-drawer]"); + await expect(drawer).toBeVisible(); + await expect(drawer.getByRole("heading", { name: "Claude Code" })).toBeVisible(); + await expect(explorer.locator('[data-filter="platform"]')).toHaveValue("macos"); + await expect(explorer.locator('[data-filter="provider"]')).toHaveValue("ppio"); + await expect(explorer.locator('[data-filter="protocol"]')).toHaveValue("anthropic"); + + await drawer.getByRole("button", { name: "关闭详情" }).click(); + await expect(page).not.toHaveURL(/agent=/); + + const card = explorer.locator('[data-agent-card][data-agent-id="claude-code"]'); + await card.focus(); + await page.keyboard.press("Enter"); + await expect(drawer).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + await expect(card).toBeFocused(); +}); + +test("Explorer filters only catalog-backed combinations", async ({ page }) => { + await page.goto("/explore/"); + const explorer = page.locator("compatibility-explorer"); + await explorer.locator('[data-filter="platform"]').selectOption("windows"); + await explorer.locator('[data-filter="protocol"]').selectOption("responses"); + await explorer.locator('[data-filter="provider"]').selectOption("ppio"); + const visible = explorer.locator("[data-agent-card]:visible"); + await expect(visible).toHaveCount(1); + await expect(visible).toHaveAttribute("data-agent-id", "codex"); + await expect(page).toHaveURL(/platform=windows/); + await expect(page).toHaveURL(/protocol=responses/); + await expect(page).toHaveURL(/provider=ppio/); +}); + +/* The download page renders from whatever the GitHub Releases feed returned at + build time. With no published release the whole platform picker is replaced by + a "not published yet" notice, which is the correct page — but it means the + picker tests have nothing to drive. + * + * They skip rather than being deleted or loosened. A release exists in the repo + * this site ships from, so these assertions are the ones that catch a regression + * there; silently dropping them would mean the download flow rots unnoticed the + * next time a build does have artifacts. The skip reason names the cause so a red + * run is not mistaken for a broken picker. + */ +async function skipWithoutPublishedRelease(page: Page) { await page.goto("/downloads/"); - const releaseLink = page.getByRole("link", { name: /查看 GitHub Releases?/ }).first(); - await expect(releaseLink).toBeVisible(); - await expect(releaseLink).toHaveAttribute("href", /^https:\/\/github\.com\/MaimoryLab\/OneAgent\/releases/); + const hasPicker = await page.locator("[data-platform-picker]").count(); + test.skip(hasPicker === 0, "no release is published, so the page renders the unavailable notice"); +} + +/* The expected digest used to be read from the site's own release-index.json and + compared against the page. That artifact is gone — release data now comes from + GitHub Releases at build time — so there is no second copy to cross-check + against, and asserting the shape of what the page prints is what remains. A + 64-hex digest and a real download link are still worth gating: an empty or + truncated checksum is exactly what a reader cannot verify with. */ +test("download center recommends an available artifact but keeps manual choices", async ({ page }) => { + await skipWithoutPublishedRelease(page); + const picker = page.getByRole("group", { name: "选择平台与架构" }); + await expect(picker).toBeVisible(); + const platform = (id: string) => picker.locator(`input[type="radio"][value="${id}"]`); + await platform("windows-x64").check(); + await expect(page.getByRole("heading", { name: "这个平台尚未公开发行" })).toBeVisible(); + await platform("macos-arm64").check(); + await expect(page.getByRole("link", { name: "下载 macOS 预览版" })).toBeVisible(); + await expect(page.locator("[data-release-panel].is-active .hash-value")).toHaveText(/^[a-f0-9]{64}$/); + await expect(page.getByText("未签名、未公证", { exact: true })).toBeVisible(); +}); + +/* Security and enterprise came out of the chrome, but the pages did not go + anywhere. The security page is where the release evidence, the privacy + statement and the Stable gate are written down, and the footer is now the only + route to it, so removing the nav entry and keeping the page reachable are + asserted together — otherwise a later cleanup drops the page and the trust + claims leave with it. The #privacy and #release-evidence anchors are checked + because other documents cite them directly, not just the page. */ +test("security and enterprise leave the navigation but stay reachable", async ({ page }) => { + for (const path of ["/", "/en/"]) { + await page.goto(path); + await expect(page.locator(".site-header").getByRole("link", { name: /安全|Security/ })).toHaveCount(0); + await expect(page.locator(".site-header").getByRole("link", { name: /企业服务|Enterprise/ })).toHaveCount(0); + await expect(page.locator(".site-footer").getByRole("link", { name: /安全与隐私|Security & privacy/ })).toHaveCount(0); + await expect(page.locator(".site-footer").getByRole("link", { name: /企业服务|Enterprise/ })).toHaveCount(0); + // The footer's other two entries are the only route to them, so they stay. + await expect(page.locator(".site-footer").getByRole("link", { name: /支持与反馈|Support & feedback/ })).toBeVisible(); + await expect(page.locator(".site-footer").getByRole("link", { name: "GitHub Releases" })).toBeVisible(); + } + + for (const path of ["/security/", "/en/security/", "/enterprise/"]) { + const response = await page.goto(path); + expect(response?.status(), `${path} must stay published`).toBe(200); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + } + await page.goto("/security/"); + await expect(page.locator("#privacy")).toBeAttached(); + await expect(page.locator("#release-evidence")).toBeAttached(); +}); + +// The picker is a real radio group rather than a styled listbox, so arrow keys +// have to move the selection — that behaviour is the reason for the markup. +test("platform picker is keyboard operable", async ({ page }) => { + await skipWithoutPublishedRelease(page); + const picker = page.getByRole("group", { name: "选择平台与架构" }); + await picker.locator('input[value="macos-arm64"]').focus(); + await page.keyboard.press("ArrowDown"); + await expect(picker.locator('input[value="macos-x64"]')).toBeChecked(); + await expect(page.locator('[data-release-panel="macos-x64"]')).toHaveClass(/is-active/); + await page.keyboard.press("ArrowUp"); + await expect(picker.locator('input[value="macos-arm64"]')).toBeChecked(); + await expect(page.getByRole("link", { name: "下载 macOS 预览版" })).toBeVisible(); + // The ring belongs to the card, not the 16px dot inside it. + await expect(picker.locator('input[value="macos-arm64"]')).toHaveCSS("outline-style", "none"); }); +// The detection note is a server-rendered localised template that the client +// fills in from the DOM. Reading those templates off the wrong element leaves +// every branch as an empty string, which blanks the note instead of failing +// loudly — so both locales assert real text with no placeholder left behind. +for (const [route, expected] of [ + ["/downloads/", "已识别为"], + ["/en/downloads/", "Detected as"], +] as const) { + test(`the download page explains its platform detection in the page's language (${route})`, async ({ page }) => { + await page.goto(route); + const note = page.locator("[data-detected-note]"); + // The note lives inside the platform picker, which is absent when no release + // is published. Same reason as skipWithoutPublishedRelease above. + test.skip((await note.count()) === 0, "no release is published, so the page renders the unavailable notice"); + await expect(note).toContainText(expected); + await expect(note).not.toContainText("{"); + }); +} + test("guide-only compatibility remains distinct from managed installation", async ({ page }) => { await page.goto("/agents/cursor/"); await expect(page.getByText("按官方方式安装", { exact: true })).toBeVisible(); @@ -36,6 +396,23 @@ test("guide-only compatibility remains distinct from managed installation", asyn await expect(page.getByText("OneAgent 可管理安装", { exact: true })).toHaveCount(0); }); +/* Replaces a test that fetched the site's own release-index.json and asserted the + artifact it published. The site no longer publishes that file, so the guarantee + worth keeping is the one a reader depends on: the download page states the + channel honestly, prints a digest they can check, and sends them to the + official release rather than a mirror the site invented. */ +test("download page states the channel and links to the official release", async ({ page }) => { + await skipWithoutPublishedRelease(page); + /* Scoped to the active panel throughout. All four platform panels are in the + DOM and only CSS hides the inactive ones, so an unscoped text match resolves + against a hidden copy first and fails on visibility. */ + const active = page.locator("[data-release-panel].is-active"); + await expect(active.getByText("未签名技术预览版", { exact: true })).toBeVisible(); + await expect(active.locator(".hash-value")).toHaveText(/^[a-f0-9]{64}$/); + const download = active.getByRole("link", { name: /下载 .* 预览版/ }); + await expect(download).toHaveAttribute("href", /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//); +}); + test("serves its own stylesheet and Agent marks rather than 404ing on them", async ({ page }) => { // A base path build (SITE_URL/BASE_PATH, as the Pages job uses) emits an // absolute , and the CSP declares base-uri 'self'. Serving such a @@ -49,14 +426,38 @@ test("serves its own stylesheet and Agent marks rather than 404ing on them", asy await page.goto("/agents/", { waitUntil: "networkidle" }); expect(missing, "every asset the page asks for must exist").toEqual([]); // A stylesheet that failed to load leaves the UA default, not this palette. - await expect(page.locator("body")).toHaveCSS("background-color", "rgb(244, 243, 239)"); - await page.waitForFunction(() => [...document.images].every((image) => image.complete)); + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(236, 236, 239)"); const brokenMarks = await page.evaluate( - () => document.images.length && [...document.images].filter((image) => image.naturalWidth === 0).length, + () => document.images.length && [...document.images].filter((image) => !image.complete || image.naturalWidth === 0).length, ); expect(brokenMarks, "Agent marks must render").toBe(0); }); +test.describe("hero particles", () => { + const frameDigest = (path: string) => + `(() => { const c = document.querySelector('${path}'); const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let h = 0; for (let i = 0; i < d.length; i += 97) h = (h * 31 + d[i]) | 0; return h; })()`; + + test("animates by default", async ({ page }) => { + await page.goto("/"); + const canvas = page.locator("[data-hero-particles]"); + await expect(canvas).toHaveAttribute("aria-hidden", "true"); + const first = await page.evaluate(frameDigest("[data-hero-particles]")); + await page.waitForTimeout(400); + expect(await page.evaluate(frameDigest("[data-hero-particles]"))).not.toBe(first); + }); + + // The CSS prefers-reduced-motion block only clamps CSS animations, so the + // canvas has to opt out in script. Only a real reduced-motion context proves it. + test("paints a static frame under reduced motion", async ({ browser }) => { + const page = await browser.newPage({ reducedMotion: "reduce" }); + await page.goto("/"); + const first = await page.evaluate(frameDigest("[data-hero-particles]")); + await page.waitForTimeout(400); + expect(await page.evaluate(frameDigest("[data-hero-particles]"))).toBe(first); + await page.close(); + }); +}); + test("ships a local-only content policy and no third-party scripts", async ({ page }) => { await page.goto("/"); const policy = await page.locator('meta[http-equiv="Content-Security-Policy"]').getAttribute("content"); @@ -79,3 +480,330 @@ for (const path of criticalPages) { expect(serious, JSON.stringify(serious, null, 2)).toEqual([]); }); } + +// axe treats as an image node and gives up resolving the background +// behind it, so every hero and header text node comes back "incomplete" rather +// than pass — the particle layer hides exactly the copy most worth checking. +// Dropping the decoration first is what actually gates those colours. +test("hero text contrast is proven once the decorative canvas is removed", async ({ page }) => { + await page.goto("/"); + await page.addScriptTag({ content: axe.source }); + const result = await page.evaluate(async () => { + document.querySelector("[data-hero-particles]")?.remove(); + const axeApi = (window as typeof window & { axe: typeof axe }).axe; + return axeApi.run(document, { runOnly: ["color-contrast"] }); + }); + expect(result.violations, JSON.stringify(result.violations, null, 2)).toEqual([]); + expect(result.incomplete, JSON.stringify(result.incomplete, null, 2)).toEqual([]); +}); + +// Navigation animates via the native cross-document path, so the proof is that +// the outgoing document reports a live transition on pageswap. Asserting on the +// CSS alone would still pass if the browser declined to run it. +// Below 920px the nav collapses into a disclosure menu, and the transition is a +// document-level behaviour that does not vary by viewport — one width proves it. +test("navigating between pages runs a cross-document view transition", async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) < 920, "nav links are collapsed at this width"); + await page.goto("/"); + await page.evaluate(() => { + window.addEventListener("pageswap", (event) => { + sessionStorage.setItem("vt-ran", (event as PageSwapEvent).viewTransition ? "yes" : "no"); + }); + }); + await page.getByLabel("主导航").getByRole("link", { name: "配置", exact: true }).click(); + await expect(page).toHaveURL(/\/explore\/$/); + expect(await page.evaluate(() => sessionStorage.getItem("vt-ran"))).toBe("yes"); + // The shared chrome opts out of the crossfade by being named on both pages. + await expect(page.locator(".site-header")).toHaveCSS("view-transition-name", "site-header"); +}); + +test.describe("hero entrance", () => { + const heroParts = [".eyebrow", ".display", ".lede", ".hero-actions", ".hero-note", ".product-shot"]; + + test("staggers the hero into place on first paint", async ({ page, viewport }) => { + await page.goto("/"); + const delays = await page.evaluate(() => + document + .getAnimations() + .filter((animation) => (animation as CSSAnimation).animationName === "hero-rise") + .map((animation) => Number((animation.effect as KeyframeEffect).getTiming().delay)) + .sort((a, b) => a - b), + ); + if ((viewport?.width ?? 0) <= 680) { + // Stacked layout moves the block, not the lines — see the note in global.css. + expect(delays).toEqual([0, 180]); + } else { + expect(delays).toEqual([0, 70, 160, 230, 290, 360]); + } + }); + + // The previous attempt faded these in, which dropped the largest type on the + // site below AA for the whole animation. Auditing only the settled page would + // not have caught it, so sample while the entrance is still running. + test("keeps hero text readable while the entrance runs", async ({ page }) => { + await page.goto("/"); + await page.addScriptTag({ content: axe.source }); + const worst = await page.evaluate(async (parts) => { + document.querySelector("[data-hero-particles]")?.remove(); + for (const selector of [...parts, ".hero-copy"]) { + const element = document.querySelector(selector); + if (!element) continue; + element.style.animation = "none"; + void element.offsetHeight; + element.style.animation = ""; + } + const axeApi = (window as typeof window & { axe: typeof axe }).axe; + let violations = 0; + for (let sample = 0; sample < 4; sample += 1) { + await new Promise((resolve) => setTimeout(resolve, 90)); + const result = await axeApi.run(document, { runOnly: ["color-contrast"] }); + violations += result.violations.reduce((total, entry) => total + entry.nodes.length, 0); + } + return violations; + }, heroParts); + expect(worst, "hero copy must clear AA at every frame of the entrance").toBe(0); + }); +}); + +test.describe("english locale", () => { + // Derived from the route list rather than written out, so adding a translation + // without auditing its English page is not possible. + const englishPages = translatedRoutes.map((route) => `/en/${route}`); + + for (const path of englishPages) { + test(`has no serious accessibility violations: ${path}`, async ({ page }) => { + await page.goto(path); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await page.addScriptTag({ content: axe.source }); + const result = await page.evaluate(async () => { + document.querySelector("[data-hero-particles]")?.remove(); + const axeApi = (window as typeof window & { axe: typeof axe }).axe; + return axeApi.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] } }); + }); + const serious = result.violations.filter( + (violation) => violation.impact === "serious" || violation.impact === "critical", + ); + expect(serious, JSON.stringify(serious, null, 2)).toEqual([]); + }); + } + + test("declares reciprocal hreflang alternates with an x-default", async ({ page }) => { + for (const path of ["/", "/en/", "/explore/", "/en/explore/", "/security/", "/en/security/"]) { + await page.goto(path); + const codes = await page.evaluate(() => + [...document.querySelectorAll('link[rel="alternate"][hreflang]')].map((link) => link.getAttribute("hreflang")), + ); + expect(new Set(codes), `alternates on ${path}`).toEqual(new Set(["zh-CN", "en", "x-default"])); + } + }); + + // An untranslated route falls back to Chinese rather than 404ing, which is the + // right call — but silently swapping the reader's language mid-navigation is + // not. Every link that does it has to say so before the click. + test("marks links that fall back to Chinese, and only on English pages", async ({ page }) => { + await page.goto("/en/"); + /* Security and enterprise left the nav, so the specific links this used to + name are gone. The sweep below is the real guarantee and covers whatever + is in the nav now — including the CTA band's untranslated team-services + link, which is where the hint has to appear on this page. */ + + // Every link leaving /en/ for an untranslated route carries the hint. + const unmarked = await page.evaluate((translated) => { + return [...document.querySelectorAll("a[href^='/']")] + .filter((link) => { + const href = link.getAttribute("href") ?? ""; + if (href.startsWith("/en/") || /\.(json|txt|webmanifest)$/.test(href)) return false; + const route = href.replace(/^\//, ""); + return !translated.includes(route); + }) + .filter((link) => !link.querySelector(".lang-hint")) + .map((link) => link.getAttribute("href")); + }, translatedRoutes); + expect(unmarked, "these links change language without saying so").toEqual([]); + + // The hint is about leaving English; a Chinese reader is already there. + await page.goto("/"); + await expect(page.locator(".lang-hint")).toHaveCount(0); + }); + + // Switching language has to keep the reader on the page they were reading; + // dropping them on the home page is the usual failure here. + test("language switch stays on the equivalent page", async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) < 920, "header actions are collapsed at this width"); + await page.goto("/downloads/"); + await page.getByRole("link", { name: "切换语言" }).click(); + await expect(page).toHaveURL(/\/en\/downloads\/$/); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + + await page.getByRole("link", { name: "Change language" }).click(); + await expect(page).toHaveURL(/\/downloads\/$/); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + }); + + test("language switch preserves the Explorer route", async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) < 920, "header actions are collapsed at this width"); + await page.goto("/explore/?platform=macos"); + await page.getByRole("link", { name: "切换语言" }).click(); + await expect(page).toHaveURL(/\/en\/explore\/$/); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await page.getByRole("link", { name: "Change language" }).click(); + await expect(page).toHaveURL(/\/explore\/$/); + }); + + // Every link on an English page must resolve; an untranslated destination is + // expected to fall back to Chinese rather than 404 under /en/. + test("navigation from an english page never lands on a missing route", async ({ page }) => { + const missing: string[] = []; + page.on("response", (response) => { + if (response.status() >= 400) missing.push(`${response.status()} ${new URL(response.url()).pathname}`); + }); + await page.goto("/en/", { waitUntil: "networkidle" }); + const targets = await page.evaluate(() => + [...document.querySelectorAll("a[href]")] + .map((anchor) => anchor.href) + .filter((href) => href.startsWith(location.origin)), + ); + for (const target of new Set(targets)) { + const response = await page.request.get(target); + expect(response.status(), `${target} from /en/`).toBeLessThan(400); + } + expect(missing, "assets on /en/ must all exist").toEqual([]); + }); +}); + +test.describe("dark scheme", () => { + // The light palette needed five values darkened to clear AA; the dark one is a + // second, independent set of colours over different grounds, so it needs the + // same gate rather than an assumption that inverting is safe. + for (const path of criticalPages) { + test(`has no serious accessibility violations in the dark: ${path}`, async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto(path); + await expect(page.locator("html")).toHaveClass(/theme-dark/); + await page.addScriptTag({ content: axe.source }); + const result = await page.evaluate(async () => { + document.querySelector("[data-hero-particles]")?.remove(); + const axeApi = (window as typeof window & { axe: typeof axe }).axe; + return axeApi.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] } }); + }); + const serious = result.violations.filter( + (violation) => violation.impact === "serious" || violation.impact === "critical", + ); + expect(serious, JSON.stringify(serious, null, 2)).toEqual([]); + }); + } + + // axe does not measure image contrast, so nothing above would have caught the + // Codex and OpenCode marks going invisible: they are fill="currentColor" and an + // resolves that to black regardless of the page. + test("keeps currentColor agent marks visible without touching brand marks", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/agents/"); + const filters = await page.evaluate(() => + [...document.querySelectorAll(".agent-mark-wrap img")].map((image) => ({ + file: image.src.split("/").pop() ?? "", + monochrome: image.hasAttribute("data-monochrome"), + filter: getComputedStyle(image).filter, + })), + ); + const monochrome = filters.filter((entry) => entry.monochrome); + expect(monochrome.map((entry) => entry.file).sort()).toEqual(["codex.svg", "opencode.svg"]); + for (const entry of monochrome) expect(entry.filter, entry.file).toBe("invert(1)"); + for (const entry of filters.filter((entry) => !entry.monochrome)) { + expect(entry.filter, `${entry.file} carries its own colours`).toBe("none"); + } + + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/agents/"); + const light = await page.evaluate(() => + [...document.querySelectorAll(".agent-mark-wrap img")].map( + (image) => getComputedStyle(image).filter, + ), + ); + expect(new Set(light), "no mark is inverted in the light scheme").toEqual(new Set(["none"])); + }); + + test("remembers an explicit choice over the system preference", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/"); + await expect(page.locator("html")).not.toHaveClass(/theme-dark/); + + const toggle = page.getByRole("button", { name: "切换深色模式" }); + await toggle.click(); + await expect(page.locator("html")).toHaveClass(/theme-dark/); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + + // The inline head script is what makes the choice survive without a flash. + await page.reload(); + await expect(page.locator("html")).toHaveClass(/theme-dark/); + await expect(page.getByRole("button", { name: "切换深色模式" })).toHaveAttribute("aria-pressed", "true"); + + await page.getByRole("button", { name: "切换深色模式" }).click(); + await expect(page.locator("html")).not.toHaveClass(/theme-dark/); + await page.reload(); + await expect(page.locator("html")).not.toHaveClass(/theme-dark/); + }); + + test("paints the resolved theme before first contentful paint", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/", { waitUntil: "commit" }); + // Sampling at commit catches a theme applied late: if the class were set by + // the deferred module script, the light palette would paint first. + expect(await page.evaluate(() => document.documentElement.className)).toContain("theme-dark"); + }); +}); + +// The interactive console must share the hero copy measure and size to its own +// content at every breakpoint. A clipped wizard can visually cover the next +// section even when overflow hides the pixels. +test("the activation console sits on the same measure as the copy", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((animation) => (animation as CSSAnimation).animationName === "hero-rise") + .map((animation) => animation.finished), + ), + ); + const layout = await page.evaluate(() => { + const edges = (selector: string) => { + const rect = document.querySelector(selector)!.getBoundingClientRect(); + return { left: Math.round(rect.left), right: Math.round(rect.right) }; + }; + const panel = document.querySelector("[data-console-window]")!; + const rect = panel.getBoundingClientRect(); + return { + copy: edges(".hero-copy"), + console: edges(".product-shot"), + contentOverflow: panel.scrollHeight - Math.round(rect.height), + gapBelowConsole: Math.round( + document.querySelector(".hero + .section")!.getBoundingClientRect().top - + document.querySelector(".product-shot")!.getBoundingClientRect().bottom, + ), + }; + }); + expect(layout.console).toEqual(layout.copy); + expect(layout.contentOverflow, "the activation console must not clip its own content").toBeLessThanOrEqual(1); + expect(layout.gapBelowConsole, "the console must not butt against the next section").toBeGreaterThan(16); +}); + +// Synthetic mouse events never set :hover, so the resting-vs-hovered difference +// is only observable by moving a real pointer. +test("interactive rows and cards respond to hover", async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) < 920, "hover is not the primary input at this width"); + + await page.goto("/agents/"); + const row = page.locator(".agent-row").first(); + await expect(row).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await row.hover(); + await expect(row).toHaveCSS("background-color", "rgb(255, 255, 255)"); + await expect(row).not.toHaveCSS("box-shadow", "none"); + + await page.goto("/providers/"); + const card = page.locator(".provider-card").first(); + const restingBorder = await card.evaluate((el) => getComputedStyle(el).borderColor); + await card.hover(); + await expect(card).not.toHaveCSS("border-color", restingBorder); + await expect(card).not.toHaveCSS("transform", "none"); +}); diff --git a/site/package.json b/site/package.json index 83d4e19a..bc1fa89b 100644 --- a/site/package.json +++ b/site/package.json @@ -9,6 +9,7 @@ "test": "vitest run", "test:e2e": "playwright test", "preview": "astro preview", + "validate": "node scripts/validate-build.mjs", "test:all": "npm test && npm run build && npm run test:e2e" }, "dependencies": { diff --git a/site/playwright.config.ts b/site/playwright.config.ts index 6a5d991d..8c9a6aa2 100644 --- a/site/playwright.config.ts +++ b/site/playwright.config.ts @@ -4,12 +4,20 @@ export default defineConfig({ testDir: "./e2e", fullyParallel: true, reporter: "list", + /* Trace teardown writes its zip inside the per-test budget, and on a full + three-project parallel run that write was itself timing out — turning + passing tests into failures whose only error was "Fixture 'trace recording' + timeout during teardown". The suite is ~1.4 min without it and was 4-7 min + with it. Traces are still captured on failure; they just get their own room + to finish, and the per-test budget is no longer shared with the recorder. */ + timeout: 60_000, + expect: { timeout: 10_000 }, use: { baseURL: "http://127.0.0.1:4321", trace: "retain-on-failure", }, webServer: { - command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4321", + command: "npm run build && python3.12 -m http.server 4321 --bind 127.0.0.1 --directory dist", url: "http://127.0.0.1:4321", reuseExistingServer: process.env.CI !== "true", }, diff --git a/site/public/llms.txt b/site/public/llms.txt new file mode 100644 index 00000000..a2d9e781 --- /dev/null +++ b/site/public/llms.txt @@ -0,0 +1,38 @@ +# OneAgent + +> A trustworthy local AI development environment activator. It detects, installs +> and configures CLI coding agents to point at an OpenAI- or Anthropic-compatible +> provider of your choosing. Everything runs locally. + +Current release: 0.2.0-dev, published only as `technical-preview-unsigned`. +The build is unsigned and unnotarised, and is never described as stable. + +## Product boundary + +- Runs locally. Configuration and backups stay on the user's device. +- Bring your own key. There is no shared key and no unified model gateway. +- Model requests are never proxied through OneAgent. +- Third-party agent binaries are not redistributed; agents come from their + official sources. +- Every distribution channel serves a byte-identical build with the same + SHA-256. + +## Pages + +- /: overview, first-success path, agent compatibility +- /downloads/: per-platform artifacts with size, build date and SHA-256 +- /quickstart/: download through to a verified first agent configuration +- /agents/: which agents OneAgent can install and configure, stated separately +- /providers/: provider protocol support and commercial-relationship disclosure +- /security/: local execution, key handling, backups, release integrity +- /changelog/: shipped changes only +- /enterprise/: team enablement and environment baselines +- /release-index.json: machine-readable release index + +English translations exist for /en/, /en/downloads/ and /en/quickstart/. + +## Notes for machine readers + +Support is stated as three separate facts — managed install, managed +configuration, and protocol — because a single "supported" checkmark would +misrepresent guide-only agents. Do not collapse them. diff --git a/site/public/site.webmanifest b/site/public/site.webmanifest new file mode 100644 index 00000000..70f2b9be --- /dev/null +++ b/site/public/site.webmanifest @@ -0,0 +1,10 @@ +{ + "name": "OneAgent", + "short_name": "OneAgent", + "description": "A trustworthy local AI development environment activator.", + "start_url": "./", + "display": "browser", + "background_color": "#ececef", + "theme_color": "#ececef", + "icons": [{ "src": "favicon.svg", "type": "image/svg+xml", "sizes": "any" }] +} diff --git a/site/scripts/validate-build.mjs b/site/scripts/validate-build.mjs index 02f41991..bb76224f 100644 --- a/site/scripts/validate-build.mjs +++ b/site/scripts/validate-build.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; @@ -33,7 +34,14 @@ function localTarget(href) { } walk(rootPath); -for (const required of ["index.html", "downloads/index.html", "quickstart/index.html", "agents/index.html", "providers/index.html", "security/index.html"]) { +// The /en/ entries are listed for the same reason as the rest: a translated page +// silently dropping out of the build is otherwise invisible, since the link +// checker below only sees links that were actually emitted. +// +// release-index.json is no longer among them: the site reads release data from +// GitHub Releases at build time instead of republishing a locally generated +// index, so there is no such artifact to require. +for (const required of ["index.html", "downloads/index.html", "quickstart/index.html", "agents/index.html", "providers/index.html", "security/index.html", "explore/index.html", "en/index.html", "en/downloads/index.html", "en/quickstart/index.html", "en/explore/index.html", "en/security/index.html", "llms.txt", "site.webmanifest"]) { if (!existsSync(join(rootPath, required))) failures.push(`Missing required output: ${required}`); } @@ -51,6 +59,20 @@ for (const path of htmlFiles) { else if (!new URL(socialImage).pathname.startsWith(`${configuredBasePrefix}images/`)) failures.push(`${label} has an Open Graph image outside the base path: ${socialImage}`); if (/]+src=["']https?:\/\//i.test(text) || /]+src=["']https?:\/\//i.test(text) || /]+rel=["']stylesheet["'][^>]+href=["']https?:\/\//i.test(text)) failures.push(`${label} loads a remote script, image, or stylesheet`); if (/sk-[A-Za-z0-9_-]{20,}/.test(text)) failures.push(`${label} appears to contain an API key`); + // A page's declared language has to match the directory it was emitted into, + // and any hreflang set has to be reciprocal and carry an x-default. Neither is + // visible to the link check below, which only sees hrefs. + const declaredLang = text.match(/ code); + if (alternates.length && !alternates.includes("x-default")) { + failures.push(`${label} declares hreflang alternates without an x-default`); + } + if (alternates.length && !(alternates.includes("en") && alternates.includes("zh-CN"))) { + failures.push(`${label} declares an incomplete hreflang set: ${alternates.join(", ")}`); + } const matches = text.matchAll(/(?:href|src)="([^"]+)"/g); for (const [, href] of matches) { if (configuredBase && href.startsWith("/") && !href.startsWith(configuredBasePrefix) && !href.startsWith("//")) { @@ -61,6 +83,19 @@ for (const path of htmlFiles) { } } +/* This used to re-hash every artifact in dist/downloads/ and compare it against + * the digest the release index claimed, catching a page that printed a checksum + * the file did not actually have. + * + * That check has no subject any more. The site no longer hosts the artifacts — + * it links to GitHub Releases and prints the digest the API reported, so there + * is no local file to hash and nothing to compare a second opinion against. Be + * aware of what that costs: a wrong digest from the release feed now reaches the + * page unchallenged, where previously it could not survive the build. + * + * Restoring an equivalent gate means fetching each asset during validation and + * hashing it, which is a network round trip per artifact. + */ if (failures.length) { console.error(failures.join("\n")); process.exit(1); diff --git a/site/scripts/verify-dev-base.mjs b/site/scripts/verify-dev-base.mjs new file mode 100644 index 00000000..2ac4d0c8 --- /dev/null +++ b/site/scripts/verify-dev-base.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const host = "127.0.0.1"; +const port = 4322; +const origin = `http://${host}:${port}`; +const canonicalOrigin = "https://oneagent.example"; +const astroBin = fileURLToPath(new URL("../node_modules/astro/bin/astro.mjs", import.meta.url)); +const child = spawn(process.execPath, [astroBin, "dev", "--host", host, "--port", String(port)], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, SITE_URL: canonicalOrigin, BASE_PATH: "/" }, + stdio: ["ignore", "pipe", "pipe"], +}); + +let output = ""; +child.stdout.on("data", (chunk) => { output += chunk; }); +child.stderr.on("data", (chunk) => { output += chunk; }); + +async function waitForPage() { + let lastError; + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const response = await fetch(origin); + if (response.ok) return response.text(); + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw lastError ?? new Error("Astro dev server did not start"); +} + +try { + const html = await waitForPage(); + const base = html.match(/ must follow the active dev origin"); + assert.equal(canonical, `${canonicalOrigin}/`, "canonical must continue to use SITE_URL"); + console.log(`Verified dev base ${base} with canonical ${canonical}`); +} catch (error) { + console.error(output); + throw error; +} finally { + child.kill("SIGTERM"); + await new Promise((resolve) => { + const stop = spawn(process.execPath, [astroBin, "dev", "stop"], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, SITE_URL: canonicalOrigin, BASE_PATH: "/" }, + stdio: "ignore", + }); + stop.once("exit", resolve); + stop.once("error", resolve); + }); +} diff --git a/site/src/components/ActivationConsole.astro b/site/src/components/ActivationConsole.astro new file mode 100644 index 00000000..c9c8ad77 --- /dev/null +++ b/site/src/components/ActivationConsole.astro @@ -0,0 +1,1485 @@ +--- +import AgentMark from "./AgentMark.astro"; +import { catalog } from "../lib/catalog"; +import { bestLocaleFor, localeFromPath, localePath, switchesLanguage } from "../i18n"; +import { DEMO_CUSTOM_MODEL_HINT, demoStateFor } from "../lib/demo-environment"; +import { recommendedCombination } from "../lib/explorer"; + +interface Props { + id?: string; +} + +const { id = "activation-console" } = Astro.props; +const locale = localeFromPath(Astro.url.pathname); +const featuredAgents = catalog.agents.slice(0, 4); +const recommendation = recommendedCombination(catalog); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +const copy = locale === "en" + ? { + label: "Interactive product evidence", + sample: "Demo environment", + title: "Activation console", + subtitle: "A guided example. This page never scans your device or asks for an API key.", + idleTitle: "Ready when you are.", + idleBody: "Start from the hero action. OneAgent will walk through an example environment without touching this computer.", + scanningTitle: "Scanning the example environment", + scanningBody: "Reading a deterministic demo scenario — not your browser, files or local services.", + agentTitle: "Choose an agent", + agentBody: "Capabilities and locked versions come from the repository catalog. Machine state is explicitly illustrative.", + providerTitle: "Choose a provider", + providerBody: "Compatibility is calculated from the protocol registry. A preview gate never becomes Ready.", + verify: "Verify sample connection", + verifyingTitle: "Verifying the sample protocol", + verifyingBody: "No credential is submitted. The outcome follows the selected catalog compatibility level.", + resultLabel: "Example outcome", + reset: "Reset demo", + download: "Download OneAgent", + explore: "Open full Explorer", + security: "Release policy", + installed: "Installed", + notInstalled: "Not installed", + configured: "Configured", + needsSetup: "Needs setup", + officialGuide: "Official guide", + recommended: "Recommended path", + locked: "Locked", + noLockedVersion: "Official release", + command: "Launch", + managed: "Managed config", + guide: "Official setup", + facts: ["No device access", "No API key field", "Registry-derived compatibility"], + steps: ["Agent", "Setup", "Provider", "Model", "Confirm"], + modeTitle: "How should this agent be configured?", + modeBody: "OneAgent can point the agent at a model service, or leave an account you already use in place.", + modeProvider: "Configure a model service", + modeProviderHint: "Choose a provider or your own endpoint, then verify the protocol before anything is written.", + modeExisting: "Keep an existing account or config", + modeExistingHint: "Already signed in, or already configured? The provider and model steps are skipped.", + customName: "Custom endpoint", + customVerdict: "Your own OpenAI-compatible URL", + customLabel: "Base URL", + customPlaceholder: "https://api.example.com/openai", + customHint: "Any OpenAI-compatible endpoint. Validated the same way the app validates it.", + register: "Need a key? Open PPIO", + registerNote: "Opens ppio.com in a new tab. OneAgent has no account of its own.", + urlRequired: "Enter a base URL.", + urlScheme: "Must start with http:// or https://", + urlCredentials: "Remove the username or password from the URL.", + urlControl: "Remove control characters from the URL.", + urlOk: "Endpoint accepted", + modelTitle: "Choose a model", + modelBody: "The app reads this list from the endpoint itself. Pick one, or type an id if discovery comes back empty.", + modelManual: "Discovery empty? Enter an id", + modelManualLabel: "Model id", + confirm: "Confirm activation", + skipped: "Skipped", + /* The real window's sidebar is a workspace nav, not a progress list — the + stepper lives in the page header. These are its actual four entries. */ + navItems: ["Activate", "Overview", "Providers", "Templates"], + sidebarNote: "Configuration stays on this machine", + footerIdle: "The demo never touches this device", + chineseOnly: "in Chinese", + } + : { + label: "可操作的产品证据", + sample: "示例环境", + title: "激活控制台", + subtitle: "这是引导演示。页面不会扫描你的设备,也不会要求输入 API Key。", + idleTitle: "等待你启动演示。", + idleBody: "从首屏主按钮开始,OneAgent 将演示一次完整激活路径,但不会访问这台电脑。", + scanningTitle: "正在扫描示例环境", + scanningBody: "读取确定性的演示场景,不读取浏览器、本机文件或本地服务。", + agentTitle: "选择一个 Agent", + agentBody: "能力与锁定版本来自仓库目录;安装和配置状态明确属于示例。", + providerTitle: "选择一个 Provider", + providerBody: "兼容结果来自协议注册表;预览门禁不会被升级成 Ready。", + verify: "验证示例连接", + verifyingTitle: "正在验证示例协议", + verifyingBody: "不会提交任何凭证;结果严格跟随所选组合的兼容等级。", + resultLabel: "示例结果", + reset: "重置演示", + download: "下载 OneAgent", + explore: "打开完整配置目录", + security: "发行政策", + installed: "已安装", + notInstalled: "未安装", + configured: "已配置", + needsSetup: "待配置", + officialGuide: "官方引导", + recommended: "推荐路径", + locked: "锁定版本", + noLockedVersion: "官方发行", + command: "启动命令", + managed: "托管配置", + guide: "官方设置", + facts: ["不访问设备", "不提供 Key 输入框", "兼容结论来自注册表"], + steps: ["Agent", "配置", "Provider", "模型", "确认"], + modeTitle: "这个 Agent 怎么配置?", + modeBody: "可以由 OneAgent 指向某个模型服务,也可以保留你已经在用的账号。", + modeProvider: "配置模型服务", + modeProviderHint: "选择 Provider 或你自己的端点,写入前先验证协议。", + modeExisting: "使用已有账号或配置", + modeExistingHint: "已经登录、或已经配置好?Provider 与模型两步会跳过。", + customName: "自定义端点", + customVerdict: "你自己的 OpenAI 兼容地址", + customLabel: "Base URL", + customPlaceholder: "https://api.example.com/openai", + customHint: "任何 OpenAI 兼容端点。校验规则与应用内一致。", + register: "还没有 Key?打开 PPIO", + registerNote: "在新标签页打开 ppio.com。OneAgent 自身没有账号体系。", + urlRequired: "请填写 Base URL。", + urlScheme: "需要以 http:// 或 https:// 开头。", + urlCredentials: "请从地址中去掉用户名或密码。", + urlControl: "请去掉地址中的控制字符。", + urlOk: "端点可用", + modelTitle: "选择模型", + modelBody: "应用会从端点自身读取这个列表。选一个,或在发现结果为空时手动输入 ID。", + modelManual: "列表为空?手动输入 ID", + modelManualLabel: "模型 ID", + confirm: "确认激活", + skipped: "已跳过", + /* The real window's sidebar is a workspace nav, not a progress list — the + stepper lives in the page header. These are its actual four entries. */ + navItems: ["激活环境", "环境总览", "Provider", "配置模板"], + sidebarNote: "配置只保存在本机", + footerIdle: "演示不会访问这台设备", + chineseOnly: "仅中文", + }; +--- +
+ +
+
+
+ {copy.label} + {copy.title} +
+ {copy.sample} +
+ +
+ + +
+
+
+ {copy.sample} +

{copy.title}

+

{copy.subtitle}

+
+ Idle +
+
    + {copy.steps.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+ +
+ +
+

{copy.idleTitle}

+

{copy.idleBody}

+
+
+ + + + + + + + + + + + + + + +
+ {locale === "en" ? "EVENT LOG" : "事件日志"} +
    +
  1. {locale === "en" ? "Demo idle. No device access requested." : "演示待机,未请求设备访问。"}
  2. +
+
+
+ + {/* The real window keeps its primary action in a fixed footer bar rather + than inside the scrolling body, so the next step is always in the same + place. The note on the left is where the app puts its own context. */} +
+ + +
+
+ +
+ {copy.facts.map((fact) => {fact})} +
+
+
+
+ + + + diff --git a/site/src/components/AgentMark.astro b/site/src/components/AgentMark.astro index f021765b..e59ab40f 100644 --- a/site/src/components/AgentMark.astro +++ b/site/src/components/AgentMark.astro @@ -15,9 +15,14 @@ const extensions: Record = { "kilo-cli": "svg", aider: "png", }; +/* These two marks ship as fill="currentColor". Loaded through the keyword + has no page to inherit from and falls back to black, which disappears on a + dark ground — so they get inverted there while the marks carrying their own + brand colours are left alone. */ +const monochrome = new Set(["codex", "opencode"]); const extension = extensions[id]; const imageSource = extension ? `${import.meta.env.BASE_URL}images/agents/${id}.${extension}` : null; --- diff --git a/site/src/components/CompatibilityExplorer.astro b/site/src/components/CompatibilityExplorer.astro new file mode 100644 index 00000000..0a58a6ad --- /dev/null +++ b/site/src/components/CompatibilityExplorer.astro @@ -0,0 +1,609 @@ +--- +import AgentMark from "./AgentMark.astro"; +import { catalog } from "../lib/catalog"; +import { localeFromPath, localePath } from "../i18n"; +import { useCatalogLabels } from "../i18n/catalog"; +import { protocolLabels } from "../lib/content"; +import { demoStateFor } from "../lib/demo-environment"; +import { compatibilityFor, type Compatibility } from "../lib/explorer"; + +const locale = localeFromPath(Astro.url.pathname); +const { agentDescriptions, groupLabels, agentFallbackDescription } = useCatalogLabels(locale); +const technicalHref = (agentId: string) => localePath("zh-CN", `agents/${agentId}/`); +const providerHref = (providerId: string) => localePath("zh-CN", `providers/${providerId}/`); +const copy = locale === "en" + ? { + filters: "Explorer filters", + platform: "Platform", + setup: "Install path", + config: "Configuration", + protocol: "Protocol", + provider: "Provider", + demo: "Demo state", + all: "All", + managedInstall: "Managed install", + officialGuide: "Official guide", + managedConfig: "Managed config", + officialConfig: "Official config", + ready: "Ready", + attention: "Needs attention", + notInstalled: "Not installed", + clear: "Clear filters", + results: "agents shown", + noResults: "No agent matches this combination.", + noResultsBody: "Clear a filter or choose a provider that implements the selected protocol.", + sample: "Demo state", + locked: "Locked", + officialRelease: "Official release", + open: "Open details for", + close: "Close details", + overview: "Environment example", + installState: "Example install state", + configState: "Example config state", + lockedVersion: "Catalog version", + configPath: "Configuration path", + command: "Launch command", + backup: "Example backup", + backupYes: "Available", + backupNo: "None yet", + installed: "Installed", + configured: "Configured", + needsSetup: "Needs setup", + providerCompatibility: "Provider compatibility", + capability: "Activation boundary", + managedBoundary: "OneAgent can manage this agent's configuration and backs up an existing file before a managed write.", + guideBoundary: "This agent stays in its official install, sign-in or extension flow. OneAgent does not write private configuration for it.", + technical: "Technical reference (Chinese)", + source: "Upstream source", + providerReference: "Provider reference (Chinese)", + illustrative: "Illustrative machine state; catalog capabilities are real.", + compatibility: { + verified: "Verified", + supported: "Implementation supported", + "preview-gate": "Release candidate required", + unsupported: "Unsupported", + } as Record, + } + : { + filters: "配置筛选", + platform: "平台", + setup: "安装路径", + config: "配置方式", + protocol: "协议", + provider: "Provider", + demo: "示例状态", + all: "全部", + managedInstall: "托管安装", + officialGuide: "官方引导", + managedConfig: "托管配置", + officialConfig: "官方配置", + ready: "Ready", + attention: "需处理", + notInstalled: "未安装", + clear: "清除筛选", + results: "个 Agent", + noResults: "没有 Agent 匹配这组条件。", + noResultsBody: "清除一个筛选,或选择实现了目标协议的 Provider。", + sample: "示例状态", + locked: "锁定", + officialRelease: "官方发行", + open: "打开详情:", + close: "关闭详情", + overview: "环境示例", + installState: "示例安装状态", + configState: "示例配置状态", + lockedVersion: "目录版本", + configPath: "配置位置", + command: "启动命令", + backup: "示例备份", + backupYes: "已有", + backupNo: "暂无", + installed: "已安装", + configured: "已配置", + needsSetup: "待配置", + providerCompatibility: "Provider 兼容性", + capability: "激活边界", + managedBoundary: "OneAgent 可以管理这个 Agent 的配置,并会在托管写入前备份已有文件。", + guideBoundary: "这个 Agent 保留官方安装、登录或扩展内流程;OneAgent 不写入它的私有配置。", + technical: "技术详情", + source: "上游源码", + providerReference: "Provider 详情", + illustrative: "机器状态属于示例;目录能力来自真实数据。", + compatibility: { + verified: "已验证", + supported: "实现支持", + "preview-gate": "需发布候选验证", + unsupported: "不支持", + } as Record, + }; + +const demoLabel = (status: string) => status === "ready" + ? copy.ready + : status === "attention" + ? copy.attention + : status === "guide-only" + ? copy.officialGuide + : copy.notInstalled; +--- + +
+ + + + + + + +
+ +
+ {catalog.agents.length} + {copy.results} + + {copy.illustrative} +
+ +
+ {catalog.agents.map((agent) => { + const demo = demoStateFor(agent); + const providers = catalog.providers + .filter((provider) => compatibilityFor(agent, provider) !== "unsupported") + .map((provider) => provider.id); + return ( + + ); + })} +
+ + + + + + +
+ +
+
+
+
+ + + + diff --git a/site/src/components/DownloadSelector.astro b/site/src/components/DownloadSelector.astro index 8e07bea3..52935bfe 100644 --- a/site/src/components/DownloadSelector.astro +++ b/site/src/components/DownloadSelector.astro @@ -1,83 +1,203 @@ --- -import { - formatBytes, - formatDate, - getLatestRelease, - releaseTargets, - releasesPageUrl, -} from "../lib/downloads"; +import { bestLocaleFor, localeFromPath, localePath } from "../i18n"; +import { formatBytes, formatDate } from "../lib/downloads"; +import { binaryArtifact, getPreviewChannel, primaryDownload } from "../lib/release-channel"; -const release = await getLatestRelease(); -const targets = release ? releaseTargets(release) : []; -const defaultTarget = targets[0] ?? null; +/* Everything below reads off a channel: the platform picker, the checksum, the + size. With no release published there is nothing to pick between, so the + component renders a single "not published yet" notice instead of four empty + panels that look like a broken page. */ +const channel = await getPreviewChannel(); +const defaultTarget = channel?.targets.find((target) => target.status === "available") ?? channel?.targets[0] ?? null; +const locale = localeFromPath(Astro.url.pathname); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +const c = locale === "en" + ? { + legend: "Choose platform and architecture", + available: "Available", + pending: "Verifying", + detected: "Showing the platform that is currently downloadable; you can switch at any time.", + notStableTitle: "This is not stable", + notStableBody: "The current package is unsigned and unnotarised. OneAgent does not document ways around your operating system's security policy.", + verified: "Verified and downloadable", + version: "Version", + channelLabel: "Release channel", + size: "File size", + built: "Build date", + signing: "Signing status", + unsigned: "Unsigned, unnotarised", + verification: "Verification", + verificationValue: "Native build + cleanroom passed", + download: (platform: string) => `Download the ${platform} preview`, + quickstart: "Read the quickstart", + sameBuildTitle: "You get the verified official build", + sameBuildBody: "This site, GitHub Releases and any mirror must serve the identical SHA-256. Repackaging is not permitted.", + checksumTitle: "macOS verification command", + copy: "Copy", + copied: "Copied", + cleanroomNote: "The cleanroom evidence applies to this file's exact SHA-256; any change to the package requires re-verification.", + unavailableTitle: "This platform has no public release yet", + unavailableBody: "The build workflow is in place, but until the native build, cleanroom evidence and release metadata are all present there is no empty download button and no CI artifact described as generally available.", + progress: "See release progress", + channelValue: "Unsigned technical preview", + fallbackPlatform: "current platform", + detectExact: "Detected as {full}. You can still switch manually.", + detectArchOnly: "Detected as {name} {arch}, but there is no exact build for that architecture here — do not run a build for a mismatched architecture.", + detectPlatformOnly: "Detected as {name}, but the browser cannot reliably tell the chip architecture. Confirm the option before downloading.", + } + : { + legend: "选择平台与架构", + available: "可下载", + pending: "验证中", + detected: "已优先显示当前可下载的平台;你可以随时手动切换。", + notStableTitle: "这不是 Stable", + notStableBody: "当前包未签名、未公证。OneAgent 不提供绕过操作系统安全策略的说明。", + verified: "已验证可下载", + version: "版本", + channelLabel: "发行渠道", + size: "文件大小", + built: "构建日期", + signing: "签名状态", + unsigned: "未签名、未公证", + verification: "验证状态", + verificationValue: "原生构建 + cleanroom 通过", + download: (platform: string) => `下载 ${platform} 预览版`, + quickstart: "查看快速开始", + sameBuildTitle: "下载即得到被校验的官方同包产物", + sameBuildBody: "任何官网、GitHub Release 或镜像渠道都必须保持相同 SHA-256,禁止二次打包。", + checksumTitle: "macOS 校验命令", + copy: "复制", + copied: "已复制", + cleanroomNote: "cleanroom 证据只对应此文件的精确 SHA-256;包体变化后必须重新验证。", + unavailableTitle: "这个平台尚未公开发行", + unavailableBody: "构建工作流已经保留,但在原生构建、cleanroom 证据和发行元数据齐备前,不提供空下载按钮,也不把 CI 产物描述为正式可用。", + progress: "查看发行进度", + channelValue: "未签名技术预览版", + fallbackPlatform: "当前平台", + detectExact: "已识别为 {full};你仍可手动切换。", + detectArchOnly: "已识别为 {name} {arch},当前目录没有完全匹配的构建;请勿运行架构不匹配的包。", + detectPlatformOnly: "已识别为 {name},但浏览器无法可靠判断芯片架构;请确认选项后下载。", + }; --- +{!channel || !defaultTarget ? ( +
+ ! +
+ {c.unavailableTitle} +

{c.unavailableBody}

+
+
+) : (
- {release && defaultTarget ? ( - <> - - + + +
+ {channel.targets.map((target) => { + const artifact = binaryArtifact(target); + const download = artifact ? primaryDownload(artifact) : null; + return ( +
+
+
+

{target.platformLabel}

+

{target.archLabel}

- {target.sha256 ? ( + {target.status === "available" ? {c.verified} : {c.pending}} +
+ + {target.status === "available" && artifact && download ? ( + <> +
+
{c.version}
{channel.version}
+
{c.channelLabel}
{c.channelValue}
+
{c.size}
{formatBytes(artifact.bytes)}
+
{c.built}
{formatDate(target.built_at)}
+
{c.signing}
{c.unsigned}
+
{c.verification}
{c.verificationValue}
+
+ +
+ i +
+ {c.sameBuildTitle} +

{c.sameBuildBody}

+
+
-

GitHub SHA-256

+

SHA-256

- {target.sha256} - + {/* Both this and the command below scroll horizontally on narrow + screens, so they need to be reachable without a pointer + (WCAG 2.1.1). The label names which value has focus. */} + {artifact.sha256} +
- ) : target.checksumUrl ? ( -
i
校验和由 Release 提供

查看 SHA256SUMS

- ) : null} -
- ))} -
- - ) : ( -
-

尚无已发布版本

-

下载页只展示 GitHub Release 中实际存在的版本和资产,不使用开发配置或本地构建结果补位。

- 查看 GitHub Releases -
- )} +
+

{c.checksumTitle}

+
shasum -a 256 {artifact.file}
+
+

{c.cleanroomNote}

+ + ) : ( +
+

{c.unavailableTitle}

+

{c.unavailableBody}

+ {c.progress} +
+ )} + + ); + })} +
+)} diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index f3e0d409..f29178b4 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -1,39 +1,69 @@ --- import BrandMark from "./BrandMark.astro"; +import { bestLocaleFor, localeFromPath, localePath, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; import { releasesPageUrl } from "../lib/downloads"; + const year = new Date().getUTCFullYear(); +const locale = localeFromPath(Astro.url.pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* Column links, grouped as rendered. A `path` is resolved per locale and gets a + hint when the target has no translation; an `href` is an absolute artifact URL + that is the same in every locale. */ +type FooterLink = { path: string; label: string } | { href: string; label: string }; +const columns: { heading: string; links: FooterLink[] }[] = [ + { + heading: t("footer.start"), + links: [ + { path: "downloads/", label: t("footer.downloadCenter") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "changelog/", label: t("nav.changelog") }, + ], + }, + { + heading: t("footer.capability"), + links: [ + { path: "explore/", label: t("nav.explorer") }, + { path: "agents/", label: t("footer.agentCatalog") }, + { path: "providers/", label: t("footer.providerCatalog") }, + ], + }, + { + heading: t("footer.trust"), + links: [ + { path: "support/", label: t("footer.supportFeedback") }, + // A published artifact rather than a page: same URL in every locale, so it + // is exempt from the locale resolution and the hint. + { href: releasesPageUrl, label: t("footer.releaseIndex") }, + ], + }, +]; --- diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 1d5a5f31..3d5b162c 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -1,38 +1,56 @@ --- import BrandMark from "./BrandMark.astro"; +import ThemeToggle from "./ThemeToggle.astro"; +import LocaleSwitch from "./LocaleSwitch.astro"; +import { bestLocaleFor, localeFromPath, localePath, routeWithoutLocale, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; const pathname = Astro.url.pathname; -const basePath = import.meta.env.BASE_URL; -const withBase = (path: string) => `${basePath}${path.replace(/^\/+/, "")}`; +const locale = localeFromPath(pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* The security and enterprise pages stay published and linked from the footer, + the release index and the demo's preview-gate result — they are just not + worth a top-level nav slot. */ const nav = [ - { path: "downloads/", label: "下载" }, - { path: "quickstart/", label: "快速开始" }, - { path: "agents/", label: "Agent" }, - { path: "providers/", label: "Provider" }, - { path: "security/", label: "安全" }, + { path: "downloads/", label: t("nav.downloads") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "explore/", label: t("nav.explorer") }, ]; -const active = (path: string) => pathname.includes(`/${path}`); +// Compared on the locale-stripped route so /en/agents/ marks the same item as +// /agents/ rather than matching on a substring of the full path. +const route = routeWithoutLocale(pathname); +const active = (path: string) => + route === path || + (path === "explore/" && (route === "agents/" || route.startsWith("agents/") || route === "providers/" || route.startsWith("providers/"))); ---