diff --git a/docs/__tests__/components/FeedbackWidget.test.tsx b/docs/__tests__/components/FeedbackWidget.test.tsx
new file mode 100644
index 00000000..ff781b15
--- /dev/null
+++ b/docs/__tests__/components/FeedbackWidget.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FeedbackWidget } from "../../components/FeedbackWidget";
+
+describe("FeedbackWidget", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("posts helpful feedback and shows a thank-you message", async () => {
+ const user = userEvent.setup();
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 202,
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "役に立った" }));
+
+ await waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith("/api/docs/feedback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ path: "/docs/getting-started",
+ helpful: true,
+ version: "latest",
+ }),
+ });
+ });
+
+ expect(
+ screen.getByText("ごフィードバックありがとうございます。"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not show error UI when fetch fails", async () => {
+ const user = userEvent.setup();
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: "役に立った" }));
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalled();
+ });
+
+ expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/エラー/i)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "役に立った" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/docs/__tests__/components/UntranslatedBanner.test.tsx b/docs/__tests__/components/UntranslatedBanner.test.tsx
new file mode 100644
index 00000000..e10ef40d
--- /dev/null
+++ b/docs/__tests__/components/UntranslatedBanner.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { UntranslatedBanner } from "../../components/UntranslatedBanner";
+
+describe("UntranslatedBanner", () => {
+ it("shows banner for en locale on ja-only pages and hides it when dismissed", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ expect(
+ screen.getByText("このページはまだ日本語のみ提供されています。"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "閉じる" }));
+
+ expect(
+ screen.queryByText("このページはまだ日本語のみ提供されています。"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/docs/__tests__/pages/not-found.test.ts b/docs/__tests__/pages/not-found.test.ts
new file mode 100644
index 00000000..a4a7d812
--- /dev/null
+++ b/docs/__tests__/pages/not-found.test.ts
@@ -0,0 +1,17 @@
+// @vitest-environment node
+
+import { readFileSync } from "fs";
+import { join } from "path";
+import { describe, expect, it } from "vitest";
+
+describe("404 page", () => {
+ it("includes title frontmatter and a link back to the docs home", () => {
+ const content = readFileSync(
+ join(__dirname, "../../pages/404.mdx"),
+ "utf-8",
+ );
+
+ expect(content).toMatch(/title:\s*ページが見つかりません/);
+ expect(content).toMatch(/\]\(\/docs[^)]*\)/);
+ });
+});
diff --git a/docs/components/FeedbackWidget.tsx b/docs/components/FeedbackWidget.tsx
new file mode 100644
index 00000000..fbd5141e
--- /dev/null
+++ b/docs/components/FeedbackWidget.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+
+type FeedbackWidgetProps = {
+ path: string;
+ version?: string;
+};
+
+export function FeedbackWidget({
+ path,
+ version = "latest",
+}: FeedbackWidgetProps) {
+ const [submitted, setSubmitted] = useState(false);
+ const [pending, setPending] = useState(false);
+
+ async function submitFeedback(helpful: boolean) {
+ if (pending || submitted) {
+ return;
+ }
+
+ setPending(true);
+
+ try {
+ const response = await fetch("/api/docs/feedback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, helpful, version }),
+ });
+
+ if (response.ok) {
+ setSubmitted(true);
+ }
+ } catch {
+ // Fail silently when the feedback endpoint is unavailable.
+ } finally {
+ setPending(false);
+ }
+ }
+
+ if (submitted) {
+ return (
+
+
+ ごフィードバックありがとうございます。
+
+
+ );
+ }
+
+ return (
+
+ このページは役に立ちましたか?
+
+
+
+ );
+}
diff --git a/docs/components/UntranslatedBanner.tsx b/docs/components/UntranslatedBanner.tsx
new file mode 100644
index 00000000..ce2fa502
--- /dev/null
+++ b/docs/components/UntranslatedBanner.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useState } from "react";
+
+type UntranslatedBannerProps = {
+ locale?: string;
+ pageLang?: string;
+};
+
+export function UntranslatedBanner({
+ locale,
+ pageLang,
+}: UntranslatedBannerProps) {
+ const [dismissed, setDismissed] = useState(false);
+
+ if (dismissed || locale !== "en" || pageLang !== "ja") {
+ return null;
+ }
+
+ return (
+
+ このページはまだ日本語のみ提供されています。
+
+
+ );
+}
diff --git a/docs/pages/404.mdx b/docs/pages/404.mdx
new file mode 100644
index 00000000..a8f9a52f
--- /dev/null
+++ b/docs/pages/404.mdx
@@ -0,0 +1,11 @@
+---
+title: ページが見つかりません
+---
+
+# ページが見つかりません
+
+お探しのページは存在しないか、移動した可能性があります。
+
+[ドキュメントのトップページへ戻る](/docs)
+
+サイト内検索を使って、目的のページを探すこともできます。
diff --git a/docs/theme.config.tsx b/docs/theme.config.tsx
index 1e1708f8..6abd08d7 100644
--- a/docs/theme.config.tsx
+++ b/docs/theme.config.tsx
@@ -1,10 +1,35 @@
import type { DocsThemeConfig } from 'nextra-theme-docs';
+import { useConfig } from 'nextra-theme-docs';
+import { usePathname } from 'next/navigation';
+import { useRouter } from 'next/router';
+import type { ReactNode } from 'react';
import Search from './components/Search';
import { DocHeader } from './components/DocHeader';
import { EditPageLink } from './components/EditPageLink';
+import { FeedbackWidget } from './components/FeedbackWidget';
+import { UntranslatedBanner } from './components/UntranslatedBanner';
+
+function DocsMain({ children }: { children: ReactNode }) {
+ const { frontMatter } = useConfig();
+ const pathname = usePathname();
+ const { locale } = useRouter();
+ const pageLang =
+ typeof frontMatter.lang === 'string' ? frontMatter.lang : undefined;
+ const version =
+ typeof frontMatter.version === 'string' ? frontMatter.version : 'latest';
+
+ return (
+ <>
+
+ {children}
+
+ >
+ );
+}
const config: DocsThemeConfig = {
logo: open-git,
+ main: DocsMain,
docsRepositoryBase: 'https://github.com/Corevice/open-git/blob/main/docs',
search: {
component: Search,