Skip to content

Commit 05ae835

Browse files
committed
refactor: split out docs/manual routes from shared MdxRoute file
1 parent a3ed0b8 commit 05ae835

File tree

8 files changed

+275
-15
lines changed

8 files changed

+275
-15
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ let default: unit => React.element
201201
- **Lefthook** runs `yarn format` on pre-commit (auto-stages fixed files).
202202
- Generated `.mjs`/`.jsx` output files from ReScript are git-tracked but excluded from Prettier.
203203

204+
## Pull Requests and Commits
205+
206+
- Use conventional commits format for commit messages (e.g. `feat: add new API docs`, `fix: resolve loader data issue`).
207+
- Commit bodies should explain what changed with some concise details
208+
- PR descriptions should provide context for the change, a summary of the changes with descriptions, and reference any related issues.
209+
204210
## Important Warnings
205211

206212
- Do **not** modify generated `.jsx` / `.mjs` files directly — they are ReScript compiler output.

app/routes.res

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,23 @@ let blogArticleRoutes =
3333
route(path, "./routes/BlogArticleRoute.jsx", ~options={id: path})
3434
)
3535

36-
let mdxRoutes =
37-
mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r =>
38-
!(r.path
39-
->Option.map(path => path === "blog" || String.startsWith(path, "blog/"))
40-
->Option.getOr(false)
36+
let docsManualRoutes =
37+
MdxFile.scanPaths(~dir="markdown-pages/docs/manual", ~alias="docs/manual")
38+
->Array.filter(path => !String.includes(path, "docs/manual/api"))
39+
->Array.map(path => route(path, "./routes/DocsManualRoute.jsx", ~options={id: path}))
40+
41+
let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r =>
42+
!(
43+
r.path
44+
->Option.map(path =>
45+
path === "blog" ||
46+
String.startsWith(path, "blog/") ||
47+
path === "docs/manual" ||
48+
String.startsWith(path, "docs/manual/")
4149
)
50+
->Option.getOr(false)
4251
)
52+
)
4353

