Skip to content

Commit a3ed0b8

Browse files
refactor: split out blog route from shared MdxRoute file (#1213)
* tests: add more unit tests to cover markdown related components * more unit tests * fix failing tests * refactor: split out blog route from shared MdxRoute file * chore: update vitest screenshots [skip ci] * fix issue with front-matter being rendered * chore: update vitest screenshots [skip ci] * fix: address review issues from PR #1213 - Use String.startsWith instead of String.includes for blog route filtering to avoid accidentally excluding non-blog routes that contain 'blog' as a substring - Replace JsExn.throw with JsError.throwWithMessage in BlogArticleRoute for consistency with BlogApi.res and to get proper Error objects with stack traces - Normalize path separators in MdxFile.scanDir to fix Windows compatibility where Node.Path.join2 produces backslashes * chore: update vitest screenshots [skip ci] * restore screenshots * restore screenshot * chore: update vitest screenshots [skip ci] * custom rendering of MDX * chore: update vitest screenshots [skip ci] * remove unused dep * tests: Stabilize screenshot tests and clean up flaky infrastructure * Update image caption test and refresh screenshots Updates the Markdown image caption test to use a new caption and image. Regenerates all related test screenshots to reflect the change. * Update test scripts to separate snapshot updates in vitest * Document Vitest usage and remove old screenshot baselines Add detailed instructions for running and updating Vitest browser-based unit tests in the README. Remove outdated screenshot baseline PNGs from __tests__/__screenshots__. * Update image test to add className and refresh screenshot Add guidance in README to be selective when updating screenshots. * Update testing guidelines for React Router and snapshot usage * reset images * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 19de01d commit a3ed0b8

16 files changed

+309
-22
lines changed

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ __tests__/ → Vitest browser-mode tests (Playwright)
6565
- Output format is ES modules with `.jsx` suffix, compiled in-source (`.jsx` files sit alongside `.res` files).
6666
- Reference the abridged documentation for clarification on how ReScript's APIs work: https://rescript-lang.org/llms/manual/llm-small.txt
6767
- 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
68+
- Never use `%raw` unless you are specifically asked to
69+
- Never use `Object.magic`
70+
- 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.
6871

6972
### ReScript Dependencies
7073

app/routes.res

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,18 @@ let stdlibRoutes =
2828
let beltRoutes =
2929
beltPaths->Array.map(path => route(path, "./routes/ApiRoute.jsx", ~options={id: path}))
3030

31-
let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")
31+
let blogArticleRoutes =
32+
MdxFile.scanPaths(~dir="markdown-pages/blog", ~alias="blog")->Array.map(path =>
33+
route(path, "./routes/BlogArticleRoute.jsx", ~options={id: path})
34+
)
35+
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)
41+
)
42+
)
3243

3344
let default = [
3445
index("./routes/LandingPageRoute.jsx"),
@@ -44,6 +55,7 @@ let default = [
4455
route("docs/manual/api/dom", "./routes/ApiRoute.jsx", ~options={id: "api-dom"}),
4556
...stdlibRoutes,
4657
...beltRoutes,
58+
...blogArticleRoutes,
4759
...mdxRoutes,
4860
route("*", "./routes/NotFoundRoute.jsx"),
4961
]

app/routes/BlogArticleRoute.res

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
type loaderData = {
2+
compiledMdx: CompiledMdx.t,
3+
blogPost: BlogApi.post,
4+
title: string,
5+
}
6+
7+
let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => {
8+
let {pathname} = WebAPI.URL.make(~url=request.url)
9+
let filePath = MdxFile.resolveFilePath(
10+
(pathname :> string),
11+
~dir="markdown-pages/blog",
12+
~alias="blog",
13+
)
14+
15+
let raw = await Node.Fs.readFile(filePath, "utf-8")
16+
let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw)
17+
18+
let frontmatter = switch BlogFrontmatter.decode(frontmatter) {
19+
| Ok(fm) => fm
20+
| Error(msg) => JsError.throwWithMessage(msg)
21+
}
22+
23+
let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins)
24+
25+
let archived = filePath->String.includes("/archived/")
26+
27+
let slug =
28+
filePath
29+
->Node.Path.basename
30+
->String.replace(".mdx", "")
31+
->String.replaceRegExp(/^\d\d\d\d-\d\d-\d\d-/, "")
32+
33+
let path = archived ? "archived/" ++ slug : slug
34+
35+
let blogPost: BlogApi.post = {
36+
path,
37+
archived,
38+
frontmatter,
39+
}
40+
41+
{
42+
compiledMdx,
43+
blogPost,
44+
title: `${frontmatter.title} | ReScript Blog`,
45+
}
46+
}
47+
48+
let default = () => {
49+
let {compiledMdx, blogPost: {frontmatter, archived, path}} = ReactRouter.useLoaderData()
50+
51+
<BlogArticle frontmatter isArchived=archived path>
52+
<MdxContent compiledMdx />
53+
</BlogArticle>
54+
}

