Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ __tests__/ → Vitest browser-mode tests (Playwright)
- Output format is ES modules with `.jsx` suffix, compiled in-source (`.jsx` files sit alongside `.res` files).
- Reference the abridged documentation for clarification on how ReScript's APIs work: https://rescript-lang.org/llms/manual/llm-small.txt
- If you need more information you can access the full documentation, but do this only when needed as the docs are very large: https://rescript-lang.org/llms/manual/llm-full.txt
- Never use `%raw` unless you are specifically asked to
- Never use `Object.magic`
- Don't add type annotations unless necessary for clarity or to resolve an error. ReScript's type inference is powerful, and often explicit annotations are not needed.

### ReScript Dependencies

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 10 additions & 1 deletion app/routes.res
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ let stdlibRoutes =
let beltRoutes =
beltPaths->Array.map(path => route(path, "./routes/ApiRoute.jsx", ~options={id: path}))

let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")
let blogArticleRoutes =
MdxFile.scanPaths(~dir="markdown-pages/blog", ~alias="blog")->Array.map(path =>
route(path, "./routes/BlogArticleRoute.jsx", ~options={id: path})
)

let mdxRoutes =
mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r =>
!(r.path->Option.map(String.startsWith(_, "blog"))->Option.getOr(false))
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter String.startsWith(_, "blog") will exclude any generated MDX route whose path happens to start with blog (e.g. blogger/...), not just blog/ and blog. To avoid surprising exclusions, consider matching "blog" exactly or the "blog/" prefix instead.

Suggested change
!(r.path->Option.map(String.startsWith(_, "blog"))->Option.getOr(false))
!(r.path
->Option.map(path => path === "blog" || String.startsWith(path, "blog/"))
->Option.getOr(false)
)

Copilot uses AI. Check for mistakes.
)

let default = [
index("./routes/LandingPageRoute.jsx"),
Expand All @@ -44,6 +52,7 @@ let default = [
route("docs/manual/api/dom", "./routes/ApiRoute.jsx", ~options={id: "api-dom"}),
...stdlibRoutes,
...beltRoutes,
...blogArticleRoutes,
...mdxRoutes,
route("*", "./routes/NotFoundRoute.jsx"),
]
54 changes: 54 additions & 0 deletions app/routes/BlogArticleRoute.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
type loaderData = {
compiledMdx: CompiledMdx.t,
blogPost: BlogApi.post,
title: string,
}

let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => {
let {pathname} = WebAPI.URL.make(~url=request.url)
let filePath = MdxFile.resolveFilePath(
(pathname :> string),
~dir="markdown-pages/blog",
~alias="blog",
)

let raw = await Node.Fs.readFile(filePath, "utf-8")
let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw)

let frontmatter = switch BlogFrontmatter.decode(frontmatter) {
| Ok(fm) => fm
| Error(msg) => JsError.throwWithMessage(msg)
}

let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins)

let archived = filePath->String.includes("/archived/")

let slug =
filePath
->Node.Path.basename
->String.replace(".mdx", "")
->String.replaceRegExp(/^\d\d\d\d-\d\d-\d\d-/, "")

let path = archived ? "archived/" ++ slug : slug

let blogPost: BlogApi.post = {
path,
archived,
frontmatter,
}

{
compiledMdx,
blogPost,
title: `${frontmatter.title} | ReScript Blog`,
}
}

let default = () => {
let {compiledMdx, blogPost: {frontmatter, archived, path}} = ReactRouter.useLoaderData()

<BlogArticle frontmatter isArchived=archived path>
<MdxContent compiledMdx />
</BlogArticle>
}
9 changes: 9 additions & 0 deletions app/routes/BlogArticleRoute.resi
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type loaderData = {
compiledMdx: CompiledMdx.t,
blogPost: BlogApi.post,
title: string,
}

let loader: ReactRouter.Loader.t<loaderData>

