From bf7ecd4744c4b7d3661345c0b6dbe265c6cff86d Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 2 May 2026 14:01:04 +0200 Subject: [PATCH 1/2] Add recursive MDX module resolution Teach resolveModules to treat preprocessed Markdown and MDX files as first-class modules alongside lazy JavaScript imports. The resolver now walks nested MDX imports, creates module exports through a caller-provided factory, and uses a cheap import-statement regex so leaf Markdown snippets avoid an extra parse during import discovery. This keeps recursive snippet rendering inside safe-mdx instead of forcing callers to hand-roll nested module maps. The shared modules object is intentionally captured by reference so circular or later-discovered imports remain visible when generated MDX components render. Validation: - pnpm test -- --run safe-mdx.test.tsx - pnpm build Session: ses_2bbe7a7767674a57abc0855d8b43490b --- src/parse.ts | 79 ++++++++++++++++++++++++++++++++++++------- src/safe-mdx.test.tsx | 62 ++++++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 57ca534..58c202d 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -257,6 +257,25 @@ function joinPaths(base: string, relative: string): string { export type LazyGlob = Record Promise>> export type EagerModules = Record> +export type MdxModuleFile = { + markdown: string + baseUrl: string +} + +export type MdxModuleFactory = (input: { + key: string + markdown: string + mdast?: Root + baseUrl: string + modules: EagerModules +}) => Record + +const importStatementRegex = /^\s*import\s+(?:[\s\S]*?\s+from\s+)?['"][^'"]+['"]/m + +function hasImportStatements(markdown: string): boolean { + return importStatementRegex.test(markdown) +} + /** * Given a lazy Vite glob and a parsed mdast, resolve only the imported * modules eagerly. Returns the exact shape `SafeMdxRenderer.modules` expects. @@ -273,26 +292,60 @@ export async function resolveModules({ glob, mdast, baseUrl, + mdxFiles, + createMdxModule, }: { glob: LazyGlob mdast: Root baseUrl: string + mdxFiles?: Record + createMdxModule?: MdxModuleFactory }): Promise { - const imports = extractImports(mdast) - if (imports.length === 0) return {} - - const keys = Object.keys(glob) const result: EagerModules = {} - await Promise.all( - imports.map(async (imp) => { - const resolved = resolveModulePath(imp.source, baseUrl, keys) - if (!resolved || !glob[resolved]) return - // Avoid loading the same module twice - if (result[resolved]) return - result[resolved] = await glob[resolved]() - }), - ) + async function loadImports(currentMdast: Root, currentBaseUrl: string) { + const imports = extractImports(currentMdast) + if (imports.length === 0) return + + const keys = [...Object.keys(glob), ...Object.keys(mdxFiles ?? {})] + + await Promise.all( + imports.map(async (imp) => { + const resolved = resolveModulePath(imp.source, currentBaseUrl, keys) + if (!resolved) return + if (result[resolved]) return + + const mdxFile = mdxFiles?.[resolved] + if (mdxFile) { + const file = typeof mdxFile === 'string' + ? { markdown: mdxFile, baseUrl: currentBaseUrl } + : mdxFile + const nestedMdast = hasImportStatements(file.markdown) ? mdxParse(file.markdown) : undefined + // Store before loading nested imports so cycles short-circuit. + // The module captures `result` by reference, so modules added later + // are still visible when the generated component renders. + result[resolved] = createMdxModule + ? createMdxModule({ + key: resolved, + markdown: file.markdown, + mdast: nestedMdast, + baseUrl: file.baseUrl, + modules: result, + }) + : { default: file.markdown } + if (nestedMdast) { + await loadImports(nestedMdast, file.baseUrl) + } + return + } + + if (!glob[resolved]) return + result[resolved] = await glob[resolved]() + }), + ) + } + + await loadImports(mdast, baseUrl) return result } diff --git a/src/safe-mdx.test.tsx b/src/safe-mdx.test.tsx index 13d53a6..f876cd0 100644 --- a/src/safe-mdx.test.tsx +++ b/src/safe-mdx.test.tsx @@ -4,8 +4,8 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { expect, test } from 'vitest' import { z } from 'zod' -import { mdxParse } from './parse.ts' -import { MdastToJsx, mdastBfs, type ComponentPropsSchema, type EvaluateOptions } from './safe-mdx.tsx' +import { mdxParse, resolveModules } from './parse.ts' +import { MdastToJsx, SafeMdxRenderer, mdastBfs, type ComponentPropsSchema, type EvaluateOptions } from './safe-mdx.tsx' import { completeJsxTags } from './streaming.tsx' const components = { @@ -3773,6 +3773,62 @@ test('modules prop: rendering subset WITHOUT import nodes fails to resolve compo `) }) +test('resolveModules recursively turns imported mdx files into modules', async () => { + const code = dedent` + import Outer from './outer.md' + + + ` + const mdxFiles = { + './docs/outer.md': { + baseUrl: './docs/', + markdown: dedent` + import Inner from './inner.md' + + Outer body. + + + `, + }, + './docs/inner.md': { + baseUrl: './docs/', + markdown: 'Inner body.', + }, + } + + const modules = await resolveModules({ + glob: {}, + mdast: mdxParse(code), + baseUrl: './docs/', + mdxFiles, + createMdxModule({ markdown, mdast, baseUrl, modules }) { + return { + default: function ImportedMdx() { + return + }, + } + }, + }) + + const visitor = new MdastToJsx({ + markdown: code, + mdast: mdxParse(code), + components, + modules, + baseUrl: './docs/', + }) + const html = renderToStaticMarkup(visitor.run()) + + expect(Object.keys(modules).sort()).toMatchInlineSnapshot(` + [ + "./docs/inner.md", + "./docs/outer.md", + ] + `) + expect(html).toMatchInlineSnapshot(`"

Outer body.

Inner body.

"`) + expect(visitor.errors).toMatchInlineSnapshot(`[]`) +}) + test('scope with function in jsx prop receiving object arg', () => { const scope = { formatTitle: (opts: { text: string; uppercase?: boolean }) => { @@ -4197,5 +4253,3 @@ test('scope with tagged template literal without generate', () => { expect(errors).toMatchInlineSnapshot(`[]`) expect(html).toMatchInlineSnapshot(`"hello WORLD"`) }) - - From 6626a58fe17a89154adbc67960449dd3197cbc85 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 2 May 2026 14:30:37 +0200 Subject: [PATCH 2/2] Build safe-mdx for git installs Add a prepare script so branches used as temporary git dependencies build their dist files during installation. This lets downstream integration branches validate safe-mdx API changes before a release is cut. Validation: - pnpm build Session: ses_2bbe7a7767674a57abc0855d8b43490b --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index fd938cb..b8db306 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "scripts": { "build": "tsc", "compile": "tsc", + "prepare": "tsc", "test": "vitest", "prepublishOnly": "rimraf dist \"*.tsbuildinfo\" && pnpm build", "demo": "vite",