fix(web): route installation links to docs section
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user