let default: unit => React.element
20 changes: 1 addition & 19 deletions app/routes/MdxRoute.res
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ type loaderData = {
...Mdx.t,
categories: array<SidebarLayout.Sidebar.Category.t>,
entries: array<TableOfContents.entry>,
blogPost?: BlogApi.post,
mdxSources?: array<SyntaxLookup.item>,
activeSyntaxItem?: SyntaxLookup.item,
breadcrumbs?: list<Url.breadcrumb>,
Expand Down Expand Up @@ -134,18 +133,7 @@ let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => {

let mdx = await loadMdx(request, ~options={remarkPlugins: Mdx.plugins})

if pathname->String.includes("blog") {
let res: loaderData = {
__raw: mdx.__raw,
attributes: mdx.attributes,
entries: [],
categories: [],
blogPost: mdx.attributes->BlogLoader.transform,
title: `${mdx.attributes.title} | ReScript Blog`,
filePath: None,
}
res
} else if pathname->String.includes("syntax-lookup") {
if pathname->String.includes("syntax-lookup") {
let mdxSources =
(await allMdx(~filterByPaths=["markdown-pages/syntax-lookup"]))
->Array.filter(page =>
Expand Down Expand Up @@ -418,12 +406,6 @@ let default = () => {
<CommunityLayout categories entries>
<div className="markdown-body"> {component()} </div>
</CommunityLayout>
} else if (pathname :> string)->String.includes("blog") {
switch loaderData.blogPost {
| Some({frontmatter, archived, path}) =>
<BlogArticle frontmatter isArchived=archived path> {component()} </BlogArticle>
| None => React.null // TODO: Post RR7 show an error?
}
} else {
switch loaderData.mdxSources {
| Some(mdxSources) =>
Expand Down
1 change: 0 additions & 1 deletion app/routes/MdxRoute.resi
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ type loaderData = {
...Mdx.t,
categories: array<SidebarLayout.Sidebar.Category.t>,
entries: array<TableOfContents.entry>,
blogPost?: BlogApi.post,
mdxSources?: array<SyntaxLookup.item>,
activeSyntaxItem?: SyntaxLookup.item,
breadcrumbs?: list<Url.breadcrumb>,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@docsearch/react": "^4.3.1",
"@headlessui/react": "^2.2.4",
"@lezer/highlight": "^1.2.1",
"@mdx-js/mdx": "^3.1.1",
"@node-cli/static-server": "^3.1.4",
"@react-router/node": "^7.8.1",
"@replit/codemirror-vim": "^6.3.0",
Expand Down
68 changes: 68 additions & 0 deletions src/MdxFile.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
type fileData = {
content: string,
frontmatter: JSON.t,
}

type compileInput = {value: string, path: string}
type compileOptions = {
outputFormat: string,
remarkPlugins: array<Mdx.remarkPlugin>,
}
@module("@mdx-js/mdx")
external compile: (compileInput, compileOptions) => promise<CompiledMdx.compileResult> = "compile"

@module("remark-frontmatter") external remarkFrontmatter: Mdx.remarkPlugin = "default"

let compileMdx = async (content, ~filePath, ~remarkPlugins=[]) => {
let compiled = await compile(
{value: content, path: filePath},
{
outputFormat: "function-body",
remarkPlugins: [remarkFrontmatter, ...remarkPlugins],
},
)
compiled->CompiledMdx.fromCompileResult
}

let resolveFilePath = (pathname, ~dir, ~alias) => {
let path = if pathname->String.startsWith("/") {
pathname->String.slice(~start=1, ~end=String.length(pathname))
} else {
pathname
}
let relativePath = path->String.replace(alias, dir)
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveFilePath uses String.replace(alias, dir), which will replace the first occurrence of alias anywhere in the path, not necessarily as a path prefix. This can produce incorrect file paths if the alias appears later in the URL segment. Safer approach: explicitly strip a leading /${alias}/ (or ${alias}/) prefix and then Node.Path.join2(dir, rest) (or use a prefix-based replacement via regexp anchored at the start).

Suggested change
let relativePath = path->String.replace(alias, dir)
let relativePath =
if path->String.startsWith(alias ++ "/") {
let rest = path->String.slice(~start=String.length(alias) + 1, ~end=String.length(path))
Node.Path.join2(dir, rest)
} else if path->String.startsWith(alias) {
let rest = path->String.slice(~start=String.length(alias), ~end=String.length(path))
Node.Path.join2(dir, rest)
} else {
path
}

Copilot uses AI. Check for mistakes.
relativePath ++ ".mdx"
}

let loadFile = async filePath => {
let raw = await Node.Fs.readFile(filePath, "utf-8")
let {frontmatter, content}: MarkdownParser.result = MarkdownParser.parseSync(raw)
{content, frontmatter}
}

// Recursively scan a directory for .mdx files
let rec scanDir = (baseDir, currentDir) => {
let entries = Node.Fs.readdirSync(currentDir)
entries->Array.flatMap(entry => {
let fullPath = Node.Path.join2(currentDir, entry)
if Node.Fs.statSync(fullPath)["isDirectory"]() {
scanDir(baseDir, fullPath)
} else if Node.Path.extname(entry) === ".mdx" {
// Get the relative path from baseDir
let relativePath =
fullPath
->String.replaceAll("\\", "/")
->String.replace(baseDir->String.replaceAll("\\", "/") ++ "/", "")
->String.replace(".mdx", "")
[relativePath]
} else {
[]
}
})
}

let scanPaths = (~dir, ~alias) => {
scanDir(dir, dir)->Array.map(relativePath => {
alias ++ "/" ++ relativePath
})
}
26 changes: 26 additions & 0 deletions src/MdxFile.resi
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type fileData = {
content: string,
frontmatter: JSON.t,
}

/** Maps a URL pathname to an .mdx file path on disk.
* e.g. `/blog/release-12-0-0` with ~dir="markdown-pages/blog" ~alias="blog"
* → `markdown-pages/blog/release-12-0-0.mdx`
*/
let resolveFilePath: (string, ~dir: string, ~alias: string) => string

/** Read a file from disk and parse its frontmatter using MarkdownParser. */
let loadFile: string => promise<fileData>

/** Scan a directory recursively for .mdx files and return URL paths.
* e.g. scanPaths(~dir="markdown-pages/blog", ~alias="blog")
* → ["blog/release-12-0-0", "blog/archived/some-post", ...]
*/
let scanPaths: (~dir: string, ~alias: string) => array<string>

/** Compile raw MDX content into a function-body string using @mdx-js/mdx. */
let compileMdx: (
string,
~filePath: string,
~remarkPlugins: array<Mdx.remarkPlugin>=?,
) => promise<CompiledMdx.t>
5 changes: 5 additions & 0 deletions src/common/CompiledMdx.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type t = string

type compileResult

@send external fromCompileResult: compileResult => t = "toString"
5 changes: 5 additions & 0 deletions src/common/CompiledMdx.resi
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type t

type compileResult

@send external fromCompileResult: compileResult => t = "toString"
10 changes: 10 additions & 0 deletions src/common/MarkdownParser.res
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ type result = {

let vfileMatterPlugin = makePlugin(_options => (_tree, vfile) => vfileMatter(vfile))

type remarkNode = {@as("type") type_: string}
type remarkTree = {mutable children: array<remarkNode>}

let stripFrontmatterPlugin = makePlugin(_options =>
(tree, _vfile) => {
tree.children = tree.children->Array.filter(node => node.type_ !== "yaml")
}
)

let parser =
make()
->use(remarkParse)
Expand All @@ -35,6 +44,7 @@ let parser =
->use(remarkComment)
->useOptions(remarkFrontmatter, [{"type": "yaml", "marker": "-"}])
->use(vfileMatterPlugin)
->use(stripFrontmatterPlugin)

let parseSync = content => {
let vfile = parser->processSync(content)
Expand Down
100 changes: 100 additions & 0 deletions src/components/MdxContent.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// ---------------------------------------------------------------------------
// JSX runtime values needed by runSync
// ---------------------------------------------------------------------------

// We re-import the jsx-runtime exports as opaque values so we can pass them
// through to runSync without running into ReScript's monomorphisation of
// the polymorphic `React.jsx` / `React.jsxs` signatures.
/**
* MdxContent — renders compiled MDX content as a React component.
*
* Uses `runSync` from `@mdx-js/mdx` to evaluate compiled MDX (produced by
* `MdxFile.compileMdx`) and renders the result with a shared component map.
*/
type jsxRuntimeValue

@module("react/jsx-runtime") external fragment: jsxRuntimeValue = "Fragment"
@module("react/jsx-runtime") external jsx: jsxRuntimeValue = "jsx"
@module("react/jsx-runtime") external jsxs: jsxRuntimeValue = "jsxs"

@val @scope(("import", "meta")) external importMetaUrl: string = "url"

// ---------------------------------------------------------------------------
// @mdx-js/mdx runSync binding
// ---------------------------------------------------------------------------

type runOptions = {
@as("Fragment") fragment: jsxRuntimeValue,
jsx: jsxRuntimeValue,
jsxs: jsxRuntimeValue,
baseUrl: string,
}

type mdxModule

@module("@mdx-js/mdx")
external runSync: (CompiledMdx.t, runOptions) => mdxModule = "runSync"

@get external getDefault: mdxModule => React.component<{..}> = "default"

let runOptions = {
fragment,
jsx,
jsxs,
baseUrl: importMetaUrl,
}

// ---------------------------------------------------------------------------
// Shared MDX component map
// ---------------------------------------------------------------------------

let components = {
// Standard HTML element overrides
"a": Markdown.A.make,
"blockquote": Markdown.Blockquote.make,
"code": Markdown.Code.make,
"h1": Markdown.H1.make,
"h2": Markdown.H2.make,
"h3": Markdown.H3.make,
"h4": Markdown.H4.make,
"h5": Markdown.H5.make,
"hr": Markdown.Hr.make,
"li": Markdown.Li.make,
"ol": Markdown.Ol.make,
"p": Markdown.P.make,
"pre": Markdown.Pre.make,
"strong": Markdown.Strong.make,
"table": Markdown.Table.make,
"th": Markdown.Th.make,
"thead": Markdown.Thead.make,
"td": Markdown.Td.make,
"ul": Markdown.Ul.make,
// Custom MDX components
"Cite": Markdown.Cite.make,
"CodeTab": Markdown.CodeTab.make,
"Image": Markdown.Image.make,
"Info": Markdown.Info.make,
"Intro": Markdown.Intro.make,
"UrlBox": Markdown.UrlBox.make,
"Video": Markdown.Video.make,
"Warn": Markdown.Warn.make,
"CommunityContent": CommunityContent.make,
"WarningTable": WarningTable.make,
"Docson": DocsonLazy.make,
"Suspense": React.Suspense.make,
}

// ---------------------------------------------------------------------------
// React component
// ---------------------------------------------------------------------------

@react.component
let make = (~compiledMdx: CompiledMdx.t) => {
let element = React.useMemo(() => {
let mdxModule = runSync(compiledMdx, runOptions)
let content = getDefault(mdxModule)
React.jsx(content, {"components": components})
}, [compiledMdx])

element
}
2 changes: 2 additions & 0 deletions src/components/MdxContent.resi
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@react.component
let make: (~compiledMdx: CompiledMdx.t) => React.element
Loading
Loading