MDX: Support reusing content with include tag

This commit is contained in:
Fuma Nama
2024-12-20 22:20:39 +08:00
parent cc80f5b190
commit bd0a1404e6
9 changed files with 115 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': minor
---
Support reusing content with `include` tag
+2
View File
@@ -31,3 +31,5 @@ Hello World dsasfdafsd
| very **important** | Hey |
| _Surprisingly_ | Fumadocs |
| very long text that looks weird | hello world hello world hello world |
<include>./test.mdx</include>
+2
View File
@@ -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",
+47 -2
View File
@@ -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<string, InternalDocCollection | InternalMetaCollection>;
defaultMdxOptions: ProcessorOptions;
global?: GlobalConfig;
_runtime: {
@@ -48,7 +50,7 @@ export async function loadConfig(configPath: string): Promise<LoadedConfig> {
}
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<LoadedConfig> {
if (err !== null) throw new Error(err);
return config;
}
function buildConfig(
config: Record<string, unknown>,
): [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(),
},
},
];
}
-43
View File
@@ -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<string, unknown>,
): [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];
}
+4 -5
View File
@@ -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,
},
);
+4 -3
View File
@@ -12,7 +12,7 @@ export async function generateJS(
configPath: string,
config: LoadedConfig,
outputPath: string,
hash: string,
configHash: string,
getFrontmatter: (file: string) => Promise<unknown>,
): Promise<string> {
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(', ')}];`;
+45 -4
View File
@@ -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<typeof createProcessor>; configHash: string }
>();
type Processor = ReturnType<typeof createProcessor>;
const cache = new Map<string, { processor: Processor; configHash: string }>();
export interface MDXOptions extends ProcessorOptions {
/**
@@ -23,12 +29,41 @@ export interface MDXOptions extends ProcessorOptions {
* Custom Vfile data
*/
data?: Record<string, unknown>;
_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,
}),
+6
View File
@@ -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/[email protected])([email protected])([email protected]([email protected]))([email protected])