app/routes/BlogArticleRoute.resi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
type loaderData = {
2+
compiledMdx: CompiledMdx.t,
3+
blogPost: BlogApi.post,
4+
title: string,
5+
}
6+
7+
let loader: ReactRouter.Loader.t<loaderData>
8+
9+
let default: unit => React.element

app/routes/MdxRoute.res

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ type loaderData = {
44
...Mdx.t,
55
categories: array<SidebarLayout.Sidebar.Category.t>,
66
entries: array<TableOfContents.entry>,
7-
blogPost?: BlogApi.post,
87
mdxSources?: array<SyntaxLookup.item>,
98
activeSyntaxItem?: SyntaxLookup.item,
109
breadcrumbs?: list<Url.breadcrumb>,
@@ -134,18 +133,7 @@ let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => {
134133

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

137-
if pathname->String.includes("blog") {
138-
let res: loaderData = {
139-
__raw: mdx.__raw,
140-
attributes: mdx.attributes,
141-
entries: [],
142-
categories: [],
143-
blogPost: mdx.attributes->BlogLoader.transform,
144-
title: `${mdx.attributes.title} | ReScript Blog`,
145-
filePath: None,
146-
}
147-
res
148-
} else if pathname->String.includes("syntax-lookup") {
136+
if pathname->String.includes("syntax-lookup") {
149137
let mdxSources =
150138
(await allMdx(~filterByPaths=["markdown-pages/syntax-lookup"]))
151139
->Array.filter(page =>
@@ -418,12 +406,6 @@ let default = () => {
418406
<CommunityLayout categories entries>
419407
<div className="markdown-body"> {component()} </div>
420408
</CommunityLayout>
421-
} else if (pathname :> string)->String.includes("blog") {
422-
switch loaderData.blogPost {
423-
| Some({frontmatter, archived, path}) =>
424-
<BlogArticle frontmatter isArchived=archived path> {component()} </BlogArticle>
425-
| None => React.null // TODO: Post RR7 show an error?
426-
}
427409
} else {
428410
switch loaderData.mdxSources {
429411
| Some(mdxSources) =>

app/routes/MdxRoute.resi

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ type loaderData = {
22
...Mdx.t,
33
categories: array<SidebarLayout.Sidebar.Category.t>,
44
entries: array<TableOfContents.entry>,
5-
blogPost?: BlogApi.post,
65
mdxSources?: array<SyntaxLookup.item>,
76
activeSyntaxItem?: SyntaxLookup.item,
87
breadcrumbs?: list<Url.breadcrumb>,

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@docsearch/react": "^4.3.1",
4949
"@headlessui/react": "^2.2.4",
5050
"@lezer/highlight": "^1.2.1",
51+
"@mdx-js/mdx": "^3.1.1",
5152
"@node-cli/static-server": "^3.1.4",
5253
"@react-router/node": "^7.8.1",
5354
"@replit/codemirror-vim": "^6.3.0",

src/MdxFile.res

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
type fileData = {
2+
content: string,
3+
frontmatter: JSON.t,
4+
}
5+
6+
type compileInput = {value: string, path: string}
7+
type compileOptions = {
8+
outputFormat: string,
9+
remarkPlugins: array<Mdx.remarkPlugin>,
10+
}
11+
@module("@mdx-js/mdx")
12+
external compile: (compileInput, compileOptions) => promise<CompiledMdx.compileResult> = "compile"
13+
14+
@module("remark-frontmatter") external remarkFrontmatter: Mdx.remarkPlugin = "default"
15+
16+
let compileMdx = async (content, ~filePath, ~remarkPlugins=[]) => {
17+
let compiled = await compile(
18+
{value: content, path: filePath},
19+
{
20+
outputFormat: "function-body",
21+
remarkPlugins: [remarkFrontmatter, ...remarkPlugins],
22+
},
23+
)
24+
compiled->CompiledMdx.fromCompileResult
25+
}
26+
27+
let resolveFilePath = (pathname, ~dir, ~alias) => {
28+
let path = if pathname->String.startsWith("/") {
29+
pathname->String.slice(~start=1, ~end=String.length(pathname))
30+
} else {
31+
pathname
32+
}
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+
}
43+
relativePath ++ ".mdx"
44+
}
45+
46+
let loadFile = async filePath => {
47+
let raw = await Node.Fs.readFile(filePath, "utf-8")
48+
let {frontmatter, content}: MarkdownParser.result = MarkdownParser.parseSync(raw)
49+
{content, frontmatter}
50+
}
51+
52+
// Recursively scan a directory for .mdx files
53+
let rec scanDir = (baseDir, currentDir) => {
54+
let entries = Node.Fs.readdirSync(currentDir)
55+
entries->Array.flatMap(entry => {
56+
let fullPath = Node.Path.join2(currentDir, entry)
57+
if Node.Fs.statSync(fullPath)["isDirectory"]() {
58+
scanDir(baseDir, fullPath)
59+
} else if Node.Path.extname(entry) === ".mdx" {
60+
// Get the relative path from baseDir
61+
let relativePath =
62+
fullPath
63+
->String.replaceAll("\\", "/")
64+
->String.replace(baseDir->String.replaceAll("\\", "/") ++ "/", "")
65+
->String.replace(".mdx", "")
66+
[relativePath]
67+
} else {
68+
[]
69+
}
70+
})
71+
}
72+
73+
let scanPaths = (~dir, ~alias) => {
74+
scanDir(dir, dir)->Array.map(relativePath => {
75+
alias ++ "/" ++ relativePath
76+
})
77+
}

src/MdxFile.resi

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
type fileData = {
2+
content: string,
3+
frontmatter: JSON.t,
4+
}
5+
6+
/** Maps a URL pathname to an .mdx file path on disk.
7+
* e.g. `/blog/release-12-0-0` with ~dir="markdown-pages/blog" ~alias="blog"
8+
* → `markdown-pages/blog/release-12-0-0.mdx`
9+
*/
10+
let resolveFilePath: (string, ~dir: string, ~alias: string) => string
11+
12+
/** Read a file from disk and parse its frontmatter using MarkdownParser. */
13+
let loadFile: string => promise<fileData>
14+
15+
/** Scan a directory recursively for .mdx files and return URL paths.
16+
* e.g. scanPaths(~dir="markdown-pages/blog", ~alias="blog")
17+
* → ["blog/release-12-0-0", "blog/archived/some-post", ...]
18+
*/
19+
let scanPaths: (~dir: string, ~alias: string) => array<string>
20+
21+
/** Compile raw MDX content into a function-body string using @mdx-js/mdx. */
22+
let compileMdx: (
23+
string,
24+
~filePath: string,
25+
~remarkPlugins: array<Mdx.remarkPlugin>=?,
26+
) => promise<CompiledMdx.t>

src/common/CompiledMdx.res

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
type t = string
2+
3+
type compileResult
4+
5+
@send external fromCompileResult: compileResult => t = "toString"

0 commit comments

Comments
 (0)