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,