Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"scripts": {
"build": "tsc",
"compile": "tsc",
"prepare": "tsc",
"test": "vitest",
"prepublishOnly": "rimraf dist \"*.tsbuildinfo\" && pnpm build",
"demo": "vite",
Expand Down
79 changes: 66 additions & 13 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,25 @@ function joinPaths(base: string, relative: string): string {
export type LazyGlob = Record<string, () => Promise<Record<string, any>>>
export type EagerModules = Record<string, Record<string, any>>

export type MdxModuleFile = {
markdown: string
baseUrl: string
}

export type MdxModuleFactory = (input: {
key: string
markdown: string
mdast?: Root
baseUrl: string
modules: EagerModules
}) => Record<string, any>

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.
Expand All @@ -273,26 +292,60 @@ export async function resolveModules({
glob,
mdast,
baseUrl,
mdxFiles,
createMdxModule,
}: {
glob: LazyGlob
mdast: Root
baseUrl: string
mdxFiles?: Record<string, MdxModuleFile | string>
createMdxModule?: MdxModuleFactory
}): Promise<EagerModules> {
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
Comment on lines +320 to +322

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve string MDX entries from their own directory

When an mdxFiles entry is provided as a plain string, its baseUrl is set to currentBaseUrl (the importer's directory) instead of the resolved file's directory. This makes nested relative imports inside that MDX resolve against the wrong path whenever the imported file lives in a subdirectory (for example importing ./sub/outer.md whose content imports ./inner.md), so recursive module discovery misses valid child modules.

Useful? React with 👍 / 👎.

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
}
Expand Down
62 changes: 58 additions & 4 deletions src/safe-mdx.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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'

<Outer />
`
const mdxFiles = {
'./docs/outer.md': {
baseUrl: './docs/',
markdown: dedent`
import Inner from './inner.md'

Outer body.

<Inner />
`,
},
'./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 <SafeMdxRenderer markdown={markdown} mdast={mdast ?? mdxParse(markdown)} components={components} modules={modules} baseUrl={baseUrl} />
},
}
},
})

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(`"<p>Outer body.</p><p>Inner body.</p>"`)
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
})

test('scope with function in jsx prop receiving object arg', () => {
const scope = {
formatTitle: (opts: { text: string; uppercase?: boolean }) => {
Expand Down Expand Up @@ -4197,5 +4253,3 @@ test('scope with tagged template literal without generate', () => {
expect(errors).toMatchInlineSnapshot(`[]`)
expect(html).toMatchInlineSnapshot(`"hello WORLD"`)
})


Loading