4454
let default = [
4555
index("./routes/LandingPageRoute.jsx"),
@@ -56,6 +66,7 @@ let default = [
5666
...stdlibRoutes,
5767
...beltRoutes,
5868
...blogArticleRoutes,
69+
...docsManualRoutes,
5970
...mdxRoutes,
6071
route("*", "./routes/NotFoundRoute.jsx"),
6172
]

app/routes/DocsManualRoute.res

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
type loaderData = {
2+
compiledMdx: CompiledMdx.t,
3+
categories: array<SidebarLayout.Sidebar.Category.t>,
4+
entries: array<TableOfContents.entry>,
5+
title: string,
6+
description: string,
7+
filePath: string,
8+
}
9+
10+
// Build sidebar categories from all manual docs, sorted by their "order" field in frontmatter
11+
let manualTableOfContents = async () => {
12+
let groups =
13+
(await MdxFile.loadAllAttributes(~dir="markdown-pages/docs"))
14+
->Mdx.filterMdxPages("docs/manual")
15+
->Mdx.groupBySection
16+
->Dict.mapValues(values =>
17+
values->Mdx.sortSection->SidebarHelpers.convertToNavItems("/docs/manual")
18+
)
19+
20+
SidebarHelpers.getAllGroups(
21+
groups,
22+
[
23+
"Overview",
24+
"Guides",
25+
"Language Features",
26+
"JavaScript Interop",
27+
"Build System",
28+
"Advanced Features",
29+
],
30+
)
31+
}
32+
33+
let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => {
34+
let {pathname} = WebAPI.URL.make(~url=request.url)
35+
let filePath = MdxFile.resolveFilePath(
36+
(pathname :> string),
37+
~dir="markdown-pages/docs/manual",
38+
~alias="docs/manual",
39+
)
40+
41+
let raw = await Node.Fs.readFile(filePath, "utf-8")
42+
let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw)
43+
44+
let description = switch frontmatter {
45+
| Object(dict) =>
46+
switch dict->Dict.get("description") {
47+
| Some(String(s)) => s
48+
| _ => ""
49+
}
50+
| _ => ""
51+
}
52+
53+
let title = switch frontmatter {
54+
| Object(dict) =>
55+
switch dict->Dict.get("title") {
56+
| Some(String(s)) => s
57+
| _ => ""
58+
}
59+
| _ => ""
60+
}
61+
62+
let categories = await manualTableOfContents()
63+
64+
let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins)
65+
66+
// Build table of contents entries from markdown headings
67+
let markdownTree = Mdast.fromMarkdown(raw)
68+
let tocResult = Mdast.toc(markdownTree, {maxDepth: 2})
69+
70+
let headers = Dict.make()
71+
Mdast.reduceHeaders(tocResult.map, headers)
72+
73+
let entries =
74+
headers
75+
->Dict.toArray
76+
->Array.map(((header, url)): TableOfContents.entry => {
77+
header,
78+
href: (url :> string),
79+
})
80+
->Array.slice(~start=2) // skip document entry and H1 title, keep h2 sections
81+
82+
{
83+
compiledMdx,
84+
categories,
85+
entries,
86+
title: `${title} | ReScript Language Manual`,
87+
description,
88+
filePath,
89+
}
90+
}
91+
92+
let default = () => {
93+
let {pathname} = ReactRouter.useLocation()
94+
let {compiledMdx, categories, entries, title, description, filePath} = ReactRouter.useLoaderData()
95+
96+
let breadcrumbs = list{
97+
{Url.name: "Docs", href: "/docs/manual/introduction"},
98+
{
99+
Url.name: "Language Manual",
100+
href: "/docs/manual/introduction",
101+
},
102+
}
103+
104+
let editHref = `https://github.com/rescript-lang/rescript-lang.org/blob/master/${filePath}`
105+
106+
let sidebarContent =
107+
<aside className="px-4 w-full block">
108+
<div className="flex justify-between items-baseline">
109+
<div className="flex flex-col text-fire font-medium">
110+
<VersionSelect />
111+
</div>
112+
<button
113+
className="flex items-center" onClick={_ => NavbarUtils.closeMobileTertiaryDrawer()}
114+
>
115+
<Icon.Close />
116+
</button>
117+
</div>
118+
<div className="mb-56">
119+
{categories
120+
->Array.map(category => {
121+
let isItemActive = (navItem: SidebarLayout.Sidebar.NavItem.t) =>
122+
navItem.href === (pathname :> string)
123+
let getActiveToc = (navItem: SidebarLayout.Sidebar.NavItem.t) =>
124+
if navItem.href === (pathname :> string) {
125+
Some({TableOfContents.title, entries})
126+
} else {
127+
None
128+
}
129+
<div key=category.name>
130+
<SidebarLayout.Sidebar.Category
131+
isItemActive
132+
getActiveToc
133+
category
134+
onClick={_ => NavbarUtils.closeMobileTertiaryDrawer()}
135+
/>
136+
</div>
137+
})
138+
->React.array}
139+
</div>
140+
</aside>
141+
142+
<>
143+
<Meta title description />
144+
<NavbarSecondary />
145+
<NavbarTertiary sidebar=sidebarContent>
146+
<SidebarLayout.BreadCrumbs crumbs=breadcrumbs />
147+
<a
148+
href=editHref className="inline text-14 hover:underline text-fire" rel="noopener noreferrer"
149+
>
150+
{React.string("Edit")}
151+
</a>
152+
</NavbarTertiary>
153+
<DocsLayout categories activeToc={title, entries}>
154+
<div className="markdown-body">
155+
<MdxContent compiledMdx />
156+
</div>
157+
</DocsLayout>
158+
</>
159+
}

app/routes/DocsManualRoute.resi

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
type loaderData = {
2+
compiledMdx: CompiledMdx.t,
3+
categories: array<SidebarLayout.Sidebar.Category.t>,
4+
entries: array<TableOfContents.entry>,
5+
title: string,
6+
description: string,
7+
filePath: string,
8+
}
9+
10+
let loader: ReactRouter.Loader.t<loaderData>
11+
12+
let default: unit => React.element

src/MdxFile.res

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,15 @@ let resolveFilePath = (pathname, ~dir, ~alias) => {
3030
} else {
3131
pathname
3232
}
33-
let relativePath =
34-
if path->String.startsWith(alias ++ "/") {
35-
let rest = path->String.slice(~start=String.length(alias) + 1, ~end=String.length(path))
36-
Node.Path.join2(dir, rest)
37-
} else if path->String.startsWith(alias) {
38-
let rest = path->String.slice(~start=String.length(alias), ~end=String.length(path))
39-
Node.Path.join2(dir, rest)
40-
} else {
41-
path
42-
}
33+
let relativePath = if path->String.startsWith(alias ++ "/") {
34+
let rest = path->String.slice(~start=String.length(alias) + 1, ~end=String.length(path))
35+
Node.Path.join2(dir, rest)
36+
} else if path->String.startsWith(alias) {
37+
let rest = path->String.slice(~start=String.length(alias), ~end=String.length(path))
38+
Node.Path.join2(dir, rest)
39+
} else {
40+
path
41+
}
4342
relativePath ++ ".mdx"
4443
}
4544

