|
| 1 | +# Plan: Further Simplify MDX & Markdown Code |
| 2 | + |
| 3 | +## Current State (post Phase 1-4 cleanup) |
| 4 | + |
| 5 | +The MDX/markdown infrastructure is functional but has accumulated structural debt: |
| 6 | + |
| 7 | +- **`Mdx.res`** is a grab-bag: domain types, sidebar utilities, remark plugins, and AST helpers all in one file |
| 8 | +- **`Mdx.attributes`** is a super-union of every frontmatter field across blog, docs, community, and syntax-lookup |
| 9 | +- **Dead types** remain in `Mdx.res` (`t`, `tree`, `badge`) |
| 10 | +- **Duplicate types** exist between `Mdx.res` and `BlogFrontmatter.res` (`social`, `author`) |
| 11 | +- **`MdxFile.loadFile`** is dead code |
| 12 | +- **`MarkdownParser.res`** exposes all internals (no `.resi`) |
| 13 | +- **Double frontmatter parsing**: every route runs both `MarkdownParser.parseSync` and `MdxFile.compileMdx` on the same raw content; the compile step internally re-parses frontmatter via `remark-frontmatter` |
| 14 | +- **Identical boilerplate** across 7 route loaders: `resolveFilePath -> readFile -> parseSync -> compileMdx` |
| 15 | +- **Identical ToC extraction** copy-pasted across 4 routes |
| 16 | +- **Identical frontmatter field extraction** (`switch frontmatter | Object(dict) => switch dict->Dict.get("field") ...`) repeated 10+ times |
| 17 | + |
| 18 | +### File inventory |
| 19 | + |
| 20 | +| File | Role | External API surface | |
| 21 | +| -------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ | |
| 22 | +| `src/Mdx.res` | Types + sidebar utils + remark plugins | `attributes`, `remarkPlugin`, `plugins`, `filterMdxPages`, `groupBySection`, `sortSection` | |
| 23 | +| `src/MdxFile.res` | File I/O + MDX compilation | `compileMdx`, `compileMarkdown`, `resolveFilePath`, `scanPaths`, `loadAllAttributes` | |
| 24 | +| `src/common/MarkdownParser.res` | Unified/remark pipeline for frontmatter | `parseSync`, `result` | |
| 25 | +| `src/common/CompiledMdx.res` | Opaque compiled MDX string | `t`, `compileResult`, `fromCompileResult` | |
| 26 | +| `src/components/MdxContent.res` | Runtime MDX evaluation + rendering | `render`, `make`, `components` | |
| 27 | +| `src/components/Markdown.res` | 22+ styled React components for MDX | All sub-modules (P, H1, H2, Code, A, Li, etc.) | |
| 28 | +| `src/bindings/Mdast.res` | mdast-util bindings for ToC extraction | `fromMarkdown`, `toc`, `reduceHeaders` | |
| 29 | +| `src/common/BlogFrontmatter.res` | Blog frontmatter decoder | `decode`, `t`, `author`, `social`, `Badge.t` | |
| 30 | +| `src/common/BlogApi.res` | Blog post listing from filesystem | `getAllPosts`, `getLivePosts`, `getArchivedPosts`, `RssFeed` | |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## Goals |
| 35 | + |
| 36 | +1. Remove dead code |
| 37 | +2. Eliminate duplicate types |
| 38 | +3. Reduce route loader boilerplate |
| 39 | +4. Encapsulate internal modules behind `.resi` files |
| 40 | +5. Give `Mdx.res` a clearer single responsibility |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## Step-by-step Plan |
| 45 | + |
| 46 | +### Phase 1: Delete dead code |
| 47 | + |
| 48 | +**Low risk, no behavior change.** |
| 49 | + |
| 50 | +1. Delete `Mdx.badge` variant type (dead -- `attributes.badge` is `Nullable.t<string>`, not `badge`) |
| 51 | +2. Delete `Mdx.t` type (dead -- never referenced; all compiled MDX uses `CompiledMdx.t`) |
| 52 | +3. Delete `Mdx.tree` type (dead -- never referenced externally; the `visit` binding uses `{..}` internally) |
| 53 | +4. Delete `MdxFile.loadFile` function (dead -- never called) |
| 54 | + |
| 55 | +#### Files to modify |
| 56 | + |
| 57 | +| File | Change | |
| 58 | +| ----------------- | ------------------------------------------ | |
| 59 | +| `src/Mdx.res` | Remove `type badge`, `type t`, `type tree` | |
| 60 | +| `src/MdxFile.res` | Remove `loadFile` function | |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +### Phase 2: Deduplicate `social` and `author` types |
| 65 | + |
| 66 | +**Low risk. `BlogFrontmatter.res` is the canonical owner of these types since it defines the author registry and decoder.** |
| 67 | + |
| 68 | +`Mdx.res` and `BlogFrontmatter.res` both define identical `social` and `author` types. The only reason they exist in `Mdx.res` is that `Mdx.attributes.co_authors` is typed as `Nullable.t<array<author>>`. But `Mdx.attributes` is only ever populated via an unsafe `%identity` cast in `MdxFile.loadAllAttributes`, so the structural shape is what matters, not the type provenance. |
| 69 | + |
| 70 | +1. Remove `type social` and `type author` from `Mdx.res` |
| 71 | +2. Change `Mdx.attributes.co_authors` to reference `BlogFrontmatter.author`: |
| 72 | + ``` |
| 73 | + co_authors: Nullable.t<array<BlogFrontmatter.author>>, |
| 74 | + ``` |
| 75 | + |
| 76 | +#### Files to modify |
| 77 | + |
| 78 | +| File | Change | |
| 79 | +| ------------- | -------------------------------------------------------------------------------------------------- | |
| 80 | +| `src/Mdx.res` | Remove `social` and `author` types; update `attributes.co_authors` to use `BlogFrontmatter.author` | |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +### Phase 3: Add frontmatter helper and reduce route boilerplate |
| 85 | + |
| 86 | +**Medium risk. Touches every route loader, but each change is mechanical.** |
| 87 | + |
| 88 | +#### 3a: Create `Frontmatter.res` helper module |
| 89 | + |
| 90 | +The pattern below appears 10+ times across route loaders: |
| 91 | + |
| 92 | +```rescript |
| 93 | +let title = switch frontmatter { |
| 94 | +| Object(dict) => |
| 95 | + switch dict->Dict.get("title") { |
| 96 | + | Some(String(s)) => s |
| 97 | + | _ => "" |
| 98 | + } |
| 99 | +| _ => "" |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +Create a small helper: |
| 104 | + |
| 105 | +```rescript |
| 106 | +// src/common/Frontmatter.res |
| 107 | +let getString = (json: JSON.t, key: string): string => |
| 108 | + switch json { |
| 109 | + | Object(dict) => |
| 110 | + switch dict->Dict.get(key) { |
| 111 | + | Some(String(s)) => s |
| 112 | + | _ => "" |
| 113 | + } |
| 114 | + | _ => "" |
| 115 | + } |
| 116 | +
|
| 117 | +let getOptionalString = (json: JSON.t, key: string): option<string> => |
| 118 | + switch json { |
| 119 | + | Object(dict) => |
| 120 | + switch dict->Dict.get(key) { |
| 121 | + | Some(String(s)) => Some(s) |
| 122 | + | _ => None |
| 123 | + } |
| 124 | + | _ => None |
| 125 | + } |
| 126 | +``` |
| 127 | + |
| 128 | +Then every route replaces 8-line blocks with one-liners: |
| 129 | + |
| 130 | +```rescript |
| 131 | +let title = Frontmatter.getString(frontmatter, "title") |
| 132 | +let description = Frontmatter.getString(frontmatter, "description") |
| 133 | +``` |
| 134 | + |
| 135 | +#### 3b: Create `MdxLoader.res` shared loader pipeline |
| 136 | + |
| 137 | +Every route loader repeats the same 5-step core. Extract it: |
| 138 | + |
| 139 | +```rescript |
| 140 | +// src/common/MdxLoader.res |
| 141 | +
|
| 142 | +type pageData = { |
| 143 | + compiledMdx: CompiledMdx.t, |
| 144 | + frontmatter: JSON.t, |
| 145 | + filePath: string, |
| 146 | + raw: string, |
| 147 | +} |
| 148 | +
|
| 149 | +let loadPage = async (~dir: string, ~alias: string, ~request: WebAPI.Request.t) => { |
| 150 | + let {pathname} = WebAPI.URL.make(~url=request.url) |
| 151 | + let filePath = MdxFile.resolveFilePath((pathname :> string), ~dir, ~alias) |
| 152 | + let raw = await Node.Fs.readFile(filePath, "utf-8") |
| 153 | + let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw) |
| 154 | + let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins) |
| 155 | + {compiledMdx, frontmatter, filePath, raw} |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +#### 3c: Extract shared ToC builder |
| 160 | + |
| 161 | +The identical Mdast ToC block in 4 routes becomes: |
| 162 | + |
| 163 | +```rescript |
| 164 | +// src/common/MdxLoader.res (continued) |
| 165 | +
|
| 166 | +let buildToc = (raw: string): array<TableOfContents.entry> => { |
| 167 | + let markdownTree = Mdast.fromMarkdown(raw) |
| 168 | + let tocResult = Mdast.toc(markdownTree, {maxDepth: 2}) |
| 169 | + let headers = Dict.make() |
| 170 | + Mdast.reduceHeaders(tocResult.map, headers) |
| 171 | + headers |
| 172 | + ->Dict.toArray |
| 173 | + ->Array.map(((header, url)): TableOfContents.entry => { |
| 174 | + header, |
| 175 | + href: (url :> string), |
| 176 | + }) |
| 177 | + ->Array.slice(~start=2) |
| 178 | +} |
| 179 | +``` |
| 180 | + |
| 181 | +#### Route loaders after refactoring (example: DocsManualRoute) |
| 182 | + |
| 183 | +Before (~40 lines of loader boilerplate): |
| 184 | + |
| 185 | +```rescript |
| 186 | +let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => { |
| 187 | + let {pathname} = WebAPI.URL.make(~url=request.url) |
| 188 | + let filePath = MdxFile.resolveFilePath(...) |
| 189 | + let raw = await Node.Fs.readFile(filePath, "utf-8") |
| 190 | + let {frontmatter}: MarkdownParser.result = MarkdownParser.parseSync(raw) |
| 191 | + let title = switch frontmatter { | Object(dict) => ... } |
| 192 | + let description = switch frontmatter { | Object(dict) => ... } |
| 193 | + let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins) |
| 194 | + let markdownTree = Mdast.fromMarkdown(raw) |
| 195 | + let tocResult = Mdast.toc(markdownTree, {maxDepth: 2}) |
| 196 | + let headers = Dict.make() |
| 197 | + Mdast.reduceHeaders(tocResult.map, headers) |
| 198 | + let entries = headers->Dict.toArray->Array.map(...) |
| 199 | + let categories = await manualTableOfContents() |
| 200 | + { compiledMdx, categories, entries, title, description, filePath } |
| 201 | +} |
| 202 | +``` |
| 203 | + |
| 204 | +After (~15 lines): |
| 205 | + |
| 206 | +```rescript |
| 207 | +let loader: ReactRouter.Loader.t<loaderData> = async ({request}) => { |
| 208 | + let {compiledMdx, frontmatter, filePath, raw} = |
| 209 | + await MdxLoader.loadPage(~dir="markdown-pages/docs/manual", ~alias="docs/manual", ~request) |
| 210 | + let title = Frontmatter.getString(frontmatter, "title") |
| 211 | + let description = Frontmatter.getString(frontmatter, "description") |
| 212 | + let entries = MdxLoader.buildToc(raw) |
| 213 | + let categories = await manualTableOfContents() |
| 214 | + { compiledMdx, categories, entries, title, description, filePath } |
| 215 | +} |
| 216 | +``` |
| 217 | + |
| 218 | +#### Files to create |
| 219 | + |
| 220 | +| File | Purpose | |
| 221 | +| ---------------------------- | ----------------------------------------------- | |
| 222 | +| `src/common/Frontmatter.res` | JSON frontmatter field extraction helpers | |
| 223 | +| `src/common/MdxLoader.res` | Shared loader pipeline (`loadPage`, `buildToc`) | |
| 224 | + |
| 225 | +#### Files to modify |
| 226 | + |
| 227 | +| File | Change | |
| 228 | +| ---------------------------------------- | ------------------------------------------------------------------------- | |
| 229 | +| `app/routes/ApiOverviewRoute.res` | Use `MdxLoader.loadPage` + `Frontmatter.getString` | |
| 230 | +| `app/routes/BlogArticleRoute.res` | Use `MdxLoader.loadPage` + keep blog-specific slug logic | |
| 231 | +| `app/routes/CommunityRoute.res` | Use `MdxLoader.loadPage` + `MdxLoader.buildToc` + `Frontmatter.getString` | |
| 232 | +| `app/routes/DocsGuidelinesRoute.res` | Use `MdxLoader.loadPage` + `MdxLoader.buildToc` + `Frontmatter.getString` | |
| 233 | +| `app/routes/DocsManualRoute.res` | Use `MdxLoader.loadPage` + `MdxLoader.buildToc` + `Frontmatter.getString` | |
| 234 | +| `app/routes/DocsReactRoute.res` | Use `MdxLoader.loadPage` + `MdxLoader.buildToc` + `Frontmatter.getString` | |
| 235 | +| `app/routes/SyntaxLookupDetailRoute.res` | Use `MdxLoader.loadPage` + `Frontmatter.getString` | |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +### Phase 4: Encapsulate `MarkdownParser` internals |
| 240 | + |
| 241 | +**No risk. Only adds a `.resi` file to hide internal plumbing.** |
| 242 | + |
| 243 | +Only `parseSync` and `result` are used outside `MarkdownParser.res`. All other symbols (the unified processor, individual plugins, custom plugin functions, internal types) are implementation details. |
| 244 | + |
| 245 | +#### Files to create |
| 246 | + |
| 247 | +| File | Content | |
| 248 | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | |
| 249 | +| `src/common/MarkdownParser.resi` | Expose only `type result = { frontmatter: JSON.t, content: string }` and `let parseSync: string => result` | |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +### Phase 5: Clean up `Mdx.res` responsibilities |
| 254 | + |
| 255 | +**Low risk. Rename/reorganize for clarity.** |
| 256 | + |
| 257 | +After Phases 1-4, `Mdx.res` contains three distinct concerns: |
| 258 | + |
| 259 | +1. **`attributes` type** -- the frontmatter schema for MDX pages |
| 260 | +2. **Sidebar utilities** -- `filterMdxPages`, `groupBySection`, `sortSection` |
| 261 | +3. **Remark plugins** -- `remarkLinkPlugin`, `remarkReScriptPreludePlugin`, `anchorLinkPlugin`, and the bundled `plugins` array |
| 262 | + |
| 263 | +These are all tightly coupled (the sidebar utilities operate on `attributes`, the plugins are always passed alongside `attributes`-producing loaders), so splitting into separate files would create more indirection than clarity. Instead, add a `.resi` to hide internals and document the three groups: |
| 264 | + |
| 265 | +#### Files to create |
| 266 | + |
| 267 | +| File | Content | |
| 268 | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | |
| 269 | +| `src/Mdx.resi` | Expose only: `type attributes`, `type remarkPlugin`, `let plugins`, `let filterMdxPages`, `let groupBySection`, `let sortSection` | |
| 270 | + |
| 271 | +This hides: `gfm`, `childrenToString`, `visit`, `makePlugin`, `remarkReScriptPrelude`, `remarkLinkPlugin` (function), `remarkReScriptPreludePlugin`, `anchorLinkPlugin`, and the re-exported `remarkLinkPlugin` (plugin). |
| 272 | + |
| 273 | +--- |
| 274 | + |
| 275 | +## Execution Order |
| 276 | + |
| 277 | +``` |
| 278 | +Phase 1 (dead code) -- standalone, no dependencies |
| 279 | +Phase 2 (dedupe types) -- standalone, no dependencies |
| 280 | +Phase 3a (Frontmatter helper) -- standalone, new file |
| 281 | +Phase 3b (MdxLoader helper) -- depends on 3a |
| 282 | +Phase 3c (route refactors) -- depends on 3a + 3b |
| 283 | +Phase 4 (MarkdownParser resi) -- standalone |
| 284 | +Phase 5 (Mdx resi) -- depends on Phase 1 + 2 |
| 285 | +``` |
| 286 | + |
| 287 | +Phases 1, 2, 3a, and 4 can be done in parallel. Phase 3b depends on 3a. Phase 3c depends on 3b. Phase 5 depends on 1 and 2. |
| 288 | + |
| 289 | +--- |
| 290 | + |
| 291 | +## Impact Summary |
| 292 | + |
| 293 | +| Metric | Before | After | |
| 294 | +| ------------------------------------- | ------------------------ | ------------------------------------------ | |
| 295 | +| Dead types in `Mdx.res` | 3 (`t`, `tree`, `badge`) | 0 | |
| 296 | +| Dead functions in `MdxFile.res` | 1 (`loadFile`) | 0 | |
| 297 | +| Duplicate type definitions | 2 (`social`, `author`) | 0 | |
| 298 | +| Lines of loader boilerplate per route | ~35-45 | ~10-15 | |
| 299 | +| Repeated frontmatter switch blocks | 10+ | 0 | |
| 300 | +| Repeated ToC extraction blocks | 4 | 0 | |
| 301 | +| Modules with `.resi` encapsulation | 1 (`CompiledMdx`) | 3 (`CompiledMdx`, `MarkdownParser`, `Mdx`) | |
| 302 | + |
| 303 | +--- |
| 304 | + |
| 305 | +## Risks & Considerations |
| 306 | + |
| 307 | +1. **`Mdx.attributes` is populated via `%identity` cast.** Changing the type shape (Phase 2) doesn't affect runtime behavior since the cast ignores types. But if `BlogFrontmatter.author` ever diverges structurally from the old `Mdx.author`, the cast would silently produce bad data. This is a pre-existing risk, not a new one. |
| 308 | + |
| 309 | +2. **`MdxLoader.loadPage` returns `raw` for ToC building.** This means the raw content travels through the loader return. Since `raw` is only used server-side (in the loader itself), it should NOT be included in `loaderData` sent to the client. The `raw` field is consumed in the loader and discarded before returning `loaderData`. |
| 310 | + |
| 311 | +3. **Blog and API routes are special cases.** `BlogArticleRoute` uses `BlogFrontmatter.decode` instead of `Frontmatter.getString`. `ApiRoute` doesn't load MDX files at all (it reads JSON and compiles individual docstrings). Neither should be forced into the `MdxLoader.loadPage` pattern if it doesn't fit naturally. The blog route CAN use `loadPage` and then layer its own logic on top. The API route should be left alone. |
| 312 | + |
| 313 | +4. **Adding `.resi` files is a one-way door.** Once added, removing them requires auditing all call sites. But since these modules already have a clear internal/external split, this is low risk. |
0 commit comments