Skip to main content
doc0blade47/fumadocsdocumentation home
Sign in

Local Markdown Runtime

Last updated Oct 12, 202688% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

The @fumadocs/local-md package provides a content source for Fumadocs that scans, compiles, and renders local Markdown and MDX files on the fly. It integrates directly with Fumadocs source loaders by discovering content directories, mapping frontmatter schemas via Standard Schema validators, and exposing both static and dynamic source generators for documentation pages.

Sources: packages/local-md/src/index.ts:1-64

Local source integration

Configuration and factory options

The localMd factory function accepts a configuration object conforming to LocalMarkdownConfig, which extends markdown compiler options with directory paths, file glob patterns, validation schemas, and renderer options. The configuration maps strongly typed frontmatter and meta schemas using the @standard-schema/spec standard.

OptionTypeDefaultPurpose
dirstring—Root directory for content files scanned by the storage layer.
includestring[]defaultIncludeList of glob patterns customizing content files to be scanned.
rendererOptionsPick<MarkdownRendererASTOptions, 'executor'>—Options passed directly to the markdown renderer, such as custom script executors.
frontmatterSchemaFrontmatterSchemadefaultSchemas.pageSchemaStandard Schema validator for page frontmatter objects.
metaSchemaMetaSchemadefaultSchemas.metaSchemaStandard Schema validator for meta files.

Sources: packages/local-md/src/index.ts:24-40, packages/local-md/src/index.ts:84-89

File loading and virtual source pipeline

The localMd return object exposes methods for generating staticSource and dynamicSource collections, managing file invalidation, and connecting to development servers Source Loader. The internal createFiles helper function retrieves raw pages and meta files from the storage layer, constructs VirtualFile objects with computed base directories, and attaches lazy load() functions to each page.

Sources: packages/local-md/src/index.ts:42-64, packages/local-md/src/index.ts:101-165

Hot reload and cache invalidation

When a file changes, invalidateFile resolves the absolute file path, resets the cached static source promise to null, invalidates the underlying storage cache, and iterates over all registered dynamic loaders to trigger v.invalidate(). The devServer method attempts to establish a WebSocket connection using an environment URL or explicit parameter, sends a watch-dir event configured with config.dir and config.include (falling back to defaultInclude), and listens for change events to invoke invalidateFile.

Note

The compilerCache WeakMap stores compilation promises keyed by raw page references, ensuring that repeated calls to load() on the same page reuse ongoing or completed compilation tasks without redundant disk reads.

Sources: packages/local-md/src/index.ts:90-93, packages/local-md/src/index.ts:125-133, packages/local-md/src/index.ts:168-195

Markdown compilation pipeline

Compiler factory and execution trace

The compilation engine in packages/local-md/src/md/compiler.ts exports createMarkdownCompiler, which returns an object with a compile method supporting both standard markdown and MDX inputs. Depending on whether the file extension is .mdx, the compiler dispatches processing through specific pipeline builders.

The call-chain execution walkthrough for compiling local documents follows this exact sequence:

  1. localMd invokes createMarkdownCompiler(config) to instantiate the compiler engine. Sources: packages/local-md/src/index.ts:91-91
  2. createMarkdownCompiler sets up lazy helper functions and returns the compiler object containing the compile(input) method. Sources: packages/local-md/src/md/compiler.ts:62-161
  3. When .mdx files are processed, compile() invokes createMdxCompiler() to construct and cache the MDX processor using @mdx-js/mdx. Sources: packages/local-md/src/md/compiler.ts:105-118, packages/local-md/src/md/compiler.ts:143-145

Sources: packages/local-md/src/md/compiler.ts:62-65, packages/local-md/src/md/compiler.ts:105-118, packages/local-md/src/md/compiler.ts:141-160

Syntax plugin resolution and processor options

Both Markdown and MDX compilers integrate a standard suite of remark and rehype plugins. These include remarkGfm for GitHub-Flavored Markdown support, remarkHeading, remarkNpm, remarkCodeTab, remarkStructure, rehypeCode, and rehypeToc. Each plugin can be conditionally disabled or customized by passing explicit option flags in MarkdownCompilerOptions.

