Skip to content

Commit 0850a6f

Browse files
committed
feat(web): file-based routing
1 parent 7eb3d75 commit 0850a6f

File tree

4 files changed

+173
-135
lines changed

4 files changed

+173
-135
lines changed

web/src/main.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
1212
<BrowserRouter basename="/LSAP">
1313
<Routes>
1414
<Route path="/" element={<HomePage />} />
15-
<Route path="/docs/:docId" element={<DocsPage />} />
15+
<Route path="/docs/*" element={<DocsPage />} />
1616
<Route path="/docs" element={<DocsPage />} />
1717
</Routes>
1818
</BrowserRouter>

web/src/pages/DocsPage.tsx

Lines changed: 168 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ChevronRight, FileText } from "lucide-react";
2-
import { isValidElement, useEffect, useState } from "react";
2+
import { isValidElement, useEffect, useMemo, useState } from "react";
33
import ReactMarkdown from "react-markdown";
44
import { Link, useParams } from "react-router-dom";
55
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@@ -13,156 +13,191 @@ import { Card } from "../components/ui/card";
1313
import { useTheme } from "../lib/ThemeProvider";
1414

1515
type DocEntry = {
16-
id: string;
16+
route: string;
17+
relativePath: string;
1718
title: string;
18-
path: string;
1919
draft?: boolean;
2020
};
2121