@@ -75,3 +74,33 @@ let scanPaths = (~dir, ~alias) => {
7574
alias ++ "/" ++ relativePath
7675
})
7776
}
77+
78+
// Convert frontmatter JSON dict to Mdx.attributes
79+
// This is the same unsafe approach as react-router-mdx — frontmatter YAML
80+
// becomes a JS object that we type as Mdx.attributes. Fields not present
81+
// in the frontmatter (e.g. blog-specific `author`, `date`) are undefined at
82+
// runtime, which is fine because docs/community code never accesses them.
83+
external dictToAttributes: Dict.t<JSON.t> => Mdx.attributes = "%identity"
84+
85+
let loadAllAttributes = async (~dir) => {
86+
let files = scanDir(dir, dir)
87+
await Promise.all(
88+
files->Array.map(async relativePath => {
89+
let fullPath = Node.Path.join2(dir, relativePath ++ ".mdx")->String.replaceAll("\\", "/")
90+
let raw = await Node.Fs.readFile(fullPath, "utf-8")
91+
let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw)
92+
93+
let dict = switch frontmatter {
94+
| Object(dict) => dict
95+
| _ => Dict.make()
96+
}
97+
98+
// Add path and slug fields (same as react-router-mdx does)
99+
dict->Dict.set("path", JSON.String(fullPath))
100+
let slug = Node.Path.basename(relativePath)
101+
dict->Dict.set("slug", JSON.String(slug))
102+
103+
dictToAttributes(dict)
104+
}),
105+
)
106+
}

src/MdxFile.resi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,9 @@ let compileMdx: (
2424
~filePath: string,
2525
~remarkPlugins: array<Mdx.remarkPlugin>=?,
2626
) => promise<CompiledMdx.t>
27+
28+
/** Scan all .mdx files in a directory, parse frontmatter only, and return
29+
* as Mdx.attributes with `path` and `slug` fields populated.
30+
* Replaces `react-router-mdx`'s `loadAllMdx`.
31+
*/
32+
let loadAllAttributes: (~dir: string) => promise<array<Mdx.attributes>>

src/SidebarHelpers.res

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
let convertToNavItems = (items, rootPath) =>
2+
Array.map(items, (item): SidebarLayout.Sidebar.NavItem.t => {
3+
let href = switch item.Mdx.slug {
4+
| Some(slug) => `${rootPath}/${slug}`
5+
| None => rootPath
6+
}
7+
{
8+
name: item.title,
9+
href,
10+
}
11+
})
12+
13+
let getGroup = (groups, groupName): SidebarLayout.Sidebar.Category.t => {
14+
{
15+
name: groupName,
16+
items: groups
17+
->Dict.get(groupName)
18+
->Option.getOr([]),
19+
}
20+
}
21+
22+
let getAllGroups = (groups, groupNames): array<SidebarLayout.Sidebar.Category.t> =>
23+
groupNames->Array.map(item => getGroup(groups, item))

src/SidebarHelpers.resi

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** Convert Mdx.attributes to sidebar nav items, building hrefs from rootPath + slug. */
2+
let convertToNavItems: (array<Mdx.attributes>, string) => array<SidebarLayout.Sidebar.NavItem.t>
3+
4+
/** Get a single sidebar category by name from a dict of grouped nav items. */
5+
let getGroup: (
6+
Dict.t<array<SidebarLayout.Sidebar.NavItem.t>>,
7+
string,
8+
) => SidebarLayout.Sidebar.Category.t
9+
10+
/** Get multiple sidebar categories by name from a dict of grouped nav items. */
11+
let getAllGroups: (
12+
Dict.t<array<SidebarLayout.Sidebar.NavItem.t>>,
13+
array<string>,
14+
) => array<SidebarLayout.Sidebar.Category.t>

0 commit comments

Comments
 (0)