diff --git a/.changeset/cuddly-lamps-count.md b/.changeset/cuddly-lamps-count.md new file mode 100644 index 000000000..47116b433 --- /dev/null +++ b/.changeset/cuddly-lamps-count.md @@ -0,0 +1,5 @@ +--- +'fumadocs-mdx': minor +--- + +Support reusing content with `include` tag diff --git a/examples/next-mdx/content/docs/index.mdx b/examples/next-mdx/content/docs/index.mdx index 4fecf9d76..8aab91a9e 100644 --- a/examples/next-mdx/content/docs/index.mdx +++ b/examples/next-mdx/content/docs/index.mdx @@ -31,3 +31,5 @@ Hello World dsasfdafsd | very **important** | Hey | | _Surprisingly_ | Fumadocs | | very long text that looks weird | hello world hello world hello world | + +./test.mdx diff --git a/packages/mdx/package.json b/packages/mdx/package.json index 1f35e8505..1f60545d1 100644 --- a/packages/mdx/package.json +++ b/packages/mdx/package.json @@ -50,6 +50,7 @@ "fast-glob": "^3.3.1", "gray-matter": "^4.0.3", "micromatch": "^4.0.8", + "unist-util-visit": "^5.0.0", "zod": "^3.24.1" }, "devDependencies": { @@ -60,6 +61,7 @@ "@types/react": "^19.0.1", "eslint-config-custom": "workspace:*", "fumadocs-core": "workspace:*", + "mdast-util-mdx-jsx": "^3.1.3", "next": "^15.1.1", "tsconfig": "workspace:*", "unified": "^11.0.5", diff --git a/packages/mdx/src/config/load.ts b/packages/mdx/src/config/load.ts index ba9223d8a..3bdb10c44 100644 --- a/packages/mdx/src/config/load.ts +++ b/packages/mdx/src/config/load.ts @@ -1,9 +1,10 @@ import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { build } from 'esbuild'; -import { validateConfig } from '@/config/validate'; import type { DocCollection, MetaCollection } from '@/config/define'; import { type GlobalConfig } from '@/config/types'; +import type { ProcessorOptions } from '@mdx-js/mdx'; +import { getDefaultMDXOptions } from '@/utils/mdx-options'; export function findConfigFile(): string { return path.resolve('source.config.ts'); @@ -11,6 +12,7 @@ export function findConfigFile(): string { export interface LoadedConfig { collections: Map; + defaultMdxOptions: ProcessorOptions; global?: GlobalConfig; _runtime: { @@ -48,7 +50,7 @@ export async function loadConfig(configPath: string): Promise { } const url = pathToFileURL(outputPath); - const [err, config] = validateConfig( + const [err, config] = buildConfig( // every call to `loadConfig` will cause the previous cache to be ignored (await import(`${url.toString()}?hash=${Date.now().toString()}`)) as Record< string, @@ -59,3 +61,46 @@ export async function loadConfig(configPath: string): Promise { if (err !== null) throw new Error(err); return config; } + +function buildConfig( + config: Record, +): [err: string, value: null] | [err: null, value: LoadedConfig] { + const collections: LoadedConfig['collections'] = new Map(); + let globalConfig: LoadedConfig['global']; + + for (const [k, v] of Object.entries(config)) { + if (!v) { + continue; + } + + if (typeof v === 'object' && '_doc' in v && v._doc === 'collections') { + collections.set( + k, + v as unknown as InternalMetaCollection | InternalDocCollection, + ); + continue; + } + + if (k === 'default') { + globalConfig = v as GlobalConfig; + continue; + } + + return [ + `Unknown export "${k}", you can only export collections from source configuration file.`, + null, + ]; + } + + return [ + null, + { + global: globalConfig, + collections, + defaultMdxOptions: getDefaultMDXOptions(globalConfig?.mdxOptions ?? {}), + _runtime: { + files: new Map(), + }, + }, + ]; +} diff --git a/packages/mdx/src/config/validate.ts b/packages/mdx/src/config/validate.ts deleted file mode 100644 index a4560f2b0..000000000 --- a/packages/mdx/src/config/validate.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - type InternalDocCollection, - type InternalMetaCollection, - type LoadedConfig, -} from '@/config/load'; -import { type GlobalConfig } from '@/config/types'; - -export function validateConfig( - config: Record, -): [err: string, value: null] | [err: null, value: LoadedConfig] { - const out: LoadedConfig = { - collections: new Map(), - _runtime: { - files: new Map(), - }, - }; - - for (const [k, v] of Object.entries(config)) { - if (!v) { - continue; - } - - if (typeof v === 'object' && '_doc' in v && v._doc === 'collections') { - out.collections.set( - k, - v as unknown as InternalMetaCollection | InternalDocCollection, - ); - continue; - } - - if (k === 'default') { - out.global = v as GlobalConfig; - continue; - } - - return [ - `Unknown export "${k}", you can only export collections from source configuration file.`, - null, - ]; - } - - return [null, out]; -} diff --git a/packages/mdx/src/loader-mdx.ts b/packages/mdx/src/loader-mdx.ts index d4227ab03..4ce11b7cc 100644 --- a/packages/mdx/src/loader-mdx.ts +++ b/packages/mdx/src/loader-mdx.ts @@ -6,7 +6,7 @@ import { type LoaderContext } from 'webpack'; import { type StructuredData } from 'fumadocs-core/mdx-plugins'; import { getConfigHash, loadConfigCached } from '@/config/cached'; import { buildMDX } from '@/utils/build-mdx'; -import { getDefaultMDXOptions, type TransformContext } from '@/config'; +import { type TransformContext } from '@/config'; import { getManifestEntryPath } from '@/map/manifest'; import { formatError } from '@/utils/format-error'; import { getGitTimestamp } from './utils/git-timestamp'; @@ -85,13 +85,11 @@ export default async function loader( collection = undefined; } - const mdxOptions = - collection?.mdxOptions ?? - getDefaultMDXOptions(config.global?.mdxOptions ?? {}); + const mdxOptions = collection?.mdxOptions ?? config.defaultMdxOptions; function getTransformContext(): TransformContext { return { - buildMDX: async (v, options = mdxOptions) => { + async buildMDX(v, options = mdxOptions) { const res = await buildMDX( collectionId ?? 'global', configHash, @@ -148,6 +146,7 @@ export default async function loader( data: { lastModified: timestamp, }, + _compiler: this, }, ); diff --git a/packages/mdx/src/map/generate.ts b/packages/mdx/src/map/generate.ts index bd63f4894..95dad8fd1 100644 --- a/packages/mdx/src/map/generate.ts +++ b/packages/mdx/src/map/generate.ts @@ -12,7 +12,7 @@ export async function generateJS( configPath: string, config: LoadedConfig, outputPath: string, - hash: string, + configHash: string, getFrontmatter: (file: string) => Promise, ): Promise { const outDir = path.dirname(outputPath); @@ -33,7 +33,7 @@ export async function generateJS( config._runtime.files.set(file.absolutePath, k); if (collection.type === 'doc' && collection.async) { - const importPath = `${toImportPath(file.absolutePath, outDir)}?hash=${hash}&collection=${k}`; + const importPath = `${toImportPath(file.absolutePath, outDir)}?hash=${configHash}&collection=${k}`; const frontmatter = await getFrontmatter(file.absolutePath); return `toRuntimeAsync(${JSON.stringify(frontmatter)}, () => import(${JSON.stringify(importPath)}), ${JSON.stringify(file)})`; @@ -43,7 +43,7 @@ export async function generateJS( imports.push({ type: 'namespace', name: importName, - specifier: `${toImportPath(file.absolutePath, outDir)}?collection=${k}&hash=${hash}`, + specifier: `${toImportPath(file.absolutePath, outDir)}?collection=${k}&hash=${configHash}`, }); return `toRuntime("${collection.type}", ${importName}, ${JSON.stringify(file)})`; @@ -55,6 +55,7 @@ export async function generateJS( importedCollections.add(k); } + // TODO: remove `transform` API on next major, migrate to runtime transform (e.g. transform & re-export in `source.ts`) return collection.transform ? `export const ${k} = await Promise.all([${resolvedItems.join(', ')}].map(v => c_${k}.transform(v, ${config.global ? 'c_default' : 'undefined'})));` : `export const ${k} = [${resolvedItems.join(', ')}];`; diff --git a/packages/mdx/src/utils/build-mdx.ts b/packages/mdx/src/utils/build-mdx.ts index b7abcf3e3..64db440c6 100644 --- a/packages/mdx/src/utils/build-mdx.ts +++ b/packages/mdx/src/utils/build-mdx.ts @@ -1,10 +1,16 @@ import { createProcessor, type ProcessorOptions } from '@mdx-js/mdx'; import type { VFile } from 'vfile'; +import type { Transformer } from 'unified'; +import { visit } from 'unist-util-visit'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; +import type { Literal } from 'mdast'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import matter from 'gray-matter'; -const cache = new Map< - string, - { processor: ReturnType; configHash: string } ->(); +type Processor = ReturnType; + +const cache = new Map(); export interface MDXOptions extends ProcessorOptions { /** @@ -23,12 +29,41 @@ export interface MDXOptions extends ProcessorOptions { * Custom Vfile data */ data?: Record; + + _compiler?: CompilerOptions; +} + +interface CompilerOptions { + addDependency: (file: string) => void; } function cacheKey(group: string, format: string): string { return `${group}:${format}`; } +function remarkInclude(this: Processor, options: CompilerOptions): Transformer { + return (tree, file) => { + visit(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => { + if (node.name === 'include') { + const child = node.children.at(0) as Literal | undefined; + + if (!child || child.type !== 'text') return; + const specifier = child.value; + + const targetPath = path.resolve(path.dirname(file.path), specifier); + + const content = readFileSync(targetPath); + const parsed = this.parse(matter(content).content); + + options.addDependency(targetPath); + Object.assign(node, parsed); + } + + return 'skip'; + }); + }; +} + /** * @param group - The cache group of MDX content, usually the collection name * @param configHash - config hash @@ -58,6 +93,12 @@ export function buildMDX( outputFormat: 'program', development: process.env.NODE_ENV === 'development', ...rest, + remarkPlugins: [ + options._compiler + ? ([remarkInclude, options._compiler] as any) + : null, + ...(rest.remarkPlugins ?? []), + ], format, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43519ab4c..f71d88178 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -782,6 +782,9 @@ importers: micromatch: specifier: ^4.0.8 version: 4.0.8 + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 zod: specifier: ^3.24.1 version: 3.24.1 @@ -807,6 +810,9 @@ importers: fumadocs-core: specifier: workspace:* version: link:../core + mdast-util-mdx-jsx: + specifier: ^3.1.3 + version: 3.1.3 next: specifier: ^15.1.1 version: 15.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)