diff --git a/AGENTS.md b/AGENTS.md index c7024c1b4..539a7ea1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,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 diff --git a/app/routes.res b/app/routes.res index 656db708c..83637584b 100644 --- a/app/routes.res +++ b/app/routes.res @@ -28,7 +28,18 @@ 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(path => path === "blog" || String.startsWith(path, "blog/")) + ->Option.getOr(false) + ) + ) let default = [ index("./routes/LandingPageRoute.jsx"), @@ -44,6 +55,7 @@ let default = [ route("docs/manual/api/dom", "./routes/ApiRoute.jsx", ~options={id: "api-dom"}), ...stdlibRoutes, ...beltRoutes, + ...blogArticleRoutes, ...mdxRoutes, route("*", "./routes/NotFoundRoute.jsx"), ] diff --git a/app/routes/BlogArticleRoute.res b/app/routes/BlogArticleRoute.res new file mode 100644 index 000000000..6644a1509 --- /dev/null +++ b/app/routes/BlogArticleRoute.res @@ -0,0 +1,54 @@ +type loaderData = { + compiledMdx: CompiledMdx.t, + blogPost: BlogApi.post, + title: string, +} + +let loader: ReactRouter.Loader.t = 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() + + + + +} diff --git a/app/routes/BlogArticleRoute.resi b/app/routes/BlogArticleRoute.resi new file mode 100644 index 000000000..40ba9580c --- /dev/null +++ b/app/routes/BlogArticleRoute.resi @@ -0,0 +1,9 @@ +type loaderData = { + compiledMdx: CompiledMdx.t, + blogPost: BlogApi.post, + title: string, +} + +let loader: ReactRouter.Loader.t + +let default: unit => React.element diff --git a/app/routes/MdxRoute.res b/app/routes/MdxRoute.res index 55f1bf4fb..f61e9db29 100644 --- a/app/routes/MdxRoute.res +++ b/app/routes/MdxRoute.res @@ -4,7 +4,6 @@ type loaderData = { ...Mdx.t, categories: array, entries: array, - blogPost?: BlogApi.post, mdxSources?: array, activeSyntaxItem?: SyntaxLookup.item, breadcrumbs?: list, @@ -134,18 +133,7 @@ let loader: ReactRouter.Loader.t = 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 => @@ -418,12 +406,6 @@ let default = () => {
{component()}
- } else if (pathname :> string)->String.includes("blog") { - switch loaderData.blogPost { - | Some({frontmatter, archived, path}) => - {component()} - | None => React.null // TODO: Post RR7 show an error? - } } else { switch loaderData.mdxSources { | Some(mdxSources) => diff --git a/app/routes/MdxRoute.resi b/app/routes/MdxRoute.resi index b6a26c12c..8e4a827fb 100644 --- a/app/routes/MdxRoute.resi +++ b/app/routes/MdxRoute.resi @@ -2,7 +2,6 @@ type loaderData = { ...Mdx.t, categories: array, entries: array, - blogPost?: BlogApi.post, mdxSources?: array, activeSyntaxItem?: SyntaxLookup.item, breadcrumbs?: list, diff --git a/package.json b/package.json index fc082b108..8f43a6852 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,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", diff --git a/src/MdxFile.res b/src/MdxFile.res new file mode 100644 index 000000000..a9bbf9d9f --- /dev/null +++ b/src/MdxFile.res @@ -0,0 +1,77 @@ +type fileData = { + content: string, + frontmatter: JSON.t, +} + +type compileInput = {value: string, path: string} +type compileOptions = { + outputFormat: string, + remarkPlugins: array, +} +@module("@mdx-js/mdx") +external compile: (compileInput, compileOptions) => promise = "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 = + 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 + } + 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 + }) +} diff --git a/src/MdxFile.resi b/src/MdxFile.resi new file mode 100644 index 000000000..9ca94395e --- /dev/null +++ b/src/MdxFile.resi @@ -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 + +/** 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 + +/** Compile raw MDX content into a function-body string using @mdx-js/mdx. */ +let compileMdx: ( + string, + ~filePath: string, + ~remarkPlugins: array=?, +) => promise diff --git a/src/common/CompiledMdx.res b/src/common/CompiledMdx.res new file mode 100644 index 000000000..840c4dde2 --- /dev/null +++ b/src/common/CompiledMdx.res @@ -0,0 +1,5 @@ +type t = string + +type compileResult + +@send external fromCompileResult: compileResult => t = "toString" diff --git a/src/common/CompiledMdx.resi b/src/common/CompiledMdx.resi new file mode 100644 index 000000000..3e9eee388 --- /dev/null +++ b/src/common/CompiledMdx.resi @@ -0,0 +1,5 @@ +type t + +type compileResult + +@send external fromCompileResult: compileResult => t = "toString" diff --git a/src/common/MarkdownParser.res b/src/common/MarkdownParser.res index 3a0a756ab..1974c75a5 100644 --- a/src/common/MarkdownParser.res +++ b/src/common/MarkdownParser.res @@ -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} + +let stripFrontmatterPlugin = makePlugin(_options => + (tree, _vfile) => { + tree.children = tree.children->Array.filter(node => node.type_ !== "yaml") + } +) + let parser = make() ->use(remarkParse) @@ -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) diff --git a/src/components/MdxContent.res b/src/components/MdxContent.res new file mode 100644 index 000000000..9b51cc393 --- /dev/null +++ b/src/components/MdxContent.res @@ -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 +} diff --git a/src/components/MdxContent.resi b/src/components/MdxContent.resi new file mode 100644 index 000000000..882a3530b --- /dev/null +++ b/src/components/MdxContent.resi @@ -0,0 +1,2 @@ +@react.component +let make: (~compiledMdx: CompiledMdx.t) => React.element diff --git a/vitest.config.mjs b/vitest.config.mjs index c21428506..dbaeaa05f 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -17,6 +17,7 @@ export default defineConfig({ }), ui: false, // https://vitest.dev/config/browser/playwright + provider: playwright(), instances: [ { browser: "chromium", diff --git a/yarn.lock b/yarn.lock index 02ae82c71..6ccb0d181 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2003,7 +2003,7 @@ __metadata: languageName: node linkType: hard -"@mdx-js/mdx@npm:^3.1.0": +"@mdx-js/mdx@npm:^3.1.0, @mdx-js/mdx@npm:^3.1.1": version: 3.1.1 resolution: "@mdx-js/mdx@npm:3.1.1" dependencies: @@ -9489,6 +9489,7 @@ __metadata: "@docsearch/react": "npm:^4.3.1" "@headlessui/react": "npm:^2.2.4" "@lezer/highlight": "npm:^1.2.1" + "@mdx-js/mdx": "npm:^3.1.1" "@node-cli/static-server": "npm:^3.1.4" "@prettier/plugin-oxc": "npm:^0.0.4" "@react-router/dev": "npm:^7.8.1"