From 5d7a8f25963ae0917ce482d9deea367ab84bd1b4 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 7 Apr 2026 15:13:01 -0400 Subject: [PATCH 1/2] refactor: split out docs/manual routes from shared MdxRoute file --- AGENTS.md | 6 ++ app/routes.res | 13 ++- app/routes/DocsManualRoute.res | 159 ++++++++++++++++++++++++++++++++ app/routes/DocsManualRoute.resi | 12 +++ src/MdxFile.res | 32 ++++++- src/MdxFile.resi | 6 ++ src/SidebarHelpers.res | 23 +++++ src/SidebarHelpers.resi | 14 +++ 8 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 app/routes/DocsManualRoute.res create mode 100644 app/routes/DocsManualRoute.resi create mode 100644 src/SidebarHelpers.res create mode 100644 src/SidebarHelpers.resi diff --git a/AGENTS.md b/AGENTS.md index 539a7ea1e..e87778a22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,12 @@ let default: unit => React.element - **Lefthook** runs `yarn format` on pre-commit (auto-stages fixed files). - Generated `.mjs`/`.jsx` output files from ReScript are git-tracked but excluded from Prettier. +## Pull Requests and Commits + +- Use conventional commits format for commit messages (e.g. `feat: add new API docs`, `fix: resolve loader data issue`). +- Commit bodies should explain what changed with some concise details +- PR descriptions should provide context for the change, a summary of the changes with descriptions, and reference any related issues. + ## Important Warnings - Do **not** modify generated `.jsx` / `.mjs` files directly — they are ReScript compiler output. diff --git a/app/routes.res b/app/routes.res index 6f5b9b8fc..dc5537c50 100644 --- a/app/routes.res +++ b/app/routes.res @@ -33,10 +33,20 @@ let blogArticleRoutes = route(path, "./routes/BlogArticleRoute.jsx", ~options={id: path}) ) +let docsManualRoutes = + MdxFile.scanPaths(~dir="markdown-pages/docs/manual", ~alias="docs/manual") + ->Array.filter(path => !String.includes(path, "docs/manual/api")) + ->Array.map(path => route(path, "./routes/DocsManualRoute.jsx", ~options={id: path})) + let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r => !( r.path - ->Option.map(path => path === "blog" || String.startsWith(path, "blog/")) + ->Option.map(path => + path === "blog" || + String.startsWith(path, "blog/") || + ((path === "docs/manual" || String.startsWith(path, "docs/manual/")) && + path !== "docs/manual/api") + ) ->Option.getOr(false) ) ) @@ -56,6 +66,7 @@ let default = [ ...stdlibRoutes, ...beltRoutes, ...blogArticleRoutes, + ...docsManualRoutes, ...mdxRoutes, route("*", "./routes/NotFoundRoute.jsx"), ] diff --git a/app/routes/DocsManualRoute.res b/app/routes/DocsManualRoute.res new file mode 100644 index 000000000..dd44b2596 --- /dev/null +++ b/app/routes/DocsManualRoute.res @@ -0,0 +1,159 @@ +type loaderData = { + compiledMdx: CompiledMdx.t, + categories: array, + entries: array, + title: string, + description: string, + filePath: string, +} + +// Build sidebar categories from all manual docs, sorted by their "order" field in frontmatter +let manualTableOfContents = async () => { + let groups = + (await MdxFile.loadAllAttributes(~dir="markdown-pages/docs")) + ->Mdx.filterMdxPages("docs/manual") + ->Mdx.groupBySection + ->Dict.mapValues(values => + values->Mdx.sortSection->SidebarHelpers.convertToNavItems("/docs/manual") + ) + + SidebarHelpers.getAllGroups( + groups, + [ + "Overview", + "Guides", + "Language Features", + "JavaScript Interop", + "Build System", + "Advanced Features", + ], + ) +} + +let loader: ReactRouter.Loader.t = async ({request}) => { + let {pathname} = WebAPI.URL.make(~url=request.url) + let filePath = MdxFile.resolveFilePath( + (pathname :> string), + ~dir="markdown-pages/docs/manual", + ~alias="docs/manual", + ) + + let raw = await Node.Fs.readFile(filePath, "utf-8") + let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw) + + let description = switch frontmatter { + | Object(dict) => + switch dict->Dict.get("description") { + | Some(String(s)) => s + | _ => "" + } + | _ => "" + } + + let title = switch frontmatter { + | Object(dict) => + switch dict->Dict.get("title") { + | Some(String(s)) => s + | _ => "" + } + | _ => "" + } + + let categories = await manualTableOfContents() + + let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins) + + // Build table of contents entries from markdown headings + let markdownTree = Mdast.fromMarkdown(raw) + let tocResult = Mdast.toc(markdownTree, {maxDepth: 2}) + + let headers = Dict.make() + Mdast.reduceHeaders(tocResult.map, headers) + + let entries = + headers + ->Dict.toArray + ->Array.map(((header, url)): TableOfContents.entry => { + header, + href: (url :> string), + }) + ->Array.slice(~start=2) // skip document entry and H1 title, keep h2 sections + + { + compiledMdx, + categories, + entries, + title: `${title} | ReScript Language Manual`, + description, + filePath, + } +} + +let default = () => { + let {pathname} = ReactRouter.useLocation() + let {compiledMdx, categories, entries, title, description, filePath} = ReactRouter.useLoaderData() + + let breadcrumbs = list{ + {Url.name: "Docs", href: "/docs/manual/introduction"}, + { + Url.name: "Language Manual", + href: "/docs/manual/introduction", + }, + } + + let editHref = `https://github.com/rescript-lang/rescript-lang.org/blob/master/${filePath}` + + let sidebarContent = + + + <> + + + + + + {React.string("Edit")} + + + +
+ +
+
+ +} diff --git a/app/routes/DocsManualRoute.resi b/app/routes/DocsManualRoute.resi new file mode 100644 index 000000000..a8a275a4c --- /dev/null +++ b/app/routes/DocsManualRoute.resi @@ -0,0 +1,12 @@ +type loaderData = { + compiledMdx: CompiledMdx.t, + categories: array, + entries: array, + title: string, + description: string, + filePath: string, +} + +let loader: ReactRouter.Loader.t + +let default: unit => React.element diff --git a/src/MdxFile.res b/src/MdxFile.res index 0366cf8b3..7358fa843 100644 --- a/src/MdxFile.res +++ b/src/MdxFile.res @@ -39,7 +39,7 @@ let resolveFilePath = (pathname, ~dir, ~alias) => { } else { path } - relativePath ++ ".mdx" + relativePath->String.replaceAll("\\", "/") ++ ".mdx" } let loadFile = async filePath => { @@ -74,3 +74,33 @@ let scanPaths = (~dir, ~alias) => { alias ++ "/" ++ relativePath }) } + +// Convert frontmatter JSON dict to Mdx.attributes +// This is the same unsafe approach as react-router-mdx — frontmatter YAML +// becomes a JS object that we type as Mdx.attributes. Fields not present +// in the frontmatter (e.g. blog-specific `author`, `date`) are undefined at +// runtime, which is fine because docs/community code never accesses them. +external dictToAttributes: Dict.t => Mdx.attributes = "%identity" + +let loadAllAttributes = async (~dir) => { + let files = scanDir(dir, dir) + await Promise.all( + files->Array.map(async relativePath => { + let fullPath = Node.Path.join2(dir, relativePath ++ ".mdx")->String.replaceAll("\\", "/") + let raw = await Node.Fs.readFile(fullPath, "utf-8") + let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw) + + let dict = switch frontmatter { + | Object(dict) => dict + | _ => Dict.make() + } + + // Add path and slug fields (same as react-router-mdx does) + dict->Dict.set("path", JSON.String(fullPath)) + let slug = Node.Path.basename(relativePath) + dict->Dict.set("slug", JSON.String(slug)) + + dictToAttributes(dict) + }), + ) +} diff --git a/src/MdxFile.resi b/src/MdxFile.resi index 9ca94395e..dc50472d3 100644 --- a/src/MdxFile.resi +++ b/src/MdxFile.resi @@ -24,3 +24,9 @@ let compileMdx: ( ~filePath: string, ~remarkPlugins: array=?, ) => promise + +/** Scan all .mdx files in a directory, parse frontmatter only, and return + * as Mdx.attributes with `path` and `slug` fields populated. + * Replaces `react-router-mdx`'s `loadAllMdx`. + */ +let loadAllAttributes: (~dir: string) => promise> diff --git a/src/SidebarHelpers.res b/src/SidebarHelpers.res new file mode 100644 index 000000000..f9f4a3532 --- /dev/null +++ b/src/SidebarHelpers.res @@ -0,0 +1,23 @@ +let convertToNavItems = (items, rootPath) => + Array.map(items, (item): SidebarLayout.Sidebar.NavItem.t => { + let href = switch item.Mdx.slug { + | Some(slug) => `${rootPath}/${slug}` + | None => rootPath + } + { + name: item.title, + href, + } + }) + +let getGroup = (groups, groupName): SidebarLayout.Sidebar.Category.t => { + { + name: groupName, + items: groups + ->Dict.get(groupName) + ->Option.getOr([]), + } +} + +let getAllGroups = (groups, groupNames): array => + groupNames->Array.map(item => getGroup(groups, item)) diff --git a/src/SidebarHelpers.resi b/src/SidebarHelpers.resi new file mode 100644 index 000000000..10b79cecd --- /dev/null +++ b/src/SidebarHelpers.resi @@ -0,0 +1,14 @@ +/** Convert Mdx.attributes to sidebar nav items, building hrefs from rootPath + slug. */ +let convertToNavItems: (array, string) => array + +/** Get a single sidebar category by name from a dict of grouped nav items. */ +let getGroup: ( + Dict.t>, + string, +) => SidebarLayout.Sidebar.Category.t + +/** Get multiple sidebar categories by name from a dict of grouped nav items. */ +let getAllGroups: ( + Dict.t>, + array, +) => array From d75209d25a85a5178648ca575f66a4076f263360 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 7 Apr 2026 15:48:58 -0400 Subject: [PATCH 2/2] refactor: address PR review feedback for DocsManualRoute - Replace unsafe %identity cast with dedicated sidebarEntry type - Narrow directory scan from docs/ to docs/manual/ - Add module-level cache for manualTableOfContents - Remove dead manual-specific code from MdxRoute - Move shared sidebar helpers to SidebarHelpers module --- app/routes/DocsManualRoute.res | 45 +++++++++++++--------- app/routes/MdxRoute.res | 68 +++++----------------------------- src/MdxFile.res | 38 +++++++++++++------ src/MdxFile.resi | 17 +++++++-- src/SidebarHelpers.res | 27 +++++++++++++- src/SidebarHelpers.resi | 16 +++++++- 6 files changed, 116 insertions(+), 95 deletions(-) diff --git a/app/routes/DocsManualRoute.res b/app/routes/DocsManualRoute.res index dd44b2596..09588944e 100644 --- a/app/routes/DocsManualRoute.res +++ b/app/routes/DocsManualRoute.res @@ -7,27 +7,36 @@ type loaderData = { filePath: string, } -// Build sidebar categories from all manual docs, sorted by their "order" field in frontmatter +// Module-level cache to avoid re-scanning frontmatter on every request +let manualTableOfContentsCache: ref>> = ref(None) + +// Build sidebar categories from manual docs, sorted by their "order" field in frontmatter let manualTableOfContents = async () => { - let groups = - (await MdxFile.loadAllAttributes(~dir="markdown-pages/docs")) - ->Mdx.filterMdxPages("docs/manual") - ->Mdx.groupBySection - ->Dict.mapValues(values => - values->Mdx.sortSection->SidebarHelpers.convertToNavItems("/docs/manual") + switch manualTableOfContentsCache.contents { + | Some(categories) => categories + | None => + let groups = + (await MdxFile.loadAllAttributes(~dir="markdown-pages/docs/manual")) + ->SidebarHelpers.groupBySection + ->Dict.mapValues(values => + values->SidebarHelpers.sortByOrder->SidebarHelpers.convertToNavItems("/docs/manual") + ) + + let categories = SidebarHelpers.getAllGroups( + groups, + [ + "Overview", + "Guides", + "Language Features", + "JavaScript Interop", + "Build System", + "Advanced Features", + ], ) - SidebarHelpers.getAllGroups( - groups, - [ - "Overview", - "Guides", - "Language Features", - "JavaScript Interop", - "Build System", - "Advanced Features", - ], - ) + manualTableOfContentsCache := Some(categories) + categories + } } let loader: ReactRouter.Loader.t = async ({request}) => { diff --git a/app/routes/MdxRoute.res b/app/routes/MdxRoute.res index f61e9db29..d6addb3b6 100644 --- a/app/routes/MdxRoute.res +++ b/app/routes/MdxRoute.res @@ -63,42 +63,6 @@ let convertToNavItems = (items, rootPath) => } }) -let getGroup = (groups, groupName): SidebarLayout.Sidebar.Category.t => { - { - name: groupName, - items: groups - ->Dict.get(groupName) - ->Option.getOr([]), - } -} - -let getAllGroups = (groups, groupNames): array => - groupNames->Array.map(item => getGroup(groups, item)) - -// These are the pages for the language manual, sorted by their "order" field in the frontmatter -let manualTableOfContents = async () => { - let groups = - (await allMdx(~filterByPaths=["markdown-pages/docs"])) - ->filterMdxPages("docs/manual") - ->groupBySection - ->Dict.mapValues(values => values->sortSection->convertToNavItems("/docs/manual")) - - // these are the categories that appear in the sidebar - let categories: array = getAllGroups( - groups, - [ - "Overview", - "Guides", - "Language Features", - "JavaScript Interop", - "Build System", - "Advanced Features", - ], - ) - - categories -} - let reactTableOfContents = async () => { let groups = (await allMdx(~filterByPaths=["markdown-pages/docs"])) @@ -107,7 +71,7 @@ let reactTableOfContents = async () => { ->Dict.mapValues(values => values->sortSection->convertToNavItems("/docs/react")) // these are the categories that appear in the sidebar - let categories: array = getAllGroups( + let categories: array = SidebarHelpers.getAllGroups( groups, ["Overview", "Main Concepts", "Hooks & State Management", "Guides"], ) @@ -123,7 +87,10 @@ let communityTableOfContents = async () => { ->Dict.mapValues(values => values->sortSection->convertToNavItems("/community")) // these are the categories that appear in the sidebar - let categories: array = getAllGroups(groups, ["Resources"]) + let categories: array = SidebarHelpers.getAllGroups( + groups, + ["Resources"], + ) categories } @@ -161,8 +128,6 @@ let loader: ReactRouter.Loader.t = async ({request}) => { let categories = { if pathname == "/docs/manual/api" { [] - } else if pathname->String.includes("docs/manual") { - await manualTableOfContents() } else if pathname->String.includes("docs/react") { await reactTableOfContents() } else if pathname->String.includes("community") { @@ -214,22 +179,14 @@ let loader: ReactRouter.Loader.t = async ({request}) => { ->Array.slice(~start=2) // skip first two entries which are the document entry and the H1 title for the page, we just want the h2 sections let breadcrumbs = - pathname->String.includes("docs/manual") + pathname->String.includes("docs/react") ? Some(list{ {Url.name: "Docs", href: "/docs/"}, { - Url.name: "Language Manual", - href: "/docs/manual/" ++ "introduction", + Url.name: "rescript-react", + href: "/docs/react/" ++ "introduction", }, }) - : pathname->String.includes("docs/react") - ? Some(list{ - {Url.name: "Docs", href: "/docs/"}, - { - Url.name: "rescript-react", - href: "/docs/react/" ++ "introduction", - }, - }) : None let metaTitleCategory = { @@ -238,8 +195,6 @@ let loader: ReactRouter.Loader.t = async ({request}) => { "ReScript React" } else if path->String.includes("docs/manual/api") { "ReScript API" - } else if path->String.includes("docs/manual") { - "ReScript Language Manual" } else if path->String.includes("community") { "ReScript Community" } else { @@ -321,9 +276,8 @@ let default = () => { } else if ( - (pathname :> string)->String.includes("docs/manual") || (pathname :> string)->String.includes("docs/react") || - (pathname :> string)->String.includes("docs/guidelines") + (pathname :> string)->String.includes("docs/guidelines") ) { <> Nullable.getOr("")} /> @@ -332,9 +286,7 @@ let default = () => { let breadcrumbs = loaderData.breadcrumbs->Option.map(crumbs => List.mapWithIndex(crumbs, (item, index) => { if index === 0 { - if (pathname :> string)->String.includes("docs/manual") { - {...item, href: "/docs/manual/introduction"} - } else if (pathname :> string)->String.includes("docs/react") { + if (pathname :> string)->String.includes("docs/react") { {...item, href: "/docs/react/introduction"} } else { item diff --git a/src/MdxFile.res b/src/MdxFile.res index 7358fa843..6b59845b5 100644 --- a/src/MdxFile.res +++ b/src/MdxFile.res @@ -75,12 +75,25 @@ let scanPaths = (~dir, ~alias) => { }) } -// Convert frontmatter JSON dict to Mdx.attributes -// This is the same unsafe approach as react-router-mdx — frontmatter YAML -// becomes a JS object that we type as Mdx.attributes. Fields not present -// in the frontmatter (e.g. blog-specific `author`, `date`) are undefined at -// runtime, which is fine because docs/community code never accesses them. -external dictToAttributes: Dict.t => Mdx.attributes = "%identity" +type sidebarEntry = { + title: string, + slug: option, + section: option, + order: option, + path: option, +} + +let jsonToString = json => + switch json { + | JSON.String(s) => Some(s) + | _ => None + } + +let jsonToInt = json => + switch json { + | JSON.Number(n) => Some(Float.toInt(n)) + | _ => None + } let loadAllAttributes = async (~dir) => { let files = scanDir(dir, dir) @@ -95,12 +108,13 @@ let loadAllAttributes = async (~dir) => { | _ => Dict.make() } - // Add path and slug fields (same as react-router-mdx does) - dict->Dict.set("path", JSON.String(fullPath)) - let slug = Node.Path.basename(relativePath) - dict->Dict.set("slug", JSON.String(slug)) - - dictToAttributes(dict) + { + title: dict->Dict.get("title")->Option.flatMap(jsonToString)->Option.getOr(""), + slug: Some(Node.Path.basename(relativePath)), + section: dict->Dict.get("section")->Option.flatMap(jsonToString), + order: dict->Dict.get("order")->Option.flatMap(jsonToInt), + path: Some(fullPath), + } }), ) } diff --git a/src/MdxFile.resi b/src/MdxFile.resi index dc50472d3..37a75bcb5 100644 --- a/src/MdxFile.resi +++ b/src/MdxFile.resi @@ -25,8 +25,19 @@ let compileMdx: ( ~remarkPlugins: array=?, ) => promise +/** Lightweight frontmatter record containing only the fields needed for + * sidebar navigation. Unlike Mdx.attributes, every field is either + * optional or has a safe default, so no unsafe casting is required. + */ +type sidebarEntry = { + title: string, + slug: option, + section: option, + order: option, + path: option, +} + /** Scan all .mdx files in a directory, parse frontmatter only, and return - * as Mdx.attributes with `path` and `slug` fields populated. - * Replaces `react-router-mdx`'s `loadAllMdx`. + * sidebar entries with `path` and `slug` fields populated. */ -let loadAllAttributes: (~dir: string) => promise> +let loadAllAttributes: (~dir: string) => promise> diff --git a/src/SidebarHelpers.res b/src/SidebarHelpers.res index f9f4a3532..3d2003742 100644 --- a/src/SidebarHelpers.res +++ b/src/SidebarHelpers.res @@ -1,6 +1,29 @@ -let convertToNavItems = (items, rootPath) => +let filterByPath = (entries: array, path) => + Array.filter(entries, entry => + entry.path->Option.map(String.includes(_, path))->Option.getOr(false) + ) + +let sortByOrder = (entries: array) => + Array.toSorted(entries, (a, b) => + switch (a.order, b.order) { + | (Some(a), Some(b)) => a > b ? 1.0 : -1.0 + | _ => -1.0 + } + ) + +let groupBySection = (entries: array) => + Array.reduce(entries, (Dict.make() :> Dict.t>), (acc, item) => { + let section = item.section->Option.flatMap(Dict.get(acc, _)) + switch section { + | Some(section) => section->Array.push(item) + | None => item.section->Option.forEach(section => acc->Dict.set(section, [item])) + } + acc + }) + +let convertToNavItems = (items: array, rootPath) => Array.map(items, (item): SidebarLayout.Sidebar.NavItem.t => { - let href = switch item.Mdx.slug { + let href = switch item.slug { | Some(slug) => `${rootPath}/${slug}` | None => rootPath } diff --git a/src/SidebarHelpers.resi b/src/SidebarHelpers.resi index 10b79cecd..8b5671dc2 100644 --- a/src/SidebarHelpers.resi +++ b/src/SidebarHelpers.resi @@ -1,5 +1,17 @@ -/** Convert Mdx.attributes to sidebar nav items, building hrefs from rootPath + slug. */ -let convertToNavItems: (array, string) => array +/** Filter sidebar entries whose path contains the given substring. */ +let filterByPath: (array, string) => array + +/** Sort sidebar entries by their `order` field (ascending). */ +let sortByOrder: array => array + +/** Group sidebar entries by their `section` field into a dict. */ +let groupBySection: array => Dict.t> + +/** Convert sidebar entries to nav items, building hrefs from rootPath + slug. */ +let convertToNavItems: ( + array, + string, +) => array /** Get a single sidebar category by name from a dict of grouped nav items. */ let getGroup: (