Plugin Option PropertyDefault Value / BehaviorTarget Plugin
remarkHeadingOptions{ generateToc: false, ...remarkHeadingOptions }remarkHeading
remarkNpmOptionsPassed directly if not falseremarkNpm
remarkCodeTabOptionsPassed directly if not falseremarkCodeTab
remarkStructureOptionsPassed directly if not falseremarkStructure
rehypeCodeOptionsPassed directly if not falserehypeCode
rehypeTocOptions{ exportToc: { as: 'data' }, ...rehypeTocOptions } (MDX uses { as: 'esm', name: 'toc' })rehypeToc

Note

Setting any of the plugin options to explicit boolean false (e.g., remarkHeadingOptions: false) completely disables that specific syntax transformation during pipeline initialization.

Sources: packages/local-md/src/md/compiler.ts:66-103, packages/local-md/src/md/compiler.ts:105-137

AST and serialized rendering

MarkdownRenderer Interface and Options

The rendering layer defined in packages/local-md/src/md/renderer.ts provides unified interfaces for managing compiled outputs, whether executed via native JavaScript compilation or abstract syntax tree transformation. The core abstraction is the MarkdownRenderer interface, which normalizes structured data retrieval, synchronous and asynchronous rendering passes, and serialization options.

export interface MarkdownRenderer<M = Record<string, unknown>> {
  structuredData: StructuredData;
  render: (
    components?: MDXComponents,
    context?: Record<string, unknown>,
  ) => Promise<MarkdownRendererResult<M>>;
  renderSync: (
    components?: MDXComponents,
    context?: Record<string, unknown>,
  ) => MarkdownRendererResult<M>;
  serialize: () => MarkdownRendererSerializedOptions;
}

Sources: packages/local-md/src/md/renderer.ts:11-22

The engine supports two primary serialization options via MarkdownRendererSerializedOptions, discriminating between JavaScript execution outputs and AST trees.

Option Type PropertyConfiguration InterfacePurpose / Fields
type: 'js'MarkdownRendererJSOptionsCode string execution containing code, filePath, optional baseUrl, and structuredData.
type: 'ast'MarkdownRendererASTOptionsHAST syntax tree processing containing tree, filePath, optional structuredData, rehypeToc, and an optional executor.

Note

The executor option in MarkdownRendererASTOptions is ignored for MDX files, as MDX always utilizes the native JavaScript runtime engine.

Sources: packages/local-md/src/md/renderer.ts:30-57

Structured Data and AST-to-React Transformation

When rendering from an Abstract Syntax Tree using fromAst, the engine maps table of contents items generated by rehype plugins into React nodes while executing through hast-util-to-jsx-runtime.

