From 15876306045b820dd7bd6e41ecbf5b9ff9f6f0dd Mon Sep 17 00:00:00 2001 From: kite Date: Fri, 3 Jul 2026 20:29:29 +0800 Subject: [PATCH] feat(pages): add per-chapter routes for docs (#294) Each docs chapter previously lived at the single /docs route driven by component state, so chapters had no shareable URL and browser back/forward did not work. Add a /docs/:slug route and derive activeSlug from the URL param (falling back to quickstart), navigating via the router on switch. Also add a dev-time invariant that throws when two sidebar entries share a slug, since each slug now maps to exactly one URL. --- pages/src/App.tsx | 1 + pages/src/pages/DocsPage.tsx | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/pages/src/App.tsx b/pages/src/App.tsx index ffe893e..d658fb1 100644 --- a/pages/src/App.tsx +++ b/pages/src/App.tsx @@ -23,6 +23,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> ); diff --git a/pages/src/pages/DocsPage.tsx b/pages/src/pages/DocsPage.tsx index 7b37ca3..2a46056 100644 --- a/pages/src/pages/DocsPage.tsx +++ b/pages/src/pages/DocsPage.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from '../i18n'; import Navbar from '../components/Navbar'; import Footer from '../components/Footer'; @@ -109,9 +110,24 @@ function buildFlatDocList(): { slug: DocSlug; labelKey: string }[] { } const flatDocList = buildFlatDocList(); +const validSlugs = new Set(flatDocList.map(d => d.slug)); + +/* Dev-time invariant: every sidebar slug maps to exactly one URL, so duplicates + * (two menu entries sharing a slug) would silently collide. Fail loudly in dev. */ +if (process.env.NODE_ENV !== 'production' && validSlugs.size !== flatDocList.length) { + const slugs = flatDocList.map(d => d.slug); + const dupes = [...new Set(slugs.filter((s, i) => slugs.indexOf(s) !== i))]; + throw new Error( + `[docs] Duplicate sidebar slug(s) detected: ${dupes.join(', ')} — each doc must have a unique slug for routing.` + ); +} const DocsPage: React.FC = () => { - const [activeSlug, setActiveSlug] = useState('quickstart'); + const { slug: slugParam } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + /* Active doc slug is derived from the URL param, falling back to quickstart */ + const activeSlug: DocSlug = + slugParam && validSlugs.has(slugParam as DocSlug) ? (slugParam as DocSlug) : 'quickstart'; const [expandedItems, setExpandedItems] = useState>({ 'sb-integrations': false }); const [activeHeadingId, setActiveHeadingId] = useState(''); const [hoveredHeadingId, setHoveredHeadingId] = useState(''); @@ -162,10 +178,10 @@ const DocsPage: React.FC = () => { }, []); const navigateToDoc = useCallback((slug: DocSlug) => { - setActiveSlug(slug); + navigate(`/docs/${slug}`); // Scroll page to top window.scrollTo(0, 0); - }, []); + }, [navigate]); /* Intercept clicks on internal doc links and convert to SPA navigation */ const handleContentClick = useCallback((e: React.MouseEvent) => { @@ -194,8 +210,7 @@ const DocsPage: React.FC = () => { const slugMap: Record = { 'ci': 'cicd' }; const slug = (slugMap[lastSegment] || lastSegment) as DocSlug; // Verify it's a valid doc slug - const validSlugs = flatDocList.map(d => d.slug); - if (validSlugs.includes(slug)) { + if (validSlugs.has(slug)) { e.preventDefault(); navigateToDoc(slug); // Handle anchor scroll after navigation with reliable retry