From 9a1f8f679f68b0c75c4ab568660f4832c05409d0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 14 May 2026 14:18:13 +0900 Subject: [PATCH] fix(web): route installation links to docs section --- web/components/docs/docs-shell.tsx | 60 +++++++++++++++++++++++---- web/e2e/example.spec.ts | 55 +++++++++++++++++++++--- web/middleware.ts | 46 +++++++++++++++++++- web/playwright.config.ts | 2 +- web/scripts/generate-docs-content.mjs | 46 +++++++++++++++----- 5 files changed, 181 insertions(+), 28 deletions(-) diff --git a/web/components/docs/docs-shell.tsx b/web/components/docs/docs-shell.tsx index 843e7017a..5c8b4ae7c 100644 --- a/web/components/docs/docs-shell.tsx +++ b/web/components/docs/docs-shell.tsx @@ -29,10 +29,43 @@ export function DocsShell({ const activeSectionRef = React.useRef("overview") + const findHashSectionId = React.useCallback((hash: string): DocSectionId | null => { + const id = hash.replace(/^#/, "") + return DOC_SECTION_IDS.find((sectionId) => sectionId === id) ?? null + }, []) + + const scrollToSection = React.useCallback((id: DocSectionId, updateHash = true) => { + const element = document.getElementById(id) + if (!element) return + + window.scrollTo({ top: element.offsetTop - 80, behavior: "auto" }) + if (updateHash && window.location.hash !== `#${id}`) { + window.history.pushState(null, "", `#${id}`) + } + + activeSectionRef.current = id + setActiveSection(id) + setIsMobileMenuOpen(false) + }, []) + React.useEffect(() => { activeSectionRef.current = activeSection }, [activeSection]) + React.useEffect(() => { + const scrollToHashSection = () => { + const sectionId = findHashSectionId(window.location.hash) + if (!sectionId) return + + window.requestAnimationFrame(() => scrollToSection(sectionId, false)) + } + + scrollToHashSection() + window.addEventListener("hashchange", scrollToHashSection) + + return () => window.removeEventListener("hashchange", scrollToHashSection) + }, [findHashSectionId, scrollToSection]) + const filteredSections = React.useMemo(() => { const query = searchQuery.trim().toLowerCase() if (!query) return sections @@ -77,14 +110,22 @@ export function DocsShell({ } }, []) - const scrollToSection = (id: DocSectionId) => { - const element = document.getElementById(id) - if (!element) return + const handleDocsClick = (event: React.MouseEvent) => { + if (!(event.target instanceof Element)) return - window.scrollTo({ top: element.offsetTop - 80, behavior: "auto" }) - activeSectionRef.current = id - setActiveSection(id) - setIsMobileMenuOpen(false) + const anchor = event.target.closest("a[href]") + if (!(anchor instanceof HTMLAnchorElement)) return + + const sectionId = findHashSectionId(anchor.hash) + if (!sectionId) return + + const href = anchor.getAttribute("href") + const isSamePath = + anchor.origin === window.location.origin && anchor.pathname === window.location.pathname + if (!href?.startsWith("#") && !isSamePath) return + + event.preventDefault() + scrollToSection(sectionId) } return ( @@ -141,7 +182,10 @@ export function DocsShell({ -
+
{children}
diff --git a/web/e2e/example.spec.ts b/web/e2e/example.spec.ts index ff1d1fd16..f4a6fa822 100644 --- a/web/e2e/example.spec.ts +++ b/web/e2e/example.spec.ts @@ -102,7 +102,17 @@ test.describe("Docs Page", () => { // when const heading = page.getByRole("heading", { name: "Configuration Reference" }) - const sidebarItems = ["Overview", "Quick Start", "Agents", "Categories", "Skills", "Hooks"] + const sidebarItems = [ + "Overview", + "Installation", + "Orchestration", + "Agent / Model Matching", + "Team Mode", + "CLI Reference", + "Configuration", + "Features", + "Manifesto", + ] // then await expect(heading).toBeVisible() @@ -120,20 +130,53 @@ test.describe("Docs Page", () => { await searchInput.fill("agent") // then - await expect(page.getByRole("button", { name: "Agents" })).toBeVisible() + await expect(page.getByRole("button", { name: "Agent / Model Matching" })).toBeVisible() }) test("sidebar navigation scrolls instantly and highlights active section", async ({ page }) => { // given await page.goto("/docs") - const quickStartButton = page.getByRole("button", { name: "Quick Start" }) + const installationButton = page.getByRole("button", { name: "Installation" }) // when - await quickStartButton.click() + await installationButton.click() // then - await expect(page.locator("#quick-start")).toBeInViewport() - await expect(quickStartButton).toHaveClass(/bg-primary\/10/) + await expect(page).toHaveURL(/\/docs#installation$/) + await expect(page.locator("#installation")).toBeInViewport() + await expect(installationButton).toHaveClass(/bg-primary\/10/) + }) + + test("hash navigation opens the installation section", async ({ page }) => { + // given / when + await page.goto("/docs#installation") + + // then + await expect(page.locator("#installation")).toBeInViewport() + await expect(page.getByRole("button", { name: "Installation" })).toHaveClass(/bg-primary\/10/) + }) + + test("internal docs links point to section hashes", async ({ page }) => { + // given + await page.goto("/docs") + const installationGuideLink = page.getByRole("link", { name: "Installation Guide" }).first() + + // when + await installationGuideLink.click() + + // then + await expect(installationGuideLink).toHaveAttribute("href", "#installation") + await expect(page).toHaveURL(/\/docs#installation$/) + await expect(page.locator("#installation")).toBeInViewport() + }) + + test("legacy Korean installation URL redirects to the docs section", async ({ page }) => { + // given / when + await page.goto("/ko/installation.md") + + // then + await expect(page).toHaveURL(/\/ko\/docs#installation$/) + await expect(page.locator("#installation")).toBeInViewport() }) }) diff --git a/web/middleware.ts b/web/middleware.ts index c163797fa..40a1e8463 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -1,20 +1,55 @@ import { NextResponse, type NextRequest } from "next/server" import createMiddleware from "next-intl/middleware" +import { locales, type Locale } from "./i18n/config" import { routing } from "./i18n/routing" const handleI18nRouting = createMiddleware(routing) const oldHosts = new Set(["ohmyopencode.org", "www.ohmyopencode.org"]) const primaryHost = "ohmyopenagent.com" +const installationPaths = new Set([ + "installation", + "installation.md", + "docs/installation", + "docs/installation.md", +]) + +function getLocaleSegment(segment: string | undefined): Locale | null { + if (!segment) return null + return locales.find((locale) => locale === segment) ?? null +} + +function getInstallationDocsPath(pathname: string): string | null { + const segments = pathname.split("/").filter(Boolean) + const locale = getLocaleSegment(segments[0]) + const routeSegments = locale ? segments.slice(1) : segments + + if (!installationPaths.has(routeSegments.join("/"))) return null + + return locale ? `/${locale}/docs` : "/docs" +} export default function middleware(request: NextRequest) { const forwardedHost = request.headers.get("x-forwarded-host") const requestHost = request.headers.get("host") const hostname = (forwardedHost ?? requestHost ?? request.nextUrl.hostname).split(":")[0] + const redirectUrl = request.nextUrl.clone() + let shouldRedirect = false if (hostname && oldHosts.has(hostname)) { - const redirectUrl = request.nextUrl.clone() redirectUrl.protocol = "https" redirectUrl.host = primaryHost + shouldRedirect = true + } + + const installationDocsPath = getInstallationDocsPath(request.nextUrl.pathname) + if (installationDocsPath) { + redirectUrl.pathname = installationDocsPath + redirectUrl.search = "" + redirectUrl.hash = "installation" + shouldRedirect = true + } + + if (shouldRedirect) { return NextResponse.redirect(redirectUrl, 308) } @@ -22,5 +57,12 @@ export default function middleware(request: NextRequest) { } export const config = { - matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).+)"], + matcher: [ + "/", + "/((?!api|_next|_vercel|.*\\..*).+)", + "/installation.md", + "/:locale(en|ko|ja|zh)/installation.md", + "/docs/installation.md", + "/:locale(en|ko|ja|zh)/docs/installation.md", + ], } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index b87da307c..f0c765a9b 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ ], webServer: { - command: "next build --webpack && next start", + command: "next build && next start", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 180000, diff --git a/web/scripts/generate-docs-content.mjs b/web/scripts/generate-docs-content.mjs index 769669e45..724138ea2 100644 --- a/web/scripts/generate-docs-content.mjs +++ b/web/scripts/generate-docs-content.mjs @@ -3,26 +3,50 @@ import path from "node:path" import { Marked } from "marked" const SECTIONS = [ - { file: "guide/overview.md" }, - { file: "guide/installation.md" }, - { file: "guide/orchestration.md" }, - { file: "guide/agent-model-matching.md" }, - { file: "guide/team-mode.md" }, - { file: "reference/cli.md" }, - { file: "reference/configuration.md" }, - { file: "reference/features.md" }, - { file: "manifesto.md" }, + { id: "overview", file: "guide/overview.md" }, + { id: "installation", file: "guide/installation.md" }, + { id: "orchestration", file: "guide/orchestration.md" }, + { id: "agent-model-matching", file: "guide/agent-model-matching.md" }, + { id: "team-mode", file: "guide/team-mode.md" }, + { id: "cli", file: "reference/cli.md" }, + { id: "configuration", file: "reference/configuration.md" }, + { id: "features", file: "reference/features.md" }, + { id: "manifesto", file: "manifesto.md" }, ] const DOCS_ROOT = path.resolve(process.cwd(), "..", "docs") const OUTPUT = path.resolve(process.cwd(), "lib", "docs-content.generated.ts") +const sectionIdByFile = new Map(SECTIONS.map((section) => [section.file, section.id])) -const marked = new Marked({ gfm: true, breaks: false }) +function rewriteDocsLink(sourceFile, href) { + if (!href || href.startsWith("#") || href.startsWith("//")) return href + if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href + + const [hrefPath] = href.split("#", 1) + if (!hrefPath) return href + + const sourceDirectory = path.posix.dirname(sourceFile) + const targetFile = path.posix.normalize(path.posix.join(sourceDirectory, hrefPath)) + const sectionId = sectionIdByFile.get(targetFile) + + return sectionId ? `#${sectionId}` : href +} + +function createMarked(sourceFile) { + const marked = new Marked({ gfm: true, breaks: false }) + marked.use({ + walkTokens(token) { + if (token.type !== "link") return + token.href = rewriteDocsLink(sourceFile, token.href) + }, + }) + return marked +} const sources = {} for (const s of SECTIONS) { const md = await readFile(path.join(DOCS_ROOT, s.file), "utf8") - sources[s.file] = await marked.parse(md) + sources[s.file] = await createMarked(s.file).parse(md) } const out =