fix(web): route installation links to docs section

This commit is contained in:
YeonGyu-Kim
2026-05-14 14:18:13 +09:00
parent 63ced1d2a0
commit 9a1f8f679f
5 changed files with 181 additions and 28 deletions
+52 -8
View File
@@ -29,10 +29,43 @@ export function DocsShell({
const activeSectionRef = React.useRef<DocSectionId>("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<HTMLElement>) => {
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({
</div>
</aside>
<main className="flex-1 px-4 pt-20 pb-20 md:ml-64 md:px-8 md:pt-8">
<main
className="flex-1 px-4 pt-20 pb-20 md:ml-64 md:px-8 md:pt-8"
onClickCapture={handleDocsClick}
>
<div className="mx-auto max-w-4xl space-y-12">{children}</div>
</main>
</div>
+49 -6
View File
@@ -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()
})
})
+44 -2
View File
@@ -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",
],
}
+1 -1
View File
@@ -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,
+35 -11
View File
@@ -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 =