22-
function hrefToDocsRoute(href: string) {
23-
const normalized = href.replace(/^\.\//, "");
22+
type Frontmatter = Record<string, string>;
2423

25-
const isSchemasPath =
26-
normalized.startsWith("/docs/schemas/") ||
27-
normalized.startsWith("schemas/");
28-
const isDraftPath =
29-
normalized.includes("/schemas/draft/") ||
30-
normalized.startsWith("draft/") ||
31-
normalized.startsWith("/docs/schemas/draft/") ||
32-
normalized.startsWith("schemas/draft/");
24+
function parseFrontmatter(markdown: string): {
25+
frontmatter: Frontmatter;
26+
body: string;
27+
} {
28+
if (!markdown.startsWith("---\n")) return { frontmatter: {}, body: markdown };
29+
const endIndex = markdown.indexOf("\n---\n", 4);
30+
if (endIndex === -1) return { frontmatter: {}, body: markdown };
31+
32+
const raw = markdown.slice(4, endIndex);
33+
const body = markdown.slice(endIndex + "\n---\n".length);
34+
const frontmatter: Frontmatter = {};
35+
36+
for (const line of raw.split("\n")) {
37+
const trimmed = line.trim();
38+
if (!trimmed) continue;
39+
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(trimmed);
40+
if (!match) continue;
41+
const key = match[1] ?? "";
42+
if (!key) continue;
43+
const value = (match[2] ?? "").replace(/^["']|["']$/g, "").trim();
44+
frontmatter[key] = value;
45+
}
46+
47+
return { frontmatter, body };
48+
}
49+
50+
function extractTitleFromMarkdown(markdown: string): string | null {
51+
const lines = markdown.split("\n");
52+
for (const line of lines) {
53+
const trimmed = line.trim();
54+
if (!trimmed) continue;
55+
if (trimmed.startsWith("# ")) return trimmed.replace(/^#\s+/, "").trim();
56+
if (trimmed.startsWith("## ")) return trimmed.replace(/^##\s+/, "").trim();
57+
}
58+
return null;
59+
}
60+
61+
function posixJoin(baseDir: string, hrefPath: string) {
62+
const parts = [...baseDir.split("/"), ...hrefPath.split("/")].filter(Boolean);
63+
const out: string[] = [];
64+
for (const part of parts) {
65+
if (part === ".") continue;
66+
if (part === "..") out.pop();
67+
else out.push(part);
68+
}
69+
return out.join("/");
70+
}
71+
72+
const RAW_DOCS = import.meta.glob("../../../docs/schemas/**/*.md", {
73+
query: "?raw",
74+
import: "default",
75+
eager: true,
76+
}) as Record<string, string>;
77+
78+
function makeDocIndex(): DocEntry[] {
79+
const entries: DocEntry[] = [];
80+
81+
for (const [sourcePath, raw] of Object.entries(RAW_DOCS)) {
82+
const marker = "/docs/schemas/";
83+
const idx = sourcePath.lastIndexOf(marker);
84+
if (idx === -1) continue;
85+
const relativePath = sourcePath.slice(idx + marker.length);
86+
const { frontmatter, body } = parseFrontmatter(raw);
87+
const draft = relativePath.startsWith("draft/");
88+
89+
const frontmatterTitle = frontmatter.title?.trim();
90+
const headingTitle = extractTitleFromMarkdown(body)?.trim();
91+
const fallbackTitle = relativePath
92+
.replace(/\.md$/, "")
93+
.split("/")
94+
.pop()
95+
?.replace(/_/g, " ");
96+
const title =
97+
frontmatterTitle || headingTitle || fallbackTitle || "Documentation";
98+
99+
const route = `/docs/schemas/${relativePath.replace(/\.md$/, "")}`;
100+
entries.push({ route, relativePath, title, draft });
101+
}
102+
103+
entries.sort((a, b) => {
104+
const aIsReadme = a.relativePath.toLowerCase() === "readme.md";
105+
const bIsReadme = b.relativePath.toLowerCase() === "readme.md";
106+
if (aIsReadme !== bIsReadme) return aIsReadme ? -1 : 1;
107+
if (a.draft !== b.draft) return a.draft ? 1 : -1;
108+
return a.relativePath.localeCompare(b.relativePath);
109+
});
110+
111+
return entries;
112+
}
113+
114+
const DOC_INDEX = makeDocIndex();
115+
const DOC_BY_ROUTE = new Map(DOC_INDEX.map((d) => [d.route, d]));
116+
const DEFAULT_ROUTE = "/docs/schemas/README";
117+
118+
function canonicalRouteFromSplat(splat?: string) {
119+
const cleaned = (splat || "").replace(/^\/+/, "").replace(/\/+$/, "");
120+
if (!cleaned) return DEFAULT_ROUTE;
121+
if (cleaned === "schemas") return DEFAULT_ROUTE;
122+
if (cleaned === "schemas/README") return DEFAULT_ROUTE;
123+
124+
if (cleaned.startsWith("schemas/")) {
125+
const normalized = cleaned.replace(/\.md$/, "");
126+
return `/docs/${normalized}`;
127+
}
128+
129+
const old = cleaned.replace(/\.md$/, "");
130+
if (old.startsWith("draft_"))
131+
return `/docs/schemas/draft/${old.replace(/^draft_/, "")}`;
132+
return `/docs/schemas/${old}`;
133+
}
134+
135+
function hrefToDocsRoute(href: string, currentDocRelativePath: string) {
136+
const [pathPart, hashPart] = href.split("#", 2);
137+
const hash = hashPart ? `#${hashPart}` : "";
138+
const normalized = (pathPart || "").replace(/^\.\//, "");
33139

34140
const filenameMatch = /([^/]+)\.md$/.exec(normalized);
35141
if (!filenameMatch) return href;
36142

37-
const docBaseName = filenameMatch[1];
38-
const docId = isDraftPath ? `draft_${docBaseName}` : docBaseName;
143+
if (normalized.startsWith("/docs/")) {
144+
const stripped = normalized.replace(/^\/docs\//, "").replace(/\.md$/, "");
145+
if (stripped.startsWith("schemas/")) return `/docs/${stripped}${hash}`;
146+
if (stripped.startsWith("draft_"))
147+
return `/docs/schemas/draft/${stripped.replace(/^draft_/, "")}${hash}`;
148+
return `/docs/schemas/${stripped}${hash}`;
149+
}
39150

40-
if (isSchemasPath || isDraftPath) return `/docs/${docId}`;
41-
return `/docs/${docId}`;
42-
}
151+
const underSchemas = normalized.startsWith("schemas/");
152+
const schemaRelative = underSchemas
153+
? normalized.replace(/^schemas\//, "")
154+
: normalized;
155+
const baseDir = currentDocRelativePath.split("/").slice(0, -1).join("/");
156+
const resolvedRelative = schemaRelative.startsWith("/")
157+
? schemaRelative.replace(/^\//, "")
158+
: posixJoin(baseDir, schemaRelative);
43159

44-
const DOCS: DocEntry[] = [
45-
{ id: "README", title: "Overview", path: "/docs/schemas/README.md" },
46-
{ id: "locate", title: "Locate", path: "/docs/schemas/locate.md" },
47-
{ id: "symbol", title: "Symbol", path: "/docs/schemas/symbol.md" },
48-
{
49-
id: "symbol_outline",
50-
title: "Symbol Outline",
51-
path: "/docs/schemas/symbol_outline.md",
52-
},
53-
{
54-
id: "definition",
55-
title: "Definition",
56-
path: "/docs/schemas/definition.md",
57-
},
58-
{ id: "reference", title: "Reference", path: "/docs/schemas/reference.md" },
59-
{
60-
id: "implementation",
61-
title: "Implementation",
62-
path: "/docs/schemas/implementation.md",
63-
},
64-
{
65-
id: "call_hierarchy",
66-
title: "Call Hierarchy",
67-
path: "/docs/schemas/call_hierarchy.md",
68-
},
69-
{
70-
id: "type_hierarchy",
71-
title: "Type Hierarchy",
72-
path: "/docs/schemas/type_hierarchy.md",
73-
},
74-
{ id: "workspace", title: "Workspace", path: "/docs/schemas/workspace.md" },
75-
{
76-
id: "completion",
77-
title: "Completion",
78-
path: "/docs/schemas/completion.md",
79-
},
80-
{
81-
id: "diagnostics",
82-
title: "Diagnostics",
83-
path: "/docs/schemas/diagnostics.md",
84-
},
85-
{ id: "rename", title: "Rename", path: "/docs/schemas/rename.md" },
86-
{
87-
id: "inlay_hints",
88-
title: "Inlay Hints",
89-
path: "/docs/schemas/inlay_hints.md",
90-
},
91-
{
92-
id: "draft_implementation",
93-
title: "Implementation",
94-
path: "/docs/schemas/draft/implementation.md",
95-
draft: true,
96-
},
97-
{
98-
id: "draft_call_hierarchy",
99-
title: "Call Hierarchy",
100-
path: "/docs/schemas/draft/call_hierarchy.md",
101-
draft: true,
102-
},
103-
{
104-
id: "draft_type_hierarchy",
105-
title: "Type Hierarchy",
106-
path: "/docs/schemas/draft/type_hierarchy.md",
107-
draft: true,
108-
},
109-
{
110-
id: "draft_diagnostics",
111-
title: "Diagnostics",
112-
path: "/docs/schemas/draft/diagnostics.md",
113-
draft: true,
114-
},
115-
{
116-
id: "draft_rename",
117-
title: "Rename",
118-
path: "/docs/schemas/draft/rename.md",
119-
draft: true,
120-
},
121-
{
122-
id: "draft_inlay_hints",
123-
title: "Inlay Hints",
124-
path: "/docs/schemas/draft/inlay_hints.md",
125-
draft: true,
126-
},
127-
];
160+
const withoutExt = resolvedRelative.replace(/\.md$/, "");
161+
return `/docs/schemas/${withoutExt}${hash}`;
162+
}
128163

129164
export default function DocsPage() {
130-
const { docId } = useParams();
165+
const params = useParams();
166+
const splat = params["*"];
131167
const [content, setContent] = useState<string>("");
132168
const [loading, setLoading] = useState(true);
133169
const { resolvedTheme } = useTheme();
134170

135-
const cleanDocId = docId?.replace(/\.md$/, "");
136-
const currentDoc = DOCS.find((d) => d.id === cleanDocId) ?? DOCS[0];
171+
const docs = useMemo(() => DOC_INDEX, []);
172+
const canonicalRoute = useMemo(() => canonicalRouteFromSplat(splat), [splat]);
173+
const currentDoc =
174+
DOC_BY_ROUTE.get(canonicalRoute) ?? DOC_BY_ROUTE.get(DEFAULT_ROUTE);
137175

138176
useEffect(() => {
139177
const titleSuffix = currentDoc?.draft ? " (draft)" : "";
140178
document.title = `${
141179
currentDoc?.title ?? "Documentation"
142180
}${titleSuffix} | LSAP`;
143-
}, [currentDoc?.id]);
181+
}, [currentDoc?.route, currentDoc?.title, currentDoc?.draft]);
144182

145183
useEffect(() => {
146184
if (!currentDoc) return;
147-
148185
setLoading(true);
149-
const baseUrl = (import.meta.env.BASE_URL || "/").replace(/\/$/, "");
150-
fetch(`${baseUrl}${currentDoc.path}`)
151-
.then((res) => {
152-
if (!res.ok) throw new Error("Failed to fetch");
153-
return res.text();
154-
})
155-
.then((text) => {
156-
setContent(text);
157-
setLoading(false);
158-
})
159-
.catch(() => {
160-
setContent(
161-
"# Documentation Not Found\n\nThe requested documentation could not be loaded."
162-
);
163-
setLoading(false);
164-
});
165-
}, [currentDoc?.path]);
186+
const sourcePath = Object.keys(RAW_DOCS).find((p) =>
187+
p.endsWith(`/docs/schemas/${currentDoc.relativePath}`)
188+
);
189+
const raw = sourcePath ? RAW_DOCS[sourcePath] ?? "" : "";
190+
const { body } = parseFrontmatter(raw);
191+
if (!body) {
192+
setContent(
193+
"# Documentation Not Found\n\nThe requested documentation could not be loaded."
194+
);
195+
setLoading(false);
196+
return;
197+
}
198+
setContent(body);
199+
setLoading(false);
200+
}, [currentDoc?.route, currentDoc?.relativePath]);
166201

167202
if (!currentDoc) return null;
168203

@@ -172,21 +207,20 @@ export default function DocsPage() {
172207

173208
<div className="container max-w-7xl mx-auto px-6 lg:px-8 py-8">
174209
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
175-
{/* Sidebar */}
176210
<aside className="lg:col-span-1">
177211
<Card className="p-4 sticky top-24">
178212
<h2 className="font-mono text-sm font-medium mb-4 text-foreground">
179213
Documentation
180214
</h2>
181215
<nav className="space-y-1">
182-
{DOCS.map((doc) => (
216+
{docs.map((doc) => (
183217
<Link
184-
key={doc.id}
185-
to={`/docs/${doc.id}`}
218+
key={doc.route}
219+
to={doc.route}
186220
className={`
187221
flex items-center gap-2 px-3 py-2 rounded-sm text-sm transition-colors
188222
${
189-
currentDoc.id === doc.id
223+
currentDoc.route === doc.route
190224
? "bg-primary/10 text-primary font-medium"
191225
: "text-muted-foreground hover:text-foreground hover:bg-muted"
192226
}
@@ -203,7 +237,7 @@ export default function DocsPage() {
203237
draft
204238
</span>
205239
)}
206-
{currentDoc.id === doc.id && (
240+
{currentDoc.route === doc.route && (
207241
<ChevronRight className="h-3.5 w-3.5 ml-auto" />
208242
)}
209243
</Link>
@@ -212,7 +246,6 @@ export default function DocsPage() {
212246
</Card>
213247
</aside>
214248

215-
{/* Main Content */}
216249
<main className="lg:col-span-3">
217250
<Card className="p-8 lg:p-12">
218251
{loading ? (
@@ -368,9 +401,10 @@ export default function DocsPage() {
368401
!href.startsWith("//") &&
369402
!href.startsWith("#");
370403
if (isInternal) {
371-
const to = href.startsWith("/")
372-
? hrefToDocsRoute(href)
373-
: hrefToDocsRoute(href);
404+
const to = hrefToDocsRoute(
405+
href,
406+
currentDoc.relativePath
407+
);
374408
return (
375409
<Link
376410
to={to}

web/src/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />

web/vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,8 @@ export default defineConfig({
1212
},
1313
server: {
1414
port: 3000,
15+
fs: {
16+
allow: [path.resolve(__dirname, '..')],
17+
},
1518
},
1619
})

0 commit comments

Comments
 (0)