export function fromAst<M>(options: MarkdownRendererASTOptions): MarkdownRenderer<M> {
  const {
    executor: getExecutor = executorVirtual,
    filePath,
    structuredData = {
      headings: [],
      contents: [],
    },
    rehypeToc = [],
    tree,
  } = options;
 
  function renderSync(components?: MDXComponents, userContext?: Record<string, unknown>) {
    const context = { ...components, ...userContext };
    const executor = getExecutor({
      jsx: JsxRuntime,
      filePath,
    });
    const evaluater = toEvaluater(executor, context);
 
    function render(tree: Root): ReactNode {
      return toJsxRuntime(tree, {
        filePath,
        components,
        development: false,
        createEvaluater() {
          return evaluater;
        },
        ...JsxRuntime,
      });
    }
 
    const toc = rehypeToc.map(
      (item): TOCItemType => ({
        ...item,
        title: render({
          type: 'root',
          children: item.title.children,
        }),
      }),
    );
 
    return {
      toc,
      body: render(tree),
      exports: executor.getExports() as M,
    };
  }

Sources: packages/local-md/src/md/renderer.ts:59-106

Tip

The rendererFromSerialized helper exported from packages/local-md/src/client.ts proxies directly to fromSerialized, checking the payload type to instantiate either fromJS or fromAst dynamically.

Sources: packages/local-md/src/client.ts:1-1, packages/local-md/src/md/renderer.ts:188-191

Virtual expression evaluation

Sandbox Engine and Executor Configuration

The virtual expression execution layer provides a lightweight sandboxed JavaScript evaluation engine specifically designed to function within constrained environments like Cloudflare's workerd runtime. Defined in packages/local-md/src/js/executor-virtual.ts, the executorVirtual function accepts a JSExecutorConfig options object and instantiates an ExpressionSync class from packages/local-md/src/eval-estree-expression/index.ts. It returns a standardized JSExecutor interface supporting expression evaluation, program execution, and export retrieval.

export function executorVirtual(options: JSExecutorConfig): JSExecutor {
  const sync = new ExpressionSync(options);
 
  return {
    expression(expression, context) {
      return sync.evaluate(expression, context);
    },
    program(expression, context) {
      return sync.evaluate(expression, context);
    },
    getExports() {
      return sync.getExports();
    },
  };
}

Sources: packages/local-md/src/js/executor-virtual.ts:7-20

Expression Tree Traversal and Security Boundaries

The ExpressionSync class manages export maps and handles AST node evaluation through a dynamic visitor pattern. To prevent prototype pollution and unauthorized access to constructor or prototype chains during property lookups, the engine maintains strict key validation checks.

Security Constant / HelperTarget Values / TypesPurpose / Constraint
UNSAFE_KEYS'constructor', 'prototype', '__proto__', '__defineGetter__'Set of blocked property names prohibited during key resolution.
isSafeKeystring | numberValidates that property keys are strings or numbers and do not belong to UNSAFE_KEYS.
isReturnValueObject where type === RETURNInternal symbol marker used to handle return statement unwrapping across block scopes.
EvaluateOptionsOptional object with jsx?: Pick<typeof JsxRuntime, 'Fragment' | 'jsx' | 'jsxs'>Optional configuration mapping React JSX runtime methods for evaluation.

Sources: packages/local-md/src/eval-estree-expression/index.ts:49-80

Caution

Property access checking via isSafeKey actively blocks lookups involving constructor, prototype, and __proto__. Attempting to evaluate expressions containing these keys will reject property resolution to secure the evaluation sandbox against prototype manipulation.

Sources: packages/local-md/src/eval-estree-expression/index.ts:49-68

Note

The ExpressionSync constructor accepts optional evaluation parameters (EvaluateOptions) which inject JSX runtime functions required when rendering JSX elements inside evaluated AST expressions.

Sources: packages/local-md/src/eval-estree-expression/index.ts:55-57, packages/local-md/src/eval-estree-expression/index.ts:82-86

Native JavaScript execution

ESTree JSX Lowering and Context Initialization

The native execution engine implemented in packages/local-md/src/js/executor-native.ts provides server-side execution of JavaScript and JSX statements using new Function(). The executorNative function accepts a JSExecutorConfig options object containing the file path and JSX runtime hooks. It initializes an exports dictionary alongside a globalContext map bound to internal runtime variables.

const INTERNAL_VAR_NAMES = {
  jsx: '_jsx',
  jsxs: '_jsxs',
  fragment: '_Fragment',
  exports: '__fumadocs_exports',
} as const;
 
/**
 * Execute JavaScript with `new Function()`, this is unsafe.
 */
export function executorNative(options: JSExecutorConfig): JSExecutor {
  const { filePath, jsx } = options;
  const exports: Record<string, unknown> = {};
  const globalContext: Record<string, unknown> = {
    [INTERNAL_VAR_NAMES.exports]: exports,
    [INTERNAL_VAR_NAMES.jsx]: jsx.jsx,
    [INTERNAL_VAR_NAMES.jsxs]: jsx.jsxs,
    [INTERNAL_VAR_NAMES.fragment]: jsx.Fragment,
  };

Sources: packages/local-md/src/js/executor-native.ts:6-24

Warning

Because executorNative evaluates code via new Function(), it is unsafe for untrusted user input and should only be utilized in trusted server environments where dynamic code evaluation is explicitly permitted.

Sources: packages/local-md/src/js/executor-native.ts:13-15

Pattern Matching and Export Transformation

Before converting AST programs to JavaScript strings via toJs, statements are processed using buildJsx with an automatic runtime configuration. The visitor pattern extracts binding names from complex variable declarations and handles various export declarations through explicit assignment generation.

function allNames(pattern: Pattern): string[] {
  switch (pattern.type) {
    case 'Identifier':
      return [pattern.name];
    case 'ArrayPattern': {
      const out: string[] = [];
      for (const element of pattern.elements) {
        if (element) out.push(...allNames(element));
      }
      return out;
    }
    case 'AssignmentPattern':
      return allNames(pattern.left);
    case 'MemberExpression':
      return [];
    case 'ObjectPattern': {
      const out: string[] = [];
      for (const prop of pattern.properties) {
        if (prop.type === 'Property') {
          if ('value' in prop && typeof prop.value === 'string') out.push(prop.value);
          else if ('name' in prop && typeof prop.name === 'string') out.push(prop.name);
        } else {
          out.push(...allNames(prop.argument));
        }
      }
      return out;
    }
    case 'RestElement':
      return allNames(pattern.argument);
  }
}

Sources: packages/local-md/src/js/executor-native.ts:44-74

The program handler inspects statement types to map exports and rewrite declarations into valid assignment expressions.

Statement TypeSupported Action / TransformationError or Fallback Behavior
ExportAllDeclarationThrows an error since star-exports are unsupported.Throws Error: ${statement.type} is not supported
ExportDefaultDeclarationRewrites class and function declarations into expressions, assigning them to __fumadocs_exports.default.Throws if initializer is missing.
ExportNamedDeclarationAppends assignment statements for declared classes, functions, or variable patterns.Throws if initializer is missing.

Sources: packages/local-md/src/js/executor-native.ts:98-153

Important

When processing ExportNamedDeclaration with VariableDeclaration nodes, the engine recursively traverses identifier patterns using allNames to generate individual export assignments for every destructured or bound identifier.

Sources: packages/local-md/src/js/executor-native.ts:140-148

Development server synchronization

CLI Launcher and Environment Configuration

The command-line interface provided by local-md exposes the dev command via commander to initialize the development server and spawn a user-defined child command, such as a local development server runner. It validates port inputs, injects the running WebSocket development server URL into the process environment using setDevServerUrlInEnv, and forwards system signals like SIGINT and SIGTERM to the active child process.

program
  .command('dev')
  .description('start the local-md dev server and run a command')
  .argument('<command...>', 'command to run')
  .option('-p, --port <number>', 'port for the dev server', parsePort, 8000)
  .allowUnknownOption()
  .passThroughOptions()
  .action(async (command: string[], options: { port: number }) => {
    const handle = await startDevServer({
      port: options.port,
    });
 
    setDevServerUrlInEnv(handle.url);
    const child = spawn(command[0], command.slice(1), {
      stdio: 'inherit',
      shell: true,
      env: process.env,
    });
 
    const forwardSignal = (signal: NodeJS.Signals) => {
      if (child.exitCode === null && child.signalCode === null) {
        child.kill(signal);
      }
    };
 
    process.on('SIGINT', forwardSignal);
    process.on('SIGTERM', forwardSignal);
 
    let exitCode = 1;
    try {
      exitCode = await new Promise<number>((resolve, reject) => {
        child.once('error', reject);
        child.once('exit', (code, signal) => {
          if (signal) {
            resolve(signalToExitCode(signal));
            return;
          }
 
          resolve(code ?? 0);
        });
      });
    } finally {
      process.off('SIGINT', forwardSignal);
      process.off('SIGTERM', forwardSignal);
      await handle.close().catch(() => undefined);
    }
 
    process.exit(exitCode);
  });

Sources: packages/local-md/src/bin.ts:14-62

Signal / OptionAction or Handled BehaviorResulting Exit Code
--port <number>Parses and validates the numeric port option (default is 8000).Throws if invalid.
SIGINTForwards the signal to the spawned child process if it is still running.Exits with 130.
SIGTERMForwards the signal to the spawned child process if it is still running.Exits with 143.
General exitResolves exit code or signal equivalent when the child process terminates.Default exit 1 on error.

Sources: packages/local-md/src/bin.ts:18-18, packages/local-md/src/bin.ts:33-41, packages/local-md/src/bin.ts:78-86

Note

The helper function signalToExitCode maps incoming POSIX process signals (SIGINT to 130 and SIGTERM to 143) to standard shell exit codes before terminating the CLI process.

Sources: packages/local-md/src/bin.ts:78-86

WebSocket File Watching and Node Server

The Node.js development server manages file system monitoring with chokidar and handles client synchronization via a ws WebSocket server. startDevServer sets up default watch parameters ignoring non-file stats and matching paths registered across active clients using picomatch.

export async function startDevServer(options: DevServerOptions): Promise<DevServerHandle> {
  const { host = '127.0.0.1', port } = options;
  const url = `ws://${host}:${port}${LOCAL_MD_DEV_PATH}`;
 
  let watchOptions: ChokidarOptions = {
    ignoreInitial: true,
    followSymlinks: false,
    ignored(file, _stats) {
      if (!_stats || !_stats.isFile()) return false;
 
      for (const client of clients) {
        for (const [dir, { matcher }] of client.watching) {
          if (matcher(path.relative(dir, file))) return false;
        }
      }
 
      return true;
    },
  };
  if (options.watchOptions) {
    watchOptions = options.watchOptions(watchOptions);
  }
 
  const watcher = watch([], watchOptions);

Sources: packages/local-md/src/dev/node-server.ts:33-56

File watcher events (add, addDir, change, unlink, and unlinkDir) captured by Chokidar are filtered through WATCH_EVENTS and broadcast to all connected clients as change notification payloads.

watcher.on('all', (event, filePath) => {
  if (!WATCH_EVENTS.has(event as DevWatchEvent)) return;
  console.log(`[@fumadocs/local-md] ${event} at "${filePath}"`);
 
  broadcast({
    type: 'change',
    event: event as DevWatchEvent,
    absolutePath: filePath,
    timestamp: Date.now(),
  });
});

Sources: packages/local-md/src/dev/node-server.ts:101-111

Warning

When a WebSocket client connection closes, the server automatically cleans up associated watches by verifying whether any remaining active clients still reference the watched directory before calling watcher.unwatch(dir).

Sources: packages/local-md/src/dev/node-server.ts:95-98, packages/local-md/src/dev/node-server.ts:150-162

Client Live-Reload Communication

Client connections are managed in the browser and Node environments. The React client component DevClient reads the development server URL from the environment, establishes a WebSocket connection, and refreshes the Next.js router whenever a file change event is decoded.

export function DevClient(): ReactNode {
  const router = useRouter();
 
  const onUpdate = useEffectEvent((event: DevServerEvent) => {
    if (event.type === 'change') {
      console.log(`[@fumadocs/local-md] "${event.absolutePath}" updated`);
      router.refresh();
    }
  });
 
  useEffect(() => {
    const url = getDevServerUrlFromEnv();
    if (!url) return;
 
    const ws = new WebSocket(url);
    ws.onopen = () => {
      console.log(`[@fumadocs/local-md] connected to dev server at ${url}`);
    };
 
    ws.onmessage = (event) => {
      const decoded = decodeDevEvent(String(event.data));
      if (decoded) onUpdate(decoded);
    };
 
    ws.onclose = () => {
      console.log(`[@fumadocs/local-md] disconnected from dev server at ${url}`);
    };
 
    return () => {
      ws.close();
    };
  }, []);
 
  return null;

Sources: packages/local-md/src/dev/react-client.ts:6-39

For server-side or non-React consumers, connectDevServer maintains a singleton map of DevServerConnection instances. It queues pending client messages if the socket is not yet open and dispatches incoming decoded events to registered subscribers.

export function connectDevServer(url: string): DevServerConnection {
  let connection = store.get(url);
 
  if (!connection) {
    connection = new DevServerConnection(url);
    store.set(url, connection);
  }
 
  return connection;
}
 
export class DevServerConnection {
  private readonly listeners = new Set<(event: DevServerEvent) => void>();
  private pendingEvents: DevClientEvent[] = [];
  private socket?: WebSocket;
  readonly url: string;
 
  constructor(url: string) {
    this.url = url;
    this.connect();
  }
 
  subscribe(listener: (event: DevServerEvent) => void) {
    this.listeners.add(listener);
 
    return () => {
      this.listeners.delete(listener);
    };
  }

Sources: packages/local-md/src/dev/node-client.ts:11-39

Was this page helpful?