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 674cc3357..de00769fc 100644 --- a/app/routes.res +++ b/app/routes.res @@ -33,6 +33,11 @@ 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 communityRoutes = MdxFile.scanPaths(~dir="markdown-pages/community", ~alias="community")->Array.map(path => route(path, "./routes/CommunityRoute.jsx", ~options={id: path}) @@ -44,6 +49,8 @@ let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r => ->Option.map(path => path === "blog" || String.startsWith(path, "blog/") || + (path === "docs/manual" || String.startsWith(path, "docs/manual/")) && + path !== "docs/manual/api" || path === "community" || String.startsWith(path, "community/") ) @@ -66,6 +73,7 @@ let default = [ ...stdlibRoutes, ...beltRoutes, ...blogArticleRoutes, + ...docsManualRoutes, ...communityRoutes, ...mdxRoutes, route("*", "./routes/NotFoundRoute.jsx"), diff --git a/app/routes/DocsManualRoute.res b/app/routes/DocsManualRoute.res new file mode 100644 index 000000000..09588944e --- /dev/null +++ b/app/routes/DocsManualRoute.res @@ -0,0 +1,168 @@ +type loaderData = { + compiledMdx: CompiledMdx.t, + categories: array, + entries: array, + title: string, + description: string, + filePath: string, +} + +// 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 () => { + 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", + ], + ) + + manualTableOfContentsCache := Some(categories) + categories + } +} + +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/app/routes/MdxRoute.res b/app/routes/MdxRoute.res index 49c8ae1c8..5715b56a0 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"], ) @@ -148,8 +112,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 { @@ -199,22 +161,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 = { @@ -304,9 +258,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("")} /> @@ -315,9 +268,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 0366cf8b3..6b59845b5 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,47 @@ let scanPaths = (~dir, ~alias) => { alias ++ "/" ++ relativePath }) } + +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) + 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() + } + + { + 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 9ca94395e..37a75bcb5 100644 --- a/src/MdxFile.resi +++ b/src/MdxFile.resi @@ -24,3 +24,20 @@ let compileMdx: ( ~filePath: string, ~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 + * sidebar entries with `path` and `slug` fields populated. + */ +let loadAllAttributes: (~dir: string) => promise> diff --git a/src/SidebarHelpers.res b/src/SidebarHelpers.res new file mode 100644 index 000000000..3d2003742 --- /dev/null +++ b/src/SidebarHelpers.res @@ -0,0 +1,46 @@ +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.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..8b5671dc2 --- /dev/null +++ b/src/SidebarHelpers.resi @@ -0,0 +1,26 @@ +/** 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